From 3b32d4e2ccd5d00a5146170798b16f1ba24a2ba8 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Thu, 28 May 2026 20:29:45 +0900 Subject: [PATCH 1/8] fix: ZeroDivisionError --- app/seeds/router.py | 3 ++- app/utils.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/seeds/router.py b/app/seeds/router.py index 8290cb9..9bf7c7a 100644 --- a/app/seeds/router.py +++ b/app/seeds/router.py @@ -37,7 +37,8 @@ def generate_seeds( background_tasks.add_task( insert_seeds, num_recommendations=body.recommendations, - num_users=body.users + num_users=body.users, + silent=True, ) return { diff --git a/app/utils.py b/app/utils.py index c4e6161..7110844 100644 --- a/app/utils.py +++ b/app/utils.py @@ -107,7 +107,9 @@ def progress( prefix = msg status = current if total is None else f"{current}/{total}" suffix = unit if total is None or unit_always else "" - suffix += f" {current/total*100:.2f}%" if percentage and total is not None else "" + if percentage and total is not None: + percentage_value = current / total * 100 if total else 100 + suffix += f" ({percentage_value:.2f}%)" print(f"\r {prefix} {status} {suffix}\x1b[K", end=end, flush=True) From fefbbb40f891de0a82ce00cabf866517be1ec169 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Thu, 28 May 2026 21:08:26 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20GET=20/seeds/status=20-=20=EC=8B=9C?= =?UTF-8?q?=EB=93=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B0=9C=EC=88=98=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/seeds/router.py | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/app/seeds/router.py b/app/seeds/router.py index 9bf7c7a..e02b81e 100644 --- a/app/seeds/router.py +++ b/app/seeds/router.py @@ -2,8 +2,14 @@ from typing import Literal from pydantic import BaseModel, Field -from fastapi import APIRouter, BackgroundTasks, status +from fastapi import APIRouter, BackgroundTasks, Depends, status +from sqlalchemy import select, func, union_all +from sqlalchemy.orm import Session +from ..database import get_db +from ..models import User, Recommendation + +from ..config import SEED_USERNAME_PREFIX, SEED_TASK_ID_PREFIX from .insert import run as insert_seeds from .drop import run as drop_seeds @@ -19,6 +25,12 @@ class SeedsResponse(BaseModel): requested: Literal[True] +class GetSeedsResponse(BaseModel): + """시드 데이터 개수 조회 응답""" + + total_users: int + total_recommendations: int + router = APIRouter(prefix="/seeds", tags=["seeds"]) @@ -66,3 +78,28 @@ def delete_seeds( return { "requested": True, } + +@router.get( + "/status", + summary="시드 데이터 개수 조회", + status_code=status.HTTP_200_OK, +) +def get_number_of_seeds( + db: Session = Depends(get_db), +) -> GetSeedsResponse: + """ + 사용자 및 추천 시드 데이터 개수 조회 + (게스트 유저는 미포함, 게스트와 연결된 시드 데이터는 포함됨) + """ + + total_users, total_recs = db.execute( + union_all( + select(func.count(User.id)).filter(User.email.startswith(SEED_USERNAME_PREFIX)), + select(func.count(Recommendation.id)).filter(Recommendation.task_id.startswith(SEED_TASK_ID_PREFIX)), + ) + ).scalars().all() + + return { + "total_users": total_users, + "total_recommendations": total_recs, + } From e87a92f8661fcc460f585339b229b3b517932b95 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Fri, 29 May 2026 03:35:05 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20prod/dev=20=ED=83=90=EC=A7=80?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20=EB=B9=8C=EB=93=9C=20=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker/build-push-action에 캐시 적용 - 환경변수 APP_ENV를 MODE로 변경 - prod/dev 2단계 스테이징 -> local 스테이징 추가 - 기존의 dev기 local임 - 변경된 dev는 이전의 dev와 100% 호환 --- .github/workflows/deploy.yml | 5 +- .gitignore | 3 ++ Dockerfile | 51 +++++++++++-------- app/config.py | 2 +- root/etc/s6-overlay/s6-rc.d/app-run/run | 6 ++- .../dependencies.d/file-delivery} | 0 root/etc/s6-overlay/s6-rc.d/deps-install/run | 40 +++++++++++---- .../dependencies.d/init-config | 0 root/etc/s6-overlay/s6-rc.d/file-delivery/run | 7 +++ .../s6-rc.d/{init-bin => file-delivery}/type | 0 root/etc/s6-overlay/s6-rc.d/file-delivery/up | 1 + root/etc/s6-overlay/s6-rc.d/init-bin/up | 1 - .../dependencies.d/file-delivery} | 0 root/etc/s6-overlay/s6-rc.d/init-chown/run | 4 +- .../register-bin/dependencies.d/file-delivery | 0 .../s6-rc.d/{init-bin => register-bin}/run | 0 root/etc/s6-overlay/s6-rc.d/register-bin/type | 1 + root/etc/s6-overlay/s6-rc.d/register-bin/up | 1 + .../s6-rc.d/user/contents.d/file-delivery | 0 .../s6-rc.d/user/contents.d/register-bin | 0 20 files changed, 86 insertions(+), 36 deletions(-) rename root/etc/s6-overlay/s6-rc.d/{init-bin/dependencies.d/init-config => deps-install/dependencies.d/file-delivery} (100%) rename root/etc/s6-overlay/s6-rc.d/{init-chown => file-delivery}/dependencies.d/init-config (100%) create mode 100755 root/etc/s6-overlay/s6-rc.d/file-delivery/run rename root/etc/s6-overlay/s6-rc.d/{init-bin => file-delivery}/type (100%) create mode 100755 root/etc/s6-overlay/s6-rc.d/file-delivery/up delete mode 100755 root/etc/s6-overlay/s6-rc.d/init-bin/up rename root/etc/s6-overlay/s6-rc.d/{user/contents.d/init-bin => init-chown/dependencies.d/file-delivery} (100%) create mode 100644 root/etc/s6-overlay/s6-rc.d/register-bin/dependencies.d/file-delivery rename root/etc/s6-overlay/s6-rc.d/{init-bin => register-bin}/run (100%) create mode 100644 root/etc/s6-overlay/s6-rc.d/register-bin/type create mode 100755 root/etc/s6-overlay/s6-rc.d/register-bin/up create mode 100644 root/etc/s6-overlay/s6-rc.d/user/contents.d/file-delivery create mode 100644 root/etc/s6-overlay/s6-rc.d/user/contents.d/register-bin diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dca69d3..b4ac581 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -70,7 +70,10 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - target: prod + target: ${{ steps.vars.outputs.LATEST == 'true' && 'prod' || 'dev' }} # pr에서는 테스트용이기 때문에 push하지 않음 (dry-run) push: ${{ github.event_name != 'pull_request' }} + + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index dd70918..8bccb82 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ __pycache__ # tests .coverage + +# lock +*.lock diff --git a/Dockerfile b/Dockerfile index d19d6ff..18cb0d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,37 +6,48 @@ ENV VIRTUAL_ENV="/venv" ENV PATH="$VIRTUAL_ENV/bin:$HOME/.local/bin:$PATH" ENV PUID=1000 ENV PGID=1000 -WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/ RUN uv python install 3.11 -COPY root/ / +WORKDIR /defaults -# 운영용: 공통 이미지 -FROM base AS prod-base -ENV APP_ENV=production -ENV UV_COMPILE_BYTECODE=1 -RUN uv venv $VIRTUAL_ENV -# 운영용: 빌드 -FROM prod-base AS prod-build +# 배포용: 공통 이미지 +FROM base AS deploy-base +RUN uv venv "$VIRTUAL_ENV" + +# 배포용: 빌드 +FROM deploy-base AS deploy-build COPY requirements.txt . -RUN uv pip install --no-cache -r requirements.txt +RUN uv pip install --link-mode=copy --no-cache -r requirements.txt -# 운영용: 메인 -FROM prod-base AS prod -COPY --from=prod-build $VIRTUAL_ENV $VIRTUAL_ENV -COPY data ./data +# 배포용: 메인 +FROM deploy-base AS deploy +COPY --from=deploy-build "$VIRTUAL_ENV" "$VIRTUAL_ENV" +COPY requirements.txt . +COPY root/ / +COPY data/url.txt ./data/url.txt COPY app ./app COPY scripts ./scripts -WORKDIR /app/scripts +WORKDIR /app -# 개발용 이미지 -FROM base AS dev -ENV APP_ENV=development -ENV PYTHONDONTWRITEBYTECODE=1 -WORKDIR /app/scripts +# 운영용 +FROM deploy AS prod +ENV MODE=production + +# 개발용: main 브랜치 push 시 자동 배포되는 이미지 +FROM deploy AS dev +ENV MODE=development + +# 로컬 개발용 이미지 +FROM base AS local +ENV MODE=local +COPY root/ / +VOLUME /venv +WORKDIR /app diff --git a/app/config.py b/app/config.py index 625a7df..72b6ad2 100644 --- a/app/config.py +++ b/app/config.py @@ -7,7 +7,7 @@ def parse_comma_separated(key: str, default_value: str): return items -MODE = os.getenv("APP_ENV") or "development" +MODE = os.getenv("MODE") or "development" """애플리케이션 실행 모드""" PROD = MODE == "production" diff --git a/root/etc/s6-overlay/s6-rc.d/app-run/run b/root/etc/s6-overlay/s6-rc.d/app-run/run index 8abc977..fcfe7dc 100755 --- a/root/etc/s6-overlay/s6-rc.d/app-run/run +++ b/root/etc/s6-overlay/s6-rc.d/app-run/run @@ -1,10 +1,12 @@ #!/usr/bin/with-contenv bash +source "/app/scripts/functions.sh" + cd /app -echo "**** Starting app ****" +SUCCESS "**** Starting app ****" -if [ "$NODE_ENV" != "production" ]; then +if [ "$MODE" != "production" ]; then exec s6-setuidgid abc fastapi dev app --host 0.0.0.0 else exec s6-setuidgid abc fastapi run app --host 0.0.0.0 diff --git a/root/etc/s6-overlay/s6-rc.d/init-bin/dependencies.d/init-config b/root/etc/s6-overlay/s6-rc.d/deps-install/dependencies.d/file-delivery similarity index 100% rename from root/etc/s6-overlay/s6-rc.d/init-bin/dependencies.d/init-config rename to root/etc/s6-overlay/s6-rc.d/deps-install/dependencies.d/file-delivery diff --git a/root/etc/s6-overlay/s6-rc.d/deps-install/run b/root/etc/s6-overlay/s6-rc.d/deps-install/run index 3d1a511..d9f7833 100755 --- a/root/etc/s6-overlay/s6-rc.d/deps-install/run +++ b/root/etc/s6-overlay/s6-rc.d/deps-install/run @@ -1,17 +1,37 @@ #!/usr/bin/with-contenv bash +source "/app/scripts/functions.sh" + cd /app -if [ "$APP_ENV" != "production" ]; then - echo "**** Installing dependencies... ****" +if [ "$MODE" == "production" ]; then + exit 0 +fi + - if [ ! -f "requirements.txt" ]; then - echo "Error: requirements.txt not found" >&2 - exit 1 - fi - if [ ! -f "$VIRTUAL_ENV/bin/activate" ]; then - uv venv "$VIRTUAL_ENV" - fi - uv pip install --no-cache -r requirements.txt +if [ -f ".requirements.lock" ] \ +&& [ -f "$VIRTUAL_ENV/.requirements.lock" ] \ +&& diff .requirements.lock "$VIRTUAL_ENV/.requirements.lock" > /dev/null \ +&& sha256sum -c .requirements.lock --quiet 2> /dev/null \ +; then + INFO "Skipping dependency installation, requirements.txt has not changed." + exit 0 fi + + + +SUCCESS "**** Installing dependencies... ****" + +if [ ! -f "requirements.txt" ]; then + ERROR "Error: requirements.txt not found" >&2 + exit 1 +fi + +if [ ! -f "$VIRTUAL_ENV/bin/activate" ]; then + uv venv "$VIRTUAL_ENV" +fi + +uv pip install --link-mode=copy --no-cache -r requirements.txt +sha256sum requirements.txt > .requirements.lock +cp .requirements.lock "$VIRTUAL_ENV/.requirements.lock" diff --git a/root/etc/s6-overlay/s6-rc.d/init-chown/dependencies.d/init-config b/root/etc/s6-overlay/s6-rc.d/file-delivery/dependencies.d/init-config similarity index 100% rename from root/etc/s6-overlay/s6-rc.d/init-chown/dependencies.d/init-config rename to root/etc/s6-overlay/s6-rc.d/file-delivery/dependencies.d/init-config diff --git a/root/etc/s6-overlay/s6-rc.d/file-delivery/run b/root/etc/s6-overlay/s6-rc.d/file-delivery/run new file mode 100755 index 0000000..7daad86 --- /dev/null +++ b/root/etc/s6-overlay/s6-rc.d/file-delivery/run @@ -0,0 +1,7 @@ +#!/usr/bin/with-contenv bash + +if [ "$MODE" == "local" ] || [ -d "/app/app" ]; then + exit 0 +fi + +mv /defaults/* /app diff --git a/root/etc/s6-overlay/s6-rc.d/init-bin/type b/root/etc/s6-overlay/s6-rc.d/file-delivery/type similarity index 100% rename from root/etc/s6-overlay/s6-rc.d/init-bin/type rename to root/etc/s6-overlay/s6-rc.d/file-delivery/type diff --git a/root/etc/s6-overlay/s6-rc.d/file-delivery/up b/root/etc/s6-overlay/s6-rc.d/file-delivery/up new file mode 100755 index 0000000..a0914dd --- /dev/null +++ b/root/etc/s6-overlay/s6-rc.d/file-delivery/up @@ -0,0 +1 @@ +/etc/s6-overlay/s6-rc.d/file-delivery/run diff --git a/root/etc/s6-overlay/s6-rc.d/init-bin/up b/root/etc/s6-overlay/s6-rc.d/init-bin/up deleted file mode 100755 index 3b0ba68..0000000 --- a/root/etc/s6-overlay/s6-rc.d/init-bin/up +++ /dev/null @@ -1 +0,0 @@ -/etc/s6-overlay/s6-rc.d/init-bin/run diff --git a/root/etc/s6-overlay/s6-rc.d/user/contents.d/init-bin b/root/etc/s6-overlay/s6-rc.d/init-chown/dependencies.d/file-delivery similarity index 100% rename from root/etc/s6-overlay/s6-rc.d/user/contents.d/init-bin rename to root/etc/s6-overlay/s6-rc.d/init-chown/dependencies.d/file-delivery diff --git a/root/etc/s6-overlay/s6-rc.d/init-chown/run b/root/etc/s6-overlay/s6-rc.d/init-chown/run index c1a665b..5f515f8 100755 --- a/root/etc/s6-overlay/s6-rc.d/init-chown/run +++ b/root/etc/s6-overlay/s6-rc.d/init-chown/run @@ -1,3 +1,5 @@ #!/usr/bin/with-contenv bash -lsiown abc:abc "$HOME"/.cache +if [ "$MODE" != "local" ]; then + chown abc:abc -R /app +fi diff --git a/root/etc/s6-overlay/s6-rc.d/register-bin/dependencies.d/file-delivery b/root/etc/s6-overlay/s6-rc.d/register-bin/dependencies.d/file-delivery new file mode 100644 index 0000000..e69de29 diff --git a/root/etc/s6-overlay/s6-rc.d/init-bin/run b/root/etc/s6-overlay/s6-rc.d/register-bin/run similarity index 100% rename from root/etc/s6-overlay/s6-rc.d/init-bin/run rename to root/etc/s6-overlay/s6-rc.d/register-bin/run diff --git a/root/etc/s6-overlay/s6-rc.d/register-bin/type b/root/etc/s6-overlay/s6-rc.d/register-bin/type new file mode 100644 index 0000000..bdd22a1 --- /dev/null +++ b/root/etc/s6-overlay/s6-rc.d/register-bin/type @@ -0,0 +1 @@ +oneshot diff --git a/root/etc/s6-overlay/s6-rc.d/register-bin/up b/root/etc/s6-overlay/s6-rc.d/register-bin/up new file mode 100755 index 0000000..1bbffe7 --- /dev/null +++ b/root/etc/s6-overlay/s6-rc.d/register-bin/up @@ -0,0 +1 @@ +/etc/s6-overlay/s6-rc.d/register-bin/run diff --git a/root/etc/s6-overlay/s6-rc.d/user/contents.d/file-delivery b/root/etc/s6-overlay/s6-rc.d/user/contents.d/file-delivery new file mode 100644 index 0000000..e69de29 diff --git a/root/etc/s6-overlay/s6-rc.d/user/contents.d/register-bin b/root/etc/s6-overlay/s6-rc.d/user/contents.d/register-bin new file mode 100644 index 0000000..e69de29 From b1778d6bf665a008bc83eb76aaf255d28c34c9b6 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Fri, 29 May 2026 14:11:07 +0900 Subject: [PATCH 4/8] =?UTF-8?q?reactor:=20manage=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20seeds=EB=A5=BC=20m?= =?UTF-8?q?anage=20=ED=95=98=EC=9C=84=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/main.py | 4 +-- app/manage/__init__.py | 1 + app/manage/router.py | 10 ++++++++ app/manage/schemas.py | 21 ++++++++++++++++ app/{ => manage}/seeds/drop.py | 6 ++--- app/{ => manage}/seeds/insert.py | 16 ++++++------ app/{ => manage}/seeds/router.py | 42 +++++++++----------------------- scripts/drop_seeds.py | 22 ++++++++++------- scripts/insert_seeds.py | 4 +-- 9 files changed, 71 insertions(+), 55 deletions(-) create mode 100644 app/manage/__init__.py create mode 100644 app/manage/router.py create mode 100644 app/manage/schemas.py rename app/{ => manage}/seeds/drop.py (71%) rename app/{ => manage}/seeds/insert.py (96%) rename app/{ => manage}/seeds/router.py (64%) diff --git a/app/main.py b/app/main.py index 1a422f7..088588c 100644 --- a/app/main.py +++ b/app/main.py @@ -27,7 +27,7 @@ recommendations, ) -from .seeds import router as seeds +from . import manage from . import demo @@ -135,7 +135,7 @@ def require_auth(router: APIRouter): # Scalar 문서는 OpenAPI 목록에서 숨김 if not PROD: app.include_router(scalar.router, prefix="/scalar", include_in_schema=False) - app.include_router(seeds.router) + app.include_router(manage.router) app.include_router(health.router) app.include_router(public.router) if not PROD: diff --git a/app/manage/__init__.py b/app/manage/__init__.py new file mode 100644 index 0000000..2378043 --- /dev/null +++ b/app/manage/__init__.py @@ -0,0 +1 @@ +from .router import router diff --git a/app/manage/router.py b/app/manage/router.py new file mode 100644 index 0000000..a2ec430 --- /dev/null +++ b/app/manage/router.py @@ -0,0 +1,10 @@ +"""관리용 라우터""" + +from fastapi import APIRouter + +from . import seeds + + +router = APIRouter(prefix="/manage", tags=["manage"]) + +router.include_router(seeds.router) diff --git a/app/manage/schemas.py b/app/manage/schemas.py new file mode 100644 index 0000000..b5e5ec8 --- /dev/null +++ b/app/manage/schemas.py @@ -0,0 +1,21 @@ +from typing import Literal +from pydantic import BaseModel, Field + + +class BackgroundSuccessResponse(BaseModel): + """백그라운드 실행 성공 응답""" + + requested: Literal[True] + + +class GenerateSeedsRequest(BaseModel): + """시드 데이터 생성 요청""" + + users: int = Field(0, description="생성할 사용자 수") + recommendations: int = Field(0, description="생성할 추천 수") + +class SeedsStatusResponse(BaseModel): + """시드 데이터 개수 조회 응답""" + + total_users: int + total_recommendations: int diff --git a/app/seeds/drop.py b/app/manage/seeds/drop.py similarity index 71% rename from app/seeds/drop.py rename to app/manage/seeds/drop.py index e2afca4..f50ed0b 100644 --- a/app/seeds/drop.py +++ b/app/manage/seeds/drop.py @@ -1,6 +1,6 @@ -from ..database import SessionLocal -from ..models import User, Recommendation -from ..config import SEED_TASK_ID_PREFIX, SEED_USERNAME_PREFIX +from ...database import SessionLocal +from ...models import User, Recommendation +from ...config import SEED_TASK_ID_PREFIX, SEED_USERNAME_PREFIX def run(): diff --git a/app/seeds/insert.py b/app/manage/seeds/insert.py similarity index 96% rename from app/seeds/insert.py rename to app/manage/seeds/insert.py index c9760e0..d3fd8f3 100644 --- a/app/seeds/insert.py +++ b/app/manage/seeds/insert.py @@ -2,13 +2,13 @@ from sqlalchemy.orm import Session from datetime import datetime, timedelta -from ..core.enums import INFRASTRUCTURE_TYPE_VALUES -from ..redis import redis -from ..database import SessionLocal -from ..demo import create_guest_user -from ..crud.service import get_regions -from ..utils import run_with_progress -from ..models import ( +from ...core.enums import INFRASTRUCTURE_TYPE_VALUES +from ...redis import redis +from ...database import SessionLocal +from ...demo import create_guest_user +from ...crud.service import get_regions +from ...utils import run_with_progress +from ...models import ( User, SearchLog, Recommendation, @@ -17,7 +17,7 @@ PropertyInfrastructure, ) -from ..config import ( +from ...config import ( SEED_TASK_ID_PREFIX, SEED_USERNAME_PREFIX, DEFAULT_INSERT_SEED_RECOMMENDATIONS, diff --git a/app/seeds/router.py b/app/manage/seeds/router.py similarity index 64% rename from app/seeds/router.py rename to app/manage/seeds/router.py index e02b81e..f308146 100644 --- a/app/seeds/router.py +++ b/app/manage/seeds/router.py @@ -1,38 +1,20 @@ -"""개발용""" +"""시드 데이터 관련 API 라우터""" -from typing import Literal -from pydantic import BaseModel, Field from fastapi import APIRouter, BackgroundTasks, Depends, status -from sqlalchemy import select, func, union_all +from sqlalchemy import union_all, select, func from sqlalchemy.orm import Session -from ..database import get_db -from ..models import User, Recommendation +from ...config import SEED_USERNAME_PREFIX, SEED_TASK_ID_PREFIX +from ...database import get_db +from ...models import User, Recommendation -from ..config import SEED_USERNAME_PREFIX, SEED_TASK_ID_PREFIX +from ..schemas import BackgroundSuccessResponse, GenerateSeedsRequest, SeedsStatusResponse from .insert import run as insert_seeds from .drop import run as drop_seeds -class GenerateSeedsRequest(BaseModel): - """시드 데이터 생성 요청""" - users: int = Field(0, description="생성할 사용자 수") - recommendations: int = Field(0, description="생성할 추천 수") - -class SeedsResponse(BaseModel): - """시드 데이터 생성/삭제 응답""" - - requested: Literal[True] - -class GetSeedsResponse(BaseModel): - """시드 데이터 개수 조회 응답""" - - total_users: int - total_recommendations: int - - -router = APIRouter(prefix="/seeds", tags=["seeds"]) +router = APIRouter(prefix="/seeds") @router.post( "", @@ -42,10 +24,9 @@ class GetSeedsResponse(BaseModel): def generate_seeds( body: GenerateSeedsRequest, background_tasks: BackgroundTasks, -) -> SeedsResponse: +) -> BackgroundSuccessResponse: """랜덤 시드 데이터 생성""" - # 백그라운드로 생성 및 즉시 응답 background_tasks.add_task( insert_seeds, num_recommendations=body.recommendations, @@ -64,13 +45,12 @@ def generate_seeds( ) def delete_seeds( background_tasks: BackgroundTasks, -) -> SeedsResponse: +) -> BackgroundSuccessResponse: """ 랜덤 시드 데이터 삭제. 단, property-infra 사이 점수 등 데이터는 삭제되지 않음. """ - # 백그라운드로 생성 및 즉시 응답 background_tasks.add_task( drop_seeds, ) @@ -84,9 +64,9 @@ def delete_seeds( summary="시드 데이터 개수 조회", status_code=status.HTTP_200_OK, ) -def get_number_of_seeds( +def check_seed_status( db: Session = Depends(get_db), -) -> GetSeedsResponse: +) -> SeedsStatusResponse: """ 사용자 및 추천 시드 데이터 개수 조회 (게스트 유저는 미포함, 게스트와 연결된 시드 데이터는 포함됨) diff --git a/scripts/drop_seeds.py b/scripts/drop_seeds.py index c76a928..ba59d4e 100755 --- a/scripts/drop_seeds.py +++ b/scripts/drop_seeds.py @@ -8,10 +8,21 @@ def usage(): - print("사용법: python drop-seeds.py [-y|--yes]") + print("사용법: python drop_seeds.py [-y|--yes]") print(" - 모든 시드 데이터를 삭제합니다.") +def main(): + try: + from app.manage import seeds + seeds.drop() + except Exception as e: + print("시드 데이터 삭제 중 오류가 발생했습니다.") + raise e + else: + print("모든 시드 데이터가 삭제되었습니다.") + + if __name__ == "__main__": if "-h" in sys.argv or "--help" in sys.argv: usage() @@ -26,11 +37,4 @@ def usage(): print("작업이 취소되었습니다.") sys.exit(0) - from app.seeds.drop import run as drop_seeds - try: - drop_seeds() - except Exception as e: - print("시드 데이터 삭제 중 오류가 발생했습니다.") - raise e - else: - print("모든 시드 데이터가 삭제되었습니다.") + main() diff --git a/scripts/insert_seeds.py b/scripts/insert_seeds.py index c763c7a..3737ee6 100755 --- a/scripts/insert_seeds.py +++ b/scripts/insert_seeds.py @@ -32,9 +32,9 @@ def usage(): usage() sys.exit(1) - from app.seeds.insert import run as insert_seeds + from app.manage import seeds try: - insert_seeds(*args) + seeds.insert(*args) except Exception as e: print("시드 데이터 삽입 중 오류가 발생했습니다.") raise e From e9deaad79fc20bc3ee1da545b578090a12ddd7d2 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Fri, 29 May 2026 14:47:29 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B6=80=20scripts?= =?UTF-8?q?=EB=A5=BC=20manage=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - insert-data, drop-to-seeds, download-data를 컨테이너로 실행 가능하도록 변경 - drop-to-seeds를 제외한 모든 파이썬 스크립트 - delete /seeds -> 백그라운드 실행이 아닌 즉시 실행 및 응답 --- Dockerfile | 6 + README.md | 9 +- app/config.py | 11 + app/manage/data/__init__.py | 6 + app/manage/data/download.py | 125 +++ app/manage/data/insert.py | 1167 ++++++++++++++++++++ app/manage/data/router.py | 97 ++ app/manage/router.py | 5 +- app/manage/schemas.py | 19 + app/manage/seeds/__init__.py | 4 + app/manage/seeds/router.py | 17 +- app/manage/tables/__init__.py | 4 + app/manage/tables/create.py | 9 + app/manage/tables/drop.py | 9 + app/manage/tables/router.py | 50 + app/parser/__init__.py | 2 + scripts/csv_parser.py => app/parser/csv.py | 0 scripts/df_parser.py => app/parser/df.py | 0 requirements-dev.txt | 2 - requirements.txt | 2 + scripts/download_data.py | 125 +-- scripts/drop_tables.py | 35 +- scripts/drop_to_seeds.py | 6 +- scripts/insert_data.py | 1161 +------------------ 24 files changed, 1558 insertions(+), 1313 deletions(-) create mode 100644 app/manage/data/__init__.py create mode 100644 app/manage/data/download.py create mode 100644 app/manage/data/insert.py create mode 100644 app/manage/data/router.py create mode 100644 app/manage/seeds/__init__.py create mode 100644 app/manage/tables/__init__.py create mode 100644 app/manage/tables/create.py create mode 100644 app/manage/tables/drop.py create mode 100644 app/manage/tables/router.py create mode 100644 app/parser/__init__.py rename scripts/csv_parser.py => app/parser/csv.py (100%) rename scripts/df_parser.py => app/parser/df.py (100%) mode change 100644 => 100755 scripts/drop_to_seeds.py mode change 100644 => 100755 scripts/insert_data.py diff --git a/Dockerfile b/Dockerfile index 18cb0d6..abf6943 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,9 @@ RUN uv venv "$VIRTUAL_ENV" # 배포용: 빌드 FROM deploy-base AS deploy-build +ENV CC=clang +ENV CXX=clang++ +RUN apk add --no-cache gdal-dev clang compiler-rt COPY requirements.txt . RUN uv pip install --link-mode=copy --no-cache -r requirements.txt @@ -48,6 +51,9 @@ ENV MODE=development # 로컬 개발용 이미지 FROM base AS local ENV MODE=local +ENV CC=clang +ENV CXX=clang++ +RUN apk add --no-cache gdal-dev clang compiler-rt COPY root/ / VOLUME /venv WORKDIR /app diff --git a/README.md b/README.md index 5d0c091..a89e256 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,7 @@ > [!NOTE] > `requirements-dev.txt` 파일 지정 시 -> [테스트](#테스트) 및 [insert-data](#insert-data) -> 작업에 사용되는 라이브러리까지 같이 설치됨 +> [테스트](#테스트) 시 사용되는 라이브러리까지 같이 설치됨 나열된 목록을 한 번에 설치 @@ -86,7 +85,8 @@ python3 scripts/download_data.py ```shell python3 scripts/insert_data.py -# docker exec -it insert-data # deprecated +# or +docker exec -it insert-data ``` ### insert seeds @@ -141,7 +141,8 @@ docker exec -it drop-tables [-y|--yes] ```shell python3 scripts/drop_to_seeds.py [num_of_recs] [num_of_users] -# docker exec -it drop-to-seeds # deprecated +# or +docker exec -it drop-to-seeds ``` ## 테스트 diff --git a/app/config.py b/app/config.py index 72b6ad2..1e643c9 100644 --- a/app/config.py +++ b/app/config.py @@ -1,4 +1,5 @@ import os +from pathlib import Path def parse_comma_separated(key: str, default_value: str): @@ -26,3 +27,13 @@ def parse_comma_separated(key: str, default_value: str): DEFAULT_INSERT_SEED_RECOMMENDATIONS = 30 DEFAULT_INSERT_SEED_USERS = 0 + + + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +DATA_BACKUP_DIR = DATA_DIR / ".backup" +DATA_DOWNLOADING_DIR = DATA_DIR / ".downloading" +DATA_DOWNLOADING_VERSION_FILE = DATA_DOWNLOADING_DIR / ".version" +DATA_VERSION_FILE = str(DATA_DIR / "version.txt") +DATA_VERSION_TEMP_FILE = str(DATA_DIR / ".version.tmp") +DATA_URL_FILE = str(DATA_DIR / "url.txt") diff --git a/app/manage/data/__init__.py b/app/manage/data/__init__.py new file mode 100644 index 0000000..ecbb40d --- /dev/null +++ b/app/manage/data/__init__.py @@ -0,0 +1,6 @@ +from .router import router + +from .insert import run as insert +from .download import run as download +from .download import get_current_version +from .download import get_downloading_version diff --git a/app/manage/data/download.py b/app/manage/data/download.py new file mode 100644 index 0000000..dd70770 --- /dev/null +++ b/app/manage/data/download.py @@ -0,0 +1,125 @@ +import os +import re +import shutil +import gdown +from pathlib import Path + +from ...utils import read_file, write_file +from ...config import ( + DATA_DIR, + DATA_BACKUP_DIR, + DATA_DOWNLOADING_DIR, + DATA_DOWNLOADING_VERSION_FILE, + DATA_VERSION_FILE, + DATA_VERSION_TEMP_FILE, + DATA_URL_FILE, +) + + + +def get_current_version(): + return read_file(DATA_VERSION_FILE, strip=True) + + +def get_downloading_version(): + return read_file(DATA_DOWNLOADING_VERSION_FILE, strip=True) + +def read_url_file(filepath: str) -> dict[str, str]: + urls = {} + content = read_file(filepath) + if not content: return urls + + for line in content.splitlines(): + line = re.sub(r"#.*", "", line).strip() + if not line: continue + + idx = line.find(":") + if idx == -1: continue + + key = line[:idx].strip() + value = line[idx+1:].strip() + if value: urls[key] = value + return urls + + +def download_file(*, silent: bool = False, **kwargs): + return gdown.download( + **kwargs, + quiet=silent, + resume=True, + ) + +def download_folder(*, silent: bool = False, **kwargs): + return gdown.download_folder( + **kwargs, + quiet=silent, + resume=True, + ) + + +def move_folder(source: str, dest: str, *, ignore: list[str] = [], silent: bool = False): + src = Path(source) + dst = Path(dest) + dst.mkdir(parents=True, exist_ok=True) + + for path in src.iterdir(): + if path == dst or path.name in ignore: + continue + try: + target_path = dst / path.name + shutil.move(str(path), str(target_path)) + except Exception as e: + if not silent: print(f"Move Failed: {e}") + + if not list(src.iterdir()): + os.rmdir(str(src)) + + + +def run(*, silent: bool = False) -> bool: + """ + 데이터 디렉터리에 버전 정보를 확인하여 최신 버전이 아니면 다운로드하여 교체합니다. + + Returns: + bool: 데이터가 업데이트되었는지 여부 + """ + + P = not silent + + urls = read_url_file(DATA_URL_FILE) + + if P: print("현재 버전 확인 중...") + curr_version = read_file(DATA_VERSION_FILE, strip=True) + if P: print(f"현재 버전: {curr_version or 'No Version'}") + + if P: print("최신 버전 확인 중...") + download_file(url=urls["version"], output=DATA_VERSION_TEMP_FILE, silent=True) + new_version: str = read_file(DATA_VERSION_TEMP_FILE, strip=True) + if curr_version: + if curr_version == new_version: + if P: print(f"현재 최신 버전입니다.") + return False + if P: print(f"최신 버전({new_version})을 다운로드합니다.") + output_folder = str(DATA_DOWNLOADING_DIR / new_version) + + try: + write_file(str(DATA_DOWNLOADING_VERSION_FILE), new_version) + download_folder(url=urls["google drive"], output=output_folder, silent=silent) + finally: + os.remove(DATA_DOWNLOADING_VERSION_FILE) + + if curr_version: + move_folder( + str(DATA_DIR), + str(DATA_BACKUP_DIR / curr_version), + ignore=[ + Path(DATA_VERSION_TEMP_FILE).name, + DATA_BACKUP_DIR.name, + DATA_DOWNLOADING_DIR.name, + ], + silent=silent, + ) + move_folder(output_folder, str(DATA_DIR)) + os.remove(DATA_VERSION_TEMP_FILE) + + return True diff --git a/app/manage/data/insert.py b/app/manage/data/insert.py new file mode 100644 index 0000000..a453bbc --- /dev/null +++ b/app/manage/data/insert.py @@ -0,0 +1,1167 @@ +import re +import numpy as np +import pandas as pd +import geopandas as gpd +from typing import Generator +from pathlib import Path +from typing import Any +from geopandas import GeoDataFrame +from collections.abc import Iterable +from sqlalchemy.orm import Session +from sqlalchemy.sql import func +from pyproj import Transformer + +from ...config import DATA_DIR +from ...database import SessionLocal, engine +from ...models import Base, Region, Property, Infrastructure +from ...utils import parse_int, parse_float, run_with_progress, get_files + +from ...parser.csv import read_csv_file +from ...parser.df import read_pandas, read_shp_file + + +PROGRESS_PRINT = 100 +DB_BATCH_SIZE = 1000 + + +# global variable +DF: dict[str, pd.DataFrame] = {} +GDF: dict[str, GeoDataFrame] = {} +ADDRESS_MAP = {} +BJD_CODE_MAP = {} +REGION_NAME_MAP = {} +REGION_UNIQUE_MAP = {} +PROPERTY_UNIQUE_MAP = {} +INFRA_UNIQUE_MAP = {} + + +# *_FIELD_NAME_MAP +# - key: filename.startswith(key) + +PRELOAD_FIELD_NAME_MAP: dict[str, dict[str, Any]] = { + "rnaddrkor_": { + "priority": -10, + "file_extension": ".txt", + "parser": None, + "category": "rnaddrkor", + }, + "jibun_rnaddrkor_": { + "priority": -10, + "file_extension": ".txt", + "parser": None, + "category": "jibun", + }, + "AL_D164_": { + "priority": -10, + "file_extension": ".shp", + "category": "AL_D164", + }, +} + +REGION_FIELD_NAME_MAP: dict[str, dict[str, Any]] = { + "법정동코드 전체": { + "file_extension": ".txt", + "delimiter": "\t", + "category": "동네", + "type": "main", + "id": "법정동코드", + "이름": "법정동명", + "상태": "폐지여부", + }, + "서울시 학원 교습소정보": { + "priority": 10, + "category": "동네", + "type": "academy", + "id": "학원지정번호", + "이름": "학원명", + "유형": "학원/교습소", + "도로명주소": "도로명주소", + "도로명상세주소": "도로명상세주소", + "운영상태": "등록상태명", + }, +} + +PROPERTY_FIELD_NAME_MAP: dict[str, dict[str, Any]] = { + "AL_D164_": { + "file_extension": ".shp", + "category": "건물", + "type": "main", + }, + "서울시 공동주택 아파트 정보": { + "priority": 10, + "category": "건물", + "type": "apartment", + "id": "k-아파트코드", + "이름": "k-아파트명", + "위도": "좌표Y", + "경도": "좌표X", + "도로명주소": "kapt도로명주소", + "시도": "주소(시도)k-apt주소split", + "시군구": "주소(시군구)", + "읍면동": "주소(읍면동)", + "나머지주소": "나머지주소", + "도로명": "주소(도로명)", + "도로상세주소": "주소(도로상세주소)", + "동수": "k-전체동수", + "세대수": "k-전체세대수", + "주차대수": "주차대수", + }, +} + +INFRA_FIELD_NAME_MAP: dict[str, dict[str, Any]] = { + "전국초중등학교위치표준데이터": { + "category": "학교", + "id": "학교ID", + "이름": "학교명", + "위도": "위도", + "경도": "경도", + "지번주소": "소재지지번주소", + "도로명주소": "소재지도로명주소", + "유형": "학교급구분", + "운영상태": "운영상태", + }, + "서울시 역사마스터 정보": { + "category": "역사", + "type": "main", + "id": lambda row: f"01_{row['역사명']}", + "이름": lambda row: row['역사명'].rstrip('역'), + "위도": "위도", + "경도": "경도", + }, + "국토교통부_도시철도 전체노선": { + "priority": 10, + "category": "역사", + "type": "lines", + "id": ["권역", "역명"], + "이름": lambda row: row['역명'].rstrip('역'), + "노선": "노선명", + }, + "서울시 응급실 위치 정보": { + "category": "병원", + "id": "기관ID", + "이름": "기관명", + "위도": "병원위도", + "경도": "병원경도", + "응급의료기관코드명": "응급의료기관코드명", + }, + "생활_대규모점포_서울특별시": { + "category": "마트", + "id": "관리번호", + "이름": "사업장명", + "tm_x": "좌표정보(X)", + "tm_y": "좌표정보(Y)", + "영업상태명": "영업상태명", + "업태구분명": "업태구분명", + "점포구분명": "점포구분명", + }, + "전국도시공원정보표준데이터": { + "category": "공원", + "id": "관리번호", + "이름": "공원명", + "위도": "위도", + "경도": "경도", + "지번주소": "소재지지번주소", + "도로명주소": "소재지도로명주소", + "면적": "공원면적", + }, +} + + +transformer = Transformer.from_crs("epsg:5179", "epsg:4326", always_xy=True) + + +def string_rcut_and_rstrip(_value: str, suffix: str) -> tuple[str, bool]: + value = _value.strip() + if value.endswith(suffix): + value = value[:-len(suffix)].strip() + return value, _value != value + +def all_join_or_none(*args, sep=" "): + if all(args): + return sep.join(str(arg) for arg in args) + return None + + +def get_field_value(row: dict[str, Any], value: Any) -> str | None: + if isinstance(value, str): + return row.get(value, "").strip() or None + + if isinstance(value, Iterable) and all(isinstance(v, str) for v in value): + return "_".join(str(row.get(key, "").strip()) for key in value) or None + + if callable(value): + return value(row).strip() or None + + +def get_coords_from_row(row: dict[str, Any], field_mapping: dict[str, Any]) -> tuple[float, float] | None: + """ + 주어진 행에서 위도와 경도를 추출하여 float 형태로 반환합니다. + + Args: + row (dict[str, Any]): CSV 파일의 한 행을 나타내는 딕셔너리 + field_mapping (dict[str, Any]): 위도와 경도 필드 이름이 매핑된 딕셔너리 + + Returns: + tuple[float, float] | None: (위도, 경도) 튜플 또는 좌표 정보를 찾을 수 없는 경우 None + """ + lat = None + lng = None + try: + lat = parse_float(row[field_mapping["위도"]]) + lng = parse_float(row[field_mapping["경도"]]) + if lat is None or lng is None: raise + except: + try: + tm_x = parse_float(row[field_mapping["tm_x"]]) + tm_y = parse_float(row[field_mapping["tm_y"]]) + lng, lat = transformer.transform(tm_x, tm_y) + except: + pass + if lat is None or lng is None: + return None + return lat, lng + + +def normalize_sido(sido: str) -> str: + sido = re.sub(r"^서울(시|특별시)?($|\s+)", "서울특별시 ", sido).strip() + return sido + + +def get_next_region_name(region_name: str) -> Generator[str, None, None]: + maybe_gu = region_name.split() + maybe_gu = maybe_gu[1] if len(maybe_gu) > 1 else None + + region_name = region_name.strip() + yield region_name + + check1 = re.sub(r"[\d -]+$", "", region_name) + yield check1 + + check2 = re.sub(r"[\d·]+", "", check1) + yield check2 + + check3 = "상일동" if maybe_gu == "강동구" and check2 == "상동" else None + if check3: yield check3 + + +def nomalize_apartment_address(name: str, address: str) -> str: + strip = string_rcut_and_rstrip + name, _ = strip(name, "아파트") + address, _ = strip(address, "관리소") + address, _ = strip(address, "관리사무소") + address, _ = strip(address, "아파트") + address, _ = strip(address, "번지") + address, _ = strip(address, name) + return address + + +def clean_series_field(series, fill_val=""): + return series.fillna(fill_val).astype(str).str.strip() + +def empty_series_to_nan(series): + return series.astype(str).str.strip().replace("", np.nan) + + +def parse_address_data(_df: pd.DataFrame, *, silent: bool = False) -> pd.DataFrame: + P = not silent + if P: + print() + print(f" - ✅ 원본 데이터 로드 완료 (총 {len(_df)}개 행)") + print(f" - ⏳ 1. 데이터 제거 및 정형화 작업 중...") + + # 필수 데이터 결측치 제거 및 정형화 + + df = _df.dropna(subset=["법정동코드"]) + + tmp_df = df[[ + "도로명주소관리번호", + "법정동코드", + "시도명", + "시군구명", + "법정읍면동명", + "법정리명", + "산여부", + "지번본번", + "지번부번", + "도로명코드", + "도로명", + "건물본번", + "건물부번", + ]].copy().apply(clean_series_field) + + tmp_df["법정읍면동명"] = tmp_df["법정읍면동명"].fillna("") + tmp_df["법정리명"] = tmp_df["법정리명"].fillna("") + tmp_df["산여부"] = tmp_df["산여부"].fillna("0") + tmp_df["지번본번"] = tmp_df["지번본번"].fillna("0") + tmp_df["지번부번"] = tmp_df["지번부번"].fillna("0") + tmp_df["지번본번"] = tmp_df["지번본번"].str.zfill(4) + tmp_df["지번부번"] = tmp_df["지번부번"].str.zfill(4) + + # 산여부 0과 1을 각각 1(일반 대지), 2(산)로 변환 + tmp_df["산여부"] = np.where(tmp_df["산여부"] == "0", "1", "2") + + # 시군구코드, 읍면동코드, 도로명번호 추출 + tmp_df["시군구코드"] = tmp_df["도로명코드"].str[:5] + tmp_df["읍면동코드"] = tmp_df["도로명주소관리번호"].str[5:8] + tmp_df["도로명번호"] = tmp_df["도로명코드"].str[5:] + + if P: print(f" - ⏳ 2. pnu_code 컬럼 결합 연산 및 지번/도로명 주소 매핑 중...") + tmp_df["pnu_code"] = tmp_df["법정동코드"] + tmp_df["산여부"] + tmp_df["지번본번"] + tmp_df["지번부번"] + + tmp_df["지번주소"] = ( + tmp_df["시도명"] + " " + + tmp_df["시군구명"] + " " + + tmp_df["법정읍면동명"] + " " + + tmp_df["법정리명"] + " " + + ( + tmp_df["지번본번"].str.lstrip("0") + + "-" + + tmp_df["지번부번"].str.lstrip("0") + ).str.strip(" -") + ).str.replace(r"\s+", " ", regex=True).str.strip() + + tmp_df["도로명주소"] = ( + tmp_df["시도명"] + " " + + tmp_df["시군구명"] + " " + + tmp_df["도로명"] + " " + + ( + tmp_df["건물본번"].str.lstrip("0") + + "-" + + tmp_df["건물부번"].str.lstrip("0") + ).str.strip(" -") + ) + + if P: + print(f" - ✅ 최종 데이터 개수: {len(tmp_df)}개") + print(f" - ✅ 제거된 데이터 개수: {len(df) - len(tmp_df)}") + + res = df.copy() + res["pnu_code"] = tmp_df["pnu_code"] + res["시군구코드"] = tmp_df["시군구코드"] + res["읍면동코드"] = tmp_df["읍면동코드"] + res["도로명번호"] = tmp_df["도로명번호"] + res["도로명주소"] = tmp_df["도로명주소"] + res["지번주소"] = tmp_df["지번주소"] + return res + +def parse_rnaddrkor(filepath: str, *_, silent: bool = False): + P = not silent + + rdf = read_pandas( + filepath, + names=[ + "도로명주소관리번호", + "법정동코드", + "시도명", + "시군구명", + "법정읍면동명", + "법정리명", + "산여부", + "지번본번", + "지번부번", + "도로명코드", + "도로명", + "지하여부", + "건물본번", + "건물부번", + "행정동코드", + "행정동명", + "기초구역번호", + "이전도로명주소", + "효력발생일", + "공동주택구분", + "이동사유코드", + "건축물대장건물명", + "시군구용건물명", + "비고", + ], + ) + + rdf = parse_address_data(rdf, silent=silent) + if P: print(f" - ⏳ 주소 변환을 위한 사전 매핑 중...") + ADDRESS_MAP["rncode_to_rn"] = dict(zip(rdf["도로명코드"], rdf["도로명"])) + DF["rnaddrkor"] = rdf + +def parse_jibun_rnaddrkor(filepath: str, *_, silent: bool = False): + jdf = read_pandas( + filepath, + names=[ + "도로명주소관리번호", + "법정동코드", + "시도명", + "시군구명", + "법정읍면동명", + "법정리명", + "산여부", + "지번본번", + "지번부번", + "도로명코드", + "지하여부", + "건물본번", + "건물부번", + "이동사유코드", + ], + ) + + jdf["도로명"] = jdf["도로명코드"].map(ADDRESS_MAP["rncode_to_rn"]) + jdf = parse_address_data(jdf, silent=silent) + DF["jibun"] = jdf + +def parse_AL_D164_shp(filepath: str, *_, silent: bool = False): + P = not silent + + gdf = read_shp_file( + filepath, + columns=[ + "GIS건물통합식별번호", + "고유번호", + "법정동코드", + "법정동명", + "특수지구분코드", + "특수지구분명", + "지번", + "건물식별번호", + "집합건물구분코드", + "집합건물구분", + "대장종류코드", + "대장종류", + "건물명", + "상위건물식별번호", + "도로명주소코드", + "도로명주소읍면동리코드", + "도로명주소지하코드", + "도로명주소본번", + "도로명주소부번", + "건물동명", + "건물주부구분코드", + "건물주부구분", + "건물대지면적(㎡)", + "건물건축면적(㎡)", + "건물연면적(㎡)", + "용적율(%)", + "건폐율(%)", + "건축물구조코드", + "건축물구조명", + "건축물용도코드", + "건축물용도명", + "건물높이", + "지상층수", + "지하층수", + "허가일자", + "사용승인일자", + "총주차수", + "총주차장면적(㎡)", + "데이터기준일자", + "시군구코드", + "geometry", + ], + ) + + jdf = DF["jibun"] + rdf = DF["rnaddrkor"] + if P: + print() + print(f" - ✅ 원본 데이터 로드 완료 (총 {len(gdf)}개 행)") + + # ========================================== + # 1. 사람이 살 수 있는 주거용 건물 추출 + # ========================================== + if P: print(" - ⏳ 1. 주거용 건물(단독/공동주택 등) 필터링 중...") + + gdf["건축물용도코드"] = clean_series_field(gdf["건축물용도코드"]) + residential_mask = ( + gdf["건축물용도코드"].str.startswith("01") | + gdf["건축물용도코드"].str.startswith("02") | + gdf["건축물용도명"].str.contains("주택|아파트|빌라|오피스텔", na=False) + ) + merged_df = gdf[residential_mask].copy() + + # ========================================================== + # 2. 주소 매핑 + # ========================================================== + if P: print(f" - ⏳ 2. 주소 매핑용 룩업 사전 빌드 중...") + + # 0) 주소 매핑용 사전 빌드: 관리번호와 PNU 기준으로 각각 도로명주소/지번주소 매핑할 수 있는 사전 조립 + jdf_by_mgmt = jdf.drop_duplicates(subset=["도로명주소관리번호"]) + rdf_by_mgmt = rdf.drop_duplicates(subset=["도로명주소관리번호"]) + + jdf_by_pnu = jdf.drop_duplicates(subset=["pnu_code"]) + rdf_by_pnu = rdf.drop_duplicates(subset=["pnu_code"]) + + # 1) rnnum_to_rnaddr : 관리번호 -> 도로명주소 및 역매핑 + ADDRESS_MAP.setdefault("rnnum_to_rnaddr", {}) + ADDRESS_MAP.setdefault("rnaddr_to_rnnum", {}) + for zips in [ + zip(rdf_by_mgmt["도로명주소"], rdf_by_mgmt["도로명주소관리번호"]), + zip(jdf_by_mgmt["도로명주소"], jdf_by_mgmt["도로명주소관리번호"]), + ]: + for addr, num in zips: + ADDRESS_MAP["rnnum_to_rnaddr"].setdefault(num, addr) + ADDRESS_MAP["rnaddr_to_rnnum"].setdefault(addr.replace(" ", ""), []).append(num) + + # 2) rnnum_to_jibun : 관리번호 -> 지번주소 및 역매핑 + ADDRESS_MAP.setdefault("rnnum_to_jibun", {}) + ADDRESS_MAP.setdefault("jibun_to_rnnum", {}) + for zips in [ + zip(jdf_by_pnu["지번주소"], jdf_by_pnu["도로명주소관리번호"]), + zip(rdf_by_pnu["지번주소"], rdf_by_pnu["도로명주소관리번호"]), + ]: + for addr, num in zips: + ADDRESS_MAP["rnnum_to_jibun"].setdefault(num, addr) + ADDRESS_MAP["jibun_to_rnnum"].setdefault(addr.replace(" ", ""), []).append(num) + + # 3) pnu_to_rnaddr : PNU -> 도로명주소 및 역매핑 + ADDRESS_MAP.setdefault("pnu_to_rnaddr", {}) + ADDRESS_MAP.setdefault("rnaddr_to_pnu", {}) + for zips in [ + zip(rdf_by_mgmt["도로명주소"], rdf_by_mgmt["pnu_code"]), + zip(jdf_by_mgmt["도로명주소"], jdf_by_mgmt["pnu_code"]), + ]: + for addr, pnu in zips: + ADDRESS_MAP["pnu_to_rnaddr"].setdefault(pnu, addr) + ADDRESS_MAP["rnaddr_to_pnu"].setdefault(addr.replace(" ", ""), []).append(pnu) + + # 4) pnu_to_jibun : PNU -> 지번주소 및 역매핑 + ADDRESS_MAP.setdefault("pnu_to_jibun", {}) + ADDRESS_MAP.setdefault("jibun_to_pnu", {}) + for zips in [ + zip(jdf_by_pnu["지번주소"], jdf_by_pnu["pnu_code"]), + zip(rdf_by_pnu["지번주소"], rdf_by_pnu["pnu_code"]), + ]: + for addr, pnu in zips: + ADDRESS_MAP["pnu_to_jibun"].setdefault(pnu, addr) + ADDRESS_MAP["jibun_to_pnu"].setdefault(addr.replace(" ", ""), []).append(pnu) + + if P: print("✨ [SUCCESS] 데이터 우선순위 역전 사전(ADDRESS_MAP) 빌드 완료!") + + # ========================================== + # 3. 도로명주소관리번호 추출 및 주소 매핑용 컬럼 조립 + # ========================================== + if P: print(" - ⏳ 3. 도로명주소관리번호 추출 및 주소 매핑용 컬럼 조립 중...") + + tmp_df = merged_df[["고유번호", "도로명주소코드", "도로명주소읍면동리코드", "도로명주소지하코드", "도로명주소본번", "도로명주소부번"]].copy() + tmp_df = tmp_df.dropna(subset=["도로명주소코드", "도로명주소읍면동리코드"], how="all") + + tmp_df["도로명주소코드"] = clean_series_field(tmp_df["도로명주소코드"]) + tmp_df["도로명주소읍면동리코드"] = clean_series_field(tmp_df["도로명주소읍면동리코드"]) + tmp_df["도로명주소지하코드"] = clean_series_field(tmp_df["도로명주소지하코드"], "0") + tmp_df["도로명주소본번"] = clean_series_field(tmp_df["도로명주소본번"], "0") + tmp_df["도로명주소부번"] = clean_series_field(tmp_df["도로명주소부번"], "0") + + tmp_df["시군구코드"] = tmp_df["도로명주소코드"].str[:5] + tmp_df["읍면동코드"] = tmp_df["도로명주소읍면동리코드"].str[:3] + tmp_df["읍면동일련번호"] = tmp_df["도로명주소읍면동리코드"].str[3:] + tmp_df["도로명번호"] = tmp_df["도로명주소코드"].str[5:] + tmp_df["도로명주소본번"] = tmp_df["도로명주소본번"].str.zfill(5) + tmp_df["도로명주소부번"] = tmp_df["도로명주소부번"].str.zfill(5) + + tmp_df["도로명주소관리번호"] = tmp_df["시군구코드"] + tmp_df["읍면동코드"] + tmp_df["도로명번호"] + tmp_df["도로명주소지하코드"] + tmp_df["도로명주소본번"] + tmp_df["도로명주소부번"] + + # Left Join + tmp_subset = tmp_df[["시군구코드", "읍면동코드", "읍면동일련번호", "도로명번호", "도로명주소관리번호"]] + merged_df = merged_df.merge(tmp_subset, left_index=True, right_index=True, how="left") + + # ========================================== + # 4. 국토부 GDF에 대장 주소 1차 수혈 (map 연산) + # ========================================== + if P: print(" - ⏳ 4. 국토부 GDF에 대장 주소 매핑 중...") + merged_df["도로명주소"] = merged_df["도로명주소관리번호"].map(ADDRESS_MAP["rnnum_to_rnaddr"]).fillna(merged_df["고유번호"].map(ADDRESS_MAP["pnu_to_rnaddr"])) + merged_df["지번주소"] = merged_df["고유번호"].map(ADDRESS_MAP["pnu_to_jibun"]).fillna(merged_df["도로명주소관리번호"].map(ADDRESS_MAP["rnnum_to_jibun"])) + + # ========================================== + # 5. 대장 미매칭 구역 대상 GDF 내장 정보 결합 + # ========================================== + if P: print(" - ⏳ 5. 대장 미매칭 유령 필지 대상 국토부 내장 정보 결합 및 예외 처리...") + + # 1) 지번주소 Fallback: 대장 주소가 끝까지 없으면 [법정동명 + 지번] 조합으로 강제 메꿈 + fallback_jibun = (merged_df["법정동명"] + " " + merged_df["지번"]).str.strip() + merged_df["지번주소"] = np.where( + merged_df["지번주소"].isna() | (merged_df["지번주소"] == "") | (merged_df["지번주소"] == "nan"), + fallback_jibun, + merged_df["지번주소"] + ) + + # 2) 도로명주소 Fallback: 대장 주소가 없으면 차선책으로 내장 건물명이라도 보존 (없으면 None) + merged_df["도로명주소"] = np.where( + merged_df["도로명주소"].isna() | (merged_df["도로명주소"] == "") | (merged_df["도로명주소"] == "nan"), + np.where(merged_df["건물명"] != "", merged_df["건물명"], None), + merged_df["도로명주소"] + ) + + # ========================================== + # 6. 주소 분리 조립 및 필수/Nullable 데이터 최종 추출 + # ========================================== + if P: print(" - ⏳ 6. 주소 분리 조립 및 필수/Nullable 데이터 최종 추출 중...") + + # 추후 region 관계설정 및 좌표를 point로 변환해야 함 + renames = { + "고유번호": "source_id", + "건축물용도명": "type", + "건물명": "name", + "지번주소": "land_lot_address", + "도로명주소": "road_name_address", + "법정동코드": "bjd_code", + } + res_df = merged_df[list(renames.keys())].rename(columns=renames) + + centroid_4326 = gpd.GeoSeries(merged_df.geometry.centroid, crs=merged_df.crs).to_crs(epsg=4326) + res_df["lon"] = centroid_4326.x + res_df["lat"] = centroid_4326.y + + # ==================================================== + # 7.: 19자리 고유번호(source_id) 기준 단지화 압축 (중복 제거) + # ==================================================== + if P: print(" - ⏳ 같은 아파트 단지(동별 데이터)를 하나의 대표 단지 데이터로 압축 중...") + agg_rules = { + "lon": "mean", + "lat": "mean", + "land_lot_address": "first", + "road_name_address": "first", # 행안부 마스터 기준으로 완벽히 통일된 주소 채택 + "name": "first", + "type": "first", + "bjd_code": "first" + } + + res_df = res_df.groupby("source_id", as_index=False).agg(agg_rules) + res_df = res_df.replace({np.nan: None}) + + GDF["AL_D164"] = res_df + + +def parse_region(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False): + name = get_field_value(row, field_mapping["이름"]) + source_id = get_field_value(row, field_mapping["id"]) + + if field_mapping["type"] == "main": + if not name.startswith("서울"): + return + + status = get_field_value(row, field_mapping["상태"]) + if status != "존재": + return + + data = { + "source_id": source_id, + "name": name, + "depth": name.count(" "), + } + REGION_NAME_MAP[name] = data + BJD_CODE_MAP[source_id] = name + BJD_CODE_MAP[name] = source_id + return data + elif field_mapping["type"] == "academy": + status = get_field_value(row, field_mapping["운영상태"]) + if status != "개원": + return + + try: + academy_type = get_field_value(row, field_mapping["유형"]) + if academy_type == "교습소": + return + + addr = get_field_value(row, field_mapping["도로명주소"]) + addr = normalize_sido(addr) + si, gu = addr.split()[:2] + + addr_detail = get_field_value(row, field_mapping["도로명상세주소"]) + start_idx = addr_detail.find("(") + if start_idx == -1: return + + details = [ + d.rstrip("층").strip() + for a in addr_detail[start_idx:].split("(") + for b in a.split(")") + for c in b.replace(".", ",").split(",") + for d in c.split(" ") + if d.strip() + ] + context.setdefault("academy", []).append((si, gu, details)) + except: + pass + +def post_parse_region(context: dict[str, Any], *, silent: bool = False): + academy = context.get("academy", []) + + for si, gu, details in academy: + dong = None + for item in details: + checks = get_next_region_name(item) + for curr in checks: + if not curr: continue + full_name = f"{si} {gu} {curr}" + if full_name in REGION_NAME_MAP: + dong = curr + break + if not dong: continue + for key in [si, f"{si} {gu}", f"{si} {gu} {dong}"]: + REGION_NAME_MAP.get(key, {}).setdefault("academy_count", 0) + REGION_NAME_MAP[key]["academy_count"] += 1 + + +def parse_apartment(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False): + bjd_codes = context.setdefault("bjd_codes", {}) + if field_mapping["type"] == "main": + gdf = GDF["AL_D164"] + records = gdf.to_dict(orient="records") + results = context.setdefault("results", {}) + for record in records: + bjd_codes[record["source_id"]] = record["bjd_code"] + results[record["source_id"]] = { + "source_id": record["source_id"], + "type": record["type"], + "name": record["name"], + "land_lot_address": record["land_lot_address"], + "road_name_address": record["road_name_address"], + "coordinates": (record["lat"], record["lon"]), + } + elif field_mapping["type"] == "apartment": + name = get_field_value(row, field_mapping["이름"]) + apt_id = get_field_value(row, field_mapping["id"]) + + rn_address = get_field_value(row, field_mapping["도로명주소"]) + sido = normalize_sido(get_field_value(row, field_mapping["시도"]) or "") + sigungu = get_field_value(row, field_mapping["시군구"]) + dong = get_field_value(row, field_mapping["읍면동"]) + jibun_suffix = get_field_value(row, field_mapping["나머지주소"]) + road_name = get_field_value(row, field_mapping["도로명"]) + road_suffix = get_field_value(row, field_mapping["도로상세주소"]) + dong_count = parse_int(get_field_value(row, field_mapping["동수"])) + household_count = parse_int(get_field_value(row, field_mapping["세대수"])) + parking_count = parse_float(get_field_value(row, field_mapping["주차대수"])) + parking_per_household = None if parking_count is None or household_count is None else parking_count / household_count if household_count else 0 + + ll_address = nomalize_apartment_address(name, all_join_or_none(sido, sigungu, dong, jibun_suffix) or "") or None + rn_address = nomalize_apartment_address(name, rn_address or all_join_or_none(sido, sigungu, road_name, road_suffix) or "") or None + + def extract_rnnum_from_kapt(): + rn_key = rn_address.replace(" ", "") if rn_address else None + if rn_key in ADDRESS_MAP["rnaddr_to_rnnum"]: + return ADDRESS_MAP["rnaddr_to_rnnum"][rn_key][0] + ll_key = ll_address.replace(" ", "") if ll_address else None + if ll_key in ADDRESS_MAP["jibun_to_rnnum"]: + return ADDRESS_MAP["jibun_to_rnnum"][ll_key][0] + return None # 양쪽 다 실패 시 안전하게 Null 리턴 + source_id = extract_rnnum_from_kapt() + + data = { + "source_id": source_id or apt_id, + "name": name, + "land_lot_address": ll_address, + "road_name_address": rn_address, + "dong_count": dong_count, + "household_count": household_count, + "parking_count": parking_count, + "parking_per_household": parking_per_household, + } + + if not source_id: + coords = get_coords_from_row(row, field_mapping) + if not coords: + return + + checks = get_next_region_name(dong) + for curr in checks: + if not curr: continue + full_name = f"{sido} {sigungu} {curr}" + if full_name in BJD_CODE_MAP: + dong = curr + break + + # BJD_CODE_MAP[full_name]: 항상 존재해야 함 + bjd_codes[apt_id] = BJD_CODE_MAP[full_name] + return { + **data, + "coordinates": coords, + } + else: + if ll_address: context.setdefault("extra_data", {})[ll_address.replace(" ", "")] = data + if rn_address: context.setdefault("extra_data", {})[rn_address.replace(" ", "")] = data + + +def parse_school(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False): + name = get_field_value(row, field_mapping["이름"]) + source_id = get_field_value(row, field_mapping["id"]) + + coords = get_coords_from_row(row, field_mapping) + if not coords: + return + + status = get_field_value(row, field_mapping["운영상태"]) + if status != "운영": + return + + address = get_field_value(row, field_mapping["도로명주소"]) or get_field_value(row, field_mapping["지번주소"]) + if not address.startswith("서울"): + return + + school_type = get_field_value(row, field_mapping["유형"]) + infrastructure_type = "ELEMENTARY_SCHOOL" if school_type == "초등학교" else "MIDDLE_SCHOOL" if school_type == "중학교" else "HIGH_SCHOOL" if school_type == "고등학교" else None + if not infrastructure_type: + return + + # 학군? + # 횡단보도 수? + + return { + "type": infrastructure_type, + "source_id": source_id, + "name": name, + "coordinates": coords, + "details": None, + } + + +def parse_subway_station(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False): + name = get_field_value(row, field_mapping["이름"]) + source_id = get_field_value(row, field_mapping["id"]) + + if field_mapping["type"] == "main": + coords = get_coords_from_row(row, field_mapping) + if coords: + old_coords = context.setdefault("coords", {}).setdefault(source_id, {}) + count = old_coords.get("count") + if count is None: + old_coords["count"] = 1 + old_coords["lat"] = coords[0] + old_coords["lng"] = coords[1] + else: + count += 1 + old_coords["count"] += count + old_coords["lat"] += (coords[0] - old_coords["lat"]) / count + old_coords["lng"] += (coords[1] - old_coords["lng"]) / count + + return { + "type": "SUBWAY_STATION", + "source_id": source_id, + "name": name, + } + elif field_mapping["type"] == "lines": + result = context.get("results", {}).get(source_id) + if not result: return + line = get_field_value(row, field_mapping["노선"]) + result.setdefault("details", {}).setdefault("lines", []).append(line) + +def post_parse_subway_station(context: dict[str, Any], *, silent: bool = False): + all_coords = context.get("coords", {}) + for source_id in all_coords: + coords = all_coords[source_id] + if not coords: continue + context.get("results", {}).get(source_id, {})["coordinates"] = (coords["lat"], coords["lng"]) + + +def parse_large_hospital(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False): + name = get_field_value(row, field_mapping["이름"]) + source_id = get_field_value(row, field_mapping["id"]) + + coords = get_coords_from_row(row, field_mapping) + if not coords: + return + + code = get_field_value(row, field_mapping["응급의료기관코드명"]) + + details = { + "응급의료기관코드명": code, + } + + infrastructure_type = "LARGE_HOSPITAL" + return { + "type": infrastructure_type, + "source_id": source_id, + "name": name, + "coordinates": coords, + "details": details, + } + + +def parse_large_supermarket(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False): + name = get_field_value(row, field_mapping["이름"]) + source_id = get_field_value(row, field_mapping["id"]) + coords = get_coords_from_row(row, field_mapping) + if not coords: + return + + status = get_field_value(row, field_mapping["영업상태명"]) + if status != "영업/정상": + return + + store_type = get_field_value(row, field_mapping["점포구분명"]) + if store_type not in ["대규모점포", "준대규모점포"]: + return + + biz_type = get_field_value(row, field_mapping["업태구분명"]) + + infrastructure_type = "LARGE_SUPERMARKET" + return { + "type": infrastructure_type, + "source_id": source_id, + "name": name, + "coordinates": coords, + "details": { + "업태구분명": biz_type, + "점포구분명": store_type, + }, + } + + +def parse_park(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False): + name = get_field_value(row, field_mapping["이름"]) + source_id = get_field_value(row, field_mapping["id"]) + + coords = get_coords_from_row(row, field_mapping) + if not coords: + return + + address = get_field_value(row, field_mapping["지번주소"]) or get_field_value(row, field_mapping["도로명주소"]) + if not address.startswith("서울"): + return + + try: + park_area = get_field_value(row, field_mapping["면적"]) + except: + park_area = None + + infrastructure_type = "PARK" + return { + "type": infrastructure_type, + "source_id": source_id, + "name": name, + "coordinates": coords, + "details": { + "면적": park_area, + }, + } + + +def connect_relationships(contexts: dict[str, Any], *, silent: bool = False): + # region self relationship + regions = contexts.get("동네", {}).get("instances", {}) + for source_id in regions: + ins = regions[source_id] + name = ins.name + if " " in name: + parent_name = " ".join(name.split(" ")[:-1]) + parent_source_id = REGION_NAME_MAP[parent_name]["source_id"] + parent_ins = regions[parent_source_id] + ins.parent = parent_ins + + # property - region relationship + context = contexts.get("건물", {}) + bjd_codes = context.get("bjd_codes", {}) + properties = context.get("instances", {}) + extra_data = context.get("extra_data", {}) + for source_id in properties: + ins = properties[source_id] + + bjd_code = bjd_codes[source_id] + ins.region = regions[bjd_code] + + keys = [ + ins.land_lot_address, + ins.road_name_address, + ] + extra = None + for key in keys: + key = key.replace(" ", "").strip() if key else None + if not key: continue + if key in extra_data: + extra = extra_data[key] + break + if not extra: continue + if not ins.name: ins.name = extra.get("name") + if not ins.land_lot_address: ins.land_lot_address = extra.get("land_lot_address") + if not ins.road_name_address: ins.road_name_address = extra.get("road_name_address") + ins.dong_count = extra.get("dong_count") + ins.household_count = extra.get("household_count") + ins.parking_count = extra.get("parking_count") + ins.parking_per_household = extra.get("parking_per_household") + + +def _run(db: Session, target_files: list[str], *, silent: bool = False): + P = not silent + + if P: print(f"데이터베이스에서 기존 데이터 조회 중...") + REGION_UNIQUE_MAP.update({x.source_id: x for x in db.query(Region).all()}) + PROPERTY_UNIQUE_MAP.update({x.source_id: x for x in db.query(Property).all()}) + INFRA_UNIQUE_MAP.update({(x.type.value, x.source_id): x for x in db.query(Infrastructure).all()}) + MAP_ROUTER = { + Region: REGION_UNIQUE_MAP, + Property: PROPERTY_UNIQUE_MAP, + Infrastructure: INFRA_UNIQUE_MAP + } + def get_model_instance(model_cls, result: dict[str, Any]): + target_map = MAP_ROUTER.get(model_cls, {}) + + if model_cls is Region or model_cls is Property: + key = result["source_id"] + elif model_cls is Infrastructure: + key = (result.get("type"), result.get("source_id")) + + return target_map.pop(key, None) + + if P: print(f"데이터 처리 중...") + contexts: dict[str, Any] = {} + map_items = [ + item + for field_map in [ + PRELOAD_FIELD_NAME_MAP, + REGION_FIELD_NAME_MAP, + PROPERTY_FIELD_NAME_MAP, + INFRA_FIELD_NAME_MAP, + ] + for item in field_map.items() + ] + priorities = {} + for k, v in map_items: + p = v.get("priority", 0) + priorities.setdefault(p, []).append((k, v)) + + filepath_and_names = [(file, Path(file).stem) for file in target_files] + for p in sorted(priorities): + for key, field_mapping in priorities[p]: + file_extension = field_mapping.get("file_extension", ".csv") + filtered_files = filter(lambda x: x[1].startswith(key) and x[0].endswith(file_extension), filepath_and_names) + for filepath, filename in filtered_files: + is_iterable = False + if file_extension != ".shp": + parser = field_mapping.get("parser", "csv") + if parser == "csv": + is_iterable = True + header = field_mapping.get("header", True) + fieldnames = field_mapping.get("fieldnames") + delimiter = field_mapping.get("delimiter", ",") + rows = read_csv_file( + filepath, + header=header, + fieldnames=fieldnames, + delimiter=delimiter, + ) + category = field_mapping["category"] + match category: + case "AL_D164": + callback = parse_AL_D164_shp + case "rnaddrkor": + callback = parse_rnaddrkor + case "jibun": + callback = parse_jibun_rnaddrkor + case "동네": + callback = parse_region + case "건물": + callback = parse_apartment + case "학교": + callback = parse_school + case "역사": + callback = parse_subway_station + case "병원": + callback = parse_large_hospital + case "마트": + callback = parse_large_supermarket + case "공원": + callback = parse_park + case _: + continue + + prefix = f"[{file_extension.strip('.')}] [{filename}]" + context = contexts.setdefault(category, {}) + if is_iterable: + def run(idx: int, row: dict[str, Any]): + result = callback(row, field_mapping, context, silent=silent) + if not result: return + + source_id = get_field_value(row, field_mapping["id"]) + context.setdefault("results", {})[source_id] = result + run_with_progress(rows, prefix, run, interval=PROGRESS_PRINT, silent=silent) + else: + def run(idx: int, filepath: str): + callback(filepath, field_mapping, context, silent=silent) + run_with_progress([filepath], prefix, run, interval=PROGRESS_PRINT, silent=silent) + if P: print(f"데이터 정리 중...") + for category in contexts: + if P: print(f" - {category}") + context = contexts[category] + match category: + case "동네": + callback = post_parse_region + case "역사": + callback = post_parse_subway_station + case _: + continue + callback(context, silent=silent) + + if P: print(f"데이터 삽입 중...") + for category in contexts: + match category: + case "동네": + Model = Region + case "건물": + Model = Property + case _: + Model = Infrastructure + + context = contexts[category] + results = context.get("results") or {} + items = list(results.values()) + def run(idx: int, result): + if not result: return + ins = get_model_instance(Model, result) + if ins: + for key, val in result.items(): + setattr(ins, key, val) + else: + ins = Model(**result) + db.add(ins) + ins.deleted_at = None + context.setdefault("instances", {})[result["source_id"]] = ins + run_with_progress(items, f"[{category}]", run, interval=PROGRESS_PRINT, silent=silent) + + if P: print(f"테이블 관계 설정 중...") + connect_relationships(contexts, silent=silent) + + if P: print(f"데이터 soft delete 처리 중...") + for model, curr_map in MAP_ROUTER.items(): + items = list(curr_map.values()) + def run(idx: int, ins): + ins.deleted_at = ins.deleted_at or func.now() + run_with_progress(items, f"[{model.__tablename__}]", run, interval=PROGRESS_PRINT, silent=silent) + + +def run(directory_path: str | None = None, *, silent: bool = False): + """ + 디렉토리 내의 CSV 파일을 읽어 데이터를 데이터베이스에 삽입하는 메인 함수입니다. + + Args: + directory_path (str): CSV 파일이 있는 디렉토리 경로 + Retruns: + None + """ + + P = not silent + + if directory_path: + folder = Path(directory_path) + else: + folder = DATA_DIR + + Base.metadata.create_all(bind=engine) + db = SessionLocal() + try: + if not folder.is_dir(): + return + + files = list(get_files( + str(folder), + extensions=[".csv", ".txt", ".shp"], + recursive=True, + )) + + _run(db, files, silent=silent) + except Exception as e: + if P: + print() + print(f"데이터 삽입 중 오류가 발생했습니다. 롤백을 진행합니다. ({e})") + db.rollback() + raise e + else: + if P: print("데이터 삽입이 성공적으로 완료되었습니다. 커밋을 진행합니다.") + db.commit() + finally: + db.close() diff --git a/app/manage/data/router.py b/app/manage/data/router.py new file mode 100644 index 0000000..1be6fe0 --- /dev/null +++ b/app/manage/data/router.py @@ -0,0 +1,97 @@ +"""관리용 라우터""" + +from fastapi import APIRouter, BackgroundTasks, Depends, status +from sqlalchemy import exists, select +from sqlalchemy.orm import Session + +from ...database import get_db +from ...models import Region, Property, Infrastructure +from ..schemas import BackgroundSuccessResponse, DataDownloadStatusResponse, DataInsertStatusResponse + +from .insert import run as insert +from .download import run as download +from .download import get_current_version, get_downloading_version + + +router = APIRouter(prefix="/data") + + +@router.post( + "/sources", + summary="소스 데이터 파일 다운로드", + status_code=status.HTTP_202_ACCEPTED, +) +def download_data( + background_tasks: BackgroundTasks, +) -> BackgroundSuccessResponse: + """구글 드라이브 내 소스 데이터 파일을 다운로드""" + + background_tasks.add_task( + download, + silent=False, + ) + + return { + "requested": True, + } + +@router.get( + "/sources/status", + summary="다운로드 상태 확인", + status_code=status.HTTP_200_OK, +) +def download_data( +) -> DataDownloadStatusResponse: + """구글 드라이브 내 소스 데이터 파일을 다운로드""" + + curr_version = get_current_version() + downloading_version = get_downloading_version() + + return { + "curr_version": curr_version, + "downloading_version": downloading_version, + } + + +@router.patch( + "", + summary="데이터 삽입", + status_code=status.HTTP_202_ACCEPTED, +) +def insert_data( + background_tasks: BackgroundTasks, +) -> BackgroundSuccessResponse: + """다운로드된 소스 데이터 파일을 읽어 데이터베이스에 삽입""" + + background_tasks.add_task( + insert, + silent=False, + ) + + return { + "requested": True, + } + +@router.get( + "/status", + summary="데이터 삽입 상태 확인", + status_code=status.HTTP_200_OK, +) +def check_data_status( + db: Session = Depends(get_db), +) -> DataInsertStatusResponse: + """데이터 삽입 상태 확인""" + + result = db.execute( + select( + exists().where(Region.id), + exists().where(Property.id), + exists().where(Infrastructure.id), + ), + ).tuples().first() + + return { + "has_region": result[0], + "has_property": result[1], + "has_infrastructure": result[2], + } diff --git a/app/manage/router.py b/app/manage/router.py index a2ec430..1489cce 100644 --- a/app/manage/router.py +++ b/app/manage/router.py @@ -2,9 +2,12 @@ from fastapi import APIRouter -from . import seeds +from . import tables, data, seeds router = APIRouter(prefix="/manage", tags=["manage"]) + +router.include_router(tables.router) +router.include_router(data.router) router.include_router(seeds.router) diff --git a/app/manage/schemas.py b/app/manage/schemas.py index b5e5ec8..ebd7c45 100644 --- a/app/manage/schemas.py +++ b/app/manage/schemas.py @@ -2,12 +2,31 @@ from pydantic import BaseModel, Field +class ImmediateResponse(BaseModel): + """즉시 실행 응답""" + + success: bool + class BackgroundSuccessResponse(BaseModel): """백그라운드 실행 성공 응답""" requested: Literal[True] +class DataDownloadStatusResponse(BaseModel): + """데이터 상태 조회 응답""" + + curr_version: str | None + downloading_version: str | None + +class DataInsertStatusResponse(BaseModel): + """데이터 상태 조회 응답""" + + has_region: bool + has_property: bool + has_infrastructure: bool + + class GenerateSeedsRequest(BaseModel): """시드 데이터 생성 요청""" diff --git a/app/manage/seeds/__init__.py b/app/manage/seeds/__init__.py new file mode 100644 index 0000000..f139b37 --- /dev/null +++ b/app/manage/seeds/__init__.py @@ -0,0 +1,4 @@ +from .router import router + +from .insert import run as insert +from .drop import run as drop diff --git a/app/manage/seeds/router.py b/app/manage/seeds/router.py index f308146..7e36761 100644 --- a/app/manage/seeds/router.py +++ b/app/manage/seeds/router.py @@ -8,7 +8,7 @@ from ...database import get_db from ...models import User, Recommendation -from ..schemas import BackgroundSuccessResponse, GenerateSeedsRequest, SeedsStatusResponse +from ..schemas import ImmediateResponse, BackgroundSuccessResponse, GenerateSeedsRequest, SeedsStatusResponse from .insert import run as insert_seeds from .drop import run as drop_seeds @@ -41,22 +41,23 @@ def generate_seeds( @router.delete( "", summary="시드 데이터 삭제", - status_code=status.HTTP_202_ACCEPTED, + status_code=status.HTTP_200_OK, ) def delete_seeds( - background_tasks: BackgroundTasks, -) -> BackgroundSuccessResponse: +) -> ImmediateResponse: """ 랜덤 시드 데이터 삭제. 단, property-infra 사이 점수 등 데이터는 삭제되지 않음. """ - background_tasks.add_task( - drop_seeds, - ) + try: + drop_seeds() + success = True + except: + success = False return { - "requested": True, + "success": success, } @router.get( diff --git a/app/manage/tables/__init__.py b/app/manage/tables/__init__.py new file mode 100644 index 0000000..ec13674 --- /dev/null +++ b/app/manage/tables/__init__.py @@ -0,0 +1,4 @@ +from .router import router + +from .create import run as create +from .drop import run as drop diff --git a/app/manage/tables/create.py b/app/manage/tables/create.py new file mode 100644 index 0000000..3cfbabd --- /dev/null +++ b/app/manage/tables/create.py @@ -0,0 +1,9 @@ +from app.database import engine +from app.models import Base + + +def run(): + try: + Base.metadata.create_all(bind=engine) + except Exception as e: + raise e diff --git a/app/manage/tables/drop.py b/app/manage/tables/drop.py new file mode 100644 index 0000000..c0fc00c --- /dev/null +++ b/app/manage/tables/drop.py @@ -0,0 +1,9 @@ +from app.database import engine +from app.models import Base + + +def run(): + try: + Base.metadata.drop_all(bind=engine) + except Exception as e: + raise e diff --git a/app/manage/tables/router.py b/app/manage/tables/router.py new file mode 100644 index 0000000..eab40dc --- /dev/null +++ b/app/manage/tables/router.py @@ -0,0 +1,50 @@ +"""관리용 라우터""" + +from fastapi import APIRouter, status + +from ..schemas import ImmediateResponse + +from .create import run as create +from .drop import run as drop + + +router = APIRouter(prefix="/tables") + + +@router.post( + "", + summary="테이블 생성", + status_code=status.HTTP_201_CREATED, +) +def create_table( +) -> ImmediateResponse: + """모든 테이블 생성""" + + try: + create() + success = True + except: + success = False + + return { + "success": success, + } + +@router.delete( + "", + summary="테이블 삭제", + status_code=status.HTTP_200_OK, +) +def drop_table( +) -> ImmediateResponse: + """모든 테이블 삭제""" + + try: + drop() + success = True + except: + success = False + + return { + "success": success, + } diff --git a/app/parser/__init__.py b/app/parser/__init__.py new file mode 100644 index 0000000..f22cc36 --- /dev/null +++ b/app/parser/__init__.py @@ -0,0 +1,2 @@ +from . import csv +from . import df diff --git a/scripts/csv_parser.py b/app/parser/csv.py similarity index 100% rename from scripts/csv_parser.py rename to app/parser/csv.py diff --git a/scripts/df_parser.py b/app/parser/df.py similarity index 100% rename from scripts/df_parser.py rename to app/parser/df.py diff --git a/requirements-dev.txt b/requirements-dev.txt index 49cf2a6..8e890d8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,5 +2,3 @@ pytest pytest-cov -geopandas -gdown diff --git a/requirements.txt b/requirements.txt index 55a8fa7..8e8663c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,5 @@ argon2-cffi python-dotenv cuid2 redis +geopandas +gdown diff --git a/scripts/download_data.py b/scripts/download_data.py index 3cce71a..25cf7fd 100644 --- a/scripts/download_data.py +++ b/scripts/download_data.py @@ -9,19 +9,6 @@ # backend 디렉터리를 Python 경로에 추가 sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from app.utils import read_file, write_file - - -TIMESTAMP = round(time()) -SCRATCH_TIMESTAMP_FILENAME = ".timestamp" - -DATA_DIR = Path(__file__).resolve().parent.parent / "data" -BACKUP_DIR = DATA_DIR / ".backup" -DOWNLOADING_DIR = DATA_DIR / ".downloading" -VERSION_FILE = str(DATA_DIR / "version.txt") -VERSION_TEMP_FILE = str(DATA_DIR / ".version.tmp") -URL_FILE = str(DATA_DIR / "url.txt") - def usage(): print("사용법: python download_data.py") @@ -29,115 +16,6 @@ def usage(): print(" - 다운로드된 파일은 data 폴더에 위치합니다.") -def make_scratch_folder(): - file = DOWNLOADING_DIR / "scratch" / SCRATCH_TIMESTAMP_FILENAME - if file.exists(): - ts = int(read_file(str(file), strip=True)) - if TIMESTAMP - ts < 60 * 60 * 24: - return file.parent - - shutil.rmtree(str(file.parent), ignore_errors=True) - file.parent.mkdir(parents=True, exist_ok=True) - write_file(str(file), str(TIMESTAMP)) - return file.parent - - -def read_url_file(filepath: str) -> dict[str, str]: - urls = {} - content = read_file(filepath) - if not content: return urls - - for line in content.splitlines(): - line = re.sub(r"#.*", "", line).strip() - if not line: continue - - idx = line.find(":") - if idx == -1: continue - - key = line[:idx].strip() - value = line[idx+1:].strip() - if value: urls[key] = value - return urls - - -def download_file(*, silent: bool = False, **kwargs): - return gdown.download( - **kwargs, - quiet=silent, - resume=True, - ) - -def download_folder(*, silent: bool = False, **kwargs): - return gdown.download_folder( - **kwargs, - quiet=silent, - resume=True, - ) - - -def move_folder(source: str, dest: str, *, ignore: list[str] = [], silent: bool = False): - src = Path(source) - dst = Path(dest) - dst.mkdir(parents=True, exist_ok=True) - - for path in src.iterdir(): - if path == dst or path.name in ignore: - continue - try: - target_path = dst / path.name - shutil.move(str(path), str(target_path)) - except Exception as e: - if not silent: print(f"Move Failed: {e}") - - if not list(src.iterdir()): - os.rmdir(str(src)) - - - -def run(*, silent: bool = False): - P = not silent - - urls = read_url_file(URL_FILE) - - if P: print("현재 버전 확인 중...") - curr_version = read_file(VERSION_FILE, strip=True) - if P: print(f"현재 버전: {curr_version or 'No Version'}") - - if not curr_version: - if P: print("버전 파일이 없습니다. 데이터를 다운로드합니다.") - output_folder = str(make_scratch_folder()) - else: - if P: print("최신 버전 확인 중...") - download_file(url=urls["version"], output=VERSION_TEMP_FILE, silent=True) - - new_version: str = read_file(VERSION_TEMP_FILE, strip=True) - if curr_version == new_version: - if P: print(f"현재 최신 버전입니다.") - return False - if P: print(f"최신 버전({new_version})을 다운로드합니다.") - output_folder = str(DOWNLOADING_DIR / new_version) - - download_folder(url=urls["google drive"], output=output_folder, silent=silent) - - if curr_version: - move_folder( - str(DATA_DIR), - str(BACKUP_DIR / curr_version), - ignore=[ - Path(VERSION_TEMP_FILE).name, - BACKUP_DIR.name, - DOWNLOADING_DIR.name, - ], - silent=silent, - ) - move_folder(output_folder, str(DATA_DIR)) - if curr_version: - os.remove(VERSION_TEMP_FILE) - else: - os.remove(str(DATA_DIR / SCRATCH_TIMESTAMP_FILENAME)) - - return True - if __name__ == "__main__": if "-h" in sys.argv or "--help" in sys.argv: @@ -147,4 +25,5 @@ def run(*, silent: bool = False): usage() sys.exit(1) - run() + from app.manage import data + data.download() diff --git a/scripts/drop_tables.py b/scripts/drop_tables.py index 63e4a14..082d5c4 100755 --- a/scripts/drop_tables.py +++ b/scripts/drop_tables.py @@ -6,24 +6,19 @@ def usage(): - print("사용법: python drop-tables.py [-y|--yes]") + print("사용법: python drop_tables.py [-y|--yes]") print(" - 모든 데이터베이스 테이블을 삭제합니다.") -def main(*, force: bool = False): - if force is True: - drop = True - else: - print("⚠️ 경고: 모든 데이터베이스 테이블이 삭제됩니다.") - confirm = input("정말로 진행하시겠습니까? (y/N): ").lower() - drop = confirm in ("y", "yes") - if drop: - from app.database import engine - from app.models import Base - Base.metadata.drop_all(bind=engine) - print("모든 테이블이 삭제되었습니다.") +def main(): + try: + from app.manage import tables + tables.drop() + except Exception as e: + print("데이터베이스 테이블 삭제 중 오류가 발생했습니다.") + raise e else: - print("작업이 취소되었습니다.") + print("모든 데이터베이스 테이블이 삭제되었습니다.") if __name__ == "__main__": @@ -33,7 +28,11 @@ def main(*, force: bool = False): elif len(sys.argv) > 2: usage() sys.exit(1) - elif len(sys.argv) == 2 and sys.argv[1] in ("-y", "--yes"): - main(force=True) - else: - main() + elif len(sys.argv) != 2 or sys.argv[1] not in ("-y", "--yes"): + print("⚠️ 경고: 모든 데이터베이스 테이블이 삭제됩니다.") + confirm = input("정말로 진행하시겠습니까? (y/N): ").lower() + if confirm not in ("y", "yes"): + print("작업이 취소되었습니다.") + sys.exit(0) + + main() diff --git a/scripts/drop_to_seeds.py b/scripts/drop_to_seeds.py old mode 100644 new mode 100755 index e185a7d..7ad28b8 --- a/scripts/drop_to_seeds.py +++ b/scripts/drop_to_seeds.py @@ -1,3 +1,5 @@ +#!/usr/bin/with-contenv python3 + import os import sys import time @@ -35,11 +37,11 @@ def usage(): sys.exit(1) from scripts import drop_tables, insert_data - from app.seeds.insert import run as insert_seeds + from app.manage import seeds try: drop_tables.main(force=True) insert_data.main() - insert_seeds(*args) + seeds.insert(*args) except Exception as e: print("작업 중 오류가 발생했습니다.") raise e diff --git a/scripts/insert_data.py b/scripts/insert_data.py old mode 100644 new mode 100755 index bcf4562..aacbd25 --- a/scripts/insert_data.py +++ b/scripts/insert_data.py @@ -1,1173 +1,24 @@ -import re +#!/usr/bin/with-contenv python3 + import os import sys -import numpy as np -import pandas as pd -import geopandas as gpd -from typing import Generator -from pathlib import Path -from typing import Any -from geopandas import GeoDataFrame -from collections.abc import Iterable -from sqlalchemy.orm import Session -from sqlalchemy.sql import func -from pyproj import Transformer # backend 디렉터리를 Python 경로에 추가 sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from app.database import SessionLocal, engine -from app.models import Base, Region, Property, Infrastructure -from app.utils import parse_int, parse_float, run_with_progress, get_files - -from scripts.csv_parser import read_csv_file -from scripts.df_parser import read_pandas, read_shp_file - - -PROGRESS_PRINT = 100 -DB_BATCH_SIZE = 1000 - - -# global variable -DF: dict[str, pd.DataFrame] = {} -GDF: dict[str, GeoDataFrame] = {} -ADDRESS_MAP = {} -BJD_CODE_MAP = {} -REGION_NAME_MAP = {} -REGION_UNIQUE_MAP = {} -PROPERTY_UNIQUE_MAP = {} -INFRA_UNIQUE_MAP = {} - - -# *_FIELD_NAME_MAP -# - key: filename.startswith(key) - -PRELOAD_FIELD_NAME_MAP: dict[str, dict[str, Any]] = { - "rnaddrkor_": { - "priority": -10, - "file_extension": ".txt", - "parser": None, - "category": "rnaddrkor", - }, - "jibun_rnaddrkor_": { - "priority": -10, - "file_extension": ".txt", - "parser": None, - "category": "jibun", - }, - "AL_D164_": { - "priority": -10, - "file_extension": ".shp", - "category": "AL_D164", - }, -} - -REGION_FIELD_NAME_MAP: dict[str, dict[str, Any]] = { - "법정동코드 전체": { - "file_extension": ".txt", - "delimiter": "\t", - "category": "동네", - "type": "main", - "id": "법정동코드", - "이름": "법정동명", - "상태": "폐지여부", - }, - "서울시 학원 교습소정보": { - "priority": 10, - "category": "동네", - "type": "academy", - "id": "학원지정번호", - "이름": "학원명", - "유형": "학원/교습소", - "도로명주소": "도로명주소", - "도로명상세주소": "도로명상세주소", - "운영상태": "등록상태명", - }, -} - -PROPERTY_FIELD_NAME_MAP: dict[str, dict[str, Any]] = { - "AL_D164_": { - "file_extension": ".shp", - "category": "건물", - "type": "main", - }, - "서울시 공동주택 아파트 정보": { - "priority": 10, - "category": "건물", - "type": "apartment", - "id": "k-아파트코드", - "이름": "k-아파트명", - "위도": "좌표Y", - "경도": "좌표X", - "도로명주소": "kapt도로명주소", - "시도": "주소(시도)k-apt주소split", - "시군구": "주소(시군구)", - "읍면동": "주소(읍면동)", - "나머지주소": "나머지주소", - "도로명": "주소(도로명)", - "도로상세주소": "주소(도로상세주소)", - "동수": "k-전체동수", - "세대수": "k-전체세대수", - "주차대수": "주차대수", - }, -} - -INFRA_FIELD_NAME_MAP: dict[str, dict[str, Any]] = { - "전국초중등학교위치표준데이터": { - "category": "학교", - "id": "학교ID", - "이름": "학교명", - "위도": "위도", - "경도": "경도", - "지번주소": "소재지지번주소", - "도로명주소": "소재지도로명주소", - "유형": "학교급구분", - "운영상태": "운영상태", - }, - "서울시 역사마스터 정보": { - "category": "역사", - "type": "main", - "id": lambda row: f"01_{row['역사명']}", - "이름": lambda row: row['역사명'].rstrip('역'), - "위도": "위도", - "경도": "경도", - }, - "국토교통부_도시철도 전체노선": { - "priority": 10, - "category": "역사", - "type": "lines", - "id": ["권역", "역명"], - "이름": lambda row: row['역명'].rstrip('역'), - "노선": "노선명", - }, - "서울시 응급실 위치 정보": { - "category": "병원", - "id": "기관ID", - "이름": "기관명", - "위도": "병원위도", - "경도": "병원경도", - "응급의료기관코드명": "응급의료기관코드명", - }, - "생활_대규모점포_서울특별시": { - "category": "마트", - "id": "관리번호", - "이름": "사업장명", - "tm_x": "좌표정보(X)", - "tm_y": "좌표정보(Y)", - "영업상태명": "영업상태명", - "업태구분명": "업태구분명", - "점포구분명": "점포구분명", - }, - "전국도시공원정보표준데이터": { - "category": "공원", - "id": "관리번호", - "이름": "공원명", - "위도": "위도", - "경도": "경도", - "지번주소": "소재지지번주소", - "도로명주소": "소재지도로명주소", - "면적": "공원면적", - }, -} - - -transformer = Transformer.from_crs("epsg:5179", "epsg:4326", always_xy=True) - def usage(): print("사용법: python insert_data.py") print(" - data 폴더 내 *.csv 파일 데이터를 데이터베이스에 삽입합니다.") -def string_rcut_and_rstrip(_value: str, suffix: str) -> tuple[str, bool]: - value = _value.strip() - if value.endswith(suffix): - value = value[:-len(suffix)].strip() - return value, _value != value - -def all_join_or_none(*args, sep=" "): - if all(args): - return sep.join(str(arg) for arg in args) - return None - - -def get_field_value(row: dict[str, Any], value: Any) -> str | None: - if isinstance(value, str): - return row.get(value, "").strip() or None - - if isinstance(value, Iterable) and all(isinstance(v, str) for v in value): - return "_".join(str(row.get(key, "").strip()) for key in value) or None - - if callable(value): - return value(row).strip() or None - - -def get_coords_from_row(row: dict[str, Any], field_mapping: dict[str, Any]) -> tuple[float, float] | None: - """ - 주어진 행에서 위도와 경도를 추출하여 float 형태로 반환합니다. - - Args: - row (dict[str, Any]): CSV 파일의 한 행을 나타내는 딕셔너리 - field_mapping (dict[str, Any]): 위도와 경도 필드 이름이 매핑된 딕셔너리 - - Returns: - tuple[float, float] | None: (위도, 경도) 튜플 또는 좌표 정보를 찾을 수 없는 경우 None - """ - lat = None - lng = None - try: - lat = parse_float(row[field_mapping["위도"]]) - lng = parse_float(row[field_mapping["경도"]]) - if lat is None or lng is None: raise - except: - try: - tm_x = parse_float(row[field_mapping["tm_x"]]) - tm_y = parse_float(row[field_mapping["tm_y"]]) - lng, lat = transformer.transform(tm_x, tm_y) - except: - pass - if lat is None or lng is None: - return None - return lat, lng - - -def normalize_sido(sido: str) -> str: - sido = re.sub(r"^서울(시|특별시)?($|\s+)", "서울특별시 ", sido).strip() - return sido - - -def get_next_region_name(region_name: str) -> Generator[str, None, None]: - maybe_gu = region_name.split() - maybe_gu = maybe_gu[1] if len(maybe_gu) > 1 else None - - region_name = region_name.strip() - yield region_name - - check1 = re.sub(r"[\d -]+$", "", region_name) - yield check1 - - check2 = re.sub(r"[\d·]+", "", check1) - yield check2 - - check3 = "상일동" if maybe_gu == "강동구" and check2 == "상동" else None - if check3: yield check3 - - -def nomalize_apartment_address(name: str, address: str) -> str: - strip = string_rcut_and_rstrip - name, _ = strip(name, "아파트") - address, _ = strip(address, "관리소") - address, _ = strip(address, "관리사무소") - address, _ = strip(address, "아파트") - address, _ = strip(address, "번지") - address, _ = strip(address, name) - return address - - -def clean_series_field(series, fill_val=""): - return series.fillna(fill_val).astype(str).str.strip() - -def empty_series_to_nan(series): - return series.astype(str).str.strip().replace("", np.nan) - - -def parse_address_data(_df: pd.DataFrame) -> pd.DataFrame: - print() - print(f" - ✅ 원본 데이터 로드 완료 (총 {len(_df)}개 행)") - print(f" - ⏳ 1. 데이터 제거 및 정형화 작업 중...") - - # 필수 데이터 결측치 제거 및 정형화 - - df = _df.dropna(subset=["법정동코드"]) - - tmp_df = df[[ - "도로명주소관리번호", - "법정동코드", - "시도명", - "시군구명", - "법정읍면동명", - "법정리명", - "산여부", - "지번본번", - "지번부번", - "도로명코드", - "도로명", - "건물본번", - "건물부번", - ]].copy().apply(clean_series_field) - - tmp_df["법정읍면동명"] = tmp_df["법정읍면동명"].fillna("") - tmp_df["법정리명"] = tmp_df["법정리명"].fillna("") - tmp_df["산여부"] = tmp_df["산여부"].fillna("0") - tmp_df["지번본번"] = tmp_df["지번본번"].fillna("0") - tmp_df["지번부번"] = tmp_df["지번부번"].fillna("0") - tmp_df["지번본번"] = tmp_df["지번본번"].str.zfill(4) - tmp_df["지번부번"] = tmp_df["지번부번"].str.zfill(4) - - # 산여부 0과 1을 각각 1(일반 대지), 2(산)로 변환 - tmp_df["산여부"] = np.where(tmp_df["산여부"] == "0", "1", "2") - - # 시군구코드, 읍면동코드, 도로명번호 추출 - tmp_df["시군구코드"] = tmp_df["도로명코드"].str[:5] - tmp_df["읍면동코드"] = tmp_df["도로명주소관리번호"].str[5:8] - tmp_df["도로명번호"] = tmp_df["도로명코드"].str[5:] - - print(f" - ⏳ 2. pnu_code 컬럼 결합 연산 및 지번/도로명 주소 매핑 중...") - tmp_df["pnu_code"] = tmp_df["법정동코드"] + tmp_df["산여부"] + tmp_df["지번본번"] + tmp_df["지번부번"] - - tmp_df["지번주소"] = ( - tmp_df["시도명"] + " " + - tmp_df["시군구명"] + " " + - tmp_df["법정읍면동명"] + " " + - tmp_df["법정리명"] + " " + - ( - tmp_df["지번본번"].str.lstrip("0") + - "-" + - tmp_df["지번부번"].str.lstrip("0") - ).str.strip(" -") - ).str.replace(r"\s+", " ", regex=True).str.strip() - - tmp_df["도로명주소"] = ( - tmp_df["시도명"] + " " + - tmp_df["시군구명"] + " " + - tmp_df["도로명"] + " " + - ( - tmp_df["건물본번"].str.lstrip("0") + - "-" + - tmp_df["건물부번"].str.lstrip("0") - ).str.strip(" -") - ) - - print(f" - ✅ 최종 데이터 개수: {len(tmp_df)}개") - print(f" - ✅ 제거된 데이터 개수: {len(df) - len(tmp_df)}") - - res = df.copy() - res["pnu_code"] = tmp_df["pnu_code"] - res["시군구코드"] = tmp_df["시군구코드"] - res["읍면동코드"] = tmp_df["읍면동코드"] - res["도로명번호"] = tmp_df["도로명번호"] - res["도로명주소"] = tmp_df["도로명주소"] - res["지번주소"] = tmp_df["지번주소"] - return res - -def parse_rnaddrkor(filepath: str, *_): - rdf = read_pandas( - filepath, - names=[ - "도로명주소관리번호", - "법정동코드", - "시도명", - "시군구명", - "법정읍면동명", - "법정리명", - "산여부", - "지번본번", - "지번부번", - "도로명코드", - "도로명", - "지하여부", - "건물본번", - "건물부번", - "행정동코드", - "행정동명", - "기초구역번호", - "이전도로명주소", - "효력발생일", - "공동주택구분", - "이동사유코드", - "건축물대장건물명", - "시군구용건물명", - "비고", - ], - ) - - rdf = parse_address_data(rdf) - print(f" - ⏳ 주소 변환을 위한 사전 매핑 중...") - ADDRESS_MAP["rncode_to_rn"] = dict(zip(rdf["도로명코드"], rdf["도로명"])) - DF["rnaddrkor"] = rdf - -def parse_jibun_rnaddrkor(filepath: str, *_): - jdf = read_pandas( - filepath, - names=[ - "도로명주소관리번호", - "법정동코드", - "시도명", - "시군구명", - "법정읍면동명", - "법정리명", - "산여부", - "지번본번", - "지번부번", - "도로명코드", - "지하여부", - "건물본번", - "건물부번", - "이동사유코드", - ], - ) - - jdf["도로명"] = jdf["도로명코드"].map(ADDRESS_MAP["rncode_to_rn"]) - jdf = parse_address_data(jdf) - DF["jibun"] = jdf - -def parse_AL_D164_shp(filepath: str, *_): - gdf = read_shp_file( - filepath, - columns=[ - "GIS건물통합식별번호", - "고유번호", - "법정동코드", - "법정동명", - "특수지구분코드", - "특수지구분명", - "지번", - "건물식별번호", - "집합건물구분코드", - "집합건물구분", - "대장종류코드", - "대장종류", - "건물명", - "상위건물식별번호", - "도로명주소코드", - "도로명주소읍면동리코드", - "도로명주소지하코드", - "도로명주소본번", - "도로명주소부번", - "건물동명", - "건물주부구분코드", - "건물주부구분", - "건물대지면적(㎡)", - "건물건축면적(㎡)", - "건물연면적(㎡)", - "용적율(%)", - "건폐율(%)", - "건축물구조코드", - "건축물구조명", - "건축물용도코드", - "건축물용도명", - "건물높이", - "지상층수", - "지하층수", - "허가일자", - "사용승인일자", - "총주차수", - "총주차장면적(㎡)", - "데이터기준일자", - "시군구코드", - "geometry", - ], - ) - - jdf = DF["jibun"] - rdf = DF["rnaddrkor"] - print() - print(f" - ✅ 원본 데이터 로드 완료 (총 {len(gdf)}개 행)") - - # ========================================== - # 1. 사람이 살 수 있는 주거용 건물 추출 - # ========================================== - print(" - ⏳ 1. 주거용 건물(단독/공동주택 등) 필터링 중...") - - gdf["건축물용도코드"] = clean_series_field(gdf["건축물용도코드"]) - residential_mask = ( - gdf["건축물용도코드"].str.startswith("01") | - gdf["건축물용도코드"].str.startswith("02") | - gdf["건축물용도명"].str.contains("주택|아파트|빌라|오피스텔", na=False) - ) - merged_df = gdf[residential_mask].copy() - - # ========================================================== - # 2. 주소 매핑 - # ========================================================== - print(f" - ⏳ 2. 주소 매핑용 룩업 사전 빌드 중...") - - # 0) 주소 매핑용 사전 빌드: 관리번호와 PNU 기준으로 각각 도로명주소/지번주소 매핑할 수 있는 사전 조립 - jdf_by_mgmt = jdf.drop_duplicates(subset=["도로명주소관리번호"]) - rdf_by_mgmt = rdf.drop_duplicates(subset=["도로명주소관리번호"]) - - jdf_by_pnu = jdf.drop_duplicates(subset=["pnu_code"]) - rdf_by_pnu = rdf.drop_duplicates(subset=["pnu_code"]) - - # 1) rnnum_to_rnaddr : 관리번호 -> 도로명주소 및 역매핑 - ADDRESS_MAP.setdefault("rnnum_to_rnaddr", {}) - ADDRESS_MAP.setdefault("rnaddr_to_rnnum", {}) - for zips in [ - zip(rdf_by_mgmt["도로명주소"], rdf_by_mgmt["도로명주소관리번호"]), - zip(jdf_by_mgmt["도로명주소"], jdf_by_mgmt["도로명주소관리번호"]), - ]: - for addr, num in zips: - ADDRESS_MAP["rnnum_to_rnaddr"].setdefault(num, addr) - ADDRESS_MAP["rnaddr_to_rnnum"].setdefault(addr.replace(" ", ""), []).append(num) - - # 2) rnnum_to_jibun : 관리번호 -> 지번주소 및 역매핑 - ADDRESS_MAP.setdefault("rnnum_to_jibun", {}) - ADDRESS_MAP.setdefault("jibun_to_rnnum", {}) - for zips in [ - zip(jdf_by_pnu["지번주소"], jdf_by_pnu["도로명주소관리번호"]), - zip(rdf_by_pnu["지번주소"], rdf_by_pnu["도로명주소관리번호"]), - ]: - for addr, num in zips: - ADDRESS_MAP["rnnum_to_jibun"].setdefault(num, addr) - ADDRESS_MAP["jibun_to_rnnum"].setdefault(addr.replace(" ", ""), []).append(num) - - # 3) pnu_to_rnaddr : PNU -> 도로명주소 및 역매핑 - ADDRESS_MAP.setdefault("pnu_to_rnaddr", {}) - ADDRESS_MAP.setdefault("rnaddr_to_pnu", {}) - for zips in [ - zip(rdf_by_mgmt["도로명주소"], rdf_by_mgmt["pnu_code"]), - zip(jdf_by_mgmt["도로명주소"], jdf_by_mgmt["pnu_code"]), - ]: - for addr, pnu in zips: - ADDRESS_MAP["pnu_to_rnaddr"].setdefault(pnu, addr) - ADDRESS_MAP["rnaddr_to_pnu"].setdefault(addr.replace(" ", ""), []).append(pnu) - - # 4) pnu_to_jibun : PNU -> 지번주소 및 역매핑 - ADDRESS_MAP.setdefault("pnu_to_jibun", {}) - ADDRESS_MAP.setdefault("jibun_to_pnu", {}) - for zips in [ - zip(jdf_by_pnu["지번주소"], jdf_by_pnu["pnu_code"]), - zip(rdf_by_pnu["지번주소"], rdf_by_pnu["pnu_code"]), - ]: - for addr, pnu in zips: - ADDRESS_MAP["pnu_to_jibun"].setdefault(pnu, addr) - ADDRESS_MAP["jibun_to_pnu"].setdefault(addr.replace(" ", ""), []).append(pnu) - - print("✨ [SUCCESS] 데이터 우선순위 역전 사전(ADDRESS_MAP) 빌드 완료!") - - # ========================================== - # 3. 도로명주소관리번호 추출 및 주소 매핑용 컬럼 조립 - # ========================================== - print(" - ⏳ 3. 도로명주소관리번호 추출 및 주소 매핑용 컬럼 조립 중...") - - tmp_df = merged_df[["고유번호", "도로명주소코드", "도로명주소읍면동리코드", "도로명주소지하코드", "도로명주소본번", "도로명주소부번"]].copy() - tmp_df = tmp_df.dropna(subset=["도로명주소코드", "도로명주소읍면동리코드"], how="all") - - tmp_df["도로명주소코드"] = clean_series_field(tmp_df["도로명주소코드"]) - tmp_df["도로명주소읍면동리코드"] = clean_series_field(tmp_df["도로명주소읍면동리코드"]) - tmp_df["도로명주소지하코드"] = clean_series_field(tmp_df["도로명주소지하코드"], "0") - tmp_df["도로명주소본번"] = clean_series_field(tmp_df["도로명주소본번"], "0") - tmp_df["도로명주소부번"] = clean_series_field(tmp_df["도로명주소부번"], "0") - - tmp_df["시군구코드"] = tmp_df["도로명주소코드"].str[:5] - tmp_df["읍면동코드"] = tmp_df["도로명주소읍면동리코드"].str[:3] - tmp_df["읍면동일련번호"] = tmp_df["도로명주소읍면동리코드"].str[3:] - tmp_df["도로명번호"] = tmp_df["도로명주소코드"].str[5:] - tmp_df["도로명주소본번"] = tmp_df["도로명주소본번"].str.zfill(5) - tmp_df["도로명주소부번"] = tmp_df["도로명주소부번"].str.zfill(5) - - tmp_df["도로명주소관리번호"] = tmp_df["시군구코드"] + tmp_df["읍면동코드"] + tmp_df["도로명번호"] + tmp_df["도로명주소지하코드"] + tmp_df["도로명주소본번"] + tmp_df["도로명주소부번"] - - # Left Join - tmp_subset = tmp_df[["시군구코드", "읍면동코드", "읍면동일련번호", "도로명번호", "도로명주소관리번호"]] - merged_df = merged_df.merge(tmp_subset, left_index=True, right_index=True, how="left") - - # ========================================== - # 4. 국토부 GDF에 대장 주소 1차 수혈 (map 연산) - # ========================================== - print(" - ⏳ 4. 국토부 GDF에 대장 주소 매핑 중...") - merged_df["도로명주소"] = merged_df["도로명주소관리번호"].map(ADDRESS_MAP["rnnum_to_rnaddr"]).fillna(merged_df["고유번호"].map(ADDRESS_MAP["pnu_to_rnaddr"])) - merged_df["지번주소"] = merged_df["고유번호"].map(ADDRESS_MAP["pnu_to_jibun"]).fillna(merged_df["도로명주소관리번호"].map(ADDRESS_MAP["rnnum_to_jibun"])) - - # ========================================== - # 5. 대장 미매칭 구역 대상 GDF 내장 정보 결합 - # ========================================== - print(" - ⏳ 5. 대장 미매칭 유령 필지 대상 국토부 내장 정보 결합 및 예외 처리...") - - # 1) 지번주소 Fallback: 대장 주소가 끝까지 없으면 [법정동명 + 지번] 조합으로 강제 메꿈 - fallback_jibun = (merged_df["법정동명"] + " " + merged_df["지번"]).str.strip() - merged_df["지번주소"] = np.where( - merged_df["지번주소"].isna() | (merged_df["지번주소"] == "") | (merged_df["지번주소"] == "nan"), - fallback_jibun, - merged_df["지번주소"] - ) - - # 2) 도로명주소 Fallback: 대장 주소가 없으면 차선책으로 내장 건물명이라도 보존 (없으면 None) - merged_df["도로명주소"] = np.where( - merged_df["도로명주소"].isna() | (merged_df["도로명주소"] == "") | (merged_df["도로명주소"] == "nan"), - np.where(merged_df["건물명"] != "", merged_df["건물명"], None), - merged_df["도로명주소"] - ) - - # ========================================== - # 6. 주소 분리 조립 및 필수/Nullable 데이터 최종 추출 - # ========================================== - print(" - ⏳ 6. 주소 분리 조립 및 필수/Nullable 데이터 최종 추출 중...") - - # 추후 region 관계설정 및 좌표를 point로 변환해야 함 - renames = { - "고유번호": "source_id", - "건축물용도명": "type", - "건물명": "name", - "지번주소": "land_lot_address", - "도로명주소": "road_name_address", - "법정동코드": "bjd_code", - } - res_df = merged_df[list(renames.keys())].rename(columns=renames) - - centroid_4326 = gpd.GeoSeries(merged_df.geometry.centroid, crs=merged_df.crs).to_crs(epsg=4326) - res_df["lon"] = centroid_4326.x - res_df["lat"] = centroid_4326.y - - # ==================================================== - # 7.: 19자리 고유번호(source_id) 기준 단지화 압축 (중복 제거) - # ==================================================== - print(" - ⏳ 같은 아파트 단지(동별 데이터)를 하나의 대표 단지 데이터로 압축 중...") - agg_rules = { - "lon": "mean", - "lat": "mean", - "land_lot_address": "first", - "road_name_address": "first", # 행안부 마스터 기준으로 완벽히 통일된 주소 채택 - "name": "first", - "type": "first", - "bjd_code": "first" - } - - res_df = res_df.groupby("source_id", as_index=False).agg(agg_rules) - res_df = res_df.replace({np.nan: None}) - - GDF["AL_D164"] = res_df - - -def parse_region(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]): - name = get_field_value(row, field_mapping["이름"]) - source_id = get_field_value(row, field_mapping["id"]) - - if field_mapping["type"] == "main": - if not name.startswith("서울"): - return - - status = get_field_value(row, field_mapping["상태"]) - if status != "존재": - return - - data = { - "source_id": source_id, - "name": name, - "depth": name.count(" "), - } - REGION_NAME_MAP[name] = data - BJD_CODE_MAP[source_id] = name - BJD_CODE_MAP[name] = source_id - return data - elif field_mapping["type"] == "academy": - status = get_field_value(row, field_mapping["운영상태"]) - if status != "개원": - return - - try: - academy_type = get_field_value(row, field_mapping["유형"]) - if academy_type == "교습소": - return - - addr = get_field_value(row, field_mapping["도로명주소"]) - addr = normalize_sido(addr) - si, gu = addr.split()[:2] - - addr_detail = get_field_value(row, field_mapping["도로명상세주소"]) - start_idx = addr_detail.find("(") - if start_idx == -1: return - - details = [ - d.rstrip("층").strip() - for a in addr_detail[start_idx:].split("(") - for b in a.split(")") - for c in b.replace(".", ",").split(",") - for d in c.split(" ") - if d.strip() - ] - context.setdefault("academy", []).append((si, gu, details)) - except: - pass - -def post_parse_region(context: dict[str, Any]): - academy = context.get("academy", []) - - for si, gu, details in academy: - dong = None - for item in details: - checks = get_next_region_name(item) - for curr in checks: - if not curr: continue - full_name = f"{si} {gu} {curr}" - if full_name in REGION_NAME_MAP: - dong = curr - break - if not dong: continue - for key in [si, f"{si} {gu}", f"{si} {gu} {dong}"]: - REGION_NAME_MAP.get(key, {}).setdefault("academy_count", 0) - REGION_NAME_MAP[key]["academy_count"] += 1 - - -def parse_apartment(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]): - bjd_codes = context.setdefault("bjd_codes", {}) - if field_mapping["type"] == "main": - gdf = GDF["AL_D164"] - records = gdf.to_dict(orient="records") - results = context.setdefault("results", {}) - for record in records: - bjd_codes[record["source_id"]] = record["bjd_code"] - results[record["source_id"]] = { - "source_id": record["source_id"], - "type": record["type"], - "name": record["name"], - "land_lot_address": record["land_lot_address"], - "road_name_address": record["road_name_address"], - "coordinates": (record["lat"], record["lon"]), - } - elif field_mapping["type"] == "apartment": - name = get_field_value(row, field_mapping["이름"]) - apt_id = get_field_value(row, field_mapping["id"]) - - rn_address = get_field_value(row, field_mapping["도로명주소"]) - sido = normalize_sido(get_field_value(row, field_mapping["시도"]) or "") - sigungu = get_field_value(row, field_mapping["시군구"]) - dong = get_field_value(row, field_mapping["읍면동"]) - jibun_suffix = get_field_value(row, field_mapping["나머지주소"]) - road_name = get_field_value(row, field_mapping["도로명"]) - road_suffix = get_field_value(row, field_mapping["도로상세주소"]) - dong_count = parse_int(get_field_value(row, field_mapping["동수"])) - household_count = parse_int(get_field_value(row, field_mapping["세대수"])) - parking_count = parse_float(get_field_value(row, field_mapping["주차대수"])) - parking_per_household = None if parking_count is None or household_count is None else parking_count / household_count if household_count else 0 - - ll_address = nomalize_apartment_address(name, all_join_or_none(sido, sigungu, dong, jibun_suffix) or "") or None - rn_address = nomalize_apartment_address(name, rn_address or all_join_or_none(sido, sigungu, road_name, road_suffix) or "") or None - - def extract_rnnum_from_kapt(): - rn_key = rn_address.replace(" ", "") if rn_address else None - if rn_key in ADDRESS_MAP["rnaddr_to_rnnum"]: - return ADDRESS_MAP["rnaddr_to_rnnum"][rn_key][0] - ll_key = ll_address.replace(" ", "") if ll_address else None - if ll_key in ADDRESS_MAP["jibun_to_rnnum"]: - return ADDRESS_MAP["jibun_to_rnnum"][ll_key][0] - return None # 양쪽 다 실패 시 안전하게 Null 리턴 - source_id = extract_rnnum_from_kapt() - - data = { - "source_id": source_id or apt_id, - "name": name, - "land_lot_address": ll_address, - "road_name_address": rn_address, - "dong_count": dong_count, - "household_count": household_count, - "parking_count": parking_count, - "parking_per_household": parking_per_household, - } - - if not source_id: - coords = get_coords_from_row(row, field_mapping) - if not coords: - return - - checks = get_next_region_name(dong) - for curr in checks: - if not curr: continue - full_name = f"{sido} {sigungu} {curr}" - if full_name in BJD_CODE_MAP: - dong = curr - break - - # BJD_CODE_MAP[full_name]: 항상 존재해야 함 - bjd_codes[apt_id] = BJD_CODE_MAP[full_name] - return { - **data, - "coordinates": coords, - } - else: - if ll_address: context.setdefault("extra_data", {})[ll_address.replace(" ", "")] = data - if rn_address: context.setdefault("extra_data", {})[rn_address.replace(" ", "")] = data - - -def parse_school(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]): - name = get_field_value(row, field_mapping["이름"]) - source_id = get_field_value(row, field_mapping["id"]) - - coords = get_coords_from_row(row, field_mapping) - if not coords: - return - - status = get_field_value(row, field_mapping["운영상태"]) - if status != "운영": - return - - address = get_field_value(row, field_mapping["도로명주소"]) or get_field_value(row, field_mapping["지번주소"]) - if not address.startswith("서울"): - return - - school_type = get_field_value(row, field_mapping["유형"]) - infrastructure_type = "ELEMENTARY_SCHOOL" if school_type == "초등학교" else "MIDDLE_SCHOOL" if school_type == "중학교" else "HIGH_SCHOOL" if school_type == "고등학교" else None - if not infrastructure_type: - return - - # 학군? - # 횡단보도 수? - - return { - "type": infrastructure_type, - "source_id": source_id, - "name": name, - "coordinates": coords, - "details": None, - } - - -def parse_subway_station(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]): - name = get_field_value(row, field_mapping["이름"]) - source_id = get_field_value(row, field_mapping["id"]) - - if field_mapping["type"] == "main": - coords = get_coords_from_row(row, field_mapping) - if coords: - old_coords = context.setdefault("coords", {}).setdefault(source_id, {}) - count = old_coords.get("count") - if count is None: - old_coords["count"] = 1 - old_coords["lat"] = coords[0] - old_coords["lng"] = coords[1] - else: - count += 1 - old_coords["count"] += count - old_coords["lat"] += (coords[0] - old_coords["lat"]) / count - old_coords["lng"] += (coords[1] - old_coords["lng"]) / count - - return { - "type": "SUBWAY_STATION", - "source_id": source_id, - "name": name, - } - elif field_mapping["type"] == "lines": - result = context.get("results", {}).get(source_id) - if not result: return - line = get_field_value(row, field_mapping["노선"]) - result.setdefault("details", {}).setdefault("lines", []).append(line) - -def post_parse_subway_station(context: dict[str, Any]): - all_coords = context.get("coords", {}) - for source_id in all_coords: - coords = all_coords[source_id] - if not coords: continue - context.get("results", {}).get(source_id, {})["coordinates"] = (coords["lat"], coords["lng"]) - - -def parse_large_hospital(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]): - name = get_field_value(row, field_mapping["이름"]) - source_id = get_field_value(row, field_mapping["id"]) - - coords = get_coords_from_row(row, field_mapping) - if not coords: - return - - code = get_field_value(row, field_mapping["응급의료기관코드명"]) - - details = { - "응급의료기관코드명": code, - } - - infrastructure_type = "LARGE_HOSPITAL" - return { - "type": infrastructure_type, - "source_id": source_id, - "name": name, - "coordinates": coords, - "details": details, - } - - -def parse_large_supermarket(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]): - name = get_field_value(row, field_mapping["이름"]) - source_id = get_field_value(row, field_mapping["id"]) - coords = get_coords_from_row(row, field_mapping) - if not coords: - return - - status = get_field_value(row, field_mapping["영업상태명"]) - if status != "영업/정상": - return - - store_type = get_field_value(row, field_mapping["점포구분명"]) - if store_type not in ["대규모점포", "준대규모점포"]: - return - - biz_type = get_field_value(row, field_mapping["업태구분명"]) - - infrastructure_type = "LARGE_SUPERMARKET" - return { - "type": infrastructure_type, - "source_id": source_id, - "name": name, - "coordinates": coords, - "details": { - "업태구분명": biz_type, - "점포구분명": store_type, - }, - } - - -def parse_park(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]): - name = get_field_value(row, field_mapping["이름"]) - source_id = get_field_value(row, field_mapping["id"]) - - coords = get_coords_from_row(row, field_mapping) - if not coords: - return - - address = get_field_value(row, field_mapping["지번주소"]) or get_field_value(row, field_mapping["도로명주소"]) - if not address.startswith("서울"): - return - - try: - park_area = get_field_value(row, field_mapping["면적"]) - except: - park_area = None - - infrastructure_type = "PARK" - return { - "type": infrastructure_type, - "source_id": source_id, - "name": name, - "coordinates": coords, - "details": { - "면적": park_area, - }, - } - - -def connect_relationships(contexts: dict[str, Any]): - # region self relationship - regions = contexts.get("동네", {}).get("instances", {}) - for source_id in regions: - ins = regions[source_id] - name = ins.name - if " " in name: - parent_name = " ".join(name.split(" ")[:-1]) - parent_source_id = REGION_NAME_MAP[parent_name]["source_id"] - parent_ins = regions[parent_source_id] - ins.parent = parent_ins - - # property - region relationship - context = contexts.get("건물", {}) - bjd_codes = context.get("bjd_codes", {}) - properties = context.get("instances", {}) - extra_data = context.get("extra_data", {}) - for source_id in properties: - ins = properties[source_id] - - bjd_code = bjd_codes[source_id] - ins.region = regions[bjd_code] - - keys = [ - ins.land_lot_address, - ins.road_name_address, - ] - extra = None - for key in keys: - key = key.replace(" ", "").strip() if key else None - if not key: continue - if key in extra_data: - extra = extra_data[key] - break - if not extra: continue - if not ins.name: ins.name = extra.get("name") - if not ins.land_lot_address: ins.land_lot_address = extra.get("land_lot_address") - if not ins.road_name_address: ins.road_name_address = extra.get("road_name_address") - ins.dong_count = extra.get("dong_count") - ins.household_count = extra.get("household_count") - ins.parking_count = extra.get("parking_count") - ins.parking_per_household = extra.get("parking_per_household") - - -def _main(db: Session, target_files: list[str]): - print(f"데이터베이스에서 기존 데이터 조회 중...") - REGION_UNIQUE_MAP.update({x.source_id: x for x in db.query(Region).all()}) - PROPERTY_UNIQUE_MAP.update({x.source_id: x for x in db.query(Property).all()}) - INFRA_UNIQUE_MAP.update({(x.type.value, x.source_id): x for x in db.query(Infrastructure).all()}) - MAP_ROUTER = { - Region: REGION_UNIQUE_MAP, - Property: PROPERTY_UNIQUE_MAP, - Infrastructure: INFRA_UNIQUE_MAP - } - def get_model_instance(model_cls, result: dict[str, Any]): - target_map = MAP_ROUTER.get(model_cls, {}) - - if model_cls is Region or model_cls is Property: - key = result["source_id"] - elif model_cls is Infrastructure: - key = (result.get("type"), result.get("source_id")) - - return target_map.pop(key, None) - - print(f"데이터 처리 중...") - contexts: dict[str, Any] = {} - map_items = [ - item - for field_map in [ - PRELOAD_FIELD_NAME_MAP, - REGION_FIELD_NAME_MAP, - PROPERTY_FIELD_NAME_MAP, - INFRA_FIELD_NAME_MAP, - ] - for item in field_map.items() - ] - priorities = {} - for k, v in map_items: - p = v.get("priority", 0) - priorities.setdefault(p, []).append((k, v)) - - filepath_and_names = [(file, Path(file).stem) for file in target_files] - for p in sorted(priorities): - for key, field_mapping in priorities[p]: - file_extension = field_mapping.get("file_extension", ".csv") - filtered_files = filter(lambda x: x[1].startswith(key) and x[0].endswith(file_extension), filepath_and_names) - for filepath, filename in filtered_files: - is_iterable = False - if file_extension != ".shp": - parser = field_mapping.get("parser", "csv") - if parser == "csv": - is_iterable = True - header = field_mapping.get("header", True) - fieldnames = field_mapping.get("fieldnames") - delimiter = field_mapping.get("delimiter", ",") - rows = read_csv_file( - filepath, - header=header, - fieldnames=fieldnames, - delimiter=delimiter, - ) - category = field_mapping["category"] - match category: - case "AL_D164": - callback = parse_AL_D164_shp - case "rnaddrkor": - callback = parse_rnaddrkor - case "jibun": - callback = parse_jibun_rnaddrkor - case "동네": - callback = parse_region - case "건물": - callback = parse_apartment - case "학교": - callback = parse_school - case "역사": - callback = parse_subway_station - case "병원": - callback = parse_large_hospital - case "마트": - callback = parse_large_supermarket - case "공원": - callback = parse_park - case _: - continue - - prefix = f"[{file_extension.strip('.')}] [{filename}]" - context = contexts.setdefault(category, {}) - if is_iterable: - def run(idx: int, row: dict[str, Any]): - result = callback(row, field_mapping, context) - if not result: return - - source_id = get_field_value(row, field_mapping["id"]) - context.setdefault("results", {})[source_id] = result - run_with_progress(rows, prefix, run, interval=PROGRESS_PRINT) - else: - def run(idx: int, filepath: str): - callback(filepath, field_mapping, context) - run_with_progress([filepath], prefix, run, interval=PROGRESS_PRINT) - - print(f"데이터 정리 중...") - for category in contexts: - print(f" - {category}") - context = contexts[category] - match category: - case "동네": - callback = post_parse_region - case "역사": - callback = post_parse_subway_station - case _: - continue - callback(context) - - print(f"데이터 삽입 중...") - for category in contexts: - match category: - case "동네": - Model = Region - case "건물": - Model = Property - case _: - Model = Infrastructure - - context = contexts[category] - results = context.get("results") or {} - items = list(results.values()) - def run(idx: int, result): - if not result: return - ins = get_model_instance(Model, result) - if ins: - for key, val in result.items(): - setattr(ins, key, val) - else: - ins = Model(**result) - db.add(ins) - ins.deleted_at = None - context.setdefault("instances", {})[result["source_id"]] = ins - run_with_progress(items, f"[{category}]", run, interval=PROGRESS_PRINT) - - print(f"테이블 관계 설정 중...") - connect_relationships(contexts) - - print(f"데이터 soft delete 처리 중...") - for model, curr_map in MAP_ROUTER.items(): - items = list(curr_map.values()) - def run(idx: int, ins): - ins.deleted_at = ins.deleted_at or func.now() - run_with_progress(items, f"[{model.__tablename__}]", run, interval=PROGRESS_PRINT) - - -def main(directory_path: str | None = None): - """ - 디렉토리 내의 CSV 파일을 읽어 데이터를 데이터베이스에 삽입하는 메인 함수입니다. - - Args: - directory_path (str): CSV 파일이 있는 디렉토리 경로 - Retruns: - None - """ - - folder = Path(directory_path) if directory_path else Path(__file__).resolve().parent.parent / "data" - - Base.metadata.create_all(bind=engine) - db = SessionLocal() - try: - if not folder.is_dir(): - print(f"폴더가 아닙니다: {str(folder)}") - return - - files = list(get_files( - str(folder), - extensions=[".csv", ".txt", ".shp"], - recursive=True, - )) - - _main(db, files) - except Exception as e: - print() - print(f"데이터 삽입 중 오류가 발생했습니다. 롤백을 진행합니다. ({e})") - db.rollback() - raise e - else: - print("데이터 삽입이 성공적으로 완료되었습니다. 커밋을 진행합니다.") - db.commit() - finally: - db.close() - - if __name__ == "__main__": if "-h" in sys.argv or "--help" in sys.argv: usage() sys.exit(0) - elif len(sys.argv) != 1: + elif len(sys.argv) > 2: usage() sys.exit(1) - else: - main() + + from app.manage import data + data.insert() From 7f8f265c1507fc9206a3280d03964305d403da1f Mon Sep 17 00:00:00 2001 From: kimzuni Date: Fri, 29 May 2026 17:31:24 +0900 Subject: [PATCH 6/8] =?UTF-8?q?test:=20test=5Fmode.py=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=B0=8F=20test=5Fmanage.py=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_manage.py | 339 +++++++++++++++++++++++++++++++++++++++++++ tests/test_mode.py | 13 +- 2 files changed, 346 insertions(+), 6 deletions(-) create mode 100644 tests/test_manage.py diff --git a/tests/test_manage.py b/tests/test_manage.py new file mode 100644 index 0000000..944d3d7 --- /dev/null +++ b/tests/test_manage.py @@ -0,0 +1,339 @@ +"""관리용 라우터 및 주요 실행 함수 단위 테스트 모듈.""" + +import os +from unittest.mock import MagicMock, patch, ANY +import pytest +from fastapi import status + + +# ============================================================================== +# 1. API 엔드포인트 테스트 (API Router Tests) +# ============================================================================== + +def test_create_tables_endpoint_success(client): + """POST /manage/tables 테이블 생성 성공 테스트""" + with patch("app.manage.tables.router.create") as mock_create: + response = client.post("/manage/tables") + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == {"success": True} + mock_create.assert_called_once() + + +def test_create_tables_endpoint_failure(client): + """POST /manage/tables 테이블 생성 실패 테스트 (try-except 내장 처리로 201 반환)""" + with patch("app.manage.tables.router.create", side_effect=Exception("Database Error")) as mock_create: + response = client.post("/manage/tables") + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == {"success": False} + mock_create.assert_called_once() + + +def test_drop_tables_endpoint_success(client): + """DELETE /manage/tables 테이블 삭제 성공 테스트""" + with patch("app.manage.tables.router.drop") as mock_drop: + response = client.delete("/manage/tables") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"success": True} + mock_drop.assert_called_once() + + +def test_drop_tables_endpoint_failure(client): + """DELETE /manage/tables 테이블 삭제 실패 테스트""" + with patch("app.manage.tables.router.drop", side_effect=Exception("Database Error")) as mock_drop: + response = client.delete("/manage/tables") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"success": False} + mock_drop.assert_called_once() + + +def test_download_data_endpoint(client): + """POST /manage/data/sources 소스 다운로드 백그라운드 요청 테스트""" + with patch("app.manage.data.router.download") as mock_download: + response = client.post("/manage/data/sources") + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.json() == {"requested": True} + mock_download.assert_called_once_with(silent=False) + + +def test_download_data_status_endpoint(client): + """GET /manage/data/sources/status 다운로드 상태 및 버전 조회 테스트""" + with patch("app.manage.data.router.get_current_version", return_value="v1.0.0") as mock_curr, \ + patch("app.manage.data.router.get_downloading_version", return_value="v1.1.0") as mock_down: + + response = client.get("/manage/data/sources/status") + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "curr_version": "v1.0.0", + "downloading_version": "v1.1.0" + } + mock_curr.assert_called_once() + mock_down.assert_called_once() + + +def test_insert_data_endpoint(client): + """PATCH /manage/data 데이터 삽입 백그라운드 요청 테스트""" + with patch("app.manage.data.router.insert") as mock_insert: + response = client.patch("/manage/data") + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.json() == {"requested": True} + mock_insert.assert_called_once_with(silent=False) + + +def test_check_data_status_endpoint(client, mock_db): + """GET /manage/data/status 데이터 삽입 유무 및 테이블 행 존재 상태 확인 테스트""" + mock_result = MagicMock() + mock_result.tuples.return_value.first.return_value = (True, False, True) + mock_db.execute.return_value = mock_result + + response = client.get("/manage/data/status") + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "has_region": True, + "has_property": False, + "has_infrastructure": True, + } + + +def test_generate_seeds_endpoint(client): + """POST /manage/seeds 시드 데이터 생성 백그라운드 요청 테스트""" + with patch("app.manage.seeds.router.insert_seeds") as mock_insert_seeds: + response = client.post("/manage/seeds", json={"recommendations": 25, "users": 10}) + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.json() == {"requested": True} + mock_insert_seeds.assert_called_once_with( + num_recommendations=25, + num_users=10, + silent=True + ) + + +def test_delete_seeds_endpoint_success(client): + """DELETE /manage/seeds 시드 데이터 삭제 성공 테스트""" + with patch("app.manage.seeds.router.drop_seeds") as mock_drop_seeds: + response = client.delete("/manage/seeds") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"success": True} + mock_drop_seeds.assert_called_once() + + +def test_delete_seeds_endpoint_failure(client): + """DELETE /manage/seeds 시드 데이터 삭제 실패 테스트""" + with patch("app.manage.seeds.router.drop_seeds", side_effect=Exception("Database Error")) as mock_drop_seeds: + response = client.delete("/manage/seeds") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"success": False} + mock_drop_seeds.assert_called_once() + + +def test_check_seed_status_endpoint(client, mock_db): + """GET /manage/seeds/status 시드 데이터 생성 상태 개수 조회 테스트""" + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [100, 250] + mock_db.execute.return_value = mock_result + + response = client.get("/manage/seeds/status") + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "total_users": 100, + "total_recommendations": 250 + } + + +# ============================================================================== +# 2. 메인 실행 함수(Main Run Functions) 테스트 +# ============================================================================== + +def test_run_create_tables(): + """tables/create.py run() 함수 호출 테스트""" + from app.manage.tables.create import run as run_create + with patch("app.manage.tables.create.Base.metadata.create_all") as mock_create_all: + run_create() + mock_create_all.assert_called_once() + + +def test_run_create_tables_error(): + """tables/create.py run() 함수 예외 발생 테스트""" + from app.manage.tables.create import run as run_create + with patch("app.manage.tables.create.Base.metadata.create_all", side_effect=Exception("Metadata Error")): + with pytest.raises(Exception) as exc_info: + run_create() + assert "Metadata Error" in str(exc_info.value) + + +def test_run_drop_tables(): + """tables/drop.py run() 함수 호출 테스트""" + from app.manage.tables.drop import run as run_drop + with patch("app.manage.tables.drop.Base.metadata.drop_all") as mock_drop_all: + run_drop() + mock_drop_all.assert_called_once() + + +def test_run_drop_tables_error(): + """tables/drop.py run() 함수 예외 발생 테스트""" + from app.manage.tables.drop import run as run_drop + with patch("app.manage.tables.drop.Base.metadata.drop_all", side_effect=Exception("Metadata Error")): + with pytest.raises(Exception) as exc_info: + run_drop() + assert "Metadata Error" in str(exc_info.value) + + +def test_run_download_no_update(): + """data/download.py run() - 현재 버전과 최신 버전이 동일하여 다운로드하지 않는 경우""" + from app.manage.data.download import run as run_download + + urls_mock = {"version": "http://ver", "google drive": "http://drive"} + + with patch("app.manage.data.download.read_url_file", return_value=urls_mock) as mock_read_urls, \ + patch("app.manage.data.download.read_file") as mock_read_file, \ + patch("app.manage.data.download.download_file") as mock_download_file, \ + patch("app.manage.data.download.download_folder") as mock_download_folder, \ + patch("app.manage.data.download.write_file") as mock_write_file, \ + patch("app.manage.data.download.move_folder") as mock_move_folder, \ + patch("app.manage.data.download.os.remove") as mock_remove: + + # read_file 첫 호출(curr_version): "v1.0", 두 번째 호출(new_version): "v1.0" + mock_read_file.side_effect = ["v1.0", "v1.0"] + + result = run_download(silent=True) + + assert result is False + mock_read_urls.assert_called_once() + mock_download_file.assert_called_once_with(url="http://ver", output=ANY, silent=True) + # 버전이 동일하므로 다운로드 폴더 및 파일 쓰기가 호출되면 안 됨 + mock_download_folder.assert_not_called() + mock_write_file.assert_not_called() + + +def test_run_download_success(): + """data/download.py run() - 새 버전이 출시되어 정상 다운로드 및 데이터 갱신 완료되는 경우""" + from app.manage.data.download import run as run_download + + urls_mock = {"version": "http://ver", "google drive": "http://drive"} + + with patch("app.manage.data.download.read_url_file", return_value=urls_mock) as mock_read_urls, \ + patch("app.manage.data.download.read_file") as mock_read_file, \ + patch("app.manage.data.download.download_file") as mock_download_file, \ + patch("app.manage.data.download.download_folder") as mock_download_folder, \ + patch("app.manage.data.download.write_file") as mock_write_file, \ + patch("app.manage.data.download.move_folder") as mock_move_folder, \ + patch("app.manage.data.download.os.remove") as mock_remove: + + # read_file 첫 호출(curr_version): "v1.0", 두 번째 호출(new_version): "v2.0" + mock_read_file.side_effect = ["v1.0", "v2.0"] + + result = run_download(silent=True) + + assert result is True + mock_read_urls.assert_called_once() + mock_download_file.assert_called_once_with(url="http://ver", output=ANY, silent=True) + mock_write_file.assert_called_once_with(ANY, "v2.0") + mock_download_folder.assert_called_once_with(url="http://drive", output=ANY, silent=True) + assert mock_move_folder.call_count == 2 + assert mock_remove.call_count == 2 + + +def test_run_insert_data_success(): + """data/insert.py run() - 데이터 파일 정상 로드 및 DB 삽입 트랜잭션 정상 완료 테스트""" + from app.manage.data.insert import run as run_insert + + mock_session = MagicMock() + + with patch("app.manage.data.insert.Base.metadata.create_all") as mock_create_all, \ + patch("app.manage.data.insert.SessionLocal", return_value=mock_session) as mock_session_local, \ + patch("app.manage.data.insert.Path.is_dir", return_value=True) as mock_is_dir, \ + patch("app.manage.data.insert.get_files", return_value=["file1.csv"]) as mock_get_files, \ + patch("app.manage.data.insert._run") as mock_internal_run: + + run_insert(silent=True) + + mock_create_all.assert_called_once() + mock_session_local.assert_called_once() + mock_internal_run.assert_called_once_with(mock_session, ["file1.csv"], silent=True) + mock_session.commit.assert_called_once() + mock_session.close.assert_called_once() + + +def test_run_insert_data_failure(): + """data/insert.py run() - 삽입 도중 에러가 발생하여 롤백 및 세션 종료가 처리되는 테스트""" + from app.manage.data.insert import run as run_insert + + mock_session = MagicMock() + + with patch("app.manage.data.insert.Base.metadata.create_all") as mock_create_all, \ + patch("app.manage.data.insert.SessionLocal", return_value=mock_session) as mock_session_local, \ + patch("app.manage.data.insert.Path.is_dir", return_value=True) as mock_is_dir, \ + patch("app.manage.data.insert.get_files", return_value=["file1.csv"]) as mock_get_files, \ + patch("app.manage.data.insert._run", side_effect=Exception("IO Error")) as mock_internal_run: + + with pytest.raises(Exception) as exc_info: + run_insert(silent=True) + + assert "IO Error" in str(exc_info.value) + mock_session.rollback.assert_called_once() + mock_session.close.assert_called_once() + + +def test_run_insert_seeds_success(): + """seeds/insert.py run() - 랜덤 시드 데이터 정상 추가 완료 테스트""" + from app.manage.seeds.insert import run as run_insert_seeds + + mock_session = MagicMock() + + with patch("app.manage.seeds.insert.SessionLocal", return_value=mock_session) as mock_session_local, \ + patch("app.manage.seeds.insert._run", return_value=50) as mock_internal_run: + + total = run_insert_seeds(num_recommendations=20, num_users=5, silent=True) + + assert total == 50 + mock_session_local.assert_called_once() + mock_internal_run.assert_called_once_with(mock_session, 20, 5, silent=True) + mock_session.commit.assert_called_once() + mock_session.close.assert_called_once() + + +def test_run_insert_seeds_failure(): + """seeds/insert.py run() - 시드 데이터 추가 중 예외 발생 시 롤백 및 세션 종료 테스트""" + from app.manage.seeds.insert import run as run_insert_seeds + + mock_session = MagicMock() + + with patch("app.manage.seeds.insert.SessionLocal", return_value=mock_session) as mock_session_local, \ + patch("app.manage.seeds.insert._run", side_effect=Exception("Seed Error")) as mock_internal_run: + + with pytest.raises(Exception) as exc_info: + run_insert_seeds(silent=True) + + assert "Seed Error" in str(exc_info.value) + mock_session.rollback.assert_called_once() + mock_session.close.assert_called_once() + + +def test_run_drop_seeds_success(): + """seeds/drop.py run() - 시드 데이터 정상 일괄 삭제 완료 테스트""" + from app.manage.seeds.drop import run as run_drop_seeds + + mock_session = MagicMock() + + with patch("app.manage.seeds.drop.SessionLocal", return_value=mock_session) as mock_session_local: + run_drop_seeds() + + mock_session_local.assert_called_once() + assert mock_session.query.call_count == 2 + mock_session.commit.assert_called_once() + mock_session.close.assert_called_once() + + +def test_run_drop_seeds_failure(): + """seeds/drop.py run() - 삭제 진행 중 오류 시 롤백 및 세션 정상 종료 테스트""" + from app.manage.seeds.drop import run as run_drop_seeds + + mock_session = MagicMock() + mock_session.query.side_effect = Exception("Drop Query Failed") + + with patch("app.manage.seeds.drop.SessionLocal", return_value=mock_session) as mock_session_local: + with pytest.raises(Exception) as exc_info: + run_drop_seeds() + + assert "Drop Query Failed" in str(exc_info.value) + mock_session.rollback.assert_called_once() + mock_session.close.assert_called_once() diff --git a/tests/test_mode.py b/tests/test_mode.py index 2b51a2f..eb8842f 100644 --- a/tests/test_mode.py +++ b/tests/test_mode.py @@ -26,8 +26,8 @@ def test_dev_only_routes_blocked_in_production(client) -> None: response_scalar = client.get("/scalar") assert response_scalar.status_code == 404 - response_seeds = client.get("/seeds") - assert response_seeds.status_code == 404 + response_manage = client.get("/manage/seeds") + assert response_manage.status_code == 404 response_guest = client.get("/auth/guest") assert response_guest.status_code == 404 @@ -50,7 +50,8 @@ def test_dev_only_routes_allowed_in_development() -> None: assert response_scalar.status_code == 200 - # 2. 접근 허용 (존재하지 않는 메소드 요청으로 405 Method Not Allowed 반환하여 라우터 마운트 상태 확인) - - response_seeds = client.get("/seeds") - assert response_seeds.status_code == 405 + # 2. 접근 허용 + + # 존재하지 않는 메소드 요청으로 405 Method Not Allowed 반환하여 라우터 마운트 상태 확인 + response_manage = client.get("/manage/seeds") + assert response_manage.status_code == 405 From e7cc6fe2d55fc30fd2f17481bae61cf1f0b50e33 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Fri, 29 May 2026 17:32:09 +0900 Subject: [PATCH 7/8] =?UTF-8?q?build:=20=EB=8F=84=EC=BB=A4=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EC=97=90=20label=20=EB=B0=8F=20GPL=203.0=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 --- .github/workflows/deploy.yml | 7 + Dockerfile | 2 + LICENSE | 674 +++++++++++++++++++++++++++++++++++ 3 files changed, 683 insertions(+) create mode 100644 LICENSE diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b4ac581..65ba977 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -63,6 +63,13 @@ jobs: tags: ${{ steps.vars.outputs.TAGS }} flavor: | latest=${{ steps.vars.outputs.LATEST }} + labels: | + maintainer="me@zuni.kim" + org.opencontainers.image.authors="me@zuni.kim" + org.opencontainers.image.vendor="Team Overclock" + org.opencontainers.image.title="Lifemap backend API server" + org.opencontainers.image.documentation="${{ github.server_url }}/${{ github.repository }}" + org.opencontainers.image.ref.name="${{ github.ref_name }}" - name: Build and push uses: docker/build-push-action@v7 diff --git a/Dockerfile b/Dockerfile index abf6943..0b24e56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ # 공통: uv + python 3.11 FROM ghcr.io/linuxserver/baseimage-alpine:3.23 AS base +LABEL org.opencontainers.image.base.name="ghcr.io/linuxserver/baseimage-alpine:3.23" + ENV HOME="/config" ENV VIRTUAL_ENV="/venv" ENV PATH="$VIRTUAL_ENV/bin:$HOME/.local/bin:$PATH" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From 4c402ad948a0bd61675071d90ae84d62b92c3365 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Fri, 29 May 2026 17:34:51 +0900 Subject: [PATCH 8/8] refactor: config/redis tab -> 4 space --- app/config.py | 6 +++--- app/redis.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/config.py b/app/config.py index 1e643c9..4db0f49 100644 --- a/app/config.py +++ b/app/config.py @@ -3,9 +3,9 @@ def parse_comma_separated(key: str, default_value: str): - value = os.getenv(key) or default_value - items = [x.strip() for x in value.split(",") if x.strip()] - return items + value = os.getenv(key) or default_value + items = [x.strip() for x in value.split(",") if x.strip()] + return items MODE = os.getenv("MODE") or "development" diff --git a/app/redis.py b/app/redis.py index d40f043..d857871 100644 --- a/app/redis.py +++ b/app/redis.py @@ -13,14 +13,14 @@ redis_db = os.getenv("REDIS_DB") or "0" redis = Redis( - host=redis_host, - port=redis_port, - username=quote_plus(redis_user) if redis_user else None, - password=quote_plus(redis_pass) if redis_pass else None, - db=redis_db, - encoding="utf-8", - decode_responses=True, + host=redis_host, + port=redis_port, + username=quote_plus(redis_user) if redis_user else None, + password=quote_plus(redis_pass) if redis_pass else None, + db=redis_db, + encoding="utf-8", + decode_responses=True, ) def get_redis(): - yield redis + yield redis