From ba4fbd327248db4d443e7591f7b3eb9065bc1ec1 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 20 Jul 2025 20:33:50 +0900 Subject: [PATCH 001/220] =?UTF-8?q?refactor:=20main.py=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=ED=97=AC=EC=8A=A4=EC=B2=B4=ED=81=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/health.py | 9 +++++++++ app/main.py | 10 +++++----- 2 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 app/api/health.py diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000..f88e530 --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,9 @@ +# app/api/health.py +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +async def health_check(): + return {"status": "ok", "message": "Service is healthy"} diff --git a/app/main.py b/app/main.py index ea2ecbe..ac7a05a 100644 --- a/app/main.py +++ b/app/main.py @@ -5,19 +5,19 @@ import uvicorn from fastapi import FastAPI +from app.api import health # 헬스체크 + app = FastAPI() +# 헬스 체크 라우터 +app.include_router(health.router) + @app.get("/") async def read_root(): return {"message": "Hello, FastAPI Backend!"} -@app.get("/health") -async def health_check(): - return {"status": "ok", "message": "Service is healthy"} - - # 이 부분이 추가된 동적 포트 할당 로직입니다. if __name__ == "__main__": # 1. 환경 변수 'PORT'가 있으면 해당 포트를 사용합니다. From 8d1fad294abaf5327a7cd1ea7b1157169c9f7e43 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 20 Jul 2025 20:45:13 +0900 Subject: [PATCH 002/220] =?UTF-8?q?refactor:=20main.py=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=8F=99=EC=A0=81=20=ED=8F=AC=ED=8A=B8=20=ED=95=A0=EB=8B=B9?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/port.py | 23 +++++++++++++++++++++++ app/main.py | 23 ++++------------------- 2 files changed, 27 insertions(+), 19 deletions(-) create mode 100644 app/core/port.py diff --git a/app/core/port.py b/app/core/port.py new file mode 100644 index 0000000..8305493 --- /dev/null +++ b/app/core/port.py @@ -0,0 +1,23 @@ +# app/core/port.py + +import os +import socket + + +def get_available_port(default: int = 8000) -> int: + """ + 환경변수 'PORT'가 존재하면 해당 포트를 사용하고, + 없다면 시스템이 할당한 사용 가능한 포트를 반환합니다. + """ + port_from_env = os.getenv("PORT") + + if port_from_env: + print(f"Using port from environment variable: {port_from_env}") + return int(port_from_env) + + # 포트 0 바인딩 → 시스템이 사용 가능한 포트 할당 + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("0.0.0.0", 0)) + assigned_port = s.getsockname()[1] + print(f"Dynamically assigned port: {assigned_port}") + return assigned_port diff --git a/app/main.py b/app/main.py index ac7a05a..66a4d9f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,10 @@ # main.py -import os -import socket # 소켓 모듈 임포트 import uvicorn from fastapi import FastAPI -from app.api import health # 헬스체크 +from app.api import health # 헬스 체크 +from app.core.port import get_available_port # 동적 포트 할당 app = FastAPI() @@ -18,22 +17,8 @@ async def read_root(): return {"message": "Hello, FastAPI Backend!"} -# 이 부분이 추가된 동적 포트 할당 로직입니다. if __name__ == "__main__": - # 1. 환경 변수 'PORT'가 있으면 해당 포트를 사용합니다. - # 2. 없으면 사용 가능한 임시 포트를 찾습니다. - port_from_env = os.getenv("PORT") - - if port_from_env: - port = int(port_from_env) - print(f"Using port from environment variable: {port}") - else: - # 시스템에서 사용 가능한 임시 포트를 찾습니다. - # 포트 0을 바인딩하면 운영체제가 사용 가능한 포트를 할당해 줍니다. - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("0.0.0.0", 0)) # 0.0.0.0에 포트 0을 바인딩 - port = s.getsockname()[1] # 할당된 포트 번호 가져오기 - print(f"Dynamically assigned port: {port}") - + # 동적 할당 로직 + port = get_available_port() # Uvicorn 서버를 시작합니다. uvicorn.run(app, host="0.0.0.0", port=port) From 5f22d10a968243ccbeafba3b6d49c916a8b80530 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 25 Jul 2025 01:04:19 +0900 Subject: [PATCH 003/220] =?UTF-8?q?style:=20app=20=EC=AA=BD=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=98=81=EC=96=B4?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build_release_and_notify.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/build_release_and_notify.yml b/.github/workflows/build_release_and_notify.yml index 9bc43ac..856bb24 100644 --- a/.github/workflows/build_release_and_notify.yml +++ b/.github/workflows/build_release_and_notify.yml @@ -1,5 +1,4 @@ # .github/workflows/build_release_and_notify.yml - name: Build and Deploy Executables on: @@ -119,7 +118,7 @@ jobs: if git diff-index --quiet HEAD; then echo "No changes to commit to QGenie APP repository." else - git commit -m "feat: API 실행 파일 업데이트 (${{ github.ref_name }})" + git commit -m "feat: Update API executable (${{ github.ref_name }})" git push fi From a5267aa51a38b4d96c798e1c90d5e78264413ca9 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 25 Jul 2025 01:30:00 +0900 Subject: [PATCH 004/220] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build_release_and_notify.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build_release_and_notify.yml b/.github/workflows/build_release_and_notify.yml index 856bb24..1c7ed82 100644 --- a/.github/workflows/build_release_and_notify.yml +++ b/.github/workflows/build_release_and_notify.yml @@ -4,6 +4,7 @@ name: Build and Deploy Executables on: release: types: [published] # Release가 'published' 상태가 될 때 트리거 + workflow_dispatch: jobs: # ================================== From 0fcc1a02ae91a2d54becd6bfc491d9919ce34e48 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 25 Jul 2025 12:40:13 +0900 Subject: [PATCH 005/220] feat: Skip deployment jobs on manual trigger --- .github/workflows/build_release_and_notify.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_release_and_notify.yml b/.github/workflows/build_release_and_notify.yml index 1c7ed82..94f9306 100644 --- a/.github/workflows/build_release_and_notify.yml +++ b/.github/workflows/build_release_and_notify.yml @@ -11,6 +11,7 @@ jobs: # 파이프라인 시작 알림 # ================================== start: + if: github.event_name == 'release' runs-on: ubuntu-latest steps: - name: Send Pipeline Start Notification @@ -28,7 +29,6 @@ jobs: # 실행 파일 빌드 # ================================== build: - needs: start strategy: matrix: os: [macos-latest, windows-latest] @@ -90,6 +90,7 @@ jobs: # ================================== deploy: needs: build + if: github.event_name == 'release' runs-on: ubuntu-latest steps: @@ -129,7 +130,7 @@ jobs: finish: needs: deploy runs-on: ubuntu-latest - if: always() + if: always() && github.event_name == 'release' steps: - name: Send Success Notification From a611e0d2651cbc76baf8c3c7cff8dc2fbb3252d1 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Wed, 30 Jul 2025 12:30:17 +0900 Subject: [PATCH 006/220] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr_bot.yml | 43 +++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr_bot.yml b/.github/workflows/pr_bot.yml index 22daee4..e77d5f4 100644 --- a/.github/workflows/pr_bot.yml +++ b/.github/workflows/pr_bot.yml @@ -7,6 +7,10 @@ on: types: [opened, closed, reopened, synchronize] issue_comment: types: [created] + pull_request_review: + types: [submitted] + pull_request_review_comment: + types: [created] jobs: notify: @@ -23,7 +27,7 @@ jobs: "username": "GitHub PR 봇", "embeds": [{ "title": "Pull Request #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}", - "description": "**${{ github.actor }}**님이 Pull Request를 생성하거나 업데이트했습니다.", + "description": "**${{ github.actor }}**님이 Pull Request를 생성 또는 업데이트했습니다.", "url": "${{ github.event.pull_request.html_url }}", "color": 2243312 }] @@ -84,3 +88,40 @@ jobs: }] }' \ ${{ secrets.DISCORD_WEBHOOK_URL }} + # ---------------------------- + # 리뷰 제출(Submit Review) 알림 + # ---------------------------- + - name: Send Review Submitted Notification + if: github.event_name == 'pull_request_review' && github.event.action == 'submitted' + run: | + REVIEW_BODY=$(echo "${{ github.event.review.body }}" | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g') + curl -X POST -H "Content-Type: application/json" \ + -d "{ + \"username\": \"GitHub 리뷰 봇\", + \"embeds\": [{ + \"title\": \"New Review on PR #${{ github.event.pull_request.number }}\", + \"description\": \"**${{ github.actor }}**님이 리뷰를 남겼습니다: \\n${REVIEW_BODY}\", + \"url\": \"${{ github.event.review.html_url }}\", + \"color\": 16776960 + }] + }" \ + ${{ secrets.DISCORD_WEBHOOK_URL }} + + # ------------------------- + # 리뷰 댓글에 대한 답글 알림 + # ------------------------- + - name: Send Review Comment Notification + if: github.event_name == 'pull_request_review_comment' && github.event.action == 'created' + run: | + COMMENT_BODY=$(echo "${{ github.event.comment.body }}" | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g') + curl -X POST -H "Content-Type: application/json" \ + -d "{ + \"username\": \"GitHub 댓글 봇\", + \"embeds\": [{ + \"title\": \"New Reply on PR #${{ github.event.pull_request.number }}\", + \"description\": \"**${{ github.actor }}**님의 새 답글: \\n${COMMENT_BODY}\", + \"url\": \"${{ github.event.comment.html_url }}\", + \"color\": 15105570 + }] + }" \ + ${{ secrets.DISCORD_WEBHOOK_URL }} \ No newline at end of file From 1fd34bf213bde6433adf511ffa1b0ba44bc2cb33 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Wed, 30 Jul 2025 12:51:25 +0900 Subject: [PATCH 007/220] =?UTF-8?q?style:=20=EC=A4=84=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr_bot.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr_bot.yml b/.github/workflows/pr_bot.yml index e77d5f4..f9f5838 100644 --- a/.github/workflows/pr_bot.yml +++ b/.github/workflows/pr_bot.yml @@ -88,6 +88,7 @@ jobs: }] }' \ ${{ secrets.DISCORD_WEBHOOK_URL }} + # ---------------------------- # 리뷰 제출(Submit Review) 알림 # ---------------------------- From 5e36934acdf1b2c7250a2ebdda76fa9bd7745da0 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Thu, 31 Jul 2025 22:51:29 +0900 Subject: [PATCH 008/220] =?UTF-8?q?feat:=20api=20router=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 5 +++++ app/main.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 app/api/api_router.py diff --git a/app/api/api_router.py b/app/api/api_router.py new file mode 100644 index 0000000..813de73 --- /dev/null +++ b/app/api/api_router.py @@ -0,0 +1,5 @@ +from fastapi import APIRouter + +api_router = APIRouter() + +# api_router.include_router(connect_driver.router, prefix="/connections", tags=["Driver"]) diff --git a/app/main.py b/app/main.py index 66a4d9f..5c37c8b 100644 --- a/app/main.py +++ b/app/main.py @@ -5,12 +5,13 @@ from app.api import health # 헬스 체크 from app.core.port import get_available_port # 동적 포트 할당 +from app.api.api_router import api_router app = FastAPI() # 헬스 체크 라우터 app.include_router(health.router) - +app.include_router(api_router, prefix="/api") @app.get("/") async def read_root(): From 1e71b6eff713b05e00c38a3174ef8884f01723e3 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 1 Aug 2025 00:12:48 +0900 Subject: [PATCH 009/220] =?UTF-8?q?feat:=20response=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=B4=EC=84=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20router=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 7 +++++- app/api/test_api.py | 47 ++++++++++++++++++++++++++++++++++++++++ app/core/exceptions.py | 48 +++++++++++++++++++++++++++++++++++++++++ app/core/status.py | 42 ++++++++++++++++++++++++++++++++++++ app/main.py | 13 ++++++++++- app/schemas/response.py | 29 +++++++++++++++++++++++++ 6 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 app/api/test_api.py create mode 100644 app/core/exceptions.py create mode 100644 app/core/status.py create mode 100644 app/schemas/response.py diff --git a/app/api/api_router.py b/app/api/api_router.py index 813de73..12c0d1c 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -1,5 +1,10 @@ from fastapi import APIRouter +from app.api import test_api api_router = APIRouter() -# api_router.include_router(connect_driver.router, prefix="/connections", tags=["Driver"]) +# 테스트 라우터 +app.include_router(test_api.router, prefix="/api", tags=["Test"]) + +# 라우터 +# api_router.include_router(connect_driver.router, prefix="/connections", tags=["Driver"]) \ No newline at end of file diff --git a/app/api/test_api.py b/app/api/test_api.py new file mode 100644 index 0000000..c6517df --- /dev/null +++ b/app/api/test_api.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter + +from app.schemas.response import ResponseMessage +from app.core.exceptions import APIException +from app.core.status import CommonCode + +router = APIRouter() + +@router.get("/test", response_model=ResponseMessage, summary="타입 변환을 이용한 성공/실패/버그 테스트") +def simple_test(mode: str): + """ + curl 테스트 시 아래 명령어 사용 + curl -i -X GET "http://localhost:/api/test?mode=1" + + 쿼리 파라미터 'mode' 값에 따라 다른 응답을 반환합니다. + + - **mode=1**: 성공 응답 (200 OK) + - **mode=2**: 커스텀 성공 응답 (200 OK) + - **mode=기타 숫자**: 예상된 실패 (404 Not Found) + - **mode=문자열**: 예상치 못한 서버 버그 (500 Internal Server Error) + """ + try: + # 1. 입력받은 mode를 정수(int)로 변환 시도 + mode_int = int(mode) + + # 2. 정수로 변환 성공 시, 값에 따라 분기 + if mode_int == 1: + # 기본 성공 코드(SUCCESS)로 응답 + return ResponseMessage.success( + value={"detail": "기본 성공 테스트입니다."} + ) + elif mode_int == 2: + # 커스텀 성공 코드(CREATED)로 응답 + return ResponseMessage.success( + value={"detail": "커스텀 성공 코드(CREATED) 테스트입니다."}, + code=CommonCode.CREATED + ) + else: + # 그 외 숫자는 '데이터 없음' 오류로 처리 + raise APIException(CommonCode.NO_SEARCH_DATA) + + except ValueError: + # 3. 정수로 변환 실패 시 (문자열이 들어온 경우) + # 예상치 못한 버그를 강제로 발생시킵니다. + # 이 에러는 generic_exception_handler가 처리하게 됩니다. + raise TypeError("의도적으로 발생시킨 타입 에러입니다.") + diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..ea68b2a --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,48 @@ +import traceback +from fastapi import Request, status +from fastapi.responses import JSONResponse +from app.core.status import CommonCode + + +class APIException(Exception): + """ + API 로직 내에서 발생하는 모든 예상된 오류에 사용할 기본 예외 클래스입니다. + """ + def __init__(self, code: CommonCode, *args): + self.code_enum = code + self.message = code.get_message(*args) + super().__init__(self.message) + +async def api_exception_handler(request: Request, exc: APIException): + """ + APIException이 발생했을 때, 이를 감지하여 표준화된 JSON 오류 응답을 반환합니다. + """ + return JSONResponse( + status_code=exc.code_enum.http_status, + content={ + "code": exc.code_enum.code, + "message": exc.message, + "data": None + } + ) + +async def generic_exception_handler(request: Request, exc: Exception): + """ + 처리되지 않은 모든 예외를 잡아, 일관된 500 서버 오류를 반환합니다. + """ + # 운영 환경에서는 파일 로그나 모니터링 시스템으로 보내야 합니다. + print("="*20, "UNEXPECTED ERROR", "="*20) + traceback.print_exc() + print("="*50) + + # 사용자에게는 간단한 500 에러 메시지만 보여줍니다. + error_response = { + "code": CommonCode.FAIL.code, + "message": CommonCode.FAIL.message, + "data": None + } + + return JSONResponse( + status_code=CommonCode.FAIL.http_status, + content=error_response, + ) \ No newline at end of file diff --git a/app/core/status.py b/app/core/status.py new file mode 100644 index 0000000..790717d --- /dev/null +++ b/app/core/status.py @@ -0,0 +1,42 @@ +from enum import Enum +from fastapi import status + +class CommonCode(Enum): + """ + 애플리케이션의 모든 상태 코드를 중앙에서 관리합니다. + 각 멤버는 (HTTP 상태 코드, 고유 비즈니스 코드, 기본 메시지) 튜플을 값으로 가집니다. + 상태 코드 참고: https://developer.mozilla.org/ko/docs/Web/HTTP/Status + """ + + # ================================== + # 성공 (Success) - 2xx + # ================================== + SUCCESS = (status.HTTP_200_OK, "2000", "성공적으로 처리되었습니다.") + CREATED = (status.HTTP_201_CREATED, "2001", "성공적으로 생성되었습니다.") + SUCCESS_DB_CONNECT = (status.HTTP_200_OK, "2002", "디비 연결을 성공하였습니다.") + + # ================================== + # 클라이언트 오류 (Client Error) - 4xx + # ================================== + NO_VALUE = (status.HTTP_400_BAD_REQUEST, "4000", "필수 값이 존재하지 않습니다.") + DUPLICATION = (status.HTTP_409_CONFLICT, "4001", "이미 존재하는 데이터입니다.") + NO_SEARCH_DATA = (status.HTTP_404_NOT_FOUND, "4002", "요청한 데이터를 찾을 수 없습니다.") + + # ================================== + # 서버 오류 (Server Error) - 5xx + # ================================== + FAIL = (status.HTTP_500_INTERNAL_SERVER_ERROR, "9999", "서버 처리 중 오류가 발생했습니다.") + + + def __init__(self, http_status: int, code: str, message: str): + """Enum 멤버가 생성될 때 각 값을 속성으로 할당합니다.""" + self.http_status = http_status + self.code = code + self.message = message + + def get_message(self, *args) -> str: + """ + 메시지 포맷팅이 필요한 경우, 인자를 받아 완성된 메시지를 반환합니다. + """ + return self.message % args if args else self.message + diff --git a/app/main.py b/app/main.py index 5c37c8b..a8e0b41 100644 --- a/app/main.py +++ b/app/main.py @@ -7,9 +7,20 @@ from app.core.port import get_available_port # 동적 포트 할당 from app.api.api_router import api_router + +from app.core.exceptions import ( + APIException, + api_exception_handler, + generic_exception_handler +) + app = FastAPI() -# 헬스 체크 라우터 +# 전역 예외 처리기 등록 +app.add_exception_handler(APIException, api_exception_handler) +app.add_exception_handler(Exception, generic_exception_handler) + +# 라우터 app.include_router(health.router) app.include_router(api_router, prefix="/api") diff --git a/app/schemas/response.py b/app/schemas/response.py new file mode 100644 index 0000000..c57d50a --- /dev/null +++ b/app/schemas/response.py @@ -0,0 +1,29 @@ +from typing import Generic, TypeVar, Optional +from pydantic import BaseModel, Field +from app.core.status import CommonCode + +T = TypeVar('T') + +class ResponseMessage(BaseModel, Generic[T]): + """ + 모든 API 응답에 사용될 공용 스키마입니다. + """ + code: str = Field(..., description="응답을 나타내는 고유 상태 코드") + message: str = Field(..., description="응답 메시지") + data: Optional[T] = Field(None, description="반환될 실제 데이터") + + @classmethod + def success( + cls, + value: Optional[T] = None, + code: CommonCode = CommonCode.SUCCESS, + *args + ) -> "ResponseMessage[T]": + """ + 성공 응답을 생성하는 팩토리 메서드입니다. + """ + return cls( + code=code.code, + message=code.get_message(*args), + data=value + ) From 547a7cbf0c841fe4e3c47b8a3dd7c3b4d7ec43af Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 1 Aug 2025 00:13:39 +0900 Subject: [PATCH 010/220] =?UTF-8?q?docs:=20=EA=B0=80=EC=83=81=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2f093e9..97279c6 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ ```bash poetry shell - uvicorn main:app --reload + uvicorn app.main:app --reload ``` 또는 Poetry Run을 사용하여 직접 실행할 수 있습니다. From d059b5310da50606829be7ffe09bf761b173d463 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 2 Aug 2025 15:15:15 +0900 Subject: [PATCH 011/220] =?UTF-8?q?refactor:=20api=20router=20=EA=B3=BC=20?= =?UTF-8?q?=ED=95=A9=EC=B9=98=EB=A9=B4=EC=84=9C=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=9C=20=EC=95=88=EB=A7=9E=EB=8A=94=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?endpoint=20=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 2 +- app/api/test_api.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/api/api_router.py b/app/api/api_router.py index 12c0d1c..4c92561 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -4,7 +4,7 @@ api_router = APIRouter() # 테스트 라우터 -app.include_router(test_api.router, prefix="/api", tags=["Test"]) +api_router.include_router(test_api.router, prefix="/test", tags=["Test"]) # 라우터 # api_router.include_router(connect_driver.router, prefix="/connections", tags=["Driver"]) \ No newline at end of file diff --git a/app/api/test_api.py b/app/api/test_api.py index c6517df..c3ae9c0 100644 --- a/app/api/test_api.py +++ b/app/api/test_api.py @@ -6,11 +6,12 @@ router = APIRouter() -@router.get("/test", response_model=ResponseMessage, summary="타입 변환을 이용한 성공/실패/버그 테스트") +@router.get("", response_model=ResponseMessage, summary="타입 변환을 이용한 성공/실패/버그 테스트") def simple_test(mode: str): """ curl 테스트 시 아래 명령어 사용 curl -i -X GET "http://localhost:/api/test?mode=1" + curl -i -X GET "http://localhost:8000/api/test?mode=1" 쿼리 파라미터 'mode' 값에 따라 다른 응답을 반환합니다. From 38c1cbead997bbea0f862df08f7f779a8c6b22f3 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 2 Aug 2025 15:21:21 +0900 Subject: [PATCH 012/220] =?UTF-8?q?docs:=20cd=20app=EC=9D=84=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=9D=B4=EB=A6=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 97279c6..3c87aee 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ ```bash git clone https://github.com/Queryus/QGenie_api.git - cd app # 복제된 저장소 디렉토리로 이동 + cd QGenie_api ``` From 4a0cf80dbe1a5e269bdadc8a61aedee226e1518d Mon Sep 17 00:00:00 2001 From: mini Date: Mon, 21 Jul 2025 22:16:32 +0900 Subject: [PATCH 013/220] =?UTF-8?q?chore:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(pydantic,=20sqlalchemy),=20dev=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20(pytest,=20h?= =?UTF-8?q?ttpx,=20pytest-asyncio)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- poetry.lock | 323 +++++++++++++++++++++++++++++++++++++++++++++++-- pyproject.toml | 7 +- 2 files changed, 322 insertions(+), 8 deletions(-) diff --git a/poetry.lock b/poetry.lock index ff51991..1738e3e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,7 +31,7 @@ version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, @@ -92,6 +92,18 @@ d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "certifi" +version = "2025.7.14" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2"}, + {file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"}, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -126,11 +138,11 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main", "dev"] -markers = "platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "distlib" @@ -182,18 +194,134 @@ docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3) testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] +[[package]] +name = "greenlet" +version = "3.2.3" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")" +files = [ + {file = "greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a433dbc54e4a37e4fff90ef34f25a8c00aed99b06856f0119dcf09fbafa16392"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:72e77ed69312bab0434d7292316d5afd6896192ac4327d44f3d613ecb85b037c"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68671180e3849b963649254a882cd544a3c75bfcd2c527346ad8bb53494444db"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49c8cfb18fb419b3d08e011228ef8a25882397f3a859b9fe1436946140b6756b"}, + {file = "greenlet-3.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:efc6dc8a792243c31f2f5674b670b3a95d46fa1c6a912b8e310d6f542e7b0712"}, + {file = "greenlet-3.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:731e154aba8e757aedd0781d4b240f1225b075b4409f1bb83b05ff410582cf00"}, + {file = "greenlet-3.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:96c20252c2f792defe9a115d3287e14811036d51e78b3aaddbee23b69b216302"}, + {file = "greenlet-3.2.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:784ae58bba89fa1fa5733d170d42486580cab9decda3484779f4759345b29822"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0921ac4ea42a5315d3446120ad48f90c3a6b9bb93dd9b3cf4e4d84a66e42de83"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d2971d93bb99e05f8c2c0c2f4aa9484a18d98c4c3bd3c62b65b7e6ae33dfcfaf"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c667c0bf9d406b77a15c924ef3285e1e05250948001220368e039b6aa5b5034b"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:592c12fb1165be74592f5de0d70f82bc5ba552ac44800d632214b76089945147"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29e184536ba333003540790ba29829ac14bb645514fbd7e32af331e8202a62a5"}, + {file = "greenlet-3.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:93c0bb79844a367782ec4f429d07589417052e621aa39a5ac1fb99c5aa308edc"}, + {file = "greenlet-3.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:751261fc5ad7b6705f5f76726567375bb2104a059454e0226e1eef6c756748ba"}, + {file = "greenlet-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:83a8761c75312361aa2b5b903b79da97f13f556164a7dd2d5448655425bd4c34"}, + {file = "greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb"}, + {file = "greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c"}, + {file = "greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163"}, + {file = "greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849"}, + {file = "greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b"}, + {file = "greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0"}, + {file = "greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36"}, + {file = "greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3"}, + {file = "greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141"}, + {file = "greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a"}, + {file = "greenlet-3.2.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:42efc522c0bd75ffa11a71e09cd8a399d83fafe36db250a87cf1dacfaa15dc64"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d760f9bdfe79bff803bad32b4d8ffb2c1d2ce906313fc10a83976ffb73d64ca7"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8324319cbd7b35b97990090808fdc99c27fe5338f87db50514959f8059999805"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8c37ef5b3787567d322331d5250e44e42b58c8c713859b8a04c6065f27efbf72"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ce539fb52fb774d0802175d37fcff5c723e2c7d249c65916257f0a940cee8904"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:003c930e0e074db83559edc8705f3a2d066d4aa8c2f198aff1e454946efd0f26"}, + {file = "greenlet-3.2.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7e70ea4384b81ef9e84192e8a77fb87573138aa5d4feee541d8014e452b434da"}, + {file = "greenlet-3.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:22eb5ba839c4b2156f18f76768233fe44b23a31decd9cc0d4cc8141c211fd1b4"}, + {file = "greenlet-3.2.3-cp39-cp39-win32.whl", hash = "sha256:4532f0d25df67f896d137431b13f4cdce89f7e3d4a96387a41290910df4d3a57"}, + {file = "greenlet-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:aaa7aae1e7f75eaa3ae400ad98f8644bb81e1dc6ba47ce8a93d3f17274e08322"}, + {file = "greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil"] + [[package]] name = "h11" version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "identify" version = "2.6.12" @@ -215,7 +343,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -224,6 +352,18 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + [[package]] name = "macholib" version = "1.16.3" @@ -318,6 +458,22 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-a test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] type = ["mypy (>=1.14.1)"] +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + [[package]] name = "pre-commit" version = "4.2.0" @@ -471,6 +627,21 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pyinstaller" version = "6.14.2" @@ -524,6 +695,47 @@ files = [ packaging = ">=22.0" setuptools = ">=42.0.0" +[[package]] +name = "pytest" +version = "8.4.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, + {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, + {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -656,12 +868,108 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "sqlalchemy" +version = "2.0.41" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "SQLAlchemy-2.0.41-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6854175807af57bdb6425e47adbce7d20a4d79bbfd6f6d6519cd10bb7109a7f8"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05132c906066142103b83d9c250b60508af556982a385d96c4eaa9fb9720ac2b"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b4af17bda11e907c51d10686eda89049f9ce5669b08fbe71a29747f1e876036"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:c0b0e5e1b5d9f3586601048dd68f392dc0cc99a59bb5faf18aab057ce00d00b2"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0b3dbf1e7e9bc95f4bac5e2fb6d3fb2f083254c3fdd20a1789af965caf2d2348"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-win32.whl", hash = "sha256:1e3f196a0c59b0cae9a0cd332eb1a4bda4696e863f4f1cf84ab0347992c548c2"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-win_amd64.whl", hash = "sha256:6ab60a5089a8f02009f127806f777fca82581c49e127f08413a66056bd9166dd"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1f09b6821406ea1f94053f346f28f8215e293344209129a9c0fcc3578598d7b"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1936af879e3db023601196a1684d28e12f19ccf93af01bf3280a3262c4b6b4e5"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2ac41acfc8d965fb0c464eb8f44995770239668956dc4cdf502d1b1ffe0d747"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81c24e0c0fde47a9723c81d5806569cddef103aebbf79dbc9fcbb617153dea30"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23a8825495d8b195c4aa9ff1c430c28f2c821e8c5e2d98089228af887e5d7e29"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:60c578c45c949f909a4026b7807044e7e564adf793537fc762b2489d522f3d11"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-win32.whl", hash = "sha256:118c16cd3f1b00c76d69343e38602006c9cfb9998fa4f798606d28d63f23beda"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-win_amd64.whl", hash = "sha256:7492967c3386df69f80cf67efd665c0f667cee67032090fe01d7d74b0e19bb08"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6375cd674fe82d7aa9816d1cb96ec592bac1726c11e0cafbf40eeee9a4516b5f"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f8c9fdd15a55d9465e590a402f42082705d66b05afc3ffd2d2eb3c6ba919560"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f9dc8c44acdee06c8fc6440db9eae8b4af8b01e4b1aee7bdd7241c22edff4f"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c11ceb9a1f482c752a71f203a81858625d8df5746d787a4786bca4ffdf71c6"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:911cc493ebd60de5f285bcae0491a60b4f2a9f0f5c270edd1c4dbaef7a38fc04"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03968a349db483936c249f4d9cd14ff2c296adfa1290b660ba6516f973139582"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90144d3b0c8b139408da50196c5cad2a6909b51b23df1f0538411cd23ffa45d3"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:023b3ee6169969beea3bb72312e44d8b7c27c75b347942d943cf49397b7edeb5"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:725875a63abf7c399d4548e686debb65cdc2549e1825437096a0af1f7e374814"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81965cc20848ab06583506ef54e37cf15c83c7e619df2ad16807c03100745dea"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dd5ec3aa6ae6e4d5b5de9357d2133c07be1aff6405b136dad753a16afb6717dd"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ff8e80c4c4932c10493ff97028decfdb622de69cae87e0f127a7ebe32b4069c6"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-win32.whl", hash = "sha256:4d44522480e0bf34c3d63167b8cfa7289c1c54264c2950cc5fc26e7850967e45"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-win_amd64.whl", hash = "sha256:81eedafa609917040d39aa9332e25881a8e7a0862495fcdf2023a9667209deda"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9a420a91913092d1e20c86a2f5f1fc85c1a8924dbcaf5e0586df8aceb09c9cc2"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:906e6b0d7d452e9a98e5ab8507c0da791856b2380fdee61b765632bb8698026f"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a373a400f3e9bac95ba2a06372c4fd1412a7cee53c37fc6c05f829bf672b8769"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:087b6b52de812741c27231b5a3586384d60c353fbd0e2f81405a814b5591dc8b"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:34ea30ab3ec98355235972dadc497bb659cc75f8292b760394824fab9cf39826"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8280856dd7c6a68ab3a164b4a4b1c51f7691f6d04af4d4ca23d6ecf2261b7923"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-win32.whl", hash = "sha256:b50eab9994d64f4a823ff99a0ed28a6903224ddbe7fef56a6dd865eec9243440"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-win_amd64.whl", hash = "sha256:5e22575d169529ac3e0a120cf050ec9daa94b6a9597993d1702884f6954a7d71"}, + {file = "sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576"}, + {file = "sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + [[package]] name = "starlette" version = "0.46.2" @@ -686,11 +994,12 @@ version = "4.14.1" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] +markers = {dev = "python_version < \"3.13\""} [[package]] name = "typing-inspection" @@ -750,4 +1059,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "3b5b7454d461acc6cfb10bd75966c73b99fada86f142d8aa3b6b1d16463c6c30" +content-hash = "b2b900ec980a2f48b1909700bd7c43c46ba2242bb5dfddf3acb5bc45e8244c65" diff --git a/pyproject.toml b/pyproject.toml index e13ea88..f44d187 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,9 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "fastapi (>=0.115.14,<0.116.0)", - "uvicorn (>=0.35.0,<0.36.0)" + "uvicorn (>=0.35.0,<0.36.0)", + "pydantic (>=2.11.7,<3.0.0)", + "sqlalchemy (>=2.0.41,<3.0.0)" ] @@ -23,6 +25,9 @@ ruff = "^0.12.2" black = "^25.1.0" pre-commit = "^4.2.0" pyinstaller = {version = "^6.14.2", python = ">=3.11,<3.14"} +pytest = "^8.4.1" +httpx = "^0.28.1" +pytest-asyncio = "^1.1.0" # ---------------------------- From 27a9b99c91a5522d21a80bad0a90ca95daab63d9 Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 23 Jul 2025 01:11:54 +0900 Subject: [PATCH 014/220] =?UTF-8?q?feat:=20Python=20import=EB=A1=9C=20DB?= =?UTF-8?q?=20=EB=93=9C=EB=9D=BC=EC=9D=B4=EB=B2=84=20=EC=84=A4=EC=B9=98=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=ED=99=95=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/connections.py | 11 +++++++++ app/main.py | 19 ++++++++-------- app/schemas/db_driver_info.py | 11 +++++++++ app/services/driver_checker.py | 41 ++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 app/api/connections.py create mode 100644 app/schemas/db_driver_info.py create mode 100644 app/services/driver_checker.py diff --git a/app/api/connections.py b/app/api/connections.py new file mode 100644 index 0000000..233bf45 --- /dev/null +++ b/app/api/connections.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from app.schemas.db_driver_info import DBDriverInfo +from app.services.driver_checker import check_driver + +router = APIRouter() + + +@router.get("/connections/drivers/{driverId}", response_model=DBDriverInfo) +def get_driver_info(driverId: str): + return check_driver(driverId) diff --git a/app/main.py b/app/main.py index a8e0b41..c20f561 100644 --- a/app/main.py +++ b/app/main.py @@ -3,16 +3,13 @@ import uvicorn from fastapi import FastAPI -from app.api import health # 헬스 체크 -from app.core.port import get_available_port # 동적 포트 할당 -from app.api.api_router import api_router - - -from app.core.exceptions import ( - APIException, - api_exception_handler, - generic_exception_handler +from app.api import ( + connections, + health, # 헬스 체크 ) +from app.api.api_router import api_router +from app.core.exceptions import APIException, api_exception_handler, generic_exception_handler +from app.core.port import get_available_port # 동적 포트 할당 app = FastAPI() @@ -20,10 +17,14 @@ app.add_exception_handler(APIException, api_exception_handler) app.add_exception_handler(Exception, generic_exception_handler) +# 드라이버 확인 라우터 +app.include_router(connections.router) + # 라우터 app.include_router(health.router) app.include_router(api_router, prefix="/api") + @app.get("/") async def read_root(): return {"message": "Hello, FastAPI Backend!"} diff --git a/app/schemas/db_driver_info.py b/app/schemas/db_driver_info.py new file mode 100644 index 0000000..408d322 --- /dev/null +++ b/app/schemas/db_driver_info.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class DBDriverInfo(BaseModel): + db_type: str + is_installed: bool + message: str + driver_path: str | None = None + driver_name: str | None = None + driver_size_bytes: int | None = None + driver_version: str | None = None diff --git a/app/services/driver_checker.py b/app/services/driver_checker.py new file mode 100644 index 0000000..1356a22 --- /dev/null +++ b/app/services/driver_checker.py @@ -0,0 +1,41 @@ +import importlib.util +import os + +from app.schemas.db_driver_info import DBDriverInfo + +DRIVER_MAP = { + "postgresql": "psycopg2", + "mysql": "pymysql", + "sqlite": "sqlite3", + "oracle": "cx_Oracle", + "sqlserver": "pyodbc", +} + + +def check_driver(driver_id: str) -> DBDriverInfo: + module_name = DRIVER_MAP.get(driver_id.lower()) + if not module_name: + return DBDriverInfo( + db_type=driver_id, + is_installed=False, + message="지원되지 않는 DB 타입입니다.", + driver_path=None, + driver_name=None, + driver_size_bytes=None, + driver_version=None, + ) + + # 해당 모듈이 현재 파이썬 환경에 설치되어 있는지 확인(즉, import 가능한지) + spec = importlib.util.find_spec(module_name) + # 설치 유무 확인 + is_installed = spec is not None + + return DBDriverInfo( + db_type=driver_id, + is_installed=is_installed, + message="드라이버가 설치되어 있습니다." if is_installed else "드라이버가 설치되어 있지 않습니다.", + driver_path=spec.origin if is_installed else None, + driver_name=os.path.basename(spec.origin) if is_installed else None, + driver_size_bytes=os.path.getsize(spec.origin) if is_installed else None, + driver_version="N/A", # 버전 확인 로직은 각 패키지별로 다르게 처리 + ) From ad8d13851369dd87f2221afeb587e59bcbd93481 Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 23 Jul 2025 01:12:54 +0900 Subject: [PATCH 015/220] =?UTF-8?q?test:=20DB=20=EB=93=9C=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B2=84=20=EC=84=A4=EC=B9=98=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EB=B0=8F=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/tests/api/test_connections.py | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 app/tests/api/test_connections.py diff --git a/app/tests/api/test_connections.py b/app/tests/api/test_connections.py new file mode 100644 index 0000000..a742fe6 --- /dev/null +++ b/app/tests/api/test_connections.py @@ -0,0 +1,60 @@ +from unittest.mock import MagicMock, patch + +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_check_mysql_driver_installed(): + # 드라이버 설치 되어있을 경우 + mock_spec = MagicMock() + mock_spec.origin = "/usr/lib/python3.11/site-packages/mysql_driver.so" + + with patch("importlib.util.find_spec", return_value=mock_spec): + with patch("os.path.getsize", return_value=123456): + response = client.get("/connections/drivers/mysql") + assert response.status_code == 200 + data = response.json() + print(data) + assert data["db_type"] == "mysql" + assert data["is_installed"] is True + assert data["driver_path"] == mock_spec.origin + assert data["driver_name"] == "mysql_driver.so" + assert data["driver_size_bytes"] == 123456 + + +def test_check_mysql_driver_not_installed(): + # 드라이버 설치 안되어있을 경우 + with patch("importlib.util.find_spec", return_value=None): + response = client.get("/connections/drivers/mysql") + assert response.status_code == 200 + data = response.json() + print(data) + assert data["db_type"] == "mysql" + assert data["is_installed"] is False + assert data["driver_path"] is None + assert data["driver_name"] is None + assert data["driver_size_bytes"] is None + assert "설치되어 있지 않습니다" in data["message"] + + +from app.services.driver_checker import check_driver + + +def test_check_driver_unsupported_db_direct(): + # 지원되지 않는 DB 타입을 넣었을 때 + unsupported_db = "unknown_db" + response = check_driver(unsupported_db) + + # response는 DBDriverInfo 객체라 바로 속성 접근 + print(response) + + assert response.db_type == unsupported_db + assert response.is_installed is False + assert response.message == "지원되지 않는 DB 타입입니다." + assert response.driver_path is None + assert response.driver_name is None + assert response.driver_size_bytes is None + assert response.driver_version is None From aa32a8d0fcfd92e6970c8e3ea53980d40624153a Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 23 Jul 2025 02:12:59 +0900 Subject: [PATCH 016/220] =?UTF-8?q?feat:=20Python=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EC=A1=B4=EC=9E=AC=20=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/connections.py | 7 ++++++- app/services/driver_checker.py | 36 +++++++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/app/api/connections.py b/app/api/connections.py index 233bf45..470f5ee 100644 --- a/app/api/connections.py +++ b/app/api/connections.py @@ -1,7 +1,7 @@ from fastapi import APIRouter from app.schemas.db_driver_info import DBDriverInfo -from app.services.driver_checker import check_driver +from app.services.driver_checker import check_driver, is_python_environment router = APIRouter() @@ -9,3 +9,8 @@ @router.get("/connections/drivers/{driverId}", response_model=DBDriverInfo) def get_driver_info(driverId: str): return check_driver(driverId) + + +@router.get("/environment/python") +def check_python_environment(): + return {"is_python_environment": is_python_environment()} diff --git a/app/services/driver_checker.py b/app/services/driver_checker.py index 1356a22..24728c1 100644 --- a/app/services/driver_checker.py +++ b/app/services/driver_checker.py @@ -1,5 +1,7 @@ import importlib.util import os +import shutil +import sys from app.schemas.db_driver_info import DBDriverInfo @@ -12,7 +14,24 @@ } +def is_python_environment() -> bool: + """현재 환경이 Python 실행 환경인지 확인""" + # 1) sys.executable 존재 여부 확인 (현재 파이썬 인터프리터 경로) + if sys.executable: + return True + # 2) 시스템 경로에 python3 또는 python 명령어 존재 여부 확인 + if shutil.which("python3") or shutil.which("python"): + return True + return False + + def check_driver(driver_id: str) -> DBDriverInfo: + """ + 주어진 DB 드라이버 ID에 대해 + - 현재 Python 환경에서 설치 여부 확인 + - 설치되어 있으면 드라이버 정보 반환 + - 설치 안되어 있거나 미지원 타입일 경우 상태 반환 + """ module_name = DRIVER_MAP.get(driver_id.lower()) if not module_name: return DBDriverInfo( @@ -25,9 +44,20 @@ def check_driver(driver_id: str) -> DBDriverInfo: driver_version=None, ) - # 해당 모듈이 현재 파이썬 환경에 설치되어 있는지 확인(즉, import 가능한지) + # Python 환경이 아니면 설치 여부 확인 불가 (필요하면 OS 레벨 체크로 확장 가능) + if not is_python_environment(): + return DBDriverInfo( + db_type=driver_id, + is_installed=False, + message="Python 환경이 아니어서 설치 여부를 확인할 수 없습니다.", + driver_path=None, + driver_name=None, + driver_size_bytes=None, + driver_version=None, + ) + + # import 가능한지 확인해서 설치 여부 판단 spec = importlib.util.find_spec(module_name) - # 설치 유무 확인 is_installed = spec is not None return DBDriverInfo( @@ -37,5 +67,5 @@ def check_driver(driver_id: str) -> DBDriverInfo: driver_path=spec.origin if is_installed else None, driver_name=os.path.basename(spec.origin) if is_installed else None, driver_size_bytes=os.path.getsize(spec.origin) if is_installed else None, - driver_version="N/A", # 버전 확인 로직은 각 패키지별로 다르게 처리 + driver_version="N/A", # 추후 각 드라이버별 버전 확인 로직 추가 가능 ) From bede728fe2982b150614b65bde740f6cb62cb9cc Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 23 Jul 2025 02:17:40 +0900 Subject: [PATCH 017/220] =?UTF-8?q?test:=20Python=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=20=EC=97=AC=EB=B6=80=20API=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/tests/api/test_connections.py | 47 ++++++++++++++++++------------- app/tests/api/test_environment.py | 15 ++++++++++ 2 files changed, 43 insertions(+), 19 deletions(-) create mode 100644 app/tests/api/test_environment.py diff --git a/app/tests/api/test_connections.py b/app/tests/api/test_connections.py index a742fe6..3786730 100644 --- a/app/tests/api/test_connections.py +++ b/app/tests/api/test_connections.py @@ -3,52 +3,61 @@ from fastapi.testclient import TestClient from app.main import app +from app.services.driver_checker import check_driver client = TestClient(app) def test_check_mysql_driver_installed(): - # 드라이버 설치 되어있을 경우 mock_spec = MagicMock() mock_spec.origin = "/usr/lib/python3.11/site-packages/mysql_driver.so" - with patch("importlib.util.find_spec", return_value=mock_spec): - with patch("os.path.getsize", return_value=123456): + with patch("app.services.driver_checker.is_python_environment", return_value=True): + with patch("importlib.util.find_spec", return_value=mock_spec): + with patch("os.path.getsize", return_value=123456): + response = client.get("/connections/drivers/mysql") + assert response.status_code == 200 + data = response.json() + print(data) + assert data["db_type"] == "mysql" + assert data["is_installed"] is True + assert data["driver_path"] == mock_spec.origin + assert data["driver_name"] == "mysql_driver.so" + assert data["driver_size_bytes"] == 123456 + + +def test_check_mysql_driver_not_installed_python_env(): + # Python 환경이지만 드라이버가 설치되어 있지 않은 경우 + with patch("app.services.driver_checker.is_python_environment", return_value=True): + with patch("importlib.util.find_spec", return_value=None): response = client.get("/connections/drivers/mysql") assert response.status_code == 200 data = response.json() print(data) assert data["db_type"] == "mysql" - assert data["is_installed"] is True - assert data["driver_path"] == mock_spec.origin - assert data["driver_name"] == "mysql_driver.so" - assert data["driver_size_bytes"] == 123456 + assert data["is_installed"] is False + assert data["driver_path"] is None + assert data["driver_name"] is None + assert data["driver_size_bytes"] is None + assert "설치되어 있지 않습니다" in data["message"] -def test_check_mysql_driver_not_installed(): - # 드라이버 설치 안되어있을 경우 - with patch("importlib.util.find_spec", return_value=None): +def test_check_driver_not_python_environment(): + # Python 환경이 아닌 경우 + with patch("app.services.driver_checker.is_python_environment", return_value=False): response = client.get("/connections/drivers/mysql") assert response.status_code == 200 data = response.json() print(data) assert data["db_type"] == "mysql" assert data["is_installed"] is False - assert data["driver_path"] is None - assert data["driver_name"] is None - assert data["driver_size_bytes"] is None - assert "설치되어 있지 않습니다" in data["message"] - - -from app.services.driver_checker import check_driver + assert "Python 환경이 아니어서" in data["message"] def test_check_driver_unsupported_db_direct(): - # 지원되지 않는 DB 타입을 넣었을 때 unsupported_db = "unknown_db" response = check_driver(unsupported_db) - # response는 DBDriverInfo 객체라 바로 속성 접근 print(response) assert response.db_type == unsupported_db diff --git a/app/tests/api/test_environment.py b/app/tests/api/test_environment.py new file mode 100644 index 0000000..da9a454 --- /dev/null +++ b/app/tests/api/test_environment.py @@ -0,0 +1,15 @@ +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_check_python_environment_api(): + response = client.get("/environment/python") + assert response.status_code == 200 + data = response.json() + print("[python env result]", data) + + assert "is_python_environment" in data + assert isinstance(data["is_python_environment"], bool) From 57c109286470c5c616ce050b94b12ea83d5fde12 Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 23 Jul 2025 03:09:23 +0900 Subject: [PATCH 018/220] =?UTF-8?q?feat:=20macOS=20=EB=B0=8F=20Windows=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20OS=20=EB=A0=88=EB=B2=A8?= =?UTF-8?q?=20=EB=93=9C=EB=9D=BC=EC=9D=B4=EB=B2=84=20=EC=84=A4=EC=B9=98=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=ED=99=95=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/db_driver_info.py | 2 + app/services/driver_checker.py | 135 +++++++++++++++++++++++++----- app/tests/api/test_connections.py | 40 +++++++-- 3 files changed, 148 insertions(+), 29 deletions(-) diff --git a/app/schemas/db_driver_info.py b/app/schemas/db_driver_info.py index 408d322..f79412b 100644 --- a/app/schemas/db_driver_info.py +++ b/app/schemas/db_driver_info.py @@ -9,3 +9,5 @@ class DBDriverInfo(BaseModel): driver_name: str | None = None driver_size_bytes: int | None = None driver_version: str | None = None + os_name: str | None = None + os_full_name: str | None = None diff --git a/app/services/driver_checker.py b/app/services/driver_checker.py index 24728c1..4d6cb86 100644 --- a/app/services/driver_checker.py +++ b/app/services/driver_checker.py @@ -1,10 +1,21 @@ +import importlib import importlib.util +import logging import os +import platform import shutil +import subprocess import sys from app.schemas.db_driver_info import DBDriverInfo +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + +os_simple_name = platform.system() # Darwin, Windows, Linux 등 간단 이름 +os_full_name = platform.platform() # Darwin-22.5.0-x86_64, Windows-10-10.0.19045 등 상세 버전 포함 + +# 주요 DB별 대표적인 파이썬 드라이버들 복수 리스트로 작성 DRIVER_MAP = { "postgresql": "psycopg2", "mysql": "pymysql", @@ -14,26 +25,67 @@ } +# OS 레벨에서 드라이버 설치 확인용 명령어 (간단 체크용) +OS_DRIVER_CHECK_COMMANDS = { + "postgresql": { + "macos": ["which", "psql"], + "windows": ["where", "psql"], + }, + "mysql": { + "macos": ["which", "mysql"], + "windows": ["where", "mysql"], + }, + "sqlite": { + "macos": ["which", "sqlite3"], + "windows": ["where", "sqlite3"], + }, + "oracle": { + "macos": ["which", "sqlplus"], + "windows": ["where", "sqlplus"], + }, + "sqlserver": { + "macos": ["which", "sqlcmd"], + "windows": ["where", "sqlcmd"], + }, +} + + def is_python_environment() -> bool: """현재 환경이 Python 실행 환경인지 확인""" - # 1) sys.executable 존재 여부 확인 (현재 파이썬 인터프리터 경로) if sys.executable: return True - # 2) 시스템 경로에 python3 또는 python 명령어 존재 여부 확인 if shutil.which("python3") or shutil.which("python"): return True return False +def check_os_driver_installed(driver_id: str) -> bool: + """Python 환경이 아닐 때 OS 레벨에서 DB 클라이언트 도구 설치 여부 확인""" + system = platform.system().lower() + driver_key = driver_id.lower() + + if driver_key not in OS_DRIVER_CHECK_COMMANDS: + return False # 지원하지 않는 드라이버 + + if system == "darwin": + cmd = OS_DRIVER_CHECK_COMMANDS[driver_key]["macos"] + elif system == "windows": + cmd = OS_DRIVER_CHECK_COMMANDS[driver_key]["windows"] + else: + return False # Linux 등은 필요 시 추가 + + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + return bool(result.stdout.strip()) + except Exception: + return False + + def check_driver(driver_id: str) -> DBDriverInfo: - """ - 주어진 DB 드라이버 ID에 대해 - - 현재 Python 환경에서 설치 여부 확인 - - 설치되어 있으면 드라이버 정보 반환 - - 설치 안되어 있거나 미지원 타입일 경우 상태 반환 - """ - module_name = DRIVER_MAP.get(driver_id.lower()) - if not module_name: + driver_key = driver_id.lower() + module_names = DRIVER_MAP.get(driver_key) + + if not module_names: return DBDriverInfo( db_type=driver_id, is_installed=False, @@ -42,30 +94,73 @@ def check_driver(driver_id: str) -> DBDriverInfo: driver_name=None, driver_size_bytes=None, driver_version=None, + os_name=os_simple_name, + os_full_name=os_full_name, ) - # Python 환경이 아니면 설치 여부 확인 불가 (필요하면 OS 레벨 체크로 확장 가능) + logger.info(f"[check_driver] 요청된 드라이버: {driver_id} → 모듈명: {module_names}") + logger.info(f"[check_driver] Python 환경 여부: {is_python_environment()}") + if not is_python_environment(): + os_installed = check_os_driver_installed(driver_key) return DBDriverInfo( db_type=driver_id, - is_installed=False, - message="Python 환경이 아니어서 설치 여부를 확인할 수 없습니다.", + is_installed=os_installed, + message=( + "Python 환경이 아니어서 OS 레벨로 설치 여부를 확인했습니다." + if os_installed + else "Python 환경이 아니며 OS 레벨에서도 드라이버를 찾을 수 없습니다." + ), driver_path=None, driver_name=None, driver_size_bytes=None, driver_version=None, + os_name=os_simple_name, + os_full_name=os_full_name, ) - # import 가능한지 확인해서 설치 여부 판단 - spec = importlib.util.find_spec(module_name) - is_installed = spec is not None + # module_names가 리스트일 때 첫 발견된 드라이버를 찾음 + if isinstance(module_names, str): + module_names = [module_names] + + installed_module = None + spec = None + for mod_name in module_names: + spec = importlib.util.find_spec(mod_name) + if spec is not None: + installed_module = mod_name + break + + is_installed = installed_module is not None + logger.info(f"[check_driver] 발견된 모듈명: {installed_module}, spec: {spec}") + + driver_path = None + driver_name = None + driver_size_bytes = None + driver_version = "N/A" + + if is_installed and spec and spec.origin: + driver_path = spec.origin + driver_name = os.path.basename(driver_path) + try: + driver_size_bytes = os.path.getsize(driver_path) + except Exception: + driver_size_bytes = None + + try: + mod = importlib.import_module(installed_module) + driver_version = getattr(mod, "__version__", "Unknown") + except Exception: + driver_version = "Unknown" return DBDriverInfo( db_type=driver_id, is_installed=is_installed, message="드라이버가 설치되어 있습니다." if is_installed else "드라이버가 설치되어 있지 않습니다.", - driver_path=spec.origin if is_installed else None, - driver_name=os.path.basename(spec.origin) if is_installed else None, - driver_size_bytes=os.path.getsize(spec.origin) if is_installed else None, - driver_version="N/A", # 추후 각 드라이버별 버전 확인 로직 추가 가능 + driver_path=driver_path, + driver_name=driver_name, + driver_size_bytes=driver_size_bytes, + driver_version=driver_version, + os_name=os_simple_name, + os_full_name=os_full_name, ) diff --git a/app/tests/api/test_connections.py b/app/tests/api/test_connections.py index 3786730..f011202 100644 --- a/app/tests/api/test_connections.py +++ b/app/tests/api/test_connections.py @@ -1,3 +1,5 @@ +import subprocess +import sys from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient @@ -5,6 +7,7 @@ from app.main import app from app.services.driver_checker import check_driver +print(f"Python 실행 경로: {sys.executable}") client = TestClient(app) @@ -42,16 +45,35 @@ def test_check_mysql_driver_not_installed_python_env(): assert "설치되어 있지 않습니다" in data["message"] -def test_check_driver_not_python_environment(): - # Python 환경이 아닌 경우 +def test_check_driver_not_python_environment_driver_installed(): + # Python 환경이 아니고, OS 레벨에서 설치되어 있다고 판단되는 경우 with patch("app.services.driver_checker.is_python_environment", return_value=False): - response = client.get("/connections/drivers/mysql") - assert response.status_code == 200 - data = response.json() - print(data) - assert data["db_type"] == "mysql" - assert data["is_installed"] is False - assert "Python 환경이 아니어서" in data["message"] + # subprocess.run 결과를 mocking: 설치되어 있다고 판단 + mock_subproc_result = subprocess.CompletedProcess( + args=["which", "mysql"], returncode=0, stdout=b"/usr/bin/mysql\n" + ) + with patch("subprocess.run", return_value=mock_subproc_result): + response = client.get("/connections/drivers/mysql") + assert response.status_code == 200 + data = response.json() + print(data) + assert data["db_type"] == "mysql" + assert data["is_installed"] is True + assert "Python 환경이 아니어서 OS 레벨로 설치 여부를 확인했습니다." in data["message"] + + +def test_check_driver_not_python_environment_driver_not_installed(): + # Python 환경이 아니고, OS 레벨에서 설치 안되어 있다고 판단되는 경우 + with patch("app.services.driver_checker.is_python_environment", return_value=False): + mock_subproc_result = subprocess.CompletedProcess(args=["which", "mysql"], returncode=1, stdout=b"") + with patch("subprocess.run", return_value=mock_subproc_result): + response = client.get("/connections/drivers/mysql") + assert response.status_code == 200 + data = response.json() + print(data) + assert data["db_type"] == "mysql" + assert data["is_installed"] is False + assert "Python 환경이 아니며 OS 레벨에서도 드라이버를 찾을 수 없습니다." in data["message"] def test_check_driver_unsupported_db_direct(): From e8694a52da492d428470c6abe62f150feaf30736 Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 23 Jul 2025 03:25:35 +0900 Subject: [PATCH 019/220] =?UTF-8?q?fix:=20=EB=93=9C=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=EB=AA=85=EC=9D=B4=20=EB=8B=A8=EC=9D=BC=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4=EC=9D=BC=20=EB=95=8C=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=E2=80=94=20=EB=AA=A8=EB=93=A0=20=EB=93=9C=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=EB=AA=85=EC=9D=84=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/driver_checker.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/services/driver_checker.py b/app/services/driver_checker.py index 4d6cb86..c6aa117 100644 --- a/app/services/driver_checker.py +++ b/app/services/driver_checker.py @@ -17,11 +17,12 @@ # 주요 DB별 대표적인 파이썬 드라이버들 복수 리스트로 작성 DRIVER_MAP = { - "postgresql": "psycopg2", - "mysql": "pymysql", - "sqlite": "sqlite3", - "oracle": "cx_Oracle", - "sqlserver": "pyodbc", + "postgresql": ["psycopg2", "psycopg2_binary", "pg8000"], + "mysql": ["mysql.connector", "pymysql", "MySQLdb", "oursql"], + "sqlite": ["sqlite3"], + "oracle": ["cx_Oracle", "oracledb"], + "sqlserver": ["pyodbc", "pymssql"], + "mariadb": ["mariadb", "mysql.connector", "pymysql", "MySQLdb", "oursql"], } @@ -124,12 +125,17 @@ def check_driver(driver_id: str) -> DBDriverInfo: module_names = [module_names] installed_module = None + spec = None + for mod_name in module_names: - spec = importlib.util.find_spec(mod_name) - if spec is not None: + try: + mod = importlib.import_module(mod_name) installed_module = mod_name + spec = getattr(mod, "__spec__", None) break + except ModuleNotFoundError: + continue is_installed = installed_module is not None logger.info(f"[check_driver] 발견된 모듈명: {installed_module}, spec: {spec}") From 59256f90b40ff50e552ec29acc69bc87025b7601 Mon Sep 17 00:00:00 2001 From: mini Date: Thu, 24 Jul 2025 22:27:14 +0900 Subject: [PATCH 020/220] =?UTF-8?q?test:=20OS=20=EB=A0=88=EB=B2=A8=20?= =?UTF-8?q?=EB=93=9C=EB=9D=BC=EC=9D=B4=EB=B2=84=20=EC=84=A4=EC=B9=98=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/driver_checker.py | 3 + ...st_connections.py => test_driver_check.py} | 57 +++++++++++-------- 2 files changed, 36 insertions(+), 24 deletions(-) rename app/tests/api/{test_connections.py => test_driver_check.py} (65%) diff --git a/app/services/driver_checker.py b/app/services/driver_checker.py index c6aa117..90207b6 100644 --- a/app/services/driver_checker.py +++ b/app/services/driver_checker.py @@ -145,6 +145,9 @@ def check_driver(driver_id: str) -> DBDriverInfo: driver_size_bytes = None driver_version = "N/A" + # print("DEBUG: spec=", spec) + # print("DEBUG: is_installed=", is_installed) + if is_installed and spec and spec.origin: driver_path = spec.origin driver_name = os.path.basename(driver_path) diff --git a/app/tests/api/test_connections.py b/app/tests/api/test_driver_check.py similarity index 65% rename from app/tests/api/test_connections.py rename to app/tests/api/test_driver_check.py index f011202..1620ed6 100644 --- a/app/tests/api/test_connections.py +++ b/app/tests/api/test_driver_check.py @@ -1,3 +1,4 @@ +import json import subprocess import sys from unittest.mock import MagicMock, patch @@ -11,32 +12,43 @@ client = TestClient(app) -def test_check_mysql_driver_installed(): - mock_spec = MagicMock() - mock_spec.origin = "/usr/lib/python3.11/site-packages/mysql_driver.so" +@patch("app.services.driver_checker.importlib.import_module") +@patch("os.path.getsize", return_value=123456) +@patch("app.services.driver_checker.is_python_environment", return_value=True) +def test_check_mysql_driver_installed(mock_env, mock_getsize, mock_import_module): + # ✅ 모듈과 spec 모킹 + spec_mock = MagicMock() + spec_mock.origin = "/usr/lib/python3.11/site-packages/mysql_driver.so" - with patch("app.services.driver_checker.is_python_environment", return_value=True): - with patch("importlib.util.find_spec", return_value=mock_spec): - with patch("os.path.getsize", return_value=123456): - response = client.get("/connections/drivers/mysql") - assert response.status_code == 200 - data = response.json() - print(data) - assert data["db_type"] == "mysql" - assert data["is_installed"] is True - assert data["driver_path"] == mock_spec.origin - assert data["driver_name"] == "mysql_driver.so" - assert data["driver_size_bytes"] == 123456 + mock_module = MagicMock() + mock_module.__spec__ = spec_mock + mock_module.__version__ = "8.0.0" + mock_import_module.return_value = mock_module + + from app.main import app + + client = TestClient(app) + response = client.get("/connections/drivers/mysql") + + data = response.json() + print(json.dumps(data, indent=4, ensure_ascii=False)) + + assert data["is_installed"] is True + assert data["driver_path"] == spec_mock.origin + assert data["driver_size_bytes"] == 123456 + assert data["driver_version"] == "8.0.0" + assert data["driver_name"] == "mysql_driver.so" def test_check_mysql_driver_not_installed_python_env(): - # Python 환경이지만 드라이버가 설치되어 있지 않은 경우 + mock_spec = None # 드라이버 미설치 상황에서는 None + with patch("app.services.driver_checker.is_python_environment", return_value=True): - with patch("importlib.util.find_spec", return_value=None): + with patch("app.services.driver_checker.importlib.util.find_spec", return_value=mock_spec): response = client.get("/connections/drivers/mysql") assert response.status_code == 200 data = response.json() - print(data) + print(json.dumps(data, indent=4, ensure_ascii=False)) assert data["db_type"] == "mysql" assert data["is_installed"] is False assert data["driver_path"] is None @@ -46,9 +58,7 @@ def test_check_mysql_driver_not_installed_python_env(): def test_check_driver_not_python_environment_driver_installed(): - # Python 환경이 아니고, OS 레벨에서 설치되어 있다고 판단되는 경우 with patch("app.services.driver_checker.is_python_environment", return_value=False): - # subprocess.run 결과를 mocking: 설치되어 있다고 판단 mock_subproc_result = subprocess.CompletedProcess( args=["which", "mysql"], returncode=0, stdout=b"/usr/bin/mysql\n" ) @@ -56,21 +66,20 @@ def test_check_driver_not_python_environment_driver_installed(): response = client.get("/connections/drivers/mysql") assert response.status_code == 200 data = response.json() - print(data) + print(json.dumps(data, indent=4, ensure_ascii=False)) assert data["db_type"] == "mysql" assert data["is_installed"] is True assert "Python 환경이 아니어서 OS 레벨로 설치 여부를 확인했습니다." in data["message"] def test_check_driver_not_python_environment_driver_not_installed(): - # Python 환경이 아니고, OS 레벨에서 설치 안되어 있다고 판단되는 경우 with patch("app.services.driver_checker.is_python_environment", return_value=False): mock_subproc_result = subprocess.CompletedProcess(args=["which", "mysql"], returncode=1, stdout=b"") with patch("subprocess.run", return_value=mock_subproc_result): response = client.get("/connections/drivers/mysql") assert response.status_code == 200 data = response.json() - print(data) + print(json.dumps(data, indent=4, ensure_ascii=False)) assert data["db_type"] == "mysql" assert data["is_installed"] is False assert "Python 환경이 아니며 OS 레벨에서도 드라이버를 찾을 수 없습니다." in data["message"] @@ -80,7 +89,7 @@ def test_check_driver_unsupported_db_direct(): unsupported_db = "unknown_db" response = check_driver(unsupported_db) - print(response) + print(json.dumps(response.model_dump(), indent=4, ensure_ascii=False)) assert response.db_type == unsupported_db assert response.is_installed is False From 929b56753722de8f17200a9c74fca7c2de0f2d09 Mon Sep 17 00:00:00 2001 From: mini Date: Fri, 25 Jul 2025 00:35:20 +0900 Subject: [PATCH 021/220] =?UTF-8?q?chore:=20DB=20=EB=93=9C=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B2=84=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(PostgreSQL,=20MySQL,=20Oracle=20=EB=93=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- poetry.lock | 212 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 7 +- 2 files changed, 217 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1738e3e..07b59ee 100644 --- a/poetry.lock +++ b/poetry.lock @@ -144,6 +144,32 @@ files = [ ] markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} +[[package]] +name = "cx-oracle" +version = "8.3.0" +description = "Python interface to Oracle" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "cx_Oracle-8.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b6a23da225f03f50a81980c61dbd6a358c3575f212ca7f4c22bb65a9faf94f7f"}, + {file = "cx_Oracle-8.3.0-cp310-cp310-win32.whl", hash = "sha256:715a8bbda5982af484ded14d184304cc552c1096c82471dd2948298470e88a04"}, + {file = "cx_Oracle-8.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:07f01608dfb6603a8f2a868fc7c7bdc951480f187df8dbc50f4d48c884874e6a"}, + {file = "cx_Oracle-8.3.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4b3afe7a911cebaceda908228d36839f6441cbd38e5df491ec25960562bb01a0"}, + {file = "cx_Oracle-8.3.0-cp36-cp36m-win32.whl", hash = "sha256:076ffb71279d6b2dcbf7df028f62a01e18ce5bb73d8b01eab582bf14a62f4a61"}, + {file = "cx_Oracle-8.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:b82e4b165ffd807a2bd256259a6b81b0a2452883d39f987509e2292d494ea163"}, + {file = "cx_Oracle-8.3.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b902db61dcdcbbf8dd981f5a46d72fef40c5150c7fc0eb0f0698b462d6eb834e"}, + {file = "cx_Oracle-8.3.0-cp37-cp37m-win32.whl", hash = "sha256:4c82ca74442c298ceec56d207450c192e06ecf8ad52eb4aaad0812e147ceabf7"}, + {file = "cx_Oracle-8.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:54164974d526b76fdefb0b66a42b68e1fca5df78713d0eeb8c1d0047b83f6bcf"}, + {file = "cx_Oracle-8.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:410747d542e5f94727f5f0e42e9706c772cf9094fb348ce965ab88b3a9e4d2d8"}, + {file = "cx_Oracle-8.3.0-cp38-cp38-win32.whl", hash = "sha256:3baa878597c5fadb2c72f359f548431c7be001e722ce4a4ebdf3d2293a1bb70b"}, + {file = "cx_Oracle-8.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:de42bdc882abdc5cea54597da27a05593b44143728e5b629ad5d35decb1a2036"}, + {file = "cx_Oracle-8.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:df412238a9948340591beee9ec64fa62a2efacc0d91107034a7023e2991fba97"}, + {file = "cx_Oracle-8.3.0-cp39-cp39-win32.whl", hash = "sha256:70d3cf030aefd71f99b45beba77237b2af448adf5e26be0db3d0d3dee6ea4230"}, + {file = "cx_Oracle-8.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:bf01ce87edb4ef663b2e5bd604e1e0154d2cc2f12b60301f788b569d9db8a900"}, + {file = "cx_Oracle-8.3.0.tar.gz", hash = "sha256:3b2d215af4441463c97ea469b9cc307460739f89fdfa8ea222ea3518f1a424d9"}, +] + [[package]] name = "distlib" version = "0.3.9" @@ -392,6 +418,49 @@ files = [ {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, ] +[[package]] +name = "mysql-connector-python" +version = "9.4.0" +description = "A self-contained Python driver for communicating with MySQL servers, using an API that is compliant with the Python Database API Specification v2.0 (PEP 249)." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "mysql_connector_python-9.4.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3c2603e00516cf4208c6266e85c5c87d5f4d0ac79768106d50de42ccc8414c05"}, + {file = "mysql_connector_python-9.4.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:47884fcb050112b8bef3458e17eac47cc81a6cbbf3524e3456146c949772d9b4"}, + {file = "mysql_connector_python-9.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:f14b6936cd326e212fc9ab5f666dea3efea654f0cb644460334e60e22986e735"}, + {file = "mysql_connector_python-9.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:0f5ad70355720e64b72d7c068e858c9fd1f69b671d9575f857f235a10f878939"}, + {file = "mysql_connector_python-9.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:7106670abce510e440d393e27fc3602b8cf21e7a8a80216cc9ad9a68cd2e4595"}, + {file = "mysql_connector_python-9.4.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:7df1a8ddd182dd8adc914f6dc902a986787bf9599705c29aca7b2ce84e79d361"}, + {file = "mysql_connector_python-9.4.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:3892f20472e13e63b1fb4983f454771dd29f211b09724e69a9750e299542f2f8"}, + {file = "mysql_connector_python-9.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d3e87142103d71c4df647ece30f98e85e826652272ed1c74822b56f6acdc38e7"}, + {file = "mysql_connector_python-9.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b27fcd403436fe83bafb2fe7fcb785891e821e639275c4ad3b3bd1e25f533206"}, + {file = "mysql_connector_python-9.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd6ff5afb9c324b0bbeae958c93156cce4168c743bf130faf224d52818d1f0ee"}, + {file = "mysql_connector_python-9.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:4efa3898a24aba6a4bfdbf7c1f5023c78acca3150d72cc91199cca2ccd22f76f"}, + {file = "mysql_connector_python-9.4.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:665c13e7402235162e5b7a2bfdee5895192121b64ea455c90a81edac6a48ede5"}, + {file = "mysql_connector_python-9.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:815aa6cad0f351c1223ef345781a538f2e5e44ef405fdb3851eb322bd9c4ca2b"}, + {file = "mysql_connector_python-9.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b3436a2c8c0ec7052932213e8d01882e6eb069dbab33402e685409084b133a1c"}, + {file = "mysql_connector_python-9.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:57b0c224676946b70548c56798d5023f65afa1ba5b8ac9f04a143d27976c7029"}, + {file = "mysql_connector_python-9.4.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:fde3bbffb5270a4b02077029914e6a9d2ec08f67d8375b4111432a2778e7540b"}, + {file = "mysql_connector_python-9.4.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:25f77ad7d845df3b5a5a3a6a8d1fed68248dc418a6938a371d1ddaaab6b9a8e3"}, + {file = "mysql_connector_python-9.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:227dd420c71e6d4788d52d98f298e563f16b6853577e5ade4bd82d644257c812"}, + {file = "mysql_connector_python-9.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5163381a312d38122eded2197eb5cd7ccf1a5c5881d4e7a6de10d6ea314d088e"}, + {file = "mysql_connector_python-9.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:c727cb1f82b40c9aaa7a15ab5cf0a7f87c5d8dce32eab5ff2530a4aa6054e7df"}, + {file = "mysql_connector_python-9.4.0-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:20f8154ab5c0ed444f8ef8e5fa91e65215037db102c137b5f995ebfffd309b78"}, + {file = "mysql_connector_python-9.4.0-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:7b8976d89d67c8b0dc452471cb557d9998ed30601fb69a876bf1f0ecaa7954a4"}, + {file = "mysql_connector_python-9.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:4ee4fe1b067e243aae21981e4b9f9d300a3104814b8274033ca8fc7a89b1729e"}, + {file = "mysql_connector_python-9.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:1c6b95404e80d003cd452e38674e91528e2b3a089fe505c882f813b564e64f9d"}, + {file = "mysql_connector_python-9.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8f820c111335f225d63367307456eb7e10494f87e7a94acded3bb762e55a6d4"}, + {file = "mysql_connector_python-9.4.0-py2.py3-none-any.whl", hash = "sha256:56e679169c704dab279b176fab2a9ee32d2c632a866c0f7cd48a8a1e2cf802c4"}, + {file = "mysql_connector_python-9.4.0.tar.gz", hash = "sha256:d111360332ae78933daf3d48ff497b70739aa292ab0017791a33e826234e743b"}, +] + +[package.extras] +dns-srv = ["dnspython (==2.6.1)"] +gssapi = ["gssapi (==1.8.3)"] +telemetry = ["opentelemetry-api (==1.33.1)", "opentelemetry-exporter-otlp-proto-http (==1.33.1)", "opentelemetry-sdk (==1.33.1)"] +webauthn = ["fido2 (==1.1.2)"] + [[package]] name = "nodeenv" version = "1.9.1" @@ -493,6 +562,84 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-win32.whl", hash = "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b"}, + {file = "psycopg2_binary-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392"}, + {file = "psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64"}, + {file = "psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:056470c3dc57904bbf63d6f534988bafc4e970ffd50f6271fc4ee7daad9498a5"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aa0e31fa4bb82578f3a6c74a73c273367727de397a7a0f07bd83cbea696baa"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8de718c0e1c4b982a54b41779667242bc630b2197948405b7bd8ce16bcecac92"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5c370b1e4975df846b0277b4deba86419ca77dbc25047f535b0bb03d1a544d44"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:ffe8ed017e4ed70f68b7b371d84b7d4a790368db9203dfc2d222febd3a9c8863"}, + {file = "psycopg2_binary-2.9.10-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:8aecc5e80c63f7459a1a2ab2c64df952051df196294d9f739933a9f6687e86b3"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-win32.whl", hash = "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8"}, + {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -695,6 +842,69 @@ files = [ packaging = ">=22.0" setuptools = ">=42.0.0" +[[package]] +name = "pymysql" +version = "1.1.1" +description = "Pure Python MySQL Driver" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "PyMySQL-1.1.1-py3-none-any.whl", hash = "sha256:4de15da4c61dc132f4fb9ab763063e693d521a80fd0e87943b9a453dd4c19d6c"}, + {file = "pymysql-1.1.1.tar.gz", hash = "sha256:e127611aaf2b417403c60bf4dc570124aeb4a57f5f37b8e95ae399a42f904cd0"}, +] + +[package.extras] +ed25519 = ["PyNaCl (>=1.4.0)"] +rsa = ["cryptography"] + +[[package]] +name = "pyodbc" +version = "5.2.0" +description = "DB API module for ODBC" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyodbc-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb0850e3e3782f57457feed297e220bb20c3e8fd7550d7a6b6bb96112bd9b6fe"}, + {file = "pyodbc-5.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0dae0fb86078c87acf135dbe5afd3c7d15d52ab0db5965c44159e84058c3e2fb"}, + {file = "pyodbc-5.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6493b9c7506ca964b80ad638d0dc82869df7058255d71f04fdd1405e88bcb36b"}, + {file = "pyodbc-5.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e04de873607fb960e71953c164c83e8e5d9291ce0d69e688e54947b254b04902"}, + {file = "pyodbc-5.2.0-cp310-cp310-win32.whl", hash = "sha256:74135cb10c1dcdbd99fe429c61539c232140e62939fa7c69b0a373cc552e4a08"}, + {file = "pyodbc-5.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:d287121eeaa562b9ab3d4c52fa77c793dfedd127049273eb882a05d3d67a8ce8"}, + {file = "pyodbc-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4627779f0a608b51ce2d2fe6d1d395384e65ca36248bf9dbb6d7cf2c8fda1cab"}, + {file = "pyodbc-5.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d997d3b6551273647825c734158ca8a6f682df269f6b3975f2499c01577ddec"}, + {file = "pyodbc-5.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5102007a8c78dd2fc1c1b6f6147de8cfc020f81013e4b46c33e66aaa7d1bf7b1"}, + {file = "pyodbc-5.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e3cbc7075a46c411b531ada557c4aef13d034060a70077717124cabc1717e2d"}, + {file = "pyodbc-5.2.0-cp311-cp311-win32.whl", hash = "sha256:de1ee7ec2eb326b7be5e2c4ce20d472c5ef1a6eb838d126d1d26779ff5486e49"}, + {file = "pyodbc-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:113f904b9852c12f10c7a3288f5a3563ecdbbefe3ccc829074a9eb8255edcd29"}, + {file = "pyodbc-5.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be43d1ece4f2cf4d430996689d89a1a15aeb3a8da8262527e5ced5aee27e89c3"}, + {file = "pyodbc-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9f7badd0055221a744d76c11440c0856fd2846ed53b6555cf8f0a8893a3e4b03"}, + {file = "pyodbc-5.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad633c52f4f4e7691daaa2278d6e6ebb2fe4ae7709e610e22c7dd1a1d620cf8b"}, + {file = "pyodbc-5.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97d086a8f7a302b74c9c2e77bedf954a603b19168af900d4d3a97322e773df63"}, + {file = "pyodbc-5.2.0-cp312-cp312-win32.whl", hash = "sha256:0e4412f8e608db2a4be5bcc75f9581f386ed6a427dbcb5eac795049ba6fc205e"}, + {file = "pyodbc-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1f5686b142759c5b2bdbeaa0692622c2ebb1f10780eb3c174b85f5607fbcf55"}, + {file = "pyodbc-5.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:26844d780045bbc3514d5c2f0d89e7fda7df7db0bd24292eb6902046f5730885"}, + {file = "pyodbc-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26d2d8fd53b71204c755abc53b0379df4e23fd9a40faf211e1cb87e8a32470f0"}, + {file = "pyodbc-5.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a27996b6d27e275dfb5fe8a34087ba1cacadfd1439e636874ef675faea5149d9"}, + {file = "pyodbc-5.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaf42c4bd323b8fd01f1cd900cca2d09232155f9b8f0b9bcd0be66763588ce64"}, + {file = "pyodbc-5.2.0-cp313-cp313-win32.whl", hash = "sha256:207f16b7e9bf09c591616429ebf2b47127e879aad21167ac15158910dc9bbcda"}, + {file = "pyodbc-5.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:96d3127f28c0dacf18da7ae009cd48eac532d3dcc718a334b86a3c65f6a5ef5c"}, + {file = "pyodbc-5.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:770e1ac2e7bdf31439bf1d57a1d34ae37d6151216367e8e3f6cdc275006c8bb0"}, + {file = "pyodbc-5.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4fde753fcea625bfaed36edae34c2fba15bf0b5d0ea27474ee038ef47b684d1d"}, + {file = "pyodbc-5.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d57843b9792994f9e73b91667da6452a4f2d7caaa2499598783eb972c4b6eb93"}, + {file = "pyodbc-5.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1f38adc47d36af392475cd4aaae0f35652fdc9e8364bf155810fe1be591336f"}, + {file = "pyodbc-5.2.0-cp38-cp38-win32.whl", hash = "sha256:dc5342d1d09466f9e76e3979551f9205a01ff0ea78b02d2d889171e8c3c4fb9c"}, + {file = "pyodbc-5.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5323be83fedc79a6d1e1b96e67bdc368c1d3f1562b8f8184b735acdd749ae9"}, + {file = "pyodbc-5.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9e8f4ee2c523bbe85124540ffad62a3b62ae481f012e390ef93e0602b6302e5e"}, + {file = "pyodbc-5.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:057b8ede91b21d9f0ef58210d1ca1aad704e641ca68ac6b02f109d86b61d7402"}, + {file = "pyodbc-5.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f0ecbc7067467df95c9b8bd38fb2682c4a13a3402d77dccaddf1e145cea8cc0"}, + {file = "pyodbc-5.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b7f8324fa01c09fe4843ad8adb0b131299ef263a1fb9e63830c9cd1d5c45e4"}, + {file = "pyodbc-5.2.0-cp39-cp39-win32.whl", hash = "sha256:600ef6f562f609f5612ffaa8a93827249150aa3030c867937c87b24a1608967e"}, + {file = "pyodbc-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:b77556349746fb90416a48bd114cd7323f7e2559a4b263dada935f9b406ba59b"}, + {file = "pyodbc-5.2.0.tar.gz", hash = "sha256:de8be39809c8ddeeee26a4b876a6463529cd487a60d1393eb2a93e9bcd44a8f5"}, +] + [[package]] name = "pytest" version = "8.4.1" @@ -1059,4 +1269,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "b2b900ec980a2f48b1909700bd7c43c46ba2242bb5dfddf3acb5bc45e8244c65" +content-hash = "b74b3112c796bc9e08554482dd7dc61da090b1788706803ec8c2b9d7668fd563" diff --git a/pyproject.toml b/pyproject.toml index f44d187..0f8cca5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,12 @@ dependencies = [ "fastapi (>=0.115.14,<0.116.0)", "uvicorn (>=0.35.0,<0.36.0)", "pydantic (>=2.11.7,<3.0.0)", - "sqlalchemy (>=2.0.41,<3.0.0)" + "sqlalchemy (>=2.0.41,<3.0.0)", + "psycopg2-binary (>=2.9.10,<3.0.0)", + "mysql-connector-python (>=9.4.0,<10.0.0)", + "pymysql (>=1.1.1,<2.0.0)", + "cx-oracle (>=8.3.0,<9.0.0)", + "pyodbc (>=5.2.0,<6.0.0)" ] From 8adb031faae8ea47988098d9d23f8df5126a1e51 Mon Sep 17 00:00:00 2001 From: mini Date: Fri, 25 Jul 2025 01:30:33 +0900 Subject: [PATCH 022/220] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=20DB=20?= =?UTF-8?q?=EB=93=9C=EB=9D=BC=EC=9D=B4=EB=B2=84=20=EC=82=AC=EC=A0=84=20?= =?UTF-8?q?=EC=84=A4=EC=B9=98=EB=A1=9C=20=ED=8C=8C=EC=9D=B4=EC=8D=AC=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD,=20OS=EB=A0=88=EB=B2=A8=20=EB=93=9C=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B2=84=20=EC=B2=B4=ED=81=AC=20=EC=83=9D=EB=9E=B5=20?= =?UTF-8?q?=EB=B0=8F=20=EB=93=9C=EB=9D=BC=EC=9D=B4=EB=B2=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/connections.py | 14 +-- app/schemas/db_driver_info.py | 13 -- app/services/driver_checker.py | 175 --------------------------- app/services/driver_info_provider.py | 45 +++++++ app/tests/api/test_driver_check.py | 100 --------------- app/tests/api/test_environment.py | 15 --- 6 files changed, 49 insertions(+), 313 deletions(-) delete mode 100644 app/schemas/db_driver_info.py delete mode 100644 app/services/driver_checker.py create mode 100644 app/services/driver_info_provider.py delete mode 100644 app/tests/api/test_driver_check.py delete mode 100644 app/tests/api/test_environment.py diff --git a/app/api/connections.py b/app/api/connections.py index 470f5ee..ce99b9b 100644 --- a/app/api/connections.py +++ b/app/api/connections.py @@ -1,16 +1,10 @@ from fastapi import APIRouter -from app.schemas.db_driver_info import DBDriverInfo -from app.services.driver_checker import check_driver, is_python_environment +from app.services.driver_info_provider import db_driver_info router = APIRouter() -@router.get("/connections/drivers/{driverId}", response_model=DBDriverInfo) -def get_driver_info(driverId: str): - return check_driver(driverId) - - -@router.get("/environment/python") -def check_python_environment(): - return {"is_python_environment": is_python_environment()} +@router.get("/connections/drivers/{driverId}") +def read_driver_info(driverId: str): + return db_driver_info(driverId) diff --git a/app/schemas/db_driver_info.py b/app/schemas/db_driver_info.py deleted file mode 100644 index f79412b..0000000 --- a/app/schemas/db_driver_info.py +++ /dev/null @@ -1,13 +0,0 @@ -from pydantic import BaseModel - - -class DBDriverInfo(BaseModel): - db_type: str - is_installed: bool - message: str - driver_path: str | None = None - driver_name: str | None = None - driver_size_bytes: int | None = None - driver_version: str | None = None - os_name: str | None = None - os_full_name: str | None = None diff --git a/app/services/driver_checker.py b/app/services/driver_checker.py deleted file mode 100644 index 90207b6..0000000 --- a/app/services/driver_checker.py +++ /dev/null @@ -1,175 +0,0 @@ -import importlib -import importlib.util -import logging -import os -import platform -import shutil -import subprocess -import sys - -from app.schemas.db_driver_info import DBDriverInfo - -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - -os_simple_name = platform.system() # Darwin, Windows, Linux 등 간단 이름 -os_full_name = platform.platform() # Darwin-22.5.0-x86_64, Windows-10-10.0.19045 등 상세 버전 포함 - -# 주요 DB별 대표적인 파이썬 드라이버들 복수 리스트로 작성 -DRIVER_MAP = { - "postgresql": ["psycopg2", "psycopg2_binary", "pg8000"], - "mysql": ["mysql.connector", "pymysql", "MySQLdb", "oursql"], - "sqlite": ["sqlite3"], - "oracle": ["cx_Oracle", "oracledb"], - "sqlserver": ["pyodbc", "pymssql"], - "mariadb": ["mariadb", "mysql.connector", "pymysql", "MySQLdb", "oursql"], -} - - -# OS 레벨에서 드라이버 설치 확인용 명령어 (간단 체크용) -OS_DRIVER_CHECK_COMMANDS = { - "postgresql": { - "macos": ["which", "psql"], - "windows": ["where", "psql"], - }, - "mysql": { - "macos": ["which", "mysql"], - "windows": ["where", "mysql"], - }, - "sqlite": { - "macos": ["which", "sqlite3"], - "windows": ["where", "sqlite3"], - }, - "oracle": { - "macos": ["which", "sqlplus"], - "windows": ["where", "sqlplus"], - }, - "sqlserver": { - "macos": ["which", "sqlcmd"], - "windows": ["where", "sqlcmd"], - }, -} - - -def is_python_environment() -> bool: - """현재 환경이 Python 실행 환경인지 확인""" - if sys.executable: - return True - if shutil.which("python3") or shutil.which("python"): - return True - return False - - -def check_os_driver_installed(driver_id: str) -> bool: - """Python 환경이 아닐 때 OS 레벨에서 DB 클라이언트 도구 설치 여부 확인""" - system = platform.system().lower() - driver_key = driver_id.lower() - - if driver_key not in OS_DRIVER_CHECK_COMMANDS: - return False # 지원하지 않는 드라이버 - - if system == "darwin": - cmd = OS_DRIVER_CHECK_COMMANDS[driver_key]["macos"] - elif system == "windows": - cmd = OS_DRIVER_CHECK_COMMANDS[driver_key]["windows"] - else: - return False # Linux 등은 필요 시 추가 - - try: - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - return bool(result.stdout.strip()) - except Exception: - return False - - -def check_driver(driver_id: str) -> DBDriverInfo: - driver_key = driver_id.lower() - module_names = DRIVER_MAP.get(driver_key) - - if not module_names: - return DBDriverInfo( - db_type=driver_id, - is_installed=False, - message="지원되지 않는 DB 타입입니다.", - driver_path=None, - driver_name=None, - driver_size_bytes=None, - driver_version=None, - os_name=os_simple_name, - os_full_name=os_full_name, - ) - - logger.info(f"[check_driver] 요청된 드라이버: {driver_id} → 모듈명: {module_names}") - logger.info(f"[check_driver] Python 환경 여부: {is_python_environment()}") - - if not is_python_environment(): - os_installed = check_os_driver_installed(driver_key) - return DBDriverInfo( - db_type=driver_id, - is_installed=os_installed, - message=( - "Python 환경이 아니어서 OS 레벨로 설치 여부를 확인했습니다." - if os_installed - else "Python 환경이 아니며 OS 레벨에서도 드라이버를 찾을 수 없습니다." - ), - driver_path=None, - driver_name=None, - driver_size_bytes=None, - driver_version=None, - os_name=os_simple_name, - os_full_name=os_full_name, - ) - - # module_names가 리스트일 때 첫 발견된 드라이버를 찾음 - if isinstance(module_names, str): - module_names = [module_names] - - installed_module = None - - spec = None - - for mod_name in module_names: - try: - mod = importlib.import_module(mod_name) - installed_module = mod_name - spec = getattr(mod, "__spec__", None) - break - except ModuleNotFoundError: - continue - - is_installed = installed_module is not None - logger.info(f"[check_driver] 발견된 모듈명: {installed_module}, spec: {spec}") - - driver_path = None - driver_name = None - driver_size_bytes = None - driver_version = "N/A" - - # print("DEBUG: spec=", spec) - # print("DEBUG: is_installed=", is_installed) - - if is_installed and spec and spec.origin: - driver_path = spec.origin - driver_name = os.path.basename(driver_path) - try: - driver_size_bytes = os.path.getsize(driver_path) - except Exception: - driver_size_bytes = None - - try: - mod = importlib.import_module(installed_module) - driver_version = getattr(mod, "__version__", "Unknown") - except Exception: - driver_version = "Unknown" - - return DBDriverInfo( - db_type=driver_id, - is_installed=is_installed, - message="드라이버가 설치되어 있습니다." if is_installed else "드라이버가 설치되어 있지 않습니다.", - driver_path=driver_path, - driver_name=driver_name, - driver_size_bytes=driver_size_bytes, - driver_version=driver_version, - os_name=os_simple_name, - os_full_name=os_full_name, - ) diff --git a/app/services/driver_info_provider.py b/app/services/driver_info_provider.py new file mode 100644 index 0000000..5fd0716 --- /dev/null +++ b/app/services/driver_info_provider.py @@ -0,0 +1,45 @@ +import importlib +import logging +import os + +DRIVER_MAP = { + "postgresql": ["psycopg2", "pg8000"], + "mysql": ["pymysql", "mysql.connector"], + "sqlite": ["sqlite3"], + "oracle": ["cx_Oracle"], + "sqlserver": ["pyodbc"], + "mariadb": ["pymysql", "mysql.connector"], +} + + +def db_driver_info(driver_id: str) -> dict: + driver_key = driver_id.lower() + module_names = DRIVER_MAP.get(driver_key) + + if not module_names: + # 지원되지 않는 DB 타입 + return {"message": "지원되지 않는 DB입니다.", "data": None} + + for mod_name in module_names: + try: + mod = importlib.import_module(mod_name) + version = getattr(mod, "__version__", None) + path = getattr(mod.__spec__, "origin", None) + size = os.path.getsize(path) if path else None + + return { + "message": "드라이버 정보를 성공적으로 불러왔습니다.", + "data": { + "db_type": driver_id, + "is_installed": True, + "driver_name": mod_name, + "driver_version": version, + "driver_size_bytes": size, + }, + } + except (ModuleNotFoundError, AttributeError, OSError) as e: + logging.warning(f"드라이버 '{mod_name}' import 실패: {e}") + continue + + # import 실패한 경우 + return {"message": "드라이버 정보를 가져오지 못했습니다. 다시 시도해주세요.", "data": None} diff --git a/app/tests/api/test_driver_check.py b/app/tests/api/test_driver_check.py deleted file mode 100644 index 1620ed6..0000000 --- a/app/tests/api/test_driver_check.py +++ /dev/null @@ -1,100 +0,0 @@ -import json -import subprocess -import sys -from unittest.mock import MagicMock, patch - -from fastapi.testclient import TestClient - -from app.main import app -from app.services.driver_checker import check_driver - -print(f"Python 실행 경로: {sys.executable}") -client = TestClient(app) - - -@patch("app.services.driver_checker.importlib.import_module") -@patch("os.path.getsize", return_value=123456) -@patch("app.services.driver_checker.is_python_environment", return_value=True) -def test_check_mysql_driver_installed(mock_env, mock_getsize, mock_import_module): - # ✅ 모듈과 spec 모킹 - spec_mock = MagicMock() - spec_mock.origin = "/usr/lib/python3.11/site-packages/mysql_driver.so" - - mock_module = MagicMock() - mock_module.__spec__ = spec_mock - mock_module.__version__ = "8.0.0" - mock_import_module.return_value = mock_module - - from app.main import app - - client = TestClient(app) - response = client.get("/connections/drivers/mysql") - - data = response.json() - print(json.dumps(data, indent=4, ensure_ascii=False)) - - assert data["is_installed"] is True - assert data["driver_path"] == spec_mock.origin - assert data["driver_size_bytes"] == 123456 - assert data["driver_version"] == "8.0.0" - assert data["driver_name"] == "mysql_driver.so" - - -def test_check_mysql_driver_not_installed_python_env(): - mock_spec = None # 드라이버 미설치 상황에서는 None - - with patch("app.services.driver_checker.is_python_environment", return_value=True): - with patch("app.services.driver_checker.importlib.util.find_spec", return_value=mock_spec): - response = client.get("/connections/drivers/mysql") - assert response.status_code == 200 - data = response.json() - print(json.dumps(data, indent=4, ensure_ascii=False)) - assert data["db_type"] == "mysql" - assert data["is_installed"] is False - assert data["driver_path"] is None - assert data["driver_name"] is None - assert data["driver_size_bytes"] is None - assert "설치되어 있지 않습니다" in data["message"] - - -def test_check_driver_not_python_environment_driver_installed(): - with patch("app.services.driver_checker.is_python_environment", return_value=False): - mock_subproc_result = subprocess.CompletedProcess( - args=["which", "mysql"], returncode=0, stdout=b"/usr/bin/mysql\n" - ) - with patch("subprocess.run", return_value=mock_subproc_result): - response = client.get("/connections/drivers/mysql") - assert response.status_code == 200 - data = response.json() - print(json.dumps(data, indent=4, ensure_ascii=False)) - assert data["db_type"] == "mysql" - assert data["is_installed"] is True - assert "Python 환경이 아니어서 OS 레벨로 설치 여부를 확인했습니다." in data["message"] - - -def test_check_driver_not_python_environment_driver_not_installed(): - with patch("app.services.driver_checker.is_python_environment", return_value=False): - mock_subproc_result = subprocess.CompletedProcess(args=["which", "mysql"], returncode=1, stdout=b"") - with patch("subprocess.run", return_value=mock_subproc_result): - response = client.get("/connections/drivers/mysql") - assert response.status_code == 200 - data = response.json() - print(json.dumps(data, indent=4, ensure_ascii=False)) - assert data["db_type"] == "mysql" - assert data["is_installed"] is False - assert "Python 환경이 아니며 OS 레벨에서도 드라이버를 찾을 수 없습니다." in data["message"] - - -def test_check_driver_unsupported_db_direct(): - unsupported_db = "unknown_db" - response = check_driver(unsupported_db) - - print(json.dumps(response.model_dump(), indent=4, ensure_ascii=False)) - - assert response.db_type == unsupported_db - assert response.is_installed is False - assert response.message == "지원되지 않는 DB 타입입니다." - assert response.driver_path is None - assert response.driver_name is None - assert response.driver_size_bytes is None - assert response.driver_version is None diff --git a/app/tests/api/test_environment.py b/app/tests/api/test_environment.py deleted file mode 100644 index da9a454..0000000 --- a/app/tests/api/test_environment.py +++ /dev/null @@ -1,15 +0,0 @@ -from fastapi.testclient import TestClient - -from app.main import app - -client = TestClient(app) - - -def test_check_python_environment_api(): - response = client.get("/environment/python") - assert response.status_code == 200 - data = response.json() - print("[python env result]", data) - - assert "is_python_environment" in data - assert isinstance(data["is_python_environment"], bool) From 5dd1832e272bc36005e374a1db4170720000257b Mon Sep 17 00:00:00 2001 From: mini Date: Fri, 25 Jul 2025 02:02:35 +0900 Subject: [PATCH 023/220] =?UTF-8?q?test:=20=EB=93=9C=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=20=EC=A0=95=EB=B3=B4=20=ED=99=95=EC=9D=B8=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EB=8B=A8=EC=9C=84=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8,=20api=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A7=84?= =?UTF-8?q?=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/tests/api/test_driver_info_provider.py | 27 ++++++++++++ app/tests/unit/test_driver_info_provider.py | 47 +++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 app/tests/api/test_driver_info_provider.py create mode 100644 app/tests/unit/test_driver_info_provider.py diff --git a/app/tests/api/test_driver_info_provider.py b/app/tests/api/test_driver_info_provider.py new file mode 100644 index 0000000..ece56f3 --- /dev/null +++ b/app/tests/api/test_driver_info_provider.py @@ -0,0 +1,27 @@ +from fastapi.testclient import TestClient + +from app.main import app # FastAPI 앱 객체 + +client = TestClient(app) + + +def test_api_supported_driver(): + response = client.get("/connections/drivers/mysql") + assert response.status_code == 200 + body = response.json() + assert body["message"] == "드라이버 정보를 성공적으로 불러왔습니다." + assert body["data"]["db_type"] == "mysql" + assert body["data"]["is_installed"] is True + + +def test_api_unsupported_driver(): + response = client.get("/connections/drivers/unknown-db") + assert response.status_code == 200 + body = response.json() + assert body["message"] == "지원되지 않는 DB입니다." + assert body["data"] is None + + +def test_api_empty_driver(): + response = client.get("/connections/drivers/") + assert response.status_code in [404, 422] # 경로 누락이므로 상태코드로 판단 diff --git a/app/tests/unit/test_driver_info_provider.py b/app/tests/unit/test_driver_info_provider.py new file mode 100644 index 0000000..d85e044 --- /dev/null +++ b/app/tests/unit/test_driver_info_provider.py @@ -0,0 +1,47 @@ +import pytest + +from app.services.driver_info_provider import db_driver_info + + +# 설치된 DB 드라이버 중 하나를 테스트 (환경에 따라 달라질 수 있음) +def test_supported_driver_installed(): + result = db_driver_info("mysql") + assert result["message"] == "드라이버 정보를 성공적으로 불러왔습니다." + assert result["data"] is not None + assert result["data"]["db_type"] == "mysql" + assert result["data"]["is_installed"] is True + assert result["data"]["driver_name"] in ["pymysql", "mysql.connector"] + + +# 존재하지 않는 DB 타입을 넘겼을 때 +def test_unsupported_driver(): + result = db_driver_info("unknown-db") + assert result["message"] == "지원되지 않는 DB입니다." + assert result["data"] is None + + +# 빈 값 넘겼을 때 +def test_empty_input(): + result = db_driver_info("") + assert result["message"] == "지원되지 않는 DB입니다." + assert result["data"] is None + + +# 지원은 하지만 환경에 설치되지 않은 드라이버를 일부러 테스트 +@pytest.mark.skip(reason="환경에 따라 설치 여부가 다르므로 건너뜀") +def test_supported_but_not_installed(): + result = db_driver_info("oracle") + assert result["message"] in [ + "드라이버 정보를 성공적으로 불러왔습니다.", + "드라이버 정보를 가져오지 못했습니다. 다시 시도해주세요.", + ] + + +# 함수 강제 실패 테스트 +def test_import_fails_and_fallback_message(): + from unittest import mock + + with mock.patch("importlib.import_module", side_effect=ModuleNotFoundError("모듈 없음")): + result = db_driver_info("mysql") + assert result["message"] == "드라이버 정보를 가져오지 못했습니다. 다시 시도해주세요." + assert result["data"] is None From 26b9c64b0eec324b3061eec2d66dda23f2913eba Mon Sep 17 00:00:00 2001 From: mini Date: Fri, 25 Jul 2025 02:16:10 +0900 Subject: [PATCH 024/220] =?UTF-8?q?test:=20=EC=BD=94=EB=93=9C=20=EC=BB=A4?= =?UTF-8?q?=EB=B2=84=EB=A6=AC=EC=A7=80=20=EC=B8=A1=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...fo_provider.py => test_driver_info_api.py} | 0 ...o_provider.py => test_driver_info_unit.py} | 0 poetry.lock | 123 +++++++++++++++++- pyproject.toml | 1 + 4 files changed, 123 insertions(+), 1 deletion(-) rename app/tests/api/{test_driver_info_provider.py => test_driver_info_api.py} (100%) rename app/tests/unit/{test_driver_info_provider.py => test_driver_info_unit.py} (100%) diff --git a/app/tests/api/test_driver_info_provider.py b/app/tests/api/test_driver_info_api.py similarity index 100% rename from app/tests/api/test_driver_info_provider.py rename to app/tests/api/test_driver_info_api.py diff --git a/app/tests/unit/test_driver_info_provider.py b/app/tests/unit/test_driver_info_unit.py similarity index 100% rename from app/tests/unit/test_driver_info_provider.py rename to app/tests/unit/test_driver_info_unit.py diff --git a/poetry.lock b/poetry.lock index 07b59ee..1ad3eb7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -144,6 +144,107 @@ files = [ ] markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} +[[package]] +name = "coverage" +version = "7.10.0" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "coverage-7.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cbd823f7ea5286c26406ad9e54268544d82f3d1cadb6d4f3b85e9877f0cab1ef"}, + {file = "coverage-7.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab3f7a5dbaab937df0b9e9e8ec6eab235ba9a6f29d71fd3b24335affaed886cc"}, + {file = "coverage-7.10.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8c63aaf850523d8cbe3f5f1a5c78f689b223797bef902635f2493ab43498f36c"}, + {file = "coverage-7.10.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c3133ce3fa84023f7c6921c4dca711be0b658784c5a51a797168229eae26172"}, + {file = "coverage-7.10.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3747d1d0af85b17d3a156cd30e4bbacf893815e846dc6c07050e9769da2b138e"}, + {file = "coverage-7.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:241923b350437f6a7cb343d9df72998305ef940c3c40009f06e05029a047677c"}, + {file = "coverage-7.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:13e82e499309307104d58ac66f9eed237f7aaceab4325416645be34064d9a2be"}, + {file = "coverage-7.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bf73cdde4f6c9cd4457b00bf1696236796ac3a241f859a55e0f84a4c58326a7f"}, + {file = "coverage-7.10.0-cp310-cp310-win32.whl", hash = "sha256:2396e13275b37870a3345f58bce8b15a7e0a985771d13a4b16ce9129954e07d6"}, + {file = "coverage-7.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d45c7c71fb3d2da92ab893602e3f28f2d1560cec765a27e1824a6e0f7e92cfd"}, + {file = "coverage-7.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4abc01843581a6f9dd72d4d15761861190973a2305416639435ef509288f7a04"}, + {file = "coverage-7.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2093297773111d7d748fe4a99b68747e57994531fb5c57bbe439af17c11c169"}, + {file = "coverage-7.10.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58240e27815bf105bd975c2fd42e700839f93d5aad034ef976411193ca32dbfd"}, + {file = "coverage-7.10.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d019eac999b40ad48521ea057958b07a9f549c0c6d257a20e5c7c4ba91af8d1c"}, + {file = "coverage-7.10.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0a1f5454bc80faf4ceab10d1d48f025f92046c9c0f3bec2e1a9dda55137f8"}, + {file = "coverage-7.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a93dd7759c416dd1cc754123b926d065055cb9a33b6699e64a1e5bdfae1ff459"}, + {file = "coverage-7.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7b3d737266048368a6ffd68f1ecd662c54de56535c82eb8f98a55ac216a72cbd"}, + {file = "coverage-7.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:93227c2707cb0effd9163cd0d8f0d9ab628982f7a3e915d6d64c7107867b9a07"}, + {file = "coverage-7.10.0-cp311-cp311-win32.whl", hash = "sha256:69270af3014ab3058ad6108c6d0e218166f568b5a7a070dc3d62c0a63aca1c4d"}, + {file = "coverage-7.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:43c16bbb661a7b4dafac0ab69e44d6dbcc6a64c4d93aefd89edc6f8911b6ab4a"}, + {file = "coverage-7.10.0-cp311-cp311-win_arm64.whl", hash = "sha256:14e7c23fcb74ed808efb4eb48fcd25a759f0e20f685f83266d1df174860e4733"}, + {file = "coverage-7.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a2adcfdaf3b4d69b0c64ad024fe9dd6996782b52790fb6033d90f36f39e287df"}, + {file = "coverage-7.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d7b27c2c0840e8eeff3f1963782bd9d3bc767488d2e67a31de18d724327f9f6"}, + {file = "coverage-7.10.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0ed50429786e935517570b08576a661fd79032e6060985ab492b9d39ba8e66ee"}, + {file = "coverage-7.10.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7171c139ab6571d70460ecf788b1dcaf376bfc75a42e1946b8c031d062bbbad4"}, + {file = "coverage-7.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a726aac7e6e406e403cdee4c443a13aed3ea3d67d856414c5beacac2e70c04e"}, + {file = "coverage-7.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2886257481a14e953e96861a00c0fe7151117a523f0470a51e392f00640bba03"}, + {file = "coverage-7.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:536578b79521e59c385a2e0a14a5dc2a8edd58761a966d79368413e339fc9535"}, + {file = "coverage-7.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77fae95558f7804a9ceefabf3c38ad41af1da92b39781b87197c6440dcaaa967"}, + {file = "coverage-7.10.0-cp312-cp312-win32.whl", hash = "sha256:97803e14736493eb029558e1502fe507bd6a08af277a5c8eeccf05c3e970cb84"}, + {file = "coverage-7.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:4c73ab554e54ffd38d114d6bc4a7115fb0c840cf6d8622211bee3da26e4bd25d"}, + {file = "coverage-7.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:3ae95d5a9aedab853641026b71b2ddd01983a0a7e9bf870a20ef3c8f5d904699"}, + {file = "coverage-7.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d883fee92b9245c0120fa25b5d36de71ccd4cfc29735906a448271e935d8d86d"}, + {file = "coverage-7.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c87e59e88268d30e33d3665ede4fbb77b513981a2df0059e7c106ca3de537586"}, + {file = "coverage-7.10.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f669d969f669a11d6ceee0b733e491d9a50573eb92a71ffab13b15f3aa2665d4"}, + {file = "coverage-7.10.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9582bd6c6771300a847d328c1c4204e751dbc339a9e249eecdc48cada41f72e6"}, + {file = "coverage-7.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91f97e9637dc7977842776fdb7ad142075d6fa40bc1b91cb73685265e0d31d32"}, + {file = "coverage-7.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae4fa92b6601a62367c6c9967ad32ad4e28a89af54b6bb37d740946b0e0534dd"}, + {file = "coverage-7.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3a5cc8b97473e7b3623dd17a42d2194a2b49de8afecf8d7d03c8987237a9552c"}, + {file = "coverage-7.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc1cbb7f623250e047c32bd7aa1bb62ebc62608d5004d74df095e1059141ac88"}, + {file = "coverage-7.10.0-cp313-cp313-win32.whl", hash = "sha256:1380cc5666d778e77f1587cd88cc317158111f44d54c0dd3975f0936993284e0"}, + {file = "coverage-7.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:bf03cf176af098ee578b754a03add4690b82bdfe070adfb5d192d0b1cd15cf82"}, + {file = "coverage-7.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:8041c78cd145088116db2329b2fb6e89dc338116c962fbe654b7e9f5d72ab957"}, + {file = "coverage-7.10.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:37cc2c06052771f48651160c080a86431884db9cd62ba622cab71049b90a95b3"}, + {file = "coverage-7.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:91f37270b16178b05fa107d85713d29bf21606e37b652d38646eef5f2dfbd458"}, + {file = "coverage-7.10.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f9b0b0168864d09bcb9a3837548f75121645c4cfd0efce0eb994c221955c5b10"}, + {file = "coverage-7.10.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0be435d3b616e7d3ee3f9ebbc0d784a213986fe5dff9c6f1042ee7cfd30157"}, + {file = "coverage-7.10.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e9aba1c4434b837b1d567a533feba5ce205e8e91179c97974b28a14c23d3a0"}, + {file = "coverage-7.10.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a0b0c481e74dfad631bdc2c883e57d8b058e5c90ba8ef087600995daf7bbec18"}, + {file = "coverage-7.10.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8aec1b7c8922808a433c13cd44ace6fceac0609f4587773f6c8217a06102674b"}, + {file = "coverage-7.10.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:04ec59ceb3a594af0927f2e0d810e1221212abd9a2e6b5b917769ff48760b460"}, + {file = "coverage-7.10.0-cp313-cp313t-win32.whl", hash = "sha256:b6871e62d29646eb9b3f5f92def59e7575daea1587db21f99e2b19561187abda"}, + {file = "coverage-7.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff99cff2be44f78920b76803f782e91ffb46ccc7fa89eccccc0da3ca94285b64"}, + {file = "coverage-7.10.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3246b63501348fe47299d12c47a27cfc221cfbffa1c2d857bcc8151323a4ae4f"}, + {file = "coverage-7.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:1f628d91f941a375b4503cb486148dbeeffb48e17bc080e0f0adfee729361574"}, + {file = "coverage-7.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a0e101d5af952d233557e445f42ebace20b06b4ceb615581595ced5386caa78"}, + {file = "coverage-7.10.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec4c1abbcc53f9f650acb14ea71725d88246a9e14ed42f8dd1b4e1b694e9d842"}, + {file = "coverage-7.10.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9c95f3a7f041b4cc68a8e3fecfa6366170c13ac773841049f1cd19c8650094e0"}, + {file = "coverage-7.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a2cd597b69c16d24e310611f2ed6fcfb8f09429316038c03a57e7b4f5345244"}, + {file = "coverage-7.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5e18591906a40c2b3609196c9879136aa4a47c5405052ca6b065ab10cb0b71d0"}, + {file = "coverage-7.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:485c55744252ed3f300cc1a0f5f365e684a0f2651a7aed301f7a67125906b80e"}, + {file = "coverage-7.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4dabea1516e5b0e9577282b149c8015e4dceeb606da66fb8d9d75932d5799bf5"}, + {file = "coverage-7.10.0-cp314-cp314-win32.whl", hash = "sha256:ac455f0537af22333fdc23b824cff81110dff2d47300bb2490f947b7c9a16017"}, + {file = "coverage-7.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:b3c94b532f52f95f36fbfde3e178510a4d04eea640b484b2fe8f1491338dc653"}, + {file = "coverage-7.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:2f807f2c3a9da99c80dfa73f09ef5fc3bd21e70c73ba1c538f23396a3a772252"}, + {file = "coverage-7.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0a889ef25215990f65073c32cadf37483363a6a22914186dedc15a6b1a597d50"}, + {file = "coverage-7.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c638ecf3123805bacbf71aff8091e93af490c676fca10ab4e442375076e483"}, + {file = "coverage-7.10.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2f2c0df0cbcf7dffa14f88a99c530cdef3f4fcfe935fa4f95d28be2e7ebc570"}, + {file = "coverage-7.10.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:048d19a5d641a2296745ab59f34a27b89a08c48d6d432685f22aac0ec1ea447f"}, + {file = "coverage-7.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1209b65d302d7a762004be37ab9396cbd8c99525ed572bdf455477e3a9449e06"}, + {file = "coverage-7.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e44aa79a36a7a0aec6ea109905a4a7c28552d90f34e5941b36217ae9556657d5"}, + {file = "coverage-7.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96124be864b89395770c9a14652afcddbcdafb99466f53a9281c51d1466fb741"}, + {file = "coverage-7.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aad222e841f94b42bd1d6be71737fade66943853f0807cf87887c88f70883a2a"}, + {file = "coverage-7.10.0-cp314-cp314t-win32.whl", hash = "sha256:0eed5354d28caa5c8ad60e07e938f253e4b2810ea7dd56784339b6ce98b6f104"}, + {file = "coverage-7.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3da35f9980058acb960b2644527cc3911f1e00f94d309d704b309fa984029109"}, + {file = "coverage-7.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cb9e138dfa8a4b5c52c92a537651e2ca4f2ca48d8cb1bc01a2cbe7a5773c2426"}, + {file = "coverage-7.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cf283ec9c6878826291b17442eb5c32d3d252dc77d25e082b460b2d2ea67ba3c"}, + {file = "coverage-7.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a83488c9fc6fff487f2ab551f9b64c70672357b8949f0951b0cd778b3ed8165"}, + {file = "coverage-7.10.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b86df3a7494d12338c11e59f210a0498d6109bbc3a4037f44de517ebb30a9c6b"}, + {file = "coverage-7.10.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6de9b460809e5e4787b742e786a36ae2346a53982e2be317cdcb7a33c56412fb"}, + {file = "coverage-7.10.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de5ef8a5954d63fa26a6aaa4600e48f885ce70fe495e8fce2c43aa9241fc9434"}, + {file = "coverage-7.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f178fe5e96f1e057527d5d0b20ab76b8616e0410169c33716cc226118eaf2c4f"}, + {file = "coverage-7.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4a38c42f0182a012fa9ec25bc6057e51114c1ba125be304f3f776d6d283cb303"}, + {file = "coverage-7.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bf09beb5c1785cb36aad042455c0afab561399b74bb8cdaf6e82b7d77322df99"}, + {file = "coverage-7.10.0-cp39-cp39-win32.whl", hash = "sha256:cb8dfbb5d3016cb8d1940444c0c69b40cdc6c8bde724b07716ee5ea47b5273c6"}, + {file = "coverage-7.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:58ff22653cd93d563110d1ff2aef958f5f21be9e917762f8124d0e36f80f172a"}, + {file = "coverage-7.10.0-py3-none-any.whl", hash = "sha256:310a786330bb0463775c21d68e26e79973839b66d29e065c5787122b8dd4489f"}, + {file = "coverage-7.10.0.tar.gz", hash = "sha256:2768885aef484b5dcde56262cbdfba559b770bfc46994fe9485dc3614c7a5867"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + [[package]] name = "cx-oracle" version = "8.3.0" @@ -946,6 +1047,26 @@ pytest = ">=8.2,<9" docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] +[[package]] +name = "pytest-cov" +version = "6.2.1" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5"}, + {file = "pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2"}, +] + +[package.dependencies] +coverage = {version = ">=7.5", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=6.2.5" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -1269,4 +1390,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "b74b3112c796bc9e08554482dd7dc61da090b1788706803ec8c2b9d7668fd563" +content-hash = "b54ff38df6da37302c0517493eeedc366a91ccbb2f4ef7cec1185e0fd83b6e3e" diff --git a/pyproject.toml b/pyproject.toml index 0f8cca5..fbc9141 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ pytest-asyncio = "^1.1.0" # ---------------------------- # Ruff 설정 # ---------------------------- +pytest-cov = "^6.2.1" [tool.ruff] line-length = 120 exclude = [ From a589add570970cae2f65ed897aea43e49a62614b Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 27 Jul 2025 02:17:53 +0900 Subject: [PATCH 025/220] =?UTF-8?q?feat:=20=EB=8F=99=EC=A0=81=20=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=ED=95=A0=EB=8B=B9=20->=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/port.py | 23 ----------------------- app/main.py | 16 +++------------- 2 files changed, 3 insertions(+), 36 deletions(-) delete mode 100644 app/core/port.py diff --git a/app/core/port.py b/app/core/port.py deleted file mode 100644 index 8305493..0000000 --- a/app/core/port.py +++ /dev/null @@ -1,23 +0,0 @@ -# app/core/port.py - -import os -import socket - - -def get_available_port(default: int = 8000) -> int: - """ - 환경변수 'PORT'가 존재하면 해당 포트를 사용하고, - 없다면 시스템이 할당한 사용 가능한 포트를 반환합니다. - """ - port_from_env = os.getenv("PORT") - - if port_from_env: - print(f"Using port from environment variable: {port_from_env}") - return int(port_from_env) - - # 포트 0 바인딩 → 시스템이 사용 가능한 포트 할당 - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("0.0.0.0", 0)) - assigned_port = s.getsockname()[1] - print(f"Dynamically assigned port: {assigned_port}") - return assigned_port diff --git a/app/main.py b/app/main.py index c20f561..831928a 100644 --- a/app/main.py +++ b/app/main.py @@ -4,12 +4,11 @@ from fastapi import FastAPI from app.api import ( - connections, + connect_driver, # 드라이버 확인 health, # 헬스 체크 ) from app.api.api_router import api_router from app.core.exceptions import APIException, api_exception_handler, generic_exception_handler -from app.core.port import get_available_port # 동적 포트 할당 app = FastAPI() @@ -17,21 +16,12 @@ app.add_exception_handler(APIException, api_exception_handler) app.add_exception_handler(Exception, generic_exception_handler) -# 드라이버 확인 라우터 -app.include_router(connections.router) - # 라우터 app.include_router(health.router) app.include_router(api_router, prefix="/api") - - -@app.get("/") -async def read_root(): - return {"message": "Hello, FastAPI Backend!"} +app.include_router(connect_driver.router, prefix="/api") if __name__ == "__main__": - # 동적 할당 로직 - port = get_available_port() # Uvicorn 서버를 시작합니다. - uvicorn.run(app, host="0.0.0.0", port=port) + uvicorn.run(app, host="0.0.0.0", port=39722) From 8283e13faebd0899a0389c12ce0b4c0e2c993555 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 27 Jul 2025 02:22:02 +0900 Subject: [PATCH 026/220] =?UTF-8?q?feat:=20API=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EB=93=9C=EB=9D=BC=EC=9D=B4=EB=B2=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(DriverEnum)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/connect_driver.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 app/api/connect_driver.py diff --git a/app/api/connect_driver.py b/app/api/connect_driver.py new file mode 100644 index 0000000..d2e1ff8 --- /dev/null +++ b/app/api/connect_driver.py @@ -0,0 +1,19 @@ +# app/api/connect_driver.py + +from fastapi import APIRouter + +from app.schemas.driver_info import DriverEnum, DriverInfoResponse +from app.services.driver_info_provider import db_driver_info + +router = APIRouter(tags=["Driver"]) + + +@router.get( + "/connections/drivers/{driver_id}", + summary="DB 드라이버 정보 조회 API", + response_model=DriverInfoResponse, +) +def read_driver_info(driver_id: DriverEnum): + module = driver_id.driver_module + db_type = driver_id.value + return db_driver_info(db_type, module) From 65b9a5230cc99b861f6bf41d8298380df83e79d9 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 27 Jul 2025 02:25:02 +0900 Subject: [PATCH 027/220] =?UTF-8?q?feat:=20DriverEnum=20=EB=B0=8F=20Driver?= =?UTF-8?q?Info=20=EC=9D=91=EB=8B=B5=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/driver_info.py | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 app/schemas/driver_info.py diff --git a/app/schemas/driver_info.py b/app/schemas/driver_info.py new file mode 100644 index 0000000..5478ba4 --- /dev/null +++ b/app/schemas/driver_info.py @@ -0,0 +1,42 @@ +# app/schemas/driver_schemas.py +from enum import Enum + +from pydantic import BaseModel + + +class DriverEnum(str, Enum): + postgresql = ("postgresql", "psycopg2") + mysql = ("mysql", "mysql.connector") + sqlite = ("sqlite", "sqlite3") + oracle = ("oracle", "cx_Oracle") + sqlserver = ("sqlserver", "pyodbc") + mariadb = ("mariadb", "pymysql") + + def __new__(cls, db_type, module): + obj = str.__new__(cls, db_type) + obj._value_ = db_type # 초기 유효성 검사 값 + obj.driver_module = module # db_type에 맞는 드라이버 + return obj + + +class DriverInfo(BaseModel): + db_type: str + is_installed: bool + driver_name: str | None + driver_version: str | None + driver_size_bytes: int | None + + +class DriverInfoResponse(BaseModel): + message: str + data: DriverInfo | None = None + + @classmethod + def success(cls, value: DriverInfo): + return cls(message="드라이버 정보를 성공적으로 불러왔습니다.", data=value) + + @classmethod + def error(cls, e: Exception | None = None): + if e: + print(f"[ERROR] {type(e).__name__}: {e}") # 로그 + return cls(message="드라이버 정보를 가져오지 못했습니다. 다시 시도해주세요.", data=None) From 36fb86b6eb19d3d04a9267e3453c7fdb743140c6 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 27 Jul 2025 02:27:22 +0900 Subject: [PATCH 028/220] =?UTF-8?q?refactor:=20=EB=93=9C=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=20=EC=A0=95=EB=B3=B4=20=EC=9D=91=EB=8B=B5=EC=9D=84=20?= =?UTF-8?q?dict=20=E2=86=92=20=EB=AA=A8=EB=8D=B8=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/driver_info_provider.py | 57 ++++++++++------------------ 1 file changed, 19 insertions(+), 38 deletions(-) diff --git a/app/services/driver_info_provider.py b/app/services/driver_info_provider.py index 5fd0716..f702735 100644 --- a/app/services/driver_info_provider.py +++ b/app/services/driver_info_provider.py @@ -1,45 +1,26 @@ +# app/service/driver_info_provider.py + import importlib -import logging import os -DRIVER_MAP = { - "postgresql": ["psycopg2", "pg8000"], - "mysql": ["pymysql", "mysql.connector"], - "sqlite": ["sqlite3"], - "oracle": ["cx_Oracle"], - "sqlserver": ["pyodbc"], - "mariadb": ["pymysql", "mysql.connector"], -} - - -def db_driver_info(driver_id: str) -> dict: - driver_key = driver_id.lower() - module_names = DRIVER_MAP.get(driver_key) +from app.schemas.driver_info import DriverInfo, DriverInfoResponse - if not module_names: - # 지원되지 않는 DB 타입 - return {"message": "지원되지 않는 DB입니다.", "data": None} - for mod_name in module_names: - try: - mod = importlib.import_module(mod_name) - version = getattr(mod, "__version__", None) - path = getattr(mod.__spec__, "origin", None) - size = os.path.getsize(path) if path else None +def db_driver_info(db_type: str, module_name: str) -> DriverInfoResponse: + try: + mod = importlib.import_module(module_name) + version = getattr(mod, "__version__", None) + path = getattr(mod.__spec__, "origin", None) + size = os.path.getsize(path) if path else None - return { - "message": "드라이버 정보를 성공적으로 불러왔습니다.", - "data": { - "db_type": driver_id, - "is_installed": True, - "driver_name": mod_name, - "driver_version": version, - "driver_size_bytes": size, - }, - } - except (ModuleNotFoundError, AttributeError, OSError) as e: - logging.warning(f"드라이버 '{mod_name}' import 실패: {e}") - continue + info = DriverInfo( + db_type=db_type, + is_installed=True, + driver_name=module_name, + driver_version=version, + driver_size_bytes=size, + ) + return DriverInfoResponse.success(info) - # import 실패한 경우 - return {"message": "드라이버 정보를 가져오지 못했습니다. 다시 시도해주세요.", "data": None} + except (ModuleNotFoundError, AttributeError, OSError) as e: + return DriverInfoResponse.error(e) From 0d1a86f79495fa0cc4919cb812d17f770470b6fe Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 27 Jul 2025 02:28:33 +0900 Subject: [PATCH 029/220] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=EB=AA=85?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD(connectioins.py=20->=20connect=5Fdriver.p?= =?UTF-8?q?y)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/connections.py | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 app/api/connections.py diff --git a/app/api/connections.py b/app/api/connections.py deleted file mode 100644 index ce99b9b..0000000 --- a/app/api/connections.py +++ /dev/null @@ -1,10 +0,0 @@ -from fastapi import APIRouter - -from app.services.driver_info_provider import db_driver_info - -router = APIRouter() - - -@router.get("/connections/drivers/{driverId}") -def read_driver_info(driverId: str): - return db_driver_info(driverId) From d4475db37370f2cf116a863790f4d5714741cbb3 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 27 Jul 2025 02:29:24 +0900 Subject: [PATCH 030/220] =?UTF-8?q?refactor:=20=EB=B3=80=EA=B2=BD=EB=90=9C?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/tests/api/test_driver_info_api.py | 18 ++++---- app/tests/unit/test_driver_info_unit.py | 55 +++++++++---------------- 2 files changed, 31 insertions(+), 42 deletions(-) diff --git a/app/tests/api/test_driver_info_api.py b/app/tests/api/test_driver_info_api.py index ece56f3..f64083a 100644 --- a/app/tests/api/test_driver_info_api.py +++ b/app/tests/api/test_driver_info_api.py @@ -6,22 +6,26 @@ def test_api_supported_driver(): - response = client.get("/connections/drivers/mysql") + response = client.get("api/connections/drivers/mysql") assert response.status_code == 200 + body = response.json() assert body["message"] == "드라이버 정보를 성공적으로 불러왔습니다." assert body["data"]["db_type"] == "mysql" assert body["data"]["is_installed"] is True + assert body["data"]["driver_name"] == "mysql.connector" + assert isinstance(body["data"]["driver_size_bytes"], int) def test_api_unsupported_driver(): - response = client.get("/connections/drivers/unknown-db") - assert response.status_code == 200 + response = client.get("api/connections/drivers/unknown-db") + assert response.status_code == 422 # ❗ Enum validation이 터짐 + body = response.json() - assert body["message"] == "지원되지 않는 DB입니다." - assert body["data"] is None + assert body["detail"][0]["msg"].startswith("Input should be") + assert body["detail"][0]["loc"] == ["path", "driver_id"] def test_api_empty_driver(): - response = client.get("/connections/drivers/") - assert response.status_code in [404, 422] # 경로 누락이므로 상태코드로 판단 + response = client.get("api/connections/drivers/") # 누락된 경로 + assert response.status_code in [404, 422] diff --git a/app/tests/unit/test_driver_info_unit.py b/app/tests/unit/test_driver_info_unit.py index d85e044..93b53bd 100644 --- a/app/tests/unit/test_driver_info_unit.py +++ b/app/tests/unit/test_driver_info_unit.py @@ -1,47 +1,32 @@ -import pytest +from unittest import mock +from app.api.connect_driver import DriverEnum from app.services.driver_info_provider import db_driver_info -# 설치된 DB 드라이버 중 하나를 테스트 (환경에 따라 달라질 수 있음) +# 드라이버 설치 되었을 때 def test_supported_driver_installed(): - result = db_driver_info("mysql") - assert result["message"] == "드라이버 정보를 성공적으로 불러왔습니다." - assert result["data"] is not None - assert result["data"]["db_type"] == "mysql" - assert result["data"]["is_installed"] is True - assert result["data"]["driver_name"] in ["pymysql", "mysql.connector"] + driver = DriverEnum.mysql + result = db_driver_info(driver.value, driver.driver_module) + print(result) -# 존재하지 않는 DB 타입을 넘겼을 때 -def test_unsupported_driver(): - result = db_driver_info("unknown-db") - assert result["message"] == "지원되지 않는 DB입니다." - assert result["data"] is None +# 존재하지 않는 DB 타입을 넘겼을 때 (Enum 변환 실패) +def test_invalid_enum_value(): + try: + DriverEnum("nonexistent-driver") + assert False # 실패하지 않으면 오류! + except ValueError as e: + print(f"[Enum 변환 실패] {e}") # 콘솔에 예외 메시지 출력 + assert True -# 빈 값 넘겼을 때 -def test_empty_input(): - result = db_driver_info("") - assert result["message"] == "지원되지 않는 DB입니다." - assert result["data"] is None +# 빈 값 넘겼을 때 -> 제거: Enum 유효성 검사로 인해 필요성 없음 -# 지원은 하지만 환경에 설치되지 않은 드라이버를 일부러 테스트 -@pytest.mark.skip(reason="환경에 따라 설치 여부가 다르므로 건너뜀") -def test_supported_but_not_installed(): - result = db_driver_info("oracle") - assert result["message"] in [ - "드라이버 정보를 성공적으로 불러왔습니다.", - "드라이버 정보를 가져오지 못했습니다. 다시 시도해주세요.", - ] - - -# 함수 강제 실패 테스트 -def test_import_fails_and_fallback_message(): - from unittest import mock - +# importlib 실패 상황 테스트 +def test_driver_import_failure_at_api(): with mock.patch("importlib.import_module", side_effect=ModuleNotFoundError("모듈 없음")): - result = db_driver_info("mysql") - assert result["message"] == "드라이버 정보를 가져오지 못했습니다. 다시 시도해주세요." - assert result["data"] is None + driver = DriverEnum.mysql + result = db_driver_info(driver.value, driver.driver_module) + print(result) From 1450d6699853bd2ee77c41d042defe80d0952411 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 27 Jul 2025 02:30:27 +0900 Subject: [PATCH 031/220] =?UTF-8?q?docs:=20Health=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=84=B0=EC=97=90=20Swagger=20=ED=83=9C=EA=B7=B8=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/health.py b/app/api/health.py index f88e530..2b20245 100644 --- a/app/api/health.py +++ b/app/api/health.py @@ -1,7 +1,7 @@ # app/api/health.py from fastapi import APIRouter -router = APIRouter() +router = APIRouter(tags=["Health"]) @router.get("/health") From 2170ad99d8f26e11035ed04d3c2a57d77a8a27a7 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 27 Jul 2025 02:32:45 +0900 Subject: [PATCH 032/220] =?UTF-8?q?docs:=20README.md=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(=EB=8F=99=EC=A0=81=20=ED=8F=AC=ED=8A=B8=20=ED=95=A0?= =?UTF-8?q?=EB=8B=B9->=20=EA=B3=A0=EC=A0=95=ED=8F=AC=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3c87aee..aa63744 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ 또는 Poetry Run을 사용하여 직접 실행할 수 있습니다. ```bash - poetry run uvicorn main:app --reload + uvicorn main:app --host 0.0.0.0 --port 39722 ``` ### **코드 컨벤션 (PEP 8, Ruff, Black)** @@ -141,9 +141,9 @@ 1. **브라우저 확인** - - 기본 루트 엔드포인트: - - 헬스 체크 엔드포인트: - - API 문서: + - 기본 루트 엔드포인트: + - 헬스 체크 엔드포인트: + - API 문서: 2. **CLI로 접속 확인하기** @@ -151,14 +151,13 @@ - 기본 루트 엔드포인트: ```bash - curl http://localhost:8000/ + curl http://localhost:39722/ ``` - 헬스 체크 엔드포인트: ```bash - curl http://localhost:8000/health + curl http://localhost:39722/health ``` - API 문서: ```bash - curl http://localhost:8000/openapi.json + curl http://localhost:39722/openapi.json ``` - From c0e76517a8246bc31003db64dba267f6cd833c86 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 27 Jul 2025 03:05:17 +0900 Subject: [PATCH 033/220] =?UTF-8?q?docs:=20README.md=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(=EB=8F=99=EC=A0=81=20=ED=8F=AC=ED=8A=B8=20=ED=95=A0?= =?UTF-8?q?=EB=8B=B9->=20=EA=B3=A0=EC=A0=95=ED=8F=AC=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index aa63744..2295f16 100644 --- a/README.md +++ b/README.md @@ -88,13 +88,13 @@ ```bash poetry shell - uvicorn app.main:app --reload + uvicorn app.main:app --host 0.0.0.0 --port 39722 --reload ``` 또는 Poetry Run을 사용하여 직접 실행할 수 있습니다. ```bash - uvicorn main:app --host 0.0.0.0 --port 39722 + poetry run uvicorn main:app --host 0.0.0.0 --port 39722 --reload ``` ### **코드 컨벤션 (PEP 8, Ruff, Black)** From c63189a107013f6d612191a47fa5f97b237e7b92 Mon Sep 17 00:00:00 2001 From: mini Date: Sat, 2 Aug 2025 21:31:04 +0900 Subject: [PATCH 034/220] =?UTF-8?q?refactor:=20connect=5Fdriver=20router?= =?UTF-8?q?=20->=20api=5Frouter=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 5 +++-- app/main.py | 6 +----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/app/api/api_router.py b/app/api/api_router.py index 4c92561..f633400 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -1,10 +1,11 @@ from fastapi import APIRouter -from app.api import test_api +from app.api import connect_driver, test_api + api_router = APIRouter() # 테스트 라우터 api_router.include_router(test_api.router, prefix="/test", tags=["Test"]) # 라우터 -# api_router.include_router(connect_driver.router, prefix="/connections", tags=["Driver"]) \ No newline at end of file +api_router.include_router(connect_driver.router, prefix="/connections", tags=["Driver"]) diff --git a/app/main.py b/app/main.py index 831928a..c3d6dd2 100644 --- a/app/main.py +++ b/app/main.py @@ -3,10 +3,7 @@ import uvicorn from fastapi import FastAPI -from app.api import ( - connect_driver, # 드라이버 확인 - health, # 헬스 체크 -) +from app.api import health # 헬스 체크 from app.api.api_router import api_router from app.core.exceptions import APIException, api_exception_handler, generic_exception_handler @@ -19,7 +16,6 @@ # 라우터 app.include_router(health.router) app.include_router(api_router, prefix="/api") -app.include_router(connect_driver.router, prefix="/api") if __name__ == "__main__": From 4c3b761e6f21a76afd7fd0424cdaf126f1bc49db Mon Sep 17 00:00:00 2001 From: mini Date: Sat, 2 Aug 2025 21:32:17 +0900 Subject: [PATCH 035/220] =?UTF-8?q?feat:=20=20422=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index 790717d..7803aa4 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -1,6 +1,8 @@ from enum import Enum + from fastapi import status + class CommonCode(Enum): """ 애플리케이션의 모든 상태 코드를 중앙에서 관리합니다. @@ -21,13 +23,13 @@ class CommonCode(Enum): NO_VALUE = (status.HTTP_400_BAD_REQUEST, "4000", "필수 값이 존재하지 않습니다.") DUPLICATION = (status.HTTP_409_CONFLICT, "4001", "이미 존재하는 데이터입니다.") NO_SEARCH_DATA = (status.HTTP_404_NOT_FOUND, "4002", "요청한 데이터를 찾을 수 없습니다.") + INVALID_ENUM_VALUE = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4003", "유효하지 않은 열거형 값입니다.") # ================================== # 서버 오류 (Server Error) - 5xx # ================================== FAIL = (status.HTTP_500_INTERNAL_SERVER_ERROR, "9999", "서버 처리 중 오류가 발생했습니다.") - def __init__(self, http_status: int, code: str, message: str): """Enum 멤버가 생성될 때 각 값을 속성으로 할당합니다.""" self.http_status = http_status @@ -39,4 +41,3 @@ def get_message(self, *args) -> str: 메시지 포맷팅이 필요한 경우, 인자를 받아 완성된 메시지를 반환합니다. """ return self.message % args if args else self.message - From e3fbb4b29f617984e651c98b6b19d7d4c645f4f9 Mon Sep 17 00:00:00 2001 From: mini Date: Sat, 2 Aug 2025 21:32:58 +0900 Subject: [PATCH 036/220] =?UTF-8?q?refactor:=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/connect_driver.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/app/api/connect_driver.py b/app/api/connect_driver.py index d2e1ff8..f5f8495 100644 --- a/app/api/connect_driver.py +++ b/app/api/connect_driver.py @@ -2,18 +2,22 @@ from fastapi import APIRouter -from app.schemas.driver_info import DriverEnum, DriverInfoResponse +from app.assets.driver_enum import DriverEnum +from app.core.exceptions import APIException +from app.core.status import CommonCode +from app.schemas.driver_info import DriverInfo +from app.schemas.response import ResponseMessage from app.services.driver_info_provider import db_driver_info -router = APIRouter(tags=["Driver"]) +router = APIRouter() -@router.get( - "/connections/drivers/{driver_id}", - summary="DB 드라이버 정보 조회 API", - response_model=DriverInfoResponse, -) -def read_driver_info(driver_id: DriverEnum): - module = driver_id.driver_module - db_type = driver_id.value - return db_driver_info(db_type, module) +@router.get("/drivers/{driverId}", response_model=ResponseMessage[DriverInfo], summary="DB 드라이버 정보 조회 API") +def read_driver_info(driverId: str): + """DB 드라이버 정보 조회""" + for driver in DriverEnum: + if driver.db_type == driverId: + return ResponseMessage.success(value=db_driver_info(driver.db_type, driver.driver_module)) + + # db_type 초기 유효성 검사 실패시 + raise APIException(CommonCode.INVALID_ENUM_VALUE) From a5d622ab591ba182b92d10a08ac3900392b074eb Mon Sep 17 00:00:00 2001 From: mini Date: Sat, 2 Aug 2025 21:34:35 +0900 Subject: [PATCH 037/220] =?UTF-8?q?feat:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=95=EB=A6=AC=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=AA=A8=EB=8D=B8=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?Enum=20=EB=B3=80=EC=88=98->=20=EA=B0=9D=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/assets/driver_enum.py | 20 ++++++++++++ app/schemas/driver_info.py | 49 +++++++++++----------------- app/services/driver_info_provider.py | 29 +++++----------- 3 files changed, 47 insertions(+), 51 deletions(-) create mode 100644 app/assets/driver_enum.py diff --git a/app/assets/driver_enum.py b/app/assets/driver_enum.py new file mode 100644 index 0000000..4560ff3 --- /dev/null +++ b/app/assets/driver_enum.py @@ -0,0 +1,20 @@ +# app/assets/driver_enum.py +from enum import Enum + + +class DriverEnum(Enum): + """지원되는 데이터베이스 드라이버 타입""" + + postgresql = ("postgresql", "psycopg2") + mysql = ("mysql", "mysql.connector") + sqlite = ("sqlite", "sqlite3") + oracle = ("oracle", "cx_Oracle") + sqlserver = ("sqlserver", "pyodbc") + mariadb = ("mariadb", "pymysql") + + def __init__(self, db_type, driver_module): + self.db_type = db_type + self.driver_module = driver_module + + def __str__(self): + return self.db_type diff --git a/app/schemas/driver_info.py b/app/schemas/driver_info.py index 5478ba4..e32f96c 100644 --- a/app/schemas/driver_info.py +++ b/app/schemas/driver_info.py @@ -1,24 +1,10 @@ -# app/schemas/driver_schemas.py -from enum import Enum +# app/schemas/driver_info.py +import importlib +import os from pydantic import BaseModel -class DriverEnum(str, Enum): - postgresql = ("postgresql", "psycopg2") - mysql = ("mysql", "mysql.connector") - sqlite = ("sqlite", "sqlite3") - oracle = ("oracle", "cx_Oracle") - sqlserver = ("sqlserver", "pyodbc") - mariadb = ("mariadb", "pymysql") - - def __new__(cls, db_type, module): - obj = str.__new__(cls, db_type) - obj._value_ = db_type # 초기 유효성 검사 값 - obj.driver_module = module # db_type에 맞는 드라이버 - return obj - - class DriverInfo(BaseModel): db_type: str is_installed: bool @@ -26,17 +12,20 @@ class DriverInfo(BaseModel): driver_version: str | None driver_size_bytes: int | None - -class DriverInfoResponse(BaseModel): - message: str - data: DriverInfo | None = None - - @classmethod - def success(cls, value: DriverInfo): - return cls(message="드라이버 정보를 성공적으로 불러왔습니다.", data=value) - @classmethod - def error(cls, e: Exception | None = None): - if e: - print(f"[ERROR] {type(e).__name__}: {e}") # 로그 - return cls(message="드라이버 정보를 가져오지 못했습니다. 다시 시도해주세요.", data=None) + def from_module(cls, db_type: str, module_name: str): + """모듈 이름으로부터 DriverInfo 객체를 생성하는 팩토리 메서드""" + # 서비스에 있던 로직을 이곳으로 이동 + mod = importlib.import_module(module_name) + version = getattr(mod, "__version__", None) + path = getattr(mod.__spec__, "origin", None) + size = os.path.getsize(path) if path else None + + # 자기 자신의 인스턴스를 생성하여 반환 + return cls( + db_type=db_type, + is_installed=True, + driver_name=module_name, + driver_version=version, + driver_size_bytes=size, + ) diff --git a/app/services/driver_info_provider.py b/app/services/driver_info_provider.py index f702735..cf6e13a 100644 --- a/app/services/driver_info_provider.py +++ b/app/services/driver_info_provider.py @@ -1,26 +1,13 @@ # app/service/driver_info_provider.py +from app.core.exceptions import APIException +from app.core.status import CommonCode +from app.schemas.driver_info import DriverInfo -import importlib -import os -from app.schemas.driver_info import DriverInfo, DriverInfoResponse - - -def db_driver_info(db_type: str, module_name: str) -> DriverInfoResponse: +def db_driver_info(db_type: str, module_name: str): try: - mod = importlib.import_module(module_name) - version = getattr(mod, "__version__", None) - path = getattr(mod.__spec__, "origin", None) - size = os.path.getsize(path) if path else None - - info = DriverInfo( - db_type=db_type, - is_installed=True, - driver_name=module_name, - driver_version=version, - driver_size_bytes=size, - ) - return DriverInfoResponse.success(info) + info = DriverInfo.from_module(db_type, module_name) + return info - except (ModuleNotFoundError, AttributeError, OSError) as e: - return DriverInfoResponse.error(e) + except (ModuleNotFoundError, AttributeError, OSError): + raise APIException(CommonCode.FAIL) From 4b3927b9a54699284ab48fec955f2e88b99717f8 Mon Sep 17 00:00:00 2001 From: mini Date: Sat, 2 Aug 2025 23:34:33 +0900 Subject: [PATCH 038/220] =?UTF-8?q?refactor:=20driver=5Fenum.py=20/core=20?= =?UTF-8?q?=EC=95=84=EB=9E=98=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/connect_driver.py | 2 +- app/{assets => core}/driver_enum.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename app/{assets => core}/driver_enum.py (100%) diff --git a/app/api/connect_driver.py b/app/api/connect_driver.py index f5f8495..aba4958 100644 --- a/app/api/connect_driver.py +++ b/app/api/connect_driver.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from app.assets.driver_enum import DriverEnum +from app.core.driver_enum import DriverEnum from app.core.exceptions import APIException from app.core.status import CommonCode from app.schemas.driver_info import DriverInfo diff --git a/app/assets/driver_enum.py b/app/core/driver_enum.py similarity index 100% rename from app/assets/driver_enum.py rename to app/core/driver_enum.py From 5fa935869e694e0c6446a53b6e229be6a3134c4f Mon Sep 17 00:00:00 2001 From: mini Date: Sat, 2 Aug 2025 23:34:55 +0900 Subject: [PATCH 039/220] =?UTF-8?q?refactor:=20422=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=A2=81=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/status.py b/app/core/status.py index 7803aa4..bd52104 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -23,7 +23,7 @@ class CommonCode(Enum): NO_VALUE = (status.HTTP_400_BAD_REQUEST, "4000", "필수 값이 존재하지 않습니다.") DUPLICATION = (status.HTTP_409_CONFLICT, "4001", "이미 존재하는 데이터입니다.") NO_SEARCH_DATA = (status.HTTP_404_NOT_FOUND, "4002", "요청한 데이터를 찾을 수 없습니다.") - INVALID_ENUM_VALUE = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4003", "유효하지 않은 열거형 값입니다.") + INVALID_ENUM_VALUE = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4003", "지원하지 않는 데이터베이스 값입니다.") # ================================== # 서버 오류 (Server Error) - 5xx From 2250308107d32f4752274d7a7a46c7ee85bf7426 Mon Sep 17 00:00:00 2001 From: mini Date: Sat, 2 Aug 2025 23:35:31 +0900 Subject: [PATCH 040/220] =?UTF-8?q?refactor:=20=EB=B3=80=EC=88=98=EB=8B=B4?= =?UTF-8?q?=EC=95=84=EC=84=9C=20=EB=B0=98=ED=99=98=ED=96=88=EB=8D=98=20?= =?UTF-8?q?=EA=B1=B8=20=EB=B3=80=EC=88=98=20=EC=A0=9C=EA=B1=B0=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/driver_info_provider.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/services/driver_info_provider.py b/app/services/driver_info_provider.py index cf6e13a..63905da 100644 --- a/app/services/driver_info_provider.py +++ b/app/services/driver_info_provider.py @@ -6,8 +6,7 @@ def db_driver_info(db_type: str, module_name: str): try: - info = DriverInfo.from_module(db_type, module_name) - return info + return DriverInfo.from_module(db_type, module_name) except (ModuleNotFoundError, AttributeError, OSError): raise APIException(CommonCode.FAIL) From a988e61b1def041db3c13e44bf57f67794e91dea Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 3 Aug 2025 17:50:33 +0900 Subject: [PATCH 041/220] =?UTF-8?q?feat:=20=EB=93=9C=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=20=EC=A0=95=EB=B3=B4=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/connect_driver.py | 16 ++++++++++------ app/schemas/driver_info.py | 28 +++++++++++++++------------- app/services/driver_info_provider.py | 12 ++++++++++-- 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/app/api/connect_driver.py b/app/api/connect_driver.py index aba4958..815d2a8 100644 --- a/app/api/connect_driver.py +++ b/app/api/connect_driver.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from app.core.driver_enum import DriverEnum +from app.core.db_driver_enum import DBTypesEnum from app.core.exceptions import APIException from app.core.status import CommonCode from app.schemas.driver_info import DriverInfo @@ -15,9 +15,13 @@ @router.get("/drivers/{driverId}", response_model=ResponseMessage[DriverInfo], summary="DB 드라이버 정보 조회 API") def read_driver_info(driverId: str): """DB 드라이버 정보 조회""" - for driver in DriverEnum: - if driver.db_type == driverId: - return ResponseMessage.success(value=db_driver_info(driver.db_type, driver.driver_module)) + try: + # DBTypesEnum 객체를 한 줄로 가져옵니다. + db_type_enum = DBTypesEnum[driverId.lower()] - # db_type 초기 유효성 검사 실패시 - raise APIException(CommonCode.INVALID_ENUM_VALUE) + return ResponseMessage.success( + value=db_driver_info(DriverInfo.from_driver_info(db_type=db_type_enum.name, driver_name=db_type_enum.value)) + ) + # db_type 유효성 검사 실패시 + except KeyError: + raise APIException(CommonCode.INVALID_ENUM_VALUE) diff --git a/app/schemas/driver_info.py b/app/schemas/driver_info.py index e32f96c..ca0936c 100644 --- a/app/schemas/driver_info.py +++ b/app/schemas/driver_info.py @@ -1,7 +1,4 @@ # app/schemas/driver_info.py -import importlib -import os - from pydantic import BaseModel @@ -13,19 +10,24 @@ class DriverInfo(BaseModel): driver_size_bytes: int | None @classmethod - def from_module(cls, db_type: str, module_name: str): - """모듈 이름으로부터 DriverInfo 객체를 생성하는 팩토리 메서드""" - # 서비스에 있던 로직을 이곳으로 이동 - mod = importlib.import_module(module_name) - version = getattr(mod, "__version__", None) - path = getattr(mod.__spec__, "origin", None) - size = os.path.getsize(path) if path else None - - # 자기 자신의 인스턴스를 생성하여 반환 + def from_module( + cls, db_type: str, driver_name: str, version: str | None, size: int | None + ): # 자기 자신의 인스턴스를 생성하여 반환 return cls( db_type=db_type, is_installed=True, - driver_name=module_name, + driver_name=driver_name, driver_version=version, driver_size_bytes=size, ) + + @classmethod + def from_driver_info(cls, db_type: str, driver_name: str): + # 최소한의 정보로 객체를 생성할 때 사용 + return cls( + db_type=db_type, + is_installed=False, + driver_name=driver_name, + driver_version=None, + driver_size_bytes=None, + ) diff --git a/app/services/driver_info_provider.py b/app/services/driver_info_provider.py index 63905da..f2ffcd1 100644 --- a/app/services/driver_info_provider.py +++ b/app/services/driver_info_provider.py @@ -1,12 +1,20 @@ # app/service/driver_info_provider.py +import importlib +import os + from app.core.exceptions import APIException from app.core.status import CommonCode from app.schemas.driver_info import DriverInfo -def db_driver_info(db_type: str, module_name: str): +def db_driver_info(driver_info: DriverInfo): try: - return DriverInfo.from_module(db_type, module_name) + mod = importlib.import_module(driver_info.driver_name) + version = getattr(mod, "__version__", None) + path = getattr(mod.__spec__, "origin", None) + size = os.path.getsize(path) if path else None + + return DriverInfo.from_module(driver_info.db_type, driver_info.driver_name, version, size) except (ModuleNotFoundError, AttributeError, OSError): raise APIException(CommonCode.FAIL) From 21bf04f0395c14004fc760f7e1bc0337a8e59606 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 3 Aug 2025 17:52:29 +0900 Subject: [PATCH 042/220] =?UTF-8?q?refactor:=20DB=20=EB=93=9C=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B2=84=20Enum=20=EA=B0=92=20=EB=8B=A8=EC=9D=BC?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/db_driver_enum.py | 13 +++++++++++++ app/core/driver_enum.py | 20 -------------------- 2 files changed, 13 insertions(+), 20 deletions(-) create mode 100644 app/core/db_driver_enum.py delete mode 100644 app/core/driver_enum.py diff --git a/app/core/db_driver_enum.py b/app/core/db_driver_enum.py new file mode 100644 index 0000000..6896760 --- /dev/null +++ b/app/core/db_driver_enum.py @@ -0,0 +1,13 @@ +# app/core/db_driver_enum.py +from enum import Enum + + +class DBTypesEnum(Enum): + """지원되는 데이터베이스 드라이버 타입""" + + postgresql = "psycopg2" + mysql = "mysql.connector" + sqlite = "sqlite3" + oracle = "cx_Oracle" + sqlserver = "pyodbc" + mariadb = "pymysql" diff --git a/app/core/driver_enum.py b/app/core/driver_enum.py deleted file mode 100644 index 4560ff3..0000000 --- a/app/core/driver_enum.py +++ /dev/null @@ -1,20 +0,0 @@ -# app/assets/driver_enum.py -from enum import Enum - - -class DriverEnum(Enum): - """지원되는 데이터베이스 드라이버 타입""" - - postgresql = ("postgresql", "psycopg2") - mysql = ("mysql", "mysql.connector") - sqlite = ("sqlite", "sqlite3") - oracle = ("oracle", "cx_Oracle") - sqlserver = ("sqlserver", "pyodbc") - mariadb = ("mariadb", "pymysql") - - def __init__(self, db_type, driver_module): - self.db_type = db_type - self.driver_module = driver_module - - def __str__(self): - return self.db_type From 6120bc2d0f6b7d798e53659d2a0be639fcae087b Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 3 Aug 2025 21:40:12 +0900 Subject: [PATCH 043/220] =?UTF-8?q?refactor:=20=EB=9D=BC=EC=9A=B0=ED=84=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20from=5Fenum=20=ED=8C=A9=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/connect_driver.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/api/connect_driver.py b/app/api/connect_driver.py index 815d2a8..bc9b132 100644 --- a/app/api/connect_driver.py +++ b/app/api/connect_driver.py @@ -18,10 +18,7 @@ def read_driver_info(driverId: str): try: # DBTypesEnum 객체를 한 줄로 가져옵니다. db_type_enum = DBTypesEnum[driverId.lower()] - - return ResponseMessage.success( - value=db_driver_info(DriverInfo.from_driver_info(db_type=db_type_enum.name, driver_name=db_type_enum.value)) - ) + return ResponseMessage.success(value=db_driver_info(DriverInfo.from_enum(db_type_enum))) # db_type 유효성 검사 실패시 except KeyError: raise APIException(CommonCode.INVALID_ENUM_VALUE) From aa7d8c1d46c4b945bfd02d620f514d80534be4aa Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 3 Aug 2025 21:40:36 +0900 Subject: [PATCH 044/220] =?UTF-8?q?feat:=20from=5Fenum=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/driver_info.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/schemas/driver_info.py b/app/schemas/driver_info.py index ca0936c..7447330 100644 --- a/app/schemas/driver_info.py +++ b/app/schemas/driver_info.py @@ -1,6 +1,8 @@ # app/schemas/driver_info.py from pydantic import BaseModel +from app.core.db_driver_enum import DBTypesEnum + class DriverInfo(BaseModel): db_type: str @@ -10,9 +12,11 @@ class DriverInfo(BaseModel): driver_size_bytes: int | None @classmethod - def from_module( - cls, db_type: str, driver_name: str, version: str | None, size: int | None - ): # 자기 자신의 인스턴스를 생성하여 반환 + def from_module(cls, db_type: str, driver_name: str, version: str | None, size: int | None): + """ + 설치된 드라이버의 모든 정보를 바탕으로 DriverInfo 객체를 생성합니다. + `is_installed`는 항상 True로 설정됩니다. + """ return cls( db_type=db_type, is_installed=True, @@ -23,7 +27,10 @@ def from_module( @classmethod def from_driver_info(cls, db_type: str, driver_name: str): - # 최소한의 정보로 객체를 생성할 때 사용 + """ + 최소한의 정보(DB 타입, 드라이버 이름)만으로 초기 DriverInfo 객체를 생성합니다. + `is_installed`는 False로 설정됩니다. + """ return cls( db_type=db_type, is_installed=False, @@ -31,3 +38,10 @@ def from_driver_info(cls, db_type: str, driver_name: str): driver_version=None, driver_size_bytes=None, ) + + @classmethod + def from_enum(cls, db_type_enum: DBTypesEnum): + """ + DBTypesEnum 객체를 인자로 받아, from_driver_info를 호출해 초기 객체를 생성합니다. + """ + return cls.from_driver_info(db_type=db_type_enum.name, driver_name=db_type_enum.value) From 32525412af41223769c6f8c62c6e7cd154cedcaf Mon Sep 17 00:00:00 2001 From: mini Date: Mon, 4 Aug 2025 00:09:15 +0900 Subject: [PATCH 045/220] =?UTF-8?q?refactor:=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=83=88=EC=83=9D=EC=84=B1=EC=9D=B4=20=EC=95=84=EB=8B=8C=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EA=B0=9D=EC=B2=B4=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=AA=85=20api,=20service=20=EC=9D=98?= =?UTF-8?q?=EB=AF=B8=20=EB=8F=99=EC=9D=BC=ED=95=98=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 4 +-- app/api/{connect_driver.py => driver_api.py} | 8 ++--- app/schemas/driver_info.py | 29 ++++++----------- ...ver_info_provider.py => driver_service.py} | 5 +-- app/tests/api/test_driver_info_api.py | 31 ------------------ app/tests/unit/test_driver_info_unit.py | 32 ------------------- 6 files changed, 18 insertions(+), 91 deletions(-) rename app/api/{connect_driver.py => driver_api.py} (78%) rename app/services/{driver_info_provider.py => driver_service.py} (79%) delete mode 100644 app/tests/api/test_driver_info_api.py delete mode 100644 app/tests/unit/test_driver_info_unit.py diff --git a/app/api/api_router.py b/app/api/api_router.py index f633400..1067cdc 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api import connect_driver, test_api +from app.api import driver_api, test_api api_router = APIRouter() @@ -8,4 +8,4 @@ api_router.include_router(test_api.router, prefix="/test", tags=["Test"]) # 라우터 -api_router.include_router(connect_driver.router, prefix="/connections", tags=["Driver"]) +api_router.include_router(driver_api.router, prefix="/connections", tags=["Driver"]) diff --git a/app/api/connect_driver.py b/app/api/driver_api.py similarity index 78% rename from app/api/connect_driver.py rename to app/api/driver_api.py index bc9b132..97a9e10 100644 --- a/app/api/connect_driver.py +++ b/app/api/driver_api.py @@ -1,4 +1,4 @@ -# app/api/connect_driver.py +# app/api/driver_api.py from fastapi import APIRouter @@ -7,7 +7,7 @@ from app.core.status import CommonCode from app.schemas.driver_info import DriverInfo from app.schemas.response import ResponseMessage -from app.services.driver_info_provider import db_driver_info +from app.services.driver_service import db_driver_info router = APIRouter() @@ -16,9 +16,9 @@ def read_driver_info(driverId: str): """DB 드라이버 정보 조회""" try: - # DBTypesEnum 객체를 한 줄로 가져옵니다. + # DBTypesEnum에서 driverID에 맞는 객체를 가져옵니다. db_type_enum = DBTypesEnum[driverId.lower()] return ResponseMessage.success(value=db_driver_info(DriverInfo.from_enum(db_type_enum))) - # db_type 유효성 검사 실패시 + # db_type_enum 유효성 검사 실패 except KeyError: raise APIException(CommonCode.INVALID_ENUM_VALUE) diff --git a/app/schemas/driver_info.py b/app/schemas/driver_info.py index 7447330..1fa41ca 100644 --- a/app/schemas/driver_info.py +++ b/app/schemas/driver_info.py @@ -11,26 +11,22 @@ class DriverInfo(BaseModel): driver_version: str | None driver_size_bytes: int | None - @classmethod - def from_module(cls, db_type: str, driver_name: str, version: str | None, size: int | None): + def update_from_module(self, version: str | None, size: int | None): """ - 설치된 드라이버의 모든 정보를 바탕으로 DriverInfo 객체를 생성합니다. - `is_installed`는 항상 True로 설정됩니다. + 객체 자신의 속성을 직접 업데이트하여 설치된 드라이버 정보를 채웁니다. """ - return cls( - db_type=db_type, - is_installed=True, - driver_name=driver_name, - driver_version=version, - driver_size_bytes=size, - ) + self.is_installed = True + self.driver_version = version + self.driver_size_bytes = size @classmethod - def from_driver_info(cls, db_type: str, driver_name: str): + def from_enum(cls, db_type_enum: DBTypesEnum): """ - 최소한의 정보(DB 타입, 드라이버 이름)만으로 초기 DriverInfo 객체를 생성합니다. + DBTypesEnum 객체를 인자로 받아, db_type, driver_name만으로 driverInfo 객체를 생성합니다. `is_installed`는 False로 설정됩니다. """ + db_type = db_type_enum.name + driver_name = db_type_enum.value return cls( db_type=db_type, is_installed=False, @@ -38,10 +34,3 @@ def from_driver_info(cls, db_type: str, driver_name: str): driver_version=None, driver_size_bytes=None, ) - - @classmethod - def from_enum(cls, db_type_enum: DBTypesEnum): - """ - DBTypesEnum 객체를 인자로 받아, from_driver_info를 호출해 초기 객체를 생성합니다. - """ - return cls.from_driver_info(db_type=db_type_enum.name, driver_name=db_type_enum.value) diff --git a/app/services/driver_info_provider.py b/app/services/driver_service.py similarity index 79% rename from app/services/driver_info_provider.py rename to app/services/driver_service.py index f2ffcd1..9f35092 100644 --- a/app/services/driver_info_provider.py +++ b/app/services/driver_service.py @@ -1,4 +1,4 @@ -# app/service/driver_info_provider.py +# app/service/driver_service.py import importlib import os @@ -14,7 +14,8 @@ def db_driver_info(driver_info: DriverInfo): path = getattr(mod.__spec__, "origin", None) size = os.path.getsize(path) if path else None - return DriverInfo.from_module(driver_info.db_type, driver_info.driver_name, version, size) + driver_info.update_from_module(version, size) + return driver_info except (ModuleNotFoundError, AttributeError, OSError): raise APIException(CommonCode.FAIL) diff --git a/app/tests/api/test_driver_info_api.py b/app/tests/api/test_driver_info_api.py deleted file mode 100644 index f64083a..0000000 --- a/app/tests/api/test_driver_info_api.py +++ /dev/null @@ -1,31 +0,0 @@ -from fastapi.testclient import TestClient - -from app.main import app # FastAPI 앱 객체 - -client = TestClient(app) - - -def test_api_supported_driver(): - response = client.get("api/connections/drivers/mysql") - assert response.status_code == 200 - - body = response.json() - assert body["message"] == "드라이버 정보를 성공적으로 불러왔습니다." - assert body["data"]["db_type"] == "mysql" - assert body["data"]["is_installed"] is True - assert body["data"]["driver_name"] == "mysql.connector" - assert isinstance(body["data"]["driver_size_bytes"], int) - - -def test_api_unsupported_driver(): - response = client.get("api/connections/drivers/unknown-db") - assert response.status_code == 422 # ❗ Enum validation이 터짐 - - body = response.json() - assert body["detail"][0]["msg"].startswith("Input should be") - assert body["detail"][0]["loc"] == ["path", "driver_id"] - - -def test_api_empty_driver(): - response = client.get("api/connections/drivers/") # 누락된 경로 - assert response.status_code in [404, 422] diff --git a/app/tests/unit/test_driver_info_unit.py b/app/tests/unit/test_driver_info_unit.py deleted file mode 100644 index 93b53bd..0000000 --- a/app/tests/unit/test_driver_info_unit.py +++ /dev/null @@ -1,32 +0,0 @@ -from unittest import mock - -from app.api.connect_driver import DriverEnum -from app.services.driver_info_provider import db_driver_info - - -# 드라이버 설치 되었을 때 -def test_supported_driver_installed(): - driver = DriverEnum.mysql - result = db_driver_info(driver.value, driver.driver_module) - print(result) - - -# 존재하지 않는 DB 타입을 넘겼을 때 (Enum 변환 실패) -def test_invalid_enum_value(): - try: - DriverEnum("nonexistent-driver") - assert False # 실패하지 않으면 오류! - except ValueError as e: - print(f"[Enum 변환 실패] {e}") # 콘솔에 예외 메시지 출력 - assert True - - -# 빈 값 넘겼을 때 -> 제거: Enum 유효성 검사로 인해 필요성 없음 - - -# importlib 실패 상황 테스트 -def test_driver_import_failure_at_api(): - with mock.patch("importlib.import_module", side_effect=ModuleNotFoundError("모듈 없음")): - driver = DriverEnum.mysql - result = db_driver_info(driver.value, driver.driver_module) - print(result) From c1fc742eafa6aee7e014a623dad08b15312282e6 Mon Sep 17 00:00:00 2001 From: mini Date: Mon, 4 Aug 2025 00:16:28 +0900 Subject: [PATCH 046/220] =?UTF-8?q?refactor:=20driver=5Finfo=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20return=20=EB=88=84=EB=9D=BD=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/driver_info.py | 2 ++ app/services/driver_service.py | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/schemas/driver_info.py b/app/schemas/driver_info.py index 1fa41ca..f8e0b62 100644 --- a/app/schemas/driver_info.py +++ b/app/schemas/driver_info.py @@ -19,6 +19,8 @@ def update_from_module(self, version: str | None, size: int | None): self.driver_version = version self.driver_size_bytes = size + return self + @classmethod def from_enum(cls, db_type_enum: DBTypesEnum): """ diff --git a/app/services/driver_service.py b/app/services/driver_service.py index 9f35092..afed25c 100644 --- a/app/services/driver_service.py +++ b/app/services/driver_service.py @@ -14,8 +14,7 @@ def db_driver_info(driver_info: DriverInfo): path = getattr(mod.__spec__, "origin", None) size = os.path.getsize(path) if path else None - driver_info.update_from_module(version, size) - return driver_info + return driver_info.update_from_module(version, size) except (ModuleNotFoundError, AttributeError, OSError): raise APIException(CommonCode.FAIL) From 7d660031419002fb6adae5f892bc89fa375819a9 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 1 Aug 2025 01:27:10 +0900 Subject: [PATCH 047/220] =?UTF-8?q?feat:=20=EC=95=B1=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=20=EB=94=94=EB=B9=84=20=EC=83=9D=EC=84=B1=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1(ConnectionProfile,=20APICredential)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/security.py | 23 +++++++++++++++ app/core/utils.py | 0 app/db/init_db.py | 67 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 app/core/security.py create mode 100644 app/core/utils.py create mode 100644 app/db/init_db.py diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..48168a5 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,23 @@ +import uuid +from pathlib import Path + +# 앱 데이터를 저장할 폴더 이름 +APP_DATA_DIR_NAME = ".my_awesome_app" + +def get_db_path() -> Path: + """ + 사용자 홈 디렉터리 내에 앱 데이터 폴더를 만들고, + SQLite DB 파일의 전체 경로를 반환합니다. + """ + home_dir = Path.home() + app_data_dir = home_dir / APP_DATA_DIR_NAME + app_data_dir.mkdir(exist_ok=True) + db_path = app_data_dir / "local_storage.sqlite" + return db_path + +def generate_uuid() -> str: + return uuid.uuid4().hex.upper() + +def generate_prefixed_uuid(prefix: str) -> str: + return f"{prefix.upper()}-{uuid.uuid4().hex.upper()}" + diff --git a/app/core/utils.py b/app/core/utils.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/init_db.py b/app/db/init_db.py new file mode 100644 index 0000000..92d22b2 --- /dev/null +++ b/app/db/init_db.py @@ -0,0 +1,67 @@ +import sqlite3 +from app.core.utils import get_db_path + +""" +데이터베이스에 연결하고, 애플리케이션에 필요한 테이블이 없으면 생성합니다. +""" +def initialize_database(): + + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + # ConnectionProfile 테이블 생성 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS ConnectionProfile ( + id INTEGER PRIMARY KEY, + db_type TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + name TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """) + + # ConnectionProfile에 updated_at 자동 갱신을 위한 트리거 생성 + cursor.execute(""" + CREATE TRIGGER IF NOT EXISTS update_connectionprofile_updated_at + AFTER UPDATE ON ConnectionProfile + FOR EACH ROW + BEGIN + UPDATE ConnectionProfile SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id; + END; + """) + + # APICredential 테이블 생성 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS APICredential ( + id INTEGER PRIMARY KEY, + service_name TEXT NOT NULL, + api_key TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """) + + # APICredential에 updated_at 자동 갱신을 위한 트리거 생성 + cursor.execute(""" + CREATE TRIGGER IF NOT EXISTS update_apicredential_updated_at + AFTER UPDATE ON APICredential + FOR EACH ROW + BEGIN + UPDATE APICredential SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id; + END; + """) + + conn.commit() + + except sqlite3.Error as e: + print(f"데이터베이스 초기화 중 오류 발생: {e}") + finally: + if conn: + conn.close() + From 2011e7f08130b91da960b069469315c57a8bd0bf Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 1 Aug 2025 01:27:59 +0900 Subject: [PATCH 048/220] =?UTF-8?q?feat:=20=EC=9E=90=EC=A3=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=90=98=EB=8A=94=20db=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=B0=8F=20uuid=20=EB=B6=80=EB=B6=84=20ut?= =?UTF-8?q?il=EB=A1=9C=20=EB=B9=BC=EC=84=9C=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20util=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/utils.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/core/utils.py b/app/core/utils.py index e69de29..48168a5 100644 --- a/app/core/utils.py +++ b/app/core/utils.py @@ -0,0 +1,23 @@ +import uuid +from pathlib import Path + +# 앱 데이터를 저장할 폴더 이름 +APP_DATA_DIR_NAME = ".my_awesome_app" + +def get_db_path() -> Path: + """ + 사용자 홈 디렉터리 내에 앱 데이터 폴더를 만들고, + SQLite DB 파일의 전체 경로를 반환합니다. + """ + home_dir = Path.home() + app_data_dir = home_dir / APP_DATA_DIR_NAME + app_data_dir.mkdir(exist_ok=True) + db_path = app_data_dir / "local_storage.sqlite" + return db_path + +def generate_uuid() -> str: + return uuid.uuid4().hex.upper() + +def generate_prefixed_uuid(prefix: str) -> str: + return f"{prefix.upper()}-{uuid.uuid4().hex.upper()}" + From 10565b2193df308883decbb1d6f1715fa841bfe8 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 1 Aug 2025 01:28:47 +0900 Subject: [PATCH 049/220] =?UTF-8?q?chore:=20AES256=20=EC=95=94=ED=98=B8?= =?UTF-8?q?=ED=99=94=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- poetry.lock | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 3 ++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1ad3eb7..ffe1686 100644 --- a/poetry.lock +++ b/poetry.lock @@ -739,6 +739,54 @@ files = [ {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287"}, {file = "psycopg2_binary-2.9.10-cp39-cp39-win32.whl", hash = "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8"}, {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, +name = "pycryptodome" +version = "3.23.0" +description = "Cryptographic library for Python" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +files = [ + {file = "pycryptodome-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566"}, + {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75"}, + {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:63dad881b99ca653302b2c7191998dd677226222a3f2ea79999aa51ce695f720"}, + {file = "pycryptodome-3.23.0-cp27-cp27m-win32.whl", hash = "sha256:b34e8e11d97889df57166eda1e1ddd7676da5fcd4d71a0062a760e75060514b4"}, + {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:7ac1080a8da569bde76c0a104589c4f414b8ba296c0b3738cf39a466a9fb1818"}, + {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6fe8258e2039eceb74dfec66b3672552b6b7d2c235b2dfecc05d16b8921649a8"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39"}, + {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27"}, + {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843"}, + {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490"}, + {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575"}, + {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b"}, + {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a"}, + {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f"}, + {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa"}, + {file = "pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886"}, + {file = "pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2"}, + {file = "pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c"}, + {file = "pycryptodome-3.23.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:350ebc1eba1da729b35ab7627a833a1a355ee4e852d8ba0447fafe7b14504d56"}, + {file = "pycryptodome-3.23.0-pp27-pypy_73-win32.whl", hash = "sha256:93837e379a3e5fd2bb00302a47aee9fdf7940d83595be3915752c74033d17ca7"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6"}, + {file = "pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef"}, ] [[package]] @@ -1390,4 +1438,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "b54ff38df6da37302c0517493eeedc366a91ccbb2f4ef7cec1185e0fd83b6e3e" +content-hash = "1426c31c951997738ac54c4653aa70807b59996be48822d9d9f59408c7f1a0bd" diff --git a/pyproject.toml b/pyproject.toml index fbc9141..f980df9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,8 @@ dependencies = [ "mysql-connector-python (>=9.4.0,<10.0.0)", "pymysql (>=1.1.1,<2.0.0)", "cx-oracle (>=8.3.0,<9.0.0)", - "pyodbc (>=5.2.0,<6.0.0)" + "pyodbc (>=5.2.0,<6.0.0)", + "pycryptodome (>=3.23.0,<4.0.0)" ] From c33a2ecbad936320f63f40fae78fbb03726501a2 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Fri, 1 Aug 2025 01:29:11 +0900 Subject: [PATCH 050/220] =?UTF-8?q?feat:=20=EB=94=94=EB=B9=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=9C=20=EC=95=94=ED=98=B8=ED=99=94=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EC=95=94=ED=98=B8=ED=99=94=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/security.py | 65 +++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/app/core/security.py b/app/core/security.py index 48168a5..a842a85 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,23 +1,44 @@ -import uuid -from pathlib import Path - -# 앱 데이터를 저장할 폴더 이름 -APP_DATA_DIR_NAME = ".my_awesome_app" - -def get_db_path() -> Path: - """ - 사용자 홈 디렉터리 내에 앱 데이터 폴더를 만들고, - SQLite DB 파일의 전체 경로를 반환합니다. - """ - home_dir = Path.home() - app_data_dir = home_dir / APP_DATA_DIR_NAME - app_data_dir.mkdir(exist_ok=True) - db_path = app_data_dir / "local_storage.sqlite" - return db_path - -def generate_uuid() -> str: - return uuid.uuid4().hex.upper() - -def generate_prefixed_uuid(prefix: str) -> str: - return f"{prefix.upper()}-{uuid.uuid4().hex.upper()}" +import os +import base64 +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad, unpad +from Crypto.Random import get_random_bytes + +""" +보안 원칙을 적용한 AES-256 암호화 및 복호화 클래스입니다. +- 암호화 시 매번 새로운 랜덤 IV를 생성합니다. +""" +class AES256: + _key = "Y1dkbGJtbGxMbUZsY3pJMU5pNXJaWGs9".encode('utf-8') + + @staticmethod + def encrypt(text: str) -> str: + iv = get_random_bytes(AES.block_size) + + cipher = AES.new(AES256._key, AES.MODE_CBC, iv) + + data_bytes = text.encode('utf-8') + padded_bytes = pad(data_bytes, AES.block_size) + + encrypted_bytes = cipher.encrypt(padded_bytes) + + combined_bytes = iv + encrypted_bytes + return base64.b64encode(combined_bytes).decode('utf-8') + + @staticmethod + def decrypt(cipher_text: str) -> str: + """ + AES-256으로 암호화된 텍스트를 복호화합니다. + """ + combined_bytes = base64.b64decode(cipher_text) + + iv = combined_bytes[:AES.block_size] + encrypted_bytes = combined_bytes[AES.block_size:] + + cipher = AES.new(AES256._key, AES.MODE_CBC, iv) + + decrypted_padded_bytes = cipher.decrypt(encrypted_bytes) + decrypted_bytes = unpad(decrypted_padded_bytes, AES.block_size) + + return decrypted_bytes.decode('utf-8') From 8a9644f64b4e2675b5393a5393ce8ee1297f245d Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Mon, 4 Aug 2025 00:01:36 +0900 Subject: [PATCH 051/220] =?UTF-8?q?feat:=20local=20db=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/utils.py | 2 +- app/db/init_db.py | 131 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 98 insertions(+), 35 deletions(-) diff --git a/app/core/utils.py b/app/core/utils.py index 48168a5..a4f7278 100644 --- a/app/core/utils.py +++ b/app/core/utils.py @@ -2,7 +2,7 @@ from pathlib import Path # 앱 데이터를 저장할 폴더 이름 -APP_DATA_DIR_NAME = ".my_awesome_app" +APP_DATA_DIR_NAME = ".qgenie" def get_db_path() -> Path: """ diff --git a/app/db/init_db.py b/app/db/init_db.py index 92d22b2..4de856d 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -1,3 +1,4 @@ +# db/init_db.py import sqlite3 from app.core.utils import get_db_path @@ -11,50 +12,112 @@ def initialize_database(): try: conn = sqlite3.connect(db_path) cursor = conn.cursor() - # ConnectionProfile 테이블 생성 + # db_profile 테이블 생성 cursor.execute(""" - CREATE TABLE IF NOT EXISTS ConnectionProfile ( - id INTEGER PRIMARY KEY, - db_type TEXT NOT NULL, - host TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - password TEXT NOT NULL, - name TEXT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ) + CREATE TABLE IF NOT EXISTS db_profile ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + type VARCHAR(32) NOT NULL, + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL, + name VARCHAR(64), + username VARCHAR(128) NOT NULL, + password VARCHAR(128) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """) + # db_profile 테이블의 updated_at을 자동으로 업데이트하는 트리거 + cursor.execute(""" + CREATE TRIGGER IF NOT EXISTS update_db_profile_updated_at + BEFORE UPDATE ON db_profile + FOR EACH ROW + BEGIN + UPDATE db_profile SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; """) - # ConnectionProfile에 updated_at 자동 갱신을 위한 트리거 생성 + # ai_credential 테이블 생성 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS ai_credential ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + service_name VARCHAR(32) NOT NULL, + api_key VARCHAR(256) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """) + # ai_credential 테이블의 updated_at을 자동으로 업데이트하는 트리거 cursor.execute(""" - CREATE TRIGGER IF NOT EXISTS update_connectionprofile_updated_at - AFTER UPDATE ON ConnectionProfile - FOR EACH ROW - BEGIN - UPDATE ConnectionProfile SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id; - END; + CREATE TRIGGER IF NOT EXISTS update_ai_credential_updated_at + BEFORE UPDATE ON ai_credential + FOR EACH ROW + BEGIN + UPDATE ai_credential SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; """) - # APICredential 테이블 생성 + # chat_tab 테이블 생성 cursor.execute(""" - CREATE TABLE IF NOT EXISTS APICredential ( - id INTEGER PRIMARY KEY, - service_name TEXT NOT NULL, - api_key TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ) + CREATE TABLE IF NOT EXISTS chat_tab ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + name VARCHAR(255), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """) + # chat_tab 테이블의 updated_at을 자동으로 업데이트하는 트리거 + cursor.execute(""" + CREATE TRIGGER IF NOT EXISTS update_chat_tab_updated_at + BEFORE UPDATE ON chat_tab + FOR EACH ROW + BEGIN + UPDATE chat_tab SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; """) - # APICredential에 updated_at 자동 갱신을 위한 트리거 생성 + # chat_message 테이블 생성 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS chat_message ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + chat_tab_id VARCHAR(64) NOT NULL, + sender VARCHAR(1) NOT NULL, + message TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (chat_tab_id) REFERENCES chat_tab(id) + ); + """) + # chat_message 테이블의 updated_at을 자동으로 업데이트하는 트리거 + cursor.execute(""" + CREATE TRIGGER IF NOT EXISTS update_chat_message_updated_at + BEFORE UPDATE ON chat_message + FOR EACH ROW + BEGIN + UPDATE chat_message SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; + """) + + # query_history 테이블 생성 + cursor.execute(""" + CREATE TABLE IF NOT EXISTS query_history ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + chat_message_id VARCHAR(64) NOT NULL, + query_text TEXT NOT NULL, + is_success VARCHAR(1) NOT NULL, + error_message TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (chat_message_id) REFERENCES chat_message(id) + ); + """) + # query_history 테이블의 updated_at을 자동으로 업데이트하는 트리거 cursor.execute(""" - CREATE TRIGGER IF NOT EXISTS update_apicredential_updated_at - AFTER UPDATE ON APICredential - FOR EACH ROW - BEGIN - UPDATE APICredential SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id; - END; + CREATE TRIGGER IF NOT EXISTS update_query_history_updated_at + BEFORE UPDATE ON query_history + FOR EACH ROW + BEGIN + UPDATE query_history SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; """) conn.commit() From 9167bc757605201172f4a815df997bb95d5ea39e Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Mon, 4 Aug 2025 00:02:15 +0900 Subject: [PATCH 052/220] =?UTF-8?q?feat:=20sqllite=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/main.py b/app/main.py index c3d6dd2..8a0f03b 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,7 @@ from app.api import health # 헬스 체크 from app.api.api_router import api_router from app.core.exceptions import APIException, api_exception_handler, generic_exception_handler +from app.db.init_db import initialize_database app = FastAPI() @@ -17,6 +18,8 @@ app.include_router(health.router) app.include_router(api_router, prefix="/api") +# initialize_database 함수가 호출되어 테이블이 생성되거나 이미 존재함을 확인합니다. +initialize_database() if __name__ == "__main__": # Uvicorn 서버를 시작합니다. From 416235e449a6c89c4f9da8bb502a7eae144061c7 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Mon, 4 Aug 2025 00:11:40 +0900 Subject: [PATCH 053/220] =?UTF-8?q?refactor:=20key=EB=A5=BC=20git=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B0=9B=EC=95=84=EC=84=9C=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build_release_and_notify.yml | 2 ++ app/core/security.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build_release_and_notify.yml b/.github/workflows/build_release_and_notify.yml index 94f9306..7870623 100644 --- a/.github/workflows/build_release_and_notify.yml +++ b/.github/workflows/build_release_and_notify.yml @@ -75,6 +75,8 @@ jobs: # 6. PyInstaller를 사용해 파이썬 코드를 실행 파일로 만듭니다. - name: Build executable with PyInstaller shell: bash + env: + ENV_AES256_KEY: ${{ secrets.ENV_AES256_KEY }} run: poetry run pyinstaller --clean --additional-hooks-dir ./hooks --add-data "app/assets:assets" --onefile --name ${{ env.EXE_NAME }} app/main.py # 7. 빌드된 실행 파일을 다음 단계(deploy)에서 사용할 수 있도록 아티팩트로 업로드합니다. diff --git a/app/core/security.py b/app/core/security.py index a842a85..47376d7 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -9,7 +9,7 @@ - 암호화 시 매번 새로운 랜덤 IV를 생성합니다. """ class AES256: - _key = "Y1dkbGJtbGxMbUZsY3pJMU5pNXJaWGs9".encode('utf-8') + _key = base64.b64decode(os.getenv("ENV_AES256_KEY")) @staticmethod def encrypt(text: str) -> str: From b8d50d3e9a3b045b132f7ad655b051e766dc997f Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Mon, 4 Aug 2025 00:58:55 +0900 Subject: [PATCH 054/220] =?UTF-8?q?refactor:=20response=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/driver_api.py | 2 +- app/api/test_api.py | 2 +- app/{schemas => core}/response.py | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename app/{schemas => core}/response.py (100%) diff --git a/app/api/driver_api.py b/app/api/driver_api.py index 97a9e10..23681ca 100644 --- a/app/api/driver_api.py +++ b/app/api/driver_api.py @@ -6,7 +6,7 @@ from app.core.exceptions import APIException from app.core.status import CommonCode from app.schemas.driver_info import DriverInfo -from app.schemas.response import ResponseMessage +from app.core.response import ResponseMessage from app.services.driver_service import db_driver_info router = APIRouter() diff --git a/app/api/test_api.py b/app/api/test_api.py index c3ae9c0..ca0dcca 100644 --- a/app/api/test_api.py +++ b/app/api/test_api.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.schemas.response import ResponseMessage +from app.core.response import ResponseMessage from app.core.exceptions import APIException from app.core.status import CommonCode diff --git a/app/schemas/response.py b/app/core/response.py similarity index 100% rename from app/schemas/response.py rename to app/core/response.py From bb14bcea8687a716cb2ae8269aed7820c3f8cc0f Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Mon, 4 Aug 2025 01:11:24 +0900 Subject: [PATCH 055/220] =?UTF-8?q?refactor:=20enum=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/driver_api.py | 2 +- app/core/{db_driver_enum.py => enum/db_driver.py} | 2 +- app/schemas/driver_info.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename app/core/{db_driver_enum.py => enum/db_driver.py} (90%) diff --git a/app/api/driver_api.py b/app/api/driver_api.py index 23681ca..8d488fc 100644 --- a/app/api/driver_api.py +++ b/app/api/driver_api.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from app.core.db_driver_enum import DBTypesEnum +from app.core.enum.db_driver import DBTypesEnum from app.core.exceptions import APIException from app.core.status import CommonCode from app.schemas.driver_info import DriverInfo diff --git a/app/core/db_driver_enum.py b/app/core/enum/db_driver.py similarity index 90% rename from app/core/db_driver_enum.py rename to app/core/enum/db_driver.py index 6896760..0ccf151 100644 --- a/app/core/db_driver_enum.py +++ b/app/core/enum/db_driver.py @@ -1,4 +1,4 @@ -# app/core/db_driver_enum.py +# app/core/enum/db_driver.py from enum import Enum diff --git a/app/schemas/driver_info.py b/app/schemas/driver_info.py index f8e0b62..0d7c62d 100644 --- a/app/schemas/driver_info.py +++ b/app/schemas/driver_info.py @@ -1,7 +1,7 @@ # app/schemas/driver_info.py from pydantic import BaseModel -from app.core.db_driver_enum import DBTypesEnum +from app.core.enum.db_driver import DBTypesEnum class DriverInfo(BaseModel): From 51702821bbac027e0913dbc915c8338088369a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 07:32:07 +0900 Subject: [PATCH 056/220] =?UTF-8?q?fix:=20pycryptodome=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EC=8B=9C=20=EB=B0=9C=EC=83=9D=ED=95=9C=20=EB=8C=80?= =?UTF-8?q?=EA=B4=84=ED=98=B8=20=EB=B0=8F=20[[package]]=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- poetry.lock | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index ffe1686..7fb114c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -739,6 +739,9 @@ files = [ {file = "psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287"}, {file = "psycopg2_binary-2.9.10-cp39-cp39-win32.whl", hash = "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8"}, {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, +] + +[[package]] name = "pycryptodome" version = "3.23.0" description = "Cryptographic library for Python" @@ -1438,4 +1441,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "1426c31c951997738ac54c4653aa70807b59996be48822d9d9f59408c7f1a0bd" +content-hash = "9304969e170879b35397b7ddccf29746aa376fbee58eba60ab85ab25e0c7a3b2" From 447f621c477e3964401dd93c67ea18677c2e29b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 11:10:38 +0900 Subject: [PATCH 057/220] =?UTF-8?q?fix:=20legacy=20alias=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=B4=20=EB=A6=B0=ED=8C=85=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5977fee..99ba938 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: "v0.12.2" # ruff 버전에 맞게 수정 hooks: - - id: ruff + - id: ruff-check args: ["--fix"] # 자동 수정 적용 - repo: https://github.com/psf/black From ae41f9637dd532eb34d7cd46dfca89d4579304f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 11:15:25 +0900 Subject: [PATCH 058/220] =?UTF-8?q?chore:=20ruff=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B6=8C=EC=9E=A5=ED=95=98=EB=8A=94=20exception=20chaining=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/driver_api.py | 4 ++-- app/api/test_api.py | 13 +++++-------- app/services/driver_service.py | 4 ++-- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/app/api/driver_api.py b/app/api/driver_api.py index 8d488fc..bf5dbad 100644 --- a/app/api/driver_api.py +++ b/app/api/driver_api.py @@ -4,9 +4,9 @@ from app.core.enum.db_driver import DBTypesEnum from app.core.exceptions import APIException +from app.core.response import ResponseMessage from app.core.status import CommonCode from app.schemas.driver_info import DriverInfo -from app.core.response import ResponseMessage from app.services.driver_service import db_driver_info router = APIRouter() @@ -21,4 +21,4 @@ def read_driver_info(driverId: str): return ResponseMessage.success(value=db_driver_info(DriverInfo.from_enum(db_type_enum))) # db_type_enum 유효성 검사 실패 except KeyError: - raise APIException(CommonCode.INVALID_ENUM_VALUE) + raise APIException(CommonCode.INVALID_ENUM_VALUE) from KeyError diff --git a/app/api/test_api.py b/app/api/test_api.py index ca0dcca..49672b9 100644 --- a/app/api/test_api.py +++ b/app/api/test_api.py @@ -1,11 +1,12 @@ from fastapi import APIRouter -from app.core.response import ResponseMessage from app.core.exceptions import APIException +from app.core.response import ResponseMessage from app.core.status import CommonCode router = APIRouter() + @router.get("", response_model=ResponseMessage, summary="타입 변환을 이용한 성공/실패/버그 테스트") def simple_test(mode: str): """ @@ -27,14 +28,11 @@ def simple_test(mode: str): # 2. 정수로 변환 성공 시, 값에 따라 분기 if mode_int == 1: # 기본 성공 코드(SUCCESS)로 응답 - return ResponseMessage.success( - value={"detail": "기본 성공 테스트입니다."} - ) + return ResponseMessage.success(value={"detail": "기본 성공 테스트입니다."}) elif mode_int == 2: # 커스텀 성공 코드(CREATED)로 응답 return ResponseMessage.success( - value={"detail": "커스텀 성공 코드(CREATED) 테스트입니다."}, - code=CommonCode.CREATED + value={"detail": "커스텀 성공 코드(CREATED) 테스트입니다."}, code=CommonCode.CREATED ) else: # 그 외 숫자는 '데이터 없음' 오류로 처리 @@ -44,5 +42,4 @@ def simple_test(mode: str): # 3. 정수로 변환 실패 시 (문자열이 들어온 경우) # 예상치 못한 버그를 강제로 발생시킵니다. # 이 에러는 generic_exception_handler가 처리하게 됩니다. - raise TypeError("의도적으로 발생시킨 타입 에러입니다.") - + raise TypeError("의도적으로 발생시킨 타입 에러입니다.") from ValueError diff --git a/app/services/driver_service.py b/app/services/driver_service.py index afed25c..a3ade45 100644 --- a/app/services/driver_service.py +++ b/app/services/driver_service.py @@ -16,5 +16,5 @@ def db_driver_info(driver_info: DriverInfo): return driver_info.update_from_module(version, size) - except (ModuleNotFoundError, AttributeError, OSError): - raise APIException(CommonCode.FAIL) + except (ModuleNotFoundError, AttributeError, OSError) as e: + raise APIException(CommonCode.FAIL) from e From 6698a70a8a62a3a76fbbd6e72ca169424f551716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 11:16:00 +0900 Subject: [PATCH 059/220] =?UTF-8?q?style:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=84=EC=B2=B4=20=ED=8C=8C=EC=9D=BC=20ruff?= =?UTF-8?q?=EB=A1=9C=20linting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/exceptions.py | 25 ++++++++--------- app/core/response.py | 23 ++++++--------- app/core/security.py | 18 ++++++------ app/db/init_db.py | 64 ++++++++++++++++++++++++++++-------------- 4 files changed, 73 insertions(+), 57 deletions(-) diff --git a/app/core/exceptions.py b/app/core/exceptions.py index ea68b2a..24f97de 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -1,6 +1,8 @@ import traceback -from fastapi import Request, status + +from fastapi import Request from fastapi.responses import JSONResponse + from app.core.status import CommonCode @@ -8,41 +10,36 @@ class APIException(Exception): """ API 로직 내에서 발생하는 모든 예상된 오류에 사용할 기본 예외 클래스입니다. """ + def __init__(self, code: CommonCode, *args): self.code_enum = code self.message = code.get_message(*args) super().__init__(self.message) + async def api_exception_handler(request: Request, exc: APIException): """ APIException이 발생했을 때, 이를 감지하여 표준화된 JSON 오류 응답을 반환합니다. """ return JSONResponse( status_code=exc.code_enum.http_status, - content={ - "code": exc.code_enum.code, - "message": exc.message, - "data": None - } + content={"code": exc.code_enum.code, "message": exc.message, "data": None}, ) + async def generic_exception_handler(request: Request, exc: Exception): """ 처리되지 않은 모든 예외를 잡아, 일관된 500 서버 오류를 반환합니다. """ # 운영 환경에서는 파일 로그나 모니터링 시스템으로 보내야 합니다. - print("="*20, "UNEXPECTED ERROR", "="*20) + print("=" * 20, "UNEXPECTED ERROR", "=" * 20) traceback.print_exc() - print("="*50) + print("=" * 50) # 사용자에게는 간단한 500 에러 메시지만 보여줍니다. - error_response = { - "code": CommonCode.FAIL.code, - "message": CommonCode.FAIL.message, - "data": None - } + error_response = {"code": CommonCode.FAIL.code, "message": CommonCode.FAIL.message, "data": None} return JSONResponse( status_code=CommonCode.FAIL.http_status, content=error_response, - ) \ No newline at end of file + ) diff --git a/app/core/response.py b/app/core/response.py index c57d50a..ba7bf61 100644 --- a/app/core/response.py +++ b/app/core/response.py @@ -1,29 +1,24 @@ -from typing import Generic, TypeVar, Optional +from typing import Generic, TypeVar + from pydantic import BaseModel, Field + from app.core.status import CommonCode -T = TypeVar('T') +T = TypeVar("T") + class ResponseMessage(BaseModel, Generic[T]): """ 모든 API 응답에 사용될 공용 스키마입니다. """ + code: str = Field(..., description="응답을 나타내는 고유 상태 코드") message: str = Field(..., description="응답 메시지") - data: Optional[T] = Field(None, description="반환될 실제 데이터") + data: T | None = Field(None, description="반환될 실제 데이터") @classmethod - def success( - cls, - value: Optional[T] = None, - code: CommonCode = CommonCode.SUCCESS, - *args - ) -> "ResponseMessage[T]": + def success(cls, value: T | None = None, code: CommonCode = CommonCode.SUCCESS, *args) -> "ResponseMessage[T]": """ 성공 응답을 생성하는 팩토리 메서드입니다. """ - return cls( - code=code.code, - message=code.get_message(*args), - data=value - ) + return cls(code=code.code, message=code.get_message(*args), data=value) diff --git a/app/core/security.py b/app/core/security.py index 47376d7..d598030 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,13 +1,16 @@ -import os import base64 +import os + from Crypto.Cipher import AES -from Crypto.Util.Padding import pad, unpad from Crypto.Random import get_random_bytes +from Crypto.Util.Padding import pad, unpad """ 보안 원칙을 적용한 AES-256 암호화 및 복호화 클래스입니다. - 암호화 시 매번 새로운 랜덤 IV를 생성합니다. """ + + class AES256: _key = base64.b64decode(os.getenv("ENV_AES256_KEY")) @@ -17,13 +20,13 @@ def encrypt(text: str) -> str: cipher = AES.new(AES256._key, AES.MODE_CBC, iv) - data_bytes = text.encode('utf-8') + data_bytes = text.encode("utf-8") padded_bytes = pad(data_bytes, AES.block_size) encrypted_bytes = cipher.encrypt(padded_bytes) combined_bytes = iv + encrypted_bytes - return base64.b64encode(combined_bytes).decode('utf-8') + return base64.b64encode(combined_bytes).decode("utf-8") @staticmethod def decrypt(cipher_text: str) -> str: @@ -32,13 +35,12 @@ def decrypt(cipher_text: str) -> str: """ combined_bytes = base64.b64decode(cipher_text) - iv = combined_bytes[:AES.block_size] - encrypted_bytes = combined_bytes[AES.block_size:] + iv = combined_bytes[: AES.block_size] + encrypted_bytes = combined_bytes[AES.block_size :] cipher = AES.new(AES256._key, AES.MODE_CBC, iv) decrypted_padded_bytes = cipher.decrypt(encrypted_bytes) decrypted_bytes = unpad(decrypted_padded_bytes, AES.block_size) - return decrypted_bytes.decode('utf-8') - + return decrypted_bytes.decode("utf-8") diff --git a/app/db/init_db.py b/app/db/init_db.py index 4de856d..0b8fa58 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -1,10 +1,13 @@ # db/init_db.py import sqlite3 + from app.core.utils import get_db_path """ 데이터베이스에 연결하고, 애플리케이션에 필요한 테이블이 없으면 생성합니다. """ + + def initialize_database(): db_path = get_db_path() @@ -13,7 +16,8 @@ def initialize_database(): conn = sqlite3.connect(db_path) cursor = conn.cursor() # db_profile 테이블 생성 - cursor.execute(""" + cursor.execute( + """ CREATE TABLE IF NOT EXISTS db_profile ( id VARCHAR(64) PRIMARY KEY NOT NULL, type VARCHAR(32) NOT NULL, @@ -25,19 +29,23 @@ def initialize_database(): created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); - """) + """ + ) # db_profile 테이블의 updated_at을 자동으로 업데이트하는 트리거 - cursor.execute(""" + cursor.execute( + """ CREATE TRIGGER IF NOT EXISTS update_db_profile_updated_at BEFORE UPDATE ON db_profile FOR EACH ROW BEGIN UPDATE db_profile SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; - """) + """ + ) # ai_credential 테이블 생성 - cursor.execute(""" + cursor.execute( + """ CREATE TABLE IF NOT EXISTS ai_credential ( id VARCHAR(64) PRIMARY KEY NOT NULL, service_name VARCHAR(32) NOT NULL, @@ -45,38 +53,46 @@ def initialize_database(): created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); - """) + """ + ) # ai_credential 테이블의 updated_at을 자동으로 업데이트하는 트리거 - cursor.execute(""" + cursor.execute( + """ CREATE TRIGGER IF NOT EXISTS update_ai_credential_updated_at BEFORE UPDATE ON ai_credential FOR EACH ROW BEGIN UPDATE ai_credential SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; - """) + """ + ) # chat_tab 테이블 생성 - cursor.execute(""" + cursor.execute( + """ CREATE TABLE IF NOT EXISTS chat_tab ( id VARCHAR(64) PRIMARY KEY NOT NULL, name VARCHAR(255), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); - """) + """ + ) # chat_tab 테이블의 updated_at을 자동으로 업데이트하는 트리거 - cursor.execute(""" + cursor.execute( + """ CREATE TRIGGER IF NOT EXISTS update_chat_tab_updated_at BEFORE UPDATE ON chat_tab FOR EACH ROW BEGIN UPDATE chat_tab SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; - """) + """ + ) # chat_message 테이블 생성 - cursor.execute(""" + cursor.execute( + """ CREATE TABLE IF NOT EXISTS chat_message ( id VARCHAR(64) PRIMARY KEY NOT NULL, chat_tab_id VARCHAR(64) NOT NULL, @@ -86,19 +102,23 @@ def initialize_database(): updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (chat_tab_id) REFERENCES chat_tab(id) ); - """) + """ + ) # chat_message 테이블의 updated_at을 자동으로 업데이트하는 트리거 - cursor.execute(""" + cursor.execute( + """ CREATE TRIGGER IF NOT EXISTS update_chat_message_updated_at BEFORE UPDATE ON chat_message FOR EACH ROW BEGIN UPDATE chat_message SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; - """) + """ + ) # query_history 테이블 생성 - cursor.execute(""" + cursor.execute( + """ CREATE TABLE IF NOT EXISTS query_history ( id VARCHAR(64) PRIMARY KEY NOT NULL, chat_message_id VARCHAR(64) NOT NULL, @@ -109,16 +129,19 @@ def initialize_database(): updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (chat_message_id) REFERENCES chat_message(id) ); - """) + """ + ) # query_history 테이블의 updated_at을 자동으로 업데이트하는 트리거 - cursor.execute(""" + cursor.execute( + """ CREATE TRIGGER IF NOT EXISTS update_query_history_updated_at BEFORE UPDATE ON query_history FOR EACH ROW BEGIN UPDATE query_history SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; - """) + """ + ) conn.commit() @@ -127,4 +150,3 @@ def initialize_database(): finally: if conn: conn.close() - From 0d282b10a621072baae5c9f196d13530699f893a Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Tue, 5 Aug 2025 00:42:13 +0900 Subject: [PATCH 060/220] =?UTF-8?q?style:=20=EA=B0=9D=EC=B2=B4=EB=A5=BC=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B3=A0=20=EB=84=98=EA=B8=B0=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/driver_api.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/api/driver_api.py b/app/api/driver_api.py index bf5dbad..26b4296 100644 --- a/app/api/driver_api.py +++ b/app/api/driver_api.py @@ -12,13 +12,16 @@ router = APIRouter() -@router.get("/drivers/{driverId}", response_model=ResponseMessage[DriverInfo], summary="DB 드라이버 정보 조회 API") -def read_driver_info(driverId: str): - """DB 드라이버 정보 조회""" +@router.get( + "/drivers/{driver_id}", + response_model=ResponseMessage, + summary="DB 드라이버 정보 조회", +) +def read_driver_info(driver_id: str) -> ResponseMessage: + """경로 파라미터로 받은 driver_id에 해당하는 DB 드라이버의 지원 정보를 조회합니다.""" try: - # DBTypesEnum에서 driverID에 맞는 객체를 가져옵니다. - db_type_enum = DBTypesEnum[driverId.lower()] - return ResponseMessage.success(value=db_driver_info(DriverInfo.from_enum(db_type_enum))) - # db_type_enum 유효성 검사 실패 + db_type_enum = DBTypesEnum[driver_id.lower()] + driver_info_data = DriverInfo.from_enum(db_type_enum) + return ResponseMessage.success(value=db_driver_info(driver_info_data)) except KeyError: raise APIException(CommonCode.INVALID_ENUM_VALUE) from KeyError From 84096470a49f6955a9b7ad9b2cf4f1ddc9a4bc4c Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Tue, 5 Aug 2025 01:49:07 +0900 Subject: [PATCH 061/220] =?UTF-8?q?feat:=20=ED=95=84=EC=88=98=20=EA=B0=92?= =?UTF-8?q?=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EB=B6=80=EB=B6=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/exceptions.py | 48 +++++++++++++++++++++++++++++++----------- app/core/status.py | 3 ++- app/main.py | 11 ++++++++-- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/app/core/exceptions.py b/app/core/exceptions.py index 24f97de..b8acf24 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -1,11 +1,28 @@ import traceback +from typing import Any from fastapi import Request +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from app.core.status import CommonCode +def _create_error_response(code: CommonCode, data: Any | None = None, *message_args) -> JSONResponse: + """ + 모든 에러 응답에 사용될 표준 JSONResponse 객체를 생성하는 헬퍼 함수. + """ + error_content = { + "code": code.code, + "message": code.get_message(*message_args), + "data": data, + } + return JSONResponse( + status_code=code.http_status, + content=error_content, + ) + + class APIException(Exception): """ API 로직 내에서 발생하는 모든 예상된 오류에 사용할 기본 예외 클래스입니다. @@ -17,13 +34,25 @@ def __init__(self, code: CommonCode, *args): super().__init__(self.message) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """ + Pydantic 모델의 유효성 검사 실패(RequestValidationError)를 감지하여 + 표준화된 JSON 오류 응답을 반환합니다. + """ + error_details = [] + for error in exc.errors(): + field_name = ".".join(map(str, error["loc"][1:])) + error_details.append({"field": field_name, "message": error["msg"]}) + + return _create_error_response(code=CommonCode.INVALID_PARAMETER, data={"details": error_details}) + + async def api_exception_handler(request: Request, exc: APIException): """ APIException이 발생했을 때, 이를 감지하여 표준화된 JSON 오류 응답을 반환합니다. """ - return JSONResponse( - status_code=exc.code_enum.http_status, - content={"code": exc.code_enum.code, "message": exc.message, "data": None}, + return _create_error_response( + code=exc.code_enum.http_status, data={"code": exc.code_enum.code, "message": exc.message, "data": None} ) @@ -31,15 +60,10 @@ async def generic_exception_handler(request: Request, exc: Exception): """ 처리되지 않은 모든 예외를 잡아, 일관된 500 서버 오류를 반환합니다. """ - # 운영 환경에서는 파일 로그나 모니터링 시스템으로 보내야 합니다. + error_traceback = traceback.format_exc() + print("=" * 20, "UNEXPECTED ERROR", "=" * 20) - traceback.print_exc() + print(error_traceback) print("=" * 50) - # 사용자에게는 간단한 500 에러 메시지만 보여줍니다. - error_response = {"code": CommonCode.FAIL.code, "message": CommonCode.FAIL.message, "data": None} - - return JSONResponse( - status_code=CommonCode.FAIL.http_status, - content=error_response, - ) + return _create_error_response(code=CommonCode.FAIL, data={"traceback": error_traceback}) diff --git a/app/core/status.py b/app/core/status.py index bd52104..d53ced3 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -23,7 +23,8 @@ class CommonCode(Enum): NO_VALUE = (status.HTTP_400_BAD_REQUEST, "4000", "필수 값이 존재하지 않습니다.") DUPLICATION = (status.HTTP_409_CONFLICT, "4001", "이미 존재하는 데이터입니다.") NO_SEARCH_DATA = (status.HTTP_404_NOT_FOUND, "4002", "요청한 데이터를 찾을 수 없습니다.") - INVALID_ENUM_VALUE = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4003", "지원하지 않는 데이터베이스 값입니다.") + INVALID_PARAMETER = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4003", "필수 값이 누락되었습니다.") + INVALID_ENUM_VALUE = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4101", "지원하지 않는 데이터베이스 값입니다.") # ================================== # 서버 오류 (Server Error) - 5xx diff --git a/app/main.py b/app/main.py index 8a0f03b..1f86c20 100644 --- a/app/main.py +++ b/app/main.py @@ -2,17 +2,24 @@ import uvicorn from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError from app.api import health # 헬스 체크 from app.api.api_router import api_router -from app.core.exceptions import APIException, api_exception_handler, generic_exception_handler +from app.core.exceptions import ( + APIException, + api_exception_handler, + generic_exception_handler, + validation_exception_handler, +) from app.db.init_db import initialize_database app = FastAPI() # 전역 예외 처리기 등록 -app.add_exception_handler(APIException, api_exception_handler) app.add_exception_handler(Exception, generic_exception_handler) +app.add_exception_handler(APIException, api_exception_handler) +app.add_exception_handler(RequestValidationError, validation_exception_handler) # 라우터 app.include_router(health.router) From cc27d9552985daa5439279d197b56280b29af11f Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Tue, 5 Aug 2025 23:04:26 +0900 Subject: [PATCH 062/220] =?UTF-8?q?refactor:=20=ED=98=95=EC=8B=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20sqlite3=20=EB=B6=80=EB=B6=84?= =?UTF-8?q?=20=EA=B0=92=20=EA=B0=80=EC=A0=B8=EC=98=A4=EB=8A=94=20=EA=B2=8C?= =?UTF-8?q?=20=EB=8B=AC=EB=9D=BC=20=EC=95=88=EB=90=98=EB=8D=98=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/driver_api.py | 13 +++++++++---- app/core/enum/db_driver.py | 2 +- app/services/driver_service.py | 29 ++++++++++++++++++++--------- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/app/api/driver_api.py b/app/api/driver_api.py index 26b4296..b3cfd72 100644 --- a/app/api/driver_api.py +++ b/app/api/driver_api.py @@ -1,13 +1,15 @@ # app/api/driver_api.py -from fastapi import APIRouter +from fastapi import APIRouter, Depends from app.core.enum.db_driver import DBTypesEnum from app.core.exceptions import APIException from app.core.response import ResponseMessage from app.core.status import CommonCode from app.schemas.driver_info import DriverInfo -from app.services.driver_service import db_driver_info +from app.services.driver_service import DriverService, driver_service + +driver_service_dependency = Depends(lambda: driver_service) router = APIRouter() @@ -17,11 +19,14 @@ response_model=ResponseMessage, summary="DB 드라이버 정보 조회", ) -def read_driver_info(driver_id: str) -> ResponseMessage: +def read_driver_info( + driver_id: str, + service: DriverService = driver_service_dependency, +) -> ResponseMessage: """경로 파라미터로 받은 driver_id에 해당하는 DB 드라이버의 지원 정보를 조회합니다.""" try: db_type_enum = DBTypesEnum[driver_id.lower()] driver_info_data = DriverInfo.from_enum(db_type_enum) - return ResponseMessage.success(value=db_driver_info(driver_info_data)) + return ResponseMessage.success(value=service.read_driver_info(driver_info_data)) except KeyError: raise APIException(CommonCode.INVALID_ENUM_VALUE) from KeyError diff --git a/app/core/enum/db_driver.py b/app/core/enum/db_driver.py index 0ccf151..e578d0a 100644 --- a/app/core/enum/db_driver.py +++ b/app/core/enum/db_driver.py @@ -8,6 +8,6 @@ class DBTypesEnum(Enum): postgresql = "psycopg2" mysql = "mysql.connector" sqlite = "sqlite3" - oracle = "cx_Oracle" + oracle = "oracledb" sqlserver = "pyodbc" mariadb = "pymysql" diff --git a/app/services/driver_service.py b/app/services/driver_service.py index a3ade45..7080808 100644 --- a/app/services/driver_service.py +++ b/app/services/driver_service.py @@ -1,20 +1,31 @@ # app/service/driver_service.py import importlib import os +import sqlite3 from app.core.exceptions import APIException from app.core.status import CommonCode from app.schemas.driver_info import DriverInfo -def db_driver_info(driver_info: DriverInfo): - try: - mod = importlib.import_module(driver_info.driver_name) - version = getattr(mod, "__version__", None) - path = getattr(mod.__spec__, "origin", None) - size = os.path.getsize(path) if path else None +class DriverService: + def read_driver_info(self, driver_info: DriverInfo): + try: + driver_name = driver_info.driver_name - return driver_info.update_from_module(version, size) + if driver_name == "sqlite3": + version = sqlite3.sqlite_version + path = sqlite3.__file__ - except (ModuleNotFoundError, AttributeError, OSError) as e: - raise APIException(CommonCode.FAIL) from e + else: + mod = importlib.import_module(driver_name) + version = getattr(mod, "__version__", None) + path = getattr(mod.__spec__, "origin", None) + + size = os.path.getsize(path) if path else None + return driver_info.update_from_module(version, size) + except (ModuleNotFoundError, AttributeError, OSError) as e: + raise APIException(CommonCode.FAIL) from e + + +driver_service = DriverService() From 69003291c1759a336e0dd10f9f262b96ddeee438 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Tue, 5 Aug 2025 23:08:48 +0900 Subject: [PATCH 063/220] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?pc=EC=97=90=20=EB=93=9C=EB=9D=BC=EC=9D=B4=EB=B2=84=EA=B0=80=20?= =?UTF-8?q?=EC=84=A4=EC=B9=98=20=EB=90=98=EC=96=B4=EC=95=BC=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20sql=20server=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20ora?= =?UTF-8?q?cle=20=EA=B8=B0=EB=B3=B8=20=EB=B2=84=EC=A0=84=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- poetry.lock | 660 +++++++++++++++++++++++++++++-------------------- pyproject.toml | 5 +- 2 files changed, 395 insertions(+), 270 deletions(-) diff --git a/poetry.lock b/poetry.lock index 7fb114c..8b5f1c4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -27,14 +27,14 @@ files = [ [[package]] name = "anyio" -version = "4.9.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" +version = "4.10.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, + {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"}, + {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, ] [package.dependencies] @@ -43,8 +43,6 @@ sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -94,16 +92,97 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2025.7.14" +version = "2025.8.3" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2"}, - {file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"}, + {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, + {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "cfgv" version = "3.4.0" @@ -146,141 +225,175 @@ markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \" [[package]] name = "coverage" -version = "7.10.0" +version = "7.10.2" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "coverage-7.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cbd823f7ea5286c26406ad9e54268544d82f3d1cadb6d4f3b85e9877f0cab1ef"}, - {file = "coverage-7.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab3f7a5dbaab937df0b9e9e8ec6eab235ba9a6f29d71fd3b24335affaed886cc"}, - {file = "coverage-7.10.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8c63aaf850523d8cbe3f5f1a5c78f689b223797bef902635f2493ab43498f36c"}, - {file = "coverage-7.10.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c3133ce3fa84023f7c6921c4dca711be0b658784c5a51a797168229eae26172"}, - {file = "coverage-7.10.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3747d1d0af85b17d3a156cd30e4bbacf893815e846dc6c07050e9769da2b138e"}, - {file = "coverage-7.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:241923b350437f6a7cb343d9df72998305ef940c3c40009f06e05029a047677c"}, - {file = "coverage-7.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:13e82e499309307104d58ac66f9eed237f7aaceab4325416645be34064d9a2be"}, - {file = "coverage-7.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bf73cdde4f6c9cd4457b00bf1696236796ac3a241f859a55e0f84a4c58326a7f"}, - {file = "coverage-7.10.0-cp310-cp310-win32.whl", hash = "sha256:2396e13275b37870a3345f58bce8b15a7e0a985771d13a4b16ce9129954e07d6"}, - {file = "coverage-7.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d45c7c71fb3d2da92ab893602e3f28f2d1560cec765a27e1824a6e0f7e92cfd"}, - {file = "coverage-7.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4abc01843581a6f9dd72d4d15761861190973a2305416639435ef509288f7a04"}, - {file = "coverage-7.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2093297773111d7d748fe4a99b68747e57994531fb5c57bbe439af17c11c169"}, - {file = "coverage-7.10.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58240e27815bf105bd975c2fd42e700839f93d5aad034ef976411193ca32dbfd"}, - {file = "coverage-7.10.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d019eac999b40ad48521ea057958b07a9f549c0c6d257a20e5c7c4ba91af8d1c"}, - {file = "coverage-7.10.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0a1f5454bc80faf4ceab10d1d48f025f92046c9c0f3bec2e1a9dda55137f8"}, - {file = "coverage-7.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a93dd7759c416dd1cc754123b926d065055cb9a33b6699e64a1e5bdfae1ff459"}, - {file = "coverage-7.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7b3d737266048368a6ffd68f1ecd662c54de56535c82eb8f98a55ac216a72cbd"}, - {file = "coverage-7.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:93227c2707cb0effd9163cd0d8f0d9ab628982f7a3e915d6d64c7107867b9a07"}, - {file = "coverage-7.10.0-cp311-cp311-win32.whl", hash = "sha256:69270af3014ab3058ad6108c6d0e218166f568b5a7a070dc3d62c0a63aca1c4d"}, - {file = "coverage-7.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:43c16bbb661a7b4dafac0ab69e44d6dbcc6a64c4d93aefd89edc6f8911b6ab4a"}, - {file = "coverage-7.10.0-cp311-cp311-win_arm64.whl", hash = "sha256:14e7c23fcb74ed808efb4eb48fcd25a759f0e20f685f83266d1df174860e4733"}, - {file = "coverage-7.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a2adcfdaf3b4d69b0c64ad024fe9dd6996782b52790fb6033d90f36f39e287df"}, - {file = "coverage-7.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d7b27c2c0840e8eeff3f1963782bd9d3bc767488d2e67a31de18d724327f9f6"}, - {file = "coverage-7.10.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0ed50429786e935517570b08576a661fd79032e6060985ab492b9d39ba8e66ee"}, - {file = "coverage-7.10.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7171c139ab6571d70460ecf788b1dcaf376bfc75a42e1946b8c031d062bbbad4"}, - {file = "coverage-7.10.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a726aac7e6e406e403cdee4c443a13aed3ea3d67d856414c5beacac2e70c04e"}, - {file = "coverage-7.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2886257481a14e953e96861a00c0fe7151117a523f0470a51e392f00640bba03"}, - {file = "coverage-7.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:536578b79521e59c385a2e0a14a5dc2a8edd58761a966d79368413e339fc9535"}, - {file = "coverage-7.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77fae95558f7804a9ceefabf3c38ad41af1da92b39781b87197c6440dcaaa967"}, - {file = "coverage-7.10.0-cp312-cp312-win32.whl", hash = "sha256:97803e14736493eb029558e1502fe507bd6a08af277a5c8eeccf05c3e970cb84"}, - {file = "coverage-7.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:4c73ab554e54ffd38d114d6bc4a7115fb0c840cf6d8622211bee3da26e4bd25d"}, - {file = "coverage-7.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:3ae95d5a9aedab853641026b71b2ddd01983a0a7e9bf870a20ef3c8f5d904699"}, - {file = "coverage-7.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d883fee92b9245c0120fa25b5d36de71ccd4cfc29735906a448271e935d8d86d"}, - {file = "coverage-7.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c87e59e88268d30e33d3665ede4fbb77b513981a2df0059e7c106ca3de537586"}, - {file = "coverage-7.10.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f669d969f669a11d6ceee0b733e491d9a50573eb92a71ffab13b15f3aa2665d4"}, - {file = "coverage-7.10.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9582bd6c6771300a847d328c1c4204e751dbc339a9e249eecdc48cada41f72e6"}, - {file = "coverage-7.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91f97e9637dc7977842776fdb7ad142075d6fa40bc1b91cb73685265e0d31d32"}, - {file = "coverage-7.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae4fa92b6601a62367c6c9967ad32ad4e28a89af54b6bb37d740946b0e0534dd"}, - {file = "coverage-7.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3a5cc8b97473e7b3623dd17a42d2194a2b49de8afecf8d7d03c8987237a9552c"}, - {file = "coverage-7.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc1cbb7f623250e047c32bd7aa1bb62ebc62608d5004d74df095e1059141ac88"}, - {file = "coverage-7.10.0-cp313-cp313-win32.whl", hash = "sha256:1380cc5666d778e77f1587cd88cc317158111f44d54c0dd3975f0936993284e0"}, - {file = "coverage-7.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:bf03cf176af098ee578b754a03add4690b82bdfe070adfb5d192d0b1cd15cf82"}, - {file = "coverage-7.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:8041c78cd145088116db2329b2fb6e89dc338116c962fbe654b7e9f5d72ab957"}, - {file = "coverage-7.10.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:37cc2c06052771f48651160c080a86431884db9cd62ba622cab71049b90a95b3"}, - {file = "coverage-7.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:91f37270b16178b05fa107d85713d29bf21606e37b652d38646eef5f2dfbd458"}, - {file = "coverage-7.10.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f9b0b0168864d09bcb9a3837548f75121645c4cfd0efce0eb994c221955c5b10"}, - {file = "coverage-7.10.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0be435d3b616e7d3ee3f9ebbc0d784a213986fe5dff9c6f1042ee7cfd30157"}, - {file = "coverage-7.10.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e9aba1c4434b837b1d567a533feba5ce205e8e91179c97974b28a14c23d3a0"}, - {file = "coverage-7.10.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a0b0c481e74dfad631bdc2c883e57d8b058e5c90ba8ef087600995daf7bbec18"}, - {file = "coverage-7.10.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8aec1b7c8922808a433c13cd44ace6fceac0609f4587773f6c8217a06102674b"}, - {file = "coverage-7.10.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:04ec59ceb3a594af0927f2e0d810e1221212abd9a2e6b5b917769ff48760b460"}, - {file = "coverage-7.10.0-cp313-cp313t-win32.whl", hash = "sha256:b6871e62d29646eb9b3f5f92def59e7575daea1587db21f99e2b19561187abda"}, - {file = "coverage-7.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff99cff2be44f78920b76803f782e91ffb46ccc7fa89eccccc0da3ca94285b64"}, - {file = "coverage-7.10.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3246b63501348fe47299d12c47a27cfc221cfbffa1c2d857bcc8151323a4ae4f"}, - {file = "coverage-7.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:1f628d91f941a375b4503cb486148dbeeffb48e17bc080e0f0adfee729361574"}, - {file = "coverage-7.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a0e101d5af952d233557e445f42ebace20b06b4ceb615581595ced5386caa78"}, - {file = "coverage-7.10.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec4c1abbcc53f9f650acb14ea71725d88246a9e14ed42f8dd1b4e1b694e9d842"}, - {file = "coverage-7.10.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9c95f3a7f041b4cc68a8e3fecfa6366170c13ac773841049f1cd19c8650094e0"}, - {file = "coverage-7.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a2cd597b69c16d24e310611f2ed6fcfb8f09429316038c03a57e7b4f5345244"}, - {file = "coverage-7.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5e18591906a40c2b3609196c9879136aa4a47c5405052ca6b065ab10cb0b71d0"}, - {file = "coverage-7.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:485c55744252ed3f300cc1a0f5f365e684a0f2651a7aed301f7a67125906b80e"}, - {file = "coverage-7.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4dabea1516e5b0e9577282b149c8015e4dceeb606da66fb8d9d75932d5799bf5"}, - {file = "coverage-7.10.0-cp314-cp314-win32.whl", hash = "sha256:ac455f0537af22333fdc23b824cff81110dff2d47300bb2490f947b7c9a16017"}, - {file = "coverage-7.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:b3c94b532f52f95f36fbfde3e178510a4d04eea640b484b2fe8f1491338dc653"}, - {file = "coverage-7.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:2f807f2c3a9da99c80dfa73f09ef5fc3bd21e70c73ba1c538f23396a3a772252"}, - {file = "coverage-7.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0a889ef25215990f65073c32cadf37483363a6a22914186dedc15a6b1a597d50"}, - {file = "coverage-7.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c638ecf3123805bacbf71aff8091e93af490c676fca10ab4e442375076e483"}, - {file = "coverage-7.10.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2f2c0df0cbcf7dffa14f88a99c530cdef3f4fcfe935fa4f95d28be2e7ebc570"}, - {file = "coverage-7.10.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:048d19a5d641a2296745ab59f34a27b89a08c48d6d432685f22aac0ec1ea447f"}, - {file = "coverage-7.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1209b65d302d7a762004be37ab9396cbd8c99525ed572bdf455477e3a9449e06"}, - {file = "coverage-7.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e44aa79a36a7a0aec6ea109905a4a7c28552d90f34e5941b36217ae9556657d5"}, - {file = "coverage-7.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96124be864b89395770c9a14652afcddbcdafb99466f53a9281c51d1466fb741"}, - {file = "coverage-7.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aad222e841f94b42bd1d6be71737fade66943853f0807cf87887c88f70883a2a"}, - {file = "coverage-7.10.0-cp314-cp314t-win32.whl", hash = "sha256:0eed5354d28caa5c8ad60e07e938f253e4b2810ea7dd56784339b6ce98b6f104"}, - {file = "coverage-7.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3da35f9980058acb960b2644527cc3911f1e00f94d309d704b309fa984029109"}, - {file = "coverage-7.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cb9e138dfa8a4b5c52c92a537651e2ca4f2ca48d8cb1bc01a2cbe7a5773c2426"}, - {file = "coverage-7.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cf283ec9c6878826291b17442eb5c32d3d252dc77d25e082b460b2d2ea67ba3c"}, - {file = "coverage-7.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a83488c9fc6fff487f2ab551f9b64c70672357b8949f0951b0cd778b3ed8165"}, - {file = "coverage-7.10.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b86df3a7494d12338c11e59f210a0498d6109bbc3a4037f44de517ebb30a9c6b"}, - {file = "coverage-7.10.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6de9b460809e5e4787b742e786a36ae2346a53982e2be317cdcb7a33c56412fb"}, - {file = "coverage-7.10.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de5ef8a5954d63fa26a6aaa4600e48f885ce70fe495e8fce2c43aa9241fc9434"}, - {file = "coverage-7.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f178fe5e96f1e057527d5d0b20ab76b8616e0410169c33716cc226118eaf2c4f"}, - {file = "coverage-7.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4a38c42f0182a012fa9ec25bc6057e51114c1ba125be304f3f776d6d283cb303"}, - {file = "coverage-7.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bf09beb5c1785cb36aad042455c0afab561399b74bb8cdaf6e82b7d77322df99"}, - {file = "coverage-7.10.0-cp39-cp39-win32.whl", hash = "sha256:cb8dfbb5d3016cb8d1940444c0c69b40cdc6c8bde724b07716ee5ea47b5273c6"}, - {file = "coverage-7.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:58ff22653cd93d563110d1ff2aef958f5f21be9e917762f8124d0e36f80f172a"}, - {file = "coverage-7.10.0-py3-none-any.whl", hash = "sha256:310a786330bb0463775c21d68e26e79973839b66d29e065c5787122b8dd4489f"}, - {file = "coverage-7.10.0.tar.gz", hash = "sha256:2768885aef484b5dcde56262cbdfba559b770bfc46994fe9485dc3614c7a5867"}, + {file = "coverage-7.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:79f0283ab5e6499fd5fe382ca3d62afa40fb50ff227676a3125d18af70eabf65"}, + {file = "coverage-7.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4545e906f595ee8ab8e03e21be20d899bfc06647925bc5b224ad7e8c40e08b8"}, + {file = "coverage-7.10.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ae385e1d58fbc6a9b1c315e5510ac52281e271478b45f92ca9b5ad42cf39643f"}, + {file = "coverage-7.10.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f0cbe5f7dd19f3a32bac2251b95d51c3b89621ac88a2648096ce40f9a5aa1e7"}, + {file = "coverage-7.10.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd17f427f041f6b116dc90b4049c6f3e1230524407d00daa2d8c7915037b5947"}, + {file = "coverage-7.10.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7f10ca4cde7b466405cce0a0e9971a13eb22e57a5ecc8b5f93a81090cc9c7eb9"}, + {file = "coverage-7.10.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3b990df23dd51dccce26d18fb09fd85a77ebe46368f387b0ffba7a74e470b31b"}, + {file = "coverage-7.10.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc3902584d25c7eef57fb38f440aa849a26a3a9f761a029a72b69acfca4e31f8"}, + {file = "coverage-7.10.2-cp310-cp310-win32.whl", hash = "sha256:9dd37e9ac00d5eb72f38ed93e3cdf2280b1dbda3bb9b48c6941805f265ad8d87"}, + {file = "coverage-7.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:99d16f15cb5baf0729354c5bd3080ae53847a4072b9ba1e10957522fb290417f"}, + {file = "coverage-7.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c3b210d79925a476dfc8d74c7d53224888421edebf3a611f3adae923e212b27"}, + {file = "coverage-7.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf67d1787cd317c3f8b2e4c6ed1ae93497be7e30605a0d32237ac37a37a8a322"}, + {file = "coverage-7.10.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:069b779d03d458602bc0e27189876e7d8bdf6b24ac0f12900de22dd2154e6ad7"}, + {file = "coverage-7.10.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c2de4cb80b9990e71c62c2d3e9f3ec71b804b1f9ca4784ec7e74127e0f42468"}, + {file = "coverage-7.10.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75bf7ab2374a7eb107602f1e07310cda164016cd60968abf817b7a0b5703e288"}, + {file = "coverage-7.10.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3f37516458ec1550815134937f73d6d15b434059cd10f64678a2068f65c62406"}, + {file = "coverage-7.10.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:de3c6271c482c250d3303fb5c6bdb8ca025fff20a67245e1425df04dc990ece9"}, + {file = "coverage-7.10.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:98a838101321ac3089c9bb1d4bfa967e8afed58021fda72d7880dc1997f20ae1"}, + {file = "coverage-7.10.2-cp311-cp311-win32.whl", hash = "sha256:f2a79145a531a0e42df32d37be5af069b4a914845b6f686590739b786f2f7bce"}, + {file = "coverage-7.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:e4f5f1320f8ee0d7cfa421ceb257bef9d39fd614dd3ddcfcacd284d4824ed2c2"}, + {file = "coverage-7.10.2-cp311-cp311-win_arm64.whl", hash = "sha256:d8f2d83118f25328552c728b8e91babf93217db259ca5c2cd4dd4220b8926293"}, + {file = "coverage-7.10.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:890ad3a26da9ec7bf69255b9371800e2a8da9bc223ae5d86daeb940b42247c83"}, + {file = "coverage-7.10.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38fd1ccfca7838c031d7a7874d4353e2f1b98eb5d2a80a2fe5732d542ae25e9c"}, + {file = "coverage-7.10.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:76c1ffaaf4f6f0f6e8e9ca06f24bb6454a7a5d4ced97a1bc466f0d6baf4bd518"}, + {file = "coverage-7.10.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:86da8a3a84b79ead5c7d0e960c34f580bc3b231bb546627773a3f53c532c2f21"}, + {file = "coverage-7.10.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99cef9731c8a39801830a604cc53c93c9e57ea8b44953d26589499eded9576e0"}, + {file = "coverage-7.10.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ea58b112f2966a8b91eb13f5d3b1f8bb43c180d624cd3283fb33b1cedcc2dd75"}, + {file = "coverage-7.10.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:20f405188d28da9522b7232e51154e1b884fc18d0b3a10f382d54784715bbe01"}, + {file = "coverage-7.10.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:64586ce42bbe0da4d9f76f97235c545d1abb9b25985a8791857690f96e23dc3b"}, + {file = "coverage-7.10.2-cp312-cp312-win32.whl", hash = "sha256:bc2e69b795d97ee6d126e7e22e78a509438b46be6ff44f4dccbb5230f550d340"}, + {file = "coverage-7.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:adda2268b8cf0d11f160fad3743b4dfe9813cd6ecf02c1d6397eceaa5b45b388"}, + {file = "coverage-7.10.2-cp312-cp312-win_arm64.whl", hash = "sha256:164429decd0d6b39a0582eaa30c67bf482612c0330572343042d0ed9e7f15c20"}, + {file = "coverage-7.10.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:aca7b5645afa688de6d4f8e89d30c577f62956fefb1bad021490d63173874186"}, + {file = "coverage-7.10.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:96e5921342574a14303dfdb73de0019e1ac041c863743c8fe1aa6c2b4a257226"}, + {file = "coverage-7.10.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:11333094c1bff621aa811b67ed794865cbcaa99984dedea4bd9cf780ad64ecba"}, + {file = "coverage-7.10.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6eb586fa7d2aee8d65d5ae1dd71414020b2f447435c57ee8de8abea0a77d5074"}, + {file = "coverage-7.10.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d358f259d8019d4ef25d8c5b78aca4c7af25e28bd4231312911c22a0e824a57"}, + {file = "coverage-7.10.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5250bda76e30382e0a2dcd68d961afcab92c3a7613606e6269855c6979a1b0bb"}, + {file = "coverage-7.10.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a91e027d66eff214d88d9afbe528e21c9ef1ecdf4956c46e366c50f3094696d0"}, + {file = "coverage-7.10.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:228946da741558904e2c03ce870ba5efd9cd6e48cbc004d9a27abee08100a15a"}, + {file = "coverage-7.10.2-cp313-cp313-win32.whl", hash = "sha256:95e23987b52d02e7c413bf2d6dc6288bd5721beb518052109a13bfdc62c8033b"}, + {file = "coverage-7.10.2-cp313-cp313-win_amd64.whl", hash = "sha256:f35481d42c6d146d48ec92d4e239c23f97b53a3f1fbd2302e7c64336f28641fe"}, + {file = "coverage-7.10.2-cp313-cp313-win_arm64.whl", hash = "sha256:65b451949cb789c346f9f9002441fc934d8ccedcc9ec09daabc2139ad13853f7"}, + {file = "coverage-7.10.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e8415918856a3e7d57a4e0ad94651b761317de459eb74d34cc1bb51aad80f07e"}, + {file = "coverage-7.10.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f287a25a8ca53901c613498e4a40885b19361a2fe8fbfdbb7f8ef2cad2a23f03"}, + {file = "coverage-7.10.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:75cc1a3f8c88c69bf16a871dab1fe5a7303fdb1e9f285f204b60f1ee539b8fc0"}, + {file = "coverage-7.10.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca07fa78cc9d26bc8c4740de1abd3489cf9c47cc06d9a8ab3d552ff5101af4c0"}, + {file = "coverage-7.10.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2e117e64c26300032755d4520cd769f2623cde1a1d1c3515b05a3b8add0ade1"}, + {file = "coverage-7.10.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:daaf98009977f577b71f8800208f4d40d4dcf5c2db53d4d822787cdc198d76e1"}, + {file = "coverage-7.10.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ea8d8fe546c528535c761ba424410bbeb36ba8a0f24be653e94b70c93fd8a8ca"}, + {file = "coverage-7.10.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fe024d40ac31eb8d5aae70215b41dafa264676caa4404ae155f77d2fa95c37bb"}, + {file = "coverage-7.10.2-cp313-cp313t-win32.whl", hash = "sha256:8f34b09f68bdadec122ffad312154eda965ade433559cc1eadd96cca3de5c824"}, + {file = "coverage-7.10.2-cp313-cp313t-win_amd64.whl", hash = "sha256:71d40b3ac0f26fa9ffa6ee16219a714fed5c6ec197cdcd2018904ab5e75bcfa3"}, + {file = "coverage-7.10.2-cp313-cp313t-win_arm64.whl", hash = "sha256:abb57fdd38bf6f7dcc66b38dafb7af7c5fdc31ac6029ce373a6f7f5331d6f60f"}, + {file = "coverage-7.10.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a3e853cc04987c85ec410905667eed4bf08b1d84d80dfab2684bb250ac8da4f6"}, + {file = "coverage-7.10.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0100b19f230df72c90fdb36db59d3f39232391e8d89616a7de30f677da4f532b"}, + {file = "coverage-7.10.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9c1cd71483ea78331bdfadb8dcec4f4edfb73c7002c1206d8e0af6797853f5be"}, + {file = "coverage-7.10.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9f75dbf4899e29a37d74f48342f29279391668ef625fdac6d2f67363518056a1"}, + {file = "coverage-7.10.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7df481e7508de1c38b9b8043da48d94931aefa3e32b47dd20277e4978ed5b95"}, + {file = "coverage-7.10.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:835f39e618099325e7612b3406f57af30ab0a0af350490eff6421e2e5f608e46"}, + {file = "coverage-7.10.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:12e52b5aa00aa720097d6947d2eb9e404e7c1101ad775f9661ba165ed0a28303"}, + {file = "coverage-7.10.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:718044729bf1fe3e9eb9f31b52e44ddae07e434ec050c8c628bf5adc56fe4bdd"}, + {file = "coverage-7.10.2-cp314-cp314-win32.whl", hash = "sha256:f256173b48cc68486299d510a3e729a96e62c889703807482dbf56946befb5c8"}, + {file = "coverage-7.10.2-cp314-cp314-win_amd64.whl", hash = "sha256:2e980e4179f33d9b65ac4acb86c9c0dde904098853f27f289766657ed16e07b3"}, + {file = "coverage-7.10.2-cp314-cp314-win_arm64.whl", hash = "sha256:14fb5b6641ab5b3c4161572579f0f2ea8834f9d3af2f7dd8fbaecd58ef9175cc"}, + {file = "coverage-7.10.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e96649ac34a3d0e6491e82a2af71098e43be2874b619547c3282fc11d3840a4b"}, + {file = "coverage-7.10.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1a2e934e9da26341d342d30bfe91422bbfdb3f1f069ec87f19b2909d10d8dcc4"}, + {file = "coverage-7.10.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:651015dcd5fd9b5a51ca79ece60d353cacc5beaf304db750407b29c89f72fe2b"}, + {file = "coverage-7.10.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81bf6a32212f9f66da03d63ecb9cd9bd48e662050a937db7199dbf47d19831de"}, + {file = "coverage-7.10.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d800705f6951f75a905ea6feb03fff8f3ea3468b81e7563373ddc29aa3e5d1ca"}, + {file = "coverage-7.10.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:248b5394718e10d067354448dc406d651709c6765669679311170da18e0e9af8"}, + {file = "coverage-7.10.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5c61675a922b569137cf943770d7ad3edd0202d992ce53ac328c5ff68213ccf4"}, + {file = "coverage-7.10.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:52d708b5fd65589461381fa442d9905f5903d76c086c6a4108e8e9efdca7a7ed"}, + {file = "coverage-7.10.2-cp314-cp314t-win32.whl", hash = "sha256:916369b3b914186b2c5e5ad2f7264b02cff5df96cdd7cdad65dccd39aa5fd9f0"}, + {file = "coverage-7.10.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5b9d538e8e04916a5df63052d698b30c74eb0174f2ca9cd942c981f274a18eaf"}, + {file = "coverage-7.10.2-cp314-cp314t-win_arm64.whl", hash = "sha256:04c74f9ef1f925456a9fd23a7eef1103126186d0500ef9a0acb0bd2514bdc7cc"}, + {file = "coverage-7.10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:765b13b164685a2f8b2abef867ad07aebedc0e090c757958a186f64e39d63dbd"}, + {file = "coverage-7.10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a219b70100500d0c7fd3ebb824a3302efb6b1a122baa9d4eb3f43df8f0b3d899"}, + {file = "coverage-7.10.2-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e33e79a219105aa315439ee051bd50b6caa705dc4164a5aba6932c8ac3ce2d98"}, + {file = "coverage-7.10.2-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc3945b7bad33957a9eca16e9e5eae4b17cb03173ef594fdaad228f4fc7da53b"}, + {file = "coverage-7.10.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bdff88e858ee608a924acfad32a180d2bf6e13e059d6a7174abbae075f30436"}, + {file = "coverage-7.10.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:44329cbed24966c0b49acb386352c9722219af1f0c80db7f218af7793d251902"}, + {file = "coverage-7.10.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:be127f292496d0fbe20d8025f73221b36117b3587f890346e80a13b310712982"}, + {file = "coverage-7.10.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6c031da749a05f7a01447dd7f47beedb498edd293e31e1878c0d52db18787df0"}, + {file = "coverage-7.10.2-cp39-cp39-win32.whl", hash = "sha256:22aca3e691c7709c5999ccf48b7a8ff5cf5a8bd6fe9b36efbd4993f5a36b2fcf"}, + {file = "coverage-7.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:c7195444b932356055a8e287fa910bf9753a84a1bc33aeb3770e8fca521e032e"}, + {file = "coverage-7.10.2-py3-none-any.whl", hash = "sha256:95db3750dd2e6e93d99fa2498f3a1580581e49c494bddccc6f85c5c21604921f"}, + {file = "coverage-7.10.2.tar.gz", hash = "sha256:5d6e6d84e6dd31a8ded64759626627247d676a23c1b892e1326f7c55c8d61055"}, ] [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] -name = "cx-oracle" -version = "8.3.0" -description = "Python interface to Oracle" +name = "cryptography" +version = "45.0.5" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = "*" +python-versions = "!=3.9.0,!=3.9.1,>=3.7" groups = ["main"] files = [ - {file = "cx_Oracle-8.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b6a23da225f03f50a81980c61dbd6a358c3575f212ca7f4c22bb65a9faf94f7f"}, - {file = "cx_Oracle-8.3.0-cp310-cp310-win32.whl", hash = "sha256:715a8bbda5982af484ded14d184304cc552c1096c82471dd2948298470e88a04"}, - {file = "cx_Oracle-8.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:07f01608dfb6603a8f2a868fc7c7bdc951480f187df8dbc50f4d48c884874e6a"}, - {file = "cx_Oracle-8.3.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4b3afe7a911cebaceda908228d36839f6441cbd38e5df491ec25960562bb01a0"}, - {file = "cx_Oracle-8.3.0-cp36-cp36m-win32.whl", hash = "sha256:076ffb71279d6b2dcbf7df028f62a01e18ce5bb73d8b01eab582bf14a62f4a61"}, - {file = "cx_Oracle-8.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:b82e4b165ffd807a2bd256259a6b81b0a2452883d39f987509e2292d494ea163"}, - {file = "cx_Oracle-8.3.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b902db61dcdcbbf8dd981f5a46d72fef40c5150c7fc0eb0f0698b462d6eb834e"}, - {file = "cx_Oracle-8.3.0-cp37-cp37m-win32.whl", hash = "sha256:4c82ca74442c298ceec56d207450c192e06ecf8ad52eb4aaad0812e147ceabf7"}, - {file = "cx_Oracle-8.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:54164974d526b76fdefb0b66a42b68e1fca5df78713d0eeb8c1d0047b83f6bcf"}, - {file = "cx_Oracle-8.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:410747d542e5f94727f5f0e42e9706c772cf9094fb348ce965ab88b3a9e4d2d8"}, - {file = "cx_Oracle-8.3.0-cp38-cp38-win32.whl", hash = "sha256:3baa878597c5fadb2c72f359f548431c7be001e722ce4a4ebdf3d2293a1bb70b"}, - {file = "cx_Oracle-8.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:de42bdc882abdc5cea54597da27a05593b44143728e5b629ad5d35decb1a2036"}, - {file = "cx_Oracle-8.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:df412238a9948340591beee9ec64fa62a2efacc0d91107034a7023e2991fba97"}, - {file = "cx_Oracle-8.3.0-cp39-cp39-win32.whl", hash = "sha256:70d3cf030aefd71f99b45beba77237b2af448adf5e26be0db3d0d3dee6ea4230"}, - {file = "cx_Oracle-8.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:bf01ce87edb4ef663b2e5bd604e1e0154d2cc2f12b60301f788b569d9db8a900"}, - {file = "cx_Oracle-8.3.0.tar.gz", hash = "sha256:3b2d215af4441463c97ea469b9cc307460739f89fdfa8ea222ea3518f1a424d9"}, + {file = "cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27"}, + {file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e"}, + {file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174"}, + {file = "cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9"}, + {file = "cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63"}, + {file = "cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492"}, + {file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0"}, + {file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a"}, + {file = "cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f"}, + {file = "cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f"}, + {file = "cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a"}, ] +[package.dependencies] +cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] +pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==45.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "distlib" -version = "0.3.9" +version = "0.4.0" description = "Distribution utilities" optional = false python-versions = "*" groups = ["dev"] files = [ - {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, - {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, + {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, + {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, ] [[package]] @@ -574,6 +687,53 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +[[package]] +name = "oracledb" +version = "3.3.0" +description = "Python interface to Oracle Database" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "oracledb-3.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e9b52231f34349165dd9a70fe7ce20bc4d6b4ee1233462937fad79396bb1af6"}, + {file = "oracledb-3.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e9e3da89174461ceebd3401817b4020b3812bfa221fcd6419bfec877972a890"}, + {file = "oracledb-3.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:605a58ade4e967bdf61284cc16417a36f42e5778191c702234adf558b799b822"}, + {file = "oracledb-3.3.0-cp310-cp310-win32.whl", hash = "sha256:f449925215cac7e41ce24107db614f49817d0a3032a595f47212bac418b14345"}, + {file = "oracledb-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:58fb5ec16fd5ff49a2bd163e71d09adda73353bde18cea0eae9b2a41affc2a41"}, + {file = "oracledb-3.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d9adb74f837838e21898d938e3a725cf73099c65f98b0b34d77146b453e945e0"}, + {file = "oracledb-3.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b063d1007882570f170ebde0f364e78d4a70c8f015735cc900663278b9ceef7"}, + {file = "oracledb-3.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:187728f0a2d161676b8c581a9d8f15d9631a8fea1e628f6d0e9fa2f01280cd22"}, + {file = "oracledb-3.3.0-cp311-cp311-win32.whl", hash = "sha256:920f14314f3402c5ab98f2efc5932e0547e9c0a4ca9338641357f73844e3e2b1"}, + {file = "oracledb-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:825edb97976468db1c7e52c78ba38d75ce7e2b71a2e88f8629bcf02be8e68a8a"}, + {file = "oracledb-3.3.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9d25e37d640872731ac9b73f83cbc5fc4743cd744766bdb250488caf0d7696a8"}, + {file = "oracledb-3.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0bf7cdc2b668f939aa364f552861bc7a149d7cd3f3794730d43ef07613b2bf9"}, + {file = "oracledb-3.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe20540fde64a6987046807ea47af93be918fd70b9766b3eb803c01e6d4202e"}, + {file = "oracledb-3.3.0-cp312-cp312-win32.whl", hash = "sha256:db080be9345cbf9506ffdaea3c13d5314605355e76d186ec4edfa49960ffb813"}, + {file = "oracledb-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:be81e3afe79f6c8ece79a86d6067ad1572d2992ce1c590a086f3755a09535eb4"}, + {file = "oracledb-3.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6abc3e4432350839ecb98527707f4929bfb58959159ea440977f621e0db82ac6"}, + {file = "oracledb-3.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6770dabc441adce5c865c9f528992a7228b2e5e59924cbd8588eb159f548fc38"}, + {file = "oracledb-3.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55af5a49db7cbd03cef449ac51165d9aa30f26064481d68a653c81cc5a29ae80"}, + {file = "oracledb-3.3.0-cp313-cp313-win32.whl", hash = "sha256:5b4a68e4d783186cea9236fb0caa295f6da382ba1b80ca7f86d2d045cf29a993"}, + {file = "oracledb-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:ad63c0057d3f764cc2d96d4f6445b89a8ea59b42ed80f719d689292392ce62a3"}, + {file = "oracledb-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:4c574a34a79934b9c6c3f5e4c715053ad3b46e18da38ec28d9c767e0541422ea"}, + {file = "oracledb-3.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172217e7511c58d8d3c09e9385f7d51696de27e639f336ba0a65d15009cd8cda"}, + {file = "oracledb-3.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d450dcada7711007a9a8a2770f81b54c24ba1e1d2456643c3fae7a2ff26b3a29"}, + {file = "oracledb-3.3.0-cp314-cp314-win32.whl", hash = "sha256:b19ca41b3344dc77c53f74d31e0ca442734314593c4bec578a62efebdb1b59d7"}, + {file = "oracledb-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a410dcf69b18ea607f3aed5cb4ecdebeb7bfb5f86e746c09a864c0f5bd563279"}, + {file = "oracledb-3.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2615f4f516a574fdf18e5aadca809bc90ac6ab37889d0293a9192c695fe07cd9"}, + {file = "oracledb-3.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed608fee4e87319618be200d2befcdd17fa534e16f20cf60df6e9cbbfeadf58e"}, + {file = "oracledb-3.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35f6df7bec55314f56d4d87a53a1d5f6a0ded9ee106bc9346a5a4d4fe64aa667"}, + {file = "oracledb-3.3.0-cp39-cp39-win32.whl", hash = "sha256:0434f4ed7ded88120487b2ed3a13c37f89fc62b283960a72ddc051293e971244"}, + {file = "oracledb-3.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:4c0e77e8dd1315f05f3d98d1f08df45f7bedd99612caccf315bb754cb768d692"}, + {file = "oracledb-3.3.0.tar.gz", hash = "sha256:e830d3544a1578296bcaa54c6e8c8ae10a58c7db467c528c4b27adbf9c8b4cb0"}, +] + +[package.dependencies] +cryptography = ">=3.2.1" + +[package.extras] +test = ["numpy", "pandas", "pyarrow"] + [[package]] name = "packaging" version = "25.0" @@ -741,6 +901,19 @@ files = [ {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, ] +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pycryptodome" version = "3.23.0" @@ -943,25 +1116,25 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstaller" -version = "6.14.2" +version = "6.15.0" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false -python-versions = "<3.14,>=3.8" +python-versions = "<3.15,>=3.8" groups = ["dev"] markers = "python_version < \"3.14\"" files = [ - {file = "pyinstaller-6.14.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:d77d18bf5343a1afef2772393d7a489d4ec2282dee5bca549803fc0d74b78330"}, - {file = "pyinstaller-6.14.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:3fa0c391e1300a9fd7752eb1ffe2950112b88fba9d2743eee2ef218a15f4705f"}, - {file = "pyinstaller-6.14.2-py3-none-manylinux2014_i686.whl", hash = "sha256:077efb2d01d16d9c8fdda3ad52788f0fead2791c5cec9ed6ce058af7e26eb74b"}, - {file = "pyinstaller-6.14.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:fdd2bd020a18736806a6bd5d3c4352f1209b427a96ad6c459d88aec1d90c4f21"}, - {file = "pyinstaller-6.14.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:03862c6b3cf7b16843d24b529f89cd4077cbe467883cd54ce7a81940d6da09d3"}, - {file = "pyinstaller-6.14.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:78827a21ada2a848e98671852d20d74b2955b6e2aaf2359ed13a462e1a603d84"}, - {file = "pyinstaller-6.14.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:185710ab1503dfdfa14c43237d394d96ac183422d588294be42531480dfa6c38"}, - {file = "pyinstaller-6.14.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6c673a7e761bd4a2560cfd5dbe1ccdcfe2dff304b774e6e5242fc5afed953661"}, - {file = "pyinstaller-6.14.2-py3-none-win32.whl", hash = "sha256:1697601aa788e3a52f0b5e620b4741a34b82e6f222ec6e1318b3a1349f566bb2"}, - {file = "pyinstaller-6.14.2-py3-none-win_amd64.whl", hash = "sha256:e10e0e67288d6dcb5898a917dd1d4272aa0ff33f197ad49a0e39618009d63ed9"}, - {file = "pyinstaller-6.14.2-py3-none-win_arm64.whl", hash = "sha256:69fd11ca57e572387826afaa4a1b3d4cb74927d76f231f0308c0bd7872ca5ac1"}, - {file = "pyinstaller-6.14.2.tar.gz", hash = "sha256:142cce0719e79315f0cc26400c2e5c45d9b6b17e7e0491fee444a9f8f16f4917"}, + {file = "pyinstaller-6.15.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:9f00c71c40148cd1e61695b2c6f1e086693d3bcf9bfa22ab513aa4254c3b966f"}, + {file = "pyinstaller-6.15.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:cbcc8eb77320c60722030ac875883b564e00768fe3ff1721c7ba3ad0e0a277e9"}, + {file = "pyinstaller-6.15.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c33e6302bc53db2df1104ed5566bd980b3e0ee7f18416a6e3caa908c12a54542"}, + {file = "pyinstaller-6.15.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:eb902d0fed3bb1f8b7190dc4df5c11f3b59505767e0d56d1ed782b853938bbf3"}, + {file = "pyinstaller-6.15.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b4df862adae7cf1f08eff53c43ace283822447f7f528f72e4f94749062712f15"}, + {file = "pyinstaller-6.15.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:b9ebf16ed0f99016ae8ae5746dee4cb244848a12941539e62ce2eea1df5a3f95"}, + {file = "pyinstaller-6.15.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:22193489e6a22435417103f61e7950363bba600ef36ec3ab1487303668c81092"}, + {file = "pyinstaller-6.15.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:18f743069849dbaee3e10900385f35795a5743eabab55e99dcc42f204e40a0db"}, + {file = "pyinstaller-6.15.0-py3-none-win32.whl", hash = "sha256:60da8f1b5071766b45c0f607d8bc3d7e59ba2c3b262d08f2e4066ba65f3544a2"}, + {file = "pyinstaller-6.15.0-py3-none-win_amd64.whl", hash = "sha256:cbea297e16eeda30b41c300d6ec2fd2abea4dbd8d8a32650eeec36431c94fcd9"}, + {file = "pyinstaller-6.15.0-py3-none-win_arm64.whl", hash = "sha256:f43c035621742cf2d19b84308c60e4e44e72c94786d176b8f6adcde351b5bd98"}, + {file = "pyinstaller-6.15.0.tar.gz", hash = "sha256:a48fc4644ee4aa2aa2a35e7b51f496f8fbd7eecf6a2150646bbf1613ad07bc2d"}, ] [package.dependencies] @@ -969,7 +1142,7 @@ altgraph = "*" macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} packaging = ">=22.0" pefile = {version = ">=2022.5.30,<2024.8.26 || >2024.8.26", markers = "sys_platform == \"win32\""} -pyinstaller-hooks-contrib = ">=2025.5" +pyinstaller-hooks-contrib = ">=2025.8" pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" @@ -979,15 +1152,15 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2025.5" +version = "2025.8" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.8" groups = ["dev"] markers = "python_version < \"3.14\"" files = [ - {file = "pyinstaller_hooks_contrib-2025.5-py3-none-any.whl", hash = "sha256:ebfae1ba341cb0002fb2770fad0edf2b3e913c2728d92df7ad562260988ca373"}, - {file = "pyinstaller_hooks_contrib-2025.5.tar.gz", hash = "sha256:707386770b8fe066c04aad18a71bc483c7b25e18b4750a756999f7da2ab31982"}, + {file = "pyinstaller_hooks_contrib-2025.8-py3-none-any.whl", hash = "sha256:8d0b8cfa0cb689a619294ae200497374234bd4e3994b3ace2a4442274c899064"}, + {file = "pyinstaller_hooks_contrib-2025.8.tar.gz", hash = "sha256:3402ad41dfe9b5110af134422e37fc5d421ba342c6cb980bd67cb30b7415641c"}, ] [package.dependencies] @@ -1010,53 +1183,6 @@ files = [ ed25519 = ["PyNaCl (>=1.4.0)"] rsa = ["cryptography"] -[[package]] -name = "pyodbc" -version = "5.2.0" -description = "DB API module for ODBC" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pyodbc-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb0850e3e3782f57457feed297e220bb20c3e8fd7550d7a6b6bb96112bd9b6fe"}, - {file = "pyodbc-5.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0dae0fb86078c87acf135dbe5afd3c7d15d52ab0db5965c44159e84058c3e2fb"}, - {file = "pyodbc-5.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6493b9c7506ca964b80ad638d0dc82869df7058255d71f04fdd1405e88bcb36b"}, - {file = "pyodbc-5.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e04de873607fb960e71953c164c83e8e5d9291ce0d69e688e54947b254b04902"}, - {file = "pyodbc-5.2.0-cp310-cp310-win32.whl", hash = "sha256:74135cb10c1dcdbd99fe429c61539c232140e62939fa7c69b0a373cc552e4a08"}, - {file = "pyodbc-5.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:d287121eeaa562b9ab3d4c52fa77c793dfedd127049273eb882a05d3d67a8ce8"}, - {file = "pyodbc-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4627779f0a608b51ce2d2fe6d1d395384e65ca36248bf9dbb6d7cf2c8fda1cab"}, - {file = "pyodbc-5.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d997d3b6551273647825c734158ca8a6f682df269f6b3975f2499c01577ddec"}, - {file = "pyodbc-5.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5102007a8c78dd2fc1c1b6f6147de8cfc020f81013e4b46c33e66aaa7d1bf7b1"}, - {file = "pyodbc-5.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e3cbc7075a46c411b531ada557c4aef13d034060a70077717124cabc1717e2d"}, - {file = "pyodbc-5.2.0-cp311-cp311-win32.whl", hash = "sha256:de1ee7ec2eb326b7be5e2c4ce20d472c5ef1a6eb838d126d1d26779ff5486e49"}, - {file = "pyodbc-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:113f904b9852c12f10c7a3288f5a3563ecdbbefe3ccc829074a9eb8255edcd29"}, - {file = "pyodbc-5.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be43d1ece4f2cf4d430996689d89a1a15aeb3a8da8262527e5ced5aee27e89c3"}, - {file = "pyodbc-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9f7badd0055221a744d76c11440c0856fd2846ed53b6555cf8f0a8893a3e4b03"}, - {file = "pyodbc-5.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad633c52f4f4e7691daaa2278d6e6ebb2fe4ae7709e610e22c7dd1a1d620cf8b"}, - {file = "pyodbc-5.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97d086a8f7a302b74c9c2e77bedf954a603b19168af900d4d3a97322e773df63"}, - {file = "pyodbc-5.2.0-cp312-cp312-win32.whl", hash = "sha256:0e4412f8e608db2a4be5bcc75f9581f386ed6a427dbcb5eac795049ba6fc205e"}, - {file = "pyodbc-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1f5686b142759c5b2bdbeaa0692622c2ebb1f10780eb3c174b85f5607fbcf55"}, - {file = "pyodbc-5.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:26844d780045bbc3514d5c2f0d89e7fda7df7db0bd24292eb6902046f5730885"}, - {file = "pyodbc-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26d2d8fd53b71204c755abc53b0379df4e23fd9a40faf211e1cb87e8a32470f0"}, - {file = "pyodbc-5.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a27996b6d27e275dfb5fe8a34087ba1cacadfd1439e636874ef675faea5149d9"}, - {file = "pyodbc-5.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaf42c4bd323b8fd01f1cd900cca2d09232155f9b8f0b9bcd0be66763588ce64"}, - {file = "pyodbc-5.2.0-cp313-cp313-win32.whl", hash = "sha256:207f16b7e9bf09c591616429ebf2b47127e879aad21167ac15158910dc9bbcda"}, - {file = "pyodbc-5.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:96d3127f28c0dacf18da7ae009cd48eac532d3dcc718a334b86a3c65f6a5ef5c"}, - {file = "pyodbc-5.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:770e1ac2e7bdf31439bf1d57a1d34ae37d6151216367e8e3f6cdc275006c8bb0"}, - {file = "pyodbc-5.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4fde753fcea625bfaed36edae34c2fba15bf0b5d0ea27474ee038ef47b684d1d"}, - {file = "pyodbc-5.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d57843b9792994f9e73b91667da6452a4f2d7caaa2499598783eb972c4b6eb93"}, - {file = "pyodbc-5.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1f38adc47d36af392475cd4aaae0f35652fdc9e8364bf155810fe1be591336f"}, - {file = "pyodbc-5.2.0-cp38-cp38-win32.whl", hash = "sha256:dc5342d1d09466f9e76e3979551f9205a01ff0ea78b02d2d889171e8c3c4fb9c"}, - {file = "pyodbc-5.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5323be83fedc79a6d1e1b96e67bdc368c1d3f1562b8f8184b735acdd749ae9"}, - {file = "pyodbc-5.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9e8f4ee2c523bbe85124540ffad62a3b62ae481f012e390ef93e0602b6302e5e"}, - {file = "pyodbc-5.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:057b8ede91b21d9f0ef58210d1ca1aad704e641ca68ac6b02f109d86b61d7402"}, - {file = "pyodbc-5.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f0ecbc7067467df95c9b8bd38fb2682c4a13a3402d77dccaddf1e145cea8cc0"}, - {file = "pyodbc-5.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b7f8324fa01c09fe4843ad8adb0b131299ef263a1fb9e63830c9cd1d5c45e4"}, - {file = "pyodbc-5.2.0-cp39-cp39-win32.whl", hash = "sha256:600ef6f562f609f5612ffaa8a93827249150aa3030c867937c87b24a1608967e"}, - {file = "pyodbc-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:b77556349746fb90416a48bd114cd7323f7e2559a4b263dada935f9b406ba59b"}, - {file = "pyodbc-5.2.0.tar.gz", hash = "sha256:de8be39809c8ddeeee26a4b876a6463529cd487a60d1393eb2a93e9bcd44a8f5"}, -] - [[package]] name = "pytest" version = "8.4.1" @@ -1196,30 +1322,30 @@ files = [ [[package]] name = "ruff" -version = "0.12.2" +version = "0.12.7" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be"}, - {file = "ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e"}, - {file = "ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc"}, - {file = "ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922"}, - {file = "ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b"}, - {file = "ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d"}, - {file = "ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1"}, - {file = "ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4"}, - {file = "ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9"}, - {file = "ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da"}, - {file = "ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce"}, - {file = "ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d"}, - {file = "ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04"}, - {file = "ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342"}, - {file = "ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a"}, - {file = "ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639"}, - {file = "ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12"}, - {file = "ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e"}, + {file = "ruff-0.12.7-py3-none-linux_armv6l.whl", hash = "sha256:76e4f31529899b8c434c3c1dede98c4483b89590e15fb49f2d46183801565303"}, + {file = "ruff-0.12.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:789b7a03e72507c54fb3ba6209e4bb36517b90f1a3569ea17084e3fd295500fb"}, + {file = "ruff-0.12.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e1c2a3b8626339bb6369116e7030a4cf194ea48f49b64bb505732a7fce4f4e3"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32dec41817623d388e645612ec70d5757a6d9c035f3744a52c7b195a57e03860"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47ef751f722053a5df5fa48d412dbb54d41ab9b17875c6840a58ec63ff0c247c"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a828a5fc25a3efd3e1ff7b241fd392686c9386f20e5ac90aa9234a5faa12c423"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5726f59b171111fa6a69d82aef48f00b56598b03a22f0f4170664ff4d8298efb"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74e6f5c04c4dd4aba223f4fe6e7104f79e0eebf7d307e4f9b18c18362124bccd"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d0bfe4e77fba61bf2ccadf8cf005d6133e3ce08793bbe870dd1c734f2699a3e"}, + {file = "ruff-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06bfb01e1623bf7f59ea749a841da56f8f653d641bfd046edee32ede7ff6c606"}, + {file = "ruff-0.12.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e41df94a957d50083fd09b916d6e89e497246698c3f3d5c681c8b3e7b9bb4ac8"}, + {file = "ruff-0.12.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4000623300563c709458d0ce170c3d0d788c23a058912f28bbadc6f905d67afa"}, + {file = "ruff-0.12.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:69ffe0e5f9b2cf2b8e289a3f8945b402a1b19eff24ec389f45f23c42a3dd6fb5"}, + {file = "ruff-0.12.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a07a5c8ffa2611a52732bdc67bf88e243abd84fe2d7f6daef3826b59abbfeda4"}, + {file = "ruff-0.12.7-py3-none-win32.whl", hash = "sha256:c928f1b2ec59fb77dfdf70e0419408898b63998789cc98197e15f560b9e77f77"}, + {file = "ruff-0.12.7-py3-none-win_amd64.whl", hash = "sha256:9c18f3d707ee9edf89da76131956aba1270c6348bfee8f6c647de841eac7194f"}, + {file = "ruff-0.12.7-py3-none-win_arm64.whl", hash = "sha256:dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69"}, + {file = "ruff-0.12.7.tar.gz", hash = "sha256:1fc3193f238bc2d7968772c82831a4ff69252f673be371fb49663f0068b7ec71"}, ] [[package]] @@ -1258,69 +1384,69 @@ files = [ [[package]] name = "sqlalchemy" -version = "2.0.41" +version = "2.0.42" description = "Database Abstraction Library" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "SQLAlchemy-2.0.41-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6854175807af57bdb6425e47adbce7d20a4d79bbfd6f6d6519cd10bb7109a7f8"}, - {file = "SQLAlchemy-2.0.41-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05132c906066142103b83d9c250b60508af556982a385d96c4eaa9fb9720ac2b"}, - {file = "SQLAlchemy-2.0.41-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b4af17bda11e907c51d10686eda89049f9ce5669b08fbe71a29747f1e876036"}, - {file = "SQLAlchemy-2.0.41-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:c0b0e5e1b5d9f3586601048dd68f392dc0cc99a59bb5faf18aab057ce00d00b2"}, - {file = "SQLAlchemy-2.0.41-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0b3dbf1e7e9bc95f4bac5e2fb6d3fb2f083254c3fdd20a1789af965caf2d2348"}, - {file = "SQLAlchemy-2.0.41-cp37-cp37m-win32.whl", hash = "sha256:1e3f196a0c59b0cae9a0cd332eb1a4bda4696e863f4f1cf84ab0347992c548c2"}, - {file = "SQLAlchemy-2.0.41-cp37-cp37m-win_amd64.whl", hash = "sha256:6ab60a5089a8f02009f127806f777fca82581c49e127f08413a66056bd9166dd"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1f09b6821406ea1f94053f346f28f8215e293344209129a9c0fcc3578598d7b"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1936af879e3db023601196a1684d28e12f19ccf93af01bf3280a3262c4b6b4e5"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2ac41acfc8d965fb0c464eb8f44995770239668956dc4cdf502d1b1ffe0d747"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81c24e0c0fde47a9723c81d5806569cddef103aebbf79dbc9fcbb617153dea30"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23a8825495d8b195c4aa9ff1c430c28f2c821e8c5e2d98089228af887e5d7e29"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:60c578c45c949f909a4026b7807044e7e564adf793537fc762b2489d522f3d11"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-win32.whl", hash = "sha256:118c16cd3f1b00c76d69343e38602006c9cfb9998fa4f798606d28d63f23beda"}, - {file = "sqlalchemy-2.0.41-cp310-cp310-win_amd64.whl", hash = "sha256:7492967c3386df69f80cf67efd665c0f667cee67032090fe01d7d74b0e19bb08"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6375cd674fe82d7aa9816d1cb96ec592bac1726c11e0cafbf40eeee9a4516b5f"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f8c9fdd15a55d9465e590a402f42082705d66b05afc3ffd2d2eb3c6ba919560"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f9dc8c44acdee06c8fc6440db9eae8b4af8b01e4b1aee7bdd7241c22edff4f"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c11ceb9a1f482c752a71f203a81858625d8df5746d787a4786bca4ffdf71c6"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:911cc493ebd60de5f285bcae0491a60b4f2a9f0f5c270edd1c4dbaef7a38fc04"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03968a349db483936c249f4d9cd14ff2c296adfa1290b660ba6516f973139582"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8"}, - {file = "sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6"}, - {file = "sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f"}, - {file = "sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90144d3b0c8b139408da50196c5cad2a6909b51b23df1f0538411cd23ffa45d3"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:023b3ee6169969beea3bb72312e44d8b7c27c75b347942d943cf49397b7edeb5"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:725875a63abf7c399d4548e686debb65cdc2549e1825437096a0af1f7e374814"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81965cc20848ab06583506ef54e37cf15c83c7e619df2ad16807c03100745dea"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dd5ec3aa6ae6e4d5b5de9357d2133c07be1aff6405b136dad753a16afb6717dd"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ff8e80c4c4932c10493ff97028decfdb622de69cae87e0f127a7ebe32b4069c6"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-win32.whl", hash = "sha256:4d44522480e0bf34c3d63167b8cfa7289c1c54264c2950cc5fc26e7850967e45"}, - {file = "sqlalchemy-2.0.41-cp38-cp38-win_amd64.whl", hash = "sha256:81eedafa609917040d39aa9332e25881a8e7a0862495fcdf2023a9667209deda"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9a420a91913092d1e20c86a2f5f1fc85c1a8924dbcaf5e0586df8aceb09c9cc2"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:906e6b0d7d452e9a98e5ab8507c0da791856b2380fdee61b765632bb8698026f"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a373a400f3e9bac95ba2a06372c4fd1412a7cee53c37fc6c05f829bf672b8769"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:087b6b52de812741c27231b5a3586384d60c353fbd0e2f81405a814b5591dc8b"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:34ea30ab3ec98355235972dadc497bb659cc75f8292b760394824fab9cf39826"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8280856dd7c6a68ab3a164b4a4b1c51f7691f6d04af4d4ca23d6ecf2261b7923"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-win32.whl", hash = "sha256:b50eab9994d64f4a823ff99a0ed28a6903224ddbe7fef56a6dd865eec9243440"}, - {file = "sqlalchemy-2.0.41-cp39-cp39-win_amd64.whl", hash = "sha256:5e22575d169529ac3e0a120cf050ec9daa94b6a9597993d1702884f6954a7d71"}, - {file = "sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576"}, - {file = "sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9"}, + {file = "SQLAlchemy-2.0.42-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7ee065898359fdee83961aed5cf1fb4cfa913ba71b58b41e036001d90bebbf7a"}, + {file = "SQLAlchemy-2.0.42-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56bc76d86216443daa2e27e6b04a9b96423f0b69b5d0c40c7f4b9a4cdf7d8d90"}, + {file = "SQLAlchemy-2.0.42-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89143290fb94c50a8dec73b06109ccd245efd8011d24fc0ddafe89dc55b36651"}, + {file = "SQLAlchemy-2.0.42-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:4efbdc9754c7145a954911bfeef815fb0843e8edab0e9cecfa3417a5cbd316af"}, + {file = "SQLAlchemy-2.0.42-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:88f8a8007a658dfd82c16a20bd9673ae6b33576c003b5166d42697d49e496e61"}, + {file = "SQLAlchemy-2.0.42-cp37-cp37m-win32.whl", hash = "sha256:c5dd245e6502990ccf612d51f220a7b04cbea3f00f6030691ffe27def76ca79b"}, + {file = "SQLAlchemy-2.0.42-cp37-cp37m-win_amd64.whl", hash = "sha256:5651eb19cacbeb2fe7431e4019312ed00a0b3fbd2d701423e0e2ceaadb5bcd9f"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:172b244753e034d91a826f80a9a70f4cbac690641207f2217f8404c261473efe"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:be28f88abd74af8519a4542185ee80ca914933ca65cdfa99504d82af0e4210df"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98b344859d282fde388047f1710860bb23f4098f705491e06b8ab52a48aafea9"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97978d223b11f1d161390a96f28c49a13ce48fdd2fed7683167c39bdb1b8aa09"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e35b9b000c59fcac2867ab3a79fc368a6caca8706741beab3b799d47005b3407"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bc7347ad7a7b1c78b94177f2d57263113bb950e62c59b96ed839b131ea4234e1"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-win32.whl", hash = "sha256:739e58879b20a179156b63aa21f05ccacfd3e28e08e9c2b630ff55cd7177c4f1"}, + {file = "sqlalchemy-2.0.42-cp310-cp310-win_amd64.whl", hash = "sha256:1aef304ada61b81f1955196f584b9e72b798ed525a7c0b46e09e98397393297b"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c34100c0b7ea31fbc113c124bcf93a53094f8951c7bf39c45f39d327bad6d1e7"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ad59dbe4d1252448c19d171dfba14c74e7950b46dc49d015722a4a06bfdab2b0"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9187498c2149919753a7fd51766ea9c8eecdec7da47c1b955fa8090bc642eaa"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f092cf83ebcafba23a247f5e03f99f5436e3ef026d01c8213b5eca48ad6efa9"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc6afee7e66fdba4f5a68610b487c1f754fccdc53894a9567785932dbb6a265e"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:260ca1d2e5910f1f1ad3fe0113f8fab28657cee2542cb48c2f342ed90046e8ec"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-win32.whl", hash = "sha256:2eb539fd83185a85e5fcd6b19214e1c734ab0351d81505b0f987705ba0a1e231"}, + {file = "sqlalchemy-2.0.42-cp311-cp311-win_amd64.whl", hash = "sha256:9193fa484bf00dcc1804aecbb4f528f1123c04bad6a08d7710c909750fa76aeb"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:09637a0872689d3eb71c41e249c6f422e3e18bbd05b4cd258193cfc7a9a50da2"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3cb3ec67cc08bea54e06b569398ae21623534a7b1b23c258883a7c696ae10df"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e87e6a5ef6f9d8daeb2ce5918bf5fddecc11cae6a7d7a671fcc4616c47635e01"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b718011a9d66c0d2f78e1997755cd965f3414563b31867475e9bc6efdc2281d"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:16d9b544873fe6486dddbb859501a07d89f77c61d29060bb87d0faf7519b6a4d"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21bfdf57abf72fa89b97dd74d3187caa3172a78c125f2144764a73970810c4ee"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-win32.whl", hash = "sha256:78b46555b730a24901ceb4cb901c6b45c9407f8875209ed3c5d6bcd0390a6ed1"}, + {file = "sqlalchemy-2.0.42-cp312-cp312-win_amd64.whl", hash = "sha256:4c94447a016f36c4da80072e6c6964713b0af3c8019e9c4daadf21f61b81ab53"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:941804f55c7d507334da38133268e3f6e5b0340d584ba0f277dd884197f4ae8c"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d3d06a968a760ce2aa6a5889fefcbdd53ca935735e0768e1db046ec08cbf01"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cf10396a8a700a0f38ccd220d940be529c8f64435c5d5b29375acab9267a6c9"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9cae6c2b05326d7c2c7c0519f323f90e0fb9e8afa783c6a05bb9ee92a90d0f04"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f50f7b20677b23cfb35b6afcd8372b2feb348a38e3033f6447ee0704540be894"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d88a1c0d66d24e229e3938e1ef16ebdbd2bf4ced93af6eff55225f7465cf350"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-win32.whl", hash = "sha256:45c842c94c9ad546c72225a0c0d1ae8ef3f7c212484be3d429715a062970e87f"}, + {file = "sqlalchemy-2.0.42-cp313-cp313-win_amd64.whl", hash = "sha256:eb9905f7f1e49fd57a7ed6269bc567fcbbdac9feadff20ad6bd7707266a91577"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ed5a6959b1668d97a32e3fd848b485f65ee3c05a759dee06d90e4545a3c77f1e"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2ddbaafe32f0dd12d64284b1c3189104b784c9f3dba8cc1ba7e642e2b14b906f"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37f4f42568b6c656ee177b3e111d354b5dda75eafe9fe63492535f91dfa35829"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb57923d852d38671a17abda9a65cc59e3e5eab51fb8307b09de46ed775bcbb8"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:437c2a8b0c780ff8168a470beb22cb4a25e1c63ea6a7aec87ffeb07aa4b76641"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:480f7df62f0b3ad6aa011eefa096049dc1770208bb71f234959ee2864206eefe"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-win32.whl", hash = "sha256:d119c80c614d62d32e236ae68e21dd28a2eaf070876b2f28a6075d5bae54ef3f"}, + {file = "sqlalchemy-2.0.42-cp38-cp38-win_amd64.whl", hash = "sha256:be3a02f963c8d66e28bb4183bebab66dc4379701d92e660f461c65fecd6ff399"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:78548fd65cd76d4c5a2e6b5f245d7734023ee4de33ee7bb298f1ac25a9935e0d"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cf4bf5a174d8a679a713b7a896470ffc6baab78e80a79e7ec5668387ffeccc8b"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8c7ff7ba08b375f8a8fa0511e595c9bdabb5494ec68f1cf69bb24e54c0d90f2"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b3c117f65d64e806ce5ce9ce578f06224dc36845e25ebd2554b3e86960e1aed"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:27e4a7b3a7a61ff919c2e7caafd612f8626114e6e5ebbe339de3b5b1df9bc27e"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b01e0dd39f96aefda5ab002d8402db4895db871eb0145836246ce0661635ce55"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-win32.whl", hash = "sha256:49362193b1f43aa158deebf438062d7b5495daa9177c6c5d0f02ceeb64b544ea"}, + {file = "sqlalchemy-2.0.42-cp39-cp39-win_amd64.whl", hash = "sha256:636ec3dc83b2422a7ff548d0f8abf9c23742ca50e2a5cdc492a151eac7a0248b"}, + {file = "sqlalchemy-2.0.42-py3-none-any.whl", hash = "sha256:defcdff7e661f0043daa381832af65d616e060ddb54d3fe4476f51df7eaa1835"}, + {file = "sqlalchemy-2.0.42.tar.gz", hash = "sha256:160bedd8a5c28765bd5be4dec2d881e109e33b34922e50a3b881a7681773ac5f"}, ] [package.dependencies] @@ -1419,14 +1545,14 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3) [[package]] name = "virtualenv" -version = "20.31.2" +version = "20.33.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11"}, - {file = "virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af"}, + {file = "virtualenv-20.33.0-py3-none-any.whl", hash = "sha256:106b6baa8ab1b526d5a9b71165c85c456fbd49b16976c88e2bc9352ee3bc5d3f"}, + {file = "virtualenv-20.33.0.tar.gz", hash = "sha256:47e0c0d2ef1801fce721708ccdf2a28b9403fa2307c3268aebd03225976f61d2"}, ] [package.dependencies] @@ -1441,4 +1567,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "9304969e170879b35397b7ddccf29746aa376fbee58eba60ab85ab25e0c7a3b2" +content-hash = "f40eb442118ade4ca787e92415f89b495f0378212e7cad8ce1847a5d2b3b548c" diff --git a/pyproject.toml b/pyproject.toml index f980df9..6621c55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,9 +16,8 @@ dependencies = [ "psycopg2-binary (>=2.9.10,<3.0.0)", "mysql-connector-python (>=9.4.0,<10.0.0)", "pymysql (>=1.1.1,<2.0.0)", - "cx-oracle (>=8.3.0,<9.0.0)", - "pyodbc (>=5.2.0,<6.0.0)", - "pycryptodome (>=3.23.0,<4.0.0)" + "pycryptodome (>=3.23.0,<4.0.0)", + "oracledb (>=3.3.0,<4.0.0)" ] From 9fa2ec2122a481cc8ddac6aa26c50cea44d614e0 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Wed, 6 Aug 2025 03:14:58 +0900 Subject: [PATCH 064/220] =?UTF-8?q?refactor:=20exception=EC=9D=B4=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=ED=95=9C=20=EA=B2=BD=EC=9A=B0=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A7=8C=20=EB=8B=B4?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index d53ced3..958e8c2 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -15,7 +15,8 @@ class CommonCode(Enum): # ================================== SUCCESS = (status.HTTP_200_OK, "2000", "성공적으로 처리되었습니다.") CREATED = (status.HTTP_201_CREATED, "2001", "성공적으로 생성되었습니다.") - SUCCESS_DB_CONNECT = (status.HTTP_200_OK, "2002", "디비 연결을 성공하였습니다.") + SUCCESS_DB_INFO = (status.HTTP_200_OK, "2100", "디비 정보 조회를 성공하였습니다.") + SUCCESS_DB_CONNECT = (status.HTTP_200_OK, "2101", "디비 연결을 성공하였습니다.") # ================================== # 클라이언트 오류 (Client Error) - 4xx @@ -24,11 +25,13 @@ class CommonCode(Enum): DUPLICATION = (status.HTTP_409_CONFLICT, "4001", "이미 존재하는 데이터입니다.") NO_SEARCH_DATA = (status.HTTP_404_NOT_FOUND, "4002", "요청한 데이터를 찾을 수 없습니다.") INVALID_PARAMETER = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4003", "필수 값이 누락되었습니다.") - INVALID_ENUM_VALUE = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4101", "지원하지 않는 데이터베이스 값입니다.") + INVALID_DB_VALUE = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4101", "지원하지 않는 데이터베이스 값입니다.") + NO_SEARCH_DB = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4102", "존재하지 않는 데이터베이스 입니다.") # ================================== # 서버 오류 (Server Error) - 5xx # ================================== + FAIL_CONNECT_DB = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 연결 중 오류가 발생했습니다.") FAIL = (status.HTTP_500_INTERNAL_SERVER_ERROR, "9999", "서버 처리 중 오류가 발생했습니다.") def __init__(self, http_status: int, code: str, message: str): @@ -41,4 +44,7 @@ def get_message(self, *args) -> str: """ 메시지 포맷팅이 필요한 경우, 인자를 받아 완성된 메시지를 반환합니다. """ - return self.message % args if args else self.message + try: + return self.message % args if args else self.message + except Exception: + return self.message From df07f0bcec95ac9d0689b8067e57cfdc251e1815 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Wed, 6 Aug 2025 03:15:44 +0900 Subject: [PATCH 065/220] =?UTF-8?q?fix:=20=EC=97=90=EB=9F=AC=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=20=EC=8B=9C=209999=EB=A7=8C=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=EC=8B=9C=ED=82=A4=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/exceptions.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/core/exceptions.py b/app/core/exceptions.py index b8acf24..9322f76 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -8,13 +8,13 @@ from app.core.status import CommonCode -def _create_error_response(code: CommonCode, data: Any | None = None, *message_args) -> JSONResponse: +def _create_error_response(code: CommonCode, data: Any | None = None) -> JSONResponse: """ 모든 에러 응답에 사용될 표준 JSONResponse 객체를 생성하는 헬퍼 함수. """ error_content = { "code": code.code, - "message": code.get_message(*message_args), + "message": code.message, "data": data, } return JSONResponse( @@ -30,7 +30,8 @@ class APIException(Exception): def __init__(self, code: CommonCode, *args): self.code_enum = code - self.message = code.get_message(*args) + self.message = code.message + self.args = args super().__init__(self.message) @@ -51,9 +52,7 @@ async def api_exception_handler(request: Request, exc: APIException): """ APIException이 발생했을 때, 이를 감지하여 표준화된 JSON 오류 응답을 반환합니다. """ - return _create_error_response( - code=exc.code_enum.http_status, data={"code": exc.code_enum.code, "message": exc.message, "data": None} - ) + return _create_error_response(code=exc.code_enum, data=exc.args) async def generic_exception_handler(request: Request, exc: Exception): From 8bca2e64783290df61fb18a828519891661fdb82 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Wed, 6 Aug 2025 03:20:07 +0900 Subject: [PATCH 066/220] =?UTF-8?q?feat:=20db=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B6=80=EB=B6=84=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/driver_api.py | 29 +++++++-- app/repository/connect_db_repository.py | 35 +++++++++++ app/schemas/db_profile_model.py | 67 +++++++++++++++++++++ app/schemas/view/connection_result_model.py | 10 +++ app/services/driver_service.py | 67 +++++++++++++++++++++ 5 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 app/repository/connect_db_repository.py create mode 100644 app/schemas/db_profile_model.py create mode 100644 app/schemas/view/connection_result_model.py diff --git a/app/api/driver_api.py b/app/api/driver_api.py index b3cfd72..109f5b7 100644 --- a/app/api/driver_api.py +++ b/app/api/driver_api.py @@ -6,6 +6,7 @@ from app.core.exceptions import APIException from app.core.response import ResponseMessage from app.core.status import CommonCode +from app.schemas.db_profile_model import DBProfileCreate from app.schemas.driver_info import DriverInfo from app.services.driver_service import DriverService, driver_service @@ -24,9 +25,27 @@ def read_driver_info( service: DriverService = driver_service_dependency, ) -> ResponseMessage: """경로 파라미터로 받은 driver_id에 해당하는 DB 드라이버의 지원 정보를 조회합니다.""" + db_type_enum = DBTypesEnum[driver_id.lower()] + driver_info_data = DriverInfo.from_enum(db_type_enum) + return ResponseMessage.success(value=service.read_driver_info(driver_info_data), code=CommonCode.SUCCESS_DB_INFO) + + +@router.post( + "/test/db", + response_model=ResponseMessage, + summary="DB 연결 테스트", +) +def test_connection_endpoint( + db_info: DBProfileCreate, + service: DriverService = driver_service_dependency, +) -> ResponseMessage: + """DB 연결 정보를 받아 연결 가능 여부를 테스트합니다.""" try: - db_type_enum = DBTypesEnum[driver_id.lower()] - driver_info_data = DriverInfo.from_enum(db_type_enum) - return ResponseMessage.success(value=service.read_driver_info(driver_info_data)) - except KeyError: - raise APIException(CommonCode.INVALID_ENUM_VALUE) from KeyError + db_info.validate_required_fields() + except ValueError as e: + raise APIException(CommonCode.NO_VALUE, *e.args) from e + + result = service.test_connection(db_info) + if not result.is_successful: + raise APIException(result.code) + return ResponseMessage.success(code=result.code) diff --git a/app/repository/connect_db_repository.py b/app/repository/connect_db_repository.py new file mode 100644 index 0000000..cca5d9b --- /dev/null +++ b/app/repository/connect_db_repository.py @@ -0,0 +1,35 @@ +from typing import Any + +import oracledb + +from app.core.status import CommonCode +from app.schemas.view.connection_result_model import TestConnectionResult + + +def test_db_connection(driver_module: Any, **kwargs: Any) -> TestConnectionResult: + """ + DB 드라이버와 연결에 필요한 매개변수들을 받아 연결을 테스트합니다. + """ + connection = None + try: + if driver_module is oracledb: + if kwargs.get("user").lower() == "sys": + kwargs["mode"] = oracledb.AUTH_MODE_SYSDBA + connection = driver_module.connect(**kwargs) + # MSSQL과 같이 전체 연결 문자열이 제공된 경우 + elif "connection_string" in kwargs: + connection = driver_module.connect(kwargs["connection_string"]) + # SQLite와 같이 파일 이름만 필요한 경우 + elif "db_name" in kwargs: + connection = driver_module.connect(kwargs["db_name"]) + # 그 외 (MySQL, PostgreSQL, Oracle 등) 일반적인 키워드 인자 방식 연결 + else: + connection = driver_module.connect(**kwargs) + + return TestConnectionResult(is_successful=True, code=CommonCode.SUCCESS_DB_CONNECT) + + except Exception: + return TestConnectionResult(is_successful=False, code=CommonCode.FAIL_CONNECT_DB) + finally: + if connection: + connection.close() diff --git a/app/schemas/db_profile_model.py b/app/schemas/db_profile_model.py new file mode 100644 index 0000000..7ef63f3 --- /dev/null +++ b/app/schemas/db_profile_model.py @@ -0,0 +1,67 @@ +# app/schemas/db_profile_model.py + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +# 사용자가 직접 입력해야 하는 정보만 포함합니다. +class DBProfileCreate(BaseModel): + type: str = Field(..., description="DB 종류") + host: str | None = Field(None, description="호스트 주소") + port: int | None = Field(None, description="포트 번호") + username: str | None = Field(None, description="사용자 이름") + password: str | None = Field(None, description="비밀번호") + name: str | None = Field(None, description="데이터베이스 이름") + driver: str | None = Field(None, description="드라이버 이름") + + def validate_required_fields(self) -> None: + """DB 종류별 필수 필드 유효성 검사""" + required_fields_by_type = { + "sqlite": ["name"], + "mysql": ["host", "port", "username", "password"], + "mariadb": ["host", "port", "username", "password"], + "postgresql": ["host", "port", "username", "password"], + "oracle": ["host", "port", "username", "password", "name"], + } + + if not self.type: + raise ValueError("DB 종류(type)는 필수 항목입니다.") + + db_type = self.type.lower() + if db_type not in required_fields_by_type: + raise ValueError(f"지원하지 않는 DB 종류입니다: {self.type}") + + missing = [ + field_name + for field_name in required_fields_by_type[db_type] + if self._is_empty(getattr(self, field_name, None)) + ] + + if missing: + raise ValueError(f"{self.type} 연결에 필요한 값이 누락되었습니다: {missing}") + + @staticmethod + def _is_empty(value: Any | None) -> bool: + """값이 None, 빈 문자열, 공백 문자열인지 검사""" + if value is None: + return True + if isinstance(value, str) and not value.strip(): + return True + return False + + +# DB에서 조회되는 모든 정보를 담는 클래스입니다. +class DBProfile(BaseModel): + id: str + type: str + host: str + port: int + name: str | None + username: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/app/schemas/view/connection_result_model.py b/app/schemas/view/connection_result_model.py new file mode 100644 index 0000000..c02deb4 --- /dev/null +++ b/app/schemas/view/connection_result_model.py @@ -0,0 +1,10 @@ +# app/schemas/view/connection_result_model.py + +from pydantic import BaseModel, Field + +from app.core.status import CommonCode + + +class TestConnectionResult(BaseModel): + is_successful: bool = Field(..., description="성공 여부") + code: CommonCode = Field(None, description="결과 코드") diff --git a/app/services/driver_service.py b/app/services/driver_service.py index 7080808..679a503 100644 --- a/app/services/driver_service.py +++ b/app/services/driver_service.py @@ -2,10 +2,15 @@ import importlib import os import sqlite3 +from typing import Any +from app.core.enum.db_driver import DBTypesEnum from app.core.exceptions import APIException from app.core.status import CommonCode +from app.repository.connect_db_repository import test_db_connection +from app.schemas.db_profile_model import DBProfileCreate from app.schemas.driver_info import DriverInfo +from app.schemas.view.connection_result_model import TestConnectionResult class DriverService: @@ -27,5 +32,67 @@ def read_driver_info(self, driver_info: DriverInfo): except (ModuleNotFoundError, AttributeError, OSError) as e: raise APIException(CommonCode.FAIL) from e + def test_connection(self, db_info: DBProfileCreate) -> TestConnectionResult: + """ + DB 연결 정보를 받아 연결 테스트를 수행하고 결과를 객체로 반환합니다. + """ + try: + driver_module = self._get_driver_module(db_info.type) + connect_kwargs = self._prepare_connection_args(db_info) + return test_db_connection(driver_module, **connect_kwargs) + except (ValueError, ImportError) as e: + raise APIException(CommonCode.FAIL) from e + + def _get_driver_module(self, db_type: str): + """ + DB 타입에 따라 동적으로 드라이버 모듈을 로드합니다. + """ + try: + driver_name = DBTypesEnum[db_type.lower()].value + except KeyError as e: + raise ValueError(f"지원하지 않는 DB 타입입니다: {db_type}") from e + + if driver_name == "sqlite3": + return sqlite3 + + return importlib.import_module(driver_name) + + def _prepare_connection_args(self, db_info: DBProfileCreate) -> dict[str, Any]: + """ + DB 타입에 따라 연결에 필요한 매개변수를 딕셔너리로 구성합니다. + """ + # SQLite는 별도 처리 + if db_info.type == "sqlite": + return {"db_name": db_info.name} + + # MSSQL은 연결 문자열을 별도로 구성 + if db_info.type == "mssql": + connection_string = ( + f"DRIVER={{ODBC Driver 17 for SQL Server}};" + f"SERVER={db_info.host},{db_info.port};" + f"UID={db_info.username};" + f"PWD={db_info.password};" + ) + if db_info.name: + connection_string += f"DATABASE={db_info.name};" + return {"connection_string": connection_string} + + # 그 외 DB들은 공통 파라미터로 시작 + kwargs = {"host": db_info.host, "port": db_info.port, "user": db_info.username, "password": db_info.password} + + # DB 이름이 없을 경우, 기본 파라미터만 반환 + if not db_info.name: + return kwargs + + # DB 이름이 있다면, 타입에 따라 적절한 파라미터를 추가합니다. + if db_info.type == "postgresql": + kwargs["dbname"] = db_info.name + elif db_info.type in ["mysql", "mariadb"]: + kwargs["database"] = db_info.name + elif db_info.type == "oracle": + kwargs["dsn"] = f"{db_info.host}:{db_info.port}/{db_info.name}" + + return kwargs + driver_service = DriverService() From fc8e81989477a3dda67b00b78aa7e43c6f0d13dd Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Wed, 6 Aug 2025 22:48:17 +0900 Subject: [PATCH 067/220] =?UTF-8?q?refactor:=20health=20check=20api=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/{health.py => health_api.py} | 2 +- app/main.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename app/api/{health.py => health_api.py} (88%) diff --git a/app/api/health.py b/app/api/health_api.py similarity index 88% rename from app/api/health.py rename to app/api/health_api.py index 2b20245..22d2dfc 100644 --- a/app/api/health.py +++ b/app/api/health_api.py @@ -1,4 +1,4 @@ -# app/api/health.py +# app/api/health_api.py from fastapi import APIRouter router = APIRouter(tags=["Health"]) diff --git a/app/main.py b/app/main.py index 1f86c20..198e5fd 100644 --- a/app/main.py +++ b/app/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.exceptions import RequestValidationError -from app.api import health # 헬스 체크 +from app.api import health_api from app.api.api_router import api_router from app.core.exceptions import ( APIException, @@ -22,7 +22,7 @@ app.add_exception_handler(RequestValidationError, validation_exception_handler) # 라우터 -app.include_router(health.router) +app.include_router(health_api.router) app.include_router(api_router, prefix="/api") # initialize_database 함수가 호출되어 테이블이 생성되거나 이미 존재함을 확인합니다. From db0ecd9ec70f2ba17743317294e5d5a3279fc2a9 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Wed, 6 Aug 2025 23:07:40 +0900 Subject: [PATCH 068/220] =?UTF-8?q?refactor:=20driver=EB=9E=91=20user=20db?= =?UTF-8?q?=20connection=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 3 +- app/api/driver_api.py | 29 ++------------- app/services/driver_service.py | 67 ---------------------------------- 3 files changed, 5 insertions(+), 94 deletions(-) diff --git a/app/api/api_router.py b/app/api/api_router.py index 1067cdc..fab0f6a 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -8,4 +8,5 @@ api_router.include_router(test_api.router, prefix="/test", tags=["Test"]) # 라우터 -api_router.include_router(driver_api.router, prefix="/connections", tags=["Driver"]) +api_router.include_router(driver_api.router, prefix="/driver", tags=["Driver"]) +# api_router.include_router(user_db_api.router, prefix="/user/db", tags=["UserDb"]) diff --git a/app/api/driver_api.py b/app/api/driver_api.py index 109f5b7..a2b9d2b 100644 --- a/app/api/driver_api.py +++ b/app/api/driver_api.py @@ -3,10 +3,8 @@ from fastapi import APIRouter, Depends from app.core.enum.db_driver import DBTypesEnum -from app.core.exceptions import APIException from app.core.response import ResponseMessage from app.core.status import CommonCode -from app.schemas.db_profile_model import DBProfileCreate from app.schemas.driver_info import DriverInfo from app.services.driver_service import DriverService, driver_service @@ -16,36 +14,15 @@ @router.get( - "/drivers/{driver_id}", - response_model=ResponseMessage, + "/info/{driver_id}", + response_model=ResponseMessage[DriverInfo], summary="DB 드라이버 정보 조회", ) def read_driver_info( driver_id: str, service: DriverService = driver_service_dependency, -) -> ResponseMessage: +) -> ResponseMessage[DriverInfo]: """경로 파라미터로 받은 driver_id에 해당하는 DB 드라이버의 지원 정보를 조회합니다.""" db_type_enum = DBTypesEnum[driver_id.lower()] driver_info_data = DriverInfo.from_enum(db_type_enum) return ResponseMessage.success(value=service.read_driver_info(driver_info_data), code=CommonCode.SUCCESS_DB_INFO) - - -@router.post( - "/test/db", - response_model=ResponseMessage, - summary="DB 연결 테스트", -) -def test_connection_endpoint( - db_info: DBProfileCreate, - service: DriverService = driver_service_dependency, -) -> ResponseMessage: - """DB 연결 정보를 받아 연결 가능 여부를 테스트합니다.""" - try: - db_info.validate_required_fields() - except ValueError as e: - raise APIException(CommonCode.NO_VALUE, *e.args) from e - - result = service.test_connection(db_info) - if not result.is_successful: - raise APIException(result.code) - return ResponseMessage.success(code=result.code) diff --git a/app/services/driver_service.py b/app/services/driver_service.py index 679a503..7080808 100644 --- a/app/services/driver_service.py +++ b/app/services/driver_service.py @@ -2,15 +2,10 @@ import importlib import os import sqlite3 -from typing import Any -from app.core.enum.db_driver import DBTypesEnum from app.core.exceptions import APIException from app.core.status import CommonCode -from app.repository.connect_db_repository import test_db_connection -from app.schemas.db_profile_model import DBProfileCreate from app.schemas.driver_info import DriverInfo -from app.schemas.view.connection_result_model import TestConnectionResult class DriverService: @@ -32,67 +27,5 @@ def read_driver_info(self, driver_info: DriverInfo): except (ModuleNotFoundError, AttributeError, OSError) as e: raise APIException(CommonCode.FAIL) from e - def test_connection(self, db_info: DBProfileCreate) -> TestConnectionResult: - """ - DB 연결 정보를 받아 연결 테스트를 수행하고 결과를 객체로 반환합니다. - """ - try: - driver_module = self._get_driver_module(db_info.type) - connect_kwargs = self._prepare_connection_args(db_info) - return test_db_connection(driver_module, **connect_kwargs) - except (ValueError, ImportError) as e: - raise APIException(CommonCode.FAIL) from e - - def _get_driver_module(self, db_type: str): - """ - DB 타입에 따라 동적으로 드라이버 모듈을 로드합니다. - """ - try: - driver_name = DBTypesEnum[db_type.lower()].value - except KeyError as e: - raise ValueError(f"지원하지 않는 DB 타입입니다: {db_type}") from e - - if driver_name == "sqlite3": - return sqlite3 - - return importlib.import_module(driver_name) - - def _prepare_connection_args(self, db_info: DBProfileCreate) -> dict[str, Any]: - """ - DB 타입에 따라 연결에 필요한 매개변수를 딕셔너리로 구성합니다. - """ - # SQLite는 별도 처리 - if db_info.type == "sqlite": - return {"db_name": db_info.name} - - # MSSQL은 연결 문자열을 별도로 구성 - if db_info.type == "mssql": - connection_string = ( - f"DRIVER={{ODBC Driver 17 for SQL Server}};" - f"SERVER={db_info.host},{db_info.port};" - f"UID={db_info.username};" - f"PWD={db_info.password};" - ) - if db_info.name: - connection_string += f"DATABASE={db_info.name};" - return {"connection_string": connection_string} - - # 그 외 DB들은 공통 파라미터로 시작 - kwargs = {"host": db_info.host, "port": db_info.port, "user": db_info.username, "password": db_info.password} - - # DB 이름이 없을 경우, 기본 파라미터만 반환 - if not db_info.name: - return kwargs - - # DB 이름이 있다면, 타입에 따라 적절한 파라미터를 추가합니다. - if db_info.type == "postgresql": - kwargs["dbname"] = db_info.name - elif db_info.type in ["mysql", "mariadb"]: - kwargs["database"] = db_info.name - elif db_info.type == "oracle": - kwargs["dsn"] = f"{db_info.host}:{db_info.port}/{db_info.name}" - - return kwargs - driver_service = DriverService() From 87d2b6c10e4d11408380bacc5f4a3944cd3df321 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Thu, 7 Aug 2025 00:23:04 +0900 Subject: [PATCH 069/220] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EB=B0=8F=20=ED=8F=B4=EB=8D=94,?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20=EB=B0=8F=20=EC=83=81=ED=83=9C=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=AA=85=ED=99=95=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 6 +- app/api/driver_api.py | 12 ++- app/api/user_db_api.py | 30 +++++++ app/core/status.py | 55 ++++++++++--- app/repository/connect_db_repository.py | 35 -------- app/repository/user_db_repository.py | 39 +++++++++ .../driver_info_model.py} | 2 +- .../connect_test_result_model.py} | 2 +- app/schemas/{ => user_db}/db_profile_model.py | 11 ++- app/services/driver_service.py | 2 +- app/services/user_db_service.py | 80 +++++++++++++++++++ 11 files changed, 216 insertions(+), 58 deletions(-) create mode 100644 app/api/user_db_api.py delete mode 100644 app/repository/connect_db_repository.py create mode 100644 app/repository/user_db_repository.py rename app/schemas/{driver_info.py => driver/driver_info_model.py} (96%) rename app/schemas/{view/connection_result_model.py => user_db/connect_test_result_model.py} (83%) rename app/schemas/{ => user_db}/db_profile_model.py (87%) create mode 100644 app/services/user_db_service.py diff --git a/app/api/api_router.py b/app/api/api_router.py index fab0f6a..c7238e1 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -1,6 +1,8 @@ +# app/api/api_router.py + from fastapi import APIRouter -from app.api import driver_api, test_api +from app.api import driver_api, test_api, user_db_api api_router = APIRouter() @@ -9,4 +11,4 @@ # 라우터 api_router.include_router(driver_api.router, prefix="/driver", tags=["Driver"]) -# api_router.include_router(user_db_api.router, prefix="/user/db", tags=["UserDb"]) +api_router.include_router(user_db_api.router, prefix="/user/db", tags=["UserDb"]) diff --git a/app/api/driver_api.py b/app/api/driver_api.py index a2b9d2b..00f8822 100644 --- a/app/api/driver_api.py +++ b/app/api/driver_api.py @@ -3,9 +3,10 @@ from fastapi import APIRouter, Depends from app.core.enum.db_driver import DBTypesEnum +from app.core.exceptions import APIException from app.core.response import ResponseMessage from app.core.status import CommonCode -from app.schemas.driver_info import DriverInfo +from app.schemas.driver.driver_info_model import DriverInfo from app.services.driver_service import DriverService, driver_service driver_service_dependency = Depends(lambda: driver_service) @@ -23,6 +24,11 @@ def read_driver_info( service: DriverService = driver_service_dependency, ) -> ResponseMessage[DriverInfo]: """경로 파라미터로 받은 driver_id에 해당하는 DB 드라이버의 지원 정보를 조회합니다.""" - db_type_enum = DBTypesEnum[driver_id.lower()] + try: + db_type_enum = DBTypesEnum[driver_id.lower()] + except KeyError as e: + raise APIException(CommonCode.INVALID_DB_DRIVER, *e.args) from e driver_info_data = DriverInfo.from_enum(db_type_enum) - return ResponseMessage.success(value=service.read_driver_info(driver_info_data), code=CommonCode.SUCCESS_DB_INFO) + return ResponseMessage.success( + value=service.read_driver_info(driver_info_data), code=CommonCode.SUCCESS_DRIVER_INFO + ) diff --git a/app/api/user_db_api.py b/app/api/user_db_api.py new file mode 100644 index 0000000..f8fec19 --- /dev/null +++ b/app/api/user_db_api.py @@ -0,0 +1,30 @@ +# app/api/user_db_api.py + +from fastapi import APIRouter, Depends + +from app.core.exceptions import APIException +from app.core.response import ResponseMessage +from app.schemas.user_db.db_profile_model import DBProfileCreate +from app.services.user_db_service import UserDbService, user_db_service + +user_db_service_dependency = Depends(lambda: user_db_service) + +router = APIRouter() + + +@router.post( + "/connect/test", + response_model=ResponseMessage[bool], + summary="DB 연결 테스트", +) +def connection_test( + db_info: DBProfileCreate, + service: UserDbService = user_db_service_dependency, +) -> ResponseMessage[bool]: + """DB 연결 정보를 받아 연결 가능 여부를 테스트합니다.""" + db_info.validate_required_fields() + + result = service.connection_test(db_info) + if not result.is_successful: + raise APIException(result.code) + return ResponseMessage.success(value=result.is_successful, code=result.code) diff --git a/app/core/status.py b/app/core/status.py index 958e8c2..7fe3fb1 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -10,29 +10,62 @@ class CommonCode(Enum): 상태 코드 참고: https://developer.mozilla.org/ko/docs/Web/HTTP/Status """ - # ================================== - # 성공 (Success) - 2xx - # ================================== + # ======================================= + # 성공 (Success) - 2xxx + # ======================================= + """ 기본 성공 코드 - 20xx """ SUCCESS = (status.HTTP_200_OK, "2000", "성공적으로 처리되었습니다.") CREATED = (status.HTTP_201_CREATED, "2001", "성공적으로 생성되었습니다.") - SUCCESS_DB_INFO = (status.HTTP_200_OK, "2100", "디비 정보 조회를 성공하였습니다.") - SUCCESS_DB_CONNECT = (status.HTTP_200_OK, "2101", "디비 연결을 성공하였습니다.") - # ================================== - # 클라이언트 오류 (Client Error) - 4xx - # ================================== + """ DRIVER, DB 성공 코드 - 21xx """ + SUCCESS_DRIVER_INFO = (status.HTTP_200_OK, "2100", "드라이버 정보 조회를 성공하였습니다.") + SUCCESS_USER_DB_CONNECT_TEST = (status.HTTP_200_OK, "2101", "테스트 연결을 성공하였습니다.") + + """ KEY 성공 코드 - 22xx """ + + """ AI CHAT, DB 성공 코드 - 23xx """ + + """ ANNOTATION 성공 코드 - 24xx """ + + """ SQL 성공 코드 - 25xx """ + + # ======================================= + # 클라이언트 오류 (Client Error) - 4xxx + # ======================================= + """ 기본 클라이언트 오류 코드 - 40xx """ NO_VALUE = (status.HTTP_400_BAD_REQUEST, "4000", "필수 값이 존재하지 않습니다.") DUPLICATION = (status.HTTP_409_CONFLICT, "4001", "이미 존재하는 데이터입니다.") NO_SEARCH_DATA = (status.HTTP_404_NOT_FOUND, "4002", "요청한 데이터를 찾을 수 없습니다.") INVALID_PARAMETER = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4003", "필수 값이 누락되었습니다.") - INVALID_DB_VALUE = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4101", "지원하지 않는 데이터베이스 값입니다.") - NO_SEARCH_DB = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4102", "존재하지 않는 데이터베이스 입니다.") + + """ DRIVER, DB 클라이언트 오류 코드 - 41xx """ + INVALID_DB_DRIVER = (status.HTTP_409_CONFLICT, "4100", "지원하지 않는 데이터베이스입니다.") + NO_DB_DRIVER = (status.HTTP_400_BAD_REQUEST, "4101", "데이터베이스는 필수 값입니다.") + + """ KEY 클라이언트 오류 코드 - 42xx """ + + """ AI CHAT, DB 클라이언트 오류 코드 - 43xx """ + + """ ANNOTATION 클라이언트 오류 코드 - 44xx """ + + """ SQL 클라이언트 오류 코드 - 45xx """ # ================================== # 서버 오류 (Server Error) - 5xx # ================================== + """ 기본 서버 오류 코드 - 50xx """ + FAIL = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5000", "서버 처리 중 오류가 발생했습니다.") + + """ DRIVER, DB 서버 오류 코드 - 51xx """ FAIL_CONNECT_DB = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 연결 중 오류가 발생했습니다.") - FAIL = (status.HTTP_500_INTERNAL_SERVER_ERROR, "9999", "서버 처리 중 오류가 발생했습니다.") + + """ KEY 서버 오류 코드 - 52xx """ + + """ AI CHAT, DB 서버 오류 코드 - 53xx """ + + """ ANNOTATION 서버 오류 코드 - 54xx """ + + """ SQL 서버 오류 코드 - 55xx """ def __init__(self, http_status: int, code: str, message: str): """Enum 멤버가 생성될 때 각 값을 속성으로 할당합니다.""" diff --git a/app/repository/connect_db_repository.py b/app/repository/connect_db_repository.py deleted file mode 100644 index cca5d9b..0000000 --- a/app/repository/connect_db_repository.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import Any - -import oracledb - -from app.core.status import CommonCode -from app.schemas.view.connection_result_model import TestConnectionResult - - -def test_db_connection(driver_module: Any, **kwargs: Any) -> TestConnectionResult: - """ - DB 드라이버와 연결에 필요한 매개변수들을 받아 연결을 테스트합니다. - """ - connection = None - try: - if driver_module is oracledb: - if kwargs.get("user").lower() == "sys": - kwargs["mode"] = oracledb.AUTH_MODE_SYSDBA - connection = driver_module.connect(**kwargs) - # MSSQL과 같이 전체 연결 문자열이 제공된 경우 - elif "connection_string" in kwargs: - connection = driver_module.connect(kwargs["connection_string"]) - # SQLite와 같이 파일 이름만 필요한 경우 - elif "db_name" in kwargs: - connection = driver_module.connect(kwargs["db_name"]) - # 그 외 (MySQL, PostgreSQL, Oracle 등) 일반적인 키워드 인자 방식 연결 - else: - connection = driver_module.connect(**kwargs) - - return TestConnectionResult(is_successful=True, code=CommonCode.SUCCESS_DB_CONNECT) - - except Exception: - return TestConnectionResult(is_successful=False, code=CommonCode.FAIL_CONNECT_DB) - finally: - if connection: - connection.close() diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py new file mode 100644 index 0000000..d77d789 --- /dev/null +++ b/app/repository/user_db_repository.py @@ -0,0 +1,39 @@ +from typing import Any + +import oracledb + +from app.core.status import CommonCode +from app.schemas.user_db.connect_test_result_model import TestConnectionResult + + +class UserDbRepository: + def test_db_connection(self, driver_module: Any, **kwargs: Any) -> TestConnectionResult: + """ + DB 드라이버와 연결에 필요한 매개변수들을 받아 연결을 테스트합니다. + """ + connection = None + try: + if driver_module is oracledb: + if kwargs.get("user").lower() == "sys": + kwargs["mode"] = oracledb.AUTH_MODE_SYSDBA + connection = driver_module.connect(**kwargs) + # MSSQL과 같이 전체 연결 문자열이 제공된 경우 + elif "connection_string" in kwargs: + connection = driver_module.connect(kwargs["connection_string"]) + # SQLite와 같이 파일 이름만 필요한 경우 + elif "db_name" in kwargs: + connection = driver_module.connect(kwargs["db_name"]) + # 그 외 (MySQL, PostgreSQL, Oracle 등) 일반적인 키워드 인자 방식 연결 + else: + connection = driver_module.connect(**kwargs) + + return TestConnectionResult(is_successful=True, code=CommonCode.SUCCESS_USER_DB_CONNECT_TEST) + + except Exception: + return TestConnectionResult(is_successful=False, code=CommonCode.FAIL_CONNECT_DB) + finally: + if connection: + connection.close() + + +user_db_repository = UserDbRepository() diff --git a/app/schemas/driver_info.py b/app/schemas/driver/driver_info_model.py similarity index 96% rename from app/schemas/driver_info.py rename to app/schemas/driver/driver_info_model.py index 0d7c62d..2cdf03e 100644 --- a/app/schemas/driver_info.py +++ b/app/schemas/driver/driver_info_model.py @@ -1,4 +1,4 @@ -# app/schemas/driver_info.py +# app/schemas/driver/driver_info_model.py from pydantic import BaseModel from app.core.enum.db_driver import DBTypesEnum diff --git a/app/schemas/view/connection_result_model.py b/app/schemas/user_db/connect_test_result_model.py similarity index 83% rename from app/schemas/view/connection_result_model.py rename to app/schemas/user_db/connect_test_result_model.py index c02deb4..5f68f38 100644 --- a/app/schemas/view/connection_result_model.py +++ b/app/schemas/user_db/connect_test_result_model.py @@ -1,4 +1,4 @@ -# app/schemas/view/connection_result_model.py +# app/schemas/user_db/connect_test_result_model.py from pydantic import BaseModel, Field diff --git a/app/schemas/db_profile_model.py b/app/schemas/user_db/db_profile_model.py similarity index 87% rename from app/schemas/db_profile_model.py rename to app/schemas/user_db/db_profile_model.py index 7ef63f3..616156c 100644 --- a/app/schemas/db_profile_model.py +++ b/app/schemas/user_db/db_profile_model.py @@ -1,10 +1,13 @@ -# app/schemas/db_profile_model.py +# app/schemas/user_db/db_profile_model.py from datetime import datetime from typing import Any from pydantic import BaseModel, Field +from app.core.exceptions import APIException +from app.core.status import CommonCode + # 사용자가 직접 입력해야 하는 정보만 포함합니다. class DBProfileCreate(BaseModel): @@ -27,11 +30,11 @@ def validate_required_fields(self) -> None: } if not self.type: - raise ValueError("DB 종류(type)는 필수 항목입니다.") + raise APIException(CommonCode.NO_DB_DRIVER) db_type = self.type.lower() if db_type not in required_fields_by_type: - raise ValueError(f"지원하지 않는 DB 종류입니다: {self.type}") + raise APIException(CommonCode.INVALID_DB_DRIVER) missing = [ field_name @@ -40,7 +43,7 @@ def validate_required_fields(self) -> None: ] if missing: - raise ValueError(f"{self.type} 연결에 필요한 값이 누락되었습니다: {missing}") + raise APIException(CommonCode.NO_VALUE) @staticmethod def _is_empty(value: Any | None) -> bool: diff --git a/app/services/driver_service.py b/app/services/driver_service.py index 7080808..8ff5a17 100644 --- a/app/services/driver_service.py +++ b/app/services/driver_service.py @@ -5,7 +5,7 @@ from app.core.exceptions import APIException from app.core.status import CommonCode -from app.schemas.driver_info import DriverInfo +from app.schemas.driver.driver_info_model import DriverInfo class DriverService: diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py new file mode 100644 index 0000000..50b5b9f --- /dev/null +++ b/app/services/user_db_service.py @@ -0,0 +1,80 @@ +# app/service/driver_service.py + +import importlib +import sqlite3 +from typing import Any + +from fastapi import Depends + +from app.core.enum.db_driver import DBTypesEnum +from app.core.exceptions import APIException +from app.core.status import CommonCode +from app.repository.user_db_repository import UserDbRepository, user_db_repository +from app.schemas.user_db.connect_test_result_model import TestConnectionResult +from app.schemas.user_db.db_profile_model import DBProfileCreate + +user_db_repository_dependency = Depends(lambda: user_db_repository) + + +class UserDbService: + def connection_test( + self, db_info: DBProfileCreate, repository: UserDbRepository = user_db_repository + ) -> TestConnectionResult: + """ + DB 연결 정보를 받아 연결 테스트를 수행하고 결과를 객체로 반환합니다. + """ + try: + driver_module = self._get_driver_module(db_info.type) + connect_kwargs = self._prepare_connection_args(db_info) + return repository.test_db_connection(driver_module, **connect_kwargs) + except (ValueError, ImportError) as e: + raise APIException(CommonCode.FAIL) from e + + def _get_driver_module(self, db_type: str): + """ + DB 타입에 따라 동적으로 드라이버 모듈을 로드합니다. + """ + driver_name = DBTypesEnum[db_type.lower()].value + if driver_name == "sqlite3": + return sqlite3 + return importlib.import_module(driver_name) + + def _prepare_connection_args(self, db_info: DBProfileCreate) -> dict[str, Any]: + """ + DB 타입에 따라 연결에 필요한 매개변수를 딕셔너리로 구성합니다. + """ + # SQLite는 별도 처리 + if db_info.type == "sqlite": + return {"db_name": db_info.name} + + # MSSQL은 연결 문자열을 별도로 구성 + if db_info.type == "mssql": + connection_string = ( + f"DRIVER={{ODBC Driver 17 for SQL Server}};" + f"SERVER={db_info.host},{db_info.port};" + f"UID={db_info.username};" + f"PWD={db_info.password};" + ) + if db_info.name: + connection_string += f"DATABASE={db_info.name};" + return {"connection_string": connection_string} + + # 그 외 DB들은 공통 파라미터로 시작 + kwargs = {"host": db_info.host, "port": db_info.port, "user": db_info.username, "password": db_info.password} + + # DB 이름이 없을 경우, 기본 파라미터만 반환 + if not db_info.name: + return kwargs + + # DB 이름이 있다면, 타입에 따라 적절한 파라미터를 추가합니다. + if db_info.type == "postgresql": + kwargs["dbname"] = db_info.name + elif db_info.type in ["mysql", "mariadb"]: + kwargs["database"] = db_info.name + elif db_info.type == "oracle": + kwargs["dsn"] = f"{db_info.host}:{db_info.port}/{db_info.name}" + + return kwargs + + +user_db_service = UserDbService() From d114fb4940af6c3ac4385ec7ccddc3defcf36b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:31:29 +0900 Subject: [PATCH 070/220] =?UTF-8?q?fix:=20ai=5Fcredential=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EC=9D=98=20service=5Fname=EC=9D=84=20UNIQUE?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db/init_db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/db/init_db.py b/app/db/init_db.py index 0b8fa58..79fa774 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -48,7 +48,7 @@ def initialize_database(): """ CREATE TABLE IF NOT EXISTS ai_credential ( id VARCHAR(64) PRIMARY KEY NOT NULL, - service_name VARCHAR(32) NOT NULL, + service_name VARCHAR(32) NOT NULL UNIQUE, api_key VARCHAR(256) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP From b16009313d1b715732c2352d5fdaf991ed9d648a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:35:43 +0900 Subject: [PATCH 071/220] =?UTF-8?q?feat:=20api=20key=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EC=8B=9C=EC=97=90=20=EC=82=AC=EC=9A=A9=EB=90=A0=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/llm_api_key.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 app/schemas/llm_api_key.py diff --git a/app/schemas/llm_api_key.py b/app/schemas/llm_api_key.py new file mode 100644 index 0000000..ea6b452 --- /dev/null +++ b/app/schemas/llm_api_key.py @@ -0,0 +1,28 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class ApiKeyCredentialBase(BaseModel): + service_name: str = Field(..., description="외부 서비스 이름 (예: OpenAI, Anthropic)") + + +class ApiKeyCredentialCreate(ApiKeyCredentialBase): + api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") + + +class ApiKeyCredentialInDB(ApiKeyCredentialBase): + id: str + api_key: str # DB 모델에서는 암호화된 키를 의미 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ApiKeyCredentialResponse(ApiKeyCredentialBase): + id: str + api_key_encrypted: str = Field(..., description="암호화된 API Key") + created_at: datetime + updated_at: datetime From e52450d2f91237378bcf1ea1161ca673f1d29503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:41:14 +0900 Subject: [PATCH 072/220] =?UTF-8?q?feat:=20DB=5FBUSY=EB=A5=BC=20=EB=8B=A4?= =?UTF-8?q?=EB=A3=B0=20status=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/core/status.py b/app/core/status.py index 7fe3fb1..3d77659 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -55,6 +55,11 @@ class CommonCode(Enum): # ================================== """ 기본 서버 오류 코드 - 50xx """ FAIL = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5000", "서버 처리 중 오류가 발생했습니다.") + DB_BUSY = ( + status.HTTP_503_SERVICE_UNAVAILABLE, + "5001", + "데이터베이스가 현재 사용 중입니다. 잠시 후 다시 시도해주세요.", + ) """ DRIVER, DB 서버 오류 코드 - 51xx """ FAIL_CONNECT_DB = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 연결 중 오류가 발생했습니다.") From 50d2c6ca82ed54a8413d813487af6e08264c95da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:42:38 +0900 Subject: [PATCH 073/220] =?UTF-8?q?feat:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/api_key/store_api_key_service.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 app/services/api_key/store_api_key_service.py diff --git a/app/services/api_key/store_api_key_service.py b/app/services/api_key/store_api_key_service.py new file mode 100644 index 0000000..dce1200 --- /dev/null +++ b/app/services/api_key/store_api_key_service.py @@ -0,0 +1,52 @@ +import sqlite3 + +from app.core.exceptions import APIException +from app.core.security import AES256 +from app.core.status import CommonCode +from app.core.utils import generate_uuid, get_db_path +from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialInDB + + +def store_api_key(credential_data: ApiKeyCredentialCreate) -> ApiKeyCredentialInDB: + """API_KEY를 암호화하여 데이터베이스에 저장합니다.""" + + encrypted_key = AES256.encrypt(credential_data.api_key) + new_id = generate_uuid() + + db_path = get_db_path() + conn = None + try: + # timeout을 10초로 설정하여 BUSY 상태에서 대기하도록 함 + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute( + """ + INSERT INTO ai_credential (id, service_name, api_key) + VALUES (?, ?, ?) + """, + (new_id, credential_data.service_name, encrypted_key), + ) + conn.commit() + + cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) + created_row = cursor.fetchone() + + if not created_row: + raise APIException(CommonCode.FAIL, "Failed to retrieve the created credential.") + + return ApiKeyCredentialInDB.model_validate(dict(created_row)) + + except sqlite3.IntegrityError as e: + # UNIQUE 제약 조건 위반 (service_name) + raise APIException(CommonCode.DUPLICATION) from e + except sqlite3.Error as e: + # "database is locked" 오류를 명시적으로 처리 + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + # 기타 모든 sqlite3 오류 + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() From 99d83dc4cfc41ddbdcd05def8da284f1dbe48151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:56:38 +0900 Subject: [PATCH 074/220] =?UTF-8?q?feat:=20AI=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=9C=EA=B3=B5=EC=82=AC=20enum=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/llm_service.py | 11 +++++++++++ app/schemas/llm_api_key.py | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 app/core/enum/llm_service.py diff --git a/app/core/enum/llm_service.py b/app/core/enum/llm_service.py new file mode 100644 index 0000000..538948f --- /dev/null +++ b/app/core/enum/llm_service.py @@ -0,0 +1,11 @@ +# app/core/enum/llm_service.py +from enum import Enum + + +class LLMServiceEnum(str, Enum): + """지원하는 외부 LLM 서비스 목록""" + + OPENAI = "OpenAI" + ANTHROPIC = "Anthropic" + GEMINI = "Gemini" + # TODO: 다른 지원 서비스를 여기에 추가 diff --git a/app/schemas/llm_api_key.py b/app/schemas/llm_api_key.py index ea6b452..c7eb43e 100644 --- a/app/schemas/llm_api_key.py +++ b/app/schemas/llm_api_key.py @@ -2,9 +2,11 @@ from pydantic import BaseModel, Field +from app.core.enum.llm_service import LLMServiceEnum + class ApiKeyCredentialBase(BaseModel): - service_name: str = Field(..., description="외부 서비스 이름 (예: OpenAI, Anthropic)") + service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") class ApiKeyCredentialCreate(ApiKeyCredentialBase): From 62744fd64c2cab8980621b492201d252b9c1cede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:57:46 +0900 Subject: [PATCH 075/220] =?UTF-8?q?feat:=20api=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/store_api_key_api.py | 39 ++++++++++++++++++++++++++++ app/core/status.py | 1 + 2 files changed, 40 insertions(+) create mode 100644 app/api/api_key/store_api_key_api.py diff --git a/app/api/api_key/store_api_key_api.py b/app/api/api_key/store_api_key_api.py new file mode 100644 index 0000000..79baddd --- /dev/null +++ b/app/api/api_key/store_api_key_api.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter + +from app.core.exceptions import APIException +from app.core.response import ResponseMessage +from app.core.status import CommonCode +from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialResponse +from app.services.api_key import store_api_key_service + +router = APIRouter() + + +@router.post( + "/actions", + response_model=ResponseMessage[ApiKeyCredentialResponse], + summary="API KEY 저장 (처음 한 번)", + description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", +) +def store_api_key(credential: ApiKeyCredentialCreate) -> ResponseMessage: + """ + - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") + - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") + """ + + # 우선은 간단하게 존재 여부와 공백 여부로 검증 + # TODO: 검증 로직 강화 + if not credential.api_key or credential.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + created_credential = store_api_key_service.store_api_key(credential) + + response_data = ApiKeyCredentialResponse( + id=created_credential.id, + service_name=created_credential.service_name.value, + api_key_encrypted=created_credential.api_key, + created_at=created_credential.created_at, + updated_at=created_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) diff --git a/app/core/status.py b/app/core/status.py index 3d77659..1e9278b 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -43,6 +43,7 @@ class CommonCode(Enum): NO_DB_DRIVER = (status.HTTP_400_BAD_REQUEST, "4101", "데이터베이스는 필수 값입니다.") """ KEY 클라이언트 오류 코드 - 42xx """ + INVALID_API_KEY_FORMAT = (status.HTTP_400_BAD_REQUEST, "4200", "API 키의 형식이 올바르지 않습니다.") """ AI CHAT, DB 클라이언트 오류 코드 - 43xx """ From 37121069592158b45e3f7c1dccefd2692f00f91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:58:09 +0900 Subject: [PATCH 076/220] =?UTF-8?q?feat:=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 2 ++ app/services/api_key/store_api_key_service.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/api/api_router.py b/app/api/api_router.py index c7238e1..d6bb494 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -3,6 +3,7 @@ from fastapi import APIRouter from app.api import driver_api, test_api, user_db_api +from app.api.api_key import store_api_key_api api_router = APIRouter() @@ -12,3 +13,4 @@ # 라우터 api_router.include_router(driver_api.router, prefix="/driver", tags=["Driver"]) api_router.include_router(user_db_api.router, prefix="/user/db", tags=["UserDb"]) +api_router.include_router(store_api_key_api.router, prefix="/credentials", tags=["Credentials"]) diff --git a/app/services/api_key/store_api_key_service.py b/app/services/api_key/store_api_key_service.py index dce1200..bd8bdc4 100644 --- a/app/services/api_key/store_api_key_service.py +++ b/app/services/api_key/store_api_key_service.py @@ -3,7 +3,7 @@ from app.core.exceptions import APIException from app.core.security import AES256 from app.core.status import CommonCode -from app.core.utils import generate_uuid, get_db_path +from app.core.utils import generate_prefixed_uuid, get_db_path from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialInDB @@ -11,7 +11,7 @@ def store_api_key(credential_data: ApiKeyCredentialCreate) -> ApiKeyCredentialIn """API_KEY를 암호화하여 데이터베이스에 저장합니다.""" encrypted_key = AES256.encrypt(credential_data.api_key) - new_id = generate_uuid() + new_id = generate_prefixed_uuid() db_path = get_db_path() conn = None From 8ef0deb43961412ee6267fd319642684e141bbee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 6 Aug 2025 19:12:32 +0900 Subject: [PATCH 077/220] =?UTF-8?q?refactor:=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=A7=A4=EC=B9=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/store_api_key_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/api_key/store_api_key_api.py b/app/api/api_key/store_api_key_api.py index 79baddd..92ccc27 100644 --- a/app/api/api_key/store_api_key_api.py +++ b/app/api/api_key/store_api_key_api.py @@ -15,7 +15,7 @@ summary="API KEY 저장 (처음 한 번)", description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", ) -def store_api_key(credential: ApiKeyCredentialCreate) -> ResponseMessage: +def store_api_key(credential: ApiKeyCredentialCreate) -> ResponseMessage[ApiKeyCredentialResponse]: """ - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") From c68c7ed28626eca6213878ceba501dda75c15d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 6 Aug 2025 19:26:10 +0900 Subject: [PATCH 078/220] =?UTF-8?q?refactor:=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20=EB=AA=A8=EB=8D=B8=EC=97=90=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/store_api_key_api.py | 7 ------- app/schemas/llm_api_key.py | 9 ++++++++- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/api/api_key/store_api_key_api.py b/app/api/api_key/store_api_key_api.py index 92ccc27..ac443c3 100644 --- a/app/api/api_key/store_api_key_api.py +++ b/app/api/api_key/store_api_key_api.py @@ -1,6 +1,5 @@ from fastapi import APIRouter -from app.core.exceptions import APIException from app.core.response import ResponseMessage from app.core.status import CommonCode from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialResponse @@ -20,12 +19,6 @@ def store_api_key(credential: ApiKeyCredentialCreate) -> ResponseMessage[ApiKeyC - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") """ - - # 우선은 간단하게 존재 여부와 공백 여부로 검증 - # TODO: 검증 로직 강화 - if not credential.api_key or credential.api_key.isspace(): - raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - created_credential = store_api_key_service.store_api_key(credential) response_data = ApiKeyCredentialResponse( diff --git a/app/schemas/llm_api_key.py b/app/schemas/llm_api_key.py index c7eb43e..4c5f2f1 100644 --- a/app/schemas/llm_api_key.py +++ b/app/schemas/llm_api_key.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from app.core.enum.llm_service import LLMServiceEnum @@ -12,6 +12,13 @@ class ApiKeyCredentialBase(BaseModel): class ApiKeyCredentialCreate(ApiKeyCredentialBase): api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") + @field_validator("api_key", mode="after") + @classmethod + def validate_api_key(cls, v: str) -> str: + if not v or v.isspace(): + raise ValueError("API key cannot be empty or just whitespace.") + return v + class ApiKeyCredentialInDB(ApiKeyCredentialBase): id: str From f69b4a236ad9816818bad4d66edcefc37a38d149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 6 Aug 2025 19:30:20 +0900 Subject: [PATCH 079/220] =?UTF-8?q?refactor:=20=EB=AA=85=ED=99=95=ED=95=9C?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=EC=BD=94=EB=93=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 7 ++++++- app/services/api_key/store_api_key_service.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index 1e9278b..701f1bf 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -61,7 +61,12 @@ class CommonCode(Enum): "5001", "데이터베이스가 현재 사용 중입니다. 잠시 후 다시 시도해주세요.", ) - + FAIL_TO_VERIFY_CREATION = ( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "5002", + "데이터 생성 후 검증 과정에서 오류가 발생했습니다.", + ) + """ DRIVER, DB 서버 오류 코드 - 51xx """ FAIL_CONNECT_DB = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 연결 중 오류가 발생했습니다.") diff --git a/app/services/api_key/store_api_key_service.py b/app/services/api_key/store_api_key_service.py index bd8bdc4..c176623 100644 --- a/app/services/api_key/store_api_key_service.py +++ b/app/services/api_key/store_api_key_service.py @@ -34,7 +34,7 @@ def store_api_key(credential_data: ApiKeyCredentialCreate) -> ApiKeyCredentialIn created_row = cursor.fetchone() if not created_row: - raise APIException(CommonCode.FAIL, "Failed to retrieve the created credential.") + raise APIException(CommonCode.FAIL_TO_VERIFY_CREATION) return ApiKeyCredentialInDB.model_validate(dict(created_row)) From f8f19276962db62b7a0dc3b64329cb3157136ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 11:15:14 +0900 Subject: [PATCH 080/220] =?UTF-8?q?refactor:=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B0=8F=20=ED=8F=B4=EB=8D=94=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store_api_key_api.py => api_key_api.py} | 13 ++++--- app/api/api_router.py | 5 +-- app/schemas/api_key/base_model.py | 10 +++++ app/schemas/api_key/create_model.py | 17 +++++++++ app/schemas/api_key/db_model.py | 16 ++++++++ app/schemas/api_key/response_model.py | 15 ++++++++ app/schemas/llm_api_key.py | 37 ------------------- ..._api_key_service.py => api_key_service.py} | 9 +++-- 8 files changed, 72 insertions(+), 50 deletions(-) rename app/api/{api_key/store_api_key_api.py => api_key_api.py} (66%) create mode 100644 app/schemas/api_key/base_model.py create mode 100644 app/schemas/api_key/create_model.py create mode 100644 app/schemas/api_key/db_model.py create mode 100644 app/schemas/api_key/response_model.py delete mode 100644 app/schemas/llm_api_key.py rename app/services/{api_key/store_api_key_service.py => api_key_service.py} (84%) diff --git a/app/api/api_key/store_api_key_api.py b/app/api/api_key_api.py similarity index 66% rename from app/api/api_key/store_api_key_api.py rename to app/api/api_key_api.py index ac443c3..f0e0142 100644 --- a/app/api/api_key/store_api_key_api.py +++ b/app/api/api_key_api.py @@ -2,26 +2,27 @@ from app.core.response import ResponseMessage from app.core.status import CommonCode -from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialResponse -from app.services.api_key import store_api_key_service +from app.schemas.api_key.create_model import APIKeyCreate +from app.schemas.api_key.response_model import APIKeyResponse +from app.services import api_key_service router = APIRouter() @router.post( "/actions", - response_model=ResponseMessage[ApiKeyCredentialResponse], + response_model=ResponseMessage[APIKeyResponse], summary="API KEY 저장 (처음 한 번)", description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", ) -def store_api_key(credential: ApiKeyCredentialCreate) -> ResponseMessage[ApiKeyCredentialResponse]: +def store_api_key(credential: APIKeyCreate) -> ResponseMessage[APIKeyResponse]: """ - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") """ - created_credential = store_api_key_service.store_api_key(credential) + created_credential = api_key_service.store_api_key(credential) - response_data = ApiKeyCredentialResponse( + response_data = APIKeyResponse( id=created_credential.id, service_name=created_credential.service_name.value, api_key_encrypted=created_credential.api_key, diff --git a/app/api/api_router.py b/app/api/api_router.py index d6bb494..6964eb9 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -2,8 +2,7 @@ from fastapi import APIRouter -from app.api import driver_api, test_api, user_db_api -from app.api.api_key import store_api_key_api +from app.api import api_key_api, driver_api, test_api, user_db_api api_router = APIRouter() @@ -13,4 +12,4 @@ # 라우터 api_router.include_router(driver_api.router, prefix="/driver", tags=["Driver"]) api_router.include_router(user_db_api.router, prefix="/user/db", tags=["UserDb"]) -api_router.include_router(store_api_key_api.router, prefix="/credentials", tags=["Credentials"]) +api_router.include_router(api_key_api.router, prefix="/credentials", tags=["Credentials"]) diff --git a/app/schemas/api_key/base_model.py b/app/schemas/api_key/base_model.py new file mode 100644 index 0000000..a8f2b8d --- /dev/null +++ b/app/schemas/api_key/base_model.py @@ -0,0 +1,10 @@ +# app/schemas/api_key/base_model.py +from pydantic import BaseModel, Field + +from app.core.enum.llm_service import LLMServiceEnum + + +class APIKeyBase(BaseModel): + """API Key 도메인의 모든 스키마가 상속하는 기본 모델""" + + service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") diff --git a/app/schemas/api_key/create_model.py b/app/schemas/api_key/create_model.py new file mode 100644 index 0000000..bf45a1c --- /dev/null +++ b/app/schemas/api_key/create_model.py @@ -0,0 +1,17 @@ +# app/schemas/api_key/create_model.py +from pydantic import Field, field_validator + +from app.schemas.api_key.base_model import APIKeyBase + + +class APIKeyCreate(APIKeyBase): + """API Key 생성을 위한 스키마""" + + api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") + + @field_validator("api_key", mode="after") + @classmethod + def validate_api_key(cls, v: str) -> str: + if not v or v.isspace(): + raise ValueError("API key cannot be empty or just whitespace.") + return v diff --git a/app/schemas/api_key/db_model.py b/app/schemas/api_key/db_model.py new file mode 100644 index 0000000..6156b98 --- /dev/null +++ b/app/schemas/api_key/db_model.py @@ -0,0 +1,16 @@ +# app/schemas/api_key/db_model.py +from datetime import datetime + +from app.schemas.api_key.base_model import APIKeyBase + + +class APIKeyInDB(APIKeyBase): + """데이터베이스에 저장된 형태의 스키마 (내부용)""" + + id: str + api_key: str # DB 모델에서는 암호화된 키를 의미 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/app/schemas/api_key/response_model.py b/app/schemas/api_key/response_model.py new file mode 100644 index 0000000..0b1356b --- /dev/null +++ b/app/schemas/api_key/response_model.py @@ -0,0 +1,15 @@ +# app/schemas/api_key/response_model.py +from datetime import datetime + +from pydantic import Field + +from app.schemas.api_key.base_model import APIKeyBase + + +class APIKeyResponse(APIKeyBase): + """API 응답용 스키마""" + + id: str + api_key_encrypted: str = Field(..., description="암호화된 API Key") + created_at: datetime + updated_at: datetime diff --git a/app/schemas/llm_api_key.py b/app/schemas/llm_api_key.py deleted file mode 100644 index 4c5f2f1..0000000 --- a/app/schemas/llm_api_key.py +++ /dev/null @@ -1,37 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, Field, field_validator - -from app.core.enum.llm_service import LLMServiceEnum - - -class ApiKeyCredentialBase(BaseModel): - service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") - - -class ApiKeyCredentialCreate(ApiKeyCredentialBase): - api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") - - @field_validator("api_key", mode="after") - @classmethod - def validate_api_key(cls, v: str) -> str: - if not v or v.isspace(): - raise ValueError("API key cannot be empty or just whitespace.") - return v - - -class ApiKeyCredentialInDB(ApiKeyCredentialBase): - id: str - api_key: str # DB 모델에서는 암호화된 키를 의미 - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - - -class ApiKeyCredentialResponse(ApiKeyCredentialBase): - id: str - api_key_encrypted: str = Field(..., description="암호화된 API Key") - created_at: datetime - updated_at: datetime diff --git a/app/services/api_key/store_api_key_service.py b/app/services/api_key_service.py similarity index 84% rename from app/services/api_key/store_api_key_service.py rename to app/services/api_key_service.py index c176623..b950b2e 100644 --- a/app/services/api_key/store_api_key_service.py +++ b/app/services/api_key_service.py @@ -4,14 +4,15 @@ from app.core.security import AES256 from app.core.status import CommonCode from app.core.utils import generate_prefixed_uuid, get_db_path -from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialInDB +from app.schemas.api_key.create_model import APIKeyCreate +from app.schemas.api_key.db_model import APIKeyInDB -def store_api_key(credential_data: ApiKeyCredentialCreate) -> ApiKeyCredentialInDB: +def store_api_key(credential_data: APIKeyCreate) -> APIKeyInDB: """API_KEY를 암호화하여 데이터베이스에 저장합니다.""" encrypted_key = AES256.encrypt(credential_data.api_key) - new_id = generate_prefixed_uuid() + new_id = generate_prefixed_uuid("QGENIE") db_path = get_db_path() conn = None @@ -36,7 +37,7 @@ def store_api_key(credential_data: ApiKeyCredentialCreate) -> ApiKeyCredentialIn if not created_row: raise APIException(CommonCode.FAIL_TO_VERIFY_CREATION) - return ApiKeyCredentialInDB.model_validate(dict(created_row)) + return APIKeyInDB.model_validate(dict(created_row)) except sqlite3.IntegrityError as e: # UNIQUE 제약 조건 위반 (service_name) From 492ab25f6cfe72ff0ae4cd544ec3bd2dabb4eacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 11:30:23 +0900 Subject: [PATCH 081/220] =?UTF-8?q?refactor:=20repository=20->=20service?= =?UTF-8?q?=20->=20api=20&=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key_api.py | 12 ++-- app/repository/api_key_repository.py | 42 ++++++++++++++ app/schemas/api_key/base_model.py | 1 - app/schemas/api_key/create_model.py | 1 - app/schemas/api_key/db_model.py | 1 - app/schemas/api_key/response_model.py | 1 - app/services/api_key_service.py | 82 +++++++++++++-------------- 7 files changed, 88 insertions(+), 52 deletions(-) create mode 100644 app/repository/api_key_repository.py diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index f0e0142..15a66ef 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -1,10 +1,12 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends from app.core.response import ResponseMessage from app.core.status import CommonCode from app.schemas.api_key.create_model import APIKeyCreate from app.schemas.api_key.response_model import APIKeyResponse -from app.services import api_key_service +from app.services.api_key_service import APIKeyService, api_key_service + +api_key_service_dependency = Depends(lambda: api_key_service) router = APIRouter() @@ -15,12 +17,14 @@ summary="API KEY 저장 (처음 한 번)", description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", ) -def store_api_key(credential: APIKeyCreate) -> ResponseMessage[APIKeyResponse]: +def store_api_key( + credential: APIKeyCreate, service: APIKeyService = api_key_service_dependency +) -> ResponseMessage[APIKeyResponse]: """ - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") """ - created_credential = api_key_service.store_api_key(credential) + created_credential = service.store_api_key(credential) response_data = APIKeyResponse( id=created_credential.id, diff --git a/app/repository/api_key_repository.py b/app/repository/api_key_repository.py new file mode 100644 index 0000000..231de98 --- /dev/null +++ b/app/repository/api_key_repository.py @@ -0,0 +1,42 @@ +import sqlite3 + +from app.core.utils import get_db_path +from app.schemas.api_key.db_model import APIKeyInDB + + +class APIKeyRepository: + def create_api_key(self, new_id: str, service_name: str, encrypted_key: str) -> APIKeyInDB: + """ + 암호화된 API Key 정보를 받아 데이터베이스에 저장하고, + 저장된 객체를 반환합니다. + """ + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute( + """ + INSERT INTO ai_credential (id, service_name, api_key) + VALUES (?, ?, ?) + """, + (new_id, service_name, encrypted_key), + ) + conn.commit() + + cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) + created_row = cursor.fetchone() + + if not created_row: + return None + + return APIKeyInDB.model_validate(dict(created_row)) + + finally: + if conn: + conn.close() + + +api_key_repository = APIKeyRepository() diff --git a/app/schemas/api_key/base_model.py b/app/schemas/api_key/base_model.py index a8f2b8d..8a4a840 100644 --- a/app/schemas/api_key/base_model.py +++ b/app/schemas/api_key/base_model.py @@ -1,4 +1,3 @@ -# app/schemas/api_key/base_model.py from pydantic import BaseModel, Field from app.core.enum.llm_service import LLMServiceEnum diff --git a/app/schemas/api_key/create_model.py b/app/schemas/api_key/create_model.py index bf45a1c..8803847 100644 --- a/app/schemas/api_key/create_model.py +++ b/app/schemas/api_key/create_model.py @@ -1,4 +1,3 @@ -# app/schemas/api_key/create_model.py from pydantic import Field, field_validator from app.schemas.api_key.base_model import APIKeyBase diff --git a/app/schemas/api_key/db_model.py b/app/schemas/api_key/db_model.py index 6156b98..251c95c 100644 --- a/app/schemas/api_key/db_model.py +++ b/app/schemas/api_key/db_model.py @@ -1,4 +1,3 @@ -# app/schemas/api_key/db_model.py from datetime import datetime from app.schemas.api_key.base_model import APIKeyBase diff --git a/app/schemas/api_key/response_model.py b/app/schemas/api_key/response_model.py index 0b1356b..fa3a090 100644 --- a/app/schemas/api_key/response_model.py +++ b/app/schemas/api_key/response_model.py @@ -1,4 +1,3 @@ -# app/schemas/api_key/response_model.py from datetime import datetime from pydantic import Field diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index b950b2e..f36db09 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -1,53 +1,47 @@ import sqlite3 +from fastapi import Depends + from app.core.exceptions import APIException from app.core.security import AES256 from app.core.status import CommonCode -from app.core.utils import generate_prefixed_uuid, get_db_path +from app.core.utils import generate_prefixed_uuid +from app.repository.api_key_repository import APIKeyRepository, api_key_repository from app.schemas.api_key.create_model import APIKeyCreate from app.schemas.api_key.db_model import APIKeyInDB +api_key_repository_dependency = Depends(lambda: api_key_repository) + + +class APIKeyService: + def store_api_key( + self, credential_data: APIKeyCreate, repository: APIKeyRepository = api_key_repository + ) -> APIKeyInDB: + """API_KEY를 암호화하고 repository를 통해 데이터베이스에 저장합니다.""" + try: + encrypted_key = AES256.encrypt(credential_data.api_key) + new_id = generate_prefixed_uuid("QGENIE") + + created_row = repository.create_api_key( + new_id=new_id, + service_name=credential_data.service_name.value, + encrypted_key=encrypted_key, + ) + + if not created_row: + raise APIException(CommonCode.FAIL_TO_VERIFY_CREATION) + + return created_row + + except sqlite3.IntegrityError as e: + # UNIQUE 제약 조건 위반 (service_name) + raise APIException(CommonCode.DUPLICATION) from e + except sqlite3.Error as e: + # "database is locked" 오류를 명시적으로 처리 + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + # 기타 모든 sqlite3 오류 + raise APIException(CommonCode.FAIL) from e + -def store_api_key(credential_data: APIKeyCreate) -> APIKeyInDB: - """API_KEY를 암호화하여 데이터베이스에 저장합니다.""" - - encrypted_key = AES256.encrypt(credential_data.api_key) - new_id = generate_prefixed_uuid("QGENIE") - - db_path = get_db_path() - conn = None - try: - # timeout을 10초로 설정하여 BUSY 상태에서 대기하도록 함 - conn = sqlite3.connect(str(db_path), timeout=10) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute( - """ - INSERT INTO ai_credential (id, service_name, api_key) - VALUES (?, ?, ?) - """, - (new_id, credential_data.service_name, encrypted_key), - ) - conn.commit() - - cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) - created_row = cursor.fetchone() - - if not created_row: - raise APIException(CommonCode.FAIL_TO_VERIFY_CREATION) - - return APIKeyInDB.model_validate(dict(created_row)) - - except sqlite3.IntegrityError as e: - # UNIQUE 제약 조건 위반 (service_name) - raise APIException(CommonCode.DUPLICATION) from e - except sqlite3.Error as e: - # "database is locked" 오류를 명시적으로 처리 - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - # 기타 모든 sqlite3 오류 - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() +api_key_service = APIKeyService() From 3e920e4fbe4731a90dbf4b5bfc16f0f7c137af07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 13:33:01 +0900 Subject: [PATCH 082/220] =?UTF-8?q?refactor:=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=EA=B3=84=EC=B8=B5=EA=B3=BC=20=EA=B5=AC=EB=B6=84=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=9C=84=ED=95=B4=20llm=5Fservice=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/{llm_service.py => llm_service_info.py} | 1 - app/schemas/api_key/base_model.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) rename app/core/enum/{llm_service.py => llm_service_info.py} (88%) diff --git a/app/core/enum/llm_service.py b/app/core/enum/llm_service_info.py similarity index 88% rename from app/core/enum/llm_service.py rename to app/core/enum/llm_service_info.py index 538948f..c568e8d 100644 --- a/app/core/enum/llm_service.py +++ b/app/core/enum/llm_service_info.py @@ -1,4 +1,3 @@ -# app/core/enum/llm_service.py from enum import Enum diff --git a/app/schemas/api_key/base_model.py b/app/schemas/api_key/base_model.py index 8a4a840..bb2c3f0 100644 --- a/app/schemas/api_key/base_model.py +++ b/app/schemas/api_key/base_model.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field -from app.core.enum.llm_service import LLMServiceEnum +from app.core.enum.llm_service_info import LLMServiceEnum class APIKeyBase(BaseModel): From 3fb1906b8326e79687b0849893e6c9ebe545a999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 13:42:29 +0900 Subject: [PATCH 083/220] =?UTF-8?q?refactor:=20method=EB=A5=BC=20=ED=86=B5?= =?UTF-8?q?=ED=95=9C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 7 ++++++- app/schemas/api_key/create_model.py | 25 ++++++++++++++++++------- app/services/api_key_service.py | 1 + 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index 701f1bf..6d5e538 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -44,6 +44,11 @@ class CommonCode(Enum): """ KEY 클라이언트 오류 코드 - 42xx """ INVALID_API_KEY_FORMAT = (status.HTTP_400_BAD_REQUEST, "4200", "API 키의 형식이 올바르지 않습니다.") + INVALID_API_KEY_PREFIX = ( + status.HTTP_400_BAD_REQUEST, + "4201", + "API 키가 선택한 서비스의 올바른 형식이 아닙니다. (예: OpenAI는 sk-로 시작)", + ) """ AI CHAT, DB 클라이언트 오류 코드 - 43xx """ @@ -66,7 +71,7 @@ class CommonCode(Enum): "5002", "데이터 생성 후 검증 과정에서 오류가 발생했습니다.", ) - + """ DRIVER, DB 서버 오류 코드 - 51xx """ FAIL_CONNECT_DB = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 연결 중 오류가 발생했습니다.") diff --git a/app/schemas/api_key/create_model.py b/app/schemas/api_key/create_model.py index 8803847..c53e3de 100644 --- a/app/schemas/api_key/create_model.py +++ b/app/schemas/api_key/create_model.py @@ -1,5 +1,8 @@ -from pydantic import Field, field_validator +from pydantic import Field +from app.core.enum.llm_service_info import LLMServiceEnum +from app.core.exceptions import APIException +from app.core.status import CommonCode from app.schemas.api_key.base_model import APIKeyBase @@ -8,9 +11,17 @@ class APIKeyCreate(APIKeyBase): api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") - @field_validator("api_key", mode="after") - @classmethod - def validate_api_key(cls, v: str) -> str: - if not v or v.isspace(): - raise ValueError("API key cannot be empty or just whitespace.") - return v + def validate_with_service(self) -> None: + """서비스 종류에 따라 API Key의 유효성을 검증합니다.""" + # 1. 기본 형식 검증 (공백 또는 빈 문자열) + if not self.api_key or self.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + # 2. 서비스별 접두사 검증 + key_prefix_map = { + LLMServiceEnum.OPENAI: "sk-", + } + required_prefix = key_prefix_map.get(self.service_name) + + if required_prefix and not self.api_key.startswith(required_prefix): + raise APIException(CommonCode.INVALID_API_KEY_PREFIX) diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index f36db09..92c85d5 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -18,6 +18,7 @@ def store_api_key( self, credential_data: APIKeyCreate, repository: APIKeyRepository = api_key_repository ) -> APIKeyInDB: """API_KEY를 암호화하고 repository를 통해 데이터베이스에 저장합니다.""" + credential_data.validate_with_service() try: encrypted_key = AES256.encrypt(credential_data.api_key) new_id = generate_prefixed_uuid("QGENIE") From ef61630deec471d90d75a19ae24b51601165acc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:57:46 +0900 Subject: [PATCH 084/220] =?UTF-8?q?feat:=20api=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/store_api_key_api.py | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 app/api/api_key/store_api_key_api.py diff --git a/app/api/api_key/store_api_key_api.py b/app/api/api_key/store_api_key_api.py new file mode 100644 index 0000000..79baddd --- /dev/null +++ b/app/api/api_key/store_api_key_api.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter + +from app.core.exceptions import APIException +from app.core.response import ResponseMessage +from app.core.status import CommonCode +from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialResponse +from app.services.api_key import store_api_key_service + +router = APIRouter() + + +@router.post( + "/actions", + response_model=ResponseMessage[ApiKeyCredentialResponse], + summary="API KEY 저장 (처음 한 번)", + description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", +) +def store_api_key(credential: ApiKeyCredentialCreate) -> ResponseMessage: + """ + - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") + - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") + """ + + # 우선은 간단하게 존재 여부와 공백 여부로 검증 + # TODO: 검증 로직 강화 + if not credential.api_key or credential.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + created_credential = store_api_key_service.store_api_key(credential) + + response_data = ApiKeyCredentialResponse( + id=created_credential.id, + service_name=created_credential.service_name.value, + api_key_encrypted=created_credential.api_key, + created_at=created_credential.created_at, + updated_at=created_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) From 075da1192b7272b488d0c04040f88e727bb9966b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 4 Aug 2025 16:58:09 +0900 Subject: [PATCH 085/220] =?UTF-8?q?feat:=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/api_key/store_api_key_service.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 app/services/api_key/store_api_key_service.py diff --git a/app/services/api_key/store_api_key_service.py b/app/services/api_key/store_api_key_service.py new file mode 100644 index 0000000..bd8bdc4 --- /dev/null +++ b/app/services/api_key/store_api_key_service.py @@ -0,0 +1,52 @@ +import sqlite3 + +from app.core.exceptions import APIException +from app.core.security import AES256 +from app.core.status import CommonCode +from app.core.utils import generate_prefixed_uuid, get_db_path +from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialInDB + + +def store_api_key(credential_data: ApiKeyCredentialCreate) -> ApiKeyCredentialInDB: + """API_KEY를 암호화하여 데이터베이스에 저장합니다.""" + + encrypted_key = AES256.encrypt(credential_data.api_key) + new_id = generate_prefixed_uuid() + + db_path = get_db_path() + conn = None + try: + # timeout을 10초로 설정하여 BUSY 상태에서 대기하도록 함 + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute( + """ + INSERT INTO ai_credential (id, service_name, api_key) + VALUES (?, ?, ?) + """, + (new_id, credential_data.service_name, encrypted_key), + ) + conn.commit() + + cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) + created_row = cursor.fetchone() + + if not created_row: + raise APIException(CommonCode.FAIL, "Failed to retrieve the created credential.") + + return ApiKeyCredentialInDB.model_validate(dict(created_row)) + + except sqlite3.IntegrityError as e: + # UNIQUE 제약 조건 위반 (service_name) + raise APIException(CommonCode.DUPLICATION) from e + except sqlite3.Error as e: + # "database is locked" 오류를 명시적으로 처리 + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + # 기타 모든 sqlite3 오류 + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() From 89373976fb8d08dcc694a1fdae9639f1334f0cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 14:19:58 +0900 Subject: [PATCH 086/220] =?UTF-8?q?feat:=20API=20Key=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20&=20=EB=8B=A8=EC=9D=BC=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 85 ++++++++++++++++++++++ app/api/api_key/store_api_key_api.py | 39 ----------- app/api/api_router.py | 2 +- app/schemas/api_key.py | 37 ++++++++++ app/services/api_key/service.py | 101 +++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 app/api/api_key/api.py delete mode 100644 app/api/api_key/store_api_key_api.py create mode 100644 app/schemas/api_key.py create mode 100644 app/services/api_key/service.py diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py new file mode 100644 index 0000000..9bd69c2 --- /dev/null +++ b/app/api/api_key/api.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter + +from app.core.enum.llm_service import LLMServiceEnum +from app.core.exceptions import APIException +from app.core.response import ResponseMessage +from app.core.status import CommonCode +from app.schemas.api_key import APIKeyInfo, APIKeyStore +from app.services.api_key import service as api_key_service + +router = APIRouter() + + +@router.post( + "/action", + response_model=ResponseMessage[APIKeyInfo], + summary="API KEY 저장 (처음 한 번)", + description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", +) +def store_api_key(credential: APIKeyStore) -> ResponseMessage: + """ + - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") + - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") + """ + + # 우선은 간단하게 존재 여부와 공백 여부로 검증 + # service_name은 enum이라서 pydantic이 자동으로 검증해줌 + # 전역 핸들러가 feature/db-connect 브랜치에 있으니 따로 처리하지 않는걸로 + # TODO: 검증 로직 강화 + if not credential.api_key or credential.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + created_credential = api_key_service.store_api_key(credential) + + response_data = APIKeyInfo( + id=created_credential.id, + service_name=created_credential.service_name, + created_at=created_credential.created_at, + updated_at=created_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) + + +@router.get( + "/result", + response_model=ResponseMessage[list[APIKeyInfo]], + summary="저장된 모든 API KEY 정보 조회", + description=""" + ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. + 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. + """, +) +def get_all_api_keys(): + """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" + db_credentials = api_key_service.get_all_api_keys() + + response_data = [ + APIKeyInfo( + id=cred.id, + service_name=cred.service_name, + created_at=cred.created_at, + updated_at=cred.updated_at, + ) + for cred in db_credentials + ] + return ResponseMessage.success(value=response_data) + + +@router.get( + "/result/{serviceName}", + response_model=ResponseMessage[APIKeyInfo], + summary="특정 서비스의 API KEY 정보 조회", + description="필요 없을 것 같음", +) +def get_api_key_by_service_name(serviceName: LLMServiceEnum): + """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" + db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) + + response_data = APIKeyInfo( + id=db_credential.id, + service_name=db_credential.service_name, + created_at=db_credential.created_at, + updated_at=db_credential.updated_at, + ) + return ResponseMessage.success(value=response_data) diff --git a/app/api/api_key/store_api_key_api.py b/app/api/api_key/store_api_key_api.py deleted file mode 100644 index 79baddd..0000000 --- a/app/api/api_key/store_api_key_api.py +++ /dev/null @@ -1,39 +0,0 @@ -from fastapi import APIRouter - -from app.core.exceptions import APIException -from app.core.response import ResponseMessage -from app.core.status import CommonCode -from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialResponse -from app.services.api_key import store_api_key_service - -router = APIRouter() - - -@router.post( - "/actions", - response_model=ResponseMessage[ApiKeyCredentialResponse], - summary="API KEY 저장 (처음 한 번)", - description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", -) -def store_api_key(credential: ApiKeyCredentialCreate) -> ResponseMessage: - """ - - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") - """ - - # 우선은 간단하게 존재 여부와 공백 여부로 검증 - # TODO: 검증 로직 강화 - if not credential.api_key or credential.api_key.isspace(): - raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - - created_credential = store_api_key_service.store_api_key(credential) - - response_data = ApiKeyCredentialResponse( - id=created_credential.id, - service_name=created_credential.service_name.value, - api_key_encrypted=created_credential.api_key, - created_at=created_credential.created_at, - updated_at=created_credential.updated_at, - ) - - return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) diff --git a/app/api/api_router.py b/app/api/api_router.py index 6964eb9..b1c2d39 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -12,4 +12,4 @@ # 라우터 api_router.include_router(driver_api.router, prefix="/driver", tags=["Driver"]) api_router.include_router(user_db_api.router, prefix="/user/db", tags=["UserDb"]) -api_router.include_router(api_key_api.router, prefix="/credentials", tags=["Credentials"]) +api_router.include_router(api_key_api.router, prefix="/keys", tags=["API Key"]) diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py new file mode 100644 index 0000000..d7d2f53 --- /dev/null +++ b/app/schemas/api_key.py @@ -0,0 +1,37 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.core.enum.llm_service import LLMServiceEnum + + +class APIKeyBase(BaseModel): + """모든 API Key 스키마의 기본 모델""" + + service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") + + +class APIKeyStore(APIKeyBase): + """API Key 저장을 위한 스키마""" + + api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") + + +class APIKeyInDB(APIKeyBase): + """데이터베이스에 저장된 형태의 스키마 (내부용)""" + + id: str + api_key: str # DB 모델에서는 암호화된 키를 의미 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class APIKeyInfo(APIKeyBase): + """API 응답용 스키마 (민감 정보 제외)""" + + id: str + created_at: datetime + updated_at: datetime diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py new file mode 100644 index 0000000..b793cfe --- /dev/null +++ b/app/services/api_key/service.py @@ -0,0 +1,101 @@ +import sqlite3 + +from app.core.exceptions import APIException +from app.core.security import AES256 +from app.core.status import CommonCode +from app.core.utils import generate_prefixed_uuid, get_db_path +from app.schemas.api_key import APIKeyInDB, APIKeyStore + + +def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: + """API Key를 암호화하여 데이터베이스에 저장합니다.""" + encrypted_key = AES256.encrypt(credential_data.api_key) + new_id = generate_prefixed_uuid() + + db_path = get_db_path() + conn = None + try: + # timeout을 10초로 설정하여 BUSY 상태에서 대기하도록 함 + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute( + """ + INSERT INTO ai_credential (id, service_name, api_key) + VALUES (?, ?, ?) + """, + (new_id, credential_data.service_name.value, encrypted_key), + ) + conn.commit() + + cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) + created_row = cursor.fetchone() + + if not created_row: + raise APIException(CommonCode.FAIL, "Failed to retrieve the created credential.") + + return APIKeyInDB.model_validate(dict(created_row)) + + except sqlite3.IntegrityError as e: + # UNIQUE 제약 조건 위반 (service_name) + raise APIException(CommonCode.DUPLICATION) from e + except sqlite3.Error as e: + # "database is locked" 오류를 명시적으로 처리 + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + # 기타 모든 sqlite3 오류 + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() + + +def get_all_api_keys() -> list[APIKeyInDB]: + """데이터베이스에 저장된 모든 API Key를 조회합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute("SELECT * FROM ai_credential") + rows = cursor.fetchall() + + # 저장된 API Key가 없으면 그냥 빈 리스트를 반환할지? + # 아니면 예외처리를 해줄지? + return [APIKeyInDB.model_validate(dict(row)) for row in rows] + + # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 + except sqlite3.Error as e: + print(e.__class__) + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() + + +def get_api_key_by_service_name(service_name: str) -> APIKeyInDB: + """서비스 이름으로 특정 API Key를 조회합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) + row = cursor.fetchone() + + if not row: + raise APIException(CommonCode.NO_SEARCH_DATA) + + return APIKeyInDB.model_validate(dict(row)) + + # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() From e8d4812c70d82c1bf00158c1e23ba38607522327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 12:00:52 +0900 Subject: [PATCH 087/220] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20&=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EC=BD=94=EB=93=9C=EB=A5=BC=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EB=90=9C=20=EA=B5=AC=EC=A1=B0=EC=97=90=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 85 --------------- app/api/api_key_api.py | 48 +++++++++ app/repository/api_key_repository.py | 37 +++++++ app/schemas/api_key.py | 37 ------- app/schemas/api_key/response_model.py | 3 - app/services/api_key/service.py | 101 ------------------ app/services/api_key/store_api_key_service.py | 52 --------- app/services/api_key_service.py | 29 +++-- 8 files changed, 107 insertions(+), 285 deletions(-) delete mode 100644 app/api/api_key/api.py delete mode 100644 app/schemas/api_key.py delete mode 100644 app/services/api_key/service.py delete mode 100644 app/services/api_key/store_api_key_service.py diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py deleted file mode 100644 index 9bd69c2..0000000 --- a/app/api/api_key/api.py +++ /dev/null @@ -1,85 +0,0 @@ -from fastapi import APIRouter - -from app.core.enum.llm_service import LLMServiceEnum -from app.core.exceptions import APIException -from app.core.response import ResponseMessage -from app.core.status import CommonCode -from app.schemas.api_key import APIKeyInfo, APIKeyStore -from app.services.api_key import service as api_key_service - -router = APIRouter() - - -@router.post( - "/action", - response_model=ResponseMessage[APIKeyInfo], - summary="API KEY 저장 (처음 한 번)", - description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", -) -def store_api_key(credential: APIKeyStore) -> ResponseMessage: - """ - - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") - """ - - # 우선은 간단하게 존재 여부와 공백 여부로 검증 - # service_name은 enum이라서 pydantic이 자동으로 검증해줌 - # 전역 핸들러가 feature/db-connect 브랜치에 있으니 따로 처리하지 않는걸로 - # TODO: 검증 로직 강화 - if not credential.api_key or credential.api_key.isspace(): - raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - - created_credential = api_key_service.store_api_key(credential) - - response_data = APIKeyInfo( - id=created_credential.id, - service_name=created_credential.service_name, - created_at=created_credential.created_at, - updated_at=created_credential.updated_at, - ) - - return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) - - -@router.get( - "/result", - response_model=ResponseMessage[list[APIKeyInfo]], - summary="저장된 모든 API KEY 정보 조회", - description=""" - ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. - 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. - """, -) -def get_all_api_keys(): - """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" - db_credentials = api_key_service.get_all_api_keys() - - response_data = [ - APIKeyInfo( - id=cred.id, - service_name=cred.service_name, - created_at=cred.created_at, - updated_at=cred.updated_at, - ) - for cred in db_credentials - ] - return ResponseMessage.success(value=response_data) - - -@router.get( - "/result/{serviceName}", - response_model=ResponseMessage[APIKeyInfo], - summary="특정 서비스의 API KEY 정보 조회", - description="필요 없을 것 같음", -) -def get_api_key_by_service_name(serviceName: LLMServiceEnum): - """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" - db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) - - response_data = APIKeyInfo( - id=db_credential.id, - service_name=db_credential.service_name, - created_at=db_credential.created_at, - updated_at=db_credential.updated_at, - ) - return ResponseMessage.success(value=response_data) diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index 15a66ef..e17e1df 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends +from app.core.enum.llm_service_info import LLMServiceEnum from app.core.response import ResponseMessage from app.core.status import CommonCode from app.schemas.api_key.create_model import APIKeyCreate @@ -35,3 +36,50 @@ def store_api_key( ) return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) + + +@router.get( + "/result", + response_model=ResponseMessage[list[APIKeyResponse]], + summary="저장된 모든 API KEY 정보 조회", + description=""" + ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. + 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. + """, +) +def get_all_api_keys( + service: APIKeyService = api_key_service_dependency, +) -> ResponseMessage[list[APIKeyResponse]]: + """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" + db_credentials = service.get_all_api_keys() + + response_data = [ + APIKeyResponse( + id=cred.id, + service_name=cred.service_name, + created_at=cred.created_at, + updated_at=cred.updated_at, + ) + for cred in db_credentials + ] + return ResponseMessage.success(value=response_data) + + +@router.get( + "/result/{serviceName}", + response_model=ResponseMessage[APIKeyResponse], + summary="특정 서비스의 API KEY 정보 조회", +) +def get_api_key_by_service_name( + serviceName: LLMServiceEnum, service: APIKeyService = api_key_service_dependency +) -> ResponseMessage[APIKeyResponse]: + """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" + db_credential = service.get_api_key_by_service_name(serviceName) + + response_data = APIKeyResponse( + id=db_credential.id, + service_name=db_credential.service_name, + created_at=db_credential.created_at, + updated_at=db_credential.updated_at, + ) + return ResponseMessage.success(value=response_data) diff --git a/app/repository/api_key_repository.py b/app/repository/api_key_repository.py index 231de98..83f3031 100644 --- a/app/repository/api_key_repository.py +++ b/app/repository/api_key_repository.py @@ -38,5 +38,42 @@ def create_api_key(self, new_id: str, service_name: str, encrypted_key: str) -> if conn: conn.close() + def get_all_api_keys(self) -> list[APIKeyInDB]: + """데이터베이스에 저장된 모든 API Key를 조회합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute("SELECT * FROM ai_credential") + rows = cursor.fetchall() + + return [APIKeyInDB.model_validate(dict(row)) for row in rows] + finally: + if conn: + conn.close() + + def get_api_key_by_service_name(self, service_name: str) -> APIKeyInDB | None: + """서비스 이름으로 특정 API Key를 조회합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) + row = cursor.fetchone() + + if not row: + return None + + return APIKeyInDB.model_validate(dict(row)) + finally: + if conn: + conn.close() + api_key_repository = APIKeyRepository() diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py deleted file mode 100644 index d7d2f53..0000000 --- a/app/schemas/api_key.py +++ /dev/null @@ -1,37 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, Field - -from app.core.enum.llm_service import LLMServiceEnum - - -class APIKeyBase(BaseModel): - """모든 API Key 스키마의 기본 모델""" - - service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") - - -class APIKeyStore(APIKeyBase): - """API Key 저장을 위한 스키마""" - - api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") - - -class APIKeyInDB(APIKeyBase): - """데이터베이스에 저장된 형태의 스키마 (내부용)""" - - id: str - api_key: str # DB 모델에서는 암호화된 키를 의미 - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - - -class APIKeyInfo(APIKeyBase): - """API 응답용 스키마 (민감 정보 제외)""" - - id: str - created_at: datetime - updated_at: datetime diff --git a/app/schemas/api_key/response_model.py b/app/schemas/api_key/response_model.py index fa3a090..236c3b1 100644 --- a/app/schemas/api_key/response_model.py +++ b/app/schemas/api_key/response_model.py @@ -1,7 +1,5 @@ from datetime import datetime -from pydantic import Field - from app.schemas.api_key.base_model import APIKeyBase @@ -9,6 +7,5 @@ class APIKeyResponse(APIKeyBase): """API 응답용 스키마""" id: str - api_key_encrypted: str = Field(..., description="암호화된 API Key") created_at: datetime updated_at: datetime diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py deleted file mode 100644 index b793cfe..0000000 --- a/app/services/api_key/service.py +++ /dev/null @@ -1,101 +0,0 @@ -import sqlite3 - -from app.core.exceptions import APIException -from app.core.security import AES256 -from app.core.status import CommonCode -from app.core.utils import generate_prefixed_uuid, get_db_path -from app.schemas.api_key import APIKeyInDB, APIKeyStore - - -def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: - """API Key를 암호화하여 데이터베이스에 저장합니다.""" - encrypted_key = AES256.encrypt(credential_data.api_key) - new_id = generate_prefixed_uuid() - - db_path = get_db_path() - conn = None - try: - # timeout을 10초로 설정하여 BUSY 상태에서 대기하도록 함 - conn = sqlite3.connect(str(db_path), timeout=10) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute( - """ - INSERT INTO ai_credential (id, service_name, api_key) - VALUES (?, ?, ?) - """, - (new_id, credential_data.service_name.value, encrypted_key), - ) - conn.commit() - - cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) - created_row = cursor.fetchone() - - if not created_row: - raise APIException(CommonCode.FAIL, "Failed to retrieve the created credential.") - - return APIKeyInDB.model_validate(dict(created_row)) - - except sqlite3.IntegrityError as e: - # UNIQUE 제약 조건 위반 (service_name) - raise APIException(CommonCode.DUPLICATION) from e - except sqlite3.Error as e: - # "database is locked" 오류를 명시적으로 처리 - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - # 기타 모든 sqlite3 오류 - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() - - -def get_all_api_keys() -> list[APIKeyInDB]: - """데이터베이스에 저장된 모든 API Key를 조회합니다.""" - db_path = get_db_path() - conn = None - try: - conn = sqlite3.connect(str(db_path), timeout=10) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute("SELECT * FROM ai_credential") - rows = cursor.fetchall() - - # 저장된 API Key가 없으면 그냥 빈 리스트를 반환할지? - # 아니면 예외처리를 해줄지? - return [APIKeyInDB.model_validate(dict(row)) for row in rows] - - # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 - except sqlite3.Error as e: - print(e.__class__) - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() - - -def get_api_key_by_service_name(service_name: str) -> APIKeyInDB: - """서비스 이름으로 특정 API Key를 조회합니다.""" - db_path = get_db_path() - conn = None - try: - conn = sqlite3.connect(str(db_path), timeout=10) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) - row = cursor.fetchone() - - if not row: - raise APIException(CommonCode.NO_SEARCH_DATA) - - return APIKeyInDB.model_validate(dict(row)) - - # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 - except sqlite3.Error as e: - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() diff --git a/app/services/api_key/store_api_key_service.py b/app/services/api_key/store_api_key_service.py deleted file mode 100644 index bd8bdc4..0000000 --- a/app/services/api_key/store_api_key_service.py +++ /dev/null @@ -1,52 +0,0 @@ -import sqlite3 - -from app.core.exceptions import APIException -from app.core.security import AES256 -from app.core.status import CommonCode -from app.core.utils import generate_prefixed_uuid, get_db_path -from app.schemas.llm_api_key import ApiKeyCredentialCreate, ApiKeyCredentialInDB - - -def store_api_key(credential_data: ApiKeyCredentialCreate) -> ApiKeyCredentialInDB: - """API_KEY를 암호화하여 데이터베이스에 저장합니다.""" - - encrypted_key = AES256.encrypt(credential_data.api_key) - new_id = generate_prefixed_uuid() - - db_path = get_db_path() - conn = None - try: - # timeout을 10초로 설정하여 BUSY 상태에서 대기하도록 함 - conn = sqlite3.connect(str(db_path), timeout=10) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute( - """ - INSERT INTO ai_credential (id, service_name, api_key) - VALUES (?, ?, ?) - """, - (new_id, credential_data.service_name, encrypted_key), - ) - conn.commit() - - cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) - created_row = cursor.fetchone() - - if not created_row: - raise APIException(CommonCode.FAIL, "Failed to retrieve the created credential.") - - return ApiKeyCredentialInDB.model_validate(dict(created_row)) - - except sqlite3.IntegrityError as e: - # UNIQUE 제약 조건 위반 (service_name) - raise APIException(CommonCode.DUPLICATION) from e - except sqlite3.Error as e: - # "database is locked" 오류를 명시적으로 처리 - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - # 기타 모든 sqlite3 오류 - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index 92c85d5..409a732 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -14,16 +14,17 @@ class APIKeyService: - def store_api_key( - self, credential_data: APIKeyCreate, repository: APIKeyRepository = api_key_repository - ) -> APIKeyInDB: + def __init__(self, repository: APIKeyRepository = api_key_repository): + self.repository = repository + + def store_api_key(self, credential_data: APIKeyCreate) -> APIKeyInDB: """API_KEY를 암호화하고 repository를 통해 데이터베이스에 저장합니다.""" credential_data.validate_with_service() try: encrypted_key = AES256.encrypt(credential_data.api_key) new_id = generate_prefixed_uuid("QGENIE") - created_row = repository.create_api_key( + created_row = self.repository.create_api_key( new_id=new_id, service_name=credential_data.service_name.value, encrypted_key=encrypted_key, @@ -35,13 +36,27 @@ def store_api_key( return created_row except sqlite3.IntegrityError as e: - # UNIQUE 제약 조건 위반 (service_name) raise APIException(CommonCode.DUPLICATION) from e except sqlite3.Error as e: - # "database is locked" 오류를 명시적으로 처리 if "database is locked" in str(e): raise APIException(CommonCode.DB_BUSY) from e - # 기타 모든 sqlite3 오류 + raise APIException(CommonCode.FAIL) from e + + def get_all_api_keys(self) -> list[APIKeyInDB]: + """데이터베이스에 저장된 모든 API Key를 조회합니다.""" + try: + return self.repository.get_all_api_keys() + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + + def get_api_key_by_service_name(self, service_name: str) -> APIKeyInDB: + """서비스 이름으로 특정 API Key를 조회합니다.""" + try: + api_key = self.repository.get_api_key_by_service_name(service_name) + if not api_key: + raise APIException(CommonCode.NO_SEARCH_DATA) + return api_key + except sqlite3.Error as e: raise APIException(CommonCode.FAIL) from e From 492a1fb613ba21a4565a50014ff8eaca9214cc3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 14:19:58 +0900 Subject: [PATCH 088/220] =?UTF-8?q?feat:=20API=20Key=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20&=20=EB=8B=A8=EC=9D=BC=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 85 +++++++++++++++++++++++++++ app/schemas/api_key.py | 37 ++++++++++++ app/services/api_key/service.py | 101 ++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 app/api/api_key/api.py create mode 100644 app/schemas/api_key.py create mode 100644 app/services/api_key/service.py diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py new file mode 100644 index 0000000..9bd69c2 --- /dev/null +++ b/app/api/api_key/api.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter + +from app.core.enum.llm_service import LLMServiceEnum +from app.core.exceptions import APIException +from app.core.response import ResponseMessage +from app.core.status import CommonCode +from app.schemas.api_key import APIKeyInfo, APIKeyStore +from app.services.api_key import service as api_key_service + +router = APIRouter() + + +@router.post( + "/action", + response_model=ResponseMessage[APIKeyInfo], + summary="API KEY 저장 (처음 한 번)", + description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", +) +def store_api_key(credential: APIKeyStore) -> ResponseMessage: + """ + - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") + - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") + """ + + # 우선은 간단하게 존재 여부와 공백 여부로 검증 + # service_name은 enum이라서 pydantic이 자동으로 검증해줌 + # 전역 핸들러가 feature/db-connect 브랜치에 있으니 따로 처리하지 않는걸로 + # TODO: 검증 로직 강화 + if not credential.api_key or credential.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + created_credential = api_key_service.store_api_key(credential) + + response_data = APIKeyInfo( + id=created_credential.id, + service_name=created_credential.service_name, + created_at=created_credential.created_at, + updated_at=created_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) + + +@router.get( + "/result", + response_model=ResponseMessage[list[APIKeyInfo]], + summary="저장된 모든 API KEY 정보 조회", + description=""" + ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. + 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. + """, +) +def get_all_api_keys(): + """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" + db_credentials = api_key_service.get_all_api_keys() + + response_data = [ + APIKeyInfo( + id=cred.id, + service_name=cred.service_name, + created_at=cred.created_at, + updated_at=cred.updated_at, + ) + for cred in db_credentials + ] + return ResponseMessage.success(value=response_data) + + +@router.get( + "/result/{serviceName}", + response_model=ResponseMessage[APIKeyInfo], + summary="특정 서비스의 API KEY 정보 조회", + description="필요 없을 것 같음", +) +def get_api_key_by_service_name(serviceName: LLMServiceEnum): + """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" + db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) + + response_data = APIKeyInfo( + id=db_credential.id, + service_name=db_credential.service_name, + created_at=db_credential.created_at, + updated_at=db_credential.updated_at, + ) + return ResponseMessage.success(value=response_data) diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py new file mode 100644 index 0000000..d7d2f53 --- /dev/null +++ b/app/schemas/api_key.py @@ -0,0 +1,37 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.core.enum.llm_service import LLMServiceEnum + + +class APIKeyBase(BaseModel): + """모든 API Key 스키마의 기본 모델""" + + service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") + + +class APIKeyStore(APIKeyBase): + """API Key 저장을 위한 스키마""" + + api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") + + +class APIKeyInDB(APIKeyBase): + """데이터베이스에 저장된 형태의 스키마 (내부용)""" + + id: str + api_key: str # DB 모델에서는 암호화된 키를 의미 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class APIKeyInfo(APIKeyBase): + """API 응답용 스키마 (민감 정보 제외)""" + + id: str + created_at: datetime + updated_at: datetime diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py new file mode 100644 index 0000000..b793cfe --- /dev/null +++ b/app/services/api_key/service.py @@ -0,0 +1,101 @@ +import sqlite3 + +from app.core.exceptions import APIException +from app.core.security import AES256 +from app.core.status import CommonCode +from app.core.utils import generate_prefixed_uuid, get_db_path +from app.schemas.api_key import APIKeyInDB, APIKeyStore + + +def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: + """API Key를 암호화하여 데이터베이스에 저장합니다.""" + encrypted_key = AES256.encrypt(credential_data.api_key) + new_id = generate_prefixed_uuid() + + db_path = get_db_path() + conn = None + try: + # timeout을 10초로 설정하여 BUSY 상태에서 대기하도록 함 + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute( + """ + INSERT INTO ai_credential (id, service_name, api_key) + VALUES (?, ?, ?) + """, + (new_id, credential_data.service_name.value, encrypted_key), + ) + conn.commit() + + cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) + created_row = cursor.fetchone() + + if not created_row: + raise APIException(CommonCode.FAIL, "Failed to retrieve the created credential.") + + return APIKeyInDB.model_validate(dict(created_row)) + + except sqlite3.IntegrityError as e: + # UNIQUE 제약 조건 위반 (service_name) + raise APIException(CommonCode.DUPLICATION) from e + except sqlite3.Error as e: + # "database is locked" 오류를 명시적으로 처리 + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + # 기타 모든 sqlite3 오류 + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() + + +def get_all_api_keys() -> list[APIKeyInDB]: + """데이터베이스에 저장된 모든 API Key를 조회합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute("SELECT * FROM ai_credential") + rows = cursor.fetchall() + + # 저장된 API Key가 없으면 그냥 빈 리스트를 반환할지? + # 아니면 예외처리를 해줄지? + return [APIKeyInDB.model_validate(dict(row)) for row in rows] + + # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 + except sqlite3.Error as e: + print(e.__class__) + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() + + +def get_api_key_by_service_name(service_name: str) -> APIKeyInDB: + """서비스 이름으로 특정 API Key를 조회합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) + row = cursor.fetchone() + + if not row: + raise APIException(CommonCode.NO_SEARCH_DATA) + + return APIKeyInDB.model_validate(dict(row)) + + # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() From f5b04221e400dfef6992f5274d8925d99226e7eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 15:34:30 +0900 Subject: [PATCH 089/220] =?UTF-8?q?feat:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=84=EC=9A=A9=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/api_key.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py index d7d2f53..5065ea8 100644 --- a/app/schemas/api_key.py +++ b/app/schemas/api_key.py @@ -5,6 +5,12 @@ from app.core.enum.llm_service import LLMServiceEnum +class APIKeyUpdate(BaseModel): + """API Key 수정을 위한 스키마""" + + api_key: str = Field(..., description="새로운 API Key") + + class APIKeyBase(BaseModel): """모든 API Key 스키마의 기본 모델""" From ecc53a6fe94582d79d2833d478f997c0d913b4e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 15:35:10 +0900 Subject: [PATCH 090/220] =?UTF-8?q?feat:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/api_key/service.py | 43 ++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py index b793cfe..d9d8372 100644 --- a/app/services/api_key/service.py +++ b/app/services/api_key/service.py @@ -4,7 +4,7 @@ from app.core.security import AES256 from app.core.status import CommonCode from app.core.utils import generate_prefixed_uuid, get_db_path -from app.schemas.api_key import APIKeyInDB, APIKeyStore +from app.schemas.api_key import APIKeyInDB, APIKeyStore, APIKeyUpdate def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: @@ -99,3 +99,44 @@ def get_api_key_by_service_name(service_name: str) -> APIKeyInDB: finally: if conn: conn.close() + + +def update_api_key(service_name: str, key_data: APIKeyUpdate) -> APIKeyInDB: + """서비스 이름에 해당하는 API Key를 수정합니다.""" + encrypted_key = AES256.encrypt(key_data.api_key) + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # 먼저 해당 서비스의 데이터가 존재하는지 확인 + cursor.execute("SELECT id FROM ai_credential WHERE service_name = ?", (service_name,)) + if not cursor.fetchone(): + raise APIException(CommonCode.NO_SEARCH_DATA) + + # 데이터 업데이트 + cursor.execute( + "UPDATE ai_credential SET api_key = ?, updated_at = datetime('now', 'localtime') WHERE service_name = ?", + (encrypted_key, service_name), + ) + conn.commit() + + # rowcount가 0이면 업데이트된 행이 없음 (정상적인 경우 발생하기 어려움) + if cursor.rowcount == 0: + raise APIException(CommonCode.FAIL) + + # 업데이트된 정보를 다시 조회하여 반환 + cursor.execute("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) + updated_row = cursor.fetchone() + + return APIKeyInDB.model_validate(dict(updated_row)) + + except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() From f78cfc879444cdc03f1fcf09f0d67f2825f9f6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 15:36:02 +0900 Subject: [PATCH 091/220] =?UTF-8?q?feat:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py index 9bd69c2..b4b0f74 100644 --- a/app/api/api_key/api.py +++ b/app/api/api_key/api.py @@ -4,7 +4,7 @@ from app.core.exceptions import APIException from app.core.response import ResponseMessage from app.core.status import CommonCode -from app.schemas.api_key import APIKeyInfo, APIKeyStore +from app.schemas.api_key import APIKeyInfo, APIKeyStore, APIKeyUpdate from app.services.api_key import service as api_key_service router = APIRouter() @@ -83,3 +83,30 @@ def get_api_key_by_service_name(serviceName: LLMServiceEnum): updated_at=db_credential.updated_at, ) return ResponseMessage.success(value=response_data) + + +@router.put( + "/result/{service_name}", + response_model=ResponseMessage[APIKeyInfo], + summary="특정 서비스의 API KEY 수정", +) +def update_api_key(service_name: LLMServiceEnum, key_data: APIKeyUpdate) -> ResponseMessage: + """ + 서비스 이름을 기준으로 특정 API Key를 새로운 값으로 수정합니다. + - **service_name**: 수정할 서비스의 이름 + - **api_key**: 새로운 API Key + """ + # 입력값 검증 + if not key_data.api_key or key_data.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + updated_credential = api_key_service.update_api_key(service_name.value, key_data) + + response_data = APIKeyInfo( + id=updated_credential.id, + service_name=updated_credential.service_name, + created_at=updated_credential.created_at, + updated_at=updated_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data) From 1b0e04a63963a85cbcd3905aa3f8652e5a6f5060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 15:36:57 +0900 Subject: [PATCH 092/220] =?UTF-8?q?refactor:=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=EB=93=A4?= =?UTF-8?q?=EC=97=90=20DB=5FBUSY=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 10 +++++----- app/services/api_key/service.py | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py index b4b0f74..a7e5dbc 100644 --- a/app/api/api_key/api.py +++ b/app/api/api_key/api.py @@ -50,7 +50,7 @@ def store_api_key(credential: APIKeyStore) -> ResponseMessage: 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. """, ) -def get_all_api_keys(): +def get_all_api_keys() -> ResponseMessage: """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" db_credentials = api_key_service.get_all_api_keys() @@ -72,7 +72,7 @@ def get_all_api_keys(): summary="특정 서비스의 API KEY 정보 조회", description="필요 없을 것 같음", ) -def get_api_key_by_service_name(serviceName: LLMServiceEnum): +def get_api_key_by_service_name(serviceName: LLMServiceEnum) -> ResponseMessage: """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) @@ -86,11 +86,11 @@ def get_api_key_by_service_name(serviceName: LLMServiceEnum): @router.put( - "/result/{service_name}", + "/result/{serviceName}", response_model=ResponseMessage[APIKeyInfo], summary="특정 서비스의 API KEY 수정", ) -def update_api_key(service_name: LLMServiceEnum, key_data: APIKeyUpdate) -> ResponseMessage: +def update_api_key(serviceName: LLMServiceEnum, key_data: APIKeyUpdate) -> ResponseMessage: """ 서비스 이름을 기준으로 특정 API Key를 새로운 값으로 수정합니다. - **service_name**: 수정할 서비스의 이름 @@ -100,7 +100,7 @@ def update_api_key(service_name: LLMServiceEnum, key_data: APIKeyUpdate) -> Resp if not key_data.api_key or key_data.api_key.isspace(): raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - updated_credential = api_key_service.update_api_key(service_name.value, key_data) + updated_credential = api_key_service.update_api_key(serviceName.value, key_data) response_data = APIKeyInfo( id=updated_credential.id, diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py index d9d8372..481a47b 100644 --- a/app/services/api_key/service.py +++ b/app/services/api_key/service.py @@ -69,7 +69,8 @@ def get_all_api_keys() -> list[APIKeyInDB]: # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 except sqlite3.Error as e: - print(e.__class__) + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e raise APIException(CommonCode.FAIL) from e finally: if conn: @@ -95,6 +96,8 @@ def get_api_key_by_service_name(service_name: str) -> APIKeyInDB: # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e raise APIException(CommonCode.FAIL) from e finally: if conn: From 89924e2b65659b1760520c4a3ac7e8aa8852e51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 16:20:37 +0900 Subject: [PATCH 093/220] =?UTF-8?q?chore:=20=EA=B2=BD=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py index a7e5dbc..4ac8321 100644 --- a/app/api/api_key/api.py +++ b/app/api/api_key/api.py @@ -86,7 +86,7 @@ def get_api_key_by_service_name(serviceName: LLMServiceEnum) -> ResponseMessage: @router.put( - "/result/{serviceName}", + "/modify/{serviceName}", response_model=ResponseMessage[APIKeyInfo], summary="특정 서비스의 API KEY 수정", ) From 98a48225d08b76bdf5fd634f52b182c431f44902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 12:31:30 +0900 Subject: [PATCH 094/220] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20&=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 112 --------------------- app/api/api_key_api.py | 28 ++++++ app/repository/api_key_repository.py | 33 ++++++ app/schemas/api_key.py | 43 -------- app/schemas/api_key/update_model.py | 14 +++ app/services/api_key/service.py | 145 --------------------------- app/services/api_key_service.py | 16 +++ 7 files changed, 91 insertions(+), 300 deletions(-) delete mode 100644 app/api/api_key/api.py delete mode 100644 app/schemas/api_key.py create mode 100644 app/schemas/api_key/update_model.py delete mode 100644 app/services/api_key/service.py diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py deleted file mode 100644 index 4ac8321..0000000 --- a/app/api/api_key/api.py +++ /dev/null @@ -1,112 +0,0 @@ -from fastapi import APIRouter - -from app.core.enum.llm_service import LLMServiceEnum -from app.core.exceptions import APIException -from app.core.response import ResponseMessage -from app.core.status import CommonCode -from app.schemas.api_key import APIKeyInfo, APIKeyStore, APIKeyUpdate -from app.services.api_key import service as api_key_service - -router = APIRouter() - - -@router.post( - "/action", - response_model=ResponseMessage[APIKeyInfo], - summary="API KEY 저장 (처음 한 번)", - description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", -) -def store_api_key(credential: APIKeyStore) -> ResponseMessage: - """ - - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") - """ - - # 우선은 간단하게 존재 여부와 공백 여부로 검증 - # service_name은 enum이라서 pydantic이 자동으로 검증해줌 - # 전역 핸들러가 feature/db-connect 브랜치에 있으니 따로 처리하지 않는걸로 - # TODO: 검증 로직 강화 - if not credential.api_key or credential.api_key.isspace(): - raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - - created_credential = api_key_service.store_api_key(credential) - - response_data = APIKeyInfo( - id=created_credential.id, - service_name=created_credential.service_name, - created_at=created_credential.created_at, - updated_at=created_credential.updated_at, - ) - - return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) - - -@router.get( - "/result", - response_model=ResponseMessage[list[APIKeyInfo]], - summary="저장된 모든 API KEY 정보 조회", - description=""" - ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. - 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. - """, -) -def get_all_api_keys() -> ResponseMessage: - """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" - db_credentials = api_key_service.get_all_api_keys() - - response_data = [ - APIKeyInfo( - id=cred.id, - service_name=cred.service_name, - created_at=cred.created_at, - updated_at=cred.updated_at, - ) - for cred in db_credentials - ] - return ResponseMessage.success(value=response_data) - - -@router.get( - "/result/{serviceName}", - response_model=ResponseMessage[APIKeyInfo], - summary="특정 서비스의 API KEY 정보 조회", - description="필요 없을 것 같음", -) -def get_api_key_by_service_name(serviceName: LLMServiceEnum) -> ResponseMessage: - """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" - db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) - - response_data = APIKeyInfo( - id=db_credential.id, - service_name=db_credential.service_name, - created_at=db_credential.created_at, - updated_at=db_credential.updated_at, - ) - return ResponseMessage.success(value=response_data) - - -@router.put( - "/modify/{serviceName}", - response_model=ResponseMessage[APIKeyInfo], - summary="특정 서비스의 API KEY 수정", -) -def update_api_key(serviceName: LLMServiceEnum, key_data: APIKeyUpdate) -> ResponseMessage: - """ - 서비스 이름을 기준으로 특정 API Key를 새로운 값으로 수정합니다. - - **service_name**: 수정할 서비스의 이름 - - **api_key**: 새로운 API Key - """ - # 입력값 검증 - if not key_data.api_key or key_data.api_key.isspace(): - raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - - updated_credential = api_key_service.update_api_key(serviceName.value, key_data) - - response_data = APIKeyInfo( - id=updated_credential.id, - service_name=updated_credential.service_name, - created_at=updated_credential.created_at, - updated_at=updated_credential.updated_at, - ) - - return ResponseMessage.success(value=response_data) diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index e17e1df..22b80cb 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -5,6 +5,7 @@ from app.core.status import CommonCode from app.schemas.api_key.create_model import APIKeyCreate from app.schemas.api_key.response_model import APIKeyResponse +from app.schemas.api_key.update_model import APIKeyUpdate from app.services.api_key_service import APIKeyService, api_key_service api_key_service_dependency = Depends(lambda: api_key_service) @@ -83,3 +84,30 @@ def get_api_key_by_service_name( updated_at=db_credential.updated_at, ) return ResponseMessage.success(value=response_data) + + +@router.put( + "/modify/{serviceName}", + response_model=ResponseMessage[APIKeyResponse], + summary="특정 서비스의 API KEY 수정", +) +def update_api_key( + serviceName: LLMServiceEnum, + key_data: APIKeyUpdate, + service: APIKeyService = api_key_service_dependency, +) -> ResponseMessage[APIKeyResponse]: + """ + 서비스 이름을 기준으로 특정 API Key를 새로운 값으로 수정합니다. + - **service_name**: 수정할 서비스의 이름 + - **api_key**: 새로운 API Key + """ + updated_credential = service.update_api_key(serviceName.value, key_data) + + response_data = APIKeyResponse( + id=updated_credential.id, + service_name=updated_credential.service_name, + created_at=updated_credential.created_at, + updated_at=updated_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data) diff --git a/app/repository/api_key_repository.py b/app/repository/api_key_repository.py index 83f3031..caaae96 100644 --- a/app/repository/api_key_repository.py +++ b/app/repository/api_key_repository.py @@ -75,5 +75,38 @@ def get_api_key_by_service_name(self, service_name: str) -> APIKeyInDB | None: if conn: conn.close() + def update_api_key(self, service_name: str, encrypted_key: str) -> APIKeyInDB | None: + """서비스 이름에 해당하는 API Key를 수정하고, 수정된 객체를 반환합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # 먼저 해당 서비스의 데이터가 존재하는지 확인 + cursor.execute("SELECT id FROM ai_credential WHERE service_name = ?", (service_name,)) + if not cursor.fetchone(): + return None + + # 데이터 업데이트 + cursor.execute( + "UPDATE ai_credential SET api_key = ?, updated_at = datetime('now', 'localtime') WHERE service_name = ?", + (encrypted_key, service_name), + ) + conn.commit() + + # rowcount가 0이면 업데이트된 행이 없음 (정상적인 경우 발생하기 어려움) + if cursor.rowcount == 0: + return None + + cursor.execute("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) + updated_row = cursor.fetchone() + + return APIKeyInDB.model_validate(dict(updated_row)) + finally: + if conn: + conn.close() + api_key_repository = APIKeyRepository() diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py deleted file mode 100644 index 5065ea8..0000000 --- a/app/schemas/api_key.py +++ /dev/null @@ -1,43 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, Field - -from app.core.enum.llm_service import LLMServiceEnum - - -class APIKeyUpdate(BaseModel): - """API Key 수정을 위한 스키마""" - - api_key: str = Field(..., description="새로운 API Key") - - -class APIKeyBase(BaseModel): - """모든 API Key 스키마의 기본 모델""" - - service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") - - -class APIKeyStore(APIKeyBase): - """API Key 저장을 위한 스키마""" - - api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") - - -class APIKeyInDB(APIKeyBase): - """데이터베이스에 저장된 형태의 스키마 (내부용)""" - - id: str - api_key: str # DB 모델에서는 암호화된 키를 의미 - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - - -class APIKeyInfo(APIKeyBase): - """API 응답용 스키마 (민감 정보 제외)""" - - id: str - created_at: datetime - updated_at: datetime diff --git a/app/schemas/api_key/update_model.py b/app/schemas/api_key/update_model.py new file mode 100644 index 0000000..5135cd7 --- /dev/null +++ b/app/schemas/api_key/update_model.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel, Field, field_validator + + +class APIKeyUpdate(BaseModel): + """API Key 수정을 위한 스키마""" + + api_key: str = Field(..., description="새로운 API Key") + + @field_validator("api_key", mode="after") + @classmethod + def validate_api_key(cls, v: str) -> str: + if not v or v.isspace(): + raise ValueError("API key cannot be empty or just whitespace.") + return v diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py deleted file mode 100644 index 481a47b..0000000 --- a/app/services/api_key/service.py +++ /dev/null @@ -1,145 +0,0 @@ -import sqlite3 - -from app.core.exceptions import APIException -from app.core.security import AES256 -from app.core.status import CommonCode -from app.core.utils import generate_prefixed_uuid, get_db_path -from app.schemas.api_key import APIKeyInDB, APIKeyStore, APIKeyUpdate - - -def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: - """API Key를 암호화하여 데이터베이스에 저장합니다.""" - encrypted_key = AES256.encrypt(credential_data.api_key) - new_id = generate_prefixed_uuid() - - db_path = get_db_path() - conn = None - try: - # timeout을 10초로 설정하여 BUSY 상태에서 대기하도록 함 - conn = sqlite3.connect(str(db_path), timeout=10) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute( - """ - INSERT INTO ai_credential (id, service_name, api_key) - VALUES (?, ?, ?) - """, - (new_id, credential_data.service_name.value, encrypted_key), - ) - conn.commit() - - cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) - created_row = cursor.fetchone() - - if not created_row: - raise APIException(CommonCode.FAIL, "Failed to retrieve the created credential.") - - return APIKeyInDB.model_validate(dict(created_row)) - - except sqlite3.IntegrityError as e: - # UNIQUE 제약 조건 위반 (service_name) - raise APIException(CommonCode.DUPLICATION) from e - except sqlite3.Error as e: - # "database is locked" 오류를 명시적으로 처리 - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - # 기타 모든 sqlite3 오류 - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() - - -def get_all_api_keys() -> list[APIKeyInDB]: - """데이터베이스에 저장된 모든 API Key를 조회합니다.""" - db_path = get_db_path() - conn = None - try: - conn = sqlite3.connect(str(db_path), timeout=10) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute("SELECT * FROM ai_credential") - rows = cursor.fetchall() - - # 저장된 API Key가 없으면 그냥 빈 리스트를 반환할지? - # 아니면 예외처리를 해줄지? - return [APIKeyInDB.model_validate(dict(row)) for row in rows] - - # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 - except sqlite3.Error as e: - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() - - -def get_api_key_by_service_name(service_name: str) -> APIKeyInDB: - """서비스 이름으로 특정 API Key를 조회합니다.""" - db_path = get_db_path() - conn = None - try: - conn = sqlite3.connect(str(db_path), timeout=10) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) - row = cursor.fetchone() - - if not row: - raise APIException(CommonCode.NO_SEARCH_DATA) - - return APIKeyInDB.model_validate(dict(row)) - - # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 - except sqlite3.Error as e: - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() - - -def update_api_key(service_name: str, key_data: APIKeyUpdate) -> APIKeyInDB: - """서비스 이름에 해당하는 API Key를 수정합니다.""" - encrypted_key = AES256.encrypt(key_data.api_key) - db_path = get_db_path() - conn = None - try: - conn = sqlite3.connect(str(db_path), timeout=10) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - # 먼저 해당 서비스의 데이터가 존재하는지 확인 - cursor.execute("SELECT id FROM ai_credential WHERE service_name = ?", (service_name,)) - if not cursor.fetchone(): - raise APIException(CommonCode.NO_SEARCH_DATA) - - # 데이터 업데이트 - cursor.execute( - "UPDATE ai_credential SET api_key = ?, updated_at = datetime('now', 'localtime') WHERE service_name = ?", - (encrypted_key, service_name), - ) - conn.commit() - - # rowcount가 0이면 업데이트된 행이 없음 (정상적인 경우 발생하기 어려움) - if cursor.rowcount == 0: - raise APIException(CommonCode.FAIL) - - # 업데이트된 정보를 다시 조회하여 반환 - cursor.execute("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) - updated_row = cursor.fetchone() - - return APIKeyInDB.model_validate(dict(updated_row)) - - except sqlite3.Error as e: - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index 409a732..5c3f89d 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -9,6 +9,7 @@ from app.repository.api_key_repository import APIKeyRepository, api_key_repository from app.schemas.api_key.create_model import APIKeyCreate from app.schemas.api_key.db_model import APIKeyInDB +from app.schemas.api_key.update_model import APIKeyUpdate api_key_repository_dependency = Depends(lambda: api_key_repository) @@ -59,5 +60,20 @@ def get_api_key_by_service_name(self, service_name: str) -> APIKeyInDB: except sqlite3.Error as e: raise APIException(CommonCode.FAIL) from e + def update_api_key(self, service_name: str, key_data: APIKeyUpdate) -> APIKeyInDB: + """서비스 이름에 해당하는 API Key를 수정합니다.""" + try: + encrypted_key = AES256.encrypt(key_data.api_key) + updated_api_key = self.repository.update_api_key(service_name, encrypted_key) + + if not updated_api_key: + raise APIException(CommonCode.NO_SEARCH_DATA) + + return updated_api_key + except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + raise APIException(CommonCode.FAIL) from e + api_key_service = APIKeyService() From a54269226cf58096cd20a4cfffb57f1347a9b473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 13:48:50 +0900 Subject: [PATCH 095/220] =?UTF-8?q?refactor:=20update=5Fmodel=EC=97=90?= =?UTF-8?q?=EB=8F=84=20method=20=EB=B0=A9=EC=8B=9D=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/api_key/update_model.py | 16 +++++++++------- app/services/api_key_service.py | 1 + 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/schemas/api_key/update_model.py b/app/schemas/api_key/update_model.py index 5135cd7..89f2b16 100644 --- a/app/schemas/api_key/update_model.py +++ b/app/schemas/api_key/update_model.py @@ -1,4 +1,7 @@ -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field + +from app.core.exceptions import APIException +from app.core.status import CommonCode class APIKeyUpdate(BaseModel): @@ -6,9 +9,8 @@ class APIKeyUpdate(BaseModel): api_key: str = Field(..., description="새로운 API Key") - @field_validator("api_key", mode="after") - @classmethod - def validate_api_key(cls, v: str) -> str: - if not v or v.isspace(): - raise ValueError("API key cannot be empty or just whitespace.") - return v + def validate_with_api_key(self) -> None: + """API Key의 유효성을 검증합니다.""" + # 기본 형식 검증 (공백 또는 빈 문자열) + if not self.api_key or self.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index 5c3f89d..f227562 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -62,6 +62,7 @@ def get_api_key_by_service_name(self, service_name: str) -> APIKeyInDB: def update_api_key(self, service_name: str, key_data: APIKeyUpdate) -> APIKeyInDB: """서비스 이름에 해당하는 API Key를 수정합니다.""" + key_data.validate_with_api_key() try: encrypted_key = AES256.encrypt(key_data.api_key) updated_api_key = self.repository.update_api_key(service_name, encrypted_key) From 06e106dafc6d4018c0d594bef8494569a20de6bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 14:19:58 +0900 Subject: [PATCH 096/220] =?UTF-8?q?feat:=20API=20Key=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20&=20=EB=8B=A8=EC=9D=BC=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 85 +++++++++++++++++++++++++++ app/schemas/api_key.py | 37 ++++++++++++ app/services/api_key/service.py | 101 ++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 app/api/api_key/api.py create mode 100644 app/schemas/api_key.py create mode 100644 app/services/api_key/service.py diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py new file mode 100644 index 0000000..9bd69c2 --- /dev/null +++ b/app/api/api_key/api.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter + +from app.core.enum.llm_service import LLMServiceEnum +from app.core.exceptions import APIException +from app.core.response import ResponseMessage +from app.core.status import CommonCode +from app.schemas.api_key import APIKeyInfo, APIKeyStore +from app.services.api_key import service as api_key_service + +router = APIRouter() + + +@router.post( + "/action", + response_model=ResponseMessage[APIKeyInfo], + summary="API KEY 저장 (처음 한 번)", + description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", +) +def store_api_key(credential: APIKeyStore) -> ResponseMessage: + """ + - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") + - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") + """ + + # 우선은 간단하게 존재 여부와 공백 여부로 검증 + # service_name은 enum이라서 pydantic이 자동으로 검증해줌 + # 전역 핸들러가 feature/db-connect 브랜치에 있으니 따로 처리하지 않는걸로 + # TODO: 검증 로직 강화 + if not credential.api_key or credential.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + created_credential = api_key_service.store_api_key(credential) + + response_data = APIKeyInfo( + id=created_credential.id, + service_name=created_credential.service_name, + created_at=created_credential.created_at, + updated_at=created_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) + + +@router.get( + "/result", + response_model=ResponseMessage[list[APIKeyInfo]], + summary="저장된 모든 API KEY 정보 조회", + description=""" + ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. + 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. + """, +) +def get_all_api_keys(): + """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" + db_credentials = api_key_service.get_all_api_keys() + + response_data = [ + APIKeyInfo( + id=cred.id, + service_name=cred.service_name, + created_at=cred.created_at, + updated_at=cred.updated_at, + ) + for cred in db_credentials + ] + return ResponseMessage.success(value=response_data) + + +@router.get( + "/result/{serviceName}", + response_model=ResponseMessage[APIKeyInfo], + summary="특정 서비스의 API KEY 정보 조회", + description="필요 없을 것 같음", +) +def get_api_key_by_service_name(serviceName: LLMServiceEnum): + """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" + db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) + + response_data = APIKeyInfo( + id=db_credential.id, + service_name=db_credential.service_name, + created_at=db_credential.created_at, + updated_at=db_credential.updated_at, + ) + return ResponseMessage.success(value=response_data) diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py new file mode 100644 index 0000000..d7d2f53 --- /dev/null +++ b/app/schemas/api_key.py @@ -0,0 +1,37 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.core.enum.llm_service import LLMServiceEnum + + +class APIKeyBase(BaseModel): + """모든 API Key 스키마의 기본 모델""" + + service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") + + +class APIKeyStore(APIKeyBase): + """API Key 저장을 위한 스키마""" + + api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") + + +class APIKeyInDB(APIKeyBase): + """데이터베이스에 저장된 형태의 스키마 (내부용)""" + + id: str + api_key: str # DB 모델에서는 암호화된 키를 의미 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class APIKeyInfo(APIKeyBase): + """API 응답용 스키마 (민감 정보 제외)""" + + id: str + created_at: datetime + updated_at: datetime diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py new file mode 100644 index 0000000..b793cfe --- /dev/null +++ b/app/services/api_key/service.py @@ -0,0 +1,101 @@ +import sqlite3 + +from app.core.exceptions import APIException +from app.core.security import AES256 +from app.core.status import CommonCode +from app.core.utils import generate_prefixed_uuid, get_db_path +from app.schemas.api_key import APIKeyInDB, APIKeyStore + + +def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: + """API Key를 암호화하여 데이터베이스에 저장합니다.""" + encrypted_key = AES256.encrypt(credential_data.api_key) + new_id = generate_prefixed_uuid() + + db_path = get_db_path() + conn = None + try: + # timeout을 10초로 설정하여 BUSY 상태에서 대기하도록 함 + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute( + """ + INSERT INTO ai_credential (id, service_name, api_key) + VALUES (?, ?, ?) + """, + (new_id, credential_data.service_name.value, encrypted_key), + ) + conn.commit() + + cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) + created_row = cursor.fetchone() + + if not created_row: + raise APIException(CommonCode.FAIL, "Failed to retrieve the created credential.") + + return APIKeyInDB.model_validate(dict(created_row)) + + except sqlite3.IntegrityError as e: + # UNIQUE 제약 조건 위반 (service_name) + raise APIException(CommonCode.DUPLICATION) from e + except sqlite3.Error as e: + # "database is locked" 오류를 명시적으로 처리 + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + # 기타 모든 sqlite3 오류 + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() + + +def get_all_api_keys() -> list[APIKeyInDB]: + """데이터베이스에 저장된 모든 API Key를 조회합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute("SELECT * FROM ai_credential") + rows = cursor.fetchall() + + # 저장된 API Key가 없으면 그냥 빈 리스트를 반환할지? + # 아니면 예외처리를 해줄지? + return [APIKeyInDB.model_validate(dict(row)) for row in rows] + + # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 + except sqlite3.Error as e: + print(e.__class__) + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() + + +def get_api_key_by_service_name(service_name: str) -> APIKeyInDB: + """서비스 이름으로 특정 API Key를 조회합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) + row = cursor.fetchone() + + if not row: + raise APIException(CommonCode.NO_SEARCH_DATA) + + return APIKeyInDB.model_validate(dict(row)) + + # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() From d0082fc87631387d8a594a993fc4dbd0dec0d3d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 12:31:30 +0900 Subject: [PATCH 097/220] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20&=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/api_key.py | 37 ------------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 app/schemas/api_key.py diff --git a/app/schemas/api_key.py b/app/schemas/api_key.py deleted file mode 100644 index d7d2f53..0000000 --- a/app/schemas/api_key.py +++ /dev/null @@ -1,37 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, Field - -from app.core.enum.llm_service import LLMServiceEnum - - -class APIKeyBase(BaseModel): - """모든 API Key 스키마의 기본 모델""" - - service_name: LLMServiceEnum = Field(..., description="외부 서비스 이름") - - -class APIKeyStore(APIKeyBase): - """API Key 저장을 위한 스키마""" - - api_key: str = Field(..., description="암호화하여 저장할 실제 API Key") - - -class APIKeyInDB(APIKeyBase): - """데이터베이스에 저장된 형태의 스키마 (내부용)""" - - id: str - api_key: str # DB 모델에서는 암호화된 키를 의미 - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True - - -class APIKeyInfo(APIKeyBase): - """API 응답용 스키마 (민감 정보 제외)""" - - id: str - created_at: datetime - updated_at: datetime From ffdc443e7530d0355e820311a76c3fbd6f099d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 16:25:50 +0900 Subject: [PATCH 098/220] =?UTF-8?q?feat:=20=EC=82=AD=EC=A0=9C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/api_key/service.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py index b793cfe..37ae454 100644 --- a/app/services/api_key/service.py +++ b/app/services/api_key/service.py @@ -99,3 +99,33 @@ def get_api_key_by_service_name(service_name: str) -> APIKeyInDB: finally: if conn: conn.close() + + +def delete_api_key(service_name: str) -> None: + """서비스 이름에 해당하는 API Key를 삭제합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + cursor = conn.cursor() + + # 먼저 해당 서비스의 데이터가 존재하는지 확인 + cursor.execute("SELECT id FROM ai_credential WHERE service_name = ?", (service_name,)) + if not cursor.fetchone(): + raise APIException(CommonCode.NO_SEARCH_DATA) + + # 데이터 삭제 + cursor.execute("DELETE FROM ai_credential WHERE service_name = ?", (service_name,)) + conn.commit() + + # rowcount가 0이면 삭제된 행이 없음 (정상적인 경우 발생하기 어려움) + if cursor.rowcount == 0: + raise APIException(CommonCode.NO_SEARCH_DATA) + + except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + raise APIException(CommonCode.FAIL) from e + finally: + if conn: + conn.close() From b30616631def6e1721f50da69ab360082d414813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 5 Aug 2025 16:26:18 +0900 Subject: [PATCH 099/220] =?UTF-8?q?feat:=20API=20Key=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=ED=94=84=EB=A0=88=EC=A0=A0=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py index 9bd69c2..eabf630 100644 --- a/app/api/api_key/api.py +++ b/app/api/api_key/api.py @@ -83,3 +83,44 @@ def get_api_key_by_service_name(serviceName: LLMServiceEnum): updated_at=db_credential.updated_at, ) return ResponseMessage.success(value=response_data) + + +@router.put( + "/modify/{serviceName}", + response_model=ResponseMessage[APIKeyInfo], + summary="특정 서비스의 API KEY 수정", +) +def update_api_key(serviceName: LLMServiceEnum, key_data: APIKeyUpdate) -> ResponseMessage: + """ + 서비스 이름을 기준으로 특정 API Key를 새로운 값으로 수정합니다. + - **service_name**: 수정할 서비스의 이름 + - **api_key**: 새로운 API Key + """ + # 입력값 검증 + if not key_data.api_key or key_data.api_key.isspace(): + raise APIException(CommonCode.INVALID_API_KEY_FORMAT) + + updated_credential = api_key_service.update_api_key(serviceName.value, key_data) + + response_data = APIKeyInfo( + id=updated_credential.id, + service_name=updated_credential.service_name, + created_at=updated_credential.created_at, + updated_at=updated_credential.updated_at, + ) + + return ResponseMessage.success(value=response_data) + + +@router.delete( + "/remove/{serviceName}", + response_model=ResponseMessage, + summary="특정 서비스의 API KEY 삭제", +) +def delete_api_key(serviceName: LLMServiceEnum) -> ResponseMessage: + """ + 서비스 이름을 기준으로 특정 API Key를 삭제합니다. + - **serviceName**: 삭제할 서비스의 이름 + """ + api_key_service.delete_api_key(serviceName.value) + return ResponseMessage.success() From cd9c4e5bc269a164824f5db18bd69d2f5218a553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 12:53:18 +0900 Subject: [PATCH 100/220] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C=20&=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key/api.py | 126 -------------------------- app/api/api_key_api.py | 14 +++ app/repository/api_key_repository.py | 26 ++++++ app/services/api_key/service.py | 131 --------------------------- app/services/api_key_service.py | 11 +++ 5 files changed, 51 insertions(+), 257 deletions(-) delete mode 100644 app/api/api_key/api.py delete mode 100644 app/services/api_key/service.py diff --git a/app/api/api_key/api.py b/app/api/api_key/api.py deleted file mode 100644 index eabf630..0000000 --- a/app/api/api_key/api.py +++ /dev/null @@ -1,126 +0,0 @@ -from fastapi import APIRouter - -from app.core.enum.llm_service import LLMServiceEnum -from app.core.exceptions import APIException -from app.core.response import ResponseMessage -from app.core.status import CommonCode -from app.schemas.api_key import APIKeyInfo, APIKeyStore -from app.services.api_key import service as api_key_service - -router = APIRouter() - - -@router.post( - "/action", - response_model=ResponseMessage[APIKeyInfo], - summary="API KEY 저장 (처음 한 번)", - description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", -) -def store_api_key(credential: APIKeyStore) -> ResponseMessage: - """ - - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") - """ - - # 우선은 간단하게 존재 여부와 공백 여부로 검증 - # service_name은 enum이라서 pydantic이 자동으로 검증해줌 - # 전역 핸들러가 feature/db-connect 브랜치에 있으니 따로 처리하지 않는걸로 - # TODO: 검증 로직 강화 - if not credential.api_key or credential.api_key.isspace(): - raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - - created_credential = api_key_service.store_api_key(credential) - - response_data = APIKeyInfo( - id=created_credential.id, - service_name=created_credential.service_name, - created_at=created_credential.created_at, - updated_at=created_credential.updated_at, - ) - - return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) - - -@router.get( - "/result", - response_model=ResponseMessage[list[APIKeyInfo]], - summary="저장된 모든 API KEY 정보 조회", - description=""" - ai_credential 테이블에 저장된 모든 서비스 이름을 확인합니다. - 이를 통해 프론트엔드에서는 비워둘 필드, 임의의 마스킹된 값을 채워둘 필드를 구분합니다. - """, -) -def get_all_api_keys(): - """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" - db_credentials = api_key_service.get_all_api_keys() - - response_data = [ - APIKeyInfo( - id=cred.id, - service_name=cred.service_name, - created_at=cred.created_at, - updated_at=cred.updated_at, - ) - for cred in db_credentials - ] - return ResponseMessage.success(value=response_data) - - -@router.get( - "/result/{serviceName}", - response_model=ResponseMessage[APIKeyInfo], - summary="특정 서비스의 API KEY 정보 조회", - description="필요 없을 것 같음", -) -def get_api_key_by_service_name(serviceName: LLMServiceEnum): - """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" - db_credential = api_key_service.get_api_key_by_service_name(serviceName.value) - - response_data = APIKeyInfo( - id=db_credential.id, - service_name=db_credential.service_name, - created_at=db_credential.created_at, - updated_at=db_credential.updated_at, - ) - return ResponseMessage.success(value=response_data) - - -@router.put( - "/modify/{serviceName}", - response_model=ResponseMessage[APIKeyInfo], - summary="특정 서비스의 API KEY 수정", -) -def update_api_key(serviceName: LLMServiceEnum, key_data: APIKeyUpdate) -> ResponseMessage: - """ - 서비스 이름을 기준으로 특정 API Key를 새로운 값으로 수정합니다. - - **service_name**: 수정할 서비스의 이름 - - **api_key**: 새로운 API Key - """ - # 입력값 검증 - if not key_data.api_key or key_data.api_key.isspace(): - raise APIException(CommonCode.INVALID_API_KEY_FORMAT) - - updated_credential = api_key_service.update_api_key(serviceName.value, key_data) - - response_data = APIKeyInfo( - id=updated_credential.id, - service_name=updated_credential.service_name, - created_at=updated_credential.created_at, - updated_at=updated_credential.updated_at, - ) - - return ResponseMessage.success(value=response_data) - - -@router.delete( - "/remove/{serviceName}", - response_model=ResponseMessage, - summary="특정 서비스의 API KEY 삭제", -) -def delete_api_key(serviceName: LLMServiceEnum) -> ResponseMessage: - """ - 서비스 이름을 기준으로 특정 API Key를 삭제합니다. - - **serviceName**: 삭제할 서비스의 이름 - """ - api_key_service.delete_api_key(serviceName.value) - return ResponseMessage.success() diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index 22b80cb..b760cfd 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -111,3 +111,17 @@ def update_api_key( ) return ResponseMessage.success(value=response_data) + + +@router.delete( + "/remove/{serviceName}", + response_model=ResponseMessage, + summary="특정 서비스의 API KEY 삭제", +) +def delete_api_key(serviceName: LLMServiceEnum, service: APIKeyService = api_key_service_dependency) -> ResponseMessage: + """ + 서비스 이름을 기준으로 특정 API Key를 삭제합니다. + - **service_name**: 삭제할 서비스의 이름 + """ + service.delete_api_key(serviceName.value) + return ResponseMessage.success() diff --git a/app/repository/api_key_repository.py b/app/repository/api_key_repository.py index caaae96..83d3854 100644 --- a/app/repository/api_key_repository.py +++ b/app/repository/api_key_repository.py @@ -108,5 +108,31 @@ def update_api_key(self, service_name: str, encrypted_key: str) -> APIKeyInDB | if conn: conn.close() + def delete_api_key(self, service_name: str) -> bool: + """서비스 이름에 해당하는 API Key를 삭제하고, 성공 여부를 반환합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + cursor = conn.cursor() + + # 먼저 해당 서비스의 데이터가 존재하는지 확인 + cursor.execute("SELECT id FROM ai_credential WHERE service_name = ?", (service_name,)) + if not cursor.fetchone(): + return False + + # 데이터 삭제 + cursor.execute("DELETE FROM ai_credential WHERE service_name = ?", (service_name,)) + conn.commit() + + # rowcount가 0이면 삭제된 행이 없음 (정상적인 경우 발생하기 어려움) + if cursor.rowcount == 0: + return False + + return cursor.rowcount > 0 + finally: + if conn: + conn.close() + api_key_repository = APIKeyRepository() diff --git a/app/services/api_key/service.py b/app/services/api_key/service.py deleted file mode 100644 index 37ae454..0000000 --- a/app/services/api_key/service.py +++ /dev/null @@ -1,131 +0,0 @@ -import sqlite3 - -from app.core.exceptions import APIException -from app.core.security import AES256 -from app.core.status import CommonCode -from app.core.utils import generate_prefixed_uuid, get_db_path -from app.schemas.api_key import APIKeyInDB, APIKeyStore - - -def store_api_key(credential_data: APIKeyStore) -> APIKeyInDB: - """API Key를 암호화하여 데이터베이스에 저장합니다.""" - encrypted_key = AES256.encrypt(credential_data.api_key) - new_id = generate_prefixed_uuid() - - db_path = get_db_path() - conn = None - try: - # timeout을 10초로 설정하여 BUSY 상태에서 대기하도록 함 - conn = sqlite3.connect(str(db_path), timeout=10) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute( - """ - INSERT INTO ai_credential (id, service_name, api_key) - VALUES (?, ?, ?) - """, - (new_id, credential_data.service_name.value, encrypted_key), - ) - conn.commit() - - cursor.execute("SELECT * FROM ai_credential WHERE id = ?", (new_id,)) - created_row = cursor.fetchone() - - if not created_row: - raise APIException(CommonCode.FAIL, "Failed to retrieve the created credential.") - - return APIKeyInDB.model_validate(dict(created_row)) - - except sqlite3.IntegrityError as e: - # UNIQUE 제약 조건 위반 (service_name) - raise APIException(CommonCode.DUPLICATION) from e - except sqlite3.Error as e: - # "database is locked" 오류를 명시적으로 처리 - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - # 기타 모든 sqlite3 오류 - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() - - -def get_all_api_keys() -> list[APIKeyInDB]: - """데이터베이스에 저장된 모든 API Key를 조회합니다.""" - db_path = get_db_path() - conn = None - try: - conn = sqlite3.connect(str(db_path), timeout=10) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute("SELECT * FROM ai_credential") - rows = cursor.fetchall() - - # 저장된 API Key가 없으면 그냥 빈 리스트를 반환할지? - # 아니면 예외처리를 해줄지? - return [APIKeyInDB.model_validate(dict(row)) for row in rows] - - # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 - except sqlite3.Error as e: - print(e.__class__) - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() - - -def get_api_key_by_service_name(service_name: str) -> APIKeyInDB: - """서비스 이름으로 특정 API Key를 조회합니다.""" - db_path = get_db_path() - conn = None - try: - conn = sqlite3.connect(str(db_path), timeout=10) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute("SELECT * FROM ai_credential WHERE service_name = ?", (service_name,)) - row = cursor.fetchone() - - if not row: - raise APIException(CommonCode.NO_SEARCH_DATA) - - return APIKeyInDB.model_validate(dict(row)) - - # TODO: 발생가능한 에러들 전부 테스트 해보며 예외처리 세분화 - except sqlite3.Error as e: - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() - - -def delete_api_key(service_name: str) -> None: - """서비스 이름에 해당하는 API Key를 삭제합니다.""" - db_path = get_db_path() - conn = None - try: - conn = sqlite3.connect(str(db_path), timeout=10) - cursor = conn.cursor() - - # 먼저 해당 서비스의 데이터가 존재하는지 확인 - cursor.execute("SELECT id FROM ai_credential WHERE service_name = ?", (service_name,)) - if not cursor.fetchone(): - raise APIException(CommonCode.NO_SEARCH_DATA) - - # 데이터 삭제 - cursor.execute("DELETE FROM ai_credential WHERE service_name = ?", (service_name,)) - conn.commit() - - # rowcount가 0이면 삭제된 행이 없음 (정상적인 경우 발생하기 어려움) - if cursor.rowcount == 0: - raise APIException(CommonCode.NO_SEARCH_DATA) - - except sqlite3.Error as e: - if "database is locked" in str(e): - raise APIException(CommonCode.DB_BUSY) from e - raise APIException(CommonCode.FAIL) from e - finally: - if conn: - conn.close() diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index f227562..7af4381 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -76,5 +76,16 @@ def update_api_key(self, service_name: str, key_data: APIKeyUpdate) -> APIKeyInD raise APIException(CommonCode.DB_BUSY) from e raise APIException(CommonCode.FAIL) from e + def delete_api_key(self, service_name: str) -> None: + """서비스 이름에 해당하는 API Key를 삭제합니다.""" + try: + is_deleted = self.repository.delete_api_key(service_name) + if not is_deleted: + raise APIException(CommonCode.NO_SEARCH_DATA) + except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + raise APIException(CommonCode.FAIL) from e + api_key_service = APIKeyService() From 94335b4dc7cdea78823798ece0a9dcc814744901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 7 Aug 2025 14:15:46 +0900 Subject: [PATCH 101/220] =?UTF-8?q?refactor:=20=EB=AA=A8=ED=98=B8=ED=95=98?= =?UTF-8?q?=EB=8D=98=20=EB=B3=80=EC=88=98=EB=AA=85=EC=9D=84=20api=5Fkey=20?= =?UTF-8?q?prefix=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key_api.py | 46 ++++++++++++++++----------------- app/services/api_key_service.py | 8 +++--- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index b760cfd..74884ea 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -20,20 +20,20 @@ description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", ) def store_api_key( - credential: APIKeyCreate, service: APIKeyService = api_key_service_dependency + api_key_data: APIKeyCreate, service: APIKeyService = api_key_service_dependency ) -> ResponseMessage[APIKeyResponse]: """ - **service_name**: API Key가 사용될 외부 서비스 이름 (예: "OpenAI") - **api_key**: 암호화하여 저장할 실제 API Key (예: "sk-***..") """ - created_credential = service.store_api_key(credential) + created_api_key = service.store_api_key(api_key_data) response_data = APIKeyResponse( - id=created_credential.id, - service_name=created_credential.service_name.value, - api_key_encrypted=created_credential.api_key, - created_at=created_credential.created_at, - updated_at=created_credential.updated_at, + id=created_api_key.id, + service_name=created_api_key.service_name.value, + api_key_encrypted=created_api_key.api_key, + created_at=created_api_key.created_at, + updated_at=created_api_key.updated_at, ) return ResponseMessage.success(value=response_data, code=CommonCode.CREATED) @@ -52,16 +52,16 @@ def get_all_api_keys( service: APIKeyService = api_key_service_dependency, ) -> ResponseMessage[list[APIKeyResponse]]: """저장된 모든 API Key의 메타데이터를 조회하여 등록 여부를 확인합니다.""" - db_credentials = service.get_all_api_keys() + api_keys_in_db = service.get_all_api_keys() response_data = [ APIKeyResponse( - id=cred.id, - service_name=cred.service_name, - created_at=cred.created_at, - updated_at=cred.updated_at, + id=api_key.id, + service_name=api_key.service_name, + created_at=api_key.created_at, + updated_at=api_key.updated_at, ) - for cred in db_credentials + for api_key in api_keys_in_db ] return ResponseMessage.success(value=response_data) @@ -75,13 +75,13 @@ def get_api_key_by_service_name( serviceName: LLMServiceEnum, service: APIKeyService = api_key_service_dependency ) -> ResponseMessage[APIKeyResponse]: """서비스 이름을 기준으로 특정 API Key의 메타데이터를 조회합니다.""" - db_credential = service.get_api_key_by_service_name(serviceName) + api_key_in_db = service.get_api_key_by_service_name(serviceName) response_data = APIKeyResponse( - id=db_credential.id, - service_name=db_credential.service_name, - created_at=db_credential.created_at, - updated_at=db_credential.updated_at, + id=api_key_in_db.id, + service_name=api_key_in_db.service_name, + created_at=api_key_in_db.created_at, + updated_at=api_key_in_db.updated_at, ) return ResponseMessage.success(value=response_data) @@ -101,13 +101,13 @@ def update_api_key( - **service_name**: 수정할 서비스의 이름 - **api_key**: 새로운 API Key """ - updated_credential = service.update_api_key(serviceName.value, key_data) + updated_api_key = service.update_api_key(serviceName.value, key_data) response_data = APIKeyResponse( - id=updated_credential.id, - service_name=updated_credential.service_name, - created_at=updated_credential.created_at, - updated_at=updated_credential.updated_at, + id=updated_api_key.id, + service_name=updated_api_key.service_name, + created_at=updated_api_key.created_at, + updated_at=updated_api_key.updated_at, ) return ResponseMessage.success(value=response_data) diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index 7af4381..7e43cb9 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -18,16 +18,16 @@ class APIKeyService: def __init__(self, repository: APIKeyRepository = api_key_repository): self.repository = repository - def store_api_key(self, credential_data: APIKeyCreate) -> APIKeyInDB: + def store_api_key(self, api_key_data: APIKeyCreate) -> APIKeyInDB: """API_KEY를 암호화하고 repository를 통해 데이터베이스에 저장합니다.""" - credential_data.validate_with_service() + api_key_data.validate_with_service() try: - encrypted_key = AES256.encrypt(credential_data.api_key) + encrypted_key = AES256.encrypt(api_key_data.api_key) new_id = generate_prefixed_uuid("QGENIE") created_row = self.repository.create_api_key( new_id=new_id, - service_name=credential_data.service_name.value, + service_name=api_key_data.service_name.value, encrypted_key=encrypted_key, ) From a694826c36fc0f6308818f533f0e4344102e7ad0 Mon Sep 17 00:00:00 2001 From: mini Date: Fri, 8 Aug 2025 01:17:38 +0900 Subject: [PATCH 102/220] =?UTF-8?q?feat:=20api=20router=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/api/api_router.py b/app/api/api_router.py index b1c2d39..d7c6eab 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from app.api import api_key_api, driver_api, test_api, user_db_api +from app.api import ai_chat_api, api_key_api, driver_api, test_api, user_db_api api_router = APIRouter() @@ -13,3 +13,4 @@ api_router.include_router(driver_api.router, prefix="/driver", tags=["Driver"]) api_router.include_router(user_db_api.router, prefix="/user/db", tags=["UserDb"]) api_router.include_router(api_key_api.router, prefix="/keys", tags=["API Key"]) +api_router.include_router(ai_chat_api.router, prefix="/chats", tags=["AI Chat"]) From 6877834cb0641ceed95ddad1067616eca7245281 Mon Sep 17 00:00:00 2001 From: mini Date: Fri, 8 Aug 2025 01:21:03 +0900 Subject: [PATCH 103/220] =?UTF-8?q?feat:=20api=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/ai_chat_api.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/api/ai_chat_api.py diff --git a/app/api/ai_chat_api.py b/app/api/ai_chat_api.py new file mode 100644 index 0000000..87ef3a1 --- /dev/null +++ b/app/api/ai_chat_api.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Depends + +from app.core.response import ResponseMessage +from app.core.status import CommonCode +from app.schemas.ai_chat.create_model import AIChatCreate +from app.schemas.ai_chat.response_model import AIChatResponse +from app.services.ai_chat_service import AIChatService, ai_chat_service + +ai_chat_service_dependency = Depends(lambda: ai_chat_service) + +router = APIRouter() + + +@router.post( + "/actions", + response_model=ResponseMessage[AIChatResponse], + summary="Chat Tab 생성", + description="새로운 Chat Tab을 생성하여 로컬 데이터베이스에 저장합니다.", +) +def store_ai_chat( + chatName: AIChatCreate, service: AIChatService = ai_chat_service_dependency +) -> ResponseMessage[AIChatResponse]: + """ + - **name**: 새로운 Chat_tab 이름 (예: "채팅 타이틀") + """ + created_chat = service.store_ai_chat(chatName) + + response_data = AIChatResponse( + id=created_chat.id, + name=created_chat.name, + created_at=created_chat.created_at, + updated_at=created_chat.updated_at, + ) + return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_AI_CHAT_CREATE) From bd87aa51e47df6953e78c39bed7f6527f87ae214 Mon Sep 17 00:00:00 2001 From: mini Date: Fri, 8 Aug 2025 01:21:20 +0900 Subject: [PATCH 104/220] =?UTF-8?q?feat:=20ai=20chat=20status=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/core/status.py b/app/core/status.py index 6d5e538..3055d4b 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -24,6 +24,7 @@ class CommonCode(Enum): """ KEY 성공 코드 - 22xx """ """ AI CHAT, DB 성공 코드 - 23xx """ + SUCCESS_AI_CHAT_CREATE = (status.HTTP_200_OK, "2300", "새로운 채팅 탭을 생성하였습니다.") """ ANNOTATION 성공 코드 - 24xx """ @@ -51,6 +52,18 @@ class CommonCode(Enum): ) """ AI CHAT, DB 클라이언트 오류 코드 - 43xx """ + INVALID_CHAT_NAME_FORMAT = (status.HTTP_400_BAD_REQUEST, "4300", "채팅 탭 이름의 형식이 올바르지 않습니다.") + INVALID_CHAT_NAME_LENGTH = ( + status.HTTP_400_BAD_REQUEST, + "4301", + "채팅 탭 이름의 길이는 255자를 초과할 수 없습니다.", + ) + INVALID_CHAT_NAME_CONTENT = ( + status.HTTP_400_BAD_REQUEST, + "4302", + "채팅 탭 이름에 SQL 예약어나 허용되지 않는 특수문자가 포함되어 있습니다. " + "허용되지 않는 특수 문자: 큰따옴표(\"), 작은따옴표('), 세미콜론(;), 꺾쇠괄호(<, >)", + ) """ ANNOTATION 클라이언트 오류 코드 - 44xx """ From 954b3a985e8f04edf5b240935fc30fd05fc21959 Mon Sep 17 00:00:00 2001 From: mini Date: Fri, 8 Aug 2025 01:22:24 +0900 Subject: [PATCH 105/220] =?UTF-8?q?feat:=20ai=20chat=20tab=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9=EB=90=A0=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/ai_chat/base_model.py | 7 +++++++ app/schemas/ai_chat/db_model.py | 15 +++++++++++++++ app/schemas/ai_chat/response_model.py | 14 ++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 app/schemas/ai_chat/base_model.py create mode 100644 app/schemas/ai_chat/db_model.py create mode 100644 app/schemas/ai_chat/response_model.py diff --git a/app/schemas/ai_chat/base_model.py b/app/schemas/ai_chat/base_model.py new file mode 100644 index 0000000..d59c248 --- /dev/null +++ b/app/schemas/ai_chat/base_model.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, Field + + +class AIChatBase(BaseModel): + """모든 AI Chat Tab 스키마의 기본 모델""" + + name: str = Field(..., description="새로운 채팅 탭 이름") diff --git a/app/schemas/ai_chat/db_model.py b/app/schemas/ai_chat/db_model.py new file mode 100644 index 0000000..8bd1bf8 --- /dev/null +++ b/app/schemas/ai_chat/db_model.py @@ -0,0 +1,15 @@ +from datetime import datetime + +from app.schemas.ai_chat.base_model import AIChatBase + + +class AIChatInDB(AIChatBase): + """데이터베이스에 저장된 형태의 스키마 (내부용)""" + + id: str + name: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/app/schemas/ai_chat/response_model.py b/app/schemas/ai_chat/response_model.py new file mode 100644 index 0000000..e6d060b --- /dev/null +++ b/app/schemas/ai_chat/response_model.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from pydantic import Field + +from app.schemas.ai_chat.base_model import AIChatBase + + +class AIChatResponse(AIChatBase): + """AI 채팅 탭 정보 API 응답용 스키마""" + + id: str = Field(..., description="채팅 세션의 고유 ID (서버에서 생성)") + name: str = Field(..., description="채팅 세션의 이름") + created_at: datetime + updated_at: datetime From 666699883df49cb22baeb72bce7073afb4f80504 Mon Sep 17 00:00:00 2001 From: mini Date: Fri, 8 Aug 2025 01:22:43 +0900 Subject: [PATCH 106/220] =?UTF-8?q?feat:=20repository=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/ai_chat_repository.py | 46 ++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 app/repository/ai_chat_repository.py diff --git a/app/repository/ai_chat_repository.py b/app/repository/ai_chat_repository.py new file mode 100644 index 0000000..a060108 --- /dev/null +++ b/app/repository/ai_chat_repository.py @@ -0,0 +1,46 @@ +import sqlite3 + +from app.core.utils import get_db_path +from app.schemas.ai_chat.db_model import AIChatInDB + + +class AIChatRepository: + + def create_ai_chat(self, new_id: str, name: str) -> AIChatInDB: + """ + 암호화된 API Key 정보를 받아 데이터베이스에 저장하고, + 저장된 객체를 반환합니다. + """ + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute( + """ + INSERT INTO chat_tab (id, name) + VALUES (?, ?) + """, + ( + new_id, + name, + ), + ) + conn.commit() + + cursor.execute("SELECT * FROM chat_tab WHERE id = ?", (new_id,)) + created_row = cursor.fetchone() + + if not created_row: + raise None + + return AIChatInDB.model_validate(dict(created_row)) + + finally: + if conn: + conn.close() + + +ai_chat_repository = AIChatRepository() From 23430767616988d16dd36b2ca6037984ddd31ea6 Mon Sep 17 00:00:00 2001 From: mini Date: Fri, 8 Aug 2025 01:22:52 +0900 Subject: [PATCH 107/220] =?UTF-8?q?feat:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/ai_chat_service.py | 43 +++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 app/services/ai_chat_service.py diff --git a/app/services/ai_chat_service.py b/app/services/ai_chat_service.py new file mode 100644 index 0000000..5c01f67 --- /dev/null +++ b/app/services/ai_chat_service.py @@ -0,0 +1,43 @@ +import sqlite3 + +from fastapi import Depends + +from app.core.exceptions import APIException +from app.core.status import CommonCode +from app.core.utils import generate_prefixed_uuid +from app.repository.ai_chat_repository import AIChatRepository, ai_chat_repository +from app.schemas.ai_chat.create_model import AIChatCreate +from app.schemas.ai_chat.db_model import AIChatInDB + +ai_chat_repository_dependency = Depends(lambda: ai_chat_repository) + + +class AIChatService: + def __init__(self, repository: AIChatRepository = ai_chat_repository): + self.repository = repository + + def store_ai_chat(self, chatName: AIChatCreate) -> AIChatInDB: + """새로운 AI 채팅을 데이터베이스에 저장합니다.""" + chatName.validate_with_name() + + new_id = generate_prefixed_uuid("CHAT_TAB") + + try: + created_row = self.repository.create_ai_chat( + new_id=new_id, + name=chatName.name, + ) + if not created_row: + raise APIException(CommonCode.FAIL_TO_VERIFY_CREATION) + + return created_row + + except sqlite3.Error as e: + # "database is locked" 오류를 명시적으로 처리 + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + # 기타 모든 sqlite3 오류 + raise APIException(CommonCode.FAIL) from e + + +ai_chat_service = AIChatService() From 85a1a5226618758216bf35975c022a3fb4c415a7 Mon Sep 17 00:00:00 2001 From: mini Date: Fri, 8 Aug 2025 01:23:39 +0900 Subject: [PATCH 108/220] =?UTF-8?q?feat:=20ai=20chat=20tab=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=EC=9D=B4=EB=A6=84=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/ai_chat/create_model.py | 30 +++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 app/schemas/ai_chat/create_model.py diff --git a/app/schemas/ai_chat/create_model.py b/app/schemas/ai_chat/create_model.py new file mode 100644 index 0000000..1bb3764 --- /dev/null +++ b/app/schemas/ai_chat/create_model.py @@ -0,0 +1,30 @@ +import re + +from app.core.exceptions import APIException +from app.core.status import CommonCode +from app.schemas.ai_chat.base_model import AIChatBase + + +class AIChatCreate(AIChatBase): + """새로운 Chat Tab 생성을 위한 스키마""" + + def validate_with_name(self) -> None: + """채팅 탭 이름에 대한 유효성 검증 로직을 수행합니다.""" + # 1. 문자열 전체가 공백 문자인지 확인 + if not self.name or self.name.isspace(): + raise APIException(CommonCode.INVALID_CHAT_NAME_FORMAT) + + # 2. 길이 제한 + if len(self.name) > 255: + raise APIException(CommonCode.INVALID_CHAT_NAME_LENGTH) + + # 3. 특수문자 및 SQL 예약어 확인 + # SQL 예약어와 위험한 특수문자를 검사합니다. + sql_keywords = ["SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "OR", "AND"] + for keyword in sql_keywords: + if keyword in self.name.upper(): + raise APIException(CommonCode.INVALID_CHAT_NAME_CONTENT) + + # 특정 특수문자를 검사하는 예시 + if re.search(r"[;\"'`<>]", self.name): + raise APIException(CommonCode.INVALID_CHAT_NAME_CONTENT) From 826ab1014211ace4625cb9768a76b745045d656e Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 00:35:37 +0900 Subject: [PATCH 109/220] =?UTF-8?q?refactor:=20=EC=A7=81=EA=B4=80=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD(ai=5Fchat=20->=20chat=5Ftab)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 4 ++-- app/api/{ai_chat_api.py => chat_tab_api.py} | 6 +++--- app/core/status.py | 8 ++++---- .../{ai_chat_repository.py => chat_tab_repository.py} | 2 +- app/schemas/{ai_chat => chat_tab}/base_model.py | 0 app/schemas/{ai_chat => chat_tab}/create_model.py | 10 +++++----- app/schemas/{ai_chat => chat_tab}/db_model.py | 2 +- app/schemas/{ai_chat => chat_tab}/response_model.py | 2 +- .../{ai_chat_service.py => chat_tab_service.py} | 6 +++--- 9 files changed, 20 insertions(+), 20 deletions(-) rename app/api/{ai_chat_api.py => chat_tab_api.py} (83%) rename app/repository/{ai_chat_repository.py => chat_tab_repository.py} (95%) rename app/schemas/{ai_chat => chat_tab}/base_model.py (100%) rename app/schemas/{ai_chat => chat_tab}/create_model.py (72%) rename app/schemas/{ai_chat => chat_tab}/db_model.py (82%) rename app/schemas/{ai_chat => chat_tab}/response_model.py (86%) rename app/services/{ai_chat_service.py => chat_tab_service.py} (86%) diff --git a/app/api/api_router.py b/app/api/api_router.py index d7c6eab..346914a 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from app.api import ai_chat_api, api_key_api, driver_api, test_api, user_db_api +from app.api import api_key_api, chat_tab_api, driver_api, test_api, user_db_api api_router = APIRouter() @@ -13,4 +13,4 @@ api_router.include_router(driver_api.router, prefix="/driver", tags=["Driver"]) api_router.include_router(user_db_api.router, prefix="/user/db", tags=["UserDb"]) api_router.include_router(api_key_api.router, prefix="/keys", tags=["API Key"]) -api_router.include_router(ai_chat_api.router, prefix="/chats", tags=["AI Chat"]) +api_router.include_router(chat_tab_api.router, prefix="/chats", tags=["AI Chat"]) diff --git a/app/api/ai_chat_api.py b/app/api/chat_tab_api.py similarity index 83% rename from app/api/ai_chat_api.py rename to app/api/chat_tab_api.py index 87ef3a1..7aa4bcb 100644 --- a/app/api/ai_chat_api.py +++ b/app/api/chat_tab_api.py @@ -2,9 +2,9 @@ from app.core.response import ResponseMessage from app.core.status import CommonCode -from app.schemas.ai_chat.create_model import AIChatCreate -from app.schemas.ai_chat.response_model import AIChatResponse -from app.services.ai_chat_service import AIChatService, ai_chat_service +from app.schemas.chat_tab.create_model import AIChatCreate +from app.schemas.chat_tab.response_model import AIChatResponse +from app.services.chat_tab_service import AIChatService, ai_chat_service ai_chat_service_dependency = Depends(lambda: ai_chat_service) diff --git a/app/core/status.py b/app/core/status.py index 3055d4b..dc87e45 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -51,14 +51,14 @@ class CommonCode(Enum): "API 키가 선택한 서비스의 올바른 형식이 아닙니다. (예: OpenAI는 sk-로 시작)", ) - """ AI CHAT, DB 클라이언트 오류 코드 - 43xx """ - INVALID_CHAT_NAME_FORMAT = (status.HTTP_400_BAD_REQUEST, "4300", "채팅 탭 이름의 형식이 올바르지 않습니다.") - INVALID_CHAT_NAME_LENGTH = ( + """ AI CHAT TAB 클라이언트 오류 코드 - 43xx """ + INVALID_CHAT_TAB_NAME_FORMAT = (status.HTTP_400_BAD_REQUEST, "4300", "채팅 탭 이름의 형식이 올바르지 않습니다.") + INVALID_CHAT_TAB_NAME_LENGTH = ( status.HTTP_400_BAD_REQUEST, "4301", "채팅 탭 이름의 길이는 255자를 초과할 수 없습니다.", ) - INVALID_CHAT_NAME_CONTENT = ( + INVALID_CHAT_TAB_NAME_CONTENT = ( status.HTTP_400_BAD_REQUEST, "4302", "채팅 탭 이름에 SQL 예약어나 허용되지 않는 특수문자가 포함되어 있습니다. " diff --git a/app/repository/ai_chat_repository.py b/app/repository/chat_tab_repository.py similarity index 95% rename from app/repository/ai_chat_repository.py rename to app/repository/chat_tab_repository.py index a060108..bf96fe8 100644 --- a/app/repository/ai_chat_repository.py +++ b/app/repository/chat_tab_repository.py @@ -1,7 +1,7 @@ import sqlite3 from app.core.utils import get_db_path -from app.schemas.ai_chat.db_model import AIChatInDB +from app.schemas.chat_tab.db_model import AIChatInDB class AIChatRepository: diff --git a/app/schemas/ai_chat/base_model.py b/app/schemas/chat_tab/base_model.py similarity index 100% rename from app/schemas/ai_chat/base_model.py rename to app/schemas/chat_tab/base_model.py diff --git a/app/schemas/ai_chat/create_model.py b/app/schemas/chat_tab/create_model.py similarity index 72% rename from app/schemas/ai_chat/create_model.py rename to app/schemas/chat_tab/create_model.py index 1bb3764..c910fe5 100644 --- a/app/schemas/ai_chat/create_model.py +++ b/app/schemas/chat_tab/create_model.py @@ -2,7 +2,7 @@ from app.core.exceptions import APIException from app.core.status import CommonCode -from app.schemas.ai_chat.base_model import AIChatBase +from app.schemas.chat_tab.base_model import AIChatBase class AIChatCreate(AIChatBase): @@ -12,19 +12,19 @@ def validate_with_name(self) -> None: """채팅 탭 이름에 대한 유효성 검증 로직을 수행합니다.""" # 1. 문자열 전체가 공백 문자인지 확인 if not self.name or self.name.isspace(): - raise APIException(CommonCode.INVALID_CHAT_NAME_FORMAT) + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_FORMAT) # 2. 길이 제한 if len(self.name) > 255: - raise APIException(CommonCode.INVALID_CHAT_NAME_LENGTH) + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_LENGTH) # 3. 특수문자 및 SQL 예약어 확인 # SQL 예약어와 위험한 특수문자를 검사합니다. sql_keywords = ["SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "OR", "AND"] for keyword in sql_keywords: if keyword in self.name.upper(): - raise APIException(CommonCode.INVALID_CHAT_NAME_CONTENT) + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) # 특정 특수문자를 검사하는 예시 if re.search(r"[;\"'`<>]", self.name): - raise APIException(CommonCode.INVALID_CHAT_NAME_CONTENT) + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) diff --git a/app/schemas/ai_chat/db_model.py b/app/schemas/chat_tab/db_model.py similarity index 82% rename from app/schemas/ai_chat/db_model.py rename to app/schemas/chat_tab/db_model.py index 8bd1bf8..833f95a 100644 --- a/app/schemas/ai_chat/db_model.py +++ b/app/schemas/chat_tab/db_model.py @@ -1,6 +1,6 @@ from datetime import datetime -from app.schemas.ai_chat.base_model import AIChatBase +from app.schemas.chat_tab.base_model import AIChatBase class AIChatInDB(AIChatBase): diff --git a/app/schemas/ai_chat/response_model.py b/app/schemas/chat_tab/response_model.py similarity index 86% rename from app/schemas/ai_chat/response_model.py rename to app/schemas/chat_tab/response_model.py index e6d060b..5a1c46f 100644 --- a/app/schemas/ai_chat/response_model.py +++ b/app/schemas/chat_tab/response_model.py @@ -2,7 +2,7 @@ from pydantic import Field -from app.schemas.ai_chat.base_model import AIChatBase +from app.schemas.chat_tab.base_model import AIChatBase class AIChatResponse(AIChatBase): diff --git a/app/services/ai_chat_service.py b/app/services/chat_tab_service.py similarity index 86% rename from app/services/ai_chat_service.py rename to app/services/chat_tab_service.py index 5c01f67..27ac918 100644 --- a/app/services/ai_chat_service.py +++ b/app/services/chat_tab_service.py @@ -5,9 +5,9 @@ from app.core.exceptions import APIException from app.core.status import CommonCode from app.core.utils import generate_prefixed_uuid -from app.repository.ai_chat_repository import AIChatRepository, ai_chat_repository -from app.schemas.ai_chat.create_model import AIChatCreate -from app.schemas.ai_chat.db_model import AIChatInDB +from app.repository.chat_tab_repository import AIChatRepository, ai_chat_repository +from app.schemas.chat_tab.create_model import AIChatCreate +from app.schemas.chat_tab.db_model import AIChatInDB ai_chat_repository_dependency = Depends(lambda: ai_chat_repository) From 84ffa96602fdbcf556df859237db5b724dbb7fd6 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 00:37:25 +0900 Subject: [PATCH 110/220] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=ED=83=AD=20=EA=B8=B8=EC=9D=B4=20=EC=A0=9C=ED=95=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95(255=20->=20128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 2 +- app/db/init_db.py | 2 +- app/schemas/chat_tab/create_model.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index dc87e45..d20e742 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -56,7 +56,7 @@ class CommonCode(Enum): INVALID_CHAT_TAB_NAME_LENGTH = ( status.HTTP_400_BAD_REQUEST, "4301", - "채팅 탭 이름의 길이는 255자를 초과할 수 없습니다.", + "채팅 탭 이름의 길이는 128자를 초과할 수 없습니다.", ) INVALID_CHAT_TAB_NAME_CONTENT = ( status.HTTP_400_BAD_REQUEST, diff --git a/app/db/init_db.py b/app/db/init_db.py index 79fa774..34a8c95 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -72,7 +72,7 @@ def initialize_database(): """ CREATE TABLE IF NOT EXISTS chat_tab ( id VARCHAR(64) PRIMARY KEY NOT NULL, - name VARCHAR(255), + name VARCHAR(128), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/app/schemas/chat_tab/create_model.py b/app/schemas/chat_tab/create_model.py index c910fe5..f811adc 100644 --- a/app/schemas/chat_tab/create_model.py +++ b/app/schemas/chat_tab/create_model.py @@ -15,7 +15,7 @@ def validate_with_name(self) -> None: raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_FORMAT) # 2. 길이 제한 - if len(self.name) > 255: + if len(self.name) > 128: raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_LENGTH) # 3. 특수문자 및 SQL 예약어 확인 From 8fc6ced05b670c0de93e8f4d0cb9298534b192a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Fri, 8 Aug 2025 17:50:37 +0900 Subject: [PATCH 111/220] =?UTF-8?q?feat:=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EC=97=90=20=EC=82=AC=EC=9A=A9=EB=90=A0=20DB?= =?UTF-8?q?=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db/init_db.py | 108 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/app/db/init_db.py b/app/db/init_db.py index 34a8c95..b9ba33c 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -143,6 +143,114 @@ def initialize_database(): """ ) + # database_annotation 테이블 생성 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS database_annotation ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + db_profile_id VARCHAR(64) NOT NULL, + database_name VARCHAR(255) NOT NULL, + description TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (db_profile_id) REFERENCES db_profile(id) ON DELETE CASCADE + ); + """ + ) + # database_annotation 테이블의 updated_at을 자동으로 업데이트하는 트리거 + cursor.execute( + """ + CREATE TRIGGER IF NOT EXISTS update_database_annotation_updated_at + BEFORE UPDATE ON database_annotation + FOR EACH ROW + BEGIN + UPDATE database_annotation SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; + """ + ) + + # table_annotation 테이블 생성 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS table_annotation ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + database_annotation_id VARCHAR(64) NOT NULL, + table_name VARCHAR(255) NOT NULL, + description TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (database_annotation_id) REFERENCES database_annotation(id) ON DELETE CASCADE + ); + """ + ) + # table_annotation 테이블의 updated_at을 자동으로 업데이트하는 트리거 + cursor.execute( + """ + CREATE TRIGGER IF NOT EXISTS update_table_annotation_updated_at + BEFORE UPDATE ON table_annotation + FOR EACH ROW + BEGIN + UPDATE table_annotation SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; + """ + ) + + # column_annotation 테이블 생성 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS column_annotation ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + table_annotation_id VARCHAR(64) NOT NULL, + column_name VARCHAR(255) NOT NULL, + description TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (table_annotation_id) REFERENCES table_annotation(id) ON DELETE CASCADE + ); + """ + ) + # column_annotation 테이블의 updated_at을 자동으로 업데이트하는 트리거 + cursor.execute( + """ + CREATE TRIGGER IF NOT EXISTS update_column_annotation_updated_at + BEFORE UPDATE ON column_annotation + FOR EACH ROW + BEGIN + UPDATE column_annotation SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; + """ + ) + + # table_relationship 테이블 생성 + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS table_relationship ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + database_annotation_id VARCHAR(64) NOT NULL, + from_table_id VARCHAR(64) NOT NULL, + to_table_id VARCHAR(64) NOT NULL, + relationship_type VARCHAR(32) NOT NULL, + description TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (database_annotation_id) REFERENCES database_annotation(id) ON DELETE CASCADE, + FOREIGN KEY (from_table_id) REFERENCES table_annotation(id) ON DELETE CASCADE, + FOREIGN KEY (to_table_id) REFERENCES table_annotation(id) ON DELETE CASCADE + ); + """ + ) + # table_relationship 테이블의 updated_at을 자동으로 업데이트하는 트리거 + cursor.execute( + """ + CREATE TRIGGER IF NOT EXISTS update_table_relationship_updated_at + BEFORE UPDATE ON table_relationship + FOR EACH ROW + BEGIN + UPDATE table_relationship SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; + """ + ) + conn.commit() except sqlite3.Error as e: From c4abbaf413cb9e1c2b7f60f4f85b02f7478e0065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Sun, 10 Aug 2025 14:33:01 +0900 Subject: [PATCH 112/220] =?UTF-8?q?feat:=20=EC=A0=9C=EC=95=BD,=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=B0=8F=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db/init_db.py | 143 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/app/db/init_db.py b/app/db/init_db.py index b9ba33c..e9a38fc 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -195,13 +195,24 @@ def initialize_database(): """ ) - # column_annotation 테이블 생성 + # column_annotation 테이블 생성 (단일 컬럼 스펙 전용) cursor.execute( """ CREATE TABLE IF NOT EXISTS column_annotation ( id VARCHAR(64) PRIMARY KEY NOT NULL, table_annotation_id VARCHAR(64) NOT NULL, column_name VARCHAR(255) NOT NULL, + -- 데이터 타입 (원본 DB의 타입 문자열을 그대로 저장; 예: BIGINT, TEXT, TIMESTAMP) + data_type VARCHAR(64), + -- NULL 허용 여부 (1:true, 0:false) + is_nullable INTEGER NOT NULL DEFAULT 1, + -- 기본값(리터럴 또는 표현식; 문자열 형태로 저장) + default_value TEXT, + -- 단일 컬럼 기준 CHECK 제약 표현(예: "value > 0") + check_expression TEXT, + -- 컬럼 순서 + ordinal_position INTEGER, + -- 설명 description TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -251,6 +262,136 @@ def initialize_database(): """ ) + # --------------------------------------------------------------------- + # 복합 제약(Primary/Unique/ForeignKey/Check) 메타데이터 테이블 생성 + # - 여러 컬럼을 묶는 제약을 '그룹' 단위로 관리 + # - UI 배지/목록은 이 테이블들에서 파생 계산 + # --------------------------------------------------------------------- + + # table_constraint 테이블 생성 (제약 그룹 본체) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS table_constraint ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + table_annotation_id VARCHAR(64) NOT NULL, + -- PRIMARY_KEY | UNIQUE | FOREIGN_KEY | CHECK + constraint_type VARCHAR(16) NOT NULL, + -- DB 제약명(선택) + name VARCHAR(255), + -- CHECK 제약식 등 (FK/PK/UNIQUE에는 NULL 가능) + expression TEXT, + -- FOREIGN KEY 전용: 참조 테이블명 + ref_table VARCHAR(255), + -- FOREIGN KEY 전용: 액션 + on_update_action VARCHAR(16), + on_delete_action VARCHAR(16), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (table_annotation_id) REFERENCES table_annotation(id) ON DELETE CASCADE + ); + """ + ) + # table_constraint 테이블의 updated_at을 자동으로 업데이트하는 트리거 + cursor.execute( + """ + CREATE TRIGGER IF NOT EXISTS update_table_constraint_updated_at + BEFORE UPDATE ON table_constraint + FOR EACH ROW + BEGIN + UPDATE table_constraint SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; + """ + ) + + # constraint_column 테이블 생성 (제약 그룹 ↔ 컬럼 매핑) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS constraint_column ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + constraint_id VARCHAR(64) NOT NULL, + column_annotation_id VARCHAR(64) NOT NULL, + -- 복합 제약 내 컬럼 순서(1, 2, 3, ...) + position INTEGER, + -- FOREIGN KEY 전용: 참조 테이블의 대응 컬럼명 (복합 FK 매핑) + referenced_column_name VARCHAR(255), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (constraint_id) REFERENCES table_constraint(id) ON DELETE CASCADE, + FOREIGN KEY (column_annotation_id) REFERENCES column_annotation(id) ON DELETE CASCADE + ); + """ + ) + # constraint_column 테이블의 updated_at을 자동으로 업데이트하는 트리거 + cursor.execute( + """ + CREATE TRIGGER IF NOT EXISTS update_constraint_column_updated_at + BEFORE UPDATE ON constraint_column + FOR EACH ROW + BEGIN + UPDATE constraint_column SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; + """ + ) + + # --------------------------------------------------------------------- + # 인덱스(복합 포함) 메타데이터 테이블 생성 + # - DB 인덱스명을 보존하고, 컬럼 순서를 기록 + # --------------------------------------------------------------------- + + # index_annotation 테이블 생성 (인덱스 그룹 본체) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS index_annotation ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + table_annotation_id VARCHAR(64) NOT NULL, + name VARCHAR(255), -- DB 인덱스명(선택) + is_unique INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (table_annotation_id) REFERENCES table_annotation(id) ON DELETE CASCADE + ); + """ + ) + # index_annotation 테이블의 updated_at을 자동으로 업데이트하는 트리거 + cursor.execute( + """ + CREATE TRIGGER IF NOT EXISTS update_index_annotation_updated_at + BEFORE UPDATE ON index_annotation + FOR EACH ROW + BEGIN + UPDATE index_annotation SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; + """ + ) + + # index_column 테이블 생성 (인덱스 그룹 ↔ 컬럼 매핑) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS index_column ( + id VARCHAR(64) PRIMARY KEY NOT NULL, + index_id VARCHAR(64) NOT NULL, + column_annotation_id VARCHAR(64) NOT NULL, + -- 인덱스 내 컬럼 순서(1, 2, 3, ...) + position INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (index_id) REFERENCES index_annotation(id) ON DELETE CASCADE, + FOREIGN KEY (column_annotation_id) REFERENCES column_annotation(id) ON DELETE CASCADE + ); + """ + ) + # index_column 테이블의 updated_at을 자동으로 업데이트하는 트리거 + cursor.execute( + """ + CREATE TRIGGER IF NOT EXISTS update_index_column_updated_at + BEFORE UPDATE ON index_column + FOR EACH ROW + BEGIN + UPDATE index_column SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END; + """ + ) + conn.commit() except sqlite3.Error as e: From 365c9278dafeeb42d780d0f3b760d6bc062cc2ef Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 9 Aug 2025 00:12:28 +0900 Subject: [PATCH 113/220] =?UTF-8?q?feat:=20db=20key=20prefix=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=84=20=EB=8B=B4=EA=B3=A0=EC=9E=88=EB=8A=94=20enu?= =?UTF-8?q?m=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/db_key_prefix_name.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 app/core/enum/db_key_prefix_name.py diff --git a/app/core/enum/db_key_prefix_name.py b/app/core/enum/db_key_prefix_name.py new file mode 100644 index 0000000..5eea582 --- /dev/null +++ b/app/core/enum/db_key_prefix_name.py @@ -0,0 +1,7 @@ +# app/core/enum/db_key_prefix_name.py +from enum import Enum + +class DBSaveIdEnum(Enum): + """저장할 디비 ID 앞에 들어갈 이름""" + user_db = "USER-DB" + driver = "DRIVER" \ No newline at end of file From ca197f5af63ee13e82ccaed518ac2cf02d878443 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 9 Aug 2025 00:13:23 +0900 Subject: [PATCH 114/220] =?UTF-8?q?feat:=20db=20profile=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=EA=B0=80=20=EC=98=A4=EB=A5=98=EC=9D=B8=EA=B1=B8=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index d20e742..d4359e9 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -20,6 +20,7 @@ class CommonCode(Enum): """ DRIVER, DB 성공 코드 - 21xx """ SUCCESS_DRIVER_INFO = (status.HTTP_200_OK, "2100", "드라이버 정보 조회를 성공하였습니다.") SUCCESS_USER_DB_CONNECT_TEST = (status.HTTP_200_OK, "2101", "테스트 연결을 성공하였습니다.") + SUCCESS_SAVE_DB_PROFILE = (status.HTTP_200_OK, "2102", "DB 연결 정보를 저장하였습니다.") """ KEY 성공 코드 - 22xx """ @@ -31,19 +32,19 @@ class CommonCode(Enum): """ SQL 성공 코드 - 25xx """ # ======================================= - # 클라이언트 오류 (Client Error) - 4xxx + # 클라이언트 에러 (Client Error) - 4xxx # ======================================= - """ 기본 클라이언트 오류 코드 - 40xx """ + """ 기본 클라이언트 에러 코드 - 40xx """ NO_VALUE = (status.HTTP_400_BAD_REQUEST, "4000", "필수 값이 존재하지 않습니다.") DUPLICATION = (status.HTTP_409_CONFLICT, "4001", "이미 존재하는 데이터입니다.") NO_SEARCH_DATA = (status.HTTP_404_NOT_FOUND, "4002", "요청한 데이터를 찾을 수 없습니다.") INVALID_PARAMETER = (status.HTTP_422_UNPROCESSABLE_ENTITY, "4003", "필수 값이 누락되었습니다.") - """ DRIVER, DB 클라이언트 오류 코드 - 41xx """ + """ DRIVER, DB 클라이언트 에러 코드 - 41xx """ INVALID_DB_DRIVER = (status.HTTP_409_CONFLICT, "4100", "지원하지 않는 데이터베이스입니다.") NO_DB_DRIVER = (status.HTTP_400_BAD_REQUEST, "4101", "데이터베이스는 필수 값입니다.") - """ KEY 클라이언트 오류 코드 - 42xx """ + """ KEY 클라이언트 에러 코드 - 42xx """ INVALID_API_KEY_FORMAT = (status.HTTP_400_BAD_REQUEST, "4200", "API 키의 형식이 올바르지 않습니다.") INVALID_API_KEY_PREFIX = ( status.HTTP_400_BAD_REQUEST, @@ -51,7 +52,7 @@ class CommonCode(Enum): "API 키가 선택한 서비스의 올바른 형식이 아닙니다. (예: OpenAI는 sk-로 시작)", ) - """ AI CHAT TAB 클라이언트 오류 코드 - 43xx """ + """ AI CHAT, DB 클라이언트 에러 코드 - 43xx """ INVALID_CHAT_TAB_NAME_FORMAT = (status.HTTP_400_BAD_REQUEST, "4300", "채팅 탭 이름의 형식이 올바르지 않습니다.") INVALID_CHAT_TAB_NAME_LENGTH = ( status.HTTP_400_BAD_REQUEST, @@ -65,15 +66,15 @@ class CommonCode(Enum): "허용되지 않는 특수 문자: 큰따옴표(\"), 작은따옴표('), 세미콜론(;), 꺾쇠괄호(<, >)", ) - """ ANNOTATION 클라이언트 오류 코드 - 44xx """ + """ ANNOTATION 클라이언트 에러 코드 - 44xx """ - """ SQL 클라이언트 오류 코드 - 45xx """ + """ SQL 클라이언트 에러 코드 - 45xx """ # ================================== - # 서버 오류 (Server Error) - 5xx + # 서버 에러 (Server Error) - 5xx # ================================== - """ 기본 서버 오류 코드 - 50xx """ - FAIL = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5000", "서버 처리 중 오류가 발생했습니다.") + """ 기본 서버 에러 코드 - 50xx """ + FAIL = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5000", "서버 처리 중 에러가 발생했습니다.") DB_BUSY = ( status.HTTP_503_SERVICE_UNAVAILABLE, "5001", @@ -82,19 +83,20 @@ class CommonCode(Enum): FAIL_TO_VERIFY_CREATION = ( status.HTTP_500_INTERNAL_SERVER_ERROR, "5002", - "데이터 생성 후 검증 과정에서 오류가 발생했습니다.", + "데이터 생성 후 검증 과정에서 에러가 발생했습니다.", ) - """ DRIVER, DB 서버 오류 코드 - 51xx """ - FAIL_CONNECT_DB = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 연결 중 오류가 발생했습니다.") + """ DRIVER, DB 서버 에러 코드 - 51xx """ + FAIL_CONNECT_DB = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 연결 중 에러가 발생했습니다.") + FAIL_SAVE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 정보 저장 중 에러가 발생했습니다.") - """ KEY 서버 오류 코드 - 52xx """ + """ KEY 서버 에러 코드 - 52xx """ - """ AI CHAT, DB 서버 오류 코드 - 53xx """ + """ AI CHAT, DB 서버 에러 코드 - 53xx """ - """ ANNOTATION 서버 오류 코드 - 54xx """ + """ ANNOTATION 서버 에러 코드 - 54xx """ - """ SQL 서버 오류 코드 - 55xx """ + """ SQL 서버 에러 코드 - 55xx """ def __init__(self, http_status: int, code: str, message: str): """Enum 멤버가 생성될 때 각 값을 속성으로 할당합니다.""" From ac0445a4c5d867be4644067dd92227b70589afe6 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 9 Aug 2025 00:14:10 +0900 Subject: [PATCH 115/220] =?UTF-8?q?refactor:=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=EC=A0=9C=EA=B1=B0=20=EC=8B=9C=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db/init_db.py | 235 +++++++++++++++++++++++++--------------------- 1 file changed, 127 insertions(+), 108 deletions(-) diff --git a/app/db/init_db.py b/app/db/init_db.py index 34a8c95..0989e5c 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -1,152 +1,171 @@ # db/init_db.py import sqlite3 +import logging from app.core.utils import get_db_path -""" -데이터베이스에 연결하고, 애플리케이션에 필요한 테이블이 없으면 생성합니다. -""" +def _synchronize_table(cursor, table_name: str, target_columns: dict): + """ + 테이블 스키마를 확인하고, 코드와 다를 경우 테이블을 재생성하여 동기화합니다. + """ + try: + cursor.execute(f"PRAGMA table_info({table_name})") + current_schema_rows = cursor.fetchall() + current_columns = {row[1]: row[2].upper() for row in current_schema_rows} + target_schema_simple = {name: definition.split()[0].upper() for name, definition in target_columns.items()} -def initialize_database(): + if current_columns == target_schema_simple: + return + + logging.warning(f"'{table_name}' 테이블의 스키마 변경을 감지했습니다. 마이그레이션을 시작합니다. (데이터 손실 위험)") + + temp_table_name = f"{table_name}_temp_old" + cursor.execute(f"ALTER TABLE {table_name} RENAME TO {temp_table_name}") + + columns_with_definitions = ", ".join([f"{name} {definition}" for name, definition in target_columns.items()]) + cursor.execute(f"CREATE TABLE {table_name} ({columns_with_definitions})") + + cursor.execute(f"PRAGMA table_info({temp_table_name})") + temp_columns = {row[1] for row in cursor.fetchall()} + common_columns = ", ".join(target_columns.keys() & temp_columns) + + if common_columns: + cursor.execute(f"INSERT INTO {table_name} ({common_columns}) SELECT {common_columns} FROM {temp_table_name}") + logging.info(f"'{temp_table_name}'에서 '{table_name}'으로 데이터를 복사했습니다.") + + cursor.execute(f"DROP TABLE {temp_table_name}") + logging.info(f"임시 테이블 '{temp_table_name}'을(를) 삭제했습니다.") + + except sqlite3.Error as e: + logging.error(f"'{table_name}' 테이블 마이그레이션 중 오류 발생: {e}") + raise e + +def initialize_database(): + """ + 데이터베이스에 연결하고, 테이블 스키마를 최신 상태로 동기화합니다. + """ db_path = get_db_path() conn = None try: conn = sqlite3.connect(db_path) + conn.execute("BEGIN") cursor = conn.cursor() - # db_profile 테이블 생성 - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS db_profile ( - id VARCHAR(64) PRIMARY KEY NOT NULL, - type VARCHAR(32) NOT NULL, - host VARCHAR(255) NOT NULL, - port INTEGER NOT NULL, - name VARCHAR(64), - username VARCHAR(128) NOT NULL, - password VARCHAR(128) NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - """ - ) - # db_profile 테이블의 updated_at을 자동으로 업데이트하는 트리거 + + # --- db_profile 테이블 처리 --- + db_profile_cols = { + "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "type": "VARCHAR(32) NOT NULL", + "host": "VARCHAR(255)", + "port": "INTEGER", + "name": "VARCHAR(64)", + "username": "VARCHAR(128)", + "password": "VARCHAR(128)", + "view_name": "VARCHAR(64)", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" + } + cursor.execute(f"CREATE TABLE IF NOT EXISTS db_profile ({', '.join([f'{k} {v}' for k, v in db_profile_cols.items()])})") + _synchronize_table(cursor, "db_profile", db_profile_cols) + cursor.execute( """ CREATE TRIGGER IF NOT EXISTS update_db_profile_updated_at - BEFORE UPDATE ON db_profile - FOR EACH ROW - BEGIN - UPDATE db_profile SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; - """ - ) - - # ai_credential 테이블 생성 - cursor.execute( + BEFORE UPDATE ON db_profile FOR EACH ROW + BEGIN UPDATE db_profile SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; """ - CREATE TABLE IF NOT EXISTS ai_credential ( - id VARCHAR(64) PRIMARY KEY NOT NULL, - service_name VARCHAR(32) NOT NULL UNIQUE, - api_key VARCHAR(256) NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - """ ) - # ai_credential 테이블의 updated_at을 자동으로 업데이트하는 트리거 + + # --- ai_credential 테이블 처리 --- + ai_credential_cols = { + "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "service_name": "VARCHAR(32) NOT NULL UNIQUE", + "api_key": "VARCHAR(256) NOT NULL", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" + } + cursor.execute(f"CREATE TABLE IF NOT EXISTS ai_credential ({', '.join([f'{k} {v}' for k, v in ai_credential_cols.items()])})") + _synchronize_table(cursor, "ai_credential", ai_credential_cols) + cursor.execute( """ CREATE TRIGGER IF NOT EXISTS update_ai_credential_updated_at - BEFORE UPDATE ON ai_credential - FOR EACH ROW - BEGIN - UPDATE ai_credential SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; - """ - ) - - # chat_tab 테이블 생성 - cursor.execute( + BEFORE UPDATE ON ai_credential FOR EACH ROW + BEGIN UPDATE ai_credential SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; """ - CREATE TABLE IF NOT EXISTS chat_tab ( - id VARCHAR(64) PRIMARY KEY NOT NULL, - name VARCHAR(128), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - """ ) - # chat_tab 테이블의 updated_at을 자동으로 업데이트하는 트리거 + + # --- chat_tab 테이블 처리 --- + chat_tab_cols = { + "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "name": "VARCHAR(128)", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" + } + cursor.execute(f"CREATE TABLE IF NOT EXISTS chat_tab ({', '.join([f'{k} {v}' for k, v in chat_tab_cols.items()])})") + _synchronize_table(cursor, "chat_tab", chat_tab_cols) cursor.execute( """ CREATE TRIGGER IF NOT EXISTS update_chat_tab_updated_at - BEFORE UPDATE ON chat_tab - FOR EACH ROW - BEGIN - UPDATE chat_tab SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; - """ - ) - - # chat_message 테이블 생성 - cursor.execute( + BEFORE UPDATE ON chat_tab FOR EACH ROW + BEGIN UPDATE chat_tab SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; """ - CREATE TABLE IF NOT EXISTS chat_message ( - id VARCHAR(64) PRIMARY KEY NOT NULL, - chat_tab_id VARCHAR(64) NOT NULL, - sender VARCHAR(1) NOT NULL, - message TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (chat_tab_id) REFERENCES chat_tab(id) - ); - """ ) - # chat_message 테이블의 updated_at을 자동으로 업데이트하는 트리거 + + # --- chat_message 테이블 처리 --- + chat_message_cols = { + "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "chat_tab_id": "VARCHAR(64) NOT NULL", + "sender": "VARCHAR(1) NOT NULL", + "message": "TEXT NOT NULL", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "FOREIGN KEY (chat_tab_id)": "REFERENCES chat_tab(id)" + } + create_chat_message_sql = ", ".join([f"{k} {v}" for k, v in chat_message_cols.items() if not k.startswith("FOREIGN KEY")]) + create_chat_message_sql += f", FOREIGN KEY (chat_tab_id) REFERENCES chat_tab(id)" + cursor.execute(f"CREATE TABLE IF NOT EXISTS chat_message ({create_chat_message_sql})") + _synchronize_table(cursor, "chat_message", {k: v for k, v in chat_message_cols.items() if not k.startswith("FOREIGN KEY")}) + cursor.execute( """ CREATE TRIGGER IF NOT EXISTS update_chat_message_updated_at - BEFORE UPDATE ON chat_message - FOR EACH ROW - BEGIN - UPDATE chat_message SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; - """ - ) - - # query_history 테이블 생성 - cursor.execute( + BEFORE UPDATE ON chat_message FOR EACH ROW + BEGIN UPDATE chat_message SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; """ - CREATE TABLE IF NOT EXISTS query_history ( - id VARCHAR(64) PRIMARY KEY NOT NULL, - chat_message_id VARCHAR(64) NOT NULL, - query_text TEXT NOT NULL, - is_success VARCHAR(1) NOT NULL, - error_message TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (chat_message_id) REFERENCES chat_message(id) - ); - """ ) - # query_history 테이블의 updated_at을 자동으로 업데이트하는 트리거 + + # --- query_history 테이블 처리 --- + query_history_cols = { + "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "chat_message_id": "VARCHAR(64) NOT NULL", + "query_text": "TEXT NOT NULL", + "is_success": "VARCHAR(1) NOT NULL", + "error_message": "TEXT NOT NULL", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "FOREIGN KEY (chat_message_id)": "REFERENCES chat_message(id)" + } + create_query_history_sql = ", ".join([f"{k} {v}" for k, v in query_history_cols.items() if not k.startswith("FOREIGN KEY")]) + create_query_history_sql += f", FOREIGN KEY (chat_message_id) REFERENCES chat_message(id)" + cursor.execute(f"CREATE TABLE IF NOT EXISTS query_history ({create_query_history_sql})") + _synchronize_table(cursor, "query_history", {k: v for k, v in query_history_cols.items() if not k.startswith("FOREIGN KEY")}) + cursor.execute( """ CREATE TRIGGER IF NOT EXISTS update_query_history_updated_at - BEFORE UPDATE ON query_history - FOR EACH ROW - BEGIN - UPDATE query_history SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; - """ + BEFORE UPDATE ON query_history FOR EACH ROW + BEGIN UPDATE query_history SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; + """ ) conn.commit() except sqlite3.Error as e: - print(f"데이터베이스 초기화 중 오류 발생: {e}") + logging.error(f"데이터베이스 초기화 중 오류 발생: {e}. 변경 사항을 롤백합니다.") + if conn: + conn.rollback() finally: if conn: conn.close() From b23b8c1f48e8d8a8cdf8bf26fc0cdaaa6e68e054 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 9 Aug 2025 00:15:18 +0900 Subject: [PATCH 116/220] =?UTF-8?q?refactor:=20result=EB=A5=BC=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=EC=9C=BC=EB=A1=9C=20=EC=82=AC=EC=9A=A9=20=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/user_db/connect_test_result_model.py | 10 ---------- app/schemas/user_db/result_model.py | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 10 deletions(-) delete mode 100644 app/schemas/user_db/connect_test_result_model.py create mode 100644 app/schemas/user_db/result_model.py diff --git a/app/schemas/user_db/connect_test_result_model.py b/app/schemas/user_db/connect_test_result_model.py deleted file mode 100644 index 5f68f38..0000000 --- a/app/schemas/user_db/connect_test_result_model.py +++ /dev/null @@ -1,10 +0,0 @@ -# app/schemas/user_db/connect_test_result_model.py - -from pydantic import BaseModel, Field - -from app.core.status import CommonCode - - -class TestConnectionResult(BaseModel): - is_successful: bool = Field(..., description="성공 여부") - code: CommonCode = Field(None, description="결과 코드") diff --git a/app/schemas/user_db/result_model.py b/app/schemas/user_db/result_model.py new file mode 100644 index 0000000..edc3d47 --- /dev/null +++ b/app/schemas/user_db/result_model.py @@ -0,0 +1,15 @@ +# app/schemas/user_db/result_model.py + +from pydantic import BaseModel, Field + +from app.core.status import CommonCode + +# 기본 반환 모델 +class BasicResult(BaseModel): + is_successful: bool = Field(..., description="성공 여부") + code: CommonCode = Field(None, description="결과 코드") + +# 디비 정보 저장 +class SaveProfileResult(BasicResult): + """DB 조회 결과를 위한 확장 모델""" + name: str = Field(..., description="저장된 디비명") From c6ad827042cd47bd708962bb992d2e6ada91f06b Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 9 Aug 2025 00:16:09 +0900 Subject: [PATCH 117/220] =?UTF-8?q?refactor:=20db=5Fprofile=EC=9D=84=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=EC=9C=BC=EB=A1=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/user_db/db_profile_model.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/schemas/user_db/db_profile_model.py b/app/schemas/user_db/db_profile_model.py index 616156c..14e1341 100644 --- a/app/schemas/user_db/db_profile_model.py +++ b/app/schemas/user_db/db_profile_model.py @@ -10,14 +10,13 @@ # 사용자가 직접 입력해야 하는 정보만 포함합니다. -class DBProfileCreate(BaseModel): +class DBProfileInfo(BaseModel): type: str = Field(..., description="DB 종류") host: str | None = Field(None, description="호스트 주소") port: int | None = Field(None, description="포트 번호") + name: str | None = Field(None, description="연결할 데이터베이스명") username: str | None = Field(None, description="사용자 이름") password: str | None = Field(None, description="비밀번호") - name: str | None = Field(None, description="데이터베이스 이름") - driver: str | None = Field(None, description="드라이버 이름") def validate_required_fields(self) -> None: """DB 종류별 필수 필드 유효성 검사""" @@ -54,6 +53,9 @@ def _is_empty(value: Any | None) -> bool: return True return False +class SaveDBProfile(DBProfileInfo): + id: str | None = Field(None, description="DB Key 값") + view_name: str | None = Field(None, description="DB 노출명") # DB에서 조회되는 모든 정보를 담는 클래스입니다. class DBProfile(BaseModel): From 5b457ad4bdb0d9d98582b92b4bea4545bfd7694d Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 9 Aug 2025 00:25:35 +0900 Subject: [PATCH 118/220] =?UTF-8?q?style:=20name=EC=9D=84=20view=5Fname?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EC=88=98=20=EB=AA=85=EC=B9=AD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/user_db/result_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/schemas/user_db/result_model.py b/app/schemas/user_db/result_model.py index edc3d47..e3461d7 100644 --- a/app/schemas/user_db/result_model.py +++ b/app/schemas/user_db/result_model.py @@ -12,4 +12,4 @@ class BasicResult(BaseModel): # 디비 정보 저장 class SaveProfileResult(BasicResult): """DB 조회 결과를 위한 확장 모델""" - name: str = Field(..., description="저장된 디비명") + view_name: str = Field(..., description="저장된 디비명") From be92d224b9cc01764e03c33dc75eb13f49ffee6e Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 9 Aug 2025 00:26:12 +0900 Subject: [PATCH 119/220] =?UTF-8?q?feat:=20db=20profile=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=98=EB=8A=94=20=EB=B6=80=EB=B6=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/user_db_api.py | 24 ++++++++++-- app/repository/user_db_repository.py | 55 +++++++++++++++++++++++++--- app/services/user_db_service.py | 32 ++++++++++++---- 3 files changed, 95 insertions(+), 16 deletions(-) diff --git a/app/api/user_db_api.py b/app/api/user_db_api.py index f8fec19..37968f3 100644 --- a/app/api/user_db_api.py +++ b/app/api/user_db_api.py @@ -4,8 +4,9 @@ from app.core.exceptions import APIException from app.core.response import ResponseMessage -from app.schemas.user_db.db_profile_model import DBProfileCreate +from app.schemas.user_db.db_profile_model import DBProfileInfo, SaveDBProfile from app.services.user_db_service import UserDbService, user_db_service +from app.schemas.user_db.result_model import SaveProfileResult user_db_service_dependency = Depends(lambda: user_db_service) @@ -18,13 +19,30 @@ summary="DB 연결 테스트", ) def connection_test( - db_info: DBProfileCreate, + db_info: DBProfileInfo, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[bool]: """DB 연결 정보를 받아 연결 가능 여부를 테스트합니다.""" db_info.validate_required_fields() - result = service.connection_test(db_info) + if not result.is_successful: raise APIException(result.code) return ResponseMessage.success(value=result.is_successful, code=result.code) + +@router.post( + "/save/profile", + response_model=ResponseMessage[str], + summary="DB 프로필 저장", +) +def save_profile( + save_db_info: SaveDBProfile, + service: UserDbService = user_db_service_dependency, +) -> ResponseMessage[str]: + """DB 연결 정보를 저장합니다.""" + save_db_info.validate_required_fields() + result = service.save_profile(save_db_info) + + if not result.is_successful: + raise APIException(result.code) + return ResponseMessage.success(value=result.view_name, code=result.code) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index d77d789..7ec4fc3 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -1,13 +1,19 @@ from typing import Any import oracledb +import sqlite3 from app.core.status import CommonCode -from app.schemas.user_db.connect_test_result_model import TestConnectionResult - +from app.schemas.user_db.result_model import BasicResult, SaveProfileResult +from app.schemas.user_db.db_profile_model import SaveDBProfile +from app.core.utils import get_db_path class UserDbRepository: - def test_db_connection(self, driver_module: Any, **kwargs: Any) -> TestConnectionResult: + def connection_test( + self, + driver_module: Any, + **kwargs: Any + ) -> BasicResult: """ DB 드라이버와 연결에 필요한 매개변수들을 받아 연결을 테스트합니다. """ @@ -27,13 +33,50 @@ def test_db_connection(self, driver_module: Any, **kwargs: Any) -> TestConnectio else: connection = driver_module.connect(**kwargs) - return TestConnectionResult(is_successful=True, code=CommonCode.SUCCESS_USER_DB_CONNECT_TEST) + return BasicResult(is_successful=True, code=CommonCode.SUCCESS_USER_DB_CONNECT_TEST) + except (AttributeError, driver_module.OperationalError, driver_module.DatabaseError) as e: + return BasicResult(is_successful=False, code=CommonCode.FAIL_CONNECT_DB) + except Exception as e: + return BasicResult(is_successful=False, code=CommonCode.FAIL) + finally: + if connection: + connection.close() + def save_profile( + self, + save_db_info: SaveDBProfile + ) -> SaveProfileResult: + """ + DB 드라이버와 연결에 필요한 매개변수들을 받아 연결을 테스트합니다. + """ + db_path = get_db_path() + connection = None + try: + connection = sqlite3.connect(db_path) + cursor = connection.cursor() + profile_dict = save_db_info.model_dump() + + columns_to_insert = { + key: value for key, value in profile_dict.items() if value is not None + } + + columns = ", ".join(columns_to_insert.keys()) + placeholders = ", ".join(["?"] * len(columns_to_insert)) + + sql = f"INSERT INTO db_profile ({columns}) VALUES ({placeholders})" + data_to_insert = tuple(columns_to_insert.values()) + + cursor.execute(sql, data_to_insert) + connection.commit() + name = save_db_info.view_name if save_db_info.view_name else save_db_info.type + + return SaveProfileResult(is_successful=True, code=CommonCode.SUCCESS_SAVE_DB_PROFILE, view_name=name) + except sqlite3.Error: + return SaveProfileResult(is_successful=False, code=CommonCode.FAIL_SAVE_PROFILE) except Exception: - return TestConnectionResult(is_successful=False, code=CommonCode.FAIL_CONNECT_DB) + return SaveProfileResult(is_successful=False, code=CommonCode.FAIL_SAVE_PROFILE) finally: if connection: connection.close() - user_db_repository = UserDbRepository() diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index 50b5b9f..4beb961 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -10,24 +10,42 @@ from app.core.exceptions import APIException from app.core.status import CommonCode from app.repository.user_db_repository import UserDbRepository, user_db_repository -from app.schemas.user_db.connect_test_result_model import TestConnectionResult -from app.schemas.user_db.db_profile_model import DBProfileCreate +from app.schemas.user_db.result_model import BasicResult, SaveProfileResult +from app.schemas.user_db.db_profile_model import DBProfileInfo, SaveDBProfile +from app.core.utils import generate_prefixed_uuid +from app.core.enum.db_key_prefix_name import DBSaveIdEnum user_db_repository_dependency = Depends(lambda: user_db_repository) class UserDbService: def connection_test( - self, db_info: DBProfileCreate, repository: UserDbRepository = user_db_repository - ) -> TestConnectionResult: + self, + db_info: DBProfileInfo, + repository: UserDbRepository = user_db_repository + ) -> BasicResult: """ DB 연결 정보를 받아 연결 테스트를 수행하고 결과를 객체로 반환합니다. """ try: driver_module = self._get_driver_module(db_info.type) connect_kwargs = self._prepare_connection_args(db_info) - return repository.test_db_connection(driver_module, **connect_kwargs) - except (ValueError, ImportError) as e: + return repository.connection_test(driver_module, **connect_kwargs) + except Exception as e: + raise APIException(CommonCode.FAIL) from e + + def save_profile( + self, + save_db_info: SaveDBProfile, + repository: UserDbRepository = user_db_repository + ) -> SaveProfileResult: + """ + DB 연결 정보를 저장 후 결과를 객체로 반환합니다. + """ + save_db_info.id = generate_prefixed_uuid(DBSaveIdEnum.user_db.value) + try: + return repository.save_profile(save_db_info) + except Exception as e: raise APIException(CommonCode.FAIL) from e def _get_driver_module(self, db_type: str): @@ -39,7 +57,7 @@ def _get_driver_module(self, db_type: str): return sqlite3 return importlib.import_module(driver_name) - def _prepare_connection_args(self, db_info: DBProfileCreate) -> dict[str, Any]: + def _prepare_connection_args(self, db_info: DBProfileInfo) -> dict[str, Any]: """ DB 타입에 따라 연결에 필요한 매개변수를 딕셔너리로 구성합니다. """ From ad016e19c03bee73a935183bf09dd21d0522a089 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 9 Aug 2025 01:00:32 +0900 Subject: [PATCH 120/220] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=EB=B0=A9?= =?UTF-8?q?=EC=83=9D=ED=95=98=EB=8A=94=20=EB=A1=9C=EA=B7=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=B0=8D=EC=96=B4=EC=A3=BC=EB=8A=94=20logging=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/all_logging.py | 33 +++++++++++++++++++++++++++++++++ app/main.py | 6 ++++++ 2 files changed, 39 insertions(+) create mode 100644 app/core/all_logging.py diff --git a/app/core/all_logging.py b/app/core/all_logging.py new file mode 100644 index 0000000..c31d4eb --- /dev/null +++ b/app/core/all_logging.py @@ -0,0 +1,33 @@ +# app/core/all_logging.py + +import logging +from fastapi import Request + +# 로깅 기본 설정 (애플리케이션 시작 시 한 번만 구성) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", # [수정] 로그 레벨(INFO, ERROR)을 포함 + datefmt="%Y-%m-%d %H:%M:%S", +) + + +async def log_requests_middleware(request: Request, call_next): + """ + 모든 API 요청과 에러에 대한 로그를 남기는 미들웨어입니다. + """ + endpoint = f"{request.method} {request.url.path}" + + # 일반 요청 로그를 남깁니다. + logging.info(f"엔드포인트: {endpoint}") + + try: + # 다음 미들웨어 또는 실제 엔드포인트를 호출합니다. + response = await call_next(request) + return response + except Exception as e: + # [수정] 에러 발생 시, exc_info=True를 추가하여 전체 트레이스백을 함께 기록합니다. + # 메시지 형식도 "ERROR 엔드포인트:"로 변경합니다. + logging.error(f"ERROR 엔드포인트: {endpoint}", exc_info=True) + # 예외를 다시 발생시켜 FastAPI의 전역 예외 처리기가 최종 응답을 만들도록 합니다. + raise e + diff --git a/app/main.py b/app/main.py index 198e5fd..01e9da2 100644 --- a/app/main.py +++ b/app/main.py @@ -14,8 +14,14 @@ ) from app.db.init_db import initialize_database +from starlette.middleware.base import BaseHTTPMiddleware +from app.core.all_logging import log_requests_middleware + app = FastAPI() +# 전체 로그 찍는 부분 +app.add_middleware(BaseHTTPMiddleware, dispatch=log_requests_middleware) + # 전역 예외 처리기 등록 app.add_exception_handler(Exception, generic_exception_handler) app.add_exception_handler(APIException, api_exception_handler) From a24d3da2bb9019fb85df235f7c1c14de4483e296 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 9 Aug 2025 02:14:28 +0900 Subject: [PATCH 121/220] =?UTF-8?q?feat:=20db=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=83=81=ED=83=9C=20=EA=B0=92=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/core/status.py b/app/core/status.py index d4359e9..415106a 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -20,7 +20,9 @@ class CommonCode(Enum): """ DRIVER, DB 성공 코드 - 21xx """ SUCCESS_DRIVER_INFO = (status.HTTP_200_OK, "2100", "드라이버 정보 조회를 성공하였습니다.") SUCCESS_USER_DB_CONNECT_TEST = (status.HTTP_200_OK, "2101", "테스트 연결을 성공하였습니다.") - SUCCESS_SAVE_DB_PROFILE = (status.HTTP_200_OK, "2102", "DB 연결 정보를 저장하였습니다.") + SUCCESS_FIND_ALL_PROFILE = (status.HTTP_200_OK, "2102", "DB 정보 조회를 성공하였습니다.") + SUCCESS_SAVE_DB_PROFILE = (status.HTTP_200_OK, "2132", "DB 연결 정보를 저장하였습니다.") + """ KEY 성공 코드 - 22xx """ @@ -89,6 +91,7 @@ class CommonCode(Enum): """ DRIVER, DB 서버 에러 코드 - 51xx """ FAIL_CONNECT_DB = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 연결 중 에러가 발생했습니다.") FAIL_SAVE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 정보 저장 중 에러가 발생했습니다.") + FAIL_FIND_ALL_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 정보 조회 중 에러가 발생했습니다.") """ KEY 서버 에러 코드 - 52xx """ From 2df2757400bdf39d5aa88d6c73b31213489ee781 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 9 Aug 2025 02:15:33 +0900 Subject: [PATCH 122/220] =?UTF-8?q?refactor:=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EB=B6=80=EB=B6=84=20result=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/user_db/db_profile_model.py | 14 -------------- app/schemas/user_db/result_model.py | 24 +++++++++++++++++++++++- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/app/schemas/user_db/db_profile_model.py b/app/schemas/user_db/db_profile_model.py index 14e1341..c7d9348 100644 --- a/app/schemas/user_db/db_profile_model.py +++ b/app/schemas/user_db/db_profile_model.py @@ -56,17 +56,3 @@ def _is_empty(value: Any | None) -> bool: class SaveDBProfile(DBProfileInfo): id: str | None = Field(None, description="DB Key 값") view_name: str | None = Field(None, description="DB 노출명") - -# DB에서 조회되는 모든 정보를 담는 클래스입니다. -class DBProfile(BaseModel): - id: str - type: str - host: str - port: int - name: str | None - username: str - created_at: datetime - updated_at: datetime - - class Config: - from_attributes = True diff --git a/app/schemas/user_db/result_model.py b/app/schemas/user_db/result_model.py index e3461d7..642e648 100644 --- a/app/schemas/user_db/result_model.py +++ b/app/schemas/user_db/result_model.py @@ -1,6 +1,8 @@ # app/schemas/user_db/result_model.py from pydantic import BaseModel, Field +from datetime import datetime +from typing import List from app.core.status import CommonCode @@ -9,7 +11,27 @@ class BasicResult(BaseModel): is_successful: bool = Field(..., description="성공 여부") code: CommonCode = Field(None, description="결과 코드") -# 디비 정보 저장 +# 디비 정보 후 반환되는 저장 모델 class SaveProfileResult(BasicResult): """DB 조회 결과를 위한 확장 모델""" view_name: str = Field(..., description="저장된 디비명") + +# DB Profile 조회되는 정보를 담는 모델입니다. +class DBProfile(BaseModel): + id: str + type: str + host: str + port: int + name: str | None + username: str + view_name: str | None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +# DB Profile 전체 조회 결과를 담는 새로운 모델 +class AllDBProfileResult(BasicResult): + """DB 프로필 전체 조회 결과를 위한 확장 모델""" + profiles: List[DBProfile] = Field([], description="DB 프로필 목록") \ No newline at end of file From dd07df67141bb9dfbda71ae68a0594f5933d2b6f Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sat, 9 Aug 2025 02:16:58 +0900 Subject: [PATCH 123/220] =?UTF-8?q?feat:=20db=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/user_db_api.py | 19 +++++++++++++++--- app/repository/user_db_repository.py | 29 +++++++++++++++++++++++++++- app/services/user_db_service.py | 18 ++++++++++++++--- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/app/api/user_db_api.py b/app/api/user_db_api.py index 37968f3..68a1754 100644 --- a/app/api/user_db_api.py +++ b/app/api/user_db_api.py @@ -1,12 +1,13 @@ # app/api/user_db_api.py from fastapi import APIRouter, Depends +from typing import List from app.core.exceptions import APIException from app.core.response import ResponseMessage from app.schemas.user_db.db_profile_model import DBProfileInfo, SaveDBProfile from app.services.user_db_service import UserDbService, user_db_service -from app.schemas.user_db.result_model import SaveProfileResult +from app.schemas.user_db.result_model import DBProfile user_db_service_dependency = Depends(lambda: user_db_service) @@ -22,7 +23,6 @@ def connection_test( db_info: DBProfileInfo, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[bool]: - """DB 연결 정보를 받아 연결 가능 여부를 테스트합니다.""" db_info.validate_required_fields() result = service.connection_test(db_info) @@ -39,10 +39,23 @@ def save_profile( save_db_info: SaveDBProfile, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[str]: - """DB 연결 정보를 저장합니다.""" save_db_info.validate_required_fields() result = service.save_profile(save_db_info) if not result.is_successful: raise APIException(result.code) return ResponseMessage.success(value=result.view_name, code=result.code) + +@router.get( + "/find/all", + response_model=ResponseMessage[List[DBProfile]], + summary="DB 프로필 전체 조회", +) +def find_all_profile( + service: UserDbService = user_db_service_dependency, +) -> ResponseMessage[List[DBProfile]]: + result = service.find_all_profile() + + if not result.is_successful: + raise APIException(result.code) + return ResponseMessage.success(value=result.profiles, code=result.code) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index 7ec4fc3..9ed14c7 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -4,7 +4,7 @@ import sqlite3 from app.core.status import CommonCode -from app.schemas.user_db.result_model import BasicResult, SaveProfileResult +from app.schemas.user_db.result_model import BasicResult, SaveProfileResult, AllDBProfileResult, DBProfile from app.schemas.user_db.db_profile_model import SaveDBProfile from app.core.utils import get_db_path @@ -79,4 +79,31 @@ def save_profile( if connection: connection.close() + def find_all_profile( + self + ) -> AllDBProfileResult: + """ + 모든 DB 연결 정보를 조회합니다. + """ + db_path = get_db_path() + connection = None + try: + connection = sqlite3.connect(db_path) + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + + sql = "SELECT * FROM db_profile" + cursor.execute(sql) + rows = cursor.fetchall() + profiles = [DBProfile(**row) for row in rows] + + return AllDBProfileResult(is_successful=True, code=CommonCode.SUCCESS_FIND_ALL_PROFILE, profiles=profiles) + except sqlite3.Error: + return AllDBProfileResult(is_successful=False, code=CommonCode.FAIL_FIND_ALL_PROFILE) + except Exception: + return AllDBProfileResult(is_successful=False, code=CommonCode.FAIL_FIND_ALL_PROFILE) + finally: + if connection: + connection.close() + user_db_repository = UserDbRepository() diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index 4beb961..67d05af 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -10,7 +10,7 @@ from app.core.exceptions import APIException from app.core.status import CommonCode from app.repository.user_db_repository import UserDbRepository, user_db_repository -from app.schemas.user_db.result_model import BasicResult, SaveProfileResult +from app.schemas.user_db.result_model import BasicResult, SaveProfileResult, AllDBProfileResult from app.schemas.user_db.db_profile_model import DBProfileInfo, SaveDBProfile from app.core.utils import generate_prefixed_uuid from app.core.enum.db_key_prefix_name import DBSaveIdEnum @@ -25,7 +25,7 @@ def connection_test( repository: UserDbRepository = user_db_repository ) -> BasicResult: """ - DB 연결 정보를 받아 연결 테스트를 수행하고 결과를 객체로 반환합니다. + DB 연결 정보를 받아 연결 테스트를 수행 후 결과를 반환합니다. """ try: driver_module = self._get_driver_module(db_info.type) @@ -40,7 +40,7 @@ def save_profile( repository: UserDbRepository = user_db_repository ) -> SaveProfileResult: """ - DB 연결 정보를 저장 후 결과를 객체로 반환합니다. + DB 연결 정보를 저장 후 결과를 반환합니다. """ save_db_info.id = generate_prefixed_uuid(DBSaveIdEnum.user_db.value) try: @@ -48,6 +48,18 @@ def save_profile( except Exception as e: raise APIException(CommonCode.FAIL) from e + def find_all_profile( + self, + repository: UserDbRepository = user_db_repository + ) -> AllDBProfileResult: + """ + 모든 DB 연결 정보를 반환합니다. + """ + try: + return repository.find_all_profile() + except Exception as e: + raise APIException(CommonCode.FAIL) from e + def _get_driver_module(self, db_type: str): """ DB 타입에 따라 동적으로 드라이버 모듈을 로드합니다. From 05b5030164a468423fd3bcaa1f18e9c6b61c44c5 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 17:52:21 +0900 Subject: [PATCH 124/220] =?UTF-8?q?feat:=20=EB=B0=98=ED=99=98=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B0=92=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index 415106a..2f3f383 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -20,8 +20,11 @@ class CommonCode(Enum): """ DRIVER, DB 성공 코드 - 21xx """ SUCCESS_DRIVER_INFO = (status.HTTP_200_OK, "2100", "드라이버 정보 조회를 성공하였습니다.") SUCCESS_USER_DB_CONNECT_TEST = (status.HTTP_200_OK, "2101", "테스트 연결을 성공하였습니다.") - SUCCESS_FIND_ALL_PROFILE = (status.HTTP_200_OK, "2102", "DB 정보 조회를 성공하였습니다.") - SUCCESS_SAVE_DB_PROFILE = (status.HTTP_200_OK, "2132", "DB 연결 정보를 저장하였습니다.") + SUCCESS_FIND_PROFILE = (status.HTTP_200_OK, "2102", "디비 정보 조회를 성공하였습니다.") + SUCCESS_FIND_SCHEMAS = (status.HTTP_200_OK, "2103", "디비 스키마 정보 조회를 성공하였습니다.") + SUCCESS_FIND_TABLES = (status.HTTP_200_OK, "2104", "디비 테이블 정보 조회를 성공하였습니다.") + SUCCESS_FIND_COLUMNS = (status.HTTP_200_OK, "2105", "디비 컬럼 정보 조회를 성공하였습니다.") + SUCCESS_SAVE_DB_PROFILE = (status.HTTP_200_OK, "2130", "디비 연결 정보를 저장하였습니다.") """ KEY 성공 코드 - 22xx """ @@ -90,8 +93,11 @@ class CommonCode(Enum): """ DRIVER, DB 서버 에러 코드 - 51xx """ FAIL_CONNECT_DB = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 연결 중 에러가 발생했습니다.") - FAIL_SAVE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 정보 저장 중 에러가 발생했습니다.") - FAIL_FIND_ALL_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 정보 조회 중 에러가 발생했습니다.") + FAIL_SAVE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5101", "디비 정보 저장 중 에러가 발생했습니다.") + FAIL_FIND_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5102", "디비 정보 조회 중 에러가 발생했습니다.") + FAIL_FIND_SCHEMAS = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5103", "디비 스키마 정보 조회 중 에러가 발생했습니다.") + FAIL_FIND_TABLES = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5104", "디비 테이블 정보 조회 중 에러가 발생했습니다.") + FAIL_FIND_COLUMNS = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5105", "디비 컬럼 정보 조회 중 에러가 발생했습니다.") """ KEY 서버 에러 코드 - 52xx """ From 846a7e7827d027aabbb6756d2d4552e6ede56ea2 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 17:52:45 +0900 Subject: [PATCH 125/220] =?UTF-8?q?feat:=20=EB=B0=98=ED=99=98=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/user_db/db_profile_model.py | 7 ++++- app/schemas/user_db/result_model.py | 38 +++++++++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/app/schemas/user_db/db_profile_model.py b/app/schemas/user_db/db_profile_model.py index c7d9348..ad312b1 100644 --- a/app/schemas/user_db/db_profile_model.py +++ b/app/schemas/user_db/db_profile_model.py @@ -17,7 +17,6 @@ class DBProfileInfo(BaseModel): name: str | None = Field(None, description="연결할 데이터베이스명") username: str | None = Field(None, description="사용자 이름") password: str | None = Field(None, description="비밀번호") - def validate_required_fields(self) -> None: """DB 종류별 필수 필드 유효성 검사""" required_fields_by_type = { @@ -56,3 +55,9 @@ def _is_empty(value: Any | None) -> bool: class SaveDBProfile(DBProfileInfo): id: str | None = Field(None, description="DB Key 값") view_name: str | None = Field(None, description="DB 노출명") + +class AllDBProfileInfo(DBProfileInfo): + id: str | None = Field(..., description="DB Key 값") + view_name: str | None = Field(None, description="DB 노출명") + created_at: datetime = Field(..., description="profile 저장일") + updated_at: datetime = Field(..., description="profile 수정일") diff --git a/app/schemas/user_db/result_model.py b/app/schemas/user_db/result_model.py index 642e648..7b8f366 100644 --- a/app/schemas/user_db/result_model.py +++ b/app/schemas/user_db/result_model.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field from datetime import datetime -from typing import List +from typing import List, Any from app.core.status import CommonCode @@ -20,10 +20,10 @@ class SaveProfileResult(BasicResult): class DBProfile(BaseModel): id: str type: str - host: str - port: int + host: str | None + port: int | None name: str | None - username: str + username: str | None view_name: str | None created_at: datetime updated_at: datetime @@ -34,4 +34,32 @@ class Config: # DB Profile 전체 조회 결과를 담는 새로운 모델 class AllDBProfileResult(BasicResult): """DB 프로필 전체 조회 결과를 위한 확장 모델""" - profiles: List[DBProfile] = Field([], description="DB 프로필 목록") \ No newline at end of file + profiles: List[DBProfile] = Field([], description="DB 프로필 목록") + +class ColumnInfo(BaseModel): + """단일 컬럼의 상세 정보를 담는 모델""" + name: str = Field(..., description="컬럼 이름") + type: str = Field(..., description="데이터 타입") + nullable: bool = Field(..., description="NULL 허용 여부") + default: Any | None = Field(None, description="기본값") + comment: str | None = Field(None, description="코멘트") + is_pk: bool = Field(False, description="기본 키(Primary Key) 여부") + +class TableInfo(BaseModel): + """단일 테이블의 이름과 컬럼 목록을 담는 모델""" + name: str = Field(..., description="테이블 이름") + columns: List[ColumnInfo] = Field([], description="컬럼 목록") + comment: str | None = Field(None, description="테이블 코멘트") + +class SchemaInfoResult(BasicResult): + """DB 스키마 상세 정보 조회 결과를 위한 확장 모델""" + schema: List[TableInfo] = Field([], description="테이블 및 컬럼 정보 목록") + +class SchemaListResult(BasicResult): + schemas: List[str] = Field([], description="스키마 이름 목록") + +class TableListResult(BasicResult): + tables: List[str] = Field([], description="테이블 이름 목록") + +class ColumnListResult(BasicResult): + columns: List[ColumnInfo] = Field([], description="컬럼 정보 목록") From e746eed5da3373c2112dd517ff69ecfee065b884 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 17:53:05 +0900 Subject: [PATCH 126/220] =?UTF-8?q?feat:=20=EC=8A=A4=ED=82=A4=EB=A7=88,=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94,=20=EC=BB=AC=EB=9F=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/user_db_api.py | 58 ++++++- app/repository/user_db_repository.py | 226 ++++++++++++++++++++++++++- app/services/user_db_service.py | 172 +++++++++++++++++++- 3 files changed, 446 insertions(+), 10 deletions(-) diff --git a/app/api/user_db_api.py b/app/api/user_db_api.py index 68a1754..e1bf203 100644 --- a/app/api/user_db_api.py +++ b/app/api/user_db_api.py @@ -7,7 +7,7 @@ from app.core.response import ResponseMessage from app.schemas.user_db.db_profile_model import DBProfileInfo, SaveDBProfile from app.services.user_db_service import UserDbService, user_db_service -from app.schemas.user_db.result_model import DBProfile +from app.schemas.user_db.result_model import DBProfile, TableInfo, ColumnInfo, SchemaListResult, TableListResult, ColumnListResult user_db_service_dependency = Depends(lambda: user_db_service) @@ -39,6 +39,7 @@ def save_profile( save_db_info: SaveDBProfile, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[str]: + save_db_info.validate_required_fields() result = service.save_profile(save_db_info) @@ -54,8 +55,63 @@ def save_profile( def find_all_profile( service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[List[DBProfile]]: + result = service.find_all_profile() if not result.is_successful: raise APIException(result.code) return ResponseMessage.success(value=result.profiles, code=result.code) + +@router.get( + "/find/schemas/{profile_id}", + response_model=ResponseMessage[List[str]], + summary="특정 DB의 전체 스키마 조회", +) +def find_schemas( + profile_id: str, + service: UserDbService = user_db_service_dependency +) -> ResponseMessage[List[str]]: + + db_info = service.find_profile(profile_id) + result = service.find_schemas(db_info) + + if not result.is_successful: + raise APIException(result.code) + return ResponseMessage.success(value=result.schemas, code=result.code) + +@router.get( + "/find/tables/{profile_id}/{schema_name}", + response_model=ResponseMessage[List[str]], + summary="특정 스키마의 전체 테이블 조회", +) +def find_tables( + profile_id: str, + schema_name: str, + service: UserDbService = user_db_service_dependency +) -> ResponseMessage[List[str]]: + + db_info = service.find_profile(profile_id) + result = service.find_tables(db_info, schema_name) + + if not result.is_successful: + raise APIException(result.code) + return ResponseMessage.success(value=result.tables, code=result.code) + +@router.get( + "/find/columns/{profile_id}/{schema_name}/{table_name}", + response_model=ResponseMessage[List[ColumnInfo]], + summary="특정 테이블의 전체 컬럼 조회", +) +def find_columns( + profile_id: str, + schema_name: str, + table_name: str, + service: UserDbService = user_db_service_dependency +) -> ResponseMessage[List[ColumnInfo]]: + + db_info = service.find_profile(profile_id) + result = service.find_columns(db_info, schema_name, table_name) + + if not result.is_successful: + raise APIException(result.code) + return ResponseMessage.success(value=result.columns, code=result.code) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index 9ed14c7..2f156c0 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -4,9 +4,22 @@ import sqlite3 from app.core.status import CommonCode -from app.schemas.user_db.result_model import BasicResult, SaveProfileResult, AllDBProfileResult, DBProfile -from app.schemas.user_db.db_profile_model import SaveDBProfile from app.core.utils import get_db_path +from app.core.exceptions import APIException +from app.schemas.user_db.result_model import ( + DBProfile, + BasicResult, + SaveProfileResult, + AllDBProfileResult, + SchemaListResult, + TableListResult, + ColumnListResult, + ColumnInfo +) +from app.schemas.user_db.db_profile_model import ( + SaveDBProfile, + AllDBProfileInfo +) class UserDbRepository: def connection_test( @@ -92,18 +105,219 @@ def find_all_profile( connection.row_factory = sqlite3.Row cursor = connection.cursor() - sql = "SELECT * FROM db_profile" + sql = """ + SELECT + id, + type, + host, + port, + name, + username, + view_name, + created_at, + updated_at + FROM db_profile + """ cursor.execute(sql) rows = cursor.fetchall() profiles = [DBProfile(**row) for row in rows] - return AllDBProfileResult(is_successful=True, code=CommonCode.SUCCESS_FIND_ALL_PROFILE, profiles=profiles) + return AllDBProfileResult(is_successful=True, code=CommonCode.SUCCESS_FIND_PROFILE, profiles=profiles) except sqlite3.Error: - return AllDBProfileResult(is_successful=False, code=CommonCode.FAIL_FIND_ALL_PROFILE) + return AllDBProfileResult(is_successful=False, code=CommonCode.FAIL_FIND_PROFILE) except Exception: - return AllDBProfileResult(is_successful=False, code=CommonCode.FAIL_FIND_ALL_PROFILE) + return AllDBProfileResult(is_successful=False, code=CommonCode.FAIL_FIND_PROFILE) finally: if connection: connection.close() + def find_profile( + self, + profile_id + ) -> AllDBProfileInfo: + """ + 특정 DB 연결 정보를 조회합니다. + """ + db_path = get_db_path() + connection = None + try: + connection = sqlite3.connect(db_path) + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + + sql = """ + SELECT + id, + type, + host, + port, + name, + username, + password, + view_name, + created_at, + updated_at + FROM db_profile + WHERE id = ? + """ + cursor.execute(sql, (profile_id,)) + row = cursor.fetchone() + + return AllDBProfileInfo(**dict(row)) + except sqlite3.Error: + raise APIException(CommonCode.FAIL_FIND_PROFILE) + except Exception: + raise APIException(CommonCode.FAIL) + finally: + if connection: + connection.close() + + # ───────────────────────────── + # 스키마 조회 + # ───────────────────────────── + def find_schemas( + self, + driver_module: Any, + schema_query: str, + **kwargs: Any + ) -> SchemaListResult: + connection = None + try: + connection = self._connect(driver_module, **kwargs) + cursor = connection.cursor() + + if not schema_query: + return SchemaListResult( + is_successful=True, + code=CommonCode.SUCCESS_FIND_SCHEMAS, + schemas=["main"] + ) + + cursor.execute(schema_query) + schemas = [row[0] for row in cursor.fetchall()] + + return SchemaListResult(is_successful=True, code=CommonCode.SUCCESS_FIND_SCHEMAS, schemas=schemas) + except Exception: + return SchemaListResult(is_successful=False, code=CommonCode.FAIL_FIND_SCHEMAS, schemas=[]) + finally: + if connection: + connection.close() + + # ───────────────────────────── + # 테이블 조회 + # ───────────────────────────── + def find_tables( + self, + driver_module: Any, + table_query: str, + schema_name: str, + **kwargs: Any + ) -> TableListResult: + connection = None + try: + connection = self._connect(driver_module, **kwargs) + cursor = connection.cursor() + + if "%s" in table_query or "?" in table_query: + cursor.execute(table_query, (schema_name,)) + elif ":owner" in table_query: + cursor.execute(table_query, {"owner": schema_name}) + else: + cursor.execute(table_query) + + tables = [row[0] for row in cursor.fetchall()] + + return TableListResult(is_successful=True, code=CommonCode.SUCCESS_FIND_TABLES, tables=tables) + except Exception: + return TableListResult(is_successful=False, code=CommonCode.FAIL_FIND_TABLES, tables=[]) + finally: + if connection: + connection.close() + + # ───────────────────────────── + # 컬럼 조회 + # ───────────────────────────── + def find_columns( + self, + driver_module: Any, + column_query: str, + schema_name: str, + db_type, + table_name: str, + **kwargs: Any + ) -> ColumnListResult: + connection = None + try: + connection = self._connect(driver_module, **kwargs) + cursor = connection.cursor() + + if db_type == "sqlite": + # SQLite는 PRAGMA를 직접 실행 + pragma_sql = f"PRAGMA table_info('{table_name}')" + cursor.execute(pragma_sql) + columns_raw = cursor.fetchall() + columns = [ + ColumnInfo( + name=c[1], + type=c[2], + nullable=(c[3] == 0), # notnull == 0 means nullable + default=c[4], + comment=None, + is_pk=(c[5] == 1) + ) + for c in columns_raw + ] + else: + if "%s" in column_query or "?" in column_query: + cursor.execute(column_query, (schema_name, table_name)) + elif ":owner" in column_query and ":table" in column_query: + owner_bind = schema_name.upper() if schema_name else schema_name + table_bind = table_name.upper() if table_name else table_name + try: + cursor.execute(column_query, {"owner": owner_bind, "table": table_bind}) + except Exception: + # fallback: try positional binds (:1, :2) if named binds fail + try: + pos_query = column_query.replace(":owner", ":1").replace(":table", ":2") + cursor.execute(pos_query, [owner_bind, table_bind]) + except Exception: + # re-raise to be handled by outer exception handler + raise + else: + cursor.execute(column_query) + + columns = [ + ColumnInfo( + name=c[0], + type=c[1], + nullable=(c[2] in ["YES", "Y", True]), + default=c[3], + comment=c[4] if len(c) > 4 else None, + is_pk=(c[5] in ["PRI", True] if len(c) > 5 else False) + ) + for c in cursor.fetchall() + ] + + return ColumnListResult(is_successful=True, code=CommonCode.SUCCESS_FIND_COLUMNS, columns=columns) + except Exception: + return ColumnListResult(is_successful=False, code=CommonCode.FAIL_FIND_COLUMNS, columns=[]) + finally: + if connection: + connection.close() + + # ───────────────────────────── + # DB 연결 메서드 + # ───────────────────────────── + def _connect(self, driver_module: Any, **kwargs): + if driver_module is oracledb: + if kwargs.get("user", "").lower() == "sys": + kwargs["mode"] = oracledb.AUTH_MODE_SYSDBA + return driver_module.connect(**kwargs) + elif "connection_string" in kwargs: + return driver_module.connect(kwargs["connection_string"]) + elif "db_name" in kwargs: + return driver_module.connect(kwargs["db_name"]) + else: + return driver_module.connect(**kwargs) + user_db_repository = UserDbRepository() diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index 67d05af..ead22e7 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -3,17 +3,27 @@ import importlib import sqlite3 from typing import Any - from fastapi import Depends from app.core.enum.db_driver import DBTypesEnum from app.core.exceptions import APIException from app.core.status import CommonCode from app.repository.user_db_repository import UserDbRepository, user_db_repository -from app.schemas.user_db.result_model import BasicResult, SaveProfileResult, AllDBProfileResult -from app.schemas.user_db.db_profile_model import DBProfileInfo, SaveDBProfile from app.core.utils import generate_prefixed_uuid from app.core.enum.db_key_prefix_name import DBSaveIdEnum +from app.schemas.user_db.result_model import ( + BasicResult, + SaveProfileResult, + AllDBProfileResult, + TableListResult, + ColumnListResult, + SchemaInfoResult +) +from app.schemas.user_db.db_profile_model import ( + DBProfileInfo, + SaveDBProfile, + AllDBProfileInfo +) user_db_repository_dependency = Depends(lambda: user_db_repository) @@ -60,6 +70,90 @@ def find_all_profile( except Exception as e: raise APIException(CommonCode.FAIL) from e + def find_profile( + self, + profile_id, + repository: UserDbRepository = user_db_repository + ) -> AllDBProfileInfo: + """ + 특정 DB 연결 정보를 반환합니다. + """ + try: + return repository.find_profile(profile_id) + except Exception as e: + raise APIException(CommonCode.FAIL) from e + + def find_schemas( + self, + db_info: AllDBProfileInfo, + repository: UserDbRepository = user_db_repository + ) -> SchemaInfoResult: + """ + DB 스키마 정보를 조회를 수행합니다. + """ + try: + driver_module = self._get_driver_module(db_info.type) + connect_kwargs = self._prepare_connection_args(db_info) + schema_query = self._get_schema_query(db_info.type) + + return repository.find_schemas( + driver_module, + schema_query, + **connect_kwargs + ) + except Exception as e: + raise APIException(CommonCode.FAIL) from e + + def find_tables( + self, + db_info: AllDBProfileInfo, + schema_name: str, + repository: UserDbRepository = user_db_repository + ) -> TableListResult: + """ + 특정 스키마 내의 테이블 정보를 조회합니다. + """ + try: + driver_module = self._get_driver_module(db_info.type) + connect_kwargs = self._prepare_connection_args(db_info) + table_query = self._get_table_query(db_info.type, for_all_schemas=False) + + return repository.find_tables( + driver_module, + table_query, + schema_name, + **connect_kwargs + ) + except Exception as e: + raise APIException(CommonCode.FAIL) from e + + def find_columns( + self, + db_info: AllDBProfileInfo, + schema_name: str, + table_name: str, + repository: UserDbRepository = user_db_repository + ) -> ColumnListResult: + """ + 특정 컬럼 정보를 조회합니다. + """ + try: + driver_module = self._get_driver_module(db_info.type) + connect_kwargs = self._prepare_connection_args(db_info) + column_query = self._get_column_query(db_info.type) + db_type = db_info.type + + return repository.find_columns( + driver_module, + column_query, + schema_name, + db_type, + table_name, + **connect_kwargs + ) + except Exception as e: + raise APIException(CommonCode.FAIL) from e + def _get_driver_module(self, db_type: str): """ DB 타입에 따라 동적으로 드라이버 모듈을 로드합니다. @@ -106,5 +200,77 @@ def _prepare_connection_args(self, db_info: DBProfileInfo) -> dict[str, Any]: return kwargs + def _get_schema_query(self, db_type: str) -> str | None: + db_type = db_type.lower() + if db_type == "postgresql": + return """ + SELECT schema_name FROM information_schema.schemata + WHERE schema_name NOT IN ('pg_catalog', 'information_schema') + """ + elif db_type in ["mysql", "mariadb"]: + return "SELECT schema_name FROM information_schema.schemata" + elif db_type == "oracle": + return "SELECT username FROM all_users" + elif db_type == "sqlite": + return None + return None + + + def _get_table_query(self, db_type: str, for_all_schemas: bool = False) -> str | None: # 수정됨 + db_type = db_type.lower() + if db_type == "postgresql": + if for_all_schemas: + return """ + SELECT table_name, table_schema FROM information_schema.tables + WHERE table_type = 'BASE TABLE' AND table_schema NOT IN ('pg_catalog', 'information_schema') + """ + else: + return """ + SELECT table_name, table_schema FROM information_schema.tables + WHERE table_type = 'BASE TABLE' AND table_schema = %s + """ + elif db_type in ["mysql", "mariadb"]: + if for_all_schemas: + return """ + SELECT table_name, table_schema FROM information_schema.tables + WHERE table_type = 'BASE TABLE' + """ + else: + return """ + SELECT table_name, table_schema FROM information_schema.tables + WHERE table_type = 'BASE TABLE' AND table_schema = %s + """ + elif db_type == "oracle": + return "SELECT table_name FROM all_tables WHERE owner = :owner" + elif db_type == "sqlite": + return "SELECT name FROM sqlite_master WHERE type='table'" + return None + + + def _get_column_query(self, db_type: str) -> str | None: + db_type = db_type.lower() + if db_type == "postgresql": + return """ + SELECT column_name, data_type, is_nullable, column_default, table_name, table_schema + FROM information_schema.columns + WHERE table_schema NOT IN ('pg_catalog', 'information_schema') + AND table_schema = %s + AND table_name = %s + """ + elif db_type in ["mysql", "mariadb"]: + return """ + SELECT column_name, data_type, is_nullable, column_default, table_name, table_schema + FROM information_schema.columns + WHERE table_schema = %s AND table_name = %s + """ + elif db_type == "oracle": + return """ + SELECT column_name, data_type, nullable, data_default, table_name + FROM all_tab_columns + WHERE owner = :owner AND table_name = :table + """ + elif db_type == "sqlite": + return None + return None user_db_service = UserDbService() From 21930f91a4afa2beeecefc758239a4528faa5cf9 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 18:34:53 +0900 Subject: [PATCH 127/220] =?UTF-8?q?feat:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B0=92=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index 2f3f383..5129a23 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -25,6 +25,7 @@ class CommonCode(Enum): SUCCESS_FIND_TABLES = (status.HTTP_200_OK, "2104", "디비 테이블 정보 조회를 성공하였습니다.") SUCCESS_FIND_COLUMNS = (status.HTTP_200_OK, "2105", "디비 컬럼 정보 조회를 성공하였습니다.") SUCCESS_SAVE_DB_PROFILE = (status.HTTP_200_OK, "2130", "디비 연결 정보를 저장하였습니다.") + SUCCESS_UPDATE_DB_PROFILE = (status.HTTP_200_OK, "2150", "디비 연결 정보를 업데이트 하였습니다.") """ KEY 성공 코드 - 22xx """ @@ -93,11 +94,12 @@ class CommonCode(Enum): """ DRIVER, DB 서버 에러 코드 - 51xx """ FAIL_CONNECT_DB = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5100", "디비 연결 중 에러가 발생했습니다.") - FAIL_SAVE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5101", "디비 정보 저장 중 에러가 발생했습니다.") - FAIL_FIND_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5102", "디비 정보 조회 중 에러가 발생했습니다.") - FAIL_FIND_SCHEMAS = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5103", "디비 스키마 정보 조회 중 에러가 발생했습니다.") - FAIL_FIND_TABLES = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5104", "디비 테이블 정보 조회 중 에러가 발생했습니다.") - FAIL_FIND_COLUMNS = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5105", "디비 컬럼 정보 조회 중 에러가 발생했습니다.") + FAIL_FIND_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5101", "디비 정보 조회 중 에러가 발생했습니다.") + FAIL_FIND_SCHEMAS = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5102", "디비 스키마 정보 조회 중 에러가 발생했습니다.") + FAIL_FIND_TABLES = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5103", "디비 테이블 정보 조회 중 에러가 발생했습니다.") + FAIL_FIND_COLUMNS = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5104", "디비 컬럼 정보 조회 중 에러가 발생했습니다.") + FAIL_SAVE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5130", "디비 정보 저장 중 에러가 발생했습니다.") + FAIL_UPDATE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5150", "디비 정보 업데이트 중 에러가 발생했습니다.") """ KEY 서버 에러 코드 - 52xx """ From 27ebc1f048543f1c850bc34a15301aa458433bc0 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 18:35:35 +0900 Subject: [PATCH 128/220] =?UTF-8?q?refactor:=20save=20=EC=8B=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=EC=9C=BC=EB=A1=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/user_db/db_profile_model.py | 2 +- app/schemas/user_db/result_model.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/schemas/user_db/db_profile_model.py b/app/schemas/user_db/db_profile_model.py index ad312b1..8a1d813 100644 --- a/app/schemas/user_db/db_profile_model.py +++ b/app/schemas/user_db/db_profile_model.py @@ -52,7 +52,7 @@ def _is_empty(value: Any | None) -> bool: return True return False -class SaveDBProfile(DBProfileInfo): +class UpdateOrSaveDBProfile(DBProfileInfo): id: str | None = Field(None, description="DB Key 값") view_name: str | None = Field(None, description="DB 노출명") diff --git a/app/schemas/user_db/result_model.py b/app/schemas/user_db/result_model.py index 7b8f366..bd08364 100644 --- a/app/schemas/user_db/result_model.py +++ b/app/schemas/user_db/result_model.py @@ -12,7 +12,7 @@ class BasicResult(BaseModel): code: CommonCode = Field(None, description="결과 코드") # 디비 정보 후 반환되는 저장 모델 -class SaveProfileResult(BasicResult): +class UpdateOrSaveProfileResult(BasicResult): """DB 조회 결과를 위한 확장 모델""" view_name: str = Field(..., description="저장된 디비명") From 08ad1e56de02881ff1bae09ba3701080424a864b Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 18:35:55 +0900 Subject: [PATCH 129/220] =?UTF-8?q?feat:=20profile=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/user_db_api.py | 21 ++++++++++-- app/repository/user_db_repository.py | 51 +++++++++++++++++++++++----- app/services/user_db_service.py | 21 +++++++++--- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/app/api/user_db_api.py b/app/api/user_db_api.py index e1bf203..3ce69cb 100644 --- a/app/api/user_db_api.py +++ b/app/api/user_db_api.py @@ -5,7 +5,7 @@ from app.core.exceptions import APIException from app.core.response import ResponseMessage -from app.schemas.user_db.db_profile_model import DBProfileInfo, SaveDBProfile +from app.schemas.user_db.db_profile_model import DBProfileInfo, UpdateOrSaveDBProfile from app.services.user_db_service import UserDbService, user_db_service from app.schemas.user_db.result_model import DBProfile, TableInfo, ColumnInfo, SchemaListResult, TableListResult, ColumnListResult @@ -36,7 +36,7 @@ def connection_test( summary="DB 프로필 저장", ) def save_profile( - save_db_info: SaveDBProfile, + save_db_info: UpdateOrSaveDBProfile, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[str]: @@ -47,6 +47,23 @@ def save_profile( raise APIException(result.code) return ResponseMessage.success(value=result.view_name, code=result.code) +@router.put( + "/modify/profile", + response_model=ResponseMessage[str], + summary="DB 프로필 저장", +) +def modify_profile( + modify_db_info: UpdateOrSaveDBProfile, + service: UserDbService = user_db_service_dependency, +) -> ResponseMessage[str]: + + modify_db_info.validate_required_fields() + result = service.modify_profile(modify_db_info) + + if not result.is_successful: + raise APIException(result.code) + return ResponseMessage.success(value=result.view_name, code=result.code) + @router.get( "/find/all", response_model=ResponseMessage[List[DBProfile]], diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index 2f156c0..16d2e3c 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -9,7 +9,7 @@ from app.schemas.user_db.result_model import ( DBProfile, BasicResult, - SaveProfileResult, + UpdateOrSaveProfileResult, AllDBProfileResult, SchemaListResult, TableListResult, @@ -17,7 +17,7 @@ ColumnInfo ) from app.schemas.user_db.db_profile_model import ( - SaveDBProfile, + UpdateOrSaveDBProfile, AllDBProfileInfo ) @@ -57,10 +57,10 @@ def connection_test( def save_profile( self, - save_db_info: SaveDBProfile - ) -> SaveProfileResult: + save_db_info: UpdateOrSaveDBProfile + ) -> UpdateOrSaveProfileResult: """ - DB 드라이버와 연결에 필요한 매개변수들을 받아 연결을 테스트합니다. + DB 드라이버와 연결에 필요한 매개변수들을 받아 저장합니다. """ db_path = get_db_path() connection = None @@ -83,11 +83,46 @@ def save_profile( connection.commit() name = save_db_info.view_name if save_db_info.view_name else save_db_info.type - return SaveProfileResult(is_successful=True, code=CommonCode.SUCCESS_SAVE_DB_PROFILE, view_name=name) + return UpdateOrSaveProfileResult(is_successful=True, code=CommonCode.SUCCESS_SAVE_DB_PROFILE, view_name=name) + except sqlite3.Error: + return UpdateOrSaveProfileResult(is_successful=False, code=CommonCode.FAIL_SAVE_PROFILE) + except Exception: + return UpdateOrSaveProfileResult(is_successful=False, code=CommonCode.FAIL_SAVE_PROFILE) + finally: + if connection: + connection.close() + + def modify_profile( + self, + modify_db_info: UpdateOrSaveDBProfile + ) -> UpdateOrSaveProfileResult: + """ + DB 드라이버와 연결에 필요한 매개변수들을 받아 업데이트합니다. + """ + db_path = get_db_path() + connection = None + try: + connection = sqlite3.connect(db_path) + cursor = connection.cursor() + profile_dict = modify_db_info.model_dump() + + columns_to_update = { + key: value for key, value in profile_dict.items() if value is not None and key != 'id' + } + + set_clause = ", ".join([f"{key} = ?" for key in columns_to_update.keys()]) + sql = f"UPDATE db_profile SET {set_clause} WHERE id = ?" + data_to_update = tuple(columns_to_update.values()) + (modify_db_info.id,) + + cursor.execute(sql, data_to_update) + connection.commit() + name = modify_db_info.view_name if modify_db_info.view_name else modify_db_info.type + + return UpdateOrSaveProfileResult(is_successful=True, code=CommonCode.SUCCESS_UPDATE_DB_PROFILE, view_name=name) except sqlite3.Error: - return SaveProfileResult(is_successful=False, code=CommonCode.FAIL_SAVE_PROFILE) + return UpdateOrSaveProfileResult(is_successful=False, code=CommonCode.FAIL_UPDATE_PROFILE) except Exception: - return SaveProfileResult(is_successful=False, code=CommonCode.FAIL_SAVE_PROFILE) + return UpdateOrSaveProfileResult(is_successful=False, code=CommonCode.FAIL_UPDATE_PROFILE) finally: if connection: connection.close() diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index ead22e7..8015a89 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -13,7 +13,7 @@ from app.core.enum.db_key_prefix_name import DBSaveIdEnum from app.schemas.user_db.result_model import ( BasicResult, - SaveProfileResult, + UpdateOrSaveProfileResult, AllDBProfileResult, TableListResult, ColumnListResult, @@ -21,7 +21,7 @@ ) from app.schemas.user_db.db_profile_model import ( DBProfileInfo, - SaveDBProfile, + UpdateOrSaveDBProfile, AllDBProfileInfo ) @@ -46,9 +46,9 @@ def connection_test( def save_profile( self, - save_db_info: SaveDBProfile, + save_db_info: UpdateOrSaveDBProfile, repository: UserDbRepository = user_db_repository - ) -> SaveProfileResult: + ) -> UpdateOrSaveProfileResult: """ DB 연결 정보를 저장 후 결과를 반환합니다. """ @@ -58,6 +58,19 @@ def save_profile( except Exception as e: raise APIException(CommonCode.FAIL) from e + def modify_profile( + self, + modify_db_info: UpdateOrSaveDBProfile, + repository: UserDbRepository = user_db_repository + ) -> UpdateOrSaveProfileResult: + """ + DB 연결 정보를 업데이트 후 결과를 반환합니다. + """ + try: + return repository.modify_profile(modify_db_info) + except Exception as e: + raise APIException(CommonCode.FAIL) from e + def find_all_profile( self, repository: UserDbRepository = user_db_repository From 61f0e7135b952aeebf6daec9e944935ee38a32ee Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 18:51:32 +0900 Subject: [PATCH 130/220] =?UTF-8?q?feat:=20=EC=A0=9C=EA=B1=B0=20=EC=8B=9C?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EA=B0=92=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index 5129a23..1dc25c5 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -24,8 +24,9 @@ class CommonCode(Enum): SUCCESS_FIND_SCHEMAS = (status.HTTP_200_OK, "2103", "디비 스키마 정보 조회를 성공하였습니다.") SUCCESS_FIND_TABLES = (status.HTTP_200_OK, "2104", "디비 테이블 정보 조회를 성공하였습니다.") SUCCESS_FIND_COLUMNS = (status.HTTP_200_OK, "2105", "디비 컬럼 정보 조회를 성공하였습니다.") - SUCCESS_SAVE_DB_PROFILE = (status.HTTP_200_OK, "2130", "디비 연결 정보를 저장하였습니다.") - SUCCESS_UPDATE_DB_PROFILE = (status.HTTP_200_OK, "2150", "디비 연결 정보를 업데이트 하였습니다.") + SUCCESS_SAVE_PROFILE = (status.HTTP_200_OK, "2130", "디비 연결 정보를 저장하였습니다.") + SUCCESS_UPDATE_PROFILE = (status.HTTP_200_OK, "2150", "디비 연결 정보를 업데이트 하였습니다.") + SUCCESS_DELETE_PROFILE = (status.HTTP_200_OK, "2170", "디비 연결 정보를 삭제 하였습니다.") """ KEY 성공 코드 - 22xx """ @@ -100,6 +101,7 @@ class CommonCode(Enum): FAIL_FIND_COLUMNS = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5104", "디비 컬럼 정보 조회 중 에러가 발생했습니다.") FAIL_SAVE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5130", "디비 정보 저장 중 에러가 발생했습니다.") FAIL_UPDATE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5150", "디비 정보 업데이트 중 에러가 발생했습니다.") + FAIL_DELETE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5170", "디비 정보 삭제 중 에러가 발생했습니다.") """ KEY 서버 에러 코드 - 52xx """ From fdb01606279bd94d1196e1bb58610e432f24a254 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 18:51:57 +0900 Subject: [PATCH 131/220] =?UTF-8?q?refactor:=20save=20=EB=B0=8F=20update?= =?UTF-8?q?=20=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EA=B3=B5=ED=86=B5=EC=9C=BC=EB=A1=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/user_db/result_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/schemas/user_db/result_model.py b/app/schemas/user_db/result_model.py index bd08364..28c375e 100644 --- a/app/schemas/user_db/result_model.py +++ b/app/schemas/user_db/result_model.py @@ -12,7 +12,7 @@ class BasicResult(BaseModel): code: CommonCode = Field(None, description="결과 코드") # 디비 정보 후 반환되는 저장 모델 -class UpdateOrSaveProfileResult(BasicResult): +class ChangeProfileResult(BasicResult): """DB 조회 결과를 위한 확장 모델""" view_name: str = Field(..., description="저장된 디비명") From 7c2b1e3d4a9eb6806e001653789a21941ac3bbcf Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 18:52:15 +0900 Subject: [PATCH 132/220] =?UTF-8?q?feat:=20=EC=82=AD=EC=A0=9C=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/user_db_api.py | 18 ++++++++++- app/repository/user_db_repository.py | 45 ++++++++++++++++++++++------ app/services/user_db_service.py | 19 ++++++++++-- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/app/api/user_db_api.py b/app/api/user_db_api.py index 3ce69cb..9a68f23 100644 --- a/app/api/user_db_api.py +++ b/app/api/user_db_api.py @@ -50,7 +50,7 @@ def save_profile( @router.put( "/modify/profile", response_model=ResponseMessage[str], - summary="DB 프로필 저장", + summary="DB 프로필 업데이트", ) def modify_profile( modify_db_info: UpdateOrSaveDBProfile, @@ -64,6 +64,22 @@ def modify_profile( raise APIException(result.code) return ResponseMessage.success(value=result.view_name, code=result.code) +@router.delete( + "/remove/{profile_id}", + response_model=ResponseMessage[str], + summary="DB 프로필 삭제", +) +def remove_profile( + profile_id: str, + service: UserDbService = user_db_service_dependency, +) -> ResponseMessage[str]: + + result = service.remove_profile(profile_id) + + if not result.is_successful: + raise APIException(result.code) + return ResponseMessage.success(value=result.view_name, code=result.code) + @router.get( "/find/all", response_model=ResponseMessage[List[DBProfile]], diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index 16d2e3c..f0c065f 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -9,7 +9,7 @@ from app.schemas.user_db.result_model import ( DBProfile, BasicResult, - UpdateOrSaveProfileResult, + ChangeProfileResult, AllDBProfileResult, SchemaListResult, TableListResult, @@ -58,7 +58,7 @@ def connection_test( def save_profile( self, save_db_info: UpdateOrSaveDBProfile - ) -> UpdateOrSaveProfileResult: + ) -> ChangeProfileResult: """ DB 드라이버와 연결에 필요한 매개변수들을 받아 저장합니다. """ @@ -83,11 +83,11 @@ def save_profile( connection.commit() name = save_db_info.view_name if save_db_info.view_name else save_db_info.type - return UpdateOrSaveProfileResult(is_successful=True, code=CommonCode.SUCCESS_SAVE_DB_PROFILE, view_name=name) + return ChangeProfileResult(is_successful=True, code=CommonCode.SUCCESS_SAVE_PROFILE, view_name=name) except sqlite3.Error: - return UpdateOrSaveProfileResult(is_successful=False, code=CommonCode.FAIL_SAVE_PROFILE) + return ChangeProfileResult(is_successful=False, code=CommonCode.FAIL_SAVE_PROFILE) except Exception: - return UpdateOrSaveProfileResult(is_successful=False, code=CommonCode.FAIL_SAVE_PROFILE) + return ChangeProfileResult(is_successful=False, code=CommonCode.FAIL_SAVE_PROFILE) finally: if connection: connection.close() @@ -95,7 +95,7 @@ def save_profile( def modify_profile( self, modify_db_info: UpdateOrSaveDBProfile - ) -> UpdateOrSaveProfileResult: + ) -> ChangeProfileResult: """ DB 드라이버와 연결에 필요한 매개변수들을 받아 업데이트합니다. """ @@ -118,11 +118,38 @@ def modify_profile( connection.commit() name = modify_db_info.view_name if modify_db_info.view_name else modify_db_info.type - return UpdateOrSaveProfileResult(is_successful=True, code=CommonCode.SUCCESS_UPDATE_DB_PROFILE, view_name=name) + return ChangeProfileResult(is_successful=True, code=CommonCode.SUCCESS_UPDATE_PROFILE, view_name=name) except sqlite3.Error: - return UpdateOrSaveProfileResult(is_successful=False, code=CommonCode.FAIL_UPDATE_PROFILE) + return ChangeProfileResult(is_successful=False, code=CommonCode.FAIL_UPDATE_PROFILE) except Exception: - return UpdateOrSaveProfileResult(is_successful=False, code=CommonCode.FAIL_UPDATE_PROFILE) + return ChangeProfileResult(is_successful=False, code=CommonCode.FAIL_UPDATE_PROFILE) + finally: + if connection: + connection.close() + + def remove_profile( + self, + profile_id: str, + ) -> ChangeProfileResult: + """ + DB 드라이버와 연결에 필요한 매개변수들을 받아 삭제합니다. + """ + db_path = get_db_path() + connection = None + try: + connection = sqlite3.connect(db_path) + cursor = connection.cursor() + + sql = "DELETE FROM db_profile WHERE id = ?" + data_to_delete = (profile_id,) + + cursor.execute(sql, data_to_delete) + connection.commit() + return ChangeProfileResult(is_successful=True, code=CommonCode.SUCCESS_DELETE_PROFILE, view_name=profile_id) + except sqlite3.Error: + return ChangeProfileResult(is_successful=False, code=CommonCode.FAIL_DELETE_PROFILE) + except Exception: + return ChangeProfileResult(is_successful=False, code=CommonCode.FAIL_DELETE_PROFILE) finally: if connection: connection.close() diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index 8015a89..da4361a 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -13,7 +13,7 @@ from app.core.enum.db_key_prefix_name import DBSaveIdEnum from app.schemas.user_db.result_model import ( BasicResult, - UpdateOrSaveProfileResult, + ChangeProfileResult, AllDBProfileResult, TableListResult, ColumnListResult, @@ -48,7 +48,7 @@ def save_profile( self, save_db_info: UpdateOrSaveDBProfile, repository: UserDbRepository = user_db_repository - ) -> UpdateOrSaveProfileResult: + ) -> ChangeProfileResult: """ DB 연결 정보를 저장 후 결과를 반환합니다. """ @@ -62,7 +62,7 @@ def modify_profile( self, modify_db_info: UpdateOrSaveDBProfile, repository: UserDbRepository = user_db_repository - ) -> UpdateOrSaveProfileResult: + ) -> ChangeProfileResult: """ DB 연결 정보를 업데이트 후 결과를 반환합니다. """ @@ -71,6 +71,19 @@ def modify_profile( except Exception as e: raise APIException(CommonCode.FAIL) from e + def remove_profile( + self, + profile_id: str, + repository: UserDbRepository = user_db_repository + ) -> ChangeProfileResult: + """ + DB 연결 정보를 삭제 후 결과를 반환합니다. + """ + try: + return repository.remove_profile(profile_id) + except Exception as e: + raise APIException(CommonCode.FAIL) from e + def find_all_profile( self, repository: UserDbRepository = user_db_repository From e1188d8dd0380f7bf6674b136c1b0ff2342674f7 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 19:22:20 +0900 Subject: [PATCH 133/220] =?UTF-8?q?style:=20modify=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=9D=BC=EC=B9=98=EC=8B=9C?= =?UTF-8?q?=ED=82=A4=EB=8A=94=20=EC=9E=91=EC=97=85=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/user_db_api.py | 11 ++++++----- app/repository/user_db_repository.py | 10 +++++----- app/services/user_db_service.py | 6 +++--- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/api/user_db_api.py b/app/api/user_db_api.py index 9a68f23..d62fd4e 100644 --- a/app/api/user_db_api.py +++ b/app/api/user_db_api.py @@ -7,7 +7,7 @@ from app.core.response import ResponseMessage from app.schemas.user_db.db_profile_model import DBProfileInfo, UpdateOrSaveDBProfile from app.services.user_db_service import UserDbService, user_db_service -from app.schemas.user_db.result_model import DBProfile, TableInfo, ColumnInfo, SchemaListResult, TableListResult, ColumnListResult +from app.schemas.user_db.result_model import DBProfile, ColumnInfo user_db_service_dependency = Depends(lambda: user_db_service) @@ -23,6 +23,7 @@ def connection_test( db_info: DBProfileInfo, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[bool]: + db_info.validate_required_fields() result = service.connection_test(db_info) @@ -52,13 +53,13 @@ def save_profile( response_model=ResponseMessage[str], summary="DB 프로필 업데이트", ) -def modify_profile( - modify_db_info: UpdateOrSaveDBProfile, +def update_profile( + update_db_info: UpdateOrSaveDBProfile, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[str]: - modify_db_info.validate_required_fields() - result = service.modify_profile(modify_db_info) + update_db_info.validate_required_fields() + result = service.update_profile(update_db_info) if not result.is_successful: raise APIException(result.code) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index f0c065f..be45b99 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -92,9 +92,9 @@ def save_profile( if connection: connection.close() - def modify_profile( + def update_profile( self, - modify_db_info: UpdateOrSaveDBProfile + update_db_info: UpdateOrSaveDBProfile ) -> ChangeProfileResult: """ DB 드라이버와 연결에 필요한 매개변수들을 받아 업데이트합니다. @@ -104,7 +104,7 @@ def modify_profile( try: connection = sqlite3.connect(db_path) cursor = connection.cursor() - profile_dict = modify_db_info.model_dump() + profile_dict = update_db_info.model_dump() columns_to_update = { key: value for key, value in profile_dict.items() if value is not None and key != 'id' @@ -112,11 +112,11 @@ def modify_profile( set_clause = ", ".join([f"{key} = ?" for key in columns_to_update.keys()]) sql = f"UPDATE db_profile SET {set_clause} WHERE id = ?" - data_to_update = tuple(columns_to_update.values()) + (modify_db_info.id,) + data_to_update = tuple(columns_to_update.values()) + (update_db_info.id,) cursor.execute(sql, data_to_update) connection.commit() - name = modify_db_info.view_name if modify_db_info.view_name else modify_db_info.type + name = update_db_info.view_name if update_db_info.view_name else update_db_info.type return ChangeProfileResult(is_successful=True, code=CommonCode.SUCCESS_UPDATE_PROFILE, view_name=name) except sqlite3.Error: diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index da4361a..1189052 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -58,16 +58,16 @@ def save_profile( except Exception as e: raise APIException(CommonCode.FAIL) from e - def modify_profile( + def update_profile( self, - modify_db_info: UpdateOrSaveDBProfile, + update_db_info: UpdateOrSaveDBProfile, repository: UserDbRepository = user_db_repository ) -> ChangeProfileResult: """ DB 연결 정보를 업데이트 후 결과를 반환합니다. """ try: - return repository.modify_profile(modify_db_info) + return repository.update_profile(update_db_info) except Exception as e: raise APIException(CommonCode.FAIL) from e From e8329fdd3913200fa7da3abab14c4018fb46eea2 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 19:23:57 +0900 Subject: [PATCH 134/220] =?UTF-8?q?style:=20delete=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=9D=BC=EA=B4=80=EC=84=B1=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/user_db_api.py | 4 ++-- app/repository/user_db_repository.py | 2 +- app/services/user_db_service.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/api/user_db_api.py b/app/api/user_db_api.py index d62fd4e..06669bb 100644 --- a/app/api/user_db_api.py +++ b/app/api/user_db_api.py @@ -70,12 +70,12 @@ def update_profile( response_model=ResponseMessage[str], summary="DB 프로필 삭제", ) -def remove_profile( +def delete_profile( profile_id: str, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[str]: - result = service.remove_profile(profile_id) + result = service.delete_profile(profile_id) if not result.is_successful: raise APIException(result.code) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index be45b99..71c6a7f 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -127,7 +127,7 @@ def update_profile( if connection: connection.close() - def remove_profile( + def delete_profile( self, profile_id: str, ) -> ChangeProfileResult: diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index 1189052..bf6855d 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -71,7 +71,7 @@ def update_profile( except Exception as e: raise APIException(CommonCode.FAIL) from e - def remove_profile( + def delete_profile( self, profile_id: str, repository: UserDbRepository = user_db_repository @@ -80,7 +80,7 @@ def remove_profile( DB 연결 정보를 삭제 후 결과를 반환합니다. """ try: - return repository.remove_profile(profile_id) + return repository.delete_profile(profile_id) except Exception as e: raise APIException(CommonCode.FAIL) from e From 7dc13013e6028da340294e0489afab91c1dc1d62 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 20:03:24 +0900 Subject: [PATCH 135/220] =?UTF-8?q?style:=20create=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=9D=BC=EA=B4=80=EC=84=B1=20?= =?UTF-8?q?=EC=9E=88=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/user_db_api.py | 14 +++++++------- app/repository/user_db_repository.py | 12 ++++++------ app/schemas/user_db/db_profile_model.py | 2 +- app/services/user_db_service.py | 12 ++++++------ 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/app/api/user_db_api.py b/app/api/user_db_api.py index 06669bb..81f6dbd 100644 --- a/app/api/user_db_api.py +++ b/app/api/user_db_api.py @@ -5,7 +5,7 @@ from app.core.exceptions import APIException from app.core.response import ResponseMessage -from app.schemas.user_db.db_profile_model import DBProfileInfo, UpdateOrSaveDBProfile +from app.schemas.user_db.db_profile_model import DBProfileInfo, UpdateOrCreateDBProfile from app.services.user_db_service import UserDbService, user_db_service from app.schemas.user_db.result_model import DBProfile, ColumnInfo @@ -32,17 +32,17 @@ def connection_test( return ResponseMessage.success(value=result.is_successful, code=result.code) @router.post( - "/save/profile", + "/create/profile", response_model=ResponseMessage[str], summary="DB 프로필 저장", ) -def save_profile( - save_db_info: UpdateOrSaveDBProfile, +def create_profile( + create_db_info: UpdateOrCreateDBProfile, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[str]: - save_db_info.validate_required_fields() - result = service.save_profile(save_db_info) + create_db_info.validate_required_fields() + result = service.create_profile(create_db_info) if not result.is_successful: raise APIException(result.code) @@ -54,7 +54,7 @@ def save_profile( summary="DB 프로필 업데이트", ) def update_profile( - update_db_info: UpdateOrSaveDBProfile, + update_db_info: UpdateOrCreateDBProfile, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[str]: diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index 71c6a7f..340c760 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -17,7 +17,7 @@ ColumnInfo ) from app.schemas.user_db.db_profile_model import ( - UpdateOrSaveDBProfile, + UpdateOrCreateDBProfile, AllDBProfileInfo ) @@ -55,9 +55,9 @@ def connection_test( if connection: connection.close() - def save_profile( + def create_profile( self, - save_db_info: UpdateOrSaveDBProfile + create_db_info: UpdateOrCreateDBProfile ) -> ChangeProfileResult: """ DB 드라이버와 연결에 필요한 매개변수들을 받아 저장합니다. @@ -67,7 +67,7 @@ def save_profile( try: connection = sqlite3.connect(db_path) cursor = connection.cursor() - profile_dict = save_db_info.model_dump() + profile_dict = create_db_info.model_dump() columns_to_insert = { key: value for key, value in profile_dict.items() if value is not None @@ -81,7 +81,7 @@ def save_profile( cursor.execute(sql, data_to_insert) connection.commit() - name = save_db_info.view_name if save_db_info.view_name else save_db_info.type + name = create_db_info.view_name if create_db_info.view_name else create_db_info.type return ChangeProfileResult(is_successful=True, code=CommonCode.SUCCESS_SAVE_PROFILE, view_name=name) except sqlite3.Error: @@ -94,7 +94,7 @@ def save_profile( def update_profile( self, - update_db_info: UpdateOrSaveDBProfile + update_db_info: UpdateOrCreateDBProfile ) -> ChangeProfileResult: """ DB 드라이버와 연결에 필요한 매개변수들을 받아 업데이트합니다. diff --git a/app/schemas/user_db/db_profile_model.py b/app/schemas/user_db/db_profile_model.py index 8a1d813..f6d601f 100644 --- a/app/schemas/user_db/db_profile_model.py +++ b/app/schemas/user_db/db_profile_model.py @@ -52,7 +52,7 @@ def _is_empty(value: Any | None) -> bool: return True return False -class UpdateOrSaveDBProfile(DBProfileInfo): +class UpdateOrCreateDBProfile(DBProfileInfo): id: str | None = Field(None, description="DB Key 값") view_name: str | None = Field(None, description="DB 노출명") diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index bf6855d..5077b1f 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -21,7 +21,7 @@ ) from app.schemas.user_db.db_profile_model import ( DBProfileInfo, - UpdateOrSaveDBProfile, + UpdateOrCreateDBProfile, AllDBProfileInfo ) @@ -44,23 +44,23 @@ def connection_test( except Exception as e: raise APIException(CommonCode.FAIL) from e - def save_profile( + def create_profile( self, - save_db_info: UpdateOrSaveDBProfile, + create_db_info: UpdateOrCreateDBProfile, repository: UserDbRepository = user_db_repository ) -> ChangeProfileResult: """ DB 연결 정보를 저장 후 결과를 반환합니다. """ - save_db_info.id = generate_prefixed_uuid(DBSaveIdEnum.user_db.value) + create_db_info.id = generate_prefixed_uuid(DBSaveIdEnum.user_db.value) try: - return repository.save_profile(save_db_info) + return repository.create_profile(create_db_info) except Exception as e: raise APIException(CommonCode.FAIL) from e def update_profile( self, - update_db_info: UpdateOrSaveDBProfile, + update_db_info: UpdateOrCreateDBProfile, repository: UserDbRepository = user_db_repository ) -> ChangeProfileResult: """ From da28d281516e1b9976214d1f615de485d32b78ec Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 20:09:19 +0900 Subject: [PATCH 136/220] =?UTF-8?q?refactor:=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=EC=84=9C=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EB=A1=9C=20=EB=BA=80=20=EB=B6=80=EB=B6=84?= =?UTF-8?q?=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index 340c760..a6377ed 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -32,20 +32,7 @@ def connection_test( """ connection = None try: - if driver_module is oracledb: - if kwargs.get("user").lower() == "sys": - kwargs["mode"] = oracledb.AUTH_MODE_SYSDBA - connection = driver_module.connect(**kwargs) - # MSSQL과 같이 전체 연결 문자열이 제공된 경우 - elif "connection_string" in kwargs: - connection = driver_module.connect(kwargs["connection_string"]) - # SQLite와 같이 파일 이름만 필요한 경우 - elif "db_name" in kwargs: - connection = driver_module.connect(kwargs["db_name"]) - # 그 외 (MySQL, PostgreSQL, Oracle 등) 일반적인 키워드 인자 방식 연결 - else: - connection = driver_module.connect(**kwargs) - + connection = self._connect(driver_module, **kwargs) return BasicResult(is_successful=True, code=CommonCode.SUCCESS_USER_DB_CONNECT_TEST) except (AttributeError, driver_module.OperationalError, driver_module.DatabaseError) as e: return BasicResult(is_successful=False, code=CommonCode.FAIL_CONNECT_DB) From 6ad7745a75ba61571603c694a25da467b7eff6c5 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 10 Aug 2025 20:45:37 +0900 Subject: [PATCH 137/220] =?UTF-8?q?refactor:=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B6=80=EB=B6=84=20service=20=EC=AA=BD?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B9=BC=EC=84=9C=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 170 ++++++--------------------- app/services/user_db_service.py | 135 ++++++++++----------- 2 files changed, 106 insertions(+), 199 deletions(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index a6377ed..cd7a0e0 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -1,32 +1,26 @@ +import sqlite3 from typing import Any import oracledb -import sqlite3 +from app.core.exceptions import APIException from app.core.status import CommonCode from app.core.utils import get_db_path -from app.core.exceptions import APIException +from app.schemas.user_db.db_profile_model import AllDBProfileInfo, UpdateOrCreateDBProfile from app.schemas.user_db.result_model import ( - DBProfile, + AllDBProfileResult, BasicResult, ChangeProfileResult, - AllDBProfileResult, + ColumnInfo, + ColumnListResult, + DBProfile, SchemaListResult, TableListResult, - ColumnListResult, - ColumnInfo -) -from app.schemas.user_db.db_profile_model import ( - UpdateOrCreateDBProfile, - AllDBProfileInfo ) + class UserDbRepository: - def connection_test( - self, - driver_module: Any, - **kwargs: Any - ) -> BasicResult: + def connection_test(self, driver_module: Any, **kwargs: Any) -> BasicResult: """ DB 드라이버와 연결에 필요한 매개변수들을 받아 연결을 테스트합니다. """ @@ -34,18 +28,15 @@ def connection_test( try: connection = self._connect(driver_module, **kwargs) return BasicResult(is_successful=True, code=CommonCode.SUCCESS_USER_DB_CONNECT_TEST) - except (AttributeError, driver_module.OperationalError, driver_module.DatabaseError) as e: + except (AttributeError, driver_module.OperationalError, driver_module.DatabaseError): return BasicResult(is_successful=False, code=CommonCode.FAIL_CONNECT_DB) - except Exception as e: + except Exception: return BasicResult(is_successful=False, code=CommonCode.FAIL) finally: if connection: connection.close() - def create_profile( - self, - create_db_info: UpdateOrCreateDBProfile - ) -> ChangeProfileResult: + def create_profile(self, sql: str, data: tuple, create_db_info: UpdateOrCreateDBProfile) -> ChangeProfileResult: """ DB 드라이버와 연결에 필요한 매개변수들을 받아 저장합니다. """ @@ -54,22 +45,9 @@ def create_profile( try: connection = sqlite3.connect(db_path) cursor = connection.cursor() - profile_dict = create_db_info.model_dump() - - columns_to_insert = { - key: value for key, value in profile_dict.items() if value is not None - } - - columns = ", ".join(columns_to_insert.keys()) - placeholders = ", ".join(["?"] * len(columns_to_insert)) - - sql = f"INSERT INTO db_profile ({columns}) VALUES ({placeholders})" - data_to_insert = tuple(columns_to_insert.values()) - - cursor.execute(sql, data_to_insert) + cursor.execute(sql, data) connection.commit() name = create_db_info.view_name if create_db_info.view_name else create_db_info.type - return ChangeProfileResult(is_successful=True, code=CommonCode.SUCCESS_SAVE_PROFILE, view_name=name) except sqlite3.Error: return ChangeProfileResult(is_successful=False, code=CommonCode.FAIL_SAVE_PROFILE) @@ -79,10 +57,7 @@ def create_profile( if connection: connection.close() - def update_profile( - self, - update_db_info: UpdateOrCreateDBProfile - ) -> ChangeProfileResult: + def update_profile(self, sql: str, data: tuple, update_db_info: UpdateOrCreateDBProfile) -> ChangeProfileResult: """ DB 드라이버와 연결에 필요한 매개변수들을 받아 업데이트합니다. """ @@ -91,20 +66,9 @@ def update_profile( try: connection = sqlite3.connect(db_path) cursor = connection.cursor() - profile_dict = update_db_info.model_dump() - - columns_to_update = { - key: value for key, value in profile_dict.items() if value is not None and key != 'id' - } - - set_clause = ", ".join([f"{key} = ?" for key in columns_to_update.keys()]) - sql = f"UPDATE db_profile SET {set_clause} WHERE id = ?" - data_to_update = tuple(columns_to_update.values()) + (update_db_info.id,) - - cursor.execute(sql, data_to_update) + cursor.execute(sql, data) connection.commit() name = update_db_info.view_name if update_db_info.view_name else update_db_info.type - return ChangeProfileResult(is_successful=True, code=CommonCode.SUCCESS_UPDATE_PROFILE, view_name=name) except sqlite3.Error: return ChangeProfileResult(is_successful=False, code=CommonCode.FAIL_UPDATE_PROFILE) @@ -116,6 +80,8 @@ def update_profile( def delete_profile( self, + sql: str, + data: tuple, profile_id: str, ) -> ChangeProfileResult: """ @@ -126,11 +92,7 @@ def delete_profile( try: connection = sqlite3.connect(db_path) cursor = connection.cursor() - - sql = "DELETE FROM db_profile WHERE id = ?" - data_to_delete = (profile_id,) - - cursor.execute(sql, data_to_delete) + cursor.execute(sql, data) connection.commit() return ChangeProfileResult(is_successful=True, code=CommonCode.SUCCESS_DELETE_PROFILE, view_name=profile_id) except sqlite3.Error: @@ -141,11 +103,9 @@ def delete_profile( if connection: connection.close() - def find_all_profile( - self - ) -> AllDBProfileResult: + def find_all_profile(self, sql: str) -> AllDBProfileResult: """ - 모든 DB 연결 정보를 조회합니다. + 전달받은 쿼리를 실행하여 모든 DB 연결 정보를 조회합니다. """ db_path = get_db_path() connection = None @@ -153,24 +113,9 @@ def find_all_profile( connection = sqlite3.connect(db_path) connection.row_factory = sqlite3.Row cursor = connection.cursor() - - sql = """ - SELECT - id, - type, - host, - port, - name, - username, - view_name, - created_at, - updated_at - FROM db_profile - """ cursor.execute(sql) rows = cursor.fetchall() profiles = [DBProfile(**row) for row in rows] - return AllDBProfileResult(is_successful=True, code=CommonCode.SUCCESS_FIND_PROFILE, profiles=profiles) except sqlite3.Error: return AllDBProfileResult(is_successful=False, code=CommonCode.FAIL_FIND_PROFILE) @@ -180,12 +125,9 @@ def find_all_profile( if connection: connection.close() - def find_profile( - self, - profile_id - ) -> AllDBProfileInfo: + def find_profile(self, sql: str, data: tuple) -> AllDBProfileInfo: """ - 특정 DB 연결 정보를 조회합니다. + 전달받은 쿼리를 실행하여 특정 DB 연결 정보를 조회합니다. """ db_path = get_db_path() connection = None @@ -193,30 +135,16 @@ def find_profile( connection = sqlite3.connect(db_path) connection.row_factory = sqlite3.Row cursor = connection.cursor() - - sql = """ - SELECT - id, - type, - host, - port, - name, - username, - password, - view_name, - created_at, - updated_at - FROM db_profile - WHERE id = ? - """ - cursor.execute(sql, (profile_id,)) + cursor.execute(sql, data) row = cursor.fetchone() + if not row: + raise APIException(CommonCode.NO_SEARCH_DATA) return AllDBProfileInfo(**dict(row)) - except sqlite3.Error: - raise APIException(CommonCode.FAIL_FIND_PROFILE) - except Exception: - raise APIException(CommonCode.FAIL) + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL_FIND_PROFILE) from e + except Exception as e: + raise APIException(CommonCode.FAIL) from e finally: if connection: connection.close() @@ -224,23 +152,14 @@ def find_profile( # ───────────────────────────── # 스키마 조회 # ───────────────────────────── - def find_schemas( - self, - driver_module: Any, - schema_query: str, - **kwargs: Any - ) -> SchemaListResult: + def find_schemas(self, driver_module: Any, schema_query: str, **kwargs: Any) -> SchemaListResult: connection = None try: connection = self._connect(driver_module, **kwargs) cursor = connection.cursor() if not schema_query: - return SchemaListResult( - is_successful=True, - code=CommonCode.SUCCESS_FIND_SCHEMAS, - schemas=["main"] - ) + return SchemaListResult(is_successful=True, code=CommonCode.SUCCESS_FIND_SCHEMAS, schemas=["main"]) cursor.execute(schema_query) schemas = [row[0] for row in cursor.fetchall()] @@ -255,13 +174,7 @@ def find_schemas( # ───────────────────────────── # 테이블 조회 # ───────────────────────────── - def find_tables( - self, - driver_module: Any, - table_query: str, - schema_name: str, - **kwargs: Any - ) -> TableListResult: + def find_tables(self, driver_module: Any, table_query: str, schema_name: str, **kwargs: Any) -> TableListResult: connection = None try: connection = self._connect(driver_module, **kwargs) @@ -287,13 +200,7 @@ def find_tables( # 컬럼 조회 # ───────────────────────────── def find_columns( - self, - driver_module: Any, - column_query: str, - schema_name: str, - db_type, - table_name: str, - **kwargs: Any + self, driver_module: Any, column_query: str, schema_name: str, db_type, table_name: str, **kwargs: Any ) -> ColumnListResult: connection = None try: @@ -312,7 +219,7 @@ def find_columns( nullable=(c[3] == 0), # notnull == 0 means nullable default=c[4], comment=None, - is_pk=(c[5] == 1) + is_pk=(c[5] == 1), ) for c in columns_raw ] @@ -325,13 +232,11 @@ def find_columns( try: cursor.execute(column_query, {"owner": owner_bind, "table": table_bind}) except Exception: - # fallback: try positional binds (:1, :2) if named binds fail try: pos_query = column_query.replace(":owner", ":1").replace(":table", ":2") cursor.execute(pos_query, [owner_bind, table_bind]) - except Exception: - # re-raise to be handled by outer exception handler - raise + except Exception as e: + raise APIException(CommonCode.FAIL) from e else: cursor.execute(column_query) @@ -342,7 +247,7 @@ def find_columns( nullable=(c[2] in ["YES", "Y", True]), default=c[3], comment=c[4] if len(c) > 4 else None, - is_pk=(c[5] in ["PRI", True] if len(c) > 5 else False) + is_pk=(c[5] in ["PRI", True] if len(c) > 5 else False), ) for c in cursor.fetchall() ] @@ -369,4 +274,5 @@ def _connect(self, driver_module: Any, **kwargs): else: return driver_module.connect(**kwargs) + user_db_repository = UserDbRepository() diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index 5077b1f..9463d85 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -3,37 +3,30 @@ import importlib import sqlite3 from typing import Any + from fastapi import Depends from app.core.enum.db_driver import DBTypesEnum +from app.core.enum.db_key_prefix_name import DBSaveIdEnum from app.core.exceptions import APIException from app.core.status import CommonCode -from app.repository.user_db_repository import UserDbRepository, user_db_repository from app.core.utils import generate_prefixed_uuid -from app.core.enum.db_key_prefix_name import DBSaveIdEnum +from app.repository.user_db_repository import UserDbRepository, user_db_repository +from app.schemas.user_db.db_profile_model import AllDBProfileInfo, DBProfileInfo, UpdateOrCreateDBProfile from app.schemas.user_db.result_model import ( + AllDBProfileResult, BasicResult, ChangeProfileResult, - AllDBProfileResult, - TableListResult, ColumnListResult, - SchemaInfoResult -) -from app.schemas.user_db.db_profile_model import ( - DBProfileInfo, - UpdateOrCreateDBProfile, - AllDBProfileInfo + SchemaInfoResult, + TableListResult, ) user_db_repository_dependency = Depends(lambda: user_db_repository) class UserDbService: - def connection_test( - self, - db_info: DBProfileInfo, - repository: UserDbRepository = user_db_repository - ) -> BasicResult: + def connection_test(self, db_info: DBProfileInfo, repository: UserDbRepository = user_db_repository) -> BasicResult: """ DB 연결 정보를 받아 연결 테스트를 수행 후 결과를 반환합니다. """ @@ -45,74 +38,67 @@ def connection_test( raise APIException(CommonCode.FAIL) from e def create_profile( - self, - create_db_info: UpdateOrCreateDBProfile, - repository: UserDbRepository = user_db_repository + self, create_db_info: UpdateOrCreateDBProfile, repository: UserDbRepository = user_db_repository ) -> ChangeProfileResult: """ DB 연결 정보를 저장 후 결과를 반환합니다. """ create_db_info.id = generate_prefixed_uuid(DBSaveIdEnum.user_db.value) try: - return repository.create_profile(create_db_info) + # [수정] 쿼리와 데이터를 서비스에서 생성하여 레포지토리로 전달합니다. + sql, data = self._get_create_query_and_data(create_db_info) + return repository.create_profile(sql, data, create_db_info) except Exception as e: raise APIException(CommonCode.FAIL) from e def update_profile( - self, - update_db_info: UpdateOrCreateDBProfile, - repository: UserDbRepository = user_db_repository + self, update_db_info: UpdateOrCreateDBProfile, repository: UserDbRepository = user_db_repository ) -> ChangeProfileResult: """ DB 연결 정보를 업데이트 후 결과를 반환합니다. """ try: - return repository.update_profile(update_db_info) + # [수정] 쿼리와 데이터를 서비스에서 생성하여 레포지토리로 전달합니다. + sql, data = self._get_update_query_and_data(update_db_info) + return repository.update_profile(sql, data, update_db_info) except Exception as e: raise APIException(CommonCode.FAIL) from e - def delete_profile( - self, - profile_id: str, - repository: UserDbRepository = user_db_repository - ) -> ChangeProfileResult: + def delete_profile(self, profile_id: str, repository: UserDbRepository = user_db_repository) -> ChangeProfileResult: """ DB 연결 정보를 삭제 후 결과를 반환합니다. """ try: - return repository.delete_profile(profile_id) + # [수정] 쿼리와 데이터를 서비스에서 생성하여 레포지토리로 전달합니다. + sql, data = self._get_delete_query_and_data(profile_id) + return repository.delete_profile(sql, data, profile_id) except Exception as e: raise APIException(CommonCode.FAIL) from e - def find_all_profile( - self, - repository: UserDbRepository = user_db_repository - ) -> AllDBProfileResult: + def find_all_profile(self, repository: UserDbRepository = user_db_repository) -> AllDBProfileResult: """ 모든 DB 연결 정보를 반환합니다. """ try: - return repository.find_all_profile() + # [수정] 쿼리를 서비스에서 생성하여 레포지토리로 전달합니다. + sql = self._get_find_all_query() + return repository.find_all_profile(sql) except Exception as e: raise APIException(CommonCode.FAIL) from e - def find_profile( - self, - profile_id, - repository: UserDbRepository = user_db_repository - ) -> AllDBProfileInfo: + def find_profile(self, profile_id, repository: UserDbRepository = user_db_repository) -> AllDBProfileInfo: """ 특정 DB 연결 정보를 반환합니다. """ try: - return repository.find_profile(profile_id) + # [수정] 쿼리와 데이터를 서비스에서 생성하여 레포지토리로 전달합니다. + sql, data = self._get_find_one_query_and_data(profile_id) + return repository.find_profile(sql, data) except Exception as e: raise APIException(CommonCode.FAIL) from e def find_schemas( - self, - db_info: AllDBProfileInfo, - repository: UserDbRepository = user_db_repository + self, db_info: AllDBProfileInfo, repository: UserDbRepository = user_db_repository ) -> SchemaInfoResult: """ DB 스키마 정보를 조회를 수행합니다. @@ -122,19 +108,12 @@ def find_schemas( connect_kwargs = self._prepare_connection_args(db_info) schema_query = self._get_schema_query(db_info.type) - return repository.find_schemas( - driver_module, - schema_query, - **connect_kwargs - ) + return repository.find_schemas(driver_module, schema_query, **connect_kwargs) except Exception as e: raise APIException(CommonCode.FAIL) from e def find_tables( - self, - db_info: AllDBProfileInfo, - schema_name: str, - repository: UserDbRepository = user_db_repository + self, db_info: AllDBProfileInfo, schema_name: str, repository: UserDbRepository = user_db_repository ) -> TableListResult: """ 특정 스키마 내의 테이블 정보를 조회합니다. @@ -144,12 +123,7 @@ def find_tables( connect_kwargs = self._prepare_connection_args(db_info) table_query = self._get_table_query(db_info.type, for_all_schemas=False) - return repository.find_tables( - driver_module, - table_query, - schema_name, - **connect_kwargs - ) + return repository.find_tables(driver_module, table_query, schema_name, **connect_kwargs) except Exception as e: raise APIException(CommonCode.FAIL) from e @@ -158,7 +132,7 @@ def find_columns( db_info: AllDBProfileInfo, schema_name: str, table_name: str, - repository: UserDbRepository = user_db_repository + repository: UserDbRepository = user_db_repository, ) -> ColumnListResult: """ 특정 컬럼 정보를 조회합니다. @@ -170,12 +144,7 @@ def find_columns( db_type = db_info.type return repository.find_columns( - driver_module, - column_query, - schema_name, - db_type, - table_name, - **connect_kwargs + driver_module, column_query, schema_name, db_type, table_name, **connect_kwargs ) except Exception as e: raise APIException(CommonCode.FAIL) from e @@ -241,7 +210,6 @@ def _get_schema_query(self, db_type: str) -> str | None: return None return None - def _get_table_query(self, db_type: str, for_all_schemas: bool = False) -> str | None: # 수정됨 db_type = db_type.lower() if db_type == "postgresql": @@ -272,7 +240,6 @@ def _get_table_query(self, db_type: str, for_all_schemas: bool = False) -> str | return "SELECT name FROM sqlite_master WHERE type='table'" return None - def _get_column_query(self, db_type: str) -> str | None: db_type = db_type.lower() if db_type == "postgresql": @@ -299,4 +266,38 @@ def _get_column_query(self, db_type: str) -> str | None: return None return None + # ───────────────────────────── + # 프로필 CRUD 쿼리 생성 메서드 + # ───────────────────────────── + def _get_create_query_and_data(self, db_info: UpdateOrCreateDBProfile) -> tuple[str, tuple]: + profile_dict = db_info.model_dump() + columns_to_insert = {k: v for k, v in profile_dict.items() if v is not None} + columns = ", ".join(columns_to_insert.keys()) + placeholders = ", ".join(["?"] * len(columns_to_insert)) + sql = f"INSERT INTO db_profile ({columns}) VALUES ({placeholders})" + data = tuple(columns_to_insert.values()) + return sql, data + + def _get_update_query_and_data(self, db_info: UpdateOrCreateDBProfile) -> tuple[str, tuple]: + profile_dict = db_info.model_dump() + columns_to_update = {k: v for k, v in profile_dict.items() if v is not None and k != "id"} + set_clause = ", ".join([f"{key} = ?" for key in columns_to_update.keys()]) + sql = f"UPDATE db_profile SET {set_clause} WHERE id = ?" + data = tuple(columns_to_update.values()) + (db_info.id,) + return sql, data + + def _get_delete_query_and_data(self, profile_id: str) -> tuple[str, tuple]: + sql = "DELETE FROM db_profile WHERE id = ?" + data = (profile_id,) + return sql, data + + def _get_find_all_query(self) -> str: + return "SELECT id, type, host, port, name, username, view_name, created_at, updated_at FROM db_profile" + + def _get_find_one_query_and_data(self, profile_id: str) -> tuple[str, tuple]: + sql = "SELECT * FROM db_profile WHERE id = ?" + data = (profile_id,) + return sql, data + + user_db_service = UserDbService() From 31cee5a0af101e3a1da85a6e72b3218c6c441958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Fri, 8 Aug 2025 16:11:02 +0900 Subject: [PATCH 138/220] =?UTF-8?q?refactor:=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EB=B3=84=20=EA=B5=AC=EB=B6=84=EC=9D=84=20=EC=9C=84=ED=95=B4?= =?UTF-8?q?=20prefix=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/api_key_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index 7e43cb9..aeceecf 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -23,7 +23,7 @@ def store_api_key(self, api_key_data: APIKeyCreate) -> APIKeyInDB: api_key_data.validate_with_service() try: encrypted_key = AES256.encrypt(api_key_data.api_key) - new_id = generate_prefixed_uuid("QGENIE") + new_id = generate_prefixed_uuid("APIKEY") created_row = self.repository.create_api_key( new_id=new_id, From dc52073ce4315faff181c1ab74b3387de7cd328e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Fri, 8 Aug 2025 16:18:00 +0900 Subject: [PATCH 139/220] =?UTF-8?q?refactor:=20=EC=84=B1=EA=B3=B5=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key_api.py | 8 ++++---- app/core/status.py | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index 74884ea..53ea9f2 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -63,7 +63,7 @@ def get_all_api_keys( ) for api_key in api_keys_in_db ] - return ResponseMessage.success(value=response_data) + return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_GET_API_KEY) @router.get( @@ -83,7 +83,7 @@ def get_api_key_by_service_name( created_at=api_key_in_db.created_at, updated_at=api_key_in_db.updated_at, ) - return ResponseMessage.success(value=response_data) + return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_GET_API_KEY) @router.put( @@ -110,7 +110,7 @@ def update_api_key( updated_at=updated_api_key.updated_at, ) - return ResponseMessage.success(value=response_data) + return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_UPDATE_API_KEY) @router.delete( @@ -124,4 +124,4 @@ def delete_api_key(serviceName: LLMServiceEnum, service: APIKeyService = api_key - **service_name**: 삭제할 서비스의 이름 """ service.delete_api_key(serviceName.value) - return ResponseMessage.success() + return ResponseMessage.success(code=CommonCode.SUCCESS_DELETE_API_KEY) diff --git a/app/core/status.py b/app/core/status.py index 1dc25c5..6da4e25 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -30,6 +30,9 @@ class CommonCode(Enum): """ KEY 성공 코드 - 22xx """ + SUCCESS_DELETE_API_KEY = (status.HTTP_204_NO_CONTENT, "2200", "API KEY가 성공적으로 삭제되었습니다.") + SUCCESS_UPDATE_API_KEY = (status.HTTP_200_OK, "2201", "API KEY가 성공적으로 수정되었습니다.") + SUCCESS_GET_API_KEY = (status.HTTP_200_OK, "2202", "API KEY 정보를 성공적으로 조회했습니다.") """ AI CHAT, DB 성공 코드 - 23xx """ SUCCESS_AI_CHAT_CREATE = (status.HTTP_200_OK, "2300", "새로운 채팅 탭을 생성하였습니다.") From dd876256e29aab9e4bab8aea8de67ea7785054cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 12 Aug 2025 10:26:32 +0900 Subject: [PATCH 140/220] =?UTF-8?q?refactor:=20prefixed=5Fuuid=EC=97=90=20?= =?UTF-8?q?enum=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/db_key_prefix_name.py | 5 ++++- app/services/api_key_service.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/core/enum/db_key_prefix_name.py b/app/core/enum/db_key_prefix_name.py index 5eea582..859ed2f 100644 --- a/app/core/enum/db_key_prefix_name.py +++ b/app/core/enum/db_key_prefix_name.py @@ -1,7 +1,10 @@ # app/core/enum/db_key_prefix_name.py from enum import Enum + class DBSaveIdEnum(Enum): """저장할 디비 ID 앞에 들어갈 이름""" + user_db = "USER-DB" - driver = "DRIVER" \ No newline at end of file + driver = "DRIVER" + api_key = "API-KEY" diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index aeceecf..36b7492 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -2,6 +2,7 @@ from fastapi import Depends +from app.core.enum.db_key_prefix_name import DBSaveIdEnum from app.core.exceptions import APIException from app.core.security import AES256 from app.core.status import CommonCode @@ -23,7 +24,7 @@ def store_api_key(self, api_key_data: APIKeyCreate) -> APIKeyInDB: api_key_data.validate_with_service() try: encrypted_key = AES256.encrypt(api_key_data.api_key) - new_id = generate_prefixed_uuid("APIKEY") + new_id = generate_prefixed_uuid(DBSaveIdEnum.api_key.value) created_row = self.repository.create_api_key( new_id=new_id, From 02cb92f07a9b9c24d243a790f2618fa6c07171d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Tue, 12 Aug 2025 10:46:51 +0900 Subject: [PATCH 141/220] =?UTF-8?q?refactor:=20init=5Fdb=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?&=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= =?UTF-8?q?=EC=8B=9C=20=EC=99=B8=EB=9E=98=ED=82=A4=20=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db/init_db.py | 421 +++++++++++++++++++++++----------------------- 1 file changed, 208 insertions(+), 213 deletions(-) diff --git a/app/db/init_db.py b/app/db/init_db.py index 8c5474a..2a83093 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -1,26 +1,38 @@ # db/init_db.py -import sqlite3 import logging +import sqlite3 from app.core.utils import get_db_path + def _synchronize_table(cursor, table_name: str, target_columns: dict): """ 테이블 스키마를 확인하고, 코드와 다를 경우 테이블을 재생성하여 동기화합니다. """ try: + # 외래 키 제약 조건 비활성화 + cursor.execute("PRAGMA foreign_keys=off;") cursor.execute(f"PRAGMA table_info({table_name})") current_schema_rows = cursor.fetchall() current_columns = {row[1]: row[2].upper() for row in current_schema_rows} - target_schema_simple = {name: definition.split()[0].upper() for name, definition in target_columns.items()} + target_schema_simple = { + name: definition.split()[0].upper() + for name, definition in target_columns.items() + if not name.startswith("FOREIGN KEY") + } if current_columns == target_schema_simple: + # 외래 키 제약 조건 다시 활성화 + cursor.execute("PRAGMA foreign_keys=on;") return - logging.warning(f"'{table_name}' 테이블의 스키마 변경을 감지했습니다. 마이그레이션을 시작합니다. (데이터 손실 위험)") + logging.warning( + f"'{table_name}' 테이블의 스키마 변경을 감지했습니다. 마이그레이션을 시작합니다. (데이터 손실 위험)" + ) temp_table_name = f"{table_name}_temp_old" + cursor.execute(f"DROP TABLE IF EXISTS {temp_table_name}") # DROP 먼저 실행 cursor.execute(f"ALTER TABLE {table_name} RENAME TO {temp_table_name}") columns_with_definitions = ", ".join([f"{name} {definition}" for name, definition in target_columns.items()]) @@ -31,7 +43,9 @@ def _synchronize_table(cursor, table_name: str, target_columns: dict): common_columns = ", ".join(target_columns.keys() & temp_columns) if common_columns: - cursor.execute(f"INSERT INTO {table_name} ({common_columns}) SELECT {common_columns} FROM {temp_table_name}") + cursor.execute( + f"INSERT INTO {table_name} ({common_columns}) SELECT {common_columns} FROM {temp_table_name}" + ) logging.info(f"'{temp_table_name}'에서 '{table_name}'으로 데이터를 복사했습니다.") cursor.execute(f"DROP TABLE {temp_table_name}") @@ -40,6 +54,9 @@ def _synchronize_table(cursor, table_name: str, target_columns: dict): except sqlite3.Error as e: logging.error(f"'{table_name}' 테이블 마이그레이션 중 오류 발생: {e}") raise e + finally: + # 외래 키 제약 조건 다시 활성화 + cursor.execute("PRAGMA foreign_keys=on;") def initialize_database(): @@ -64,9 +81,12 @@ def initialize_database(): "password": "VARCHAR(128)", "view_name": "VARCHAR(64)", "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", - "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", } - cursor.execute(f"CREATE TABLE IF NOT EXISTS db_profile ({', '.join([f'{k} {v}' for k, v in db_profile_cols.items()])})") + create_sql = ( + f"CREATE TABLE IF NOT EXISTS db_profile ({', '.join([f'{k} {v}' for k, v in db_profile_cols.items()])})" + ) + cursor.execute(create_sql) _synchronize_table(cursor, "db_profile", db_profile_cols) cursor.execute( @@ -83,9 +103,10 @@ def initialize_database(): "service_name": "VARCHAR(32) NOT NULL UNIQUE", "api_key": "VARCHAR(256) NOT NULL", "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", - "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", } - cursor.execute(f"CREATE TABLE IF NOT EXISTS ai_credential ({', '.join([f'{k} {v}' for k, v in ai_credential_cols.items()])})") + create_sql = f"CREATE TABLE IF NOT EXISTS ai_credential ({', '.join([f'{k} {v}' for k, v in ai_credential_cols.items()])})" + cursor.execute(create_sql) _synchronize_table(cursor, "ai_credential", ai_credential_cols) cursor.execute( @@ -101,9 +122,12 @@ def initialize_database(): "id": "VARCHAR(64) PRIMARY KEY NOT NULL", "name": "VARCHAR(128)", "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", - "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", } - cursor.execute(f"CREATE TABLE IF NOT EXISTS chat_tab ({', '.join([f'{k} {v}' for k, v in chat_tab_cols.items()])})") + create_sql = ( + f"CREATE TABLE IF NOT EXISTS chat_tab ({', '.join([f'{k} {v}' for k, v in chat_tab_cols.items()])})" + ) + cursor.execute(create_sql) _synchronize_table(cursor, "chat_tab", chat_tab_cols) cursor.execute( """ @@ -121,12 +145,15 @@ def initialize_database(): "message": "TEXT NOT NULL", "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", - "FOREIGN KEY (chat_tab_id)": "REFERENCES chat_tab(id)" + "FOREIGN KEY (chat_tab_id)": "REFERENCES chat_tab(id) ON DELETE CASCADE", } - create_chat_message_sql = ", ".join([f"{k} {v}" for k, v in chat_message_cols.items() if not k.startswith("FOREIGN KEY")]) - create_chat_message_sql += f", FOREIGN KEY (chat_tab_id) REFERENCES chat_tab(id)" - cursor.execute(f"CREATE TABLE IF NOT EXISTS chat_message ({create_chat_message_sql})") - _synchronize_table(cursor, "chat_message", {k: v for k, v in chat_message_cols.items() if not k.startswith("FOREIGN KEY")}) + create_sql = ( + f"CREATE TABLE IF NOT EXISTS chat_message ({', '.join([f'{k} {v}' for k, v in chat_message_cols.items()])})" + ) + cursor.execute(create_sql) + _synchronize_table( + cursor, "chat_message", {k: v for k, v in chat_message_cols.items() if not k.startswith("FOREIGN KEY")} + ) cursor.execute( """ @@ -145,12 +172,13 @@ def initialize_database(): "error_message": "TEXT NOT NULL", "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", - "FOREIGN KEY (chat_message_id)": "REFERENCES chat_message(id)" + "FOREIGN KEY (chat_message_id)": "REFERENCES chat_message(id) ON DELETE CASCADE", } - create_query_history_sql = ", ".join([f"{k} {v}" for k, v in query_history_cols.items() if not k.startswith("FOREIGN KEY")]) - create_query_history_sql += f", FOREIGN KEY (chat_message_id) REFERENCES chat_message(id)" - cursor.execute(f"CREATE TABLE IF NOT EXISTS query_history ({create_query_history_sql})") - _synchronize_table(cursor, "query_history", {k: v for k, v in query_history_cols.items() if not k.startswith("FOREIGN KEY")}) + create_sql = f"CREATE TABLE IF NOT EXISTS query_history ({', '.join([f'{k} {v}' for k, v in query_history_cols.items()])})" + cursor.execute(create_sql) + _synchronize_table( + cursor, "query_history", {k: v for k, v in query_history_cols.items() if not k.startswith("FOREIGN KEY")} + ) cursor.execute( """ @@ -160,252 +188,219 @@ def initialize_database(): """ ) - # database_annotation 테이블 생성 - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS database_annotation ( - id VARCHAR(64) PRIMARY KEY NOT NULL, - db_profile_id VARCHAR(64) NOT NULL, - database_name VARCHAR(255) NOT NULL, - description TEXT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (db_profile_id) REFERENCES db_profile(id) ON DELETE CASCADE - ); - """ + # --- database_annotation 테이블 처리 --- + database_annotation_cols = { + "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "db_profile_id": "VARCHAR(64) NOT NULL", + "database_name": "VARCHAR(255) NOT NULL", + "description": "TEXT", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "FOREIGN KEY (db_profile_id)": "REFERENCES db_profile(id) ON DELETE CASCADE", + } + create_sql = f"CREATE TABLE IF NOT EXISTS database_annotation ({', '.join([f'{k} {v}' for k, v in database_annotation_cols.items()])})" + cursor.execute(create_sql) + _synchronize_table( + cursor, + "database_annotation", + {k: v for k, v in database_annotation_cols.items() if not k.startswith("FOREIGN KEY")}, ) - # database_annotation 테이블의 updated_at을 자동으로 업데이트하는 트리거 cursor.execute( """ CREATE TRIGGER IF NOT EXISTS update_database_annotation_updated_at - BEFORE UPDATE ON database_annotation - FOR EACH ROW - BEGIN - UPDATE database_annotation SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; + BEFORE UPDATE ON database_annotation FOR EACH ROW + BEGIN UPDATE database_annotation SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; """ ) - # table_annotation 테이블 생성 - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS table_annotation ( - id VARCHAR(64) PRIMARY KEY NOT NULL, - database_annotation_id VARCHAR(64) NOT NULL, - table_name VARCHAR(255) NOT NULL, - description TEXT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (database_annotation_id) REFERENCES database_annotation(id) ON DELETE CASCADE - ); - """ + # --- table_annotation 테이블 처리 --- + table_annotation_cols = { + "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "database_annotation_id": "VARCHAR(64) NOT NULL", + "table_name": "VARCHAR(255) NOT NULL", + "description": "TEXT", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "FOREIGN KEY (database_annotation_id)": "REFERENCES database_annotation(id) ON DELETE CASCADE", + } + create_sql = f"CREATE TABLE IF NOT EXISTS table_annotation ({', '.join([f'{k} {v}' for k, v in table_annotation_cols.items()])})" + cursor.execute(create_sql) + _synchronize_table( + cursor, + "table_annotation", + {k: v for k, v in table_annotation_cols.items() if not k.startswith("FOREIGN KEY")}, ) - # table_annotation 테이블의 updated_at을 자동으로 업데이트하는 트리거 cursor.execute( """ CREATE TRIGGER IF NOT EXISTS update_table_annotation_updated_at - BEFORE UPDATE ON table_annotation - FOR EACH ROW - BEGIN - UPDATE table_annotation SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; + BEFORE UPDATE ON table_annotation FOR EACH ROW + BEGIN UPDATE table_annotation SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; """ ) - # column_annotation 테이블 생성 (단일 컬럼 스펙 전용) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS column_annotation ( - id VARCHAR(64) PRIMARY KEY NOT NULL, - table_annotation_id VARCHAR(64) NOT NULL, - column_name VARCHAR(255) NOT NULL, - -- 데이터 타입 (원본 DB의 타입 문자열을 그대로 저장; 예: BIGINT, TEXT, TIMESTAMP) - data_type VARCHAR(64), - -- NULL 허용 여부 (1:true, 0:false) - is_nullable INTEGER NOT NULL DEFAULT 1, - -- 기본값(리터럴 또는 표현식; 문자열 형태로 저장) - default_value TEXT, - -- 단일 컬럼 기준 CHECK 제약 표현(예: "value > 0") - check_expression TEXT, - -- 컬럼 순서 - ordinal_position INTEGER, - -- 설명 - description TEXT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (table_annotation_id) REFERENCES table_annotation(id) ON DELETE CASCADE - ); - """ + # --- column_annotation 테이블 처리 --- + column_annotation_cols = { + "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "table_annotation_id": "VARCHAR(64) NOT NULL", + "column_name": "VARCHAR(255) NOT NULL", + "data_type": "VARCHAR(64)", + "is_nullable": "INTEGER NOT NULL DEFAULT 1", + "default_value": "TEXT", + "check_expression": "TEXT", + "ordinal_position": "INTEGER", + "description": "TEXT", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "FOREIGN KEY (table_annotation_id)": "REFERENCES table_annotation(id) ON DELETE CASCADE", + } + create_sql = f"CREATE TABLE IF NOT EXISTS column_annotation ({', '.join([f'{k} {v}' for k, v in column_annotation_cols.items()])})" + cursor.execute(create_sql) + _synchronize_table( + cursor, + "column_annotation", + {k: v for k, v in column_annotation_cols.items() if not k.startswith("FOREIGN KEY")}, ) - # column_annotation 테이블의 updated_at을 자동으로 업데이트하는 트리거 cursor.execute( """ CREATE TRIGGER IF NOT EXISTS update_column_annotation_updated_at - BEFORE UPDATE ON column_annotation - FOR EACH ROW - BEGIN - UPDATE column_annotation SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; + BEFORE UPDATE ON column_annotation FOR EACH ROW + BEGIN UPDATE column_annotation SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; """ ) - # table_relationship 테이블 생성 - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS table_relationship ( - id VARCHAR(64) PRIMARY KEY NOT NULL, - database_annotation_id VARCHAR(64) NOT NULL, - from_table_id VARCHAR(64) NOT NULL, - to_table_id VARCHAR(64) NOT NULL, - relationship_type VARCHAR(32) NOT NULL, - description TEXT, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (database_annotation_id) REFERENCES database_annotation(id) ON DELETE CASCADE, - FOREIGN KEY (from_table_id) REFERENCES table_annotation(id) ON DELETE CASCADE, - FOREIGN KEY (to_table_id) REFERENCES table_annotation(id) ON DELETE CASCADE - ); - """ + # --- table_relationship 테이블 처리 --- + table_relationship_cols = { + "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "database_annotation_id": "VARCHAR(64) NOT NULL", + "from_table_id": "VARCHAR(64) NOT NULL", + "to_table_id": "VARCHAR(64) NOT NULL", + "relationship_type": "VARCHAR(32) NOT NULL", + "description": "TEXT", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "FOREIGN KEY (database_annotation_id)": "REFERENCES database_annotation(id) ON DELETE CASCADE", + "FOREIGN KEY (from_table_id)": "REFERENCES table_annotation(id) ON DELETE CASCADE", + "FOREIGN KEY (to_table_id)": "REFERENCES table_annotation(id) ON DELETE CASCADE", + } + create_sql = f"CREATE TABLE IF NOT EXISTS table_relationship ({', '.join([f'{k} {v}' for k, v in table_relationship_cols.items()])})" + cursor.execute(create_sql) + _synchronize_table( + cursor, + "table_relationship", + {k: v for k, v in table_relationship_cols.items() if not k.startswith("FOREIGN KEY")}, ) - # table_relationship 테이블의 updated_at을 자동으로 업데이트하는 트리거 cursor.execute( """ CREATE TRIGGER IF NOT EXISTS update_table_relationship_updated_at - BEFORE UPDATE ON table_relationship - FOR EACH ROW - BEGIN - UPDATE table_relationship SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; + BEFORE UPDATE ON table_relationship FOR EACH ROW + BEGIN UPDATE table_relationship SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; """ ) - # --------------------------------------------------------------------- - # 복합 제약(Primary/Unique/ForeignKey/Check) 메타데이터 테이블 생성 - # - 여러 컬럼을 묶는 제약을 '그룹' 단위로 관리 - # - UI 배지/목록은 이 테이블들에서 파생 계산 - # --------------------------------------------------------------------- - - # table_constraint 테이블 생성 (제약 그룹 본체) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS table_constraint ( - id VARCHAR(64) PRIMARY KEY NOT NULL, - table_annotation_id VARCHAR(64) NOT NULL, - -- PRIMARY_KEY | UNIQUE | FOREIGN_KEY | CHECK - constraint_type VARCHAR(16) NOT NULL, - -- DB 제약명(선택) - name VARCHAR(255), - -- CHECK 제약식 등 (FK/PK/UNIQUE에는 NULL 가능) - expression TEXT, - -- FOREIGN KEY 전용: 참조 테이블명 - ref_table VARCHAR(255), - -- FOREIGN KEY 전용: 액션 - on_update_action VARCHAR(16), - on_delete_action VARCHAR(16), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (table_annotation_id) REFERENCES table_annotation(id) ON DELETE CASCADE - ); - """ + # --- table_constraint 테이블 처리 --- + table_constraint_cols = { + "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "table_annotation_id": "VARCHAR(64) NOT NULL", + "constraint_type": "VARCHAR(16) NOT NULL", + "name": "VARCHAR(255)", + "expression": "TEXT", + "ref_table": "VARCHAR(255)", + "on_update_action": "VARCHAR(16)", + "on_delete_action": "VARCHAR(16)", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "FOREIGN KEY (table_annotation_id)": "REFERENCES table_annotation(id) ON DELETE CASCADE", + } + create_sql = f"CREATE TABLE IF NOT EXISTS table_constraint ({', '.join([f'{k} {v}' for k, v in table_constraint_cols.items()])})" + cursor.execute(create_sql) + _synchronize_table( + cursor, + "table_constraint", + {k: v for k, v in table_constraint_cols.items() if not k.startswith("FOREIGN KEY")}, ) - # table_constraint 테이블의 updated_at을 자동으로 업데이트하는 트리거 cursor.execute( """ CREATE TRIGGER IF NOT EXISTS update_table_constraint_updated_at - BEFORE UPDATE ON table_constraint - FOR EACH ROW - BEGIN - UPDATE table_constraint SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; + BEFORE UPDATE ON table_constraint FOR EACH ROW + BEGIN UPDATE table_constraint SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; """ ) - # constraint_column 테이블 생성 (제약 그룹 ↔ 컬럼 매핑) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS constraint_column ( - id VARCHAR(64) PRIMARY KEY NOT NULL, - constraint_id VARCHAR(64) NOT NULL, - column_annotation_id VARCHAR(64) NOT NULL, - -- 복합 제약 내 컬럼 순서(1, 2, 3, ...) - position INTEGER, - -- FOREIGN KEY 전용: 참조 테이블의 대응 컬럼명 (복합 FK 매핑) - referenced_column_name VARCHAR(255), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (constraint_id) REFERENCES table_constraint(id) ON DELETE CASCADE, - FOREIGN KEY (column_annotation_id) REFERENCES column_annotation(id) ON DELETE CASCADE - ); - """ + # --- constraint_column 테이블 처리 --- + constraint_column_cols = { + "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "constraint_id": "VARCHAR(64) NOT NULL", + "column_annotation_id": "VARCHAR(64) NOT NULL", + "position": "INTEGER", + "referenced_column_name": "VARCHAR(255)", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "FOREIGN KEY (constraint_id)": "REFERENCES table_constraint(id) ON DELETE CASCADE", + "FOREIGN KEY (column_annotation_id)": "REFERENCES column_annotation(id) ON DELETE CASCADE", + } + create_sql = f"CREATE TABLE IF NOT EXISTS constraint_column ({', '.join([f'{k} {v}' for k, v in constraint_column_cols.items()])})" + cursor.execute(create_sql) + _synchronize_table( + cursor, + "constraint_column", + {k: v for k, v in constraint_column_cols.items() if not k.startswith("FOREIGN KEY")}, ) - # constraint_column 테이블의 updated_at을 자동으로 업데이트하는 트리거 cursor.execute( """ CREATE TRIGGER IF NOT EXISTS update_constraint_column_updated_at - BEFORE UPDATE ON constraint_column - FOR EACH ROW - BEGIN - UPDATE constraint_column SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; + BEFORE UPDATE ON constraint_column FOR EACH ROW + BEGIN UPDATE constraint_column SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; """ ) - # --------------------------------------------------------------------- - # 인덱스(복합 포함) 메타데이터 테이블 생성 - # - DB 인덱스명을 보존하고, 컬럼 순서를 기록 - # --------------------------------------------------------------------- - - # index_annotation 테이블 생성 (인덱스 그룹 본체) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS index_annotation ( - id VARCHAR(64) PRIMARY KEY NOT NULL, - table_annotation_id VARCHAR(64) NOT NULL, - name VARCHAR(255), -- DB 인덱스명(선택) - is_unique INTEGER NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (table_annotation_id) REFERENCES table_annotation(id) ON DELETE CASCADE - ); - """ + # --- index_annotation 테이블 처리 --- + index_annotation_cols = { + "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "table_annotation_id": "VARCHAR(64) NOT NULL", + "name": "VARCHAR(255)", + "is_unique": "INTEGER NOT NULL DEFAULT 0", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "FOREIGN KEY (table_annotation_id)": "REFERENCES table_annotation(id) ON DELETE CASCADE", + } + create_sql = f"CREATE TABLE IF NOT EXISTS index_annotation ({', '.join([f'{k} {v}' for k, v in index_annotation_cols.items()])})" + cursor.execute(create_sql) + _synchronize_table( + cursor, + "index_annotation", + {k: v for k, v in index_annotation_cols.items() if not k.startswith("FOREIGN KEY")}, ) - # index_annotation 테이블의 updated_at을 자동으로 업데이트하는 트리거 cursor.execute( """ CREATE TRIGGER IF NOT EXISTS update_index_annotation_updated_at - BEFORE UPDATE ON index_annotation - FOR EACH ROW - BEGIN - UPDATE index_annotation SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; + BEFORE UPDATE ON index_annotation FOR EACH ROW + BEGIN UPDATE index_annotation SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; """ ) - # index_column 테이블 생성 (인덱스 그룹 ↔ 컬럼 매핑) - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS index_column ( - id VARCHAR(64) PRIMARY KEY NOT NULL, - index_id VARCHAR(64) NOT NULL, - column_annotation_id VARCHAR(64) NOT NULL, - -- 인덱스 내 컬럼 순서(1, 2, 3, ...) - position INTEGER, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (index_id) REFERENCES index_annotation(id) ON DELETE CASCADE, - FOREIGN KEY (column_annotation_id) REFERENCES column_annotation(id) ON DELETE CASCADE - ); - """ + # --- index_column 테이블 처리 --- + index_column_cols = { + "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "index_id": "VARCHAR(64) NOT NULL", + "column_annotation_id": "VARCHAR(64) NOT NULL", + "position": "INTEGER", + "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "FOREIGN KEY (index_id)": "REFERENCES index_annotation(id) ON DELETE CASCADE", + "FOREIGN KEY (column_annotation_id)": "REFERENCES column_annotation(id) ON DELETE CASCADE", + } + create_sql = ( + f"CREATE TABLE IF NOT EXISTS index_column ({', '.join([f'{k} {v}' for k, v in index_column_cols.items()])})" + ) + cursor.execute(create_sql) + _synchronize_table( + cursor, "index_column", {k: v for k, v in index_column_cols.items() if not k.startswith("FOREIGN KEY")} ) - # index_column 테이블의 updated_at을 자동으로 업데이트하는 트리거 cursor.execute( """ CREATE TRIGGER IF NOT EXISTS update_index_column_updated_at - BEFORE UPDATE ON index_column - FOR EACH ROW - BEGIN - UPDATE index_column SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END; + BEFORE UPDATE ON index_column FOR EACH ROW + BEGIN UPDATE index_column SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END; """ ) From 7a88e1b83287cbe142acac264534d637c986a665 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 16:48:52 +0900 Subject: [PATCH 142/220] =?UTF-8?q?refactor:=20chat=5Ftab=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20utils=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/chat_tab/create_model.py | 24 ++-------------------- app/schemas/chat_tab/validation_utils.py | 26 ++++++++++++++++++++++++ app/services/chat_tab_service.py | 3 ++- 3 files changed, 30 insertions(+), 23 deletions(-) create mode 100644 app/schemas/chat_tab/validation_utils.py diff --git a/app/schemas/chat_tab/create_model.py b/app/schemas/chat_tab/create_model.py index f811adc..620cdf6 100644 --- a/app/schemas/chat_tab/create_model.py +++ b/app/schemas/chat_tab/create_model.py @@ -1,30 +1,10 @@ -import re -from app.core.exceptions import APIException -from app.core.status import CommonCode from app.schemas.chat_tab.base_model import AIChatBase +from app.schemas.chat_tab.validation_utils import validate_chat_name class AIChatCreate(AIChatBase): """새로운 Chat Tab 생성을 위한 스키마""" def validate_with_name(self) -> None: - """채팅 탭 이름에 대한 유효성 검증 로직을 수행합니다.""" - # 1. 문자열 전체가 공백 문자인지 확인 - if not self.name or self.name.isspace(): - raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_FORMAT) - - # 2. 길이 제한 - if len(self.name) > 128: - raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_LENGTH) - - # 3. 특수문자 및 SQL 예약어 확인 - # SQL 예약어와 위험한 특수문자를 검사합니다. - sql_keywords = ["SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "OR", "AND"] - for keyword in sql_keywords: - if keyword in self.name.upper(): - raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) - - # 특정 특수문자를 검사하는 예시 - if re.search(r"[;\"'`<>]", self.name): - raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) + validate_chat_name(self.name) diff --git a/app/schemas/chat_tab/validation_utils.py b/app/schemas/chat_tab/validation_utils.py new file mode 100644 index 0000000..eb1b1af --- /dev/null +++ b/app/schemas/chat_tab/validation_utils.py @@ -0,0 +1,26 @@ +import re + +from app.core.exceptions import APIException +from app.core.status import CommonCode + + +def validate_chat_name(name: str | None) -> None: + """채팅 탭 이름에 대한 유효성 검증 로직을 수행합니다.""" + # 1. 문자열 전체가 공백 문자인지 확인 + if name is None or name.isspace(): + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_FORMAT) + + # 2. 길이 제한 + if len(name) > 128: + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_LENGTH) + + # 3. 특수문자 및 SQL 예약어 확인 + # SQL 예약어와 위험한 특수문자를 검사합니다. + sql_keywords = ["SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "OR", "AND"] + for keyword in sql_keywords: + if keyword in name.upper(): + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) + + # 특정 특수문자를 검사하는 예시 + if re.search(r"[;\"'`<>]", name): + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) diff --git a/app/services/chat_tab_service.py b/app/services/chat_tab_service.py index 27ac918..ce6804a 100644 --- a/app/services/chat_tab_service.py +++ b/app/services/chat_tab_service.py @@ -8,6 +8,7 @@ from app.repository.chat_tab_repository import AIChatRepository, ai_chat_repository from app.schemas.chat_tab.create_model import AIChatCreate from app.schemas.chat_tab.db_model import AIChatInDB +from app.schemas.chat_tab.validation_utils import validate_chat_name ai_chat_repository_dependency = Depends(lambda: ai_chat_repository) @@ -18,7 +19,7 @@ def __init__(self, repository: AIChatRepository = ai_chat_repository): def store_ai_chat(self, chatName: AIChatCreate) -> AIChatInDB: """새로운 AI 채팅을 데이터베이스에 저장합니다.""" - chatName.validate_with_name() + validate_chat_name(chatName.name) new_id = generate_prefixed_uuid("CHAT_TAB") From d98aade200e64fcffc444db05968935441eec775 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 17:07:12 +0900 Subject: [PATCH 143/220] =?UTF-8?q?refactor:=20=EB=B3=80=EC=88=98=EB=AA=85?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=EC=9D=B4=20=EB=8D=9C=20=EB=90=9C=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/chat_tab_api.py | 22 +++++++++++----------- app/core/status.py | 2 +- app/repository/chat_tab_repository.py | 10 +++++----- app/schemas/chat_tab/base_model.py | 2 +- app/schemas/chat_tab/create_model.py | 8 ++++---- app/schemas/chat_tab/db_model.py | 4 ++-- app/schemas/chat_tab/response_model.py | 4 ++-- app/schemas/chat_tab/validation_utils.py | 2 +- app/services/chat_tab_service.py | 22 +++++++++++----------- 9 files changed, 38 insertions(+), 38 deletions(-) diff --git a/app/api/chat_tab_api.py b/app/api/chat_tab_api.py index 7aa4bcb..e835c55 100644 --- a/app/api/chat_tab_api.py +++ b/app/api/chat_tab_api.py @@ -2,33 +2,33 @@ from app.core.response import ResponseMessage from app.core.status import CommonCode -from app.schemas.chat_tab.create_model import AIChatCreate -from app.schemas.chat_tab.response_model import AIChatResponse -from app.services.chat_tab_service import AIChatService, ai_chat_service +from app.schemas.chat_tab.create_model import ChatTabCreate +from app.schemas.chat_tab.response_model import ChatTabResponse +from app.services.chat_tab_service import ChatTabService, chat_tab_service -ai_chat_service_dependency = Depends(lambda: ai_chat_service) +chat_tab_service_dependency = Depends(lambda: chat_tab_service) router = APIRouter() @router.post( "/actions", - response_model=ResponseMessage[AIChatResponse], + response_model=ResponseMessage[ChatTabResponse], summary="Chat Tab 생성", description="새로운 Chat Tab을 생성하여 로컬 데이터베이스에 저장합니다.", ) -def store_ai_chat( - chatName: AIChatCreate, service: AIChatService = ai_chat_service_dependency -) -> ResponseMessage[AIChatResponse]: +def store_chat_tab( + chatName: ChatTabCreate, service: ChatTabService = chat_tab_service_dependency +) -> ResponseMessage[ChatTabResponse]: """ - **name**: 새로운 Chat_tab 이름 (예: "채팅 타이틀") """ - created_chat = service.store_ai_chat(chatName) + created_chat = service.store_chat_tab(chatName) - response_data = AIChatResponse( + response_data = ChatTabResponse( id=created_chat.id, name=created_chat.name, created_at=created_chat.created_at, updated_at=created_chat.updated_at, ) - return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_AI_CHAT_CREATE) + return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_CHAT_TAB_CREATE) diff --git a/app/core/status.py b/app/core/status.py index 6da4e25..f4c21b7 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -35,7 +35,7 @@ class CommonCode(Enum): SUCCESS_GET_API_KEY = (status.HTTP_200_OK, "2202", "API KEY 정보를 성공적으로 조회했습니다.") """ AI CHAT, DB 성공 코드 - 23xx """ - SUCCESS_AI_CHAT_CREATE = (status.HTTP_200_OK, "2300", "새로운 채팅 탭을 생성하였습니다.") + SUCCESS_CHAT_TAB_CREATE = (status.HTTP_200_OK, "2300", "새로운 채팅 탭을 생성하였습니다.") """ ANNOTATION 성공 코드 - 24xx """ diff --git a/app/repository/chat_tab_repository.py b/app/repository/chat_tab_repository.py index bf96fe8..4807b4b 100644 --- a/app/repository/chat_tab_repository.py +++ b/app/repository/chat_tab_repository.py @@ -1,12 +1,12 @@ import sqlite3 from app.core.utils import get_db_path -from app.schemas.chat_tab.db_model import AIChatInDB +from app.schemas.chat_tab.db_model import ChatTabInDB -class AIChatRepository: +class ChatTabRepository: - def create_ai_chat(self, new_id: str, name: str) -> AIChatInDB: + def create_chat_tab(self, new_id: str, name: str) -> ChatTabInDB: """ 암호화된 API Key 정보를 받아 데이터베이스에 저장하고, 저장된 객체를 반환합니다. @@ -36,11 +36,11 @@ def create_ai_chat(self, new_id: str, name: str) -> AIChatInDB: if not created_row: raise None - return AIChatInDB.model_validate(dict(created_row)) + return ChatTabInDB.model_validate(dict(created_row)) finally: if conn: conn.close() -ai_chat_repository = AIChatRepository() +chat_tab_repository = ChatTabRepository() diff --git a/app/schemas/chat_tab/base_model.py b/app/schemas/chat_tab/base_model.py index d59c248..84d600b 100644 --- a/app/schemas/chat_tab/base_model.py +++ b/app/schemas/chat_tab/base_model.py @@ -1,7 +1,7 @@ from pydantic import BaseModel, Field -class AIChatBase(BaseModel): +class ChatTabBase(BaseModel): """모든 AI Chat Tab 스키마의 기본 모델""" name: str = Field(..., description="새로운 채팅 탭 이름") diff --git a/app/schemas/chat_tab/create_model.py b/app/schemas/chat_tab/create_model.py index 620cdf6..f5be4d8 100644 --- a/app/schemas/chat_tab/create_model.py +++ b/app/schemas/chat_tab/create_model.py @@ -1,10 +1,10 @@ -from app.schemas.chat_tab.base_model import AIChatBase -from app.schemas.chat_tab.validation_utils import validate_chat_name +from app.schemas.chat_tab.base_model import ChatTabBase +from app.schemas.chat_tab.validation_utils import validate_chat_tab_name -class AIChatCreate(AIChatBase): +class ChatTabCreate(ChatTabBase): """새로운 Chat Tab 생성을 위한 스키마""" def validate_with_name(self) -> None: - validate_chat_name(self.name) + validate_chat_tab_name(self.name) diff --git a/app/schemas/chat_tab/db_model.py b/app/schemas/chat_tab/db_model.py index 833f95a..7f3ab1d 100644 --- a/app/schemas/chat_tab/db_model.py +++ b/app/schemas/chat_tab/db_model.py @@ -1,9 +1,9 @@ from datetime import datetime -from app.schemas.chat_tab.base_model import AIChatBase +from app.schemas.chat_tab.base_model import ChatTabBase -class AIChatInDB(AIChatBase): +class ChatTabInDB(ChatTabBase): """데이터베이스에 저장된 형태의 스키마 (내부용)""" id: str diff --git a/app/schemas/chat_tab/response_model.py b/app/schemas/chat_tab/response_model.py index 5a1c46f..0d2cf15 100644 --- a/app/schemas/chat_tab/response_model.py +++ b/app/schemas/chat_tab/response_model.py @@ -2,10 +2,10 @@ from pydantic import Field -from app.schemas.chat_tab.base_model import AIChatBase +from app.schemas.chat_tab.base_model import ChatTabBase -class AIChatResponse(AIChatBase): +class ChatTabResponse(ChatTabBase): """AI 채팅 탭 정보 API 응답용 스키마""" id: str = Field(..., description="채팅 세션의 고유 ID (서버에서 생성)") diff --git a/app/schemas/chat_tab/validation_utils.py b/app/schemas/chat_tab/validation_utils.py index eb1b1af..693b809 100644 --- a/app/schemas/chat_tab/validation_utils.py +++ b/app/schemas/chat_tab/validation_utils.py @@ -4,7 +4,7 @@ from app.core.status import CommonCode -def validate_chat_name(name: str | None) -> None: +def validate_chat_tab_name(name: str | None) -> None: """채팅 탭 이름에 대한 유효성 검증 로직을 수행합니다.""" # 1. 문자열 전체가 공백 문자인지 확인 if name is None or name.isspace(): diff --git a/app/services/chat_tab_service.py b/app/services/chat_tab_service.py index ce6804a..8a52a34 100644 --- a/app/services/chat_tab_service.py +++ b/app/services/chat_tab_service.py @@ -5,26 +5,26 @@ from app.core.exceptions import APIException from app.core.status import CommonCode from app.core.utils import generate_prefixed_uuid -from app.repository.chat_tab_repository import AIChatRepository, ai_chat_repository -from app.schemas.chat_tab.create_model import AIChatCreate -from app.schemas.chat_tab.db_model import AIChatInDB -from app.schemas.chat_tab.validation_utils import validate_chat_name +from app.repository.chat_tab_repository import ChatTabRepository, chat_tab_repository +from app.schemas.chat_tab.create_model import ChatTabCreate +from app.schemas.chat_tab.db_model import ChatTabInDB +from app.schemas.chat_tab.validation_utils import validate_chat_tab_name -ai_chat_repository_dependency = Depends(lambda: ai_chat_repository) +chat_tab_repository_dependency = Depends(lambda: chat_tab_repository) -class AIChatService: - def __init__(self, repository: AIChatRepository = ai_chat_repository): +class ChatTabService: + def __init__(self, repository: ChatTabRepository = chat_tab_repository): self.repository = repository - def store_ai_chat(self, chatName: AIChatCreate) -> AIChatInDB: + def store_chat_tab(self, chatName: ChatTabCreate) -> ChatTabInDB: """새로운 AI 채팅을 데이터베이스에 저장합니다.""" - validate_chat_name(chatName.name) + validate_chat_tab_name(chatName.name) new_id = generate_prefixed_uuid("CHAT_TAB") try: - created_row = self.repository.create_ai_chat( + created_row = self.repository.create_chat_tab( new_id=new_id, name=chatName.name, ) @@ -41,4 +41,4 @@ def store_ai_chat(self, chatName: AIChatCreate) -> AIChatInDB: raise APIException(CommonCode.FAIL) from e -ai_chat_service = AIChatService() +chat_tab_service = ChatTabService() From 7f5e139a08fe618da8a0690d496db1abd38601c3 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 18:07:26 +0900 Subject: [PATCH 144/220] =?UTF-8?q?feat:=20api=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/chat_tab_api.py | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/app/api/chat_tab_api.py b/app/api/chat_tab_api.py index e835c55..70b01cc 100644 --- a/app/api/chat_tab_api.py +++ b/app/api/chat_tab_api.py @@ -1,9 +1,10 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Path from app.core.response import ResponseMessage from app.core.status import CommonCode from app.schemas.chat_tab.create_model import ChatTabCreate from app.schemas.chat_tab.response_model import ChatTabResponse +from app.schemas.chat_tab.update_model import ChatTabUpdate from app.services.chat_tab_service import ChatTabService, chat_tab_service chat_tab_service_dependency = Depends(lambda: chat_tab_service) @@ -23,12 +24,38 @@ def store_chat_tab( """ - **name**: 새로운 Chat_tab 이름 (예: "채팅 타이틀") """ - created_chat = service.store_chat_tab(chatName) + created_chat_tab = service.store_chat_tab(chatName) response_data = ChatTabResponse( - id=created_chat.id, - name=created_chat.name, - created_at=created_chat.created_at, - updated_at=created_chat.updated_at, + id=created_chat_tab.id, + name=created_chat_tab.name, + created_at=created_chat_tab.created_at, + updated_at=created_chat_tab.updated_at, ) return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_CHAT_TAB_CREATE) + +@router.put( + "/modify/{tabId}", + response_model=ResponseMessage[ChatTabResponse], + summary="특정 Chat Tab Name 수정", +) +def updated_chat_tab( + chatName: ChatTabUpdate, + tabId: str = Path(..., description="수정할 채팅 탭의 고유 ID"), + service: ChatTabService = chat_tab_service_dependency +) -> ResponseMessage[ChatTabResponse]: + """ + 채팅 탭 ID를 기준으로 채팅 탭의 이름을 새로운 값으로 수정합니다. + - **id**: 수정할 채팅 탭 ID + - **name**: 새로운 채팅 탭의 이름 + """ + updated_chat_tab = service.updated_chat_tab(tabId, chatName) + + response_data = ChatTabResponse( + id=updated_chat_tab.id, + name=updated_chat_tab.name, + created_at=updated_chat_tab.created_at, + updated_at=updated_chat_tab.updated_at, + ) + + return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_CHAT_TAB_UPDATE) From a8ddacc6aeb13178d3e18ac164315e9683011264 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 18:07:58 +0900 Subject: [PATCH 145/220] =?UTF-8?q?feat:=20chat=20tab=20status=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index f4c21b7..8f699ff 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -35,7 +35,8 @@ class CommonCode(Enum): SUCCESS_GET_API_KEY = (status.HTTP_200_OK, "2202", "API KEY 정보를 성공적으로 조회했습니다.") """ AI CHAT, DB 성공 코드 - 23xx """ - SUCCESS_CHAT_TAB_CREATE = (status.HTTP_200_OK, "2300", "새로운 채팅 탭을 생성하였습니다.") + SUCCESS_CHAT_TAB_CREATE = (status.HTTP_200_OK, "2300", "새로운 채팅 탭을 성공적으로 생성되었습니다.") + SUCCESS_CHAT_TAB_UPDATE = (status.HTTP_200_OK, "2301", "채팅 탭 이름 수정이 성공적으로 처리되었습니다.") """ ANNOTATION 성공 코드 - 24xx """ @@ -62,8 +63,9 @@ class CommonCode(Enum): "API 키가 선택한 서비스의 올바른 형식이 아닙니다. (예: OpenAI는 sk-로 시작)", ) - """ AI CHAT, DB 클라이언트 에러 코드 - 43xx """ - INVALID_CHAT_TAB_NAME_FORMAT = (status.HTTP_400_BAD_REQUEST, "4300", "채팅 탭 이름의 형식이 올바르지 않습니다.") + """ AI CHAT TAB 클라이언트 오류 코드 - 43xx """ + INVALID_CHAT_TAB_NAME_FORMAT = (status.HTTP_400_BAD_REQUEST, "4300", "채팅 탭 이름은 공백 또는 빈 값일 수 없습니다.") + INVALID_CHAT_TAB_NAME_LENGTH = ( status.HTTP_400_BAD_REQUEST, "4301", From 1df9969dd6dd1d85e1d92e179a9370d89a5beb36 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 18:09:06 +0900 Subject: [PATCH 146/220] =?UTF-8?q?feat:=20repository=20update=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/chat_tab_repository.py | 35 +++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/app/repository/chat_tab_repository.py b/app/repository/chat_tab_repository.py index 4807b4b..f3a8c74 100644 --- a/app/repository/chat_tab_repository.py +++ b/app/repository/chat_tab_repository.py @@ -8,8 +8,7 @@ class ChatTabRepository: def create_chat_tab(self, new_id: str, name: str) -> ChatTabInDB: """ - 암호화된 API Key 정보를 받아 데이터베이스에 저장하고, - 저장된 객체를 반환합니다. + 새로운 채팅 탭 이름을 데이터베이스에 저장하고, 저장된 객체를 반환합니다. """ db_path = get_db_path() conn = None @@ -41,6 +40,38 @@ def create_chat_tab(self, new_id: str, name: str) -> ChatTabInDB: finally: if conn: conn.close() + def updated_chat_tab(self, id: str, new_name: str | None) -> ChatTabInDB | None: + """채팅 탭ID에 해당하는 ChatName를 수정하고, 수정된 객체를 반환합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # 먼저 해당 서비스의 데이터가 존재하는지 확인 + cursor.execute("SELECT id FROM chat_tab WHERE id = ?", (id,)) + if not cursor.fetchone(): + return None + + # 데이터 업데이트 + cursor.execute( + "UPDATE chat_tab SET name = ?, updated_at = datetime('now', 'localtime') WHERE id = ?", + (new_name, id), + ) + conn.commit() + + # rowcount가 0이면 업데이트된 행이 없음 (정상적인 경우 발생하기 어려움) + if cursor.rowcount == 0: + return None + + cursor.execute("SELECT * FROM chat_tab WHERE id = ?", (id,)) + updated_row = cursor.fetchone() + + return ChatTabInDB.model_validate(dict(updated_row)) + finally: + if conn: + conn.close() chat_tab_repository = ChatTabRepository() From 6296ad49659887ea52f9f30935212321afa36bdf Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 18:09:34 +0900 Subject: [PATCH 147/220] =?UTF-8?q?feat:=20update=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/chat_tab/update_model.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 app/schemas/chat_tab/update_model.py diff --git a/app/schemas/chat_tab/update_model.py b/app/schemas/chat_tab/update_model.py new file mode 100644 index 0000000..4227463 --- /dev/null +++ b/app/schemas/chat_tab/update_model.py @@ -0,0 +1,12 @@ +from pydantic import Field + +from app.schemas.chat_tab.base_model import ChatTabBase +from app.schemas.chat_tab.validation_utils import validate_chat_tab_name + + +class ChatTabUpdate(ChatTabBase): + """채팅 탭 이름 수정을 위한 스키마""" + name: str | None = Field(None, description="수정할 채팅 탭 이름") + + def validate_with_name(self) -> None: + validate_chat_tab_name(self.name) From 55ea26afc9f94058911fae4eedd21d895f55c10b Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 18:09:46 +0900 Subject: [PATCH 148/220] =?UTF-8?q?feat:=20update=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/chat_tab_service.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/services/chat_tab_service.py b/app/services/chat_tab_service.py index 8a52a34..f543a88 100644 --- a/app/services/chat_tab_service.py +++ b/app/services/chat_tab_service.py @@ -8,6 +8,7 @@ from app.repository.chat_tab_repository import ChatTabRepository, chat_tab_repository from app.schemas.chat_tab.create_model import ChatTabCreate from app.schemas.chat_tab.db_model import ChatTabInDB +from app.schemas.chat_tab.update_model import ChatTabUpdate from app.schemas.chat_tab.validation_utils import validate_chat_tab_name chat_tab_repository_dependency = Depends(lambda: chat_tab_repository) @@ -39,6 +40,20 @@ def store_chat_tab(self, chatName: ChatTabCreate) -> ChatTabInDB: raise APIException(CommonCode.DB_BUSY) from e # 기타 모든 sqlite3 오류 raise APIException(CommonCode.FAIL) from e + def updated_chat_tab(self, chatID: str, chatName: ChatTabUpdate) -> ChatTabInDB: + """서비스 이름에 해당하는 API Key를 수정합니다.""" + validate_chat_tab_name(chatName.name) + try: + updated_chat_tab = self.repository.updated_chat_tab(chatID, chatName.name) + + if not updated_chat_tab: + raise APIException(CommonCode.NO_SEARCH_DATA) + + return updated_chat_tab + except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + raise APIException(CommonCode.FAIL) from e chat_tab_service = ChatTabService() From a6950f80eb8ae2dc70ed20c7b418a8879c2d7921 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 18:10:31 +0900 Subject: [PATCH 149/220] =?UTF-8?q?refactor:=20=EA=B3=B5=EB=B0=B1=EC=9D=BC?= =?UTF-8?q?=20=EB=95=8C=20=EB=AC=B4=EC=8B=9C=EB=90=98=EB=8D=98=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/chat_tab/validation_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/schemas/chat_tab/validation_utils.py b/app/schemas/chat_tab/validation_utils.py index 693b809..6d453ce 100644 --- a/app/schemas/chat_tab/validation_utils.py +++ b/app/schemas/chat_tab/validation_utils.py @@ -6,8 +6,8 @@ def validate_chat_tab_name(name: str | None) -> None: """채팅 탭 이름에 대한 유효성 검증 로직을 수행합니다.""" - # 1. 문자열 전체가 공백 문자인지 확인 - if name is None or name.isspace(): + # 1. 문자열이 None, 문자열 전체가 공백 문자인지 확인 + if not name or name.isspace(): raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_FORMAT) # 2. 길이 제한 From ce502f152546d582dbf9ca537074c8462756fe39 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 18:42:43 +0900 Subject: [PATCH 150/220] =?UTF-8?q?feat:=20delete=20api=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/chat_tab_api.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/api/chat_tab_api.py b/app/api/chat_tab_api.py index 70b01cc..cc73197 100644 --- a/app/api/chat_tab_api.py +++ b/app/api/chat_tab_api.py @@ -59,3 +59,19 @@ def updated_chat_tab( ) return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_CHAT_TAB_UPDATE) + +@router.delete( + "/remove/{tabId}", + response_model=ResponseMessage, + summary="특정 Chat Tab 삭제", +) +def delete_chat_tab( + tabId: str = Path(..., description="수정할 채팅 탭의 고유 ID"), + service: ChatTabService = chat_tab_service_dependency +) -> ResponseMessage: + """ + 채팅 탭 ID를 기준으로 채팅 탭을 삭제합니다. + - **id**: 삭제할 채팅 탭 ID + """ + service.delete_chat_tab(tabId) + return ResponseMessage.success(code=CommonCode.SUCCESS_CHAT_TAB_DELETE) From 40c31d6d5d3f02b42c9437d422a016b0eec8a4d1 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 18:43:28 +0900 Subject: [PATCH 151/220] =?UTF-8?q?feat:=20delete=20=EC=84=B1=EA=B3=B5=20s?= =?UTF-8?q?tatus=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index 8f699ff..ce25f6a 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -35,8 +35,7 @@ class CommonCode(Enum): SUCCESS_GET_API_KEY = (status.HTTP_200_OK, "2202", "API KEY 정보를 성공적으로 조회했습니다.") """ AI CHAT, DB 성공 코드 - 23xx """ - SUCCESS_CHAT_TAB_CREATE = (status.HTTP_200_OK, "2300", "새로운 채팅 탭을 성공적으로 생성되었습니다.") - SUCCESS_CHAT_TAB_UPDATE = (status.HTTP_200_OK, "2301", "채팅 탭 이름 수정이 성공적으로 처리되었습니다.") + SUCCESS_AI_CHAT_CREATE = (status.HTTP_200_OK, "2300", "새로운 채팅 탭을 생성하였습니다.") """ ANNOTATION 성공 코드 - 24xx """ @@ -64,8 +63,7 @@ class CommonCode(Enum): ) """ AI CHAT TAB 클라이언트 오류 코드 - 43xx """ - INVALID_CHAT_TAB_NAME_FORMAT = (status.HTTP_400_BAD_REQUEST, "4300", "채팅 탭 이름은 공백 또는 빈 값일 수 없습니다.") - + INVALID_CHAT_TAB_NAME_FORMAT = (status.HTTP_400_BAD_REQUEST, "4300", "채팅 탭 이름의 형식이 올바르지 않습니다.") INVALID_CHAT_TAB_NAME_LENGTH = ( status.HTTP_400_BAD_REQUEST, "4301", From 6dae64fa13e646308e455fa1cb91bf38bea1a389 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 18:44:01 +0900 Subject: [PATCH 152/220] =?UTF-8?q?feat:=20delete=20repository=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/chat_tab_repository.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/repository/chat_tab_repository.py b/app/repository/chat_tab_repository.py index f3a8c74..d3665ca 100644 --- a/app/repository/chat_tab_repository.py +++ b/app/repository/chat_tab_repository.py @@ -73,5 +73,31 @@ def updated_chat_tab(self, id: str, new_name: str | None) -> ChatTabInDB | None: if conn: conn.close() + def delete_chat_tab(self, id: str) -> bool: + """채팅 탭ID에 해당하는 ChatTab을 삭제하고, 성공 여부를 반환합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + cursor = conn.cursor() + + # 먼저 해당 서비스의 데이터가 존재하는지 확인 + cursor.execute("SELECT id FROM chat_tab WHERE id = ?", (id,)) + if not cursor.fetchone(): + return False + + # 데이터 삭제 + cursor.execute("DELETE FROM chat_tab WHERE id = ?", (id,)) + conn.commit() + + # rowcount가 0이면 삭제된 행이 없음 (정상적인 경우 발생하기 어려움) + if cursor.rowcount == 0: + return False + + return cursor.rowcount > 0 + finally: + if conn: + conn.close() + chat_tab_repository = ChatTabRepository() From 681bbfc67a757a51d48e6ab0382eaaeb4f39c842 Mon Sep 17 00:00:00 2001 From: mini Date: Sun, 10 Aug 2025 18:44:59 +0900 Subject: [PATCH 153/220] =?UTF-8?q?feat:=20delete=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/chat_tab_service.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/services/chat_tab_service.py b/app/services/chat_tab_service.py index f543a88..fee44ac 100644 --- a/app/services/chat_tab_service.py +++ b/app/services/chat_tab_service.py @@ -41,7 +41,7 @@ def store_chat_tab(self, chatName: ChatTabCreate) -> ChatTabInDB: # 기타 모든 sqlite3 오류 raise APIException(CommonCode.FAIL) from e def updated_chat_tab(self, chatID: str, chatName: ChatTabUpdate) -> ChatTabInDB: - """서비스 이름에 해당하는 API Key를 수정합니다.""" + """TabID에 해당하는 AIChatTab name을 수정합니다.""" validate_chat_tab_name(chatName.name) try: updated_chat_tab = self.repository.updated_chat_tab(chatID, chatName.name) @@ -55,5 +55,15 @@ def updated_chat_tab(self, chatID: str, chatName: ChatTabUpdate) -> ChatTabInDB: raise APIException(CommonCode.DB_BUSY) from e raise APIException(CommonCode.FAIL) from e + def delete_chat_tab(self, tabId: str) -> None: + """TabID에 해당하는 AIChatTab을 삭제합니다.""" + try: + is_deleted = self.repository.delete_chat_tab(tabId) + if not is_deleted: + raise APIException(CommonCode.NO_SEARCH_DATA) + except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + raise APIException(CommonCode.FAIL) from e chat_tab_service = ChatTabService() From 6328730f842032781023b6a51c1f73be6cb0a600 Mon Sep 17 00:00:00 2001 From: mini Date: Tue, 12 Aug 2025 14:48:48 +0900 Subject: [PATCH 154/220] =?UTF-8?q?feat:=20prefix=5Fuuid=20enum=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/db_key_prefix_name.py | 1 + app/services/chat_tab_service.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/core/enum/db_key_prefix_name.py b/app/core/enum/db_key_prefix_name.py index 859ed2f..b10b72c 100644 --- a/app/core/enum/db_key_prefix_name.py +++ b/app/core/enum/db_key_prefix_name.py @@ -8,3 +8,4 @@ class DBSaveIdEnum(Enum): user_db = "USER-DB" driver = "DRIVER" api_key = "API-KEY" + chat_tab = "CHAT_TAB" \ No newline at end of file diff --git a/app/services/chat_tab_service.py b/app/services/chat_tab_service.py index fee44ac..f13b82d 100644 --- a/app/services/chat_tab_service.py +++ b/app/services/chat_tab_service.py @@ -10,6 +10,7 @@ from app.schemas.chat_tab.db_model import ChatTabInDB from app.schemas.chat_tab.update_model import ChatTabUpdate from app.schemas.chat_tab.validation_utils import validate_chat_tab_name +from app.core.enum.db_key_prefix_name import DBSaveIdEnum chat_tab_repository_dependency = Depends(lambda: chat_tab_repository) @@ -22,7 +23,7 @@ def store_chat_tab(self, chatName: ChatTabCreate) -> ChatTabInDB: """새로운 AI 채팅을 데이터베이스에 저장합니다.""" validate_chat_tab_name(chatName.name) - new_id = generate_prefixed_uuid("CHAT_TAB") + new_id = generate_prefixed_uuid(DBSaveIdEnum.chat_tab.value) try: created_row = self.repository.create_chat_tab( From e69786984029c08f0627d93d1bfc8e010e755531 Mon Sep 17 00:00:00 2001 From: mini Date: Tue, 12 Aug 2025 15:26:34 +0900 Subject: [PATCH 155/220] =?UTF-8?q?feat:=20chat=5Ftab=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=83=81=ED=83=9C=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/core/status.py b/app/core/status.py index ce25f6a..7d68f56 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -35,7 +35,10 @@ class CommonCode(Enum): SUCCESS_GET_API_KEY = (status.HTTP_200_OK, "2202", "API KEY 정보를 성공적으로 조회했습니다.") """ AI CHAT, DB 성공 코드 - 23xx """ - SUCCESS_AI_CHAT_CREATE = (status.HTTP_200_OK, "2300", "새로운 채팅 탭을 생성하였습니다.") + SUCCESS_CHAT_TAB_CREATE = (status.HTTP_200_OK, "2300", "새로운 채팅 탭이 성공적으로 생성하였습니다.") + SUCCESS_CHAT_TAB_UPDATE = (status.HTTP_200_OK, "2301", "채팅 탭 이름이 성공적으로 수정되었습니다.") + SUCCESS_CHAT_TAB_DELETE = (status.HTTP_200_OK, "2302", "채팅 탭을 성공적으로 삭제되었습니다.") + SUCCESS_GET_CHAT_TAB = (status.HTTP_200_OK, "2303", "모든 채팅 탭을 성공적으로 조회하였습니다.") """ ANNOTATION 성공 코드 - 24xx """ From 5f7afd77d7e3c3b5496616c1edeae3cdb9ce9ab6 Mon Sep 17 00:00:00 2001 From: mini Date: Tue, 12 Aug 2025 15:28:44 +0900 Subject: [PATCH 156/220] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20api=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/chat_tab_api.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/api/chat_tab_api.py b/app/api/chat_tab_api.py index cc73197..e5cb938 100644 --- a/app/api/chat_tab_api.py +++ b/app/api/chat_tab_api.py @@ -75,3 +75,28 @@ def delete_chat_tab( """ service.delete_chat_tab(tabId) return ResponseMessage.success(code=CommonCode.SUCCESS_CHAT_TAB_DELETE) + +@router.get( + "/result", + response_model=ResponseMessage[list[ChatTabResponse]], + summary="저장된 모든 Chat_tab 정보 조회", + description=""" + chat_tab 테이블에 저장된 모든 chat tab들을 확인합니다. + """, +) +def get_all_chat_tab( + service: ChatTabService = chat_tab_service_dependency, +) -> ResponseMessage[list[ChatTabResponse]]: + """저장된 모든 chat_tab의 메타데이터를 조회하여 등록 여부를 확인합니다.""" + chat_tabs_in_db = service.get_all_chat_tab() + + response_data = [ + ChatTabResponse( + id=chat_tab.id, + name=chat_tab.name, + created_at=chat_tab.created_at, + updated_at=chat_tab.updated_at, + ) + for chat_tab in chat_tabs_in_db + ] + return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_GET_CHAT_TAB) From 744419844adfb89bb558c09aff614b8bc0e9d5c5 Mon Sep 17 00:00:00 2001 From: mini Date: Tue, 12 Aug 2025 15:29:14 +0900 Subject: [PATCH 157/220] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20repository=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/chat_tab_repository.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/repository/chat_tab_repository.py b/app/repository/chat_tab_repository.py index d3665ca..18dea38 100644 --- a/app/repository/chat_tab_repository.py +++ b/app/repository/chat_tab_repository.py @@ -98,6 +98,21 @@ def delete_chat_tab(self, id: str) -> bool: finally: if conn: conn.close() + def get_all_chat_tab(self) -> list[ChatTabInDB]: + """데이터베이스에 저장된 모든 API Key를 조회합니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute("SELECT * FROM chat_tab") + rows = cursor.fetchall() + return [ChatTabInDB.model_validate(dict(row)) for row in rows] + finally: + if conn: + conn.close() chat_tab_repository = ChatTabRepository() From 20322d1b5f82b941f4598c9b073fff5a15c506c9 Mon Sep 17 00:00:00 2001 From: mini Date: Tue, 12 Aug 2025 15:29:34 +0900 Subject: [PATCH 158/220] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/chat_tab_service.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/services/chat_tab_service.py b/app/services/chat_tab_service.py index f13b82d..685bc93 100644 --- a/app/services/chat_tab_service.py +++ b/app/services/chat_tab_service.py @@ -67,4 +67,11 @@ def delete_chat_tab(self, tabId: str) -> None: raise APIException(CommonCode.DB_BUSY) from e raise APIException(CommonCode.FAIL) from e + def get_all_chat_tab(self) -> list[ChatTabInDB]: + """데이터베이스에 저장된 모든 Chat_tab을 조회합니다.""" + try: + return self.repository.get_all_chat_tab() + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + chat_tab_service = ChatTabService() From 418eebaeb8eaeabfb9380237c9c9a9a47b87d948 Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 13 Aug 2025 01:21:13 +0900 Subject: [PATCH 159/220] =?UTF-8?q?refactor:=20enum=20=ED=95=98=EC=9D=B4?= =?UTF-8?q?=ED=94=88(-)=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/db_key_prefix_name.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/enum/db_key_prefix_name.py b/app/core/enum/db_key_prefix_name.py index b10b72c..83aed3b 100644 --- a/app/core/enum/db_key_prefix_name.py +++ b/app/core/enum/db_key_prefix_name.py @@ -8,4 +8,4 @@ class DBSaveIdEnum(Enum): user_db = "USER-DB" driver = "DRIVER" api_key = "API-KEY" - chat_tab = "CHAT_TAB" \ No newline at end of file + chat_tab = "CHAT-TAB" From 132d7f52ea13184de9582632dcfeb08ad0eb4f5e Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 13 Aug 2025 01:21:47 +0900 Subject: [PATCH 160/220] =?UTF-8?q?feat:=20chat=5Ftab,=20message=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/core/status.py b/app/core/status.py index 7d68f56..953b8b4 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -28,7 +28,6 @@ class CommonCode(Enum): SUCCESS_UPDATE_PROFILE = (status.HTTP_200_OK, "2150", "디비 연결 정보를 업데이트 하였습니다.") SUCCESS_DELETE_PROFILE = (status.HTTP_200_OK, "2170", "디비 연결 정보를 삭제 하였습니다.") - """ KEY 성공 코드 - 22xx """ SUCCESS_DELETE_API_KEY = (status.HTTP_204_NO_CONTENT, "2200", "API KEY가 성공적으로 삭제되었습니다.") SUCCESS_UPDATE_API_KEY = (status.HTTP_200_OK, "2201", "API KEY가 성공적으로 수정되었습니다.") @@ -39,6 +38,7 @@ class CommonCode(Enum): SUCCESS_CHAT_TAB_UPDATE = (status.HTTP_200_OK, "2301", "채팅 탭 이름이 성공적으로 수정되었습니다.") SUCCESS_CHAT_TAB_DELETE = (status.HTTP_200_OK, "2302", "채팅 탭을 성공적으로 삭제되었습니다.") SUCCESS_GET_CHAT_TAB = (status.HTTP_200_OK, "2303", "모든 채팅 탭을 성공적으로 조회하였습니다.") + SUCCESS_GET_CHAT_MESSAGES = (status.HTTP_200_OK, "2304", "채팅 탭의 모든 메시지를 성공적으로 불러왔습니다.") """ ANNOTATION 성공 코드 - 24xx """ @@ -78,6 +78,8 @@ class CommonCode(Enum): "채팅 탭 이름에 SQL 예약어나 허용되지 않는 특수문자가 포함되어 있습니다. " "허용되지 않는 특수 문자: 큰따옴표(\"), 작은따옴표('), 세미콜론(;), 꺾쇠괄호(<, >)", ) + INVALID_CHAT_TAB_ID_FORMAT = (status.HTTP_400_BAD_REQUEST, "4303", "채팅 탭 ID의 형식이 올바르지 않습니다.") + NO_CHAT_TAB_DATA = (status.HTTP_404_NOT_FOUND, "4304", "해당 ID를 가진 채팅 탭을 찾을 수 없습니다.") """ ANNOTATION 클라이언트 에러 코드 - 44xx """ From a1b5d8df2ca7e5abf95307e2ed810166df4425f4 Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 13 Aug 2025 01:22:53 +0900 Subject: [PATCH 161/220] =?UTF-8?q?feat:=20chat=5Ftab=5Fmessage=20api=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/chat_tab_api.py | 81 +++++++++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/app/api/chat_tab_api.py b/app/api/chat_tab_api.py index e5cb938..7d57c6e 100644 --- a/app/api/chat_tab_api.py +++ b/app/api/chat_tab_api.py @@ -3,7 +3,7 @@ from app.core.response import ResponseMessage from app.core.status import CommonCode from app.schemas.chat_tab.create_model import ChatTabCreate -from app.schemas.chat_tab.response_model import ChatTabResponse +from app.schemas.chat_tab.response_model import ChatMessagesResponse, ChatTabResponse from app.schemas.chat_tab.update_model import ChatTabUpdate from app.services.chat_tab_service import ChatTabService, chat_tab_service @@ -13,9 +13,9 @@ @router.post( - "/actions", + "/create", response_model=ResponseMessage[ChatTabResponse], - summary="Chat Tab 생성", + summary="새로운 Chat Tab 생성", description="새로운 Chat Tab을 생성하여 로컬 데이터베이스에 저장합니다.", ) def store_chat_tab( @@ -34,6 +34,33 @@ def store_chat_tab( ) return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_CHAT_TAB_CREATE) + +@router.get( + "/find", + response_model=ResponseMessage[list[ChatTabResponse]], + summary="저장된 모든 Chat_tab 정보 조회", + description=""" + chat_tab 테이블에 저장된 모든 chat tab들을 확인합니다. + """, +) +def get_all_chat_tab( + service: ChatTabService = chat_tab_service_dependency, +) -> ResponseMessage[list[ChatTabResponse]]: + """저장된 모든 chat_tab의 메타데이터를 조회하여 등록 여부를 확인합니다.""" + chat_tabs_in_db = service.get_all_chat_tab() + + response_data = [ + ChatTabResponse( + id=chat_tab.id, + name=chat_tab.name, + created_at=chat_tab.created_at, + updated_at=chat_tab.updated_at, + ) + for chat_tab in chat_tabs_in_db + ] + return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_GET_CHAT_TAB) + + @router.put( "/modify/{tabId}", response_model=ResponseMessage[ChatTabResponse], @@ -42,7 +69,7 @@ def store_chat_tab( def updated_chat_tab( chatName: ChatTabUpdate, tabId: str = Path(..., description="수정할 채팅 탭의 고유 ID"), - service: ChatTabService = chat_tab_service_dependency + service: ChatTabService = chat_tab_service_dependency, ) -> ResponseMessage[ChatTabResponse]: """ 채팅 탭 ID를 기준으로 채팅 탭의 이름을 새로운 값으로 수정합니다. @@ -60,14 +87,15 @@ def updated_chat_tab( return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_CHAT_TAB_UPDATE) + @router.delete( "/remove/{tabId}", response_model=ResponseMessage, summary="특정 Chat Tab 삭제", ) def delete_chat_tab( - tabId: str = Path(..., description="수정할 채팅 탭의 고유 ID"), - service: ChatTabService = chat_tab_service_dependency + tabId: str = Path(..., description="수정할 채팅 탭의 고유 ID"), + service: ChatTabService = chat_tab_service_dependency, ) -> ResponseMessage: """ 채팅 탭 ID를 기준으로 채팅 탭을 삭제합니다. @@ -76,27 +104,26 @@ def delete_chat_tab( service.delete_chat_tab(tabId) return ResponseMessage.success(code=CommonCode.SUCCESS_CHAT_TAB_DELETE) + @router.get( - "/result", - response_model=ResponseMessage[list[ChatTabResponse]], - summary="저장된 모든 Chat_tab 정보 조회", - description=""" - chat_tab 테이블에 저장된 모든 chat tab들을 확인합니다. - """, + "/find/{tabId}/messages", + response_model=ResponseMessage[ChatMessagesResponse], + summary="특정 탭의 메시지 전체 조회", ) -def get_all_chat_tab( - service: ChatTabService = chat_tab_service_dependency, -) -> ResponseMessage[list[ChatTabResponse]]: - """저장된 모든 chat_tab의 메타데이터를 조회하여 등록 여부를 확인합니다.""" - chat_tabs_in_db = service.get_all_chat_tab() +def get_chat_messages_by_tabId( + tabId: str = Path(..., description="채팅 탭 고유 ID"), service: ChatTabService = chat_tab_service_dependency +) -> ResponseMessage[list[ChatMessagesResponse]]: + """tabId를 기준으로 해당 chat_tab의 전체 메시지를 가져옵니다.""" + chat_tab = service.get_chat_tab_by_tabId(tabId) - response_data = [ - ChatTabResponse( - id=chat_tab.id, - name=chat_tab.name, - created_at=chat_tab.created_at, - updated_at=chat_tab.updated_at, - ) - for chat_tab in chat_tabs_in_db - ] - return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_GET_CHAT_TAB) + chat_messages = service.get_chat_messages_by_tabId(tabId) + + response_data = ChatMessagesResponse( + id=chat_tab.id, + name=chat_tab.name, + created_at=chat_tab.created_at, + updated_at=chat_tab.updated_at, + messages=chat_messages, + ) + + return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_GET_CHAT_MESSAGES) From bf4755d314de6cd78bb8e87f2bd9b09a07e424ab Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 13 Aug 2025 01:23:20 +0900 Subject: [PATCH 162/220] =?UTF-8?q?feat:=20chat=5Fmessage=20repository=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/chat_message_repository.py | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/repository/chat_message_repository.py diff --git a/app/repository/chat_message_repository.py b/app/repository/chat_message_repository.py new file mode 100644 index 0000000..352a0bd --- /dev/null +++ b/app/repository/chat_message_repository.py @@ -0,0 +1,34 @@ +import sqlite3 + +from app.core.utils import get_db_path +from app.schemas.chat_tab.db_model import ChatMessageInDB + + +class ChatMessageRepository: + + def get_chat_messages_by_tabId(self, id: str) -> list[ChatMessageInDB]: + """주어진 chat_tab_id에 해당하는 모든 메시지를 가져옵니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # chat_message 테이블에서 chat_tab_id에 해당하는 모든 메시지를 조회합니다.' + # 메시지가 없을 경우, 빈 리스트를 반환합니다. + cursor.execute( + "SELECT * FROM chat_message WHERE chat_tab_id = ? ORDER BY created_at ASC", + (id,), + ) + rows = cursor.fetchall() + + # 조회된 모든 행을 ChatMessageInDB 객체 리스트로 변환 + return [ChatMessageInDB.model_validate(dict(row)) for row in rows] + + finally: + if conn: + conn.close() + + +chat_message_repository = ChatMessageRepository() From 1661b033fd1cffb4f86cb481dde39e4a13cbb42d Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 13 Aug 2025 01:23:47 +0900 Subject: [PATCH 163/220] =?UTF-8?q?feat:=20chat=5Ftab=20=EB=8B=A8=EC=9D=BC?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/chat_tab_repository.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/app/repository/chat_tab_repository.py b/app/repository/chat_tab_repository.py index 18dea38..2123b29 100644 --- a/app/repository/chat_tab_repository.py +++ b/app/repository/chat_tab_repository.py @@ -33,13 +33,14 @@ def create_chat_tab(self, new_id: str, name: str) -> ChatTabInDB: created_row = cursor.fetchone() if not created_row: - raise None + return None return ChatTabInDB.model_validate(dict(created_row)) finally: if conn: conn.close() + def updated_chat_tab(self, id: str, new_name: str | None) -> ChatTabInDB | None: """채팅 탭ID에 해당하는 ChatName를 수정하고, 수정된 객체를 반환합니다.""" db_path = get_db_path() @@ -98,6 +99,7 @@ def delete_chat_tab(self, id: str) -> bool: finally: if conn: conn.close() + def get_all_chat_tab(self) -> list[ChatTabInDB]: """데이터베이스에 저장된 모든 API Key를 조회합니다.""" db_path = get_db_path() @@ -115,4 +117,26 @@ def get_all_chat_tab(self) -> list[ChatTabInDB]: if conn: conn.close() + def get_chat_tab_by_id(self, id: str | None) -> ChatTabInDB | None: + """ID에 해당하는 채팅 탭 정보를 가져옵니다.""" + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute("SELECT * FROM chat_tab WHERE id = ?", (id,)) + row = cursor.fetchone() + + if not row: + return None + + return ChatTabInDB.model_validate(dict(row)) + + finally: + if conn: + conn.close() + + chat_tab_repository = ChatTabRepository() From 8ea9608df27c1a02385edf13919e5cfa36547e6b Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 13 Aug 2025 01:24:12 +0900 Subject: [PATCH 164/220] =?UTF-8?q?feat:=20chat=5Ftab=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/chat_tab_service.py | 60 +++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/app/services/chat_tab_service.py b/app/services/chat_tab_service.py index 685bc93..0950cdd 100644 --- a/app/services/chat_tab_service.py +++ b/app/services/chat_tab_service.py @@ -2,22 +2,29 @@ from fastapi import Depends +from app.core.enum.db_key_prefix_name import DBSaveIdEnum from app.core.exceptions import APIException from app.core.status import CommonCode from app.core.utils import generate_prefixed_uuid +from app.repository.chat_message_repository import ChatMessageRepository, chat_message_repository from app.repository.chat_tab_repository import ChatTabRepository, chat_tab_repository from app.schemas.chat_tab.create_model import ChatTabCreate -from app.schemas.chat_tab.db_model import ChatTabInDB +from app.schemas.chat_tab.db_model import ChatMessageInDB, ChatTabInDB from app.schemas.chat_tab.update_model import ChatTabUpdate -from app.schemas.chat_tab.validation_utils import validate_chat_tab_name -from app.core.enum.db_key_prefix_name import DBSaveIdEnum +from app.schemas.chat_tab.validation_utils import validate_chat_tab_id, validate_chat_tab_name chat_tab_repository_dependency = Depends(lambda: chat_tab_repository) +chat_tab_repository_dependency = Depends(lambda: chat_tab_repository) class ChatTabService: - def __init__(self, repository: ChatTabRepository = chat_tab_repository): - self.repository = repository + def __init__( + self, + tab_repository: ChatTabRepository = chat_tab_repository, + message_repository: ChatMessageRepository = chat_message_repository, + ): + self.tab_repository = tab_repository + self.message_repository = message_repository def store_chat_tab(self, chatName: ChatTabCreate) -> ChatTabInDB: """새로운 AI 채팅을 데이터베이스에 저장합니다.""" @@ -26,7 +33,7 @@ def store_chat_tab(self, chatName: ChatTabCreate) -> ChatTabInDB: new_id = generate_prefixed_uuid(DBSaveIdEnum.chat_tab.value) try: - created_row = self.repository.create_chat_tab( + created_row = self.tab_repository.create_chat_tab( new_id=new_id, name=chatName.name, ) @@ -41,14 +48,15 @@ def store_chat_tab(self, chatName: ChatTabCreate) -> ChatTabInDB: raise APIException(CommonCode.DB_BUSY) from e # 기타 모든 sqlite3 오류 raise APIException(CommonCode.FAIL) from e + def updated_chat_tab(self, chatID: str, chatName: ChatTabUpdate) -> ChatTabInDB: """TabID에 해당하는 AIChatTab name을 수정합니다.""" validate_chat_tab_name(chatName.name) try: - updated_chat_tab = self.repository.updated_chat_tab(chatID, chatName.name) + updated_chat_tab = self.tab_repository.updated_chat_tab(chatID, chatName.name) if not updated_chat_tab: - raise APIException(CommonCode.NO_SEARCH_DATA) + raise APIException(CommonCode.NO_CHAT_TAB_DATA) return updated_chat_tab except sqlite3.Error as e: @@ -59,19 +67,45 @@ def updated_chat_tab(self, chatID: str, chatName: ChatTabUpdate) -> ChatTabInDB: def delete_chat_tab(self, tabId: str) -> None: """TabID에 해당하는 AIChatTab을 삭제합니다.""" try: - is_deleted = self.repository.delete_chat_tab(tabId) + is_deleted = self.tab_repository.delete_chat_tab(tabId) if not is_deleted: - raise APIException(CommonCode.NO_SEARCH_DATA) + raise APIException(CommonCode.NO_CHAT_TAB_DATA) except sqlite3.Error as e: if "database is locked" in str(e): raise APIException(CommonCode.DB_BUSY) from e raise APIException(CommonCode.FAIL) from e - def get_all_chat_tab(self) -> list[ChatTabInDB]: + def get_all_chat_tab(self) -> ChatTabInDB: """데이터베이스에 저장된 모든 Chat_tab을 조회합니다.""" try: - return self.repository.get_all_chat_tab() + return self.tab_repository.get_all_chat_tab() + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + + def get_chat_tab_by_tabId(self, tabId: str) -> ChatTabInDB: + """데이터베이스에 저장된 특정 Chat_tab을 조회합니다.""" + validate_chat_tab_id(tabId) + + try: + chat_tab = self.tab_repository.get_chat_tab_by_id(tabId) + + if not chat_tab: + raise APIException(CommonCode.NO_CHAT_TAB_DATA) + return chat_tab + except sqlite3.Error as e: raise APIException(CommonCode.FAIL) from e - + + def get_chat_messages_by_tabId(self, tabId: str) -> ChatMessageInDB: + """ + 채팅 탭 메타데이터와 메시지 목록을 모두 가져와서 조합합니다. + 탭이 존재하지 않으면 예외를 발생시킵니다. + """ + try: + return self.message_repository.get_chat_messages_by_tabId(tabId) + + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + + chat_tab_service = ChatTabService() From 7ee0f4b629304a6ed1b35657e48e522b10d635de Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 13 Aug 2025 01:24:38 +0900 Subject: [PATCH 165/220] =?UTF-8?q?feat:=20chat=5Ftab=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/chat_tab/db_model.py | 16 ++++++++++++++++ app/schemas/chat_tab/response_model.py | 12 ++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/app/schemas/chat_tab/db_model.py b/app/schemas/chat_tab/db_model.py index 7f3ab1d..ddd37fd 100644 --- a/app/schemas/chat_tab/db_model.py +++ b/app/schemas/chat_tab/db_model.py @@ -1,5 +1,7 @@ from datetime import datetime +from pydantic import Field + from app.schemas.chat_tab.base_model import ChatTabBase @@ -13,3 +15,17 @@ class ChatTabInDB(ChatTabBase): class Config: from_attributes = True + + +class ChatMessageInDB(ChatTabBase): + """데이터베이스에 저장된 형태의 메시지 스키마 (내부용)""" + + id: str = Field(..., description="메시지의 고유 ID (서버에서 생성)") + chat_tab_id: str = Field(..., description="해당 메시지가 속한 채팅 탭의 ID") + sender: str = Field(..., description="메시지 발신자 ('AI' 또는 'User')") + message: str = Field(..., description="메시지 내용") + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/app/schemas/chat_tab/response_model.py b/app/schemas/chat_tab/response_model.py index 0d2cf15..0f017d5 100644 --- a/app/schemas/chat_tab/response_model.py +++ b/app/schemas/chat_tab/response_model.py @@ -3,12 +3,20 @@ from pydantic import Field from app.schemas.chat_tab.base_model import ChatTabBase +from app.schemas.chat_tab.db_model import ChatMessageInDB class ChatTabResponse(ChatTabBase): """AI 채팅 탭 정보 API 응답용 스키마""" - id: str = Field(..., description="채팅 세션의 고유 ID (서버에서 생성)") - name: str = Field(..., description="채팅 세션의 이름") + id: str = Field(..., description="채팅 탭의 고유 ID (서버에서 생성)") + name: str = Field(..., description="채팅 탭의 이름") created_at: datetime updated_at: datetime + + +class ChatMessagesResponse(ChatTabResponse): + """AI 채팅 탭의 메타데이터와 전체 메시지 목록을 담는 API 응답 스키마""" + + # 해당 탭의 모든 메시지를 리스트로 담습니다. + messages: list[ChatMessageInDB] = Field(..., description="해당 채팅 탭에 속한 모든 메시지 목록") From 9038c99580a86339ac211d4f8153ed88d9331435 Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 13 Aug 2025 01:29:06 +0900 Subject: [PATCH 166/220] =?UTF-8?q?feat:=20chat=5Ftab=20ID=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/chat_tab/validation_utils.py | 47 +++++++++++++++--------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/app/schemas/chat_tab/validation_utils.py b/app/schemas/chat_tab/validation_utils.py index 6d453ce..378075b 100644 --- a/app/schemas/chat_tab/validation_utils.py +++ b/app/schemas/chat_tab/validation_utils.py @@ -1,26 +1,37 @@ import re +from app.core.enum.db_key_prefix_name import DBSaveIdEnum from app.core.exceptions import APIException from app.core.status import CommonCode +# Util 폴더안 or base_model.py 안으로 이동 리팩토링 진행 예정 def validate_chat_tab_name(name: str | None) -> None: - """채팅 탭 이름에 대한 유효성 검증 로직을 수행합니다.""" - # 1. 문자열이 None, 문자열 전체가 공백 문자인지 확인 - if not name or name.isspace(): - raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_FORMAT) - - # 2. 길이 제한 - if len(name) > 128: - raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_LENGTH) - - # 3. 특수문자 및 SQL 예약어 확인 - # SQL 예약어와 위험한 특수문자를 검사합니다. - sql_keywords = ["SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "OR", "AND"] - for keyword in sql_keywords: - if keyword in name.upper(): - raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) - - # 특정 특수문자를 검사하는 예시 - if re.search(r"[;\"'`<>]", name): + """채팅 탭 이름에 대한 유효성 검증 로직을 수행합니다.""" + # 1. 문자열이 None, 문자열 전체가 공백 문자인지 확인 + if not name or name.isspace(): + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_FORMAT) + + # 2. 길이 제한 + if len(name) > 128: + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_LENGTH) + + # 3. 특수문자 및 SQL 예약어 확인 + # SQL 예약어와 위험한 특수문자를 검사합니다. + sql_keywords = ["SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "OR", "AND"] + for keyword in sql_keywords: + if keyword in name.upper(): raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) + + # 특정 특수문자를 검사하는 예시 + if re.search(r"[;\"'`<>]", name): + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) + + +def validate_chat_tab_id(id: str | None) -> None: + """채팅 탭 ID에 대한 유효성 검증 로직을 수행합니다.""" + + # 1. 'CHAT-TAB-' 접두사 검증 + required_prefix = DBSaveIdEnum.chat_tab.value + "-" + if not id.startswith(required_prefix): + raise APIException(CommonCode.INVALID_CHAT_TAB_ID_FORMAT) From 19ed9c678b9e859b18e1490c1bb8208ad4ee46bf Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 13 Aug 2025 01:31:46 +0900 Subject: [PATCH 167/220] =?UTF-8?q?style:=20=EC=BD=94=EB=93=9C=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=20=EC=9E=90=EB=8F=99=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/user_db_api.py | 47 +++++++++-------------- app/core/all_logging.py | 4 +- app/core/utils.py | 4 +- app/repository/chat_message_repository.py | 1 - app/repository/chat_tab_repository.py | 1 - app/schemas/chat_tab/create_model.py | 1 - app/schemas/chat_tab/update_model.py | 1 + app/schemas/user_db/db_profile_model.py | 3 ++ app/schemas/user_db/result_model.py | 32 +++++++++++---- 9 files changed, 52 insertions(+), 42 deletions(-) diff --git a/app/api/user_db_api.py b/app/api/user_db_api.py index 81f6dbd..957d431 100644 --- a/app/api/user_db_api.py +++ b/app/api/user_db_api.py @@ -1,13 +1,13 @@ # app/api/user_db_api.py + from fastapi import APIRouter, Depends -from typing import List from app.core.exceptions import APIException from app.core.response import ResponseMessage from app.schemas.user_db.db_profile_model import DBProfileInfo, UpdateOrCreateDBProfile +from app.schemas.user_db.result_model import ColumnInfo, DBProfile from app.services.user_db_service import UserDbService, user_db_service -from app.schemas.user_db.result_model import DBProfile, ColumnInfo user_db_service_dependency = Depends(lambda: user_db_service) @@ -23,7 +23,6 @@ def connection_test( db_info: DBProfileInfo, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[bool]: - db_info.validate_required_fields() result = service.connection_test(db_info) @@ -31,6 +30,7 @@ def connection_test( raise APIException(result.code) return ResponseMessage.success(value=result.is_successful, code=result.code) + @router.post( "/create/profile", response_model=ResponseMessage[str], @@ -40,7 +40,6 @@ def create_profile( create_db_info: UpdateOrCreateDBProfile, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[str]: - create_db_info.validate_required_fields() result = service.create_profile(create_db_info) @@ -48,6 +47,7 @@ def create_profile( raise APIException(result.code) return ResponseMessage.success(value=result.view_name, code=result.code) + @router.put( "/modify/profile", response_model=ResponseMessage[str], @@ -57,7 +57,6 @@ def update_profile( update_db_info: UpdateOrCreateDBProfile, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[str]: - update_db_info.validate_required_fields() result = service.update_profile(update_db_info) @@ -65,6 +64,7 @@ def update_profile( raise APIException(result.code) return ResponseMessage.success(value=result.view_name, code=result.code) + @router.delete( "/remove/{profile_id}", response_model=ResponseMessage[str], @@ -74,38 +74,34 @@ def delete_profile( profile_id: str, service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[str]: - result = service.delete_profile(profile_id) if not result.is_successful: raise APIException(result.code) return ResponseMessage.success(value=result.view_name, code=result.code) + @router.get( "/find/all", - response_model=ResponseMessage[List[DBProfile]], + response_model=ResponseMessage[list[DBProfile]], summary="DB 프로필 전체 조회", ) def find_all_profile( service: UserDbService = user_db_service_dependency, -) -> ResponseMessage[List[DBProfile]]: - +) -> ResponseMessage[list[DBProfile]]: result = service.find_all_profile() if not result.is_successful: raise APIException(result.code) return ResponseMessage.success(value=result.profiles, code=result.code) + @router.get( "/find/schemas/{profile_id}", - response_model=ResponseMessage[List[str]], + response_model=ResponseMessage[list[str]], summary="특정 DB의 전체 스키마 조회", ) -def find_schemas( - profile_id: str, - service: UserDbService = user_db_service_dependency -) -> ResponseMessage[List[str]]: - +def find_schemas(profile_id: str, service: UserDbService = user_db_service_dependency) -> ResponseMessage[list[str]]: db_info = service.find_profile(profile_id) result = service.find_schemas(db_info) @@ -113,17 +109,15 @@ def find_schemas( raise APIException(result.code) return ResponseMessage.success(value=result.schemas, code=result.code) + @router.get( "/find/tables/{profile_id}/{schema_name}", - response_model=ResponseMessage[List[str]], + response_model=ResponseMessage[list[str]], summary="특정 스키마의 전체 테이블 조회", ) def find_tables( - profile_id: str, - schema_name: str, - service: UserDbService = user_db_service_dependency -) -> ResponseMessage[List[str]]: - + profile_id: str, schema_name: str, service: UserDbService = user_db_service_dependency +) -> ResponseMessage[list[str]]: db_info = service.find_profile(profile_id) result = service.find_tables(db_info, schema_name) @@ -131,18 +125,15 @@ def find_tables( raise APIException(result.code) return ResponseMessage.success(value=result.tables, code=result.code) + @router.get( "/find/columns/{profile_id}/{schema_name}/{table_name}", - response_model=ResponseMessage[List[ColumnInfo]], + response_model=ResponseMessage[list[ColumnInfo]], summary="특정 테이블의 전체 컬럼 조회", ) def find_columns( - profile_id: str, - schema_name: str, - table_name: str, - service: UserDbService = user_db_service_dependency -) -> ResponseMessage[List[ColumnInfo]]: - + profile_id: str, schema_name: str, table_name: str, service: UserDbService = user_db_service_dependency +) -> ResponseMessage[list[ColumnInfo]]: db_info = service.find_profile(profile_id) result = service.find_columns(db_info, schema_name, table_name) diff --git a/app/core/all_logging.py b/app/core/all_logging.py index c31d4eb..796a01f 100644 --- a/app/core/all_logging.py +++ b/app/core/all_logging.py @@ -1,12 +1,13 @@ # app/core/all_logging.py import logging + from fastapi import Request # 로깅 기본 설정 (애플리케이션 시작 시 한 번만 구성) logging.basicConfig( level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s", # [수정] 로그 레벨(INFO, ERROR)을 포함 + format="%(asctime)s - %(levelname)s - %(message)s", # [수정] 로그 레벨(INFO, ERROR)을 포함 datefmt="%Y-%m-%d %H:%M:%S", ) @@ -30,4 +31,3 @@ async def log_requests_middleware(request: Request, call_next): logging.error(f"ERROR 엔드포인트: {endpoint}", exc_info=True) # 예외를 다시 발생시켜 FastAPI의 전역 예외 처리기가 최종 응답을 만들도록 합니다. raise e - diff --git a/app/core/utils.py b/app/core/utils.py index a4f7278..571817a 100644 --- a/app/core/utils.py +++ b/app/core/utils.py @@ -4,6 +4,7 @@ # 앱 데이터를 저장할 폴더 이름 APP_DATA_DIR_NAME = ".qgenie" + def get_db_path() -> Path: """ 사용자 홈 디렉터리 내에 앱 데이터 폴더를 만들고, @@ -15,9 +16,10 @@ def get_db_path() -> Path: db_path = app_data_dir / "local_storage.sqlite" return db_path + def generate_uuid() -> str: return uuid.uuid4().hex.upper() + def generate_prefixed_uuid(prefix: str) -> str: return f"{prefix.upper()}-{uuid.uuid4().hex.upper()}" - diff --git a/app/repository/chat_message_repository.py b/app/repository/chat_message_repository.py index 352a0bd..6e1c67f 100644 --- a/app/repository/chat_message_repository.py +++ b/app/repository/chat_message_repository.py @@ -5,7 +5,6 @@ class ChatMessageRepository: - def get_chat_messages_by_tabId(self, id: str) -> list[ChatMessageInDB]: """주어진 chat_tab_id에 해당하는 모든 메시지를 가져옵니다.""" db_path = get_db_path() diff --git a/app/repository/chat_tab_repository.py b/app/repository/chat_tab_repository.py index 2123b29..eb732d6 100644 --- a/app/repository/chat_tab_repository.py +++ b/app/repository/chat_tab_repository.py @@ -5,7 +5,6 @@ class ChatTabRepository: - def create_chat_tab(self, new_id: str, name: str) -> ChatTabInDB: """ 새로운 채팅 탭 이름을 데이터베이스에 저장하고, 저장된 객체를 반환합니다. diff --git a/app/schemas/chat_tab/create_model.py b/app/schemas/chat_tab/create_model.py index f5be4d8..31104cd 100644 --- a/app/schemas/chat_tab/create_model.py +++ b/app/schemas/chat_tab/create_model.py @@ -1,4 +1,3 @@ - from app.schemas.chat_tab.base_model import ChatTabBase from app.schemas.chat_tab.validation_utils import validate_chat_tab_name diff --git a/app/schemas/chat_tab/update_model.py b/app/schemas/chat_tab/update_model.py index 4227463..3236c71 100644 --- a/app/schemas/chat_tab/update_model.py +++ b/app/schemas/chat_tab/update_model.py @@ -6,6 +6,7 @@ class ChatTabUpdate(ChatTabBase): """채팅 탭 이름 수정을 위한 스키마""" + name: str | None = Field(None, description="수정할 채팅 탭 이름") def validate_with_name(self) -> None: diff --git a/app/schemas/user_db/db_profile_model.py b/app/schemas/user_db/db_profile_model.py index f6d601f..49598d8 100644 --- a/app/schemas/user_db/db_profile_model.py +++ b/app/schemas/user_db/db_profile_model.py @@ -17,6 +17,7 @@ class DBProfileInfo(BaseModel): name: str | None = Field(None, description="연결할 데이터베이스명") username: str | None = Field(None, description="사용자 이름") password: str | None = Field(None, description="비밀번호") + def validate_required_fields(self) -> None: """DB 종류별 필수 필드 유효성 검사""" required_fields_by_type = { @@ -52,10 +53,12 @@ def _is_empty(value: Any | None) -> bool: return True return False + class UpdateOrCreateDBProfile(DBProfileInfo): id: str | None = Field(None, description="DB Key 값") view_name: str | None = Field(None, description="DB 노출명") + class AllDBProfileInfo(DBProfileInfo): id: str | None = Field(..., description="DB Key 값") view_name: str | None = Field(None, description="DB 노출명") diff --git a/app/schemas/user_db/result_model.py b/app/schemas/user_db/result_model.py index 28c375e..ea8ad73 100644 --- a/app/schemas/user_db/result_model.py +++ b/app/schemas/user_db/result_model.py @@ -1,21 +1,26 @@ # app/schemas/user_db/result_model.py -from pydantic import BaseModel, Field from datetime import datetime -from typing import List, Any +from typing import Any + +from pydantic import BaseModel, Field from app.core.status import CommonCode + # 기본 반환 모델 class BasicResult(BaseModel): is_successful: bool = Field(..., description="성공 여부") code: CommonCode = Field(None, description="결과 코드") + # 디비 정보 후 반환되는 저장 모델 class ChangeProfileResult(BasicResult): """DB 조회 결과를 위한 확장 모델""" + view_name: str = Field(..., description="저장된 디비명") + # DB Profile 조회되는 정보를 담는 모델입니다. class DBProfile(BaseModel): id: str @@ -31,13 +36,17 @@ class DBProfile(BaseModel): class Config: from_attributes = True + # DB Profile 전체 조회 결과를 담는 새로운 모델 class AllDBProfileResult(BasicResult): """DB 프로필 전체 조회 결과를 위한 확장 모델""" - profiles: List[DBProfile] = Field([], description="DB 프로필 목록") + + profiles: list[DBProfile] = Field([], description="DB 프로필 목록") + class ColumnInfo(BaseModel): """단일 컬럼의 상세 정보를 담는 모델""" + name: str = Field(..., description="컬럼 이름") type: str = Field(..., description="데이터 타입") nullable: bool = Field(..., description="NULL 허용 여부") @@ -45,21 +54,28 @@ class ColumnInfo(BaseModel): comment: str | None = Field(None, description="코멘트") is_pk: bool = Field(False, description="기본 키(Primary Key) 여부") + class TableInfo(BaseModel): """단일 테이블의 이름과 컬럼 목록을 담는 모델""" + name: str = Field(..., description="테이블 이름") - columns: List[ColumnInfo] = Field([], description="컬럼 목록") + columns: list[ColumnInfo] = Field([], description="컬럼 목록") comment: str | None = Field(None, description="테이블 코멘트") + class SchemaInfoResult(BasicResult): """DB 스키마 상세 정보 조회 결과를 위한 확장 모델""" - schema: List[TableInfo] = Field([], description="테이블 및 컬럼 정보 목록") + + schema: list[TableInfo] = Field([], description="테이블 및 컬럼 정보 목록") + class SchemaListResult(BasicResult): - schemas: List[str] = Field([], description="스키마 이름 목록") + schemas: list[str] = Field([], description="스키마 이름 목록") + class TableListResult(BasicResult): - tables: List[str] = Field([], description="테이블 이름 목록") + tables: list[str] = Field([], description="테이블 이름 목록") + class ColumnListResult(BasicResult): - columns: List[ColumnInfo] = Field([], description="컬럼 정보 목록") + columns: list[ColumnInfo] = Field([], description="컬럼 정보 목록") From 667553e233f293a658fa1ee62502765e5e8ab725 Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 13 Aug 2025 13:52:33 +0900 Subject: [PATCH 168/220] =?UTF-8?q?refactor:=20api=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/chat_tab_api.py | 48 ++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/app/api/chat_tab_api.py b/app/api/chat_tab_api.py index 7d57c6e..8d3cf04 100644 --- a/app/api/chat_tab_api.py +++ b/app/api/chat_tab_api.py @@ -61,6 +61,30 @@ def get_all_chat_tab( return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_GET_CHAT_TAB) +@router.get( + "/find/{tabId}", + response_model=ResponseMessage[ChatMessagesResponse], + summary="특정 탭의 메시지 전체 조회", +) +def get_chat_messages_by_tabId( + tabId: str = Path(..., description="채팅 탭 고유 ID"), service: ChatTabService = chat_tab_service_dependency +) -> ResponseMessage[list[ChatMessagesResponse]]: + """tabId를 기준으로 해당 chat_tab의 전체 메시지를 가져옵니다.""" + chat_tab = service.get_chat_tab_by_tabId(tabId) + + chat_messages = service.get_chat_messages_by_tabId(tabId) + + response_data = ChatMessagesResponse( + id=chat_tab.id, + name=chat_tab.name, + created_at=chat_tab.created_at, + updated_at=chat_tab.updated_at, + messages=chat_messages, + ) + + return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_GET_CHAT_MESSAGES) + + @router.put( "/modify/{tabId}", response_model=ResponseMessage[ChatTabResponse], @@ -103,27 +127,3 @@ def delete_chat_tab( """ service.delete_chat_tab(tabId) return ResponseMessage.success(code=CommonCode.SUCCESS_CHAT_TAB_DELETE) - - -@router.get( - "/find/{tabId}/messages", - response_model=ResponseMessage[ChatMessagesResponse], - summary="특정 탭의 메시지 전체 조회", -) -def get_chat_messages_by_tabId( - tabId: str = Path(..., description="채팅 탭 고유 ID"), service: ChatTabService = chat_tab_service_dependency -) -> ResponseMessage[list[ChatMessagesResponse]]: - """tabId를 기준으로 해당 chat_tab의 전체 메시지를 가져옵니다.""" - chat_tab = service.get_chat_tab_by_tabId(tabId) - - chat_messages = service.get_chat_messages_by_tabId(tabId) - - response_data = ChatMessagesResponse( - id=chat_tab.id, - name=chat_tab.name, - created_at=chat_tab.created_at, - updated_at=chat_tab.updated_at, - messages=chat_messages, - ) - - return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_GET_CHAT_MESSAGES) From 9007dc7560c30309d76b2f9daf93d248676ff692 Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 13 Aug 2025 16:18:58 +0900 Subject: [PATCH 169/220] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EC=95=88?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=8F=20=EC=8B=A4=EC=A0=9C=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EC=9D=B4=20=EC=95=88=EB=90=98=EB=8D=98=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/chat_tab_api.py | 4 ++-- app/schemas/chat_tab/create_model.py | 9 --------- app/schemas/chat_tab/update_model.py | 6 +----- app/services/chat_tab_service.py | 11 ++++++----- 4 files changed, 9 insertions(+), 21 deletions(-) delete mode 100644 app/schemas/chat_tab/create_model.py diff --git a/app/api/chat_tab_api.py b/app/api/chat_tab_api.py index 8d3cf04..a29af95 100644 --- a/app/api/chat_tab_api.py +++ b/app/api/chat_tab_api.py @@ -2,7 +2,7 @@ from app.core.response import ResponseMessage from app.core.status import CommonCode -from app.schemas.chat_tab.create_model import ChatTabCreate +from app.schemas.chat_tab.base_model import ChatTabBase from app.schemas.chat_tab.response_model import ChatMessagesResponse, ChatTabResponse from app.schemas.chat_tab.update_model import ChatTabUpdate from app.services.chat_tab_service import ChatTabService, chat_tab_service @@ -19,7 +19,7 @@ description="새로운 Chat Tab을 생성하여 로컬 데이터베이스에 저장합니다.", ) def store_chat_tab( - chatName: ChatTabCreate, service: ChatTabService = chat_tab_service_dependency + chatName: ChatTabBase, service: ChatTabService = chat_tab_service_dependency ) -> ResponseMessage[ChatTabResponse]: """ - **name**: 새로운 Chat_tab 이름 (예: "채팅 타이틀") diff --git a/app/schemas/chat_tab/create_model.py b/app/schemas/chat_tab/create_model.py deleted file mode 100644 index 31104cd..0000000 --- a/app/schemas/chat_tab/create_model.py +++ /dev/null @@ -1,9 +0,0 @@ -from app.schemas.chat_tab.base_model import ChatTabBase -from app.schemas.chat_tab.validation_utils import validate_chat_tab_name - - -class ChatTabCreate(ChatTabBase): - """새로운 Chat Tab 생성을 위한 스키마""" - - def validate_with_name(self) -> None: - validate_chat_tab_name(self.name) diff --git a/app/schemas/chat_tab/update_model.py b/app/schemas/chat_tab/update_model.py index 3236c71..e9ca291 100644 --- a/app/schemas/chat_tab/update_model.py +++ b/app/schemas/chat_tab/update_model.py @@ -1,13 +1,9 @@ from pydantic import Field from app.schemas.chat_tab.base_model import ChatTabBase -from app.schemas.chat_tab.validation_utils import validate_chat_tab_name class ChatTabUpdate(ChatTabBase): """채팅 탭 이름 수정을 위한 스키마""" - name: str | None = Field(None, description="수정할 채팅 탭 이름") - - def validate_with_name(self) -> None: - validate_chat_tab_name(self.name) + name: str = Field(None, description="수정할 채팅 탭 이름") diff --git a/app/services/chat_tab_service.py b/app/services/chat_tab_service.py index 0950cdd..c7a3f25 100644 --- a/app/services/chat_tab_service.py +++ b/app/services/chat_tab_service.py @@ -8,10 +8,10 @@ from app.core.utils import generate_prefixed_uuid from app.repository.chat_message_repository import ChatMessageRepository, chat_message_repository from app.repository.chat_tab_repository import ChatTabRepository, chat_tab_repository -from app.schemas.chat_tab.create_model import ChatTabCreate +from app.schemas.chat_tab.base_model import ChatTabBase from app.schemas.chat_tab.db_model import ChatMessageInDB, ChatTabInDB from app.schemas.chat_tab.update_model import ChatTabUpdate -from app.schemas.chat_tab.validation_utils import validate_chat_tab_id, validate_chat_tab_name +from app.schemas.chat_tab.validation_utils import validate_chat_tab_id # 삭제 예정 chat_tab_repository_dependency = Depends(lambda: chat_tab_repository) chat_tab_repository_dependency = Depends(lambda: chat_tab_repository) @@ -26,9 +26,9 @@ def __init__( self.tab_repository = tab_repository self.message_repository = message_repository - def store_chat_tab(self, chatName: ChatTabCreate) -> ChatTabInDB: + def store_chat_tab(self, chatName: ChatTabBase) -> ChatTabInDB: """새로운 AI 채팅을 데이터베이스에 저장합니다.""" - validate_chat_tab_name(chatName.name) + chatName.validate_chat_tab_name() new_id = generate_prefixed_uuid(DBSaveIdEnum.chat_tab.value) @@ -51,7 +51,7 @@ def store_chat_tab(self, chatName: ChatTabCreate) -> ChatTabInDB: def updated_chat_tab(self, chatID: str, chatName: ChatTabUpdate) -> ChatTabInDB: """TabID에 해당하는 AIChatTab name을 수정합니다.""" - validate_chat_tab_name(chatName.name) + chatName.validate_chat_tab_name() try: updated_chat_tab = self.tab_repository.updated_chat_tab(chatID, chatName.name) @@ -84,6 +84,7 @@ def get_all_chat_tab(self) -> ChatTabInDB: def get_chat_tab_by_tabId(self, tabId: str) -> ChatTabInDB: """데이터베이스에 저장된 특정 Chat_tab을 조회합니다.""" + # 리팩토링 예정 validate_chat_tab_id(tabId) try: From 77efca3e794995bfad8cab253a863a628b7e4a95 Mon Sep 17 00:00:00 2001 From: mini Date: Wed, 13 Aug 2025 16:19:41 +0900 Subject: [PATCH 170/220] =?UTF-8?q?refactor:=20chat=5Ftab=5Fname=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20basemodel?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/chat_tab/base_model.py | 34 ++++++++++++++++++++++-- app/schemas/chat_tab/validation_utils.py | 26 +----------------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/app/schemas/chat_tab/base_model.py b/app/schemas/chat_tab/base_model.py index 84d600b..9c4e9c8 100644 --- a/app/schemas/chat_tab/base_model.py +++ b/app/schemas/chat_tab/base_model.py @@ -1,7 +1,37 @@ +import re + from pydantic import BaseModel, Field +from app.core.exceptions import APIException +from app.core.status import CommonCode + class ChatTabBase(BaseModel): - """모든 AI Chat Tab 스키마의 기본 모델""" + """ + 모든 AI Chat Tab 스키마의 기본 모델 + - 새로운 Chat Tab 생성을 위한 스키마 + - 채팅 탭 이름 수정을 위한 스키마 + """ + + name: str | None = Field(..., description="새로운 채팅 탭 이름") + + def validate_chat_tab_name(self) -> None: + """채팅 탭 이름에 대한 유효성 검증 로직을 수행합니다.""" + # 1. 문자열이 None, 문자열 전체가 공백 문자인지 확인 + if not self.name or self.name.isspace(): + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_FORMAT) + + # 2. 길이 제한 + if len(self.name) > 128: + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_LENGTH) + + # 3. 특수문자 및 SQL 예약어 확인 + # SQL 예약어와 위험한 특수문자를 검사합니다. + sql_keywords = ["SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "OR", "AND"] + for keyword in sql_keywords: + if keyword in self.name.upper(): + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) - name: str = Field(..., description="새로운 채팅 탭 이름") + # 특정 특수문자를 검사하는 예시 + if re.search(r"[;\"'`<>]", self.name): + raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) diff --git a/app/schemas/chat_tab/validation_utils.py b/app/schemas/chat_tab/validation_utils.py index 378075b..82470df 100644 --- a/app/schemas/chat_tab/validation_utils.py +++ b/app/schemas/chat_tab/validation_utils.py @@ -1,33 +1,9 @@ -import re - from app.core.enum.db_key_prefix_name import DBSaveIdEnum from app.core.exceptions import APIException from app.core.status import CommonCode -# Util 폴더안 or base_model.py 안으로 이동 리팩토링 진행 예정 -def validate_chat_tab_name(name: str | None) -> None: - """채팅 탭 이름에 대한 유효성 검증 로직을 수행합니다.""" - # 1. 문자열이 None, 문자열 전체가 공백 문자인지 확인 - if not name or name.isspace(): - raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_FORMAT) - - # 2. 길이 제한 - if len(name) > 128: - raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_LENGTH) - - # 3. 특수문자 및 SQL 예약어 확인 - # SQL 예약어와 위험한 특수문자를 검사합니다. - sql_keywords = ["SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "OR", "AND"] - for keyword in sql_keywords: - if keyword in name.upper(): - raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) - - # 특정 특수문자를 검사하는 예시 - if re.search(r"[;\"'`<>]", name): - raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) - - +# 리팩토링 예정 def validate_chat_tab_id(id: str | None) -> None: """채팅 탭 ID에 대한 유효성 검증 로직을 수행합니다.""" From aafdb9b02e523b3c01d36946a36aed8eb3dcc2b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 13 Aug 2025 17:08:58 +0900 Subject: [PATCH 171/220] =?UTF-8?q?feat(user=5Fdb):=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=83=9D=EC=84=B1=EC=8B=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=A0=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/user_db_api.py | 23 ++++++- app/core/status.py | 5 ++ app/repository/user_db_repository.py | 90 ++++++++++++++++++++++++++++ app/schemas/user_db/result_model.py | 25 +++++++- app/services/user_db_service.py | 68 +++++++++++++++++++++ 5 files changed, 209 insertions(+), 2 deletions(-) diff --git a/app/api/user_db_api.py b/app/api/user_db_api.py index 957d431..ffd6656 100644 --- a/app/api/user_db_api.py +++ b/app/api/user_db_api.py @@ -5,8 +5,9 @@ from app.core.exceptions import APIException from app.core.response import ResponseMessage +from app.core.status import CommonCode from app.schemas.user_db.db_profile_model import DBProfileInfo, UpdateOrCreateDBProfile -from app.schemas.user_db.result_model import ColumnInfo, DBProfile +from app.schemas.user_db.result_model import ColumnInfo, DBProfile, TableInfo from app.services.user_db_service import UserDbService, user_db_service user_db_service_dependency = Depends(lambda: user_db_service) @@ -89,6 +90,7 @@ def delete_profile( def find_all_profile( service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[list[DBProfile]]: + result = service.find_all_profile() if not result.is_successful: @@ -102,6 +104,7 @@ def find_all_profile( summary="특정 DB의 전체 스키마 조회", ) def find_schemas(profile_id: str, service: UserDbService = user_db_service_dependency) -> ResponseMessage[list[str]]: + db_info = service.find_profile(profile_id) result = service.find_schemas(db_info) @@ -118,6 +121,7 @@ def find_schemas(profile_id: str, service: UserDbService = user_db_service_depen def find_tables( profile_id: str, schema_name: str, service: UserDbService = user_db_service_dependency ) -> ResponseMessage[list[str]]: + db_info = service.find_profile(profile_id) result = service.find_tables(db_info, schema_name) @@ -134,9 +138,26 @@ def find_tables( def find_columns( profile_id: str, schema_name: str, table_name: str, service: UserDbService = user_db_service_dependency ) -> ResponseMessage[list[ColumnInfo]]: + db_info = service.find_profile(profile_id) result = service.find_columns(db_info, schema_name, table_name) if not result.is_successful: raise APIException(result.code) return ResponseMessage.success(value=result.columns, code=result.code) + + +@router.get( + "/find/all-schemas/{profile_id}", + response_model=ResponseMessage[list[TableInfo]], + summary="특정 DB의 전체 스키마의 상세 정보 조회", + description="테이블, 컬럼, 제약조건, 인덱스를 포함한 모든 스키마 정보를 반환합니다.", +) +def find_all_schema_info( + profile_id: str, service: UserDbService = user_db_service_dependency +) -> ResponseMessage[list[TableInfo]]: + + db_info = service.find_profile(profile_id) + full_schema_info = service.get_full_schema_info(db_info) + + return ResponseMessage.success(value=full_schema_info, code=CommonCode.SUCCESS) diff --git a/app/core/status.py b/app/core/status.py index 953b8b4..1375a7d 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -107,6 +107,11 @@ class CommonCode(Enum): FAIL_FIND_SCHEMAS = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5102", "디비 스키마 정보 조회 중 에러가 발생했습니다.") FAIL_FIND_TABLES = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5103", "디비 테이블 정보 조회 중 에러가 발생했습니다.") FAIL_FIND_COLUMNS = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5104", "디비 컬럼 정보 조회 중 에러가 발생했습니다.") + FAIL_FIND_CONSTRAINTS_OR_INDEXES = ( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "5105", + "디비 제약조건 또는 인덱스 정보 조회 중 에러가 발생했습니다.", + ) FAIL_SAVE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5130", "디비 정보 저장 중 에러가 발생했습니다.") FAIL_UPDATE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5150", "디비 정보 업데이트 중 에러가 발생했습니다.") FAIL_DELETE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5170", "디비 정보 삭제 중 에러가 발생했습니다.") diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index cd7a0e0..b437572 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -13,7 +13,9 @@ ChangeProfileResult, ColumnInfo, ColumnListResult, + ConstraintInfo, DBProfile, + IndexInfo, SchemaListResult, TableListResult, ) @@ -259,6 +261,94 @@ def find_columns( if connection: connection.close() + def find_constraints( + self, driver_module: Any, db_type: str, table_name: str, **kwargs: Any + ) -> list[ConstraintInfo]: + """ + 테이블의 제약 조건 정보를 조회합니다. + - 현재는 SQLite만 지원합니다. + - 실패 시 DB 드라이버의 예외를 직접 발생시킵니다. + """ + connection = None + try: + connection = self._connect(driver_module, **kwargs) + cursor = connection.cursor() + constraints = [] + + if db_type == "sqlite": + # Foreign Key 제약 조건 조회 + fk_list_sql = f"PRAGMA foreign_key_list('{table_name}')" + cursor.execute(fk_list_sql) + fks = cursor.fetchall() + + # Foreign Key 정보를 그룹화 + fk_groups = {} + for fk in fks: + fk_id = fk[0] + if fk_id not in fk_groups: + fk_groups[fk_id] = {"referenced_table": fk[2], "columns": [], "referenced_columns": []} + fk_groups[fk_id]["columns"].append(fk[3]) + fk_groups[fk_id]["referenced_columns"].append(fk[4]) + + for _, group in fk_groups.items(): + constraints.append( + ConstraintInfo( + name=f"fk_{table_name}_{'_'.join(group['columns'])}", + type="FOREIGN KEY", + columns=group["columns"], + referenced_table=group["referenced_table"], + referenced_columns=group["referenced_columns"], + ) + ) + + # 다른 DB 타입에 대한 제약 조건 조회 로직 추가 가능 + # elif db_type == "postgresql": ... + + return constraints + finally: + if connection: + connection.close() + + def find_indexes(self, driver_module: Any, db_type: str, table_name: str, **kwargs: Any) -> list[IndexInfo]: + """ + 테이블의 인덱스 정보를 조회합니다. + - 현재는 SQLite만 지원합니다. + - 실패 시 DB 드라이버의 예외를 직접 발생시킵니다. + """ + connection = None + try: + connection = self._connect(driver_module, **kwargs) + cursor = connection.cursor() + indexes = [] + + if db_type == "sqlite": + index_list_sql = f"PRAGMA index_list('{table_name}')" + cursor.execute(index_list_sql) + raw_indexes = cursor.fetchall() + + for idx in raw_indexes: + index_name = idx[1] + is_unique = idx[2] == 1 + + # "sqlite_autoindex_"로 시작하는 인덱스는 PK에 의해 자동 생성된 것이므로 제외 + if index_name.startswith("sqlite_autoindex_"): + continue + + index_info_sql = f"PRAGMA index_info('{index_name}')" + cursor.execute(index_info_sql) + index_columns = [row[2] for row in cursor.fetchall()] + + if index_columns: + indexes.append(IndexInfo(name=index_name, columns=index_columns, is_unique=is_unique)) + + # 다른 DB 타입에 대한 인덱스 조회 로직 추가 가능 + # elif db_type == "postgresql": ... + + return indexes + finally: + if connection: + connection.close() + # ───────────────────────────── # DB 연결 메서드 # ───────────────────────────── diff --git a/app/schemas/user_db/result_model.py b/app/schemas/user_db/result_model.py index ea8ad73..64d9190 100644 --- a/app/schemas/user_db/result_model.py +++ b/app/schemas/user_db/result_model.py @@ -55,11 +55,34 @@ class ColumnInfo(BaseModel): is_pk: bool = Field(False, description="기본 키(Primary Key) 여부") +class ConstraintInfo(BaseModel): + """테이블 제약 조건 정보를 담는 모델""" + + name: str | None = Field(None, description="제약 조건 이름") + type: str = Field(..., description="제약 조건 타입 (PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK)") + columns: list[str] = Field(..., description="제약 조건에 포함된 컬럼 목록") + # FOREIGN KEY 관련 필드 + referenced_table: str | None = Field(None, description="참조하는 테이블 (FK)") + referenced_columns: list[str] | None = Field(None, description="참조하는 테이블의 컬럼 (FK)") + # CHECK 관련 필드 + check_expression: str | None = Field(None, description="CHECK 제약 조건 표현식") + + +class IndexInfo(BaseModel): + """테이블 인덱스 정보를 담는 모델""" + + name: str | None = Field(None, description="인덱스 이름") + columns: list[str] = Field(..., description="인덱스에 포함된 컬럼 목록") + is_unique: bool = Field(False, description="고유 인덱스 여부") + + class TableInfo(BaseModel): - """단일 테이블의 이름과 컬럼 목록을 담는 모델""" + """단일 테이블의 이름과 상세 정보를 담는 모델""" name: str = Field(..., description="테이블 이름") columns: list[ColumnInfo] = Field([], description="컬럼 목록") + constraints: list[ConstraintInfo] = Field([], description="제약 조건 목록") + indexes: list[IndexInfo] = Field([], description="인덱스 목록") comment: str | None = Field(None, description="테이블 코멘트") diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index 9463d85..3afe6fd 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -19,6 +19,7 @@ ChangeProfileResult, ColumnListResult, SchemaInfoResult, + TableInfo, TableListResult, ) @@ -149,6 +150,73 @@ def find_columns( except Exception as e: raise APIException(CommonCode.FAIL) from e + def get_full_schema_info( + self, db_info: AllDBProfileInfo, repository: UserDbRepository = user_db_repository + ) -> SchemaInfoResult: + """ + DB 프로필 정보를 받아 해당 데이터베이스의 전체 스키마 정보 + (테이블, 컬럼, 제약조건, 인덱스)를 조회하여 반환합니다. + """ + try: + driver_module = self._get_driver_module(db_info.type) + connect_kwargs = self._prepare_connection_args(db_info) + + # 1. 모든 스키마(DB) 목록 조회 + schemas_result = repository.find_schemas( + driver_module, self._get_schema_query(db_info.type), **connect_kwargs + ) + if not schemas_result.is_successful: + raise APIException(schemas_result.code) + + full_schema_info = [] + + # 2. 각 스키마의 모든 테이블 목록 조회 + for schema_name in schemas_result.schemas: + tables_result = repository.find_tables( + driver_module, self._get_table_query(db_info.type), schema_name, **connect_kwargs + ) + if not tables_result.is_successful: + # 특정 스키마에서 테이블 조회 실패 시 건너뛰거나 로깅 + continue + + # 3. 각 테이블의 상세 정보 조회 + for table_name in tables_result.tables: + columns_result = repository.find_columns( + driver_module, + self._get_column_query(db_info.type), + schema_name, + db_info.type, + table_name, + **connect_kwargs, + ) + + try: + constraints = repository.find_constraints( + driver_module, db_info.type, table_name, **connect_kwargs + ) + indexes = repository.find_indexes(driver_module, db_info.type, table_name, **connect_kwargs) + except sqlite3.Error as e: + # 레포지토리에서 발생한 DB 예외를 서비스에서 처리 + raise APIException(CommonCode.FAIL_FIND_CONSTRAINTS_OR_INDEXES) from e + + table_info = TableInfo( + name=table_name, + columns=columns_result.columns if columns_result.is_successful else [], + constraints=constraints, + indexes=indexes, + comment=None, # 테이블 코멘트는 현재 조회 로직에 없음 + ) + full_schema_info.append(table_info) + + return full_schema_info + + except APIException: + # 이미 APIException인 경우 그대로 전달 + raise + except Exception as e: + # 그 외 모든 예외는 일반 실패로 처리 + raise APIException(CommonCode.FAIL) from e + def _get_driver_module(self, db_type: str): """ DB 타입에 따라 동적으로 드라이버 모듈을 로드합니다. From 8de7545f3a40861636c2509b3c52859cb5114d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 13 Aug 2025 17:10:41 +0900 Subject: [PATCH 172/220] =?UTF-8?q?feat:=20=EC=83=81=ED=83=9C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/core/status.py b/app/core/status.py index 1375a7d..d2c2ccd 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -41,6 +41,9 @@ class CommonCode(Enum): SUCCESS_GET_CHAT_MESSAGES = (status.HTTP_200_OK, "2304", "채팅 탭의 모든 메시지를 성공적으로 불러왔습니다.") """ ANNOTATION 성공 코드 - 24xx """ + SUCCESS_CREATE_ANNOTATION = (status.HTTP_201_CREATED, "2400", "어노테이션을 성공적으로 생성하였습니다.") + SUCCESS_FIND_ANNOTATION = (status.HTTP_200_OK, "2401", "어노테이션 정보를 성공적으로 조회하였습니다.") + SUCCESS_DELETE_ANNOTATION = (status.HTTP_200_OK, "2402", "어노테이션을 성공적으로 삭제하였습니다.") """ SQL 성공 코드 - 25xx """ @@ -121,6 +124,10 @@ class CommonCode(Enum): """ AI CHAT, DB 서버 에러 코드 - 53xx """ """ ANNOTATION 서버 에러 코드 - 54xx """ + FAIL_CREATE_ANNOTATION = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5400", "어노테이션 생성 중 에러가 발생했습니다.") + FAIL_FIND_ANNOTATION = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5401", "어노테이션 조회 중 에러가 발생했습니다.") + FAIL_DELETE_ANNOTATION = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5402", "어노테이션 삭제 중 에러가 발생했습니다.") + FAIL_AI_SERVER_CONNECTION = (status.HTTP_503_SERVICE_UNAVAILABLE, "5403", "AI 서버 연결에 실패했습니다.") """ SQL 서버 에러 코드 - 55xx """ From f20516ff8ee05683076be3c996b20337f76da86a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 13 Aug 2025 17:11:31 +0900 Subject: [PATCH 173/220] =?UTF-8?q?feat:=20UUID=20prefix=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/db_key_prefix_name.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/core/enum/db_key_prefix_name.py b/app/core/enum/db_key_prefix_name.py index 83aed3b..1d25f5b 100644 --- a/app/core/enum/db_key_prefix_name.py +++ b/app/core/enum/db_key_prefix_name.py @@ -8,4 +8,8 @@ class DBSaveIdEnum(Enum): user_db = "USER-DB" driver = "DRIVER" api_key = "API-KEY" - chat_tab = "CHAT-TAB" + chat_tab = "CHAT_TAB" + + database_annotation = "DB-ANNO" + table_annotation = "TBL-ANNO" + column_annotation = "COL-ANNO" From 8d0df0cccca5b441e41fa14ecb73820ae2477e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 13 Aug 2025 17:12:10 +0900 Subject: [PATCH 174/220] =?UTF-8?q?feat:=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EB=90=A0=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/annotation/base_model.py | 25 ++++++++++ app/schemas/annotation/db_model.py | 63 ++++++++++++++++++++++++ app/schemas/annotation/request_model.py | 12 +++++ app/schemas/annotation/response_model.py | 61 +++++++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 app/schemas/annotation/base_model.py create mode 100644 app/schemas/annotation/db_model.py create mode 100644 app/schemas/annotation/request_model.py create mode 100644 app/schemas/annotation/response_model.py diff --git a/app/schemas/annotation/base_model.py b/app/schemas/annotation/base_model.py new file mode 100644 index 0000000..970ea5e --- /dev/null +++ b/app/schemas/annotation/base_model.py @@ -0,0 +1,25 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.core.exceptions import APIException +from app.core.status import CommonCode + + +class AnnotationBase(BaseModel): + """어노테이션 스키마의 기본 모델""" + + id: str = Field(..., description="고유 ID") + created_at: datetime = Field(..., description="생성 시각") + updated_at: datetime = Field(..., description="마지막 수정 시각") + + +class RequestBase(BaseModel): + """요청 스키마의 기본 모델""" + + def validate_required_fields(self, fields: list[str]): + """필수 필드가 비어있는지 검사하는 공통 유효성 검사 메서드""" + for field_name in fields: + value = getattr(self, field_name, None) + if not value or (isinstance(value, str) and not value.strip()): + raise APIException(CommonCode.INVALID_PARAMETER, detail=f"'{field_name}' 필드는 비워둘 수 없습니다.") diff --git a/app/schemas/annotation/db_model.py b/app/schemas/annotation/db_model.py new file mode 100644 index 0000000..c711e41 --- /dev/null +++ b/app/schemas/annotation/db_model.py @@ -0,0 +1,63 @@ +from pydantic import Field + +from app.schemas.annotation.base_model import AnnotationBase + + +class DatabaseAnnotationInDB(AnnotationBase): + db_profile_id: str + database_name: str + description: str | None = Field(None, description="AI가 생성한 설명") + + +class TableAnnotationInDB(AnnotationBase): + database_annotation_id: str + table_name: str + description: str | None = Field(None, description="AI가 생성한 설명") + + +class ColumnAnnotationInDB(AnnotationBase): + table_annotation_id: str + column_name: str + data_type: str | None = None + is_nullable: int = 1 + default_value: str | None = None + check_expression: str | None = None + ordinal_position: int | None = None + description: str | None = Field(None, description="AI가 생성한 설명") + + +class TableRelationshipInDB(AnnotationBase): + database_annotation_id: str + from_table_id: str + to_table_id: str + relationship_type: str + description: str | None = Field(None, description="AI가 생성한 설명") + + +class TableConstraintInDB(AnnotationBase): + table_annotation_id: str + constraint_type: str + name: str | None = None + expression: str | None = None + ref_table: str | None = None + on_update_action: str | None = None + on_delete_action: str | None = None + + +class ConstraintColumnInDB(AnnotationBase): + constraint_id: str + column_annotation_id: str + position: int | None = None + referenced_column_name: str | None = None + + +class IndexAnnotationInDB(AnnotationBase): + table_annotation_id: str + name: str | None = None + is_unique: int = 0 + + +class IndexColumnInDB(AnnotationBase): + index_id: str + column_annotation_id: str + position: int | None = None diff --git a/app/schemas/annotation/request_model.py b/app/schemas/annotation/request_model.py new file mode 100644 index 0000000..1c5c40e --- /dev/null +++ b/app/schemas/annotation/request_model.py @@ -0,0 +1,12 @@ +from pydantic import Field + +from app.schemas.annotation.base_model import RequestBase + + +class AnnotationCreateRequest(RequestBase): + """어노테이션 생성 요청 스키마""" + + db_profile_id: str = Field(..., description="어노테이션을 생성할 DB 프로필의 고유 ID") + + def validate(self): + self.validate_required_fields(["db_profile_id"]) diff --git a/app/schemas/annotation/response_model.py b/app/schemas/annotation/response_model.py new file mode 100644 index 0000000..5602e0e --- /dev/null +++ b/app/schemas/annotation/response_model.py @@ -0,0 +1,61 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.schemas.annotation.base_model import AnnotationBase + + +# 상세 정보 모델 (조회 시 사용) +class ColumnAnnotationDetail(BaseModel): + id: str + column_name: str + description: str | None = None + + +class ConstraintDetail(BaseModel): + name: str | None = None + type: str + columns: list[str] + description: str | None = None # AI가 생성해줄 수 있음 + + +class IndexDetail(BaseModel): + name: str | None = None + columns: list[str] + is_unique: bool + description: str | None = None # AI가 생성해줄 수 있음 + + +class TableAnnotationDetail(AnnotationBase): + table_name: str + description: str | None = None + columns: list[ColumnAnnotationDetail] + constraints: list[ConstraintDetail] + indexes: list[IndexDetail] + + +class FullAnnotationResponse(AnnotationBase): + """전체 어노테이션 상세 정보 응답 스키마""" + + db_profile_id: str = Field(..., description="DB 프로필의 고유 ID") + database_name: str = Field(..., description="데이터베이스 이름") + description: str | None = Field(None, description="데이터베이스 전체에 대한 설명") + tables: list[TableAnnotationDetail] = Field([], description="테이블 어노테이션 목록") + + +# 간단한 생성/삭제 응답 모델 +# 필요할지는 모르겠음 +class AnnotationCreationSummary(BaseModel): + """어노테이션 생성 결과 요약 응답 스키마""" + + id: str = Field(..., description="생성된 어노테이션의 고유 ID") + db_profile_id: str = Field(..., description="DB 프로필의 고유 ID") + database_name: str = Field(..., description="데이터베이스 이름") + created_at: datetime = Field(..., description="어노테이션 생성 시각") + + +class AnnotationDeleteResponse(BaseModel): + """어노테이션 삭제 API 응답 스키마""" + + id: str = Field(..., description="삭제된 어노테이션의 고유 ID") + message: str = Field("성공적으로 삭제되었습니다.", description="삭제 결과 메시지") From 676301c9db3dbf70911e3ae13907fcaae0ccebf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 13 Aug 2025 17:18:23 +0900 Subject: [PATCH 175/220] =?UTF-8?q?feat:=20constraints,=20index=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8=20sqlite=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/annotation_api.py | 59 ++++++++ app/api/api_router.py | 3 +- app/repository/annotation_repository.py | 170 ++++++++++++++++++++++++ app/services/annotation_service.py | 170 ++++++++++++++++++++++++ 4 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 app/api/annotation_api.py create mode 100644 app/repository/annotation_repository.py create mode 100644 app/services/annotation_service.py diff --git a/app/api/annotation_api.py b/app/api/annotation_api.py new file mode 100644 index 0000000..ba802b7 --- /dev/null +++ b/app/api/annotation_api.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, Depends + +from app.core.response import ResponseMessage +from app.core.status import CommonCode +from app.schemas.annotation.request_model import AnnotationCreateRequest +from app.schemas.annotation.response_model import AnnotationDeleteResponse, FullAnnotationResponse +from app.services.annotation_service import AnnotationService, annotation_service + +annotation_service_dependency = Depends(lambda: annotation_service) + +router = APIRouter() + + +@router.post( + "/actions", + response_model=ResponseMessage[FullAnnotationResponse], + summary="새로운 어노테이션 생성", +) +async def create_annotation( + request: AnnotationCreateRequest, + service: AnnotationService = annotation_service_dependency, +) -> ResponseMessage[FullAnnotationResponse]: + """ + `db_profile_id`를 받아 AI를 통해 DB 스키마를 분석하고 어노테이션을 생성하여 반환합니다. + """ + new_annotation = await service.create_annotation(request) + return ResponseMessage.success(value=new_annotation, code=CommonCode.SUCCESS_CREATE_ANNOTATION) + + +@router.get( + "/find/{annotation_id}", + response_model=ResponseMessage[FullAnnotationResponse], + summary="특정 어노테이션 상세 정보 조회", +) +def get_annotation( + annotation_id: str, + service: AnnotationService = annotation_service_dependency, +) -> ResponseMessage[FullAnnotationResponse]: + """ + `annotation_id`에 해당하는 어노테이션의 전체 상세 정보를 조회합니다. + """ + annotation = service.get_full_annotation(annotation_id) + return ResponseMessage.success(value=annotation, code=CommonCode.SUCCESS_FIND_ANNOTATION) + + +@router.delete( + "/remove/{annotation_id}", + response_model=ResponseMessage[AnnotationDeleteResponse], + summary="특정 어노테이션 삭제", +) +def delete_annotation( + annotation_id: str, + service: AnnotationService = annotation_service_dependency, +) -> ResponseMessage[AnnotationDeleteResponse]: + """ + `annotation_id`에 해당하는 어노테이션 및 하위 데이터를 모두 삭제합니다. + """ + result = service.delete_annotation(annotation_id) + return ResponseMessage.success(value=result, code=CommonCode.SUCCESS_DELETE_ANNOTATION) diff --git a/app/api/api_router.py b/app/api/api_router.py index 346914a..da2d854 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from app.api import api_key_api, chat_tab_api, driver_api, test_api, user_db_api +from app.api import annotation_api, api_key_api, chat_tab_api, driver_api, test_api, user_db_api api_router = APIRouter() @@ -14,3 +14,4 @@ api_router.include_router(user_db_api.router, prefix="/user/db", tags=["UserDb"]) api_router.include_router(api_key_api.router, prefix="/keys", tags=["API Key"]) api_router.include_router(chat_tab_api.router, prefix="/chats", tags=["AI Chat"]) +api_router.include_router(annotation_api.router, prefix="/annotations", tags=["Annotation"]) diff --git a/app/repository/annotation_repository.py b/app/repository/annotation_repository.py new file mode 100644 index 0000000..b43883c --- /dev/null +++ b/app/repository/annotation_repository.py @@ -0,0 +1,170 @@ +import sqlite3 + +from app.core.utils import get_db_path +from app.schemas.annotation.db_model import ( + ColumnAnnotationInDB, + DatabaseAnnotationInDB, + TableAnnotationInDB, +) +from app.schemas.annotation.response_model import ( + ColumnAnnotationDetail, + FullAnnotationResponse, + TableAnnotationDetail, +) + + +class AnnotationRepository: + def create_full_annotation( + self, + db_conn: sqlite3.Connection, + db_annotation: DatabaseAnnotationInDB, + table_annotations: list[TableAnnotationInDB], + column_annotations: list[ColumnAnnotationInDB], + # TODO: Add other annotation types + ) -> None: + """ + 하나의 트랜잭션 내에서 전체 어노테이션 데이터를 저장합니다. + - 서비스 계층에서 트랜잭션을 관리하므로 connection을 인자로 받습니다. + - 실패 시 sqlite3.Error를 발생시킵니다. + """ + cursor = db_conn.cursor() + + # 1. Database Annotation 저장 + cursor.execute( + """ + INSERT INTO database_annotation (id, db_profile_id, database_name, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + db_annotation.id, + db_annotation.db_profile_id, + db_annotation.database_name, + db_annotation.description, + db_annotation.created_at, + db_annotation.updated_at, + ), + ) + + # 2. Table Annotations 저장 (executemany 사용) + table_data = [ + (t.id, t.database_annotation_id, t.table_name, t.description, t.created_at, t.updated_at) + for t in table_annotations + ] + cursor.executemany( + """ + INSERT INTO table_annotation (id, database_annotation_id, table_name, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + table_data, + ) + + # 3. Column Annotations 저장 (executemany 사용) + column_data = [ + ( + c.id, + c.table_annotation_id, + c.column_name, + c.data_type, + c.is_nullable, + c.default_value, + c.check_expression, + c.ordinal_position, + c.description, + c.created_at, + c.updated_at, + ) + for c in column_annotations + ] + cursor.executemany( + """ + INSERT INTO column_annotation (id, table_annotation_id, column_name, data_type, is_nullable, default_value, check_expression, ordinal_position, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + column_data, + ) + + # TODO: Constraint, Index 등 나머지 데이터 저장 로직 추가 + + def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationResponse | None: + """ + annotationId로 전체 어노테이션 상세 정보를 조회합니다. + - 여러 테이블을 JOIN하여 구조화된 데이터를 반환합니다. + - 실패 시 sqlite3.Error를 발생시킵니다. + """ + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # 1. 기본 Database Annotation 정보 조회 + cursor.execute("SELECT * FROM database_annotation WHERE id = ?", (annotation_id,)) + db_row = cursor.fetchone() + if not db_row: + return None + + # 2. 테이블 및 하위 정보 조회 + cursor.execute("SELECT * FROM table_annotation WHERE database_annotation_id = ?", (annotation_id,)) + table_rows = cursor.fetchall() + + tables_details = [] + for table_row in table_rows: + table_id = table_row["id"] + + # 컬럼 정보 + cursor.execute( + "SELECT id, column_name, description FROM column_annotation WHERE table_annotation_id = ?", + (table_id,), + ) + columns = [ColumnAnnotationDetail.model_validate(dict(c)) for c in cursor.fetchall()] + + # TODO: 제약조건 및 인덱스 정보 조회 로직 추가 + + tables_details.append( + TableAnnotationDetail( + id=table_id, + table_name=table_row["table_name"], + description=table_row["description"], + created_at=table_row["created_at"], + updated_at=table_row["updated_at"], + columns=columns, + constraints=[], # Placeholder + indexes=[], # Placeholder + ) + ) + + return FullAnnotationResponse( + id=db_row["id"], + db_profile_id=db_row["db_profile_id"], + database_name=db_row["database_name"], + description=db_row["description"], + tables=tables_details, + created_at=db_row["created_at"], + updated_at=db_row["updated_at"], + ) + finally: + if conn: + conn.close() + + def delete_annotation_by_id(self, annotation_id: str) -> bool: + """ + annotationId로 특정 어노테이션을 삭제합니다. + ON DELETE CASCADE에 의해 하위 데이터도 모두 삭제됩니다. + 성공 시 True, 대상이 없으면 False를 반환합니다. + 실패 시 sqlite3.Error를 발생시킵니다. + """ + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + cursor = conn.cursor() + cursor.execute("DELETE FROM database_annotation WHERE id = ?", (annotation_id,)) + conn.commit() + return cursor.rowcount > 0 + finally: + if conn: + conn.close() + + +annotation_repository = AnnotationRepository() diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py new file mode 100644 index 0000000..06aeb4a --- /dev/null +++ b/app/services/annotation_service.py @@ -0,0 +1,170 @@ +import sqlite3 +from datetime import datetime + +import httpx +from fastapi import Depends + +from app.core.enum.db_key_prefix_name import DBSaveIdEnum +from app.core.exceptions import APIException +from app.core.status import CommonCode +from app.core.utils import generate_prefixed_uuid, get_db_path +from app.repository.annotation_repository import AnnotationRepository, annotation_repository +from app.schemas.annotation.db_model import ( + ColumnAnnotationInDB, + DatabaseAnnotationInDB, + TableAnnotationInDB, +) +from app.schemas.annotation.request_model import AnnotationCreateRequest +from app.schemas.annotation.response_model import AnnotationDeleteResponse, FullAnnotationResponse +from app.schemas.user_db.result_model import TableInfo as UserDBTableInfo +from app.services.user_db_service import UserDbService, user_db_service + +annotation_repository_dependency = Depends(lambda: annotation_repository) +user_db_service_dependency = Depends(lambda: user_db_service) + +# AI 서버의 주소 (임시) +AI_SERVER_URL = "http://localhost:8001/api/v1/annotate/database" + + +class AnnotationService: + def __init__( + self, repository: AnnotationRepository = annotation_repository, user_db_serv: UserDbService = user_db_service + ): + self.repository = repository + self.user_db_service = user_db_serv + + async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnotationResponse: + """ + 어노테이션 생성을 위한 전체 프로세스를 관장합니다. + 1. DB 프로필 및 전체 스키마 정보 조회 + 2. AI 서버에 요청 (현재는 Mock 데이터 사용) + 3. 트랜잭션 내에서 전체 어노테이션 정보 저장 + """ + request.validate() + + # 1. DB 프로필 및 전체 스키마 정보 조회 + db_profile = self.user_db_service.find_profile(request.db_profile_id) + full_schema_info = self.user_db_service.get_full_schema_info(db_profile) + + # 2. AI 서버에 요청 (현재는 Mock 데이터 사용) + ai_response = self._get_mock_ai_response(full_schema_info) + + # 3. 트랜잭션 내에서 전체 어노테이션 정보 저장 + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.execute("BEGIN") + + # 3.1 데이터 변환: AI 응답 -> DB 모델 + now = datetime.now() + annotation_id = generate_prefixed_uuid(DBSaveIdEnum.database_annotation.value) + + db_anno = DatabaseAnnotationInDB( + id=annotation_id, + db_profile_id=request.db_profile_id, + database_name=db_profile.name, + description=ai_response.get("database_annotation"), + created_at=now, + updated_at=now, + ) + + table_annos = [] + col_annos = [] + # ... 다른 어노테이션 리스트들도 초기화 + + # 테이블, 컬럼 어노테이션 데이터 준비 + for tbl_data in ai_response.get("tables", []): + table_id = generate_prefixed_uuid(DBSaveIdEnum.table_annotation.value) + table_annos.append( + TableAnnotationInDB( + id=table_id, + database_annotation_id=annotation_id, + table_name=tbl_data["table_name"], + description=tbl_data.get("annotation"), + created_at=now, + updated_at=now, + ) + ) + for col_data in tbl_data.get("columns", []): + col_annos.append( + ColumnAnnotationInDB( + id=generate_prefixed_uuid(DBSaveIdEnum.column_annotation.value), + table_annotation_id=table_id, + column_name=col_data["column_name"], + description=col_data.get("annotation"), + created_at=now, + updated_at=now, + ) + ) + + # 3.2 레포지토리를 통해 DB에 저장 + self.repository.create_full_annotation(conn, db_anno, table_annos, col_annos) + + conn.commit() + + except sqlite3.Error as e: + if conn: + conn.rollback() + raise APIException(CommonCode.FAIL_CREATE_ANNOTATION, detail=f"Database transaction failed: {e}") from e + finally: + if conn: + conn.close() + + # 4. 저장된 전체 어노테이션 정보 반환 + return self.get_full_annotation(annotation_id) + + def get_full_annotation(self, annotation_id: str) -> FullAnnotationResponse: + """ID로 특정 어노테이션 정보를 조회합니다.""" + try: + annotation = self.repository.find_full_annotation_by_id(annotation_id) + if not annotation: + raise APIException(CommonCode.NO_SEARCH_DATA) + return annotation + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL_FIND_ANNOTATION) from e + + def delete_annotation(self, annotation_id: str) -> AnnotationDeleteResponse: + try: + is_deleted = self.repository.delete_annotation_by_id(annotation_id) + if not is_deleted: + raise APIException(CommonCode.NO_SEARCH_DATA) + return AnnotationDeleteResponse(id=annotation_id) + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL_DELETE_ANNOTATION) from e + + async def _request_annotation_to_ai_server(self, schema_info: list[UserDBTableInfo]) -> dict: + """AI 서버에 스키마 정보를 보내고 어노테이션을 받아옵니다.""" + request_body = {"database_schema": {"tables": [table.model_dump() for table in schema_info]}} + + async with httpx.AsyncClient() as client: + try: + response = await client.post(AI_SERVER_URL, json=request_body, timeout=60.0) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + raise APIException(CommonCode.FAIL, detail=f"AI 서버 에러: {e.response.text}") from e + except httpx.RequestError as e: + raise APIException(CommonCode.FAIL, detail=f"AI 서버 요청 실패: {e}") from e + + def _get_mock_ai_response(self, schema_info: list[UserDBTableInfo]) -> dict: + """테스트를 위한 Mock AI 서버 응답 생성""" + mock_response = { + "database_annotation": "Mock: 데이터베이스 전체에 대한 설명입니다.", + "tables": [], + "relationships": [], + } + for table in schema_info: + mock_table = { + "table_name": table.name, + "annotation": f"Mock: '{table.name}' 테이블에 대한 설명입니다.", + "columns": [ + {"column_name": col.name, "annotation": f"Mock: '{col.name}' 컬럼에 대한 설명입니다."} + for col in table.columns + ], + } + mock_response["tables"].append(mock_table) + return mock_response + + +annotation_service = AnnotationService() From beb518979bd32b634fbb159e91820f7e205167f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 13 Aug 2025 19:43:42 +0900 Subject: [PATCH 176/220] =?UTF-8?q?feat:=20mock=20data=EB=A1=9C=20sqlite?= =?UTF-8?q?=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=99=84=EB=A3=8C=20(AI=EC=99=80=20=EC=86=8C?= =?UTF-8?q?=ED=86=B5=20=ED=95=84=EC=9A=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/db_key_prefix_name.py | 5 + app/repository/annotation_repository.py | 158 +++++++++++++++++++----- app/services/annotation_service.py | 102 +++++++++++++-- 3 files changed, 228 insertions(+), 37 deletions(-) diff --git a/app/core/enum/db_key_prefix_name.py b/app/core/enum/db_key_prefix_name.py index 1d25f5b..0e3fa18 100644 --- a/app/core/enum/db_key_prefix_name.py +++ b/app/core/enum/db_key_prefix_name.py @@ -13,3 +13,8 @@ class DBSaveIdEnum(Enum): database_annotation = "DB-ANNO" table_annotation = "TBL-ANNO" column_annotation = "COL-ANNO" + table_constraint = "TC-ANNO" + constraint_column = "CC-ANNO" + index_annotation = "IDX-ANNO" + index_column = "IC-ANNO" + table_relationship = "TR-ANNO" diff --git a/app/repository/annotation_repository.py b/app/repository/annotation_repository.py index b43883c..c32ec61 100644 --- a/app/repository/annotation_repository.py +++ b/app/repository/annotation_repository.py @@ -3,12 +3,18 @@ from app.core.utils import get_db_path from app.schemas.annotation.db_model import ( ColumnAnnotationInDB, + ConstraintColumnInDB, DatabaseAnnotationInDB, + IndexAnnotationInDB, + IndexColumnInDB, TableAnnotationInDB, + TableConstraintInDB, ) from app.schemas.annotation.response_model import ( ColumnAnnotationDetail, + ConstraintDetail, FullAnnotationResponse, + IndexDetail, TableAnnotationDetail, ) @@ -20,7 +26,10 @@ def create_full_annotation( db_annotation: DatabaseAnnotationInDB, table_annotations: list[TableAnnotationInDB], column_annotations: list[ColumnAnnotationInDB], - # TODO: Add other annotation types + constraint_annotations: list[TableConstraintInDB], + constraint_column_annotations: list[ConstraintColumnInDB], + index_annotations: list[IndexAnnotationInDB], + index_column_annotations: list[IndexColumnInDB], ) -> None: """ 하나의 트랜잭션 내에서 전체 어노테이션 데이터를 저장합니다. @@ -29,23 +38,22 @@ def create_full_annotation( """ cursor = db_conn.cursor() - # 1. Database Annotation 저장 + # Database, Table, Column Annotations 저장 + db_data = ( + db_annotation.id, + db_annotation.db_profile_id, + db_annotation.database_name, + db_annotation.description, + db_annotation.created_at, + db_annotation.updated_at, + ) cursor.execute( """ INSERT INTO database_annotation (id, db_profile_id, database_name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) """, - ( - db_annotation.id, - db_annotation.db_profile_id, - db_annotation.database_name, - db_annotation.description, - db_annotation.created_at, - db_annotation.updated_at, - ), + db_data, ) - - # 2. Table Annotations 저장 (executemany 사용) table_data = [ (t.id, t.database_annotation_id, t.table_name, t.description, t.created_at, t.updated_at) for t in table_annotations @@ -57,8 +65,6 @@ def create_full_annotation( """, table_data, ) - - # 3. Column Annotations 저장 (executemany 사용) column_data = [ ( c.id, @@ -83,7 +89,71 @@ def create_full_annotation( column_data, ) - # TODO: Constraint, Index 등 나머지 데이터 저장 로직 추가 + # Constraint Annotations 저장 + constraint_data = [ + ( + c.id, + c.table_annotation_id, + c.constraint_type, + c.name, + c.expression, + c.ref_table, + c.on_update_action, + c.on_delete_action, + c.created_at, + c.updated_at, + ) + for c in constraint_annotations + ] + cursor.executemany( + """ + INSERT INTO table_constraint (id, table_annotation_id, constraint_type, name, expression, ref_table, on_update_action, on_delete_action, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + constraint_data, + ) + constraint_column_data = [ + ( + cc.id, + cc.constraint_id, + cc.column_annotation_id, + cc.position, + cc.referenced_column_name, + cc.created_at, + cc.updated_at, + ) + for cc in constraint_column_annotations + ] + cursor.executemany( + """ + INSERT INTO constraint_column (id, constraint_id, column_annotation_id, position, referenced_column_name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + constraint_column_data, + ) + + # Index Annotations 저장 + index_data = [ + (i.id, i.table_annotation_id, i.name, i.is_unique, i.created_at, i.updated_at) for i in index_annotations + ] + cursor.executemany( + """ + INSERT INTO index_annotation (id, table_annotation_id, name, is_unique, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + index_data, + ) + index_column_data = [ + (ic.id, ic.index_id, ic.column_annotation_id, ic.position, ic.created_at, ic.updated_at) + for ic in index_column_annotations + ] + cursor.executemany( + """ + INSERT INTO index_column (id, index_id, column_annotation_id, position, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + index_column_data, + ) def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationResponse | None: """ @@ -98,13 +168,11 @@ def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationRespon conn.row_factory = sqlite3.Row cursor = conn.cursor() - # 1. 기본 Database Annotation 정보 조회 cursor.execute("SELECT * FROM database_annotation WHERE id = ?", (annotation_id,)) db_row = cursor.fetchone() if not db_row: return None - # 2. 테이블 및 하위 정보 조회 cursor.execute("SELECT * FROM table_annotation WHERE database_annotation_id = ?", (annotation_id,)) table_rows = cursor.fetchall() @@ -119,7 +187,45 @@ def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationRespon ) columns = [ColumnAnnotationDetail.model_validate(dict(c)) for c in cursor.fetchall()] - # TODO: 제약조건 및 인덱스 정보 조회 로직 추가 + # 제약조건 정보 + cursor.execute( + """ + SELECT tc.name, tc.constraint_type, ca.column_name + FROM table_constraint tc + JOIN constraint_column cc ON tc.id = cc.constraint_id + JOIN column_annotation ca ON cc.column_annotation_id = ca.id + WHERE tc.table_annotation_id = ? + """, + (table_id,), + ) + constraint_map = {} + for row in cursor.fetchall(): + if row["name"] not in constraint_map: + constraint_map[row["name"]] = {"type": row["constraint_type"], "columns": []} + constraint_map[row["name"]]["columns"].append(row["column_name"]) + constraints = [ + ConstraintDetail(name=k, type=v["type"], columns=v["columns"]) for k, v in constraint_map.items() + ] + + # 인덱스 정보 + cursor.execute( + """ + SELECT ia.name, ia.is_unique, ca.column_name + FROM index_annotation ia + JOIN index_column ic ON ia.id = ic.index_id + JOIN column_annotation ca ON ic.column_annotation_id = ca.id + WHERE ia.table_annotation_id = ? + """, + (table_id,), + ) + index_map = {} + for row in cursor.fetchall(): + if row["name"] not in index_map: + index_map[row["name"]] = {"is_unique": bool(row["is_unique"]), "columns": []} + index_map[row["name"]]["columns"].append(row["column_name"]) + indexes = [ + IndexDetail(name=k, is_unique=v["is_unique"], columns=v["columns"]) for k, v in index_map.items() + ] tables_details.append( TableAnnotationDetail( @@ -129,20 +235,14 @@ def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationRespon created_at=table_row["created_at"], updated_at=table_row["updated_at"], columns=columns, - constraints=[], # Placeholder - indexes=[], # Placeholder + constraints=constraints, + indexes=indexes, ) ) - return FullAnnotationResponse( - id=db_row["id"], - db_profile_id=db_row["db_profile_id"], - database_name=db_row["database_name"], - description=db_row["description"], - tables=tables_details, - created_at=db_row["created_at"], - updated_at=db_row["updated_at"], - ) + db_row_dict = dict(db_row) + db_row_dict["tables"] = tables_details + return FullAnnotationResponse.model_validate(db_row_dict) finally: if conn: conn.close() diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index 06aeb4a..39be075 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -11,8 +11,12 @@ from app.repository.annotation_repository import AnnotationRepository, annotation_repository from app.schemas.annotation.db_model import ( ColumnAnnotationInDB, + ConstraintColumnInDB, DatabaseAnnotationInDB, + IndexAnnotationInDB, + IndexColumnInDB, TableAnnotationInDB, + TableConstraintInDB, ) from app.schemas.annotation.request_model import AnnotationCreateRequest from app.schemas.annotation.response_model import AnnotationDeleteResponse, FullAnnotationResponse @@ -37,7 +41,7 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot """ 어노테이션 생성을 위한 전체 프로세스를 관장합니다. 1. DB 프로필 및 전체 스키마 정보 조회 - 2. AI 서버에 요청 (현재는 Mock 데이터 사용) + 2. TODO: AI 서버에 요청 (현재는 Mock 데이터 사용) 3. 트랜잭션 내에서 전체 어노테이션 정보 저장 """ request.validate() @@ -69,11 +73,17 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot updated_at=now, ) - table_annos = [] - col_annos = [] - # ... 다른 어노테이션 리스트들도 초기화 + # 어노테이션 리스트들 초기화 + table_annos, col_annos, constraint_annos, constraint_col_annos, index_annos, index_col_annos = ( + [], + [], + [], + [], + [], + [], + ) - # 테이블, 컬럼 어노테이션 데이터 준비 + # AI 응답을 DB 모델로 변환 for tbl_data in ai_response.get("tables", []): table_id = generate_prefixed_uuid(DBSaveIdEnum.table_annotation.value) table_annos.append( @@ -86,10 +96,14 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot updated_at=now, ) ) + + col_map = {} # 컬럼 이름으로 ID를 찾기 위한 맵 for col_data in tbl_data.get("columns", []): + col_id = generate_prefixed_uuid(DBSaveIdEnum.column_annotation.value) + col_map[col_data["column_name"]] = col_id col_annos.append( ColumnAnnotationInDB( - id=generate_prefixed_uuid(DBSaveIdEnum.column_annotation.value), + id=col_id, table_annotation_id=table_id, column_name=col_data["column_name"], description=col_data.get("annotation"), @@ -98,9 +112,63 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot ) ) - # 3.2 레포지토리를 통해 DB에 저장 - self.repository.create_full_annotation(conn, db_anno, table_annos, col_annos) + for const_data in tbl_data.get("constraints", []): + const_id = generate_prefixed_uuid(DBSaveIdEnum.table_constraint.value) + constraint_annos.append( + TableConstraintInDB( + id=const_id, + table_annotation_id=table_id, + name=const_data["name"], + constraint_type=const_data["type"], + created_at=now, + updated_at=now, + ) + ) + for col_name in const_data.get("columns", []): + constraint_col_annos.append( + ConstraintColumnInDB( + id=generate_prefixed_uuid(DBSaveIdEnum.constraint_column.value), + constraint_id=const_id, + column_annotation_id=col_map[col_name], + created_at=now, + updated_at=now, + ) + ) + + for idx_data in tbl_data.get("indexes", []): + idx_id = generate_prefixed_uuid(DBSaveIdEnum.index_annotation.value) + index_annos.append( + IndexAnnotationInDB( + id=idx_id, + table_annotation_id=table_id, + name=idx_data["name"], + is_unique=1 if idx_data["is_unique"] else 0, + created_at=now, + updated_at=now, + ) + ) + for col_name in idx_data.get("columns", []): + index_col_annos.append( + IndexColumnInDB( + id=generate_prefixed_uuid(DBSaveIdEnum.index_column.value), + index_id=idx_id, + column_annotation_id=col_map[col_name], + created_at=now, + updated_at=now, + ) + ) + # 3.2 레포지토리를 통해 DB에 저장 + self.repository.create_full_annotation( + db_conn=conn, + db_annotation=db_anno, + table_annotations=table_annos, + column_annotations=col_annos, + constraint_annotations=constraint_annos, + constraint_column_annotations=constraint_col_annos, + index_annotations=index_annos, + index_column_annotations=index_col_annos, + ) conn.commit() except sqlite3.Error as e: @@ -162,6 +230,24 @@ def _get_mock_ai_response(self, schema_info: list[UserDBTableInfo]) -> dict: {"column_name": col.name, "annotation": f"Mock: '{col.name}' 컬럼에 대한 설명입니다."} for col in table.columns ], + "constraints": [ + { + "name": c.name, + "type": c.type, + "columns": c.columns, + "annotation": f"Mock: 제약조건 '{c.name}' 설명.", + } + for c in table.constraints + ], + "indexes": [ + { + "name": i.name, + "columns": i.columns, + "is_unique": i.is_unique, + "annotation": f"Mock: 인덱스 '{i.name}' 설명.", + } + for i in table.indexes + ], } mock_response["tables"].append(mock_table) return mock_response From 9c42c7acb8f5cb4eb1f0c2c6c99c6ae8a4615712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 13 Aug 2025 20:00:02 +0900 Subject: [PATCH 177/220] =?UTF-8?q?refactor:=20constraint=20types=EB=A5=BC?= =?UTF-8?q?=20enum=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/constraint_type.py | 16 ++++++++++++++++ app/schemas/annotation/db_model.py | 3 ++- app/services/annotation_service.py | 3 ++- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 app/core/enum/constraint_type.py diff --git a/app/core/enum/constraint_type.py b/app/core/enum/constraint_type.py new file mode 100644 index 0000000..d7a3bcb --- /dev/null +++ b/app/core/enum/constraint_type.py @@ -0,0 +1,16 @@ +from enum import Enum + + +class ConstraintTypeEnum(str, Enum): + """ + 데이터베이스 제약 조건의 유형을 정의하는 Enum 클래스입니다. + - str을 상속하여 Enum 멤버를 문자열 값처럼 사용할 수 있습니다. + """ + + PRIMARY_KEY = "PRIMARY KEY" + FOREIGN_KEY = "FOREIGN KEY" + UNIQUE = "UNIQUE" + CHECK = "CHECK" + NOT_NULL = "NOT NULL" # 일부 DB에서는 제약조건으로 취급 + DEFAULT = "DEFAULT" # 일부 DB에서는 제약조건으로 취급 + INDEX = "INDEX" # 제약조건은 아니지만, 관련 정보로 포함 diff --git a/app/schemas/annotation/db_model.py b/app/schemas/annotation/db_model.py index c711e41..667765b 100644 --- a/app/schemas/annotation/db_model.py +++ b/app/schemas/annotation/db_model.py @@ -1,5 +1,6 @@ from pydantic import Field +from app.core.enum.constraint_type import ConstraintTypeEnum from app.schemas.annotation.base_model import AnnotationBase @@ -36,7 +37,7 @@ class TableRelationshipInDB(AnnotationBase): class TableConstraintInDB(AnnotationBase): table_annotation_id: str - constraint_type: str + constraint_type: ConstraintTypeEnum name: str | None = None expression: str | None = None ref_table: str | None = None diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index 39be075..e1e9940 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -4,6 +4,7 @@ import httpx from fastapi import Depends +from app.core.enum.constraint_type import ConstraintTypeEnum from app.core.enum.db_key_prefix_name import DBSaveIdEnum from app.core.exceptions import APIException from app.core.status import CommonCode @@ -119,7 +120,7 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot id=const_id, table_annotation_id=table_id, name=const_data["name"], - constraint_type=const_data["type"], + constraint_type=ConstraintTypeEnum(const_data["type"]), created_at=now, updated_at=now, ) From 842e54ead48700fd51066036929e19308550e3dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 13 Aug 2025 20:26:38 +0900 Subject: [PATCH 178/220] =?UTF-8?q?chore:=20=EC=83=81=ED=83=9C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/core/status.py b/app/core/status.py index d2c2ccd..22d4488 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -85,6 +85,7 @@ class CommonCode(Enum): NO_CHAT_TAB_DATA = (status.HTTP_404_NOT_FOUND, "4304", "해당 ID를 가진 채팅 탭을 찾을 수 없습니다.") """ ANNOTATION 클라이언트 에러 코드 - 44xx """ + INVALID_ANNOTATION_REQUEST = (status.HTTP_400_BAD_REQUEST, "4400", "어노테이션 요청 데이터가 유효하지 않습니다.") """ SQL 클라이언트 에러 코드 - 45xx """ @@ -128,6 +129,11 @@ class CommonCode(Enum): FAIL_FIND_ANNOTATION = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5401", "어노테이션 조회 중 에러가 발생했습니다.") FAIL_DELETE_ANNOTATION = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5402", "어노테이션 삭제 중 에러가 발생했습니다.") FAIL_AI_SERVER_CONNECTION = (status.HTTP_503_SERVICE_UNAVAILABLE, "5403", "AI 서버 연결에 실패했습니다.") + FAIL_AI_SERVER_PROCESSING = ( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "5404", + "AI 서버가 요청을 처리하는 데 실패했습니다.", + ) """ SQL 서버 에러 코드 - 55xx """ From 74f62f766809f862df94496fbcc6ab24c9526de7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Wed, 13 Aug 2025 20:39:35 +0900 Subject: [PATCH 179/220] =?UTF-8?q?refactor:=20=EA=B3=BC=EB=8F=84=ED=95=9C?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B1=85=EC=9E=84=20=EB=B6=84?= =?UTF-8?q?=EB=B0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/annotation_service.py | 240 +++++++++++++++-------------- 1 file changed, 125 insertions(+), 115 deletions(-) diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index e1e9940..b217586 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -1,7 +1,7 @@ import sqlite3 from datetime import datetime +from typing import Any -import httpx from fastapi import Depends from app.core.enum.constraint_type import ConstraintTypeEnum @@ -21,6 +21,7 @@ ) from app.schemas.annotation.request_model import AnnotationCreateRequest from app.schemas.annotation.response_model import AnnotationDeleteResponse, FullAnnotationResponse +from app.schemas.user_db.db_profile_model import AllDBProfileInfo from app.schemas.user_db.result_model import TableInfo as UserDBTableInfo from app.services.user_db_service import UserDbService, user_db_service @@ -45,14 +46,17 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot 2. TODO: AI 서버에 요청 (현재는 Mock 데이터 사용) 3. 트랜잭션 내에서 전체 어노테이션 정보 저장 """ - request.validate() + try: + request.validate() + except ValueError as e: + raise APIException(CommonCode.INVALID_ANNOTATION_REQUEST, detail=str(e)) from e # 1. DB 프로필 및 전체 스키마 정보 조회 db_profile = self.user_db_service.find_profile(request.db_profile_id) full_schema_info = self.user_db_service.get_full_schema_info(db_profile) # 2. AI 서버에 요청 (현재는 Mock 데이터 사용) - ai_response = self._get_mock_ai_response(full_schema_info) + ai_response = await self._request_annotation_to_ai_server(full_schema_info) # 3. 트랜잭션 내에서 전체 어노테이션 정보 저장 db_path = get_db_path() @@ -61,130 +65,133 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot conn = sqlite3.connect(str(db_path), timeout=10) conn.execute("BEGIN") - # 3.1 데이터 변환: AI 응답 -> DB 모델 - now = datetime.now() - annotation_id = generate_prefixed_uuid(DBSaveIdEnum.database_annotation.value) + db_models = self._transform_ai_response_to_db_models(ai_response, db_profile, request.db_profile_id) + self.repository.create_full_annotation(db_conn=conn, **db_models) - db_anno = DatabaseAnnotationInDB( - id=annotation_id, - db_profile_id=request.db_profile_id, - database_name=db_profile.name, - description=ai_response.get("database_annotation"), - created_at=now, - updated_at=now, - ) + conn.commit() + annotation_id = db_models["db_annotation"].id + + except sqlite3.Error as e: + if conn: + conn.rollback() + raise APIException(CommonCode.FAIL_CREATE_ANNOTATION, detail=f"Database transaction failed: {e}") from e + finally: + if conn: + conn.close() - # 어노테이션 리스트들 초기화 - table_annos, col_annos, constraint_annos, constraint_col_annos, index_annos, index_col_annos = ( - [], - [], - [], - [], - [], - [], + return self.get_full_annotation(annotation_id) + + def _transform_ai_response_to_db_models( + self, ai_response: dict[str, Any], db_profile: AllDBProfileInfo, db_profile_id: str + ) -> dict[str, Any]: + now = datetime.now() + annotation_id = generate_prefixed_uuid(DBSaveIdEnum.database_annotation.value) + + db_anno = DatabaseAnnotationInDB( + id=annotation_id, + db_profile_id=db_profile_id, + database_name=db_profile.name, + description=ai_response.get("database_annotation"), + created_at=now, + updated_at=now, + ) + + table_annos, col_annos, constraint_annos, constraint_col_annos, index_annos, index_col_annos = ( + [], + [], + [], + [], + [], + [], + ) + + for tbl_data in ai_response.get("tables", []): + table_id = generate_prefixed_uuid(DBSaveIdEnum.table_annotation.value) + table_annos.append( + TableAnnotationInDB( + id=table_id, + database_annotation_id=annotation_id, + table_name=tbl_data["table_name"], + description=tbl_data.get("annotation"), + created_at=now, + updated_at=now, + ) ) - # AI 응답을 DB 모델로 변환 - for tbl_data in ai_response.get("tables", []): - table_id = generate_prefixed_uuid(DBSaveIdEnum.table_annotation.value) - table_annos.append( - TableAnnotationInDB( - id=table_id, - database_annotation_id=annotation_id, - table_name=tbl_data["table_name"], - description=tbl_data.get("annotation"), + col_map = { + col["column_name"]: generate_prefixed_uuid(DBSaveIdEnum.column_annotation.value) + for col in tbl_data.get("columns", []) + } + + for col_data in tbl_data.get("columns", []): + col_annos.append( + ColumnAnnotationInDB( + id=col_map[col_data["column_name"]], + table_annotation_id=table_id, + column_name=col_data["column_name"], + description=col_data.get("annotation"), created_at=now, updated_at=now, ) ) - col_map = {} # 컬럼 이름으로 ID를 찾기 위한 맵 - for col_data in tbl_data.get("columns", []): - col_id = generate_prefixed_uuid(DBSaveIdEnum.column_annotation.value) - col_map[col_data["column_name"]] = col_id - col_annos.append( - ColumnAnnotationInDB( - id=col_id, - table_annotation_id=table_id, - column_name=col_data["column_name"], - description=col_data.get("annotation"), - created_at=now, - updated_at=now, - ) + for const_data in tbl_data.get("constraints", []): + const_id = generate_prefixed_uuid(DBSaveIdEnum.table_constraint.value) + constraint_annos.append( + TableConstraintInDB( + id=const_id, + table_annotation_id=table_id, + name=const_data["name"], + constraint_type=ConstraintTypeEnum(const_data["type"]), + created_at=now, + updated_at=now, ) - - for const_data in tbl_data.get("constraints", []): - const_id = generate_prefixed_uuid(DBSaveIdEnum.table_constraint.value) - constraint_annos.append( - TableConstraintInDB( - id=const_id, - table_annotation_id=table_id, - name=const_data["name"], - constraint_type=ConstraintTypeEnum(const_data["type"]), + ) + for col_name in const_data.get("columns", []): + constraint_col_annos.append( + ConstraintColumnInDB( + id=generate_prefixed_uuid(DBSaveIdEnum.constraint_column.value), + constraint_id=const_id, + column_annotation_id=col_map[col_name], created_at=now, updated_at=now, ) ) - for col_name in const_data.get("columns", []): - constraint_col_annos.append( - ConstraintColumnInDB( - id=generate_prefixed_uuid(DBSaveIdEnum.constraint_column.value), - constraint_id=const_id, - column_annotation_id=col_map[col_name], - created_at=now, - updated_at=now, - ) - ) - for idx_data in tbl_data.get("indexes", []): - idx_id = generate_prefixed_uuid(DBSaveIdEnum.index_annotation.value) - index_annos.append( - IndexAnnotationInDB( - id=idx_id, - table_annotation_id=table_id, - name=idx_data["name"], - is_unique=1 if idx_data["is_unique"] else 0, + for idx_data in tbl_data.get("indexes", []): + idx_id = generate_prefixed_uuid(DBSaveIdEnum.index_annotation.value) + index_annos.append( + IndexAnnotationInDB( + id=idx_id, + table_annotation_id=table_id, + name=idx_data["name"], + is_unique=1 if idx_data.get("is_unique") else 0, + created_at=now, + updated_at=now, + ) + ) + for col_name in idx_data.get("columns", []): + index_col_annos.append( + IndexColumnInDB( + id=generate_prefixed_uuid(DBSaveIdEnum.index_column.value), + index_id=idx_id, + column_annotation_id=col_map[col_name], created_at=now, updated_at=now, ) ) - for col_name in idx_data.get("columns", []): - index_col_annos.append( - IndexColumnInDB( - id=generate_prefixed_uuid(DBSaveIdEnum.index_column.value), - index_id=idx_id, - column_annotation_id=col_map[col_name], - created_at=now, - updated_at=now, - ) - ) - - # 3.2 레포지토리를 통해 DB에 저장 - self.repository.create_full_annotation( - db_conn=conn, - db_annotation=db_anno, - table_annotations=table_annos, - column_annotations=col_annos, - constraint_annotations=constraint_annos, - constraint_column_annotations=constraint_col_annos, - index_annotations=index_annos, - index_column_annotations=index_col_annos, - ) - conn.commit() - except sqlite3.Error as e: - if conn: - conn.rollback() - raise APIException(CommonCode.FAIL_CREATE_ANNOTATION, detail=f"Database transaction failed: {e}") from e - finally: - if conn: - conn.close() - - # 4. 저장된 전체 어노테이션 정보 반환 - return self.get_full_annotation(annotation_id) + return { + "db_annotation": db_anno, + "table_annotations": table_annos, + "column_annotations": col_annos, + "constraint_annotations": constraint_annos, + "constraint_column_annotations": constraint_col_annos, + "index_annotations": index_annos, + "index_column_annotations": index_col_annos, + } def get_full_annotation(self, annotation_id: str) -> FullAnnotationResponse: - """ID로 특정 어노테이션 정보를 조회합니다.""" try: annotation = self.repository.find_full_annotation_by_id(annotation_id) if not annotation: @@ -204,17 +211,20 @@ def delete_annotation(self, annotation_id: str) -> AnnotationDeleteResponse: async def _request_annotation_to_ai_server(self, schema_info: list[UserDBTableInfo]) -> dict: """AI 서버에 스키마 정보를 보내고 어노테이션을 받아옵니다.""" - request_body = {"database_schema": {"tables": [table.model_dump() for table in schema_info]}} + # 우선은 목업 데이터 활용 + return self._get_mock_ai_response(schema_info) - async with httpx.AsyncClient() as client: - try: - response = await client.post(AI_SERVER_URL, json=request_body, timeout=60.0) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - raise APIException(CommonCode.FAIL, detail=f"AI 서버 에러: {e.response.text}") from e - except httpx.RequestError as e: - raise APIException(CommonCode.FAIL, detail=f"AI 서버 요청 실패: {e}") from e + # Real implementation below + # request_body = {"database_schema": {"tables": [table.model_dump() for table in schema_info]}} + # async with httpx.AsyncClient() as client: + # try: + # response = await client.post(AI_SERVER_URL, json=request_body, timeout=60.0) + # response.raise_for_status() + # return response.json() + # except httpx.HTTPStatusError as e: + # raise APIException(CommonCode.FAIL_AI_SERVER_PROCESSING, detail=f"AI server error: {e.response.text}") from e + # except httpx.RequestError as e: + # raise APIException(CommonCode.FAIL_AI_SERVER_CONNECTION, detail=f"AI server connection failed: {e}") from e def _get_mock_ai_response(self, schema_info: list[UserDBTableInfo]) -> dict: """테스트를 위한 Mock AI 서버 응답 생성""" From dd302dcff6a39186b249ce0c97d789c727ece751 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 10:31:21 +0900 Subject: [PATCH 180/220] =?UTF-8?q?chore:=20=EC=83=9D=EC=84=B1=20actions?= =?UTF-8?q?=20->=20create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/annotation_api.py | 2 +- app/api/api_key_api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/annotation_api.py b/app/api/annotation_api.py index ba802b7..aa5ce45 100644 --- a/app/api/annotation_api.py +++ b/app/api/annotation_api.py @@ -12,7 +12,7 @@ @router.post( - "/actions", + "/create", response_model=ResponseMessage[FullAnnotationResponse], summary="새로운 어노테이션 생성", ) diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index 53ea9f2..7a26140 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -14,7 +14,7 @@ @router.post( - "/actions", + "/create", response_model=ResponseMessage[APIKeyResponse], summary="API KEY 저장 (처음 한 번)", description="외부 AI 서비스의 API Key를 암호화하여 로컬 데이터베이스에 저장합니다.", From 1d4fe6e88d83518183991a87b9157039ee98c07b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 16:14:57 +0900 Subject: [PATCH 181/220] =?UTF-8?q?feat:=20postgresql=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EB=94=94=ED=85=8C=EC=9D=BC=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 323 ++++++++++++++++++--------- app/services/user_db_service.py | 49 +++- 2 files changed, 260 insertions(+), 112 deletions(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index b437572..9fa66c5 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -3,6 +3,7 @@ import oracledb +from app.core.enum.db_driver import DBTypesEnum from app.core.exceptions import APIException from app.core.status import CommonCode from app.core.utils import get_db_path @@ -210,49 +211,9 @@ def find_columns( cursor = connection.cursor() if db_type == "sqlite": - # SQLite는 PRAGMA를 직접 실행 - pragma_sql = f"PRAGMA table_info('{table_name}')" - cursor.execute(pragma_sql) - columns_raw = cursor.fetchall() - columns = [ - ColumnInfo( - name=c[1], - type=c[2], - nullable=(c[3] == 0), # notnull == 0 means nullable - default=c[4], - comment=None, - is_pk=(c[5] == 1), - ) - for c in columns_raw - ] + columns = self._find_columns_for_sqlite(cursor, table_name) else: - if "%s" in column_query or "?" in column_query: - cursor.execute(column_query, (schema_name, table_name)) - elif ":owner" in column_query and ":table" in column_query: - owner_bind = schema_name.upper() if schema_name else schema_name - table_bind = table_name.upper() if table_name else table_name - try: - cursor.execute(column_query, {"owner": owner_bind, "table": table_bind}) - except Exception: - try: - pos_query = column_query.replace(":owner", ":1").replace(":table", ":2") - cursor.execute(pos_query, [owner_bind, table_bind]) - except Exception as e: - raise APIException(CommonCode.FAIL) from e - else: - cursor.execute(column_query) - - columns = [ - ColumnInfo( - name=c[0], - type=c[1], - nullable=(c[2] in ["YES", "Y", True]), - default=c[3], - comment=c[4] if len(c) > 4 else None, - is_pk=(c[5] in ["PRI", True] if len(c) > 5 else False), - ) - for c in cursor.fetchall() - ] + columns = self._find_columns_for_general(cursor, column_query, schema_name, table_name) return ColumnListResult(is_successful=True, code=CommonCode.SUCCESS_FIND_COLUMNS, columns=columns) except Exception: @@ -261,94 +222,252 @@ def find_columns( if connection: connection.close() + def _find_columns_for_sqlite(self, cursor: Any, table_name: str) -> list[ColumnInfo]: + pragma_sql = f"PRAGMA table_info('{table_name}')" + cursor.execute(pragma_sql) + columns_raw = cursor.fetchall() + return [ + ColumnInfo( + name=c[1], + type=c[2], + nullable=(c[3] == 0), + default=c[4], + comment=None, + is_pk=(c[5] == 1), + ) + for c in columns_raw + ] + + def _find_columns_for_general( + self, cursor: Any, column_query: str, schema_name: str, table_name: str + ) -> list[ColumnInfo]: + if "%s" in column_query or "?" in column_query: + cursor.execute(column_query, (schema_name, table_name)) + elif ":owner" in column_query and ":table" in column_query: + owner_bind = schema_name.upper() if schema_name else schema_name + table_bind = table_name.upper() if table_name else table_name + try: + cursor.execute(column_query, {"owner": owner_bind, "table": table_bind}) + except Exception: + try: + pos_query = column_query.replace(":owner", ":1").replace(":table", ":2") + cursor.execute(pos_query, [owner_bind, table_bind]) + except Exception as e: + raise APIException(CommonCode.FAIL) from e + else: + cursor.execute(column_query) + + columns = [] + for c in cursor.fetchall(): + data_type = c[1] + if c[6] is not None: + data_type = f"{data_type}({c[6]})" + elif c[7] is not None and c[8] is not None: + data_type = f"{data_type}({c[7]}, {c[8]})" + + columns.append( + ColumnInfo( + name=c[0], + type=data_type, + nullable=(c[2] in ["YES", "Y", True]), + default=c[3], + comment=c[4] if len(c) > 4 else None, + is_pk=(c[5] in ["PRI", True] if len(c) > 5 else False), + ) + ) + return columns + def find_constraints( - self, driver_module: Any, db_type: str, table_name: str, **kwargs: Any + self, driver_module: Any, db_type: str, schema_name: str, table_name: str, **kwargs: Any ) -> list[ConstraintInfo]: """ 테이블의 제약 조건 정보를 조회합니다. - - 현재는 SQLite만 지원합니다. + - 현재는 SQLite, PostgreSQL만 지원합니다. - 실패 시 DB 드라이버의 예외를 직접 발생시킵니다. """ connection = None try: connection = self._connect(driver_module, **kwargs) cursor = connection.cursor() - constraints = [] - if db_type == "sqlite": - # Foreign Key 제약 조건 조회 - fk_list_sql = f"PRAGMA foreign_key_list('{table_name}')" - cursor.execute(fk_list_sql) - fks = cursor.fetchall() - - # Foreign Key 정보를 그룹화 - fk_groups = {} - for fk in fks: - fk_id = fk[0] - if fk_id not in fk_groups: - fk_groups[fk_id] = {"referenced_table": fk[2], "columns": [], "referenced_columns": []} - fk_groups[fk_id]["columns"].append(fk[3]) - fk_groups[fk_id]["referenced_columns"].append(fk[4]) - - for _, group in fk_groups.items(): - constraints.append( - ConstraintInfo( - name=f"fk_{table_name}_{'_'.join(group['columns'])}", - type="FOREIGN KEY", - columns=group["columns"], - referenced_table=group["referenced_table"], - referenced_columns=group["referenced_columns"], - ) - ) - - # 다른 DB 타입에 대한 제약 조건 조회 로직 추가 가능 - # elif db_type == "postgresql": ... - - return constraints + if db_type == DBTypesEnum.sqlite.name: + return self._find_constraints_for_sqlite(cursor, table_name) + elif db_type == DBTypesEnum.postgresql.name: + return self._find_constraints_for_postgresql(cursor, schema_name, table_name) + # elif db_type == ...: + return [] finally: if connection: connection.close() - def find_indexes(self, driver_module: Any, db_type: str, table_name: str, **kwargs: Any) -> list[IndexInfo]: + def _find_constraints_for_sqlite(self, cursor: Any, table_name: str) -> list[ConstraintInfo]: + constraints = [] + fk_list_sql = f"PRAGMA foreign_key_list('{table_name}')" + cursor.execute(fk_list_sql) + fks = cursor.fetchall() + + # Foreign Key 정보를 그룹화 + fk_groups = {} + for fk in fks: + fk_id = fk[0] + if fk_id not in fk_groups: + fk_groups[fk_id] = {"referenced_table": fk[2], "columns": [], "referenced_columns": []} + fk_groups[fk_id]["columns"].append(fk[3]) + fk_groups[fk_id]["referenced_columns"].append(fk[4]) + + for _, group in fk_groups.items(): + constraints.append( + ConstraintInfo( + name=f"fk_{table_name}_{'_'.join(group['columns'])}", + type="FOREIGN KEY", + columns=group["columns"], + referenced_table=group["referenced_table"], + referenced_columns=group["referenced_columns"], + ) + ) + return constraints + + def _find_constraints_for_postgresql(self, cursor: Any, schema_name: str, table_name: str) -> list[ConstraintInfo]: + sql = """ + SELECT + tc.constraint_name, + tc.constraint_type, + kcu.column_name, + rc.update_rule, + rc.delete_rule, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name, + chk.check_clause + FROM + information_schema.table_constraints tc + LEFT JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema AND tc.table_name = kcu.table_name + LEFT JOIN information_schema.referential_constraints rc + ON tc.constraint_name = rc.constraint_name AND tc.table_schema = rc.constraint_schema + LEFT JOIN information_schema.constraint_column_usage ccu + ON rc.unique_constraint_name = ccu.constraint_name AND rc.unique_constraint_schema = ccu.table_schema + LEFT JOIN information_schema.check_constraints chk + ON tc.constraint_name = chk.constraint_name AND tc.table_schema = chk.constraint_schema + WHERE + tc.table_schema = %s AND tc.table_name = %s; + """ + cursor.execute(sql, (schema_name, table_name)) + raw_constraints = cursor.fetchall() + + constraint_map = {} + for row in raw_constraints: + (name, const_type, column, _, _, ref_table, ref_column, check_expr) = row + if name not in constraint_map: + constraint_map[name] = { + "type": const_type, + "columns": [], + "referenced_table": ref_table, + "referenced_columns": [], + "check_expression": check_expr, + } + if column and column not in constraint_map[name]["columns"]: + constraint_map[name]["columns"].append(column) + if ref_column and ref_column not in constraint_map[name]["referenced_columns"]: + constraint_map[name]["referenced_columns"].append(ref_column) + + return [ + ConstraintInfo( + name=name, + type=data["type"], + columns=data["columns"], + referenced_table=data["referenced_table"], + referenced_columns=data["referenced_columns"] if data["referenced_columns"] else None, + check_expression=data["check_expression"], + ) + for name, data in constraint_map.items() + ] + + def find_indexes( + self, driver_module: Any, db_type: str, schema_name: str, table_name: str, **kwargs: Any + ) -> list[IndexInfo]: """ 테이블의 인덱스 정보를 조회합니다. - - 현재는 SQLite만 지원합니다. - 실패 시 DB 드라이버의 예외를 직접 발생시킵니다. """ connection = None try: connection = self._connect(driver_module, **kwargs) cursor = connection.cursor() - indexes = [] - - if db_type == "sqlite": - index_list_sql = f"PRAGMA index_list('{table_name}')" - cursor.execute(index_list_sql) - raw_indexes = cursor.fetchall() - - for idx in raw_indexes: - index_name = idx[1] - is_unique = idx[2] == 1 - # "sqlite_autoindex_"로 시작하는 인덱스는 PK에 의해 자동 생성된 것이므로 제외 - if index_name.startswith("sqlite_autoindex_"): - continue - - index_info_sql = f"PRAGMA index_info('{index_name}')" - cursor.execute(index_info_sql) - index_columns = [row[2] for row in cursor.fetchall()] - - if index_columns: - indexes.append(IndexInfo(name=index_name, columns=index_columns, is_unique=is_unique)) - - # 다른 DB 타입에 대한 인덱스 조회 로직 추가 가능 - # elif db_type == "postgresql": ... - - return indexes + if db_type == DBTypesEnum.sqlite.name: + return self._find_indexes_for_sqlite(cursor, table_name) + elif db_type == DBTypesEnum.postgresql.name: + return self._find_indexes_for_postgresql(cursor, schema_name, table_name) + # elif db_type == ...: + return [] finally: if connection: connection.close() + def _find_indexes_for_sqlite(self, cursor: Any, table_name: str) -> list[IndexInfo]: + indexes = [] + index_list_sql = f"PRAGMA index_list('{table_name}')" + cursor.execute(index_list_sql) + raw_indexes = cursor.fetchall() + + for idx in raw_indexes: + index_name = idx[1] + is_unique = idx[2] == 1 + + # "sqlite_autoindex_"로 시작하는 인덱스는 PK에 의해 자동 생성된 것이므로 제외 + if index_name.startswith("sqlite_autoindex_"): + continue + + index_info_sql = f"PRAGMA index_info('{index_name}')" + cursor.execute(index_info_sql) + index_columns = [row[2] for row in cursor.fetchall()] + + if index_columns: + indexes.append(IndexInfo(name=index_name, columns=index_columns, is_unique=is_unique)) + return indexes + + def _find_indexes_for_postgresql(self, cursor: Any, schema_name: str, table_name: str) -> list[IndexInfo]: + sql = """ + SELECT + i.relname as index_name, + a.attname as column_name, + ix.indisunique as is_unique, + ix.indisprimary as is_primary + FROM + pg_class t, + pg_class i, + pg_index ix, + pg_attribute a, + pg_namespace n + WHERE + t.oid = ix.indrelid + and i.oid = ix.indexrelid + and a.attrelid = t.oid + and a.attnum = ANY(ix.indkey) + and t.relkind = 'r' + and n.oid = t.relnamespace + and n.nspname = %s + and t.relname = %s + ORDER BY + i.relname, a.attnum; + """ + cursor.execute(sql, (schema_name, table_name)) + raw_indexes = cursor.fetchall() + + index_map = {} + for row in raw_indexes: + index_name, column_name, is_unique, is_primary = row + if is_primary: # Exclude indexes created for PRIMARY KEY constraints + continue + if index_name not in index_map: + index_map[index_name] = {"columns": [], "is_unique": is_unique} + index_map[index_name]["columns"].append(column_name) + + return [ + IndexInfo(name=name, columns=data["columns"], is_unique=data["is_unique"]) + for name, data in index_map.items() + ] + # ───────────────────────────── # DB 연결 메서드 # ───────────────────────────── diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index 3afe6fd..eff501a 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -192,10 +192,12 @@ def get_full_schema_info( try: constraints = repository.find_constraints( - driver_module, db_info.type, table_name, **connect_kwargs + driver_module, db_info.type, schema_name, table_name, **connect_kwargs ) - indexes = repository.find_indexes(driver_module, db_info.type, table_name, **connect_kwargs) - except sqlite3.Error as e: + indexes = repository.find_indexes( + driver_module, db_info.type, schema_name, table_name, **connect_kwargs + ) + except (sqlite3.Error, self._get_driver_module(db_info.type).Error) as e: # 레포지토리에서 발생한 DB 예외를 서비스에서 처리 raise APIException(CommonCode.FAIL_FIND_CONSTRAINTS_OR_INDEXES) from e @@ -268,7 +270,7 @@ def _get_schema_query(self, db_type: str) -> str | None: if db_type == "postgresql": return """ SELECT schema_name FROM information_schema.schemata - WHERE schema_name NOT IN ('pg_catalog', 'information_schema') + WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') """ elif db_type in ["mysql", "mariadb"]: return "SELECT schema_name FROM information_schema.schemata" @@ -288,7 +290,7 @@ def _get_table_query(self, db_type: str, for_all_schemas: bool = False) -> str | """ else: return """ - SELECT table_name, table_schema FROM information_schema.tables + SELECT table_name FROM information_schema.tables WHERE table_type = 'BASE TABLE' AND table_schema = %s """ elif db_type in ["mysql", "mariadb"]: @@ -312,11 +314,38 @@ def _get_column_query(self, db_type: str) -> str | None: db_type = db_type.lower() if db_type == "postgresql": return """ - SELECT column_name, data_type, is_nullable, column_default, table_name, table_schema - FROM information_schema.columns - WHERE table_schema NOT IN ('pg_catalog', 'information_schema') - AND table_schema = %s - AND table_name = %s + SELECT + c.column_name, + c.data_type, + c.is_nullable, + c.column_default, + pgd.description AS comment, + ( + SELECT TRUE + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND kcu.column_name = c.column_name + ) AS is_pk, + c.character_maximum_length, + c.numeric_precision, + c.numeric_scale + FROM + information_schema.columns c + LEFT JOIN + pg_catalog.pg_stat_all_tables st + ON c.table_schema = st.schemaname AND c.table_name = st.relname + LEFT JOIN + pg_catalog.pg_description pgd + ON pgd.objoid = st.relid AND pgd.objsubid = c.ordinal_position + WHERE + c.table_schema = %s AND c.table_name = %s + ORDER BY + c.ordinal_position; """ elif db_type in ["mysql", "mariadb"]: return """ From 86642b285b0aea3894d909e3c32cfa9d434f7ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 16:36:16 +0900 Subject: [PATCH 182/220] =?UTF-8?q?refactor:=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=EC=99=80=20AI=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9D=84=20=EB=AA=A8=EB=91=90=20=ED=99=9C=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/annotation_service.py | 234 ++++++++++++++++++++--------- 1 file changed, 162 insertions(+), 72 deletions(-) diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index b217586..651b564 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -65,7 +65,9 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot conn = sqlite3.connect(str(db_path), timeout=10) conn.execute("BEGIN") - db_models = self._transform_ai_response_to_db_models(ai_response, db_profile, request.db_profile_id) + db_models = self._transform_ai_response_to_db_models( + ai_response, db_profile, request.db_profile_id, full_schema_info + ) self.repository.create_full_annotation(db_conn=conn, **db_models) conn.commit() @@ -82,11 +84,18 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot return self.get_full_annotation(annotation_id) def _transform_ai_response_to_db_models( - self, ai_response: dict[str, Any], db_profile: AllDBProfileInfo, db_profile_id: str + self, + ai_response: dict[str, Any], + db_profile: AllDBProfileInfo, + db_profile_id: str, + full_schema_info: list[UserDBTableInfo], ) -> dict[str, Any]: now = datetime.now() annotation_id = generate_prefixed_uuid(DBSaveIdEnum.database_annotation.value) + # 원본 스키마 정보를 쉽게 조회할 수 있도록 룩업 테이블 생성 + schema_lookup: dict[str, UserDBTableInfo] = {table.name: table for table in full_schema_info} + db_anno = DatabaseAnnotationInDB( id=annotation_id, db_profile_id=db_profile_id, @@ -96,7 +105,14 @@ def _transform_ai_response_to_db_models( updated_at=now, ) - table_annos, col_annos, constraint_annos, constraint_col_annos, index_annos, index_col_annos = ( + ( + all_table_annos, + all_col_annos, + all_constraint_annos, + all_constraint_col_annos, + all_index_annos, + all_index_col_annos, + ) = ( [], [], [], @@ -106,90 +122,164 @@ def _transform_ai_response_to_db_models( ) for tbl_data in ai_response.get("tables", []): - table_id = generate_prefixed_uuid(DBSaveIdEnum.table_annotation.value) - table_annos.append( - TableAnnotationInDB( - id=table_id, - database_annotation_id=annotation_id, - table_name=tbl_data["table_name"], - description=tbl_data.get("annotation"), + original_table = schema_lookup.get(tbl_data["table_name"]) + if not original_table: + continue + + ( + table_anno, + col_annos, + constraint_annos, + constraint_col_annos, + index_annos, + index_col_annos, + ) = self._create_annotations_for_table(tbl_data, original_table, annotation_id, now) + + all_table_annos.append(table_anno) + all_col_annos.extend(col_annos) + all_constraint_annos.extend(constraint_annos) + all_constraint_col_annos.extend(constraint_col_annos) + all_index_annos.extend(index_annos) + all_index_col_annos.extend(index_col_annos) + + return { + "db_annotation": db_anno, + "table_annotations": all_table_annos, + "column_annotations": all_col_annos, + "constraint_annotations": all_constraint_annos, + "constraint_column_annotations": all_constraint_col_annos, + "index_annotations": all_index_annos, + "index_column_annotations": all_index_col_annos, + } + + def _create_annotations_for_table( + self, + tbl_data: dict[str, Any], + original_table: UserDBTableInfo, + database_annotation_id: str, + now: datetime, + ) -> tuple: + table_id = generate_prefixed_uuid(DBSaveIdEnum.table_annotation.value) + table_anno = TableAnnotationInDB( + id=table_id, + database_annotation_id=database_annotation_id, + table_name=original_table.name, + description=tbl_data.get("annotation"), + created_at=now, + updated_at=now, + ) + + col_map = { + col.name: generate_prefixed_uuid(DBSaveIdEnum.column_annotation.value) for col in original_table.columns + } + + col_annos = self._process_columns(tbl_data, original_table, table_id, col_map, now) + constraint_annos, constraint_col_annos = self._process_constraints( + tbl_data, original_table, table_id, col_map, now + ) + index_annos, index_col_annos = self._process_indexes(tbl_data, original_table, table_id, col_map, now) + + return table_anno, col_annos, constraint_annos, constraint_col_annos, index_annos, index_col_annos + + def _process_columns( + self, tbl_data: dict, original_table: UserDBTableInfo, table_id: str, col_map: dict, now: datetime + ) -> list[ColumnAnnotationInDB]: + col_annos = [] + for col_data in tbl_data.get("columns", []): + original_column = next((c for c in original_table.columns if c.name == col_data["column_name"]), None) + if not original_column: + continue + col_annos.append( + ColumnAnnotationInDB( + id=col_map[original_column.name], + table_annotation_id=table_id, + column_name=original_column.name, + data_type=original_column.type, + is_nullable=1 if original_column.nullable else 0, + default_value=original_column.default, + description=col_data.get("annotation"), + # TODO: check_expression, ordinal_position은 현재 original_column에 없음 created_at=now, updated_at=now, ) ) + return col_annos - col_map = { - col["column_name"]: generate_prefixed_uuid(DBSaveIdEnum.column_annotation.value) - for col in tbl_data.get("columns", []) - } - - for col_data in tbl_data.get("columns", []): - col_annos.append( - ColumnAnnotationInDB( - id=col_map[col_data["column_name"]], - table_annotation_id=table_id, - column_name=col_data["column_name"], - description=col_data.get("annotation"), - created_at=now, - updated_at=now, - ) + def _process_constraints( + self, tbl_data: dict, original_table: UserDBTableInfo, table_id: str, col_map: dict, now: datetime + ) -> tuple[list[TableConstraintInDB], list[ConstraintColumnInDB]]: + constraint_annos, constraint_col_annos = [], [] + for const_data in tbl_data.get("constraints", []): + original_constraint = next((c for c in original_table.constraints if c.name == const_data["name"]), None) + if not original_constraint: + continue + const_id = generate_prefixed_uuid(DBSaveIdEnum.table_constraint.value) + constraint_annos.append( + TableConstraintInDB( + id=const_id, + table_annotation_id=table_id, + name=original_constraint.name, + constraint_type=ConstraintTypeEnum(original_constraint.type), + ref_table=original_constraint.referenced_table, + # TODO: on_update/on_delete/expression 등 추가 정보 필요 + created_at=now, + updated_at=now, ) - - for const_data in tbl_data.get("constraints", []): - const_id = generate_prefixed_uuid(DBSaveIdEnum.table_constraint.value) - constraint_annos.append( - TableConstraintInDB( - id=const_id, - table_annotation_id=table_id, - name=const_data["name"], - constraint_type=ConstraintTypeEnum(const_data["type"]), + ) + for i, col_name in enumerate(original_constraint.columns): + if col_name not in col_map: + continue + constraint_col_annos.append( + ConstraintColumnInDB( + id=generate_prefixed_uuid(DBSaveIdEnum.constraint_column.value), + constraint_id=const_id, + column_annotation_id=col_map[col_name], + position=i + 1, + referenced_column_name=( + original_constraint.referenced_columns[i] + if original_constraint.referenced_columns + and i < len(original_constraint.referenced_columns) + else None + ), created_at=now, updated_at=now, ) ) - for col_name in const_data.get("columns", []): - constraint_col_annos.append( - ConstraintColumnInDB( - id=generate_prefixed_uuid(DBSaveIdEnum.constraint_column.value), - constraint_id=const_id, - column_annotation_id=col_map[col_name], - created_at=now, - updated_at=now, - ) - ) + return constraint_annos, constraint_col_annos - for idx_data in tbl_data.get("indexes", []): - idx_id = generate_prefixed_uuid(DBSaveIdEnum.index_annotation.value) - index_annos.append( - IndexAnnotationInDB( - id=idx_id, - table_annotation_id=table_id, - name=idx_data["name"], - is_unique=1 if idx_data.get("is_unique") else 0, + def _process_indexes( + self, tbl_data: dict, original_table: UserDBTableInfo, table_id: str, col_map: dict, now: datetime + ) -> tuple[list[IndexAnnotationInDB], list[IndexColumnInDB]]: + index_annos, index_col_annos = [], [] + for idx_data in tbl_data.get("indexes", []): + original_index = next((i for i in original_table.indexes if i.name == idx_data["name"]), None) + if not original_index: + continue + idx_id = generate_prefixed_uuid(DBSaveIdEnum.index_annotation.value) + index_annos.append( + IndexAnnotationInDB( + id=idx_id, + table_annotation_id=table_id, + name=original_index.name, + is_unique=1 if original_index.is_unique else 0, + created_at=now, + updated_at=now, + ) + ) + for i, col_name in enumerate(original_index.columns): + if col_name not in col_map: + continue + index_col_annos.append( + IndexColumnInDB( + id=generate_prefixed_uuid(DBSaveIdEnum.index_column.value), + index_id=idx_id, + column_annotation_id=col_map[col_name], + position=i + 1, created_at=now, updated_at=now, ) ) - for col_name in idx_data.get("columns", []): - index_col_annos.append( - IndexColumnInDB( - id=generate_prefixed_uuid(DBSaveIdEnum.index_column.value), - index_id=idx_id, - column_annotation_id=col_map[col_name], - created_at=now, - updated_at=now, - ) - ) - - return { - "db_annotation": db_anno, - "table_annotations": table_annos, - "column_annotations": col_annos, - "constraint_annotations": constraint_annos, - "constraint_column_annotations": constraint_col_annos, - "index_annotations": index_annos, - "index_column_annotations": index_col_annos, - } + return index_annos, index_col_annos def get_full_annotation(self, annotation_id: str) -> FullAnnotationResponse: try: From 55ea5f1a3cc20bf5399fe9b61b604aaaa9f1fae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 16:54:54 +0900 Subject: [PATCH 183/220] =?UTF-8?q?fix:=20DB=EC=97=90=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=90=98=EC=A7=80=EB=A7=8C=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=88=84=EB=9D=BD=EB=90=9C=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/annotation_repository.py | 26 +++++++++++++++++------- app/schemas/annotation/response_model.py | 3 +++ app/services/annotation_service.py | 2 +- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/app/repository/annotation_repository.py b/app/repository/annotation_repository.py index c32ec61..0e2b3e5 100644 --- a/app/repository/annotation_repository.py +++ b/app/repository/annotation_repository.py @@ -182,18 +182,24 @@ def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationRespon # 컬럼 정보 cursor.execute( - "SELECT id, column_name, description FROM column_annotation WHERE table_annotation_id = ?", + "SELECT id, column_name, description, data_type, is_nullable, default_value FROM column_annotation WHERE table_annotation_id = ?", (table_id,), ) - columns = [ColumnAnnotationDetail.model_validate(dict(c)) for c in cursor.fetchall()] + columns = [] + for c in cursor.fetchall(): + c_dict = dict(c) + c_dict["is_nullable"] = ( + bool(c_dict["is_nullable"]) if c_dict.get("is_nullable") is not None else None + ) + columns.append(ColumnAnnotationDetail.model_validate(c_dict)) # 제약조건 정보 cursor.execute( """ SELECT tc.name, tc.constraint_type, ca.column_name FROM table_constraint tc - JOIN constraint_column cc ON tc.id = cc.constraint_id - JOIN column_annotation ca ON cc.column_annotation_id = ca.id + LEFT JOIN constraint_column cc ON tc.id = cc.constraint_id + LEFT JOIN column_annotation ca ON cc.column_annotation_id = ca.id WHERE tc.table_annotation_id = ? """, (table_id,), @@ -201,10 +207,16 @@ def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationRespon constraint_map = {} for row in cursor.fetchall(): if row["name"] not in constraint_map: - constraint_map[row["name"]] = {"type": row["constraint_type"], "columns": []} - constraint_map[row["name"]]["columns"].append(row["column_name"]) + constraint_map[row["name"]] = { + "type": row["constraint_type"], + "columns": [], + "description": None, + } + if row["column_name"]: + constraint_map[row["name"]]["columns"].append(row["column_name"]) constraints = [ - ConstraintDetail(name=k, type=v["type"], columns=v["columns"]) for k, v in constraint_map.items() + ConstraintDetail(name=k, type=v["type"], columns=v["columns"], description=v["description"]) + for k, v in constraint_map.items() ] # 인덱스 정보 diff --git a/app/schemas/annotation/response_model.py b/app/schemas/annotation/response_model.py index 5602e0e..5ec4556 100644 --- a/app/schemas/annotation/response_model.py +++ b/app/schemas/annotation/response_model.py @@ -10,6 +10,9 @@ class ColumnAnnotationDetail(BaseModel): id: str column_name: str description: str | None = None + data_type: str | None = None + is_nullable: bool | None = None + default_value: str | None = None class ConstraintDetail(BaseModel): diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index 651b564..8489c54 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -99,7 +99,7 @@ def _transform_ai_response_to_db_models( db_anno = DatabaseAnnotationInDB( id=annotation_id, db_profile_id=db_profile_id, - database_name=db_profile.name, + database_name=db_profile.name or db_profile.username, description=ai_response.get("database_annotation"), created_at=now, updated_at=now, From ac431f6344bcb196603b1c2bd0d49326655e1982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 19:14:00 +0900 Subject: [PATCH 184/220] =?UTF-8?q?feat:=20postgre=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=83=9D=EC=84=B1=20&=20ordinal=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=ED=8F=AC=ED=95=A8=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 53 +++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index 9fa66c5..d60c25f 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -210,8 +210,10 @@ def find_columns( connection = self._connect(driver_module, **kwargs) cursor = connection.cursor() - if db_type == "sqlite": + if db_type == DBTypesEnum.sqlite.name: columns = self._find_columns_for_sqlite(cursor, table_name) + elif db_type == DBTypesEnum.postgresql.name: + columns = self._find_columns_for_postgresql(cursor, schema_name, table_name) else: columns = self._find_columns_for_general(cursor, column_query, schema_name, table_name) @@ -226,6 +228,7 @@ def _find_columns_for_sqlite(self, cursor: Any, table_name: str) -> list[ColumnI pragma_sql = f"PRAGMA table_info('{table_name}')" cursor.execute(pragma_sql) columns_raw = cursor.fetchall() + # SQLite는 pragma에서 순서(cid)를 반환하지만, ordinal_position은 1부터 시작하는 표준이므로 +1 return [ ColumnInfo( name=c[1], @@ -234,6 +237,54 @@ def _find_columns_for_sqlite(self, cursor: Any, table_name: str) -> list[ColumnI default=c[4], comment=None, is_pk=(c[5] == 1), + ordinal_position=c[0] + 1, + ) + for c in columns_raw + ] + + def _find_columns_for_postgresql(self, cursor: Any, schema_name: str, table_name: str) -> list[ColumnInfo]: + sql = """ + SELECT + column_name, + udt_name, + is_nullable, + column_default, + ordinal_position, + (SELECT pg_catalog.col_description(c.oid, a.attnum) + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = a.table_name AND n.nspname = a.table_schema) as comment, + CASE + WHEN ( + SELECT constraint_type + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + WHERE tc.table_schema = a.table_schema + AND tc.table_name = a.table_name + AND kcu.column_name = a.column_name + AND tc.constraint_type = 'PRIMARY KEY' + ) = 'PRIMARY KEY' THEN TRUE + ELSE FALSE + END as is_pk + FROM + information_schema.columns a + WHERE + table_schema = %s AND table_name = %s + ORDER BY + ordinal_position; + """ + cursor.execute(sql, (schema_name, table_name)) + columns_raw = cursor.fetchall() + return [ + ColumnInfo( + name=c[0], + type=c[1], + nullable=(c[2] == "YES"), + default=c[3], + ordinal_position=c[4], + comment=c[5], + is_pk=c[6], ) for c in columns_raw ] From a0e82e6af3ab3a006cb4daa04c99f385662cde05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 19:17:02 +0900 Subject: [PATCH 185/220] =?UTF-8?q?fix:=20on=5Fupdate=20&=20on=5Fdelete=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 6 +++++- app/schemas/user_db/result_model.py | 3 +++ app/services/annotation_service.py | 6 ++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index d60c25f..c1eb0a8 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -407,7 +407,7 @@ def _find_constraints_for_postgresql(self, cursor: Any, schema_name: str, table_ constraint_map = {} for row in raw_constraints: - (name, const_type, column, _, _, ref_table, ref_column, check_expr) = row + (name, const_type, column, on_update, on_delete, ref_table, ref_column, check_expr) = row if name not in constraint_map: constraint_map[name] = { "type": const_type, @@ -415,6 +415,8 @@ def _find_constraints_for_postgresql(self, cursor: Any, schema_name: str, table_ "referenced_table": ref_table, "referenced_columns": [], "check_expression": check_expr, + "on_update": on_update, + "on_delete": on_delete, } if column and column not in constraint_map[name]["columns"]: constraint_map[name]["columns"].append(column) @@ -429,6 +431,8 @@ def _find_constraints_for_postgresql(self, cursor: Any, schema_name: str, table_ referenced_table=data["referenced_table"], referenced_columns=data["referenced_columns"] if data["referenced_columns"] else None, check_expression=data["check_expression"], + on_update=data["on_update"], + on_delete=data["on_delete"], ) for name, data in constraint_map.items() ] diff --git a/app/schemas/user_db/result_model.py b/app/schemas/user_db/result_model.py index 64d9190..d379019 100644 --- a/app/schemas/user_db/result_model.py +++ b/app/schemas/user_db/result_model.py @@ -53,6 +53,7 @@ class ColumnInfo(BaseModel): default: Any | None = Field(None, description="기본값") comment: str | None = Field(None, description="코멘트") is_pk: bool = Field(False, description="기본 키(Primary Key) 여부") + ordinal_position: int | None = Field(None, description="컬럼 순서") class ConstraintInfo(BaseModel): @@ -64,6 +65,8 @@ class ConstraintInfo(BaseModel): # FOREIGN KEY 관련 필드 referenced_table: str | None = Field(None, description="참조하는 테이블 (FK)") referenced_columns: list[str] | None = Field(None, description="참조하는 테이블의 컬럼 (FK)") + on_update: str | None = Field(None, description="UPDATE 시 동작 (FK)") + on_delete: str | None = Field(None, description="DELETE 시 동작 (FK)") # CHECK 관련 필드 check_expression: str | None = Field(None, description="CHECK 제약 조건 표현식") diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index 8489c54..90c8559 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -198,7 +198,7 @@ def _process_columns( is_nullable=1 if original_column.nullable else 0, default_value=original_column.default, description=col_data.get("annotation"), - # TODO: check_expression, ordinal_position은 현재 original_column에 없음 + ordinal_position=original_column.ordinal_position, created_at=now, updated_at=now, ) @@ -221,7 +221,9 @@ def _process_constraints( name=original_constraint.name, constraint_type=ConstraintTypeEnum(original_constraint.type), ref_table=original_constraint.referenced_table, - # TODO: on_update/on_delete/expression 등 추가 정보 필요 + expression=original_constraint.check_expression, + on_update_action=original_constraint.on_update, + on_delete_action=original_constraint.on_delete, created_at=now, updated_at=now, ) From 675bd1a315149e57a6ae636795eab40d4ad5501d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 19:29:50 +0900 Subject: [PATCH 186/220] =?UTF-8?q?fix:=20postgres=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=8B=9C=20columns=EA=B0=80=20=EB=B9=88=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EB=B0=98=ED=99=98=EB=90=98=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 51 ++++++++++++++-------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index c1eb0a8..fe748a0 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -245,36 +245,35 @@ def _find_columns_for_sqlite(self, cursor: Any, table_name: str) -> list[ColumnI def _find_columns_for_postgresql(self, cursor: Any, schema_name: str, table_name: str) -> list[ColumnInfo]: sql = """ SELECT - column_name, - udt_name, - is_nullable, - column_default, - ordinal_position, - (SELECT pg_catalog.col_description(c.oid, a.attnum) - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = a.table_name AND n.nspname = a.table_schema) as comment, - CASE - WHEN ( - SELECT constraint_type - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - WHERE tc.table_schema = a.table_schema - AND tc.table_name = a.table_name - AND kcu.column_name = a.column_name - AND tc.constraint_type = 'PRIMARY KEY' - ) = 'PRIMARY KEY' THEN TRUE - ELSE FALSE - END as is_pk + c.column_name, + c.udt_name, + c.is_nullable, + c.column_default, + c.ordinal_position, + (SELECT pg_catalog.col_description(cls.oid, c.dtd_identifier::int) + FROM pg_catalog.pg_class cls + JOIN pg_catalog.pg_namespace n ON n.oid = cls.relnamespace + WHERE cls.relname = c.table_name AND n.nspname = c.table_schema) as comment, + CASE WHEN kcu.column_name IS NOT NULL THEN TRUE ELSE FALSE END as is_pk FROM - information_schema.columns a + information_schema.columns c + LEFT JOIN information_schema.key_column_usage kcu + ON c.table_schema = kcu.table_schema + AND c.table_name = kcu.table_name + AND c.column_name = kcu.column_name + AND kcu.constraint_name IN ( + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_schema = %s + AND table_name = %s + AND constraint_type = 'PRIMARY KEY' + ) WHERE - table_schema = %s AND table_name = %s + c.table_schema = %s AND c.table_name = %s ORDER BY - ordinal_position; + c.ordinal_position; """ - cursor.execute(sql, (schema_name, table_name)) + cursor.execute(sql, (schema_name, table_name, schema_name, table_name)) columns_raw = cursor.fetchall() return [ ColumnInfo( From 8b767133ea6f6e34abe333e63be36833e4935416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 19:35:20 +0900 Subject: [PATCH 187/220] =?UTF-8?q?feat:=20postgresql=EC=9D=B4=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=EC=A0=81=EC=9C=BC=EB=A1=9C=20NOT=20NULL=EC=9D=84=20CH?= =?UTF-8?q?ECK=EB=A1=9C=20=EA=B4=80=EB=A6=AC=ED=95=98=EC=97=AC=20=EC=9D=B4?= =?UTF-8?q?=EB=A5=BC=20=ED=95=84=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index fe748a0..0ddf8b3 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -406,7 +406,13 @@ def _find_constraints_for_postgresql(self, cursor: Any, schema_name: str, table_ constraint_map = {} for row in raw_constraints: - (name, const_type, column, on_update, on_delete, ref_table, ref_column, check_expr) = row + # Filter out 'NOT NULL' constraints which are handled by `is_nullable` in column info + const_type = row[1] + check_clause = row[7] + if const_type == "CHECK" and check_clause and "IS NOT NULL" in check_clause.upper(): + continue + + (name, _, column, on_update, on_delete, ref_table, ref_column, check_expr) = row if name not in constraint_map: constraint_map[name] = { "type": const_type, From 356109c46527baef6c03a2c5bfe3a560217af1a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 19:45:54 +0900 Subject: [PATCH 188/220] =?UTF-8?q?feat:=20constraints=EC=97=90=20descript?= =?UTF-8?q?ion=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db/init_db.py | 1 + app/repository/annotation_repository.py | 9 +++++---- app/schemas/annotation/db_model.py | 1 + app/services/annotation_service.py | 1 + 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/db/init_db.py b/app/db/init_db.py index 2a83093..9148262 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -303,6 +303,7 @@ def initialize_database(): "table_annotation_id": "VARCHAR(64) NOT NULL", "constraint_type": "VARCHAR(16) NOT NULL", "name": "VARCHAR(255)", + "description": "TEXT", "expression": "TEXT", "ref_table": "VARCHAR(255)", "on_update_action": "VARCHAR(16)", diff --git a/app/repository/annotation_repository.py b/app/repository/annotation_repository.py index 0e2b3e5..3afae67 100644 --- a/app/repository/annotation_repository.py +++ b/app/repository/annotation_repository.py @@ -96,6 +96,7 @@ def create_full_annotation( c.table_annotation_id, c.constraint_type, c.name, + c.description, c.expression, c.ref_table, c.on_update_action, @@ -107,8 +108,8 @@ def create_full_annotation( ] cursor.executemany( """ - INSERT INTO table_constraint (id, table_annotation_id, constraint_type, name, expression, ref_table, on_update_action, on_delete_action, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO table_constraint (id, table_annotation_id, constraint_type, name, description, expression, ref_table, on_update_action, on_delete_action, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, constraint_data, ) @@ -196,7 +197,7 @@ def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationRespon # 제약조건 정보 cursor.execute( """ - SELECT tc.name, tc.constraint_type, ca.column_name + SELECT tc.name, tc.constraint_type, tc.description, ca.column_name FROM table_constraint tc LEFT JOIN constraint_column cc ON tc.id = cc.constraint_id LEFT JOIN column_annotation ca ON cc.column_annotation_id = ca.id @@ -210,7 +211,7 @@ def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationRespon constraint_map[row["name"]] = { "type": row["constraint_type"], "columns": [], - "description": None, + "description": row["description"], } if row["column_name"]: constraint_map[row["name"]]["columns"].append(row["column_name"]) diff --git a/app/schemas/annotation/db_model.py b/app/schemas/annotation/db_model.py index 667765b..8a73aa9 100644 --- a/app/schemas/annotation/db_model.py +++ b/app/schemas/annotation/db_model.py @@ -39,6 +39,7 @@ class TableConstraintInDB(AnnotationBase): table_annotation_id: str constraint_type: ConstraintTypeEnum name: str | None = None + description: str | None = None expression: str | None = None ref_table: str | None = None on_update_action: str | None = None diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index 90c8559..63f2783 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -220,6 +220,7 @@ def _process_constraints( table_annotation_id=table_id, name=original_constraint.name, constraint_type=ConstraintTypeEnum(original_constraint.type), + description=const_data.get("annotation"), ref_table=original_constraint.referenced_table, expression=original_constraint.check_expression, on_update_action=original_constraint.on_update, From d122a2ee059d102da636d523e4e6ee0bd3ca9e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Thu, 14 Aug 2025 20:52:17 +0900 Subject: [PATCH 189/220] =?UTF-8?q?docs:=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/annotation_repository.py | 5 +++++ app/services/annotation_service.py | 28 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/app/repository/annotation_repository.py b/app/repository/annotation_repository.py index 3afae67..0a74be9 100644 --- a/app/repository/annotation_repository.py +++ b/app/repository/annotation_repository.py @@ -20,6 +20,11 @@ class AnnotationRepository: + """ + 어노테이션 데이터에 대한 데이터베이스 CRUD 작업을 처리합니다. + 모든 메서드는 내부적으로 `sqlite3`를 사용하여 로컬 DB와 상호작용합니다. + """ + def create_full_annotation( self, db_conn: sqlite3.Connection, diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index 63f2783..c7a7ce7 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -36,6 +36,13 @@ class AnnotationService: def __init__( self, repository: AnnotationRepository = annotation_repository, user_db_serv: UserDbService = user_db_service ): + """ + AnnotationService를 초기화합니다. + + Args: + repository (AnnotationRepository): 어노테이션 레포지토리 의존성 주입. + user_db_serv (UserDbService): 사용자 DB 서비스 의존성 주입. + """ self.repository = repository self.user_db_service = user_db_serv @@ -90,6 +97,9 @@ def _transform_ai_response_to_db_models( db_profile_id: str, full_schema_info: list[UserDBTableInfo], ) -> dict[str, Any]: + """ + AI 서버의 응답을 받아서 DB에 저장할 수 있는 모델 딕셔너리로 변환합니다. + """ now = datetime.now() annotation_id = generate_prefixed_uuid(DBSaveIdEnum.database_annotation.value) @@ -159,6 +169,9 @@ def _create_annotations_for_table( database_annotation_id: str, now: datetime, ) -> tuple: + """ + 단일 테이블에 대한 모든 하위 어노테이션(컬럼, 제약조건, 인덱스)을 생성합니다. + """ table_id = generate_prefixed_uuid(DBSaveIdEnum.table_annotation.value) table_anno = TableAnnotationInDB( id=table_id, @@ -184,6 +197,9 @@ def _create_annotations_for_table( def _process_columns( self, tbl_data: dict, original_table: UserDBTableInfo, table_id: str, col_map: dict, now: datetime ) -> list[ColumnAnnotationInDB]: + """ + 테이블의 컬럼 어노테이션 모델 리스트를 생성합니다. + """ col_annos = [] for col_data in tbl_data.get("columns", []): original_column = next((c for c in original_table.columns if c.name == col_data["column_name"]), None) @@ -208,6 +224,9 @@ def _process_columns( def _process_constraints( self, tbl_data: dict, original_table: UserDBTableInfo, table_id: str, col_map: dict, now: datetime ) -> tuple[list[TableConstraintInDB], list[ConstraintColumnInDB]]: + """ + 테이블의 제약조건 및 제약조건 컬럼 어노테이션 모델 리스트를 생성합니다. + """ constraint_annos, constraint_col_annos = [], [] for const_data in tbl_data.get("constraints", []): original_constraint = next((c for c in original_table.constraints if c.name == const_data["name"]), None) @@ -253,6 +272,9 @@ def _process_constraints( def _process_indexes( self, tbl_data: dict, original_table: UserDBTableInfo, table_id: str, col_map: dict, now: datetime ) -> tuple[list[IndexAnnotationInDB], list[IndexColumnInDB]]: + """ + 테이블의 인덱스 및 인덱스 컬럼 어노테이션 모델 리스트를 생성합니다. + """ index_annos, index_col_annos = [], [] for idx_data in tbl_data.get("indexes", []): original_index = next((i for i in original_table.indexes if i.name == idx_data["name"]), None) @@ -285,6 +307,9 @@ def _process_indexes( return index_annos, index_col_annos def get_full_annotation(self, annotation_id: str) -> FullAnnotationResponse: + """ + ID를 기반으로 완전한 어노테이션 정보를 조회합니다. + """ try: annotation = self.repository.find_full_annotation_by_id(annotation_id) if not annotation: @@ -294,6 +319,9 @@ def get_full_annotation(self, annotation_id: str) -> FullAnnotationResponse: raise APIException(CommonCode.FAIL_FIND_ANNOTATION) from e def delete_annotation(self, annotation_id: str) -> AnnotationDeleteResponse: + """ + ID를 기반으로 어노테이션 및 관련 하위 데이터를 모두 삭제합니다. + """ try: is_deleted = self.repository.delete_annotation_by_id(annotation_id) if not is_deleted: From 18328af32eeca88b3ae15ee9d0f0c8e988c1711e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Fri, 15 Aug 2025 17:02:11 +0900 Subject: [PATCH 190/220] =?UTF-8?q?feat:=20AI=20request=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EB=90=A0=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/annotation/ai_model.py | 64 ++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 app/schemas/annotation/ai_model.py diff --git a/app/schemas/annotation/ai_model.py b/app/schemas/annotation/ai_model.py new file mode 100644 index 0000000..f902699 --- /dev/null +++ b/app/schemas/annotation/ai_model.py @@ -0,0 +1,64 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class AIColumnInfo(BaseModel): + """AI 요청을 위한 컬럼 정보 모델""" + + column_name: str = Field(..., description="컬럼 이름") + data_type: str = Field(..., description="데이터 타입") + is_pk: bool = Field(False, description="기본 키(Primary Key) 여부") + is_nullable: bool = Field(..., description="NULL 허용 여부") + default_value: Any | None = Field(None, description="기본값") + + +class AIConstraintInfo(BaseModel): + """AI 요청을 위한 제약 조건 정보 모델 (FK 제외)""" + + name: str | None = Field(None, description="제약 조건 이름") + type: str = Field(..., description="제약 조건 타입 (PRIMARY KEY, UNIQUE, CHECK)") + columns: list[str] = Field(..., description="제약 조건에 포함된 컬럼 목록") + check_expression: str | None = Field(None, description="CHECK 제약 조건 표현식") + + +class AIIndexInfo(BaseModel): + """AI 요청을 위한 인덱스 정보 모델""" + + name: str | None = Field(None, description="인덱스 이름") + columns: list[str] = Field(..., description="인덱스에 포함된 컬럼 목록") + is_unique: bool = Field(False, description="고유 인덱스 여부") + + +class AITableInfo(BaseModel): + """AI 요청을 위한 테이블 정보 모델""" + + table_name: str = Field(..., description="테이블 이름") + columns: list[AIColumnInfo] = Field(..., description="컬럼 목록") + constraints: list[AIConstraintInfo] = Field([], description="제약 조건 목록 (FK 제외)") + indexes: list[AIIndexInfo] = Field([], description="인덱스 목록") + sample_rows: list[dict[str, Any]] = Field([], description="테이블 샘플 데이터") + + +class AIRelationship(BaseModel): + """AI 요청을 위한 관계(FK) 정보 모델""" + + from_table: str = Field(..., description="관계를 시작하는 테이블") + from_columns: list[str] = Field(..., description="관계를 시작하는 컬럼") + to_table: str = Field(..., description="관계를 맺는 대상 테이블") + to_columns: list[str] = Field(..., description="관계를 맺는 대상 컬럼") + + +class AIDatabaseInfo(BaseModel): + """AI 요청을 위한 데이터베이스 정보 모델""" + + database_name: str = Field(..., description="데이터베이스 이름") + tables: list[AITableInfo] = Field(..., description="테이블 목록") + relationships: list[AIRelationship] = Field([], description="관계(FK) 목록") + + +class AIAnnotationRequest(BaseModel): + """AI 어노테이션 생성 요청 최상위 모델""" + + dbms_type: str = Field(..., description="DBMS 종류") + databases: list[AIDatabaseInfo] = Field(..., description="데이터베이스 목록") From 9015ae61b5a16ec3768390995aaea25212e98d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Fri, 15 Aug 2025 17:03:02 +0900 Subject: [PATCH 191/220] =?UTF-8?q?feat:=20AI=20=ED=8C=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9A=94=EC=B2=AD=ED=95=9C=20sample=5Frows=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/user_db_repository.py | 53 ++++++++++++++++++++++++++++ app/services/user_db_service.py | 20 ++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index 0ddf8b3..191ea36 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -528,6 +528,59 @@ def _find_indexes_for_postgresql(self, cursor: Any, schema_name: str, table_name for name, data in index_map.items() ] + def find_sample_rows( + self, driver_module: Any, db_type: str, schema_name: str, table_names: list[str], **kwargs: Any + ) -> dict[str, list[dict[str, Any]]]: + """ + 주어진 테이블 목록에 대해 상위 3개의 샘플 행을 조회합니다. + - 실패 시 DB 드라이버의 예외를 직접 발생시킵니다. + """ + connection = None + try: + connection = self._connect(driver_module, **kwargs) + cursor = connection.cursor() + + if db_type == DBTypesEnum.sqlite.name: + return self._find_sample_rows_for_sqlite(cursor, table_names) + elif db_type == DBTypesEnum.postgresql.name: + return self._find_sample_rows_for_postgresql(cursor, schema_name, table_names) + # elif db_type == ...: + return {table_name: [] for table_name in table_names} + finally: + if connection: + connection.close() + + def _find_sample_rows_for_sqlite(self, cursor: Any, table_names: list[str]) -> dict[str, list[dict[str, Any]]]: + sample_rows_map = {} + for table_name in table_names: + try: + # 컬럼명 조회를 위해 PRAGMA 사용 + cursor.execute(f"PRAGMA table_info('{table_name}')") + columns = [row[1] for row in cursor.fetchall()] + + # 데이터 조회 + cursor.execute(f'SELECT * FROM "{table_name}" LIMIT 3') + rows = cursor.fetchall() + sample_rows_map[table_name] = [dict(zip(columns, row, strict=False)) for row in rows] + except Exception: + sample_rows_map[table_name] = [] # 오류 발생 시 빈 리스트 할당 + return sample_rows_map + + def _find_sample_rows_for_postgresql( + self, cursor: Any, schema_name: str, table_names: list[str] + ) -> dict[str, list[dict[str, Any]]]: + sample_rows_map = {} + for table_name in table_names: + try: + # PostgreSQL은 cursor.description을 통해 컬럼명을 바로 얻을 수 있음 + cursor.execute(f'SELECT * FROM "{schema_name}"."{table_name}" LIMIT 3') + columns = [desc[0] for desc in cursor.description] + rows = cursor.fetchall() + sample_rows_map[table_name] = [dict(zip(columns, row, strict=False)) for row in rows] + except Exception: + sample_rows_map[table_name] = [] + return sample_rows_map + # ───────────────────────────── # DB 연결 메서드 # ───────────────────────────── diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index eff501a..7b3454d 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -152,7 +152,7 @@ def find_columns( def get_full_schema_info( self, db_info: AllDBProfileInfo, repository: UserDbRepository = user_db_repository - ) -> SchemaInfoResult: + ) -> list[TableInfo]: """ DB 프로필 정보를 받아 해당 데이터베이스의 전체 스키마 정보 (테이블, 컬럼, 제약조건, 인덱스)를 조회하여 반환합니다. @@ -219,6 +219,24 @@ def get_full_schema_info( # 그 외 모든 예외는 일반 실패로 처리 raise APIException(CommonCode.FAIL) from e + def get_sample_rows( + self, db_info: AllDBProfileInfo, table_infos: list[TableInfo], repository: UserDbRepository = user_db_repository + ) -> dict[str, list[dict[str, Any]]]: + """ + 테이블 정보 목록을 받아 각 테이블의 샘플 데이터를 조회하여 반환합니다. + """ + try: + driver_module = self._get_driver_module(db_info.type) + connect_kwargs = self._prepare_connection_args(db_info) + + # SQLite는 스키마 이름이 필요 없음 + schema_name = db_info.name if db_info.type != "sqlite" else "" + table_names = [table.name for table in table_infos] + + return repository.find_sample_rows(driver_module, db_info.type, schema_name, table_names, **connect_kwargs) + except Exception as e: + raise APIException(CommonCode.FAIL_FIND_SAMPLE_ROWS) from e + def _get_driver_module(self, db_type: str): """ DB 타입에 따라 동적으로 드라이버 모듈을 로드합니다. From f615ce69efb51a0375cdaa986162cc95302d2caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Fri, 15 Aug 2025 17:04:33 +0900 Subject: [PATCH 192/220] =?UTF-8?q?feat:=20AI=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EC=83=9D=EC=84=B1=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20&=20relationships=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/annotation_service.py | 122 +++++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 16 deletions(-) diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index c7a7ce7..f90696c 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -10,6 +10,15 @@ from app.core.status import CommonCode from app.core.utils import generate_prefixed_uuid, get_db_path from app.repository.annotation_repository import AnnotationRepository, annotation_repository +from app.schemas.annotation.ai_model import ( + AIAnnotationRequest, + AIColumnInfo, + AIConstraintInfo, + AIDatabaseInfo, + AIIndexInfo, + AIRelationship, + AITableInfo, +) from app.schemas.annotation.db_model import ( ColumnAnnotationInDB, ConstraintColumnInDB, @@ -49,23 +58,29 @@ def __init__( async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnotationResponse: """ 어노테이션 생성을 위한 전체 프로세스를 관장합니다. - 1. DB 프로필 및 전체 스키마 정보 조회 - 2. TODO: AI 서버에 요청 (현재는 Mock 데이터 사용) - 3. 트랜잭션 내에서 전체 어노테이션 정보 저장 + 1. DB 프로필, 전체 스키마 정보, 샘플 데이터 조회 + 2. AI 서버에 요청할 데이터 모델 생성 + 3. TODO: AI 서버에 요청 (현재는 Mock 데이터 사용) + 4. 트랜잭션 내에서 전체 어노테이션 정보 저장 """ try: request.validate() except ValueError as e: raise APIException(CommonCode.INVALID_ANNOTATION_REQUEST, detail=str(e)) from e - # 1. DB 프로필 및 전체 스키마 정보 조회 + # 1. DB 프로필, 전체 스키마 정보, 샘플 데이터 조회 db_profile = self.user_db_service.find_profile(request.db_profile_id) full_schema_info = self.user_db_service.get_full_schema_info(db_profile) + sample_rows = self.user_db_service.get_sample_rows(db_profile, full_schema_info) + + # 2. AI 서버에 요청할 데이터 모델 생성 + ai_request_body = self._prepare_ai_request_body(db_profile, full_schema_info, sample_rows) + print(ai_request_body.model_dump_json(indent=2)) - # 2. AI 서버에 요청 (현재는 Mock 데이터 사용) - ai_response = await self._request_annotation_to_ai_server(full_schema_info) + # 3. AI 서버에 요청 (현재는 Mock 데이터 사용) + ai_response = await self._request_annotation_to_ai_server(ai_request_body) - # 3. 트랜잭션 내에서 전체 어노테이션 정보 저장 + # 4. 트랜잭션 내에서 전체 어노테이션 정보 저장 db_path = get_db_path() conn = None try: @@ -90,6 +105,68 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot return self.get_full_annotation(annotation_id) + def _prepare_ai_request_body( + self, + db_profile: AllDBProfileInfo, + full_schema_info: list[UserDBTableInfo], + sample_rows: dict[str, list[dict[str, Any]]], + ) -> AIAnnotationRequest: + """ + AI 서버에 보낼 요청 본문을 Pydantic 모델로 생성합니다. + """ + ai_tables = [] + ai_relationships = [] + + for table_info in full_schema_info: + # FK 제약조건을 분리하여 relationships 목록 생성 + non_fk_constraints = [] + for const in table_info.constraints: + if const.type == "FOREIGN KEY" and const.referenced_table and const.referenced_columns: + ai_relationships.append( + AIRelationship( + from_table=table_info.name, + from_columns=const.columns, + to_table=const.referenced_table, + to_columns=const.referenced_columns, + ) + ) + else: + non_fk_constraints.append( + AIConstraintInfo( + name=const.name, + type=const.type, + columns=const.columns, + check_expression=const.check_expression, + ) + ) + + ai_table = AITableInfo( + table_name=table_info.name, + columns=[ + AIColumnInfo( + column_name=col.name, + data_type=col.type, + is_pk=col.is_pk, + is_nullable=col.nullable, + default_value=col.default, + ) + for col in table_info.columns + ], + constraints=non_fk_constraints, + indexes=[ + AIIndexInfo(name=idx.name, columns=idx.columns, is_unique=idx.is_unique) + for idx in table_info.indexes + ], + sample_rows=sample_rows.get(table_info.name, []), + ) + ai_tables.append(ai_table) + + ai_database = AIDatabaseInfo( + database_name=db_profile.name or db_profile.username, tables=ai_tables, relationships=ai_relationships + ) + + return AIAnnotationRequest(dbms_type=db_profile.type, databases=[ai_database]) + def _transform_ai_response_to_db_models( self, ai_response: dict[str, Any], @@ -330,13 +407,13 @@ def delete_annotation(self, annotation_id: str) -> AnnotationDeleteResponse: except sqlite3.Error as e: raise APIException(CommonCode.FAIL_DELETE_ANNOTATION) from e - async def _request_annotation_to_ai_server(self, schema_info: list[UserDBTableInfo]) -> dict: + async def _request_annotation_to_ai_server(self, ai_request: AIAnnotationRequest) -> dict: """AI 서버에 스키마 정보를 보내고 어노테이션을 받아옵니다.""" # 우선은 목업 데이터 활용 - return self._get_mock_ai_response(schema_info) + return self._get_mock_ai_response(ai_request) # Real implementation below - # request_body = {"database_schema": {"tables": [table.model_dump() for table in schema_info]}} + # request_body = ai_request.model_dump() # async with httpx.AsyncClient() as client: # try: # response = await client.post(AI_SERVER_URL, json=request_body, timeout=60.0) @@ -347,19 +424,21 @@ async def _request_annotation_to_ai_server(self, schema_info: list[UserDBTableIn # except httpx.RequestError as e: # raise APIException(CommonCode.FAIL_AI_SERVER_CONNECTION, detail=f"AI server connection failed: {e}") from e - def _get_mock_ai_response(self, schema_info: list[UserDBTableInfo]) -> dict: + def _get_mock_ai_response(self, ai_request: AIAnnotationRequest) -> dict: """테스트를 위한 Mock AI 서버 응답 생성""" + # 요청 데이터를 기반으로 동적으로 Mock 응답을 생성하도록 수정 + db_info = ai_request.databases[0] mock_response = { - "database_annotation": "Mock: 데이터베이스 전체에 대한 설명입니다.", + "database_annotation": f"Mock: '{db_info.database_name}' 데이터베이스 전체에 대한 설명입니다.", "tables": [], "relationships": [], } - for table in schema_info: + for table in db_info.tables: mock_table = { - "table_name": table.name, - "annotation": f"Mock: '{table.name}' 테이블에 대한 설명입니다.", + "table_name": table.table_name, + "annotation": f"Mock: '{table.table_name}' 테이블에 대한 설명입니다.", "columns": [ - {"column_name": col.name, "annotation": f"Mock: '{col.name}' 컬럼에 대한 설명입니다."} + {"column_name": col.column_name, "annotation": f"Mock: '{col.column_name}' 컬럼에 대한 설명입니다."} for col in table.columns ], "constraints": [ @@ -382,6 +461,17 @@ def _get_mock_ai_response(self, schema_info: list[UserDBTableInfo]) -> dict: ], } mock_response["tables"].append(mock_table) + + for rel in db_info.relationships: + mock_response["relationships"].append( + { + "from_table": rel.from_table, + "from_columns": rel.from_columns, + "to_table": rel.to_table, + "to_columns": rel.to_columns, + "annotation": f"Mock: '{rel.from_table}'과 '{rel.to_table}'의 관계 설명.", + } + ) return mock_response From 422addc98d6789cc4d94f6f39d70a06a6e73bc21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Fri, 15 Aug 2025 20:43:40 +0900 Subject: [PATCH 193/220] =?UTF-8?q?feat:=20db=5Fprofile=5Fid=EB=A1=9C=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/annotation_api.py | 16 ++++++++++++++++ app/db/init_db.py | 6 +++++- app/repository/annotation_repository.py | 14 ++++++++++++++ app/schemas/user_db/db_profile_model.py | 2 ++ app/schemas/user_db/result_model.py | 1 + app/services/annotation_service.py | 20 +++++++++++++++++--- 6 files changed, 55 insertions(+), 4 deletions(-) diff --git a/app/api/annotation_api.py b/app/api/annotation_api.py index aa5ce45..9c5d6d7 100644 --- a/app/api/annotation_api.py +++ b/app/api/annotation_api.py @@ -43,6 +43,22 @@ def get_annotation( return ResponseMessage.success(value=annotation, code=CommonCode.SUCCESS_FIND_ANNOTATION) +@router.get( + "/by-db-profile/{db_profile_id}", + response_model=ResponseMessage[FullAnnotationResponse], + summary="DB 프로필 ID로 어노테이션 조회", +) +def get_annotation_by_db_profile_id( + db_profile_id: str, + service: AnnotationService = annotation_service_dependency, +) -> ResponseMessage[FullAnnotationResponse]: + """ + `db_profile_id`에 연결된 어노테이션의 전체 상세 정보를 조회합니다. + """ + annotation = service.get_annotation_by_db_profile_id(db_profile_id) + return ResponseMessage.success(value=annotation, code=CommonCode.SUCCESS_FIND_ANNOTATION) + + @router.delete( "/remove/{annotation_id}", response_model=ResponseMessage[AnnotationDeleteResponse], diff --git a/app/db/init_db.py b/app/db/init_db.py index 9148262..b90cf66 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -80,14 +80,18 @@ def initialize_database(): "username": "VARCHAR(128)", "password": "VARCHAR(128)", "view_name": "VARCHAR(64)", + "annotation_id": "VARCHAR(64)", "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", + "FOREIGN KEY (annotation_id)": "REFERENCES database_annotation(id) ON DELETE SET NULL", } create_sql = ( f"CREATE TABLE IF NOT EXISTS db_profile ({', '.join([f'{k} {v}' for k, v in db_profile_cols.items()])})" ) cursor.execute(create_sql) - _synchronize_table(cursor, "db_profile", db_profile_cols) + _synchronize_table( + cursor, "db_profile", {k: v for k, v in db_profile_cols.items() if not k.startswith("FOREIGN KEY")} + ) cursor.execute( """ diff --git a/app/repository/annotation_repository.py b/app/repository/annotation_repository.py index 0a74be9..8ad55c3 100644 --- a/app/repository/annotation_repository.py +++ b/app/repository/annotation_repository.py @@ -161,6 +161,20 @@ def create_full_annotation( index_column_data, ) + def update_db_profile_annotation_id( + self, db_conn: sqlite3.Connection, db_profile_id: str, annotation_id: str + ) -> None: + """ + 주어진 db_profile_id에 해당하는 레코드의 annotation_id를 업데이트합니다. + - 서비스 계층에서 트랜잭션을 관리하므로 connection을 인자로 받습니다. + - 실패 시 sqlite3.Error를 발생시킵니다. + """ + cursor = db_conn.cursor() + cursor.execute( + "UPDATE db_profile SET annotation_id = ? WHERE id = ?", + (annotation_id, db_profile_id), + ) + def find_full_annotation_by_id(self, annotation_id: str) -> FullAnnotationResponse | None: """ annotationId로 전체 어노테이션 상세 정보를 조회합니다. diff --git a/app/schemas/user_db/db_profile_model.py b/app/schemas/user_db/db_profile_model.py index 49598d8..32d666a 100644 --- a/app/schemas/user_db/db_profile_model.py +++ b/app/schemas/user_db/db_profile_model.py @@ -57,10 +57,12 @@ def _is_empty(value: Any | None) -> bool: class UpdateOrCreateDBProfile(DBProfileInfo): id: str | None = Field(None, description="DB Key 값") view_name: str | None = Field(None, description="DB 노출명") + annotation_id: str | None = Field(None, description="연결된 어노테이션 ID") class AllDBProfileInfo(DBProfileInfo): id: str | None = Field(..., description="DB Key 값") view_name: str | None = Field(None, description="DB 노출명") + annotation_id: str | None = Field(None, description="연결된 어노테이션 ID") created_at: datetime = Field(..., description="profile 저장일") updated_at: datetime = Field(..., description="profile 수정일") diff --git a/app/schemas/user_db/result_model.py b/app/schemas/user_db/result_model.py index d379019..d2a5b2a 100644 --- a/app/schemas/user_db/result_model.py +++ b/app/schemas/user_db/result_model.py @@ -30,6 +30,7 @@ class DBProfile(BaseModel): name: str | None username: str | None view_name: str | None + annotation_id: str | None = None created_at: datetime updated_at: datetime diff --git a/app/services/annotation_service.py b/app/services/annotation_service.py index f90696c..e10fe0b 100644 --- a/app/services/annotation_service.py +++ b/app/services/annotation_service.py @@ -61,7 +61,7 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot 1. DB 프로필, 전체 스키마 정보, 샘플 데이터 조회 2. AI 서버에 요청할 데이터 모델 생성 3. TODO: AI 서버에 요청 (현재는 Mock 데이터 사용) - 4. 트랜잭션 내에서 전체 어노테이션 정보 저장 + 4. 트랜잭션 내에서 전체 어노테이션 정보 저장 및 DB 프로필 업데이트 """ try: request.validate() @@ -80,7 +80,7 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot # 3. AI 서버에 요청 (현재는 Mock 데이터 사용) ai_response = await self._request_annotation_to_ai_server(ai_request_body) - # 4. 트랜잭션 내에서 전체 어노테이션 정보 저장 + # 4. 트랜잭션 내에서 전체 어노테이션 정보 저장 및 DB 프로필 업데이트 db_path = get_db_path() conn = None try: @@ -92,8 +92,12 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot ) self.repository.create_full_annotation(db_conn=conn, **db_models) - conn.commit() annotation_id = db_models["db_annotation"].id + self.repository.update_db_profile_annotation_id( + db_conn=conn, db_profile_id=request.db_profile_id, annotation_id=annotation_id + ) + + conn.commit() except sqlite3.Error as e: if conn: @@ -105,6 +109,16 @@ async def create_annotation(self, request: AnnotationCreateRequest) -> FullAnnot return self.get_full_annotation(annotation_id) + def get_annotation_by_db_profile_id(self, db_profile_id: str) -> FullAnnotationResponse: + """ + db_profile_id를 기반으로 완전한 어노테이션 정보를 조회합니다. + """ + db_profile = self.user_db_service.find_profile(db_profile_id) + if not db_profile.annotation_id: + raise APIException(CommonCode.NO_ANNOTATION_FOR_PROFILE) + + return self.get_full_annotation(db_profile.annotation_id) + def _prepare_ai_request_body( self, db_profile: AllDBProfileInfo, From a32219700a43f7adae109097242045e96a3976cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Fri, 15 Aug 2025 22:33:04 +0900 Subject: [PATCH 194/220] =?UTF-8?q?chore:=20path=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/annotation_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/annotation_api.py b/app/api/annotation_api.py index 9c5d6d7..f974189 100644 --- a/app/api/annotation_api.py +++ b/app/api/annotation_api.py @@ -44,7 +44,7 @@ def get_annotation( @router.get( - "/by-db-profile/{db_profile_id}", + "/find/db/{db_profile_id}", response_model=ResponseMessage[FullAnnotationResponse], summary="DB 프로필 ID로 어노테이션 조회", ) From b3fc6781fb357341f26dd759ea2705b2d8d9cb1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Sat, 16 Aug 2025 15:02:42 +0900 Subject: [PATCH 195/220] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20&=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 3 ++ app/repository/user_db_repository.py | 4 ++- app/services/user_db_service.py | 51 ++++++++++++++++++++-------- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/app/core/status.py b/app/core/status.py index 22d4488..e1574dd 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -59,6 +59,7 @@ class CommonCode(Enum): """ DRIVER, DB 클라이언트 에러 코드 - 41xx """ INVALID_DB_DRIVER = (status.HTTP_409_CONFLICT, "4100", "지원하지 않는 데이터베이스입니다.") NO_DB_DRIVER = (status.HTTP_400_BAD_REQUEST, "4101", "데이터베이스는 필수 값입니다.") + NO_DB_PROFILE_FOUND = (status.HTTP_404_NOT_FOUND, "4102", "해당 ID의 DB 프로필을 찾을 수 없습니다.") """ KEY 클라이언트 에러 코드 - 42xx """ INVALID_API_KEY_FORMAT = (status.HTTP_400_BAD_REQUEST, "4200", "API 키의 형식이 올바르지 않습니다.") @@ -86,6 +87,7 @@ class CommonCode(Enum): """ ANNOTATION 클라이언트 에러 코드 - 44xx """ INVALID_ANNOTATION_REQUEST = (status.HTTP_400_BAD_REQUEST, "4400", "어노테이션 요청 데이터가 유효하지 않습니다.") + NO_ANNOTATION_FOR_PROFILE = (status.HTTP_404_NOT_FOUND, "4401", "해당 DB 프로필에 연결된 어노테이션이 없습니다.") """ SQL 클라이언트 에러 코드 - 45xx """ @@ -116,6 +118,7 @@ class CommonCode(Enum): "5105", "디비 제약조건 또는 인덱스 정보 조회 중 에러가 발생했습니다.", ) + FAIL_FIND_SAMPLE_ROWS = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5106", "샘플 데이터 조회 중 에러가 발생했습니다.") FAIL_SAVE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5130", "디비 정보 저장 중 에러가 발생했습니다.") FAIL_UPDATE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5150", "디비 정보 업데이트 중 에러가 발생했습니다.") FAIL_DELETE_PROFILE = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5170", "디비 정보 삭제 중 에러가 발생했습니다.") diff --git a/app/repository/user_db_repository.py b/app/repository/user_db_repository.py index 191ea36..d30d7c7 100644 --- a/app/repository/user_db_repository.py +++ b/app/repository/user_db_repository.py @@ -142,8 +142,10 @@ def find_profile(self, sql: str, data: tuple) -> AllDBProfileInfo: row = cursor.fetchone() if not row: - raise APIException(CommonCode.NO_SEARCH_DATA) + raise APIException(CommonCode.NO_DB_PROFILE_FOUND) return AllDBProfileInfo(**dict(row)) + except APIException: + raise except sqlite3.Error as e: raise APIException(CommonCode.FAIL_FIND_PROFILE) from e except Exception as e: diff --git a/app/services/user_db_service.py b/app/services/user_db_service.py index 7b3454d..782f396 100644 --- a/app/services/user_db_service.py +++ b/app/services/user_db_service.py @@ -34,7 +34,12 @@ def connection_test(self, db_info: DBProfileInfo, repository: UserDbRepository = try: driver_module = self._get_driver_module(db_info.type) connect_kwargs = self._prepare_connection_args(db_info) - return repository.connection_test(driver_module, **connect_kwargs) + result = repository.connection_test(driver_module, **connect_kwargs) + if not result.is_successful: + raise APIException(result.code) + return result + except APIException: + raise except Exception as e: raise APIException(CommonCode.FAIL) from e @@ -46,11 +51,15 @@ def create_profile( """ create_db_info.id = generate_prefixed_uuid(DBSaveIdEnum.user_db.value) try: - # [수정] 쿼리와 데이터를 서비스에서 생성하여 레포지토리로 전달합니다. sql, data = self._get_create_query_and_data(create_db_info) - return repository.create_profile(sql, data, create_db_info) + result = repository.create_profile(sql, data, create_db_info) + if not result.is_successful: + raise APIException(result.code) + return result + except APIException: + raise except Exception as e: - raise APIException(CommonCode.FAIL) from e + raise APIException(CommonCode.FAIL_SAVE_PROFILE) from e def update_profile( self, update_db_info: UpdateOrCreateDBProfile, repository: UserDbRepository = user_db_repository @@ -59,33 +68,45 @@ def update_profile( DB 연결 정보를 업데이트 후 결과를 반환합니다. """ try: - # [수정] 쿼리와 데이터를 서비스에서 생성하여 레포지토리로 전달합니다. sql, data = self._get_update_query_and_data(update_db_info) - return repository.update_profile(sql, data, update_db_info) + result = repository.update_profile(sql, data, update_db_info) + if not result.is_successful: + raise APIException(result.code) + return result + except APIException: + raise except Exception as e: - raise APIException(CommonCode.FAIL) from e + raise APIException(CommonCode.FAIL_UPDATE_PROFILE) from e def delete_profile(self, profile_id: str, repository: UserDbRepository = user_db_repository) -> ChangeProfileResult: """ DB 연결 정보를 삭제 후 결과를 반환합니다. """ try: - # [수정] 쿼리와 데이터를 서비스에서 생성하여 레포지토리로 전달합니다. sql, data = self._get_delete_query_and_data(profile_id) - return repository.delete_profile(sql, data, profile_id) + result = repository.delete_profile(sql, data, profile_id) + if not result.is_successful: + raise APIException(result.code) + return result + except APIException: + raise except Exception as e: - raise APIException(CommonCode.FAIL) from e + raise APIException(CommonCode.FAIL_DELETE_PROFILE) from e def find_all_profile(self, repository: UserDbRepository = user_db_repository) -> AllDBProfileResult: """ 모든 DB 연결 정보를 반환합니다. """ try: - # [수정] 쿼리를 서비스에서 생성하여 레포지토리로 전달합니다. sql = self._get_find_all_query() - return repository.find_all_profile(sql) + result = repository.find_all_profile(sql) + if not result.is_successful: + raise APIException(result.code) + return result + except APIException: + raise except Exception as e: - raise APIException(CommonCode.FAIL) from e + raise APIException(CommonCode.FAIL_FIND_PROFILE) from e def find_profile(self, profile_id, repository: UserDbRepository = user_db_repository) -> AllDBProfileInfo: """ @@ -95,8 +116,10 @@ def find_profile(self, profile_id, repository: UserDbRepository = user_db_reposi # [수정] 쿼리와 데이터를 서비스에서 생성하여 레포지토리로 전달합니다. sql, data = self._get_find_one_query_and_data(profile_id) return repository.find_profile(sql, data) + except APIException: + raise except Exception as e: - raise APIException(CommonCode.FAIL) from e + raise APIException(CommonCode.FAIL_FIND_PROFILE) from e def find_schemas( self, db_info: AllDBProfileInfo, repository: UserDbRepository = user_db_repository From be2d4703151c442e117694c32596fc0f0d8f5d47 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 17 Aug 2025 01:21:57 +0900 Subject: [PATCH 196/220] =?UTF-8?q?feat:=20router=EC=97=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/api/api_router.py b/app/api/api_router.py index da2d854..f1c7c0f 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from app.api import annotation_api, api_key_api, chat_tab_api, driver_api, test_api, user_db_api +from app.api import annotation_api, api_key_api, chat_tab_api, driver_api, query_api, test_api, user_db_api api_router = APIRouter() @@ -15,3 +15,4 @@ api_router.include_router(api_key_api.router, prefix="/keys", tags=["API Key"]) api_router.include_router(chat_tab_api.router, prefix="/chats", tags=["AI Chat"]) api_router.include_router(annotation_api.router, prefix="/annotations", tags=["Annotation"]) +api_router.include_router(query_api.router, prefix="/query", tags=["query"]) From 3f907f30c40ae50914031d0857de537d9b6ef2e4 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 17 Aug 2025 01:22:28 +0900 Subject: [PATCH 197/220] =?UTF-8?q?feat:=20db=20key=20name=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/db_key_prefix_name.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/core/enum/db_key_prefix_name.py b/app/core/enum/db_key_prefix_name.py index 0e3fa18..819edee 100644 --- a/app/core/enum/db_key_prefix_name.py +++ b/app/core/enum/db_key_prefix_name.py @@ -9,6 +9,7 @@ class DBSaveIdEnum(Enum): driver = "DRIVER" api_key = "API-KEY" chat_tab = "CHAT_TAB" + query = "QUERY" database_annotation = "DB-ANNO" table_annotation = "TBL-ANNO" From 2f79c65a2df64c6581776e8222dbaf8d988c9777 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 17 Aug 2025 01:22:55 +0900 Subject: [PATCH 198/220] =?UTF-8?q?feat:=20query=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/core/status.py b/app/core/status.py index e1574dd..930094c 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -46,7 +46,7 @@ class CommonCode(Enum): SUCCESS_DELETE_ANNOTATION = (status.HTTP_200_OK, "2402", "어노테이션을 성공적으로 삭제하였습니다.") """ SQL 성공 코드 - 25xx """ - + SUCCESS_EXECUTION = (status.HTTP_201_CREATED, "2400", "쿼리를 성공적으로 수행하였습니다.") # ======================================= # 클라이언트 에러 (Client Error) - 4xxx # ======================================= @@ -90,6 +90,8 @@ class CommonCode(Enum): NO_ANNOTATION_FOR_PROFILE = (status.HTTP_404_NOT_FOUND, "4401", "해당 DB 프로필에 연결된 어노테이션이 없습니다.") """ SQL 클라이언트 에러 코드 - 45xx """ + NO_CHAT_KEY = (status.HTTP_400_BAD_REQUEST, "4501", "CHAT 키는 필수 값입니다.") + NO_QUERY = (status.HTTP_400_BAD_REQUEST, "4500", "쿼리는 필수 값입니다.") # ================================== # 서버 에러 (Server Error) - 5xx @@ -139,6 +141,7 @@ class CommonCode(Enum): ) """ SQL 서버 에러 코드 - 55xx """ + FAIL_CREATE_QUERY = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5170", "쿼리 실행 정보 저장 중 에러가 발생했습니다.") def __init__(self, http_status: int, code: str, message: str): """Enum 멤버가 생성될 때 각 값을 속성으로 할당합니다.""" From 3087319455c001757f1247f4d09e74f40281aaad Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 17 Aug 2025 01:23:20 +0900 Subject: [PATCH 199/220] =?UTF-8?q?refactor:=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=88=98=ED=96=89=20=EC=8B=9C=20=EC=A0=80=EC=9E=A5=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=B6=80=EB=B6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db/init_db.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/db/init_db.py b/app/db/init_db.py index b90cf66..c514815 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -170,13 +170,17 @@ def initialize_database(): # --- query_history 테이블 처리 --- query_history_cols = { "id": "VARCHAR(64) PRIMARY KEY NOT NULL", + "user_db_id": "VARCHAR(64) NOT NULL", "chat_message_id": "VARCHAR(64) NOT NULL", + "database": "VARCHAR(256) NOT NULL", "query_text": "TEXT NOT NULL", - "is_success": "VARCHAR(1) NOT NULL", - "error_message": "TEXT NOT NULL", + "type": "VARCHAR(32)", + "is_success": "VARCHAR(1)", + "error_message": "TEXT", "created_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", "updated_at": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP", "FOREIGN KEY (chat_message_id)": "REFERENCES chat_message(id) ON DELETE CASCADE", + "FOREIGN KEY (user_db_id)": "REFERENCES db_profile(id) ON DELETE CASCADE", } create_sql = f"CREATE TABLE IF NOT EXISTS query_history ({', '.join([f'{k} {v}' for k, v in query_history_cols.items()])})" cursor.execute(create_sql) From ea4de3f3d1f74f76611b62573d45440f163a5db2 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 17 Aug 2025 01:23:50 +0900 Subject: [PATCH 200/220] =?UTF-8?q?feat:=20query=20=EC=88=98=ED=96=89=20?= =?UTF-8?q?=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/query/query_model.py | 58 +++++++++++++++++++++++++++++++ app/schemas/query/result_model.py | 29 ++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 app/schemas/query/query_model.py create mode 100644 app/schemas/query/result_model.py diff --git a/app/schemas/query/query_model.py b/app/schemas/query/query_model.py new file mode 100644 index 0000000..d645f89 --- /dev/null +++ b/app/schemas/query/query_model.py @@ -0,0 +1,58 @@ +# app/schemas/query/query_model.py + +from typing import Any + +from pydantic import BaseModel, Field + +from app.core.enum.db_key_prefix_name import DBSaveIdEnum +from app.core.exceptions import APIException +from app.core.status import CommonCode +from app.core.utils import generate_prefixed_uuid + + +# 사용자가 직접 입력해야 하는 정보만 포함합니다. +class QueryInfo(BaseModel): + user_db_id: str = Field(..., description="DB Key") + chat_message_id: str | None = Field(None, description="연결된 메시지 Key") + database: str | None = Field(None, description="database 명") + query_text: str | None = Field(None, description="쿼리 내용") + + def validate_required_fields(self) -> None: + """DB 종류별 필수 필드 유효성 검사""" + if self._is_empty(self.user_db_id): + raise APIException(CommonCode.NO_DB_DRIVER) + + if self._is_empty(self.chat_message_id): + raise APIException(CommonCode.NO_CHAT_KEY) + + if self._is_empty(self.query_text): + raise APIException(CommonCode.NO_QUERY) + + @staticmethod + def _is_empty(value: Any | None) -> bool: + """값이 None, 빈 문자열, 공백 문자열인지 검사""" + if value is None: + return True + if isinstance(value, str) and not value.strip(): + return True + return False + + +class ExecutionQuery(QueryInfo): + id: str | None = Field(None, description="Query Key 값") + type: str | None = Field(None, description="디비 타입") + is_success: str | None = Field(None, description="성공 여부") + error_message: str | None = Field(None, description="에러 메시지") + + @classmethod + def from_query_info(cls, query_info: QueryInfo, type: str, is_success: bool, error_message: str | None = None): + return cls( + id=generate_prefixed_uuid(DBSaveIdEnum.query.value), + user_db_id=query_info.user_db_id, + chat_message_id=query_info.chat_message_id, + database=query_info.database, + query_text=query_info.query_text, + type=type, + is_success="Y" if is_success else "N", + error_message=error_message, + ) diff --git a/app/schemas/query/result_model.py b/app/schemas/query/result_model.py new file mode 100644 index 0000000..91772df --- /dev/null +++ b/app/schemas/query/result_model.py @@ -0,0 +1,29 @@ +# app/schemas/user_db/result_model.py + +from pydantic import BaseModel, Field + +from app.core.status import CommonCode + + +# 기본 반환 모델 +class BasicResult(BaseModel): + is_successful: bool = Field(..., description="성공 여부") + code: CommonCode = Field(None, description="결과 코드") + + +class ExecutionSelectResult(BasicResult): + """DB 조회 결과를 위한 확장 모델""" + + data: dict = Field(..., description="쿼리 조회 후 결과 - 데이터") + + +class ExecutionResult(BasicResult): + """DB 결과를 위한 확장 모델""" + + data: str = Field(..., description="쿼리 수행 후 결과") + + +class InsertLocalDBResult(BasicResult): + """DB 결과를 위한 확장 모델""" + + data: str = Field(..., description="쿼리 수행 후 결과") From 83e94235f149c3aa8fe844c5d5333c2263d73e7f Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 17 Aug 2025 01:26:34 +0900 Subject: [PATCH 201/220] =?UTF-8?q?feat:=20query=20=EC=88=98=ED=96=89=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/query_api.py | 35 ++++++++++ app/repository/query_repository.py | 106 +++++++++++++++++++++++++++++ app/services/query_service.py | 90 ++++++++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 app/api/query_api.py create mode 100644 app/repository/query_repository.py create mode 100644 app/services/query_service.py diff --git a/app/api/query_api.py b/app/api/query_api.py new file mode 100644 index 0000000..f2abe5a --- /dev/null +++ b/app/api/query_api.py @@ -0,0 +1,35 @@ +# app/api/query_api.py + + +from fastapi import APIRouter, Depends + +from app.core.exceptions import APIException +from app.core.response import ResponseMessage +from app.schemas.query.query_model import QueryInfo +from app.services.query_service import QueryService, query_service +from app.services.user_db_service import UserDbService, user_db_service + +query_service_dependency = Depends(lambda: query_service) +user_db_service_dependency = Depends(lambda: user_db_service) + +router = APIRouter() + + +@router.post( + "/execution", + response_model=ResponseMessage[dict | str | None], + summary="쿼리 실행", +) +def execution( + query_info: QueryInfo, + service: QueryService = query_service_dependency, + userDbservice: UserDbService = user_db_service_dependency, +) -> ResponseMessage[dict | str | None]: + + query_info.validate_required_fields() + db_info = userDbservice.find_profile(query_info.user_db_id) + result = service.execution(query_info, db_info) + + if not result.is_successful: + raise APIException(result.code) + return ResponseMessage.success(value=result.data, code=result.code) diff --git a/app/repository/query_repository.py b/app/repository/query_repository.py new file mode 100644 index 0000000..21e88b3 --- /dev/null +++ b/app/repository/query_repository.py @@ -0,0 +1,106 @@ +import sqlite3 +from typing import Any + +import oracledb + +from app.core.exceptions import APIException +from app.core.status import CommonCode +from app.core.utils import get_db_path +from app.schemas.query.result_model import ( + BasicResult, + ExecutionResult, + ExecutionSelectResult, + InsertLocalDBResult, +) + + +class QueryRepository: + def execution( + self, + query: str, + driver_module: Any, + **kwargs: Any, + ) -> ExecutionSelectResult | ExecutionResult | BasicResult: + """ + 쿼리 수행합니다. + """ + connection = None + try: + connection = self._connect(driver_module, **kwargs) + cursor = connection.cursor() + + cursor.execute(query) + + if self._is_select_query(query): + rows = cursor.fetchall() + + if cursor.description: + columns = [desc[0] for desc in cursor.description] + data = [dict(zip(columns, row, strict=False)) for row in rows] + else: + columns = [] + data = [] + result = {"columns": columns, "data": data} + + return ExecutionSelectResult(is_successful=True, code=CommonCode.SUCCESS_EXECUTION, data=result) + + connection.commit() + return ExecutionResult(is_successful=True, code=CommonCode.SUCCESS_EXECUTION, data=cursor.rowcount) + except (AttributeError, driver_module.OperationalError, driver_module.DatabaseError): + return BasicResult(is_successful=False, code=CommonCode.FAIL_CONNECT_DB) + except Exception: + return BasicResult(is_successful=False, code=CommonCode.FAIL) + finally: + if connection: + connection.close() + + def create_query_history( + self, + sql: str, + data: tuple, + query: str, + ) -> InsertLocalDBResult: + """ + 쿼리 실행 결과를 저장합니다. + """ + db_path = get_db_path() + connection = None + try: + connection = sqlite3.connect(db_path) + cursor = connection.cursor() + cursor.execute(sql, data) + connection.commit() + + return ExecutionResult(is_successful=True, code=CommonCode.SUCCESS_EXECUTION, data=query) + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL_CONNECT_DB) from e + except Exception as e: + raise APIException(CommonCode.FAIL_CREATE_QUERY) from e + finally: + if connection: + connection.close() + + # ───────────────────────────── + # DB 연결 메서드 + # ───────────────────────────── + def _connect(self, driver_module: Any, **kwargs): + if driver_module is oracledb: + if kwargs.get("user", "").lower() == "sys": + kwargs["mode"] = oracledb.AUTH_MODE_SYSDBA + return driver_module.connect(**kwargs) + elif "connection_string" in kwargs: + return driver_module.connect(kwargs["connection_string"]) + elif "db_name" in kwargs: + return driver_module.connect(kwargs["db_name"]) + else: + return driver_module.connect(**kwargs) + + def _is_select_query(self, query_text: str) -> bool: + for stmt in query_text.split(";"): + cleaned_stmt = stmt.strip().lower() + if cleaned_stmt and not cleaned_stmt.startswith("--") and cleaned_stmt.startswith("select"): + return True + return False + + +query_repository = QueryRepository() diff --git a/app/services/query_service.py b/app/services/query_service.py new file mode 100644 index 0000000..a9b980b --- /dev/null +++ b/app/services/query_service.py @@ -0,0 +1,90 @@ +# app/service/query_service.py + +import importlib +import sqlite3 +from typing import Any + +from fastapi import Depends + +from app.core.enum.db_driver import DBTypesEnum +from app.core.exceptions import APIException +from app.core.status import CommonCode +from app.repository.query_repository import QueryRepository, query_repository +from app.schemas.query.query_model import ExecutionQuery, QueryInfo +from app.schemas.query.result_model import ( + BasicResult, + ExecutionResult, + ExecutionSelectResult, +) +from app.schemas.user_db.db_profile_model import AllDBProfileInfo, DBProfileInfo + +query_repository_dependency = Depends(lambda: query_repository) + + +class QueryService: + def execution( + self, query_info: QueryInfo, db_info: AllDBProfileInfo, repository: QueryRepository = query_repository + ) -> ExecutionSelectResult | ExecutionResult | BasicResult: + """ + 쿼리 수행 후 결과를 저장합니다. + """ + driver_module = self._get_driver_module(db_info.type) + connect_kwargs = self._prepare_connection_args(db_info, query_info.database) + result = repository.execution(query_info.query_text, driver_module, **connect_kwargs) + try: + query_history_info = ExecutionQuery.from_query_info(query_info, db_info.type, result.is_successful, None) + sql, data = self._get_create_query_and_data(query_history_info) + repository.create_query_history(sql, data, query_history_info.query_text) + except Exception as e: + raise APIException(CommonCode.FAIL) from e + return result + + def _get_driver_module(self, db_type: str): + """ + DB 타입에 따라 동적으로 드라이버 모듈을 로드합니다. + """ + driver_name = DBTypesEnum[db_type.lower()].value + if driver_name == "sqlite3": + return sqlite3 + return importlib.import_module(driver_name) + + def _prepare_connection_args(self, db_info: DBProfileInfo, database_name: str) -> dict[str, Any]: + """ + DB 타입에 따라 연결에 필요한 매개변수를 딕셔너리로 구성합니다. + """ + # SQLite는 별도 처리 + if db_info.type == "sqlite": + return {"db_name": db_info.name} + + # 그 외 DB들은 공통 파라미터로 시작 + kwargs = {"host": db_info.host, "port": db_info.port, "user": db_info.username, "password": db_info.password} + + # DB 이름이 없을 경우, 기본 파라미터만 반환 + if not db_info.name and not database_name: + return kwargs + + # DB 이름이 있다면, 타입에 따라 적절한 파라미터를 추가합니다. + final_db = database_name if database_name else db_info.name + if db_info.type == "postgresql": + kwargs["dbname"] = final_db + elif db_info.type in ["mysql", "mariadb"]: + kwargs["database"] = final_db + elif db_info.type == "oracle": + kwargs["dsn"] = f"{db_info.host}:{db_info.port}/{final_db}" + + return kwargs + + # ───────────────────────────── + # 프로필 CRUD 쿼리 생성 메서드 + # ───────────────────────────── + def _get_create_query_and_data(self, query_info: ExecutionQuery) -> tuple[str, tuple]: + profile_dict = query_info.model_dump() + columns_to_insert = {k: v for k, v in profile_dict.items() if v is not None} + columns = ", ".join(columns_to_insert.keys()) + placeholders = ", ".join(["?"] * len(columns_to_insert)) + sql = f"INSERT INTO query_history ({columns}) VALUES ({placeholders})" + data = tuple(columns_to_insert.values()) + return sql, data + + +query_service = QueryService() From 6e0f50cc4f2365c033c2afaea721c3e7f27ee3db Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 17 Aug 2025 01:58:56 +0900 Subject: [PATCH 202/220] =?UTF-8?q?feat:=20query=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/core/status.py b/app/core/status.py index 930094c..a02ddf7 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -47,6 +47,8 @@ class CommonCode(Enum): """ SQL 성공 코드 - 25xx """ SUCCESS_EXECUTION = (status.HTTP_201_CREATED, "2400", "쿼리를 성공적으로 수행하였습니다.") + SUCCESS_FIND_QUERY_HISTORY = (status.HTTP_200_OK, "2102", "쿼리 이력 조회를 성공하였습니다.") + # ======================================= # 클라이언트 에러 (Client Error) - 4xxx # ======================================= From 0e9fcf75811b6ea479ebcd111ca14274bb89fd46 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 17 Aug 2025 01:59:32 +0900 Subject: [PATCH 203/220] =?UTF-8?q?feat:=20query=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/query/result_model.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/schemas/query/result_model.py b/app/schemas/query/result_model.py index 91772df..127c4b7 100644 --- a/app/schemas/query/result_model.py +++ b/app/schemas/query/result_model.py @@ -27,3 +27,9 @@ class InsertLocalDBResult(BasicResult): """DB 결과를 위한 확장 모델""" data: str = Field(..., description="쿼리 수행 후 결과") + + +class SelectQueryHistoryResult(BasicResult): + """DB 결과를 위한 확장 모델""" + + data: dict = Field(..., description="쿼리 이력 조회") From ff6c6b8671573b99ee933e3a953ffa78775f60ee Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 17 Aug 2025 01:59:52 +0900 Subject: [PATCH 204/220] =?UTF-8?q?feat:=20query=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/query_api.py | 17 +++++++++++++ app/repository/query_repository.py | 38 ++++++++++++++++++++++++++++++ app/services/query_service.py | 12 ++++++++++ 3 files changed, 67 insertions(+) diff --git a/app/api/query_api.py b/app/api/query_api.py index f2abe5a..b7525d7 100644 --- a/app/api/query_api.py +++ b/app/api/query_api.py @@ -33,3 +33,20 @@ def execution( if not result.is_successful: raise APIException(result.code) return ResponseMessage.success(value=result.data, code=result.code) + + +@router.get( + "/find/{chat_tab_id}", + response_model=ResponseMessage[dict], + summary="쿼리 실행 내역 조회", +) +def find_query_history( + chat_tab_id: str, + service: QueryService = query_service_dependency, +) -> ResponseMessage[dict]: + + result = service.find_query_history(chat_tab_id) + + if not result.is_successful: + raise APIException(result.code) + return ResponseMessage.success(value=result.data, code=result.code) diff --git a/app/repository/query_repository.py b/app/repository/query_repository.py index 21e88b3..3056837 100644 --- a/app/repository/query_repository.py +++ b/app/repository/query_repository.py @@ -11,6 +11,7 @@ ExecutionResult, ExecutionSelectResult, InsertLocalDBResult, + SelectQueryHistoryResult, ) @@ -80,6 +81,43 @@ def create_query_history( if connection: connection.close() + def find_query_history(self, chat_tab_id: int) -> SelectQueryHistoryResult: + """ + 전달받은 쿼리를 실행하여 모든 DB 연결 정보를 조회합니다. + """ + db_path = get_db_path() + connection = None + try: + connection = sqlite3.connect(db_path) + connection.row_factory = sqlite3.Row + cursor = connection.cursor() + + sql = """ + SELECT qh.* + FROM query_history AS qh + LEFT JOIN chat_message AS cm ON qh.chat_message_id = cm.id + WHERE cm.chat_tab_id = ? + ORDER BY qh.created_at DESC + LIMIT 5; + """ + data = (chat_tab_id,) + + cursor.execute(sql, data) + rows = cursor.fetchall() + + columns = [desc[0] for desc in cursor.description] + data = [dict(zip(columns, row, strict=False)) for row in rows] + result = {"columns": columns, "data": data} + + return SelectQueryHistoryResult(is_successful=True, code=CommonCode.SUCCESS_FIND_QUERY_HISTORY, data=result) + except sqlite3.Error: + return SelectQueryHistoryResult(is_successful=False, code=CommonCode.FAIL_CONNECT_DB) + except Exception: + return SelectQueryHistoryResult(is_successful=False, code=CommonCode.FAIL) + finally: + if connection: + connection.close() + # ───────────────────────────── # DB 연결 메서드 # ───────────────────────────── diff --git a/app/services/query_service.py b/app/services/query_service.py index a9b980b..6222259 100644 --- a/app/services/query_service.py +++ b/app/services/query_service.py @@ -15,6 +15,7 @@ BasicResult, ExecutionResult, ExecutionSelectResult, + SelectQueryHistoryResult, ) from app.schemas.user_db.db_profile_model import AllDBProfileInfo, DBProfileInfo @@ -39,6 +40,17 @@ def execution( raise APIException(CommonCode.FAIL) from e return result + def find_query_history( + self, chat_tab_id: int, repository: QueryRepository = query_repository + ) -> SelectQueryHistoryResult: + """ + 쿼리 기록을 조회합니다. + """ + try: + return repository.find_query_history(chat_tab_id) + except Exception as e: + raise APIException(CommonCode.FAIL) from e + def _get_driver_module(self, db_type: str): """ DB 타입에 따라 동적으로 드라이버 모듈을 로드합니다. From 3fb3f3ae6690b868609af433f0cbe5ec236399d6 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 17 Aug 2025 16:48:26 +0900 Subject: [PATCH 205/220] =?UTF-8?q?feat:=20query=20test=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/status.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/core/status.py b/app/core/status.py index a02ddf7..86e73d9 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -48,6 +48,7 @@ class CommonCode(Enum): """ SQL 성공 코드 - 25xx """ SUCCESS_EXECUTION = (status.HTTP_201_CREATED, "2400", "쿼리를 성공적으로 수행하였습니다.") SUCCESS_FIND_QUERY_HISTORY = (status.HTTP_200_OK, "2102", "쿼리 이력 조회를 성공하였습니다.") + SUCCESS_EXECUTION_TEST = (status.HTTP_201_CREATED, "2400", "쿼리 TEST를 성공적으로 수행하였습니다.") # ======================================= # 클라이언트 에러 (Client Error) - 4xxx From 79dc12f184d4377bad16d141931cd2df9d6abb44 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 17 Aug 2025 16:48:55 +0900 Subject: [PATCH 206/220] =?UTF-8?q?feat:=20query=20test=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/query/query_model.py | 52 +++++++++++++++++++------------ app/schemas/query/result_model.py | 6 ++++ 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/app/schemas/query/query_model.py b/app/schemas/query/query_model.py index d645f89..bec85a2 100644 --- a/app/schemas/query/query_model.py +++ b/app/schemas/query/query_model.py @@ -2,7 +2,7 @@ from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from app.core.enum.db_key_prefix_name import DBSaveIdEnum from app.core.exceptions import APIException @@ -10,42 +10,54 @@ from app.core.utils import generate_prefixed_uuid -# 사용자가 직접 입력해야 하는 정보만 포함합니다. +def _is_empty(value: Any | None) -> bool: + """값이 None, 빈 문자열, 공백 문자열인지 검사""" + if value is None: + return True + if isinstance(value, str) and not value.strip(): + return True + return False + + class QueryInfo(BaseModel): user_db_id: str = Field(..., description="DB Key") - chat_message_id: str | None = Field(None, description="연결된 메시지 Key") database: str | None = Field(None, description="database 명") query_text: str | None = Field(None, description="쿼리 내용") - def validate_required_fields(self) -> None: - """DB 종류별 필수 필드 유효성 검사""" - if self._is_empty(self.user_db_id): + @model_validator(mode="after") + def validate_required_fields(self) -> "QueryInfo": + """QueryInfo 모델에 대한 필수 필드 유효성 검사""" + if _is_empty(self.user_db_id): raise APIException(CommonCode.NO_DB_DRIVER) - if self._is_empty(self.chat_message_id): - raise APIException(CommonCode.NO_CHAT_KEY) - - if self._is_empty(self.query_text): + if _is_empty(self.query_text): raise APIException(CommonCode.NO_QUERY) - @staticmethod - def _is_empty(value: Any | None) -> bool: - """값이 None, 빈 문자열, 공백 문자열인지 검사""" - if value is None: - return True - if isinstance(value, str) and not value.strip(): - return True - return False + return self + + +class RequestExecutionQuery(QueryInfo): + chat_message_id: str | None = Field(None, description="연결된 메시지 Key") + + @model_validator(mode="after") + def validate_chat_message_id(self) -> "RequestExecutionQuery": + """RequestExecutionQuery 모델에만 필요한 추가 필드 유효성 검사""" + if _is_empty(self.chat_message_id): + raise APIException(CommonCode.NO_CHAT_KEY) + + return self -class ExecutionQuery(QueryInfo): +class ExecutionQuery(RequestExecutionQuery): id: str | None = Field(None, description="Query Key 값") type: str | None = Field(None, description="디비 타입") is_success: str | None = Field(None, description="성공 여부") error_message: str | None = Field(None, description="에러 메시지") @classmethod - def from_query_info(cls, query_info: QueryInfo, type: str, is_success: bool, error_message: str | None = None): + def from_query_info( + cls, query_info: RequestExecutionQuery, type: str, is_success: bool, error_message: str | None = None + ): return cls( id=generate_prefixed_uuid(DBSaveIdEnum.query.value), user_db_id=query_info.user_db_id, diff --git a/app/schemas/query/result_model.py b/app/schemas/query/result_model.py index 127c4b7..4559553 100644 --- a/app/schemas/query/result_model.py +++ b/app/schemas/query/result_model.py @@ -33,3 +33,9 @@ class SelectQueryHistoryResult(BasicResult): """DB 결과를 위한 확장 모델""" data: dict = Field(..., description="쿼리 이력 조회") + + +class QueryTestResult(BasicResult): + """DB Test 결과를 위한 확장 모델""" + + data: bool = Field(..., description="쿼리 수행 결과") From 0a6e845bc375a68aa196e5a652394d5a7fa02d94 Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Sun, 17 Aug 2025 16:49:10 +0900 Subject: [PATCH 207/220] =?UTF-8?q?feat:=20query=20test=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/query_api.py | 26 ++++++++++++++++++++++---- app/repository/query_repository.py | 27 +++++++++++++++++++++++++++ app/services/query_service.py | 18 ++++++++++++++++-- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/app/api/query_api.py b/app/api/query_api.py index b7525d7..5b9927b 100644 --- a/app/api/query_api.py +++ b/app/api/query_api.py @@ -5,7 +5,7 @@ from app.core.exceptions import APIException from app.core.response import ResponseMessage -from app.schemas.query.query_model import QueryInfo +from app.schemas.query.query_model import QueryInfo, RequestExecutionQuery from app.services.query_service import QueryService, query_service from app.services.user_db_service import UserDbService, user_db_service @@ -16,17 +16,16 @@ @router.post( - "/execution", + "/execute", response_model=ResponseMessage[dict | str | None], summary="쿼리 실행", ) def execution( - query_info: QueryInfo, + query_info: RequestExecutionQuery, service: QueryService = query_service_dependency, userDbservice: UserDbService = user_db_service_dependency, ) -> ResponseMessage[dict | str | None]: - query_info.validate_required_fields() db_info = userDbservice.find_profile(query_info.user_db_id) result = service.execution(query_info, db_info) @@ -35,6 +34,25 @@ def execution( return ResponseMessage.success(value=result.data, code=result.code) +@router.post( + "/execute/test", + response_model=ResponseMessage[bool], + summary="쿼리 실행", +) +def execution_test( + query_info: QueryInfo, + service: QueryService = query_service_dependency, + userDbservice: UserDbService = user_db_service_dependency, +) -> ResponseMessage[bool]: + + db_info = userDbservice.find_profile(query_info.user_db_id) + result = service.execution_test(query_info, db_info) + + if not result.is_successful: + raise APIException(result.code) + return ResponseMessage.success(value=result.data, code=result.code) + + @router.get( "/find/{chat_tab_id}", response_model=ResponseMessage[dict], diff --git a/app/repository/query_repository.py b/app/repository/query_repository.py index 3056837..9dda359 100644 --- a/app/repository/query_repository.py +++ b/app/repository/query_repository.py @@ -11,6 +11,7 @@ ExecutionResult, ExecutionSelectResult, InsertLocalDBResult, + QueryTestResult, SelectQueryHistoryResult, ) @@ -55,6 +56,32 @@ def execution( if connection: connection.close() + def execution_test( + self, + query: str, + driver_module: Any, + **kwargs: Any, + ) -> QueryTestResult: + """ + 쿼리가 문법적으로 유효한지 테스트합니다. + 실제 데이터는 변경되지 않습니다. (모든 작업은 롤백됩니다). + """ + connection = None + try: + connection = self._connect(driver_module, **kwargs) + cursor = connection.cursor() + cursor.execute(query) + + connection.rollback() + return QueryTestResult(is_successful=True, code=CommonCode.SUCCESS_EXECUTION_TEST, data=True) + except (AttributeError, driver_module.OperationalError, driver_module.DatabaseError): + return QueryTestResult(is_successful=False, code=CommonCode.FAIL_CONNECT_DB, data=False) + except Exception: + return QueryTestResult(is_successful=False, code=CommonCode.FAIL, data=False) + finally: + if connection: + connection.close() + def create_query_history( self, sql: str, diff --git a/app/services/query_service.py b/app/services/query_service.py index 6222259..4a4a651 100644 --- a/app/services/query_service.py +++ b/app/services/query_service.py @@ -10,11 +10,12 @@ from app.core.exceptions import APIException from app.core.status import CommonCode from app.repository.query_repository import QueryRepository, query_repository -from app.schemas.query.query_model import ExecutionQuery, QueryInfo +from app.schemas.query.query_model import ExecutionQuery, QueryInfo, RequestExecutionQuery from app.schemas.query.result_model import ( BasicResult, ExecutionResult, ExecutionSelectResult, + QueryTestResult, SelectQueryHistoryResult, ) from app.schemas.user_db.db_profile_model import AllDBProfileInfo, DBProfileInfo @@ -24,7 +25,10 @@ class QueryService: def execution( - self, query_info: QueryInfo, db_info: AllDBProfileInfo, repository: QueryRepository = query_repository + self, + query_info: RequestExecutionQuery, + db_info: AllDBProfileInfo, + repository: QueryRepository = query_repository, ) -> ExecutionSelectResult | ExecutionResult | BasicResult: """ 쿼리 수행 후 결과를 저장합니다. @@ -40,6 +44,16 @@ def execution( raise APIException(CommonCode.FAIL) from e return result + def execution_test( + self, query_info: QueryInfo, db_info: AllDBProfileInfo, repository: QueryRepository = query_repository + ) -> QueryTestResult: + """ + 쿼리 수행 후 결과를 저장합니다. + """ + driver_module = self._get_driver_module(db_info.type) + connect_kwargs = self._prepare_connection_args(db_info, query_info.database) + return repository.execution_test(query_info.query_text, driver_module, **connect_kwargs) + def find_query_history( self, chat_tab_id: int, repository: QueryRepository = query_repository ) -> SelectQueryHistoryResult: From 7ff47781cb1f37a9d09d6482bfe5182f84be31cb Mon Sep 17 00:00:00 2001 From: ChoiseU Date: Mon, 18 Aug 2025 22:00:12 +0900 Subject: [PATCH 208/220] =?UTF-8?q?feat:=20query=20test=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EB=A5=BC=20=EC=A7=81=EC=A0=91=20=EB=8B=B4=EC=95=84=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/query_api.py | 8 ++++---- app/repository/query_repository.py | 24 ++++++++++++++++++------ app/schemas/query/result_model.py | 4 +++- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/app/api/query_api.py b/app/api/query_api.py index 5b9927b..9504a24 100644 --- a/app/api/query_api.py +++ b/app/api/query_api.py @@ -1,6 +1,8 @@ # app/api/query_api.py +from typing import Any + from fastapi import APIRouter, Depends from app.core.exceptions import APIException @@ -36,20 +38,18 @@ def execution( @router.post( "/execute/test", - response_model=ResponseMessage[bool], + response_model=ResponseMessage[Any], summary="쿼리 실행", ) def execution_test( query_info: QueryInfo, service: QueryService = query_service_dependency, userDbservice: UserDbService = user_db_service_dependency, -) -> ResponseMessage[bool]: +) -> ResponseMessage[Any]: db_info = userDbservice.find_profile(query_info.user_db_id) result = service.execution_test(query_info, db_info) - if not result.is_successful: - raise APIException(result.code) return ResponseMessage.success(value=result.data, code=result.code) diff --git a/app/repository/query_repository.py b/app/repository/query_repository.py index 9dda359..e309e8b 100644 --- a/app/repository/query_repository.py +++ b/app/repository/query_repository.py @@ -72,14 +72,26 @@ def execution_test( cursor = connection.cursor() cursor.execute(query) - connection.rollback() - return QueryTestResult(is_successful=True, code=CommonCode.SUCCESS_EXECUTION_TEST, data=True) - except (AttributeError, driver_module.OperationalError, driver_module.DatabaseError): - return QueryTestResult(is_successful=False, code=CommonCode.FAIL_CONNECT_DB, data=False) - except Exception: - return QueryTestResult(is_successful=False, code=CommonCode.FAIL, data=False) + if not self._is_select_query(query): + return QueryTestResult(is_successful=True, code=CommonCode.SUCCESS_EXECUTION_TEST, data=True) + + rows = cursor.fetchall() + if cursor.description: + columns = [desc[0] for desc in cursor.description] + data = [dict(zip(columns, row, strict=False)) for row in rows] + else: + columns = [] + data = [] + + result = {"columns": columns, "data": data} + return QueryTestResult(is_successful=True, code=CommonCode.SUCCESS_EXECUTION, data=result) + except (AttributeError, driver_module.OperationalError, driver_module.DatabaseError) as e: + return QueryTestResult(is_successful=False, code=CommonCode.FAIL_CONNECT_DB, data=str(e)) + except Exception as e: + return QueryTestResult(is_successful=False, code=CommonCode.FAIL, data=str(e)) finally: if connection: + connection.rollback() connection.close() def create_query_history( diff --git a/app/schemas/query/result_model.py b/app/schemas/query/result_model.py index 4559553..e4d0c62 100644 --- a/app/schemas/query/result_model.py +++ b/app/schemas/query/result_model.py @@ -1,5 +1,7 @@ # app/schemas/user_db/result_model.py +from typing import Any + from pydantic import BaseModel, Field from app.core.status import CommonCode @@ -38,4 +40,4 @@ class SelectQueryHistoryResult(BasicResult): class QueryTestResult(BasicResult): """DB Test 결과를 위한 확장 모델""" - data: bool = Field(..., description="쿼리 수행 결과") + data: Any = Field(..., description="쿼리 수행 결과") From 9b925742dd02b082b5c5f67a3e69ef3222321532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 18 Aug 2025 22:12:32 +0900 Subject: [PATCH 209/220] =?UTF-8?q?feat:=20=EB=9E=AD=EC=B2=B4=EC=9D=B8?= =?UTF-8?q?=EC=97=90=20=EC=82=AC=EC=9A=A9=EB=90=A0=20API=20KEY=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key_api.py | 17 ++++++++++++++++- app/schemas/api_key/decrypted_response_model.py | 7 +++++++ app/services/api_key_service.py | 10 ++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 app/schemas/api_key/decrypted_response_model.py diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index 7a26140..f9be6e2 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -4,6 +4,7 @@ from app.core.response import ResponseMessage from app.core.status import CommonCode from app.schemas.api_key.create_model import APIKeyCreate +from app.schemas.api_key.decrypted_response_model import DecryptedAPIKeyResponse from app.schemas.api_key.response_model import APIKeyResponse from app.schemas.api_key.update_model import APIKeyUpdate from app.services.api_key_service import APIKeyService, api_key_service @@ -31,7 +32,6 @@ def store_api_key( response_data = APIKeyResponse( id=created_api_key.id, service_name=created_api_key.service_name.value, - api_key_encrypted=created_api_key.api_key, created_at=created_api_key.created_at, updated_at=created_api_key.updated_at, ) @@ -86,6 +86,21 @@ def get_api_key_by_service_name( return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_GET_API_KEY) +@router.get( + "/internal/decrypted/{serviceName}", + response_model=ResponseMessage[DecryptedAPIKeyResponse], + summary="[내부용] 복호화된 API KEY 조회", + description="내부 AI 서버와 같이, 신뢰된 서비스가 복호화된 API 키를 요청할 때 사용합니다. (외부 노출 금지)", + # include_in_schema=False, # Swagger 문서에 포함하지 않음 +) +def get_decrypted_api_key( + serviceName: LLMServiceEnum, service: APIKeyService = api_key_service_dependency +) -> ResponseMessage[DecryptedAPIKeyResponse]: + """서비스 이름을 기준으로 API Key를 복호화하여 반환합니다.""" + decrypted_key = service.get_decrypted_api_key(serviceName.value) + return ResponseMessage.success(value=DecryptedAPIKeyResponse(api_key=decrypted_key)) + + @router.put( "/modify/{serviceName}", response_model=ResponseMessage[APIKeyResponse], diff --git a/app/schemas/api_key/decrypted_response_model.py b/app/schemas/api_key/decrypted_response_model.py new file mode 100644 index 0000000..65c730e --- /dev/null +++ b/app/schemas/api_key/decrypted_response_model.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, Field + + +class DecryptedAPIKeyResponse(BaseModel): + """Decrypted API Key 응답용 스키마""" + + api_key: str = Field(..., description="복호화된 실제 API Key") diff --git a/app/services/api_key_service.py b/app/services/api_key_service.py index 36b7492..88f1730 100644 --- a/app/services/api_key_service.py +++ b/app/services/api_key_service.py @@ -61,6 +61,16 @@ def get_api_key_by_service_name(self, service_name: str) -> APIKeyInDB: except sqlite3.Error as e: raise APIException(CommonCode.FAIL) from e + def get_decrypted_api_key(self, service_name: str) -> str: + """서비스 이름으로 암호화된 API Key를 조회하고 복호화하여 반환합니다.""" + api_key_in_db = self.get_api_key_by_service_name(service_name) + try: + decrypted_key = AES256.decrypt(api_key_in_db.api_key) + return decrypted_key + except Exception as e: + # 복호화 실패 시 서버 에러 발생 + raise APIException(CommonCode.FAIL_DECRYPT_API_KEY) from e + def update_api_key(self, service_name: str, key_data: APIKeyUpdate) -> APIKeyInDB: """서비스 이름에 해당하는 API Key를 수정합니다.""" key_data.validate_with_api_key() From 4b52c37f4518d6f5eb04edddb3d5ef60ab363eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 18 Aug 2025 22:20:20 +0900 Subject: [PATCH 210/220] =?UTF-8?q?chore:=20=EC=83=81=ED=83=9C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index f9be6e2..6989a7b 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -98,7 +98,9 @@ def get_decrypted_api_key( ) -> ResponseMessage[DecryptedAPIKeyResponse]: """서비스 이름을 기준으로 API Key를 복호화하여 반환합니다.""" decrypted_key = service.get_decrypted_api_key(serviceName.value) - return ResponseMessage.success(value=DecryptedAPIKeyResponse(api_key=decrypted_key)) + return ResponseMessage.success( + value=DecryptedAPIKeyResponse(api_key=decrypted_key), code=CommonCode.SUCCESS_GET_API_KEY + ) @router.put( From b0e4af842037ab70ae10e20649de6a882183aed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=ED=98=95=EC=A7=84?= Date: Mon, 18 Aug 2025 22:21:55 +0900 Subject: [PATCH 211/220] =?UTF-8?q?chore:=20path=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_key_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/api_key_api.py b/app/api/api_key_api.py index 6989a7b..0617237 100644 --- a/app/api/api_key_api.py +++ b/app/api/api_key_api.py @@ -87,7 +87,7 @@ def get_api_key_by_service_name( @router.get( - "/internal/decrypted/{serviceName}", + "/find/decrypted/{serviceName}", response_model=ResponseMessage[DecryptedAPIKeyResponse], summary="[내부용] 복호화된 API KEY 조회", description="내부 AI 서버와 같이, 신뢰된 서비스가 복호화된 API 키를 요청할 때 사용합니다. (외부 노출 금지)", From b6ca7a77a01a34bf4868d3ebbf6b03cde4d86769 Mon Sep 17 00:00:00 2001 From: mini Date: Mon, 18 Aug 2025 23:07:47 +0900 Subject: [PATCH 212/220] =?UTF-8?q?feat:=20chat=5Fmessage=20API=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 5 +++-- app/api/chat_messages_api.py | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 app/api/chat_messages_api.py diff --git a/app/api/api_router.py b/app/api/api_router.py index f1c7c0f..f2d1ac4 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from app.api import annotation_api, api_key_api, chat_tab_api, driver_api, query_api, test_api, user_db_api +from app.api import annotation_api, api_key_api, chat_tab_api, driver_api, query_api, test_api, user_db_api, chat_messages_api api_router = APIRouter() @@ -13,6 +13,7 @@ api_router.include_router(driver_api.router, prefix="/driver", tags=["Driver"]) api_router.include_router(user_db_api.router, prefix="/user/db", tags=["UserDb"]) api_router.include_router(api_key_api.router, prefix="/keys", tags=["API Key"]) -api_router.include_router(chat_tab_api.router, prefix="/chats", tags=["AI Chat"]) +api_router.include_router(chat_tab_api.router, prefix="/chatTabs", tags=["Chat Tab"]) api_router.include_router(annotation_api.router, prefix="/annotations", tags=["Annotation"]) api_router.include_router(query_api.router, prefix="/query", tags=["query"]) +api_router.include_router(chat_messages_api.router, prefix="/chatMessages", tags=["Chat Message"]) diff --git a/app/api/chat_messages_api.py b/app/api/chat_messages_api.py new file mode 100644 index 0000000..d63ca92 --- /dev/null +++ b/app/api/chat_messages_api.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, Depends + +from app.core.enum.sender import SenderEnum +from app.core.response import ResponseMessage +from app.core.status import CommonCode +from app.schemas.chat_message.request_model import ChatMessagesReqeust +from app.schemas.chat_message.response_model import ChatMessagesResponse +from app.services.chat_message_service import ChatMessageService, chat_message_service + +chat_message_service_dependency = Depends(lambda: chat_message_service) + +router = APIRouter() + + +@router.post( + "/create", + response_model=ResponseMessage[ChatMessagesResponse], + summary="새로운 사용자 질의 생성", +) +async def create_chat_message( + request: ChatMessagesReqeust, service: ChatMessageService = chat_message_service_dependency +) -> ResponseMessage[ChatMessagesResponse]: + """ + `tabId`, `message`를 받아 DB에 저장하고 AI를 통해 사용자 질의를 분석하고 답변을 생성하여 반환합니다. + """ + new_messages = await service.create_chat_message(request) + + print(ChatMessagesResponse.model_json_schema()) + + response_data = ChatMessagesResponse( + id=new_messages.id, + chat_tab_id=new_messages.chat_tab_id, + sender=SenderEnum(new_messages.sender), + message=new_messages.message, + created_at=new_messages.created_at, + updated_at=new_messages.updated_at, + ) + + return ResponseMessage.success(value=response_data, code=CommonCode.SUCCESS_CREATE_CHAT_MESSAGES) From 698f7717e13b5b6ac2cb97d982252b9066035086 Mon Sep 17 00:00:00 2001 From: mini Date: Mon, 18 Aug 2025 23:08:20 +0900 Subject: [PATCH 213/220] =?UTF-8?q?feat:=20chat=5Fmessage=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/api_router.py | 11 ++++++++++- app/api/query_api.py | 3 --- app/api/user_db_api.py | 5 ----- app/core/status.py | 11 ++++++++++- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/app/api/api_router.py b/app/api/api_router.py index f2d1ac4..45e22f4 100644 --- a/app/api/api_router.py +++ b/app/api/api_router.py @@ -2,7 +2,16 @@ from fastapi import APIRouter -from app.api import annotation_api, api_key_api, chat_tab_api, driver_api, query_api, test_api, user_db_api, chat_messages_api +from app.api import ( + annotation_api, + api_key_api, + chat_tab_api, + driver_api, + query_api, + test_api, + user_db_api, + chat_messages_api, +) api_router = APIRouter() diff --git a/app/api/query_api.py b/app/api/query_api.py index 9504a24..f924591 100644 --- a/app/api/query_api.py +++ b/app/api/query_api.py @@ -27,7 +27,6 @@ def execution( service: QueryService = query_service_dependency, userDbservice: UserDbService = user_db_service_dependency, ) -> ResponseMessage[dict | str | None]: - db_info = userDbservice.find_profile(query_info.user_db_id) result = service.execution(query_info, db_info) @@ -46,7 +45,6 @@ def execution_test( service: QueryService = query_service_dependency, userDbservice: UserDbService = user_db_service_dependency, ) -> ResponseMessage[Any]: - db_info = userDbservice.find_profile(query_info.user_db_id) result = service.execution_test(query_info, db_info) @@ -62,7 +60,6 @@ def find_query_history( chat_tab_id: str, service: QueryService = query_service_dependency, ) -> ResponseMessage[dict]: - result = service.find_query_history(chat_tab_id) if not result.is_successful: diff --git a/app/api/user_db_api.py b/app/api/user_db_api.py index ffd6656..f837dc3 100644 --- a/app/api/user_db_api.py +++ b/app/api/user_db_api.py @@ -90,7 +90,6 @@ def delete_profile( def find_all_profile( service: UserDbService = user_db_service_dependency, ) -> ResponseMessage[list[DBProfile]]: - result = service.find_all_profile() if not result.is_successful: @@ -104,7 +103,6 @@ def find_all_profile( summary="특정 DB의 전체 스키마 조회", ) def find_schemas(profile_id: str, service: UserDbService = user_db_service_dependency) -> ResponseMessage[list[str]]: - db_info = service.find_profile(profile_id) result = service.find_schemas(db_info) @@ -121,7 +119,6 @@ def find_schemas(profile_id: str, service: UserDbService = user_db_service_depen def find_tables( profile_id: str, schema_name: str, service: UserDbService = user_db_service_dependency ) -> ResponseMessage[list[str]]: - db_info = service.find_profile(profile_id) result = service.find_tables(db_info, schema_name) @@ -138,7 +135,6 @@ def find_tables( def find_columns( profile_id: str, schema_name: str, table_name: str, service: UserDbService = user_db_service_dependency ) -> ResponseMessage[list[ColumnInfo]]: - db_info = service.find_profile(profile_id) result = service.find_columns(db_info, schema_name, table_name) @@ -156,7 +152,6 @@ def find_columns( def find_all_schema_info( profile_id: str, service: UserDbService = user_db_service_dependency ) -> ResponseMessage[list[TableInfo]]: - db_info = service.find_profile(profile_id) full_schema_info = service.get_full_schema_info(db_info) diff --git a/app/core/status.py b/app/core/status.py index 86e73d9..130614f 100644 --- a/app/core/status.py +++ b/app/core/status.py @@ -33,7 +33,7 @@ class CommonCode(Enum): SUCCESS_UPDATE_API_KEY = (status.HTTP_200_OK, "2201", "API KEY가 성공적으로 수정되었습니다.") SUCCESS_GET_API_KEY = (status.HTTP_200_OK, "2202", "API KEY 정보를 성공적으로 조회했습니다.") - """ AI CHAT, DB 성공 코드 - 23xx """ + """ CHAT TAB 성공 코드 - 23xx """ SUCCESS_CHAT_TAB_CREATE = (status.HTTP_200_OK, "2300", "새로운 채팅 탭이 성공적으로 생성하였습니다.") SUCCESS_CHAT_TAB_UPDATE = (status.HTTP_200_OK, "2301", "채팅 탭 이름이 성공적으로 수정되었습니다.") SUCCESS_CHAT_TAB_DELETE = (status.HTTP_200_OK, "2302", "채팅 탭을 성공적으로 삭제되었습니다.") @@ -50,6 +50,9 @@ class CommonCode(Enum): SUCCESS_FIND_QUERY_HISTORY = (status.HTTP_200_OK, "2102", "쿼리 이력 조회를 성공하였습니다.") SUCCESS_EXECUTION_TEST = (status.HTTP_201_CREATED, "2400", "쿼리 TEST를 성공적으로 수행하였습니다.") + """ ChAT MESSAGE 성공 코드 - 26xx """ + SUCCESS_CREATE_CHAT_MESSAGES = (status.HTTP_201_CREATED, "2600", "메시지를 성공적으로 요청하였습니다.") + # ======================================= # 클라이언트 에러 (Client Error) - 4xxx # ======================================= @@ -96,6 +99,9 @@ class CommonCode(Enum): NO_CHAT_KEY = (status.HTTP_400_BAD_REQUEST, "4501", "CHAT 키는 필수 값입니다.") NO_QUERY = (status.HTTP_400_BAD_REQUEST, "4500", "쿼리는 필수 값입니다.") + """ CHAT MESSAGE 에러 코드 - 46xx """ + INVALID_CHAT_MESSAGE_REQUEST = (status.HTTP_400_BAD_REQUEST, "4600", "AI 채팅 요청 데이터가 유효하지 않습니다.") + # ================================== # 서버 에러 (Server Error) - 5xx # ================================== @@ -144,8 +150,11 @@ class CommonCode(Enum): ) """ SQL 서버 에러 코드 - 55xx """ + FAIL_CREATE_QUERY = (status.HTTP_500_INTERNAL_SERVER_ERROR, "5170", "쿼리 실행 정보 저장 중 에러가 발생했습니다.") + """ CHAT MESSAGE 에러 코드 - 56xx """ + def __init__(self, http_status: int, code: str, message: str): """Enum 멤버가 생성될 때 각 값을 속성으로 할당합니다.""" self.http_status = http_status From bd0ff9bc7438a733ce571c633447d429bc82ebb3 Mon Sep 17 00:00:00 2001 From: mini Date: Mon, 18 Aug 2025 23:09:04 +0900 Subject: [PATCH 214/220] =?UTF-8?q?feat:=20chat=5Fmessage=20=20enum=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/db_key_prefix_name.py | 1 + app/core/enum/sender.py | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 app/core/enum/sender.py diff --git a/app/core/enum/db_key_prefix_name.py b/app/core/enum/db_key_prefix_name.py index 819edee..fe9356c 100644 --- a/app/core/enum/db_key_prefix_name.py +++ b/app/core/enum/db_key_prefix_name.py @@ -10,6 +10,7 @@ class DBSaveIdEnum(Enum): api_key = "API-KEY" chat_tab = "CHAT_TAB" query = "QUERY" + chat_message = "CHAT_MESSAGE" database_annotation = "DB-ANNO" table_annotation = "TBL-ANNO" diff --git a/app/core/enum/sender.py b/app/core/enum/sender.py new file mode 100644 index 0000000..a6394b2 --- /dev/null +++ b/app/core/enum/sender.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class SenderEnum(str, Enum): + """채팅 메시지 발신자 구분""" + + user = "U" + ai = "A" From 14b952c0ef5e47b648a2947019541994022ed8ba Mon Sep 17 00:00:00 2001 From: mini Date: Mon, 18 Aug 2025 23:10:00 +0900 Subject: [PATCH 215/220] =?UTF-8?q?feat:=20chat=5Fmessage=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/chat_message/base_model.py | 24 ++++++++++++++++++++++ app/schemas/chat_message/db_model.py | 13 ++++++++++++ app/schemas/chat_message/request_model.py | 13 ++++++++++++ app/schemas/chat_message/response_model.py | 13 ++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 app/schemas/chat_message/base_model.py create mode 100644 app/schemas/chat_message/db_model.py create mode 100644 app/schemas/chat_message/request_model.py create mode 100644 app/schemas/chat_message/response_model.py diff --git a/app/schemas/chat_message/base_model.py b/app/schemas/chat_message/base_model.py new file mode 100644 index 0000000..049770c --- /dev/null +++ b/app/schemas/chat_message/base_model.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.core.enum.db_key_prefix_name import DBSaveIdEnum +from app.core.exceptions import APIException +from app.core.status import CommonCode + + +class ChatMessagesBase(BaseModel): + id: str = Field(..., description="고유 ID") + created_at: datetime = Field(..., description="생성 시각") + updated_at: datetime = Field(..., description="마지막 수정 시각") + + +class RequestBase(BaseModel): + """요청 스키마의 기본 모델""" + + def validate_chat_tab_id(self) -> None: + """채팅 탭 ID에 대한 유효성 검증 로직을 수행합니다.""" + + required_prefix = DBSaveIdEnum.chat_tab.value + "-" + if not self.chat_tab_id.startswith(required_prefix): + raise APIException(CommonCode.INVALID_CHAT_TAB_ID_FORMAT) diff --git a/app/schemas/chat_message/db_model.py b/app/schemas/chat_message/db_model.py new file mode 100644 index 0000000..2833efd --- /dev/null +++ b/app/schemas/chat_message/db_model.py @@ -0,0 +1,13 @@ +from pydantic import Field + +from app.core.enum.sender import SenderEnum +from app.schemas.chat_message.base_model import ChatMessagesBase + + +class ChatMessageInDB(ChatMessagesBase): + chat_tab_id: str = Field(..., description="해당 메시지가 속한 채팅 탭의 ID") + sender: SenderEnum = Field(..., description="메시지 발신자 ('A' 또는 'U')") + message: str = Field(..., description="메시지 내용") + + class Config: + use_enum_values = True diff --git a/app/schemas/chat_message/request_model.py b/app/schemas/chat_message/request_model.py new file mode 100644 index 0000000..fef913a --- /dev/null +++ b/app/schemas/chat_message/request_model.py @@ -0,0 +1,13 @@ +from pydantic import Field + +from app.schemas.chat_message.base_model import RequestBase + + +class ChatMessagesReqeust(RequestBase): + """채팅 메시지 생성 요청 스키마""" + + chat_tab_id: str = Field(..., description="채팅 탭의 고유 ID") + message: str = Field(..., description="메시지 내용") + + def validate(self): + self.validate_chat_tab_id() diff --git a/app/schemas/chat_message/response_model.py b/app/schemas/chat_message/response_model.py new file mode 100644 index 0000000..f1ce682 --- /dev/null +++ b/app/schemas/chat_message/response_model.py @@ -0,0 +1,13 @@ +from pydantic import Field + +from app.core.enum.sender import SenderEnum +from app.schemas.chat_message.base_model import ChatMessagesBase + + +class ChatMessagesResponse(ChatMessagesBase): + chat_tab_id: str = Field(..., description="해당 메시지가 속한 채팅 탭의 ID") + sender: SenderEnum = Field(..., description="메시지 발신자 ('AI' 또는 'User')") + message: str = Field(..., description="메시지 내용") + + class Config: + use_enum_values = True From 26240b0e5f46da638a23ab7c05c68401b27ccb14 Mon Sep 17 00:00:00 2001 From: mini Date: Mon, 18 Aug 2025 23:10:40 +0900 Subject: [PATCH 216/220] =?UTF-8?q?feat:=20chat=5Fmessage=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/chat_message_service.py | 146 +++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 app/services/chat_message_service.py diff --git a/app/services/chat_message_service.py b/app/services/chat_message_service.py new file mode 100644 index 0000000..fab4d57 --- /dev/null +++ b/app/services/chat_message_service.py @@ -0,0 +1,146 @@ +import sqlite3 + +import httpx +from fastapi import Depends + +from app.core.enum.db_key_prefix_name import DBSaveIdEnum +from app.core.enum.sender import SenderEnum +from app.core.exceptions import APIException +from app.core.status import CommonCode +from app.core.utils import generate_prefixed_uuid +from app.repository.chat_message_repository import ChatMessageRepository, chat_message_repository +from app.schemas.chat_message.db_model import ChatMessageInDB +from app.schemas.chat_message.request_model import ChatMessagesReqeust +from app.schemas.chat_message.response_model import ChatMessagesResponse + +chat_message_repository_dependency = Depends(lambda: chat_message_repository) + +# AI 서버의 주소 +AI_SERVER_URL = "http://localhost:35816/api/v1/chat" + + +class ChatMessageService: + def __init__(self, repository: ChatMessageRepository = chat_message_repository): + self.repository = repository + + def get_chat_messages_by_tabId(self, tabId: str) -> ChatMessageInDB: + """ + 채팅 탭 메타데이터와 메시지 목록을 모두 가져와서 조합합니다. + 탭이 존재하지 않으면 예외를 발생시킵니다. + """ + try: + return self.repository.get_chat_messages_by_tabId(tabId) + + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + + async def create_chat_message(self, request: ChatMessagesReqeust) -> ChatMessagesResponse: + # 1. tab_id 확인 + chat_tab_id = request.chat_tab_id + + # chat_tab_id 유효성 검사 + try: + request.validate() + except ValueError as e: + raise APIException(CommonCode.INVALID_CHAT_MESSAGE_REQUEST, detail=str(e)) from e + + try: + # 같은 서비스 메서드 호출 + self.get_chat_messages_by_tabId(chat_tab_id) + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + + # 2. 사용자 질의 저장 + try: + user_request = self._transform_user_request_to_db_models(request) + except sqlite3.Error as e: + raise APIException(CommonCode.FAIL) from e + + # 3. AI 서버에 요청 + ai_response = await self._request_chat_message_to_ai_server(user_request) + + # 4. AI 서버 응답 저장 + response = self._transform_ai_response_to_db_models(request, ai_response) + + return response + + def _transform_user_request_to_db_models(self, request: ChatMessagesReqeust) -> ChatMessageInDB: + """사용자 질의를 데이터베이스에 저장합니다.""" + + new_id = generate_prefixed_uuid(DBSaveIdEnum.chat_message.value) + sender = SenderEnum.user + + chat_tab_id = request.chat_tab_id + message = request.message + + try: + created_row = self.repository.create_chat_message( + new_id=new_id, + sender=sender, + chat_tab_id=chat_tab_id, + message=message, + ) + if not created_row: + raise APIException(CommonCode.FAIL_TO_VERIFY_CREATION) + + return created_row + + except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + raise APIException(CommonCode.FAIL) from e + + async def _request_chat_message_to_ai_server(self, user_request: ChatMessagesReqeust) -> dict: + """AI 서버에 사용자 질의를 보내고 답변을 받아옵니다.""" + # 1. DB에서 해당 탭의 모든 메시지 조회 + messages: list[ChatMessageInDB] = self.repository.get_chat_messages_by_tabId(user_request.chat_tab_id) + + if not messages: + history = [] + latest_message = user_request.message # DB에 없으면 요청 메시지 그대로 + else: + history = [{"role": m.sender, "content": m.message} for m in messages[:-1]] + latest_message = messages[-1].message + + # 3. AI 서버에 보내는 DATA + request_body = {"question": latest_message, "chat_history": history} + + # 4. AI 서버에 POST 요청 + async with httpx.AsyncClient() as client: + try: + response = await client.post(AI_SERVER_URL, json=request_body, timeout=60.0) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + raise APIException(CommonCode.FAIL_AI_SERVER_PROCESSING) from e + except httpx.RequestError as e: + raise APIException(CommonCode.FAIL_AI_SERVER_CONNECTION) from e + + def _transform_ai_response_to_db_models(self, request: ChatMessagesReqeust, ai_response: str) -> ChatMessageInDB: + """AI 서버에서 받은 답변을 데이터베이스에 저장합니다.""" + + new_id = generate_prefixed_uuid(DBSaveIdEnum.chat_message.value) + sender = SenderEnum.ai + + chat_tab_id = request.chat_tab_id + message = ai_response["answer"] + + try: + created_row = self.repository.create_chat_message( + new_id=new_id, + sender=sender, + chat_tab_id=chat_tab_id, + message=message, + ) + if not created_row: + raise APIException(CommonCode.FAIL_TO_VERIFY_CREATION) + + return created_row + + except sqlite3.Error as e: + if "database is locked" in str(e): + raise APIException(CommonCode.DB_BUSY) from e + raise APIException(CommonCode.FAIL) from e + + +chat_message_service = ChatMessageService() From 240a65b70a5ed38a0b8428a714cd90d2161a8787 Mon Sep 17 00:00:00 2001 From: mini Date: Mon, 18 Aug 2025 23:11:20 +0900 Subject: [PATCH 217/220] =?UTF-8?q?feat:=20chat=5Fmessage=20repository=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/repository/chat_message_repository.py | 40 ++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/app/repository/chat_message_repository.py b/app/repository/chat_message_repository.py index 6e1c67f..4539b0c 100644 --- a/app/repository/chat_message_repository.py +++ b/app/repository/chat_message_repository.py @@ -1,10 +1,48 @@ import sqlite3 from app.core.utils import get_db_path -from app.schemas.chat_tab.db_model import ChatMessageInDB +from app.schemas.chat_message.db_model import ChatMessageInDB class ChatMessageRepository: + def create_chat_message(self, new_id: str, sender: str, chat_tab_id: str, message: str) -> ChatMessageInDB: + """ + 새로운 채팅을 데이터베이스에 저장하고, 저장된 객체를 반환합니다. + """ + + db_path = get_db_path() + conn = None + try: + conn = sqlite3.connect(str(db_path), timeout=10) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute( + """ + INSERT INTO chat_message (id, chat_tab_id, sender, message) + VALUES (?, ?, ?, ?) + """, + ( + new_id, + chat_tab_id, + sender, + message, + ), + ) + conn.commit() + + cursor.execute("SELECT * FROM chat_message WHERE id = ?", (new_id,)) + created_row = cursor.fetchone() + + if not created_row: + return None + + return ChatMessageInDB.model_validate(dict(created_row)) + + finally: + if conn: + conn.close() + def get_chat_messages_by_tabId(self, id: str) -> list[ChatMessageInDB]: """주어진 chat_tab_id에 해당하는 모든 메시지를 가져옵니다.""" db_path = get_db_path() From 8f83e3e45f74143b0bab5f4d745aed952ed65bbe Mon Sep 17 00:00:00 2001 From: mini Date: Mon, 18 Aug 2025 23:12:43 +0900 Subject: [PATCH 218/220] =?UTF-8?q?refactor:=20chat=5Ftab=EC=97=90=20?= =?UTF-8?q?=EB=82=A8=EC=95=84=EC=9E=88=EB=8D=98=20chat=5Fmessage=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/chat_tab_api.py | 4 +-- app/schemas/chat_tab/base_model.py | 9 ++++++ app/schemas/chat_tab/db_model.py | 6 ---- app/schemas/chat_tab/update_model.py | 3 ++ app/schemas/chat_tab/validation_utils.py | 13 -------- app/services/chat_tab_service.py | 40 ++++++++---------------- 6 files changed, 27 insertions(+), 48 deletions(-) delete mode 100644 app/schemas/chat_tab/validation_utils.py diff --git a/app/api/chat_tab_api.py b/app/api/chat_tab_api.py index a29af95..de08f37 100644 --- a/app/api/chat_tab_api.py +++ b/app/api/chat_tab_api.py @@ -18,13 +18,13 @@ summary="새로운 Chat Tab 생성", description="새로운 Chat Tab을 생성하여 로컬 데이터베이스에 저장합니다.", ) -def store_chat_tab( +def create_chat_tab( chatName: ChatTabBase, service: ChatTabService = chat_tab_service_dependency ) -> ResponseMessage[ChatTabResponse]: """ - **name**: 새로운 Chat_tab 이름 (예: "채팅 타이틀") """ - created_chat_tab = service.store_chat_tab(chatName) + created_chat_tab = service.create_chat_tab(chatName) response_data = ChatTabResponse( id=created_chat_tab.id, diff --git a/app/schemas/chat_tab/base_model.py b/app/schemas/chat_tab/base_model.py index 9c4e9c8..f642c19 100644 --- a/app/schemas/chat_tab/base_model.py +++ b/app/schemas/chat_tab/base_model.py @@ -2,6 +2,7 @@ from pydantic import BaseModel, Field +from app.core.enum.db_key_prefix_name import DBSaveIdEnum from app.core.exceptions import APIException from app.core.status import CommonCode @@ -35,3 +36,11 @@ def validate_chat_tab_name(self) -> None: # 특정 특수문자를 검사하는 예시 if re.search(r"[;\"'`<>]", self.name): raise APIException(CommonCode.INVALID_CHAT_TAB_NAME_CONTENT) + + def validate_chat_tab_id(self) -> None: + """채팅 탭 ID에 대한 유효성 검증 로직을 수행합니다.""" + + # 1. 'CHAT-TAB-' 접두사 검증 + required_prefix = DBSaveIdEnum.chat_tab.value + "-" + if not self.id.startswith(required_prefix): + raise APIException(CommonCode.INVALID_CHAT_TAB_ID_FORMAT) diff --git a/app/schemas/chat_tab/db_model.py b/app/schemas/chat_tab/db_model.py index ddd37fd..d548797 100644 --- a/app/schemas/chat_tab/db_model.py +++ b/app/schemas/chat_tab/db_model.py @@ -13,9 +13,6 @@ class ChatTabInDB(ChatTabBase): created_at: datetime updated_at: datetime - class Config: - from_attributes = True - class ChatMessageInDB(ChatTabBase): """데이터베이스에 저장된 형태의 메시지 스키마 (내부용)""" @@ -26,6 +23,3 @@ class ChatMessageInDB(ChatTabBase): message: str = Field(..., description="메시지 내용") created_at: datetime updated_at: datetime - - class Config: - from_attributes = True diff --git a/app/schemas/chat_tab/update_model.py b/app/schemas/chat_tab/update_model.py index e9ca291..c42c56b 100644 --- a/app/schemas/chat_tab/update_model.py +++ b/app/schemas/chat_tab/update_model.py @@ -7,3 +7,6 @@ class ChatTabUpdate(ChatTabBase): """채팅 탭 이름 수정을 위한 스키마""" name: str = Field(None, description="수정할 채팅 탭 이름") + + def validate(self): + self.validate_chat_tab_name(["name"]) diff --git a/app/schemas/chat_tab/validation_utils.py b/app/schemas/chat_tab/validation_utils.py deleted file mode 100644 index 82470df..0000000 --- a/app/schemas/chat_tab/validation_utils.py +++ /dev/null @@ -1,13 +0,0 @@ -from app.core.enum.db_key_prefix_name import DBSaveIdEnum -from app.core.exceptions import APIException -from app.core.status import CommonCode - - -# 리팩토링 예정 -def validate_chat_tab_id(id: str | None) -> None: - """채팅 탭 ID에 대한 유효성 검증 로직을 수행합니다.""" - - # 1. 'CHAT-TAB-' 접두사 검증 - required_prefix = DBSaveIdEnum.chat_tab.value + "-" - if not id.startswith(required_prefix): - raise APIException(CommonCode.INVALID_CHAT_TAB_ID_FORMAT) diff --git a/app/services/chat_tab_service.py b/app/services/chat_tab_service.py index c7a3f25..f2e63ff 100644 --- a/app/services/chat_tab_service.py +++ b/app/services/chat_tab_service.py @@ -6,34 +6,29 @@ from app.core.exceptions import APIException from app.core.status import CommonCode from app.core.utils import generate_prefixed_uuid -from app.repository.chat_message_repository import ChatMessageRepository, chat_message_repository from app.repository.chat_tab_repository import ChatTabRepository, chat_tab_repository from app.schemas.chat_tab.base_model import ChatTabBase -from app.schemas.chat_tab.db_model import ChatMessageInDB, ChatTabInDB +from app.schemas.chat_tab.db_model import ChatTabInDB from app.schemas.chat_tab.update_model import ChatTabUpdate -from app.schemas.chat_tab.validation_utils import validate_chat_tab_id # 삭제 예정 chat_tab_repository_dependency = Depends(lambda: chat_tab_repository) -chat_tab_repository_dependency = Depends(lambda: chat_tab_repository) class ChatTabService: def __init__( self, - tab_repository: ChatTabRepository = chat_tab_repository, - message_repository: ChatMessageRepository = chat_message_repository, + repository: ChatTabRepository = chat_tab_repository, ): - self.tab_repository = tab_repository - self.message_repository = message_repository + self.repository = repository - def store_chat_tab(self, chatName: ChatTabBase) -> ChatTabInDB: + def create_chat_tab(self, chatName: ChatTabBase) -> ChatTabInDB: """새로운 AI 채팅을 데이터베이스에 저장합니다.""" chatName.validate_chat_tab_name() new_id = generate_prefixed_uuid(DBSaveIdEnum.chat_tab.value) try: - created_row = self.tab_repository.create_chat_tab( + created_row = self.repository.create_chat_tab( new_id=new_id, name=chatName.name, ) @@ -53,7 +48,7 @@ def updated_chat_tab(self, chatID: str, chatName: ChatTabUpdate) -> ChatTabInDB: """TabID에 해당하는 AIChatTab name을 수정합니다.""" chatName.validate_chat_tab_name() try: - updated_chat_tab = self.tab_repository.updated_chat_tab(chatID, chatName.name) + updated_chat_tab = self.repository.updated_chat_tab(chatID, chatName.name) if not updated_chat_tab: raise APIException(CommonCode.NO_CHAT_TAB_DATA) @@ -67,7 +62,7 @@ def updated_chat_tab(self, chatID: str, chatName: ChatTabUpdate) -> ChatTabInDB: def delete_chat_tab(self, tabId: str) -> None: """TabID에 해당하는 AIChatTab을 삭제합니다.""" try: - is_deleted = self.tab_repository.delete_chat_tab(tabId) + is_deleted = self.repository.delete_chat_tab(tabId) if not is_deleted: raise APIException(CommonCode.NO_CHAT_TAB_DATA) except sqlite3.Error as e: @@ -78,17 +73,19 @@ def delete_chat_tab(self, tabId: str) -> None: def get_all_chat_tab(self) -> ChatTabInDB: """데이터베이스에 저장된 모든 Chat_tab을 조회합니다.""" try: - return self.tab_repository.get_all_chat_tab() + return self.repository.get_all_chat_tab() except sqlite3.Error as e: raise APIException(CommonCode.FAIL) from e def get_chat_tab_by_tabId(self, tabId: str) -> ChatTabInDB: """데이터베이스에 저장된 특정 Chat_tab을 조회합니다.""" - # 리팩토링 예정 - validate_chat_tab_id(tabId) + try: + tabId.validate(tabId) + except ValueError as e: + raise APIException(CommonCode.INVALID_ANNOTATION_REQUEST, detail=str(e)) from e try: - chat_tab = self.tab_repository.get_chat_tab_by_id(tabId) + chat_tab = self.repository.get_chat_tab_by_id(tabId) if not chat_tab: raise APIException(CommonCode.NO_CHAT_TAB_DATA) @@ -97,16 +94,5 @@ def get_chat_tab_by_tabId(self, tabId: str) -> ChatTabInDB: except sqlite3.Error as e: raise APIException(CommonCode.FAIL) from e - def get_chat_messages_by_tabId(self, tabId: str) -> ChatMessageInDB: - """ - 채팅 탭 메타데이터와 메시지 목록을 모두 가져와서 조합합니다. - 탭이 존재하지 않으면 예외를 발생시킵니다. - """ - try: - return self.message_repository.get_chat_messages_by_tabId(tabId) - - except sqlite3.Error as e: - raise APIException(CommonCode.FAIL) from e - chat_tab_service = ChatTabService() From 721de877ff2f0110224a7612c86b0baef0e0d334 Mon Sep 17 00:00:00 2001 From: mini Date: Mon, 18 Aug 2025 23:52:37 +0900 Subject: [PATCH 219/220] =?UTF-8?q?refactor:=20prefix=5Fname=20=ED=95=98?= =?UTF-8?q?=EC=9D=B4=ED=94=88(-)=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/enum/db_key_prefix_name.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/core/enum/db_key_prefix_name.py b/app/core/enum/db_key_prefix_name.py index fe9356c..252a801 100644 --- a/app/core/enum/db_key_prefix_name.py +++ b/app/core/enum/db_key_prefix_name.py @@ -8,9 +8,9 @@ class DBSaveIdEnum(Enum): user_db = "USER-DB" driver = "DRIVER" api_key = "API-KEY" - chat_tab = "CHAT_TAB" + chat_tab = "CHAT-TAB" query = "QUERY" - chat_message = "CHAT_MESSAGE" + chat_message = "CHAT-MESSAGE" database_annotation = "DB-ANNO" table_annotation = "TBL-ANNO" From 5398a1b3085206d1fde74d83e815427c2f92ee42 Mon Sep 17 00:00:00 2001 From: mini Date: Mon, 18 Aug 2025 23:53:16 +0900 Subject: [PATCH 220/220] =?UTF-8?q?refactor:=20AI=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/chat_message_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/chat_message_service.py b/app/services/chat_message_service.py index fab4d57..493554c 100644 --- a/app/services/chat_message_service.py +++ b/app/services/chat_message_service.py @@ -1,3 +1,4 @@ +import os import sqlite3 import httpx @@ -15,8 +16,7 @@ chat_message_repository_dependency = Depends(lambda: chat_message_repository) -# AI 서버의 주소 -AI_SERVER_URL = "http://localhost:35816/api/v1/chat" +AI_SERVER_URL = os.getenv("ENV_AI_SERVER_URL") class ChatMessageService: