From 468c584d09bfa9939f67aacca69a05f604e59129 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Wed, 27 May 2026 16:19:47 +0900 Subject: [PATCH 1/9] =?UTF-8?q?fix:=20=EC=9C=A0=EC=A0=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20name=EC=9D=B4=20=EB=B9=88=20=EA=B0=92?= =?UTF-8?q?=EC=9D=B4=EB=A9=B4=20None=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/demo.py | 2 +- app/schemas/auth.py | 4 ++-- app/schemas/common.py | 8 ++++++-- app/schemas/user.py | 4 ++-- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/demo.py b/app/demo.py index 778603a..710ebc7 100644 --- a/app/demo.py +++ b/app/demo.py @@ -17,7 +17,7 @@ GUEST_LOGIN_ENABLE = (os.getenv("GUEST_LOGIN_ENABLE") or "false").lower() == "true" GUEST_EMAIL = os.getenv("GUEST_EMAIL") or "guest@example.com" -GUEST_USERNAME = os.getenv("GUEST_USERNAME") +GUEST_USERNAME = os.getenv("GUEST_USERNAME") or None GUEST_PASSWORD = os.getenv("GUEST_PASSWORD") or "guest" diff --git a/app/schemas/auth.py b/app/schemas/auth.py index fdddaf3..9c5014e 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, EmailStr -from .common import PK_STR, UserName, Password +from .common import PK_STR, OptionalStr, Password from .user import UserInfo @@ -15,7 +15,7 @@ class UserLoginRequest(BaseModel): class UserCreateRequest(BaseModel): - name: UserName | None = None + name: OptionalStr = None email: EmailStr password: Password diff --git a/app/schemas/common.py b/app/schemas/common.py index 73300af..3f3ddfe 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -8,13 +8,17 @@ def empty_to_none(v: str | None): return None return v -UserName = Annotated[str, Field(min_length=1)] -Password = Annotated[str, Field(min_length=4)] +OptionalStr = Annotated[ + Union[str, None], + BeforeValidator(empty_to_none) +] OptionalEmail = Annotated[ Union[EmailStr, None], BeforeValidator(empty_to_none) ] +Password = Annotated[str, Field(min_length=4)] + PK_AI = Annotated[int, Field(description="Primary Key (Auto Increment)", ge=1)] PK_STR = Annotated[str, Field(description="Primary Key (String)", examples=["abc123"], min_length=1)] diff --git a/app/schemas/user.py b/app/schemas/user.py index c626b8f..0f4d0e2 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -1,7 +1,7 @@ from pydantic import BaseModel, EmailStr, Field from datetime import datetime -from .common import PK_STR, Password, OptionalEmail +from .common import PK_STR, Password, OptionalStr, OptionalEmail class UserInfo(BaseModel): @@ -12,7 +12,7 @@ class UserInfo(BaseModel): class UserInfoUpdateRequest(BaseModel): - name: str | None = None + name: OptionalStr = None email: OptionalEmail = None class UserPasswordChangeRequest(BaseModel): From 78168c28aa33441f36eeed6d150cf476c90badfc Mon Sep 17 00:00:00 2001 From: kimzuni Date: Wed, 27 May 2026 19:38:58 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat!:=20=EC=9A=B4=EC=98=81=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=EC=97=90=EC=84=9C=20=EA=B4=80=EB=A6=AC/=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=EC=9A=A9=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - swagger, scalar, demo - redoc은 읽기 전용이기 때문에 유지 BREAKING_CHANGE: /docs, /scalar 및 데모 관련 기능이 production 모드에서 제거됨 --- app/config.py | 8 +++++++ app/main.py | 11 +++++---- tests/test_mode.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 app/config.py create mode 100644 tests/test_mode.py diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..745be69 --- /dev/null +++ b/app/config.py @@ -0,0 +1,8 @@ +import os + + +MODE = os.getenv("APP_ENV") or "development" +"""애플리케이션 실행 모드""" + +PROD = MODE == "production" +"""운영 모드 여부""" diff --git a/app/main.py b/app/main.py index ac786f6..3cc8e57 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.sessions import SessionMiddleware +from .config import PROD from .models import Base from .redis import redis from .database import engine, SessionLocal @@ -53,7 +54,7 @@ def create_app() -> FastAPI: """FastAPI 앱 생성 및 라우터를 등록 후 반환.""" app = FastAPI( title="fastapi", - docs_url="/docs", + docs_url=None if PROD else "/docs", redoc_url="/redoc", redirect_slashes=False, lifespan=lifespan, @@ -73,7 +74,7 @@ def create_app() -> FastAPI: secret_key=os.getenv("SECRET_KEY", "x"), session_cookie="session", same_site="lax", # CSRF 방어 - https_only=os.getenv("APP_ENV") == "production", # 운영 모드에서만 True + https_only=PROD, # 운영 모드에서만 True max_age=60 * 60 * 24, # 1일 동안 세션 유지 ) @@ -119,10 +120,12 @@ def require_auth(router: APIRouter): ) # Scalar 문서는 OpenAPI 목록에서 숨김 - app.include_router(scalar.router, prefix="/scalar", include_in_schema=False) + if not PROD: + app.include_router(scalar.router, prefix="/scalar", include_in_schema=False) app.include_router(health.router) app.include_router(public.router) - app.include_router(demo.router) + if not PROD: + app.include_router(demo.router) app.include_router(auth.router) require_auth(users.router) require_auth(recommendations.router) diff --git a/tests/test_mode.py b/tests/test_mode.py new file mode 100644 index 0000000..2b51a2f --- /dev/null +++ b/tests/test_mode.py @@ -0,0 +1,56 @@ +"""환경 모드 테스트.""" + +from unittest.mock import patch +from fastapi.testclient import TestClient + + +def test_dev_only_routes_blocked_in_production(client) -> None: + """운영 환경(PROD=True)에서 개발 전용 엔드포인트 접근이 올바르지 허용 및 차단되는지 검증.""" + with patch("app.main.PROD", True): + from app.main import create_app + test_app = create_app() + client = TestClient(test_app) + + + # 1. 접근 허용 (200 OK) + + response_redoc = client.get("/redoc") + assert response_redoc.status_code == 200 + + + # 2. 접근 차단 (404 Not Found) + + response_docs = client.get("/docs") + assert response_docs.status_code == 404 + + response_scalar = client.get("/scalar") + assert response_scalar.status_code == 404 + + response_seeds = client.get("/seeds") + assert response_seeds.status_code == 404 + + response_guest = client.get("/auth/guest") + assert response_guest.status_code == 404 + + +def test_dev_only_routes_allowed_in_development() -> None: + """개발 환경(PROD=False)에서 개발 전용 엔드포인트 접근이 허용되는지 검증.""" + with patch("app.main.PROD", False): + from app.main import create_app + test_app = create_app() + client = TestClient(test_app) + + + # 1. 접근 허용 (200 OK) + + response_docs = client.get("/docs") + assert response_docs.status_code == 200 + + response_scalar = client.get("/scalar") + assert response_scalar.status_code == 200 + + + # 2. 접근 허용 (존재하지 않는 메소드 요청으로 405 Method Not Allowed 반환하여 라우터 마운트 상태 확인) + + response_seeds = client.get("/seeds") + assert response_seeds.status_code == 405 From 47890708f5cd40bc495a08e2e9fbf0bdaa8c5724 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Thu, 28 May 2026 16:47:23 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20API=20=EC=9A=94=EC=B2=AD=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=97=90=EB=9F=AC=EB=A5=BC=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=B4=20=20allowed/t?= =?UTF-8?q?rusted=20hosts=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++++ app/config.py | 13 +++++++++++++ app/main.py | 14 +++++++++++++- tests/conftest.py | 9 +++++++++ tests/test_hosts.py | 24 ++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/test_hosts.py diff --git a/.env.example b/.env.example index c1ba8a3..167f50c 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,7 @@ GUEST_PASSWORD=guest # 세션 로그인 시 사용되는 SECRET KEY SECRET_KEY="CHANGE_ME!!!" + +# allowed/trusted host 설정 +ALLOWED_HOSTS="localhost,testserver,api.example.com" +TRUSTED_HOSTS="10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.1" diff --git a/app/config.py b/app/config.py index 745be69..69877aa 100644 --- a/app/config.py +++ b/app/config.py @@ -1,8 +1,21 @@ import os +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 + + MODE = os.getenv("APP_ENV") or "development" """애플리케이션 실행 모드""" PROD = MODE == "production" """운영 모드 여부""" + + +ALLOWED_HOSTS = parse_comma_separated("ALLOWED_HOSTS", "localhost") +"""허용된 호스트 목록 (도메인)""" + +TRUSTED_HOSTS = parse_comma_separated("TRUSTED_HOSTS", "127.0.0.1") +"""신뢰할 수 있는 프록시 IP 목록""" diff --git a/app/main.py b/app/main.py index 3cc8e57..de66497 100644 --- a/app/main.py +++ b/app/main.py @@ -4,11 +4,13 @@ from contextlib import asynccontextmanager from fastapi import FastAPI, APIRouter, Request, Depends from fastapi.responses import JSONResponse +from fastapi.middleware.trustedhost import TrustedHostMiddleware +from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from fastapi.middleware.cors import CORSMiddleware from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.sessions import SessionMiddleware -from .config import PROD +from .config import PROD, ALLOWED_HOSTS, TRUSTED_HOSTS from .models import Base from .redis import redis from .database import engine, SessionLocal @@ -60,6 +62,16 @@ def create_app() -> FastAPI: lifespan=lifespan, ) + app.add_middleware( + TrustedHostMiddleware, + allowed_hosts=ALLOWED_HOSTS, + ) + + app.add_middleware( + ProxyHeadersMiddleware, + trusted_hosts=TRUSTED_HOSTS, + ) + app.add_middleware( CORSMiddleware, allow_credentials=True, diff --git a/tests/conftest.py b/tests/conftest.py index f3591c6..aea0fc5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,15 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) + +# ============================================================================== +# 환경 변수 설정 +# ============================================================================== + +import os +os.environ["ALLOWED_HOSTS"] = "testserver" + + # ============================================================================== # 외부 인프라 (Redis, Database) 완벽 모킹 설정 # app 모듈들이 import되기 전에 가짜 모듈/클래스를 준비하여 실제 연결 시도를 완벽 차단합니다. diff --git a/tests/test_hosts.py b/tests/test_hosts.py new file mode 100644 index 0000000..7599a9d --- /dev/null +++ b/tests/test_hosts.py @@ -0,0 +1,24 @@ +"""호스트 및 프록시 허용 검증 테스트.""" + +from unittest.mock import patch +from fastapi.testclient import TestClient + + +def test_allowed_hosts_in_production() -> None: + """운영 환경에서 ALLOWED_HOSTS 정책이 올바르게 적용되는지 검증.""" + # PROD = True 이고 ALLOWED_HOSTS = ["allowed.com"] 일 때를 시뮬레이션 + with patch("app.main.PROD", True), \ + patch("app.main.ALLOWED_HOSTS", ["allowed.com"]): + + from app.main import create_app + test_app = create_app() + client = TestClient(test_app) + + # 1. 허용된 호스트로 요청 + response = client.get("/", headers={"Host": "allowed.com"}) + assert response.status_code == 200 + + # 2. 허용되지 않은 호스트로 요청 (TrustedHostMiddleware에 의해 400 Bad Request) + response_invalid = client.get("/", headers={"Host": "malicious.com"}) + assert response_invalid.status_code == 400 + assert "Invalid host header" in response_invalid.text From 24d3d8ace911b780cf9ed6c444d11881d0ce3dc4 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Wed, 27 May 2026 19:11:24 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(dev):=20seeds=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/common을 app/utils로 이동 - progress 함수에 silent 옵션 추가 - 기존 scripts/seeds 함수를 app/seeds/로 이동 - drop to seeds -> 파이썬 직접 실행 --- app/config.py | 7 + app/main.py | 2 + app/seeds/drop.py | 17 +++ app/seeds/insert.py | 225 ++++++++++++++++++++++++++++++ app/seeds/router.py | 67 +++++++++ scripts/common.py => app/utils.py | 34 +++-- scripts/csv_parser.py | 2 +- scripts/df_parser.py | 2 +- scripts/drop_seeds.py | 42 ++---- scripts/insert_data.py | 2 +- scripts/insert_seeds.py | 214 ++-------------------------- 11 files changed, 367 insertions(+), 247 deletions(-) create mode 100644 app/seeds/drop.py create mode 100644 app/seeds/insert.py create mode 100644 app/seeds/router.py rename scripts/common.py => app/utils.py (80%) diff --git a/app/config.py b/app/config.py index 69877aa..625a7df 100644 --- a/app/config.py +++ b/app/config.py @@ -19,3 +19,10 @@ def parse_comma_separated(key: str, default_value: str): TRUSTED_HOSTS = parse_comma_separated("TRUSTED_HOSTS", "127.0.0.1") """신뢰할 수 있는 프록시 IP 목록""" + + +SEED_TASK_ID_PREFIX = "random_seed_" +SEED_USERNAME_PREFIX = "seed_user_" + +DEFAULT_INSERT_SEED_RECOMMENDATIONS = 30 +DEFAULT_INSERT_SEED_USERS = 0 diff --git a/app/main.py b/app/main.py index de66497..1a422f7 100644 --- a/app/main.py +++ b/app/main.py @@ -27,6 +27,7 @@ recommendations, ) +from .seeds import router as seeds from . import demo @@ -134,6 +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(health.router) app.include_router(public.router) if not PROD: diff --git a/app/seeds/drop.py b/app/seeds/drop.py new file mode 100644 index 0000000..e2afca4 --- /dev/null +++ b/app/seeds/drop.py @@ -0,0 +1,17 @@ +from ..database import SessionLocal +from ..models import User, Recommendation +from ..config import SEED_TASK_ID_PREFIX, SEED_USERNAME_PREFIX + + +def run(): + db = SessionLocal() + try: + db.query(User).filter(User.name.like(f"{SEED_USERNAME_PREFIX}%")).delete() + db.query(Recommendation).filter(Recommendation.task_id.like(f"{SEED_TASK_ID_PREFIX}%")).delete() + except Exception as e: + db.rollback() + raise e + else: + db.commit() + finally: + db.close() diff --git a/app/seeds/insert.py b/app/seeds/insert.py new file mode 100644 index 0000000..c9760e0 --- /dev/null +++ b/app/seeds/insert.py @@ -0,0 +1,225 @@ +import random +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 ( + User, + SearchLog, + Recommendation, + Infrastructure, + Property, + PropertyInfrastructure, +) + +from ..config import ( + SEED_TASK_ID_PREFIX, + SEED_USERNAME_PREFIX, + DEFAULT_INSERT_SEED_RECOMMENDATIONS, + DEFAULT_INSERT_SEED_USERS, +) + + +start_ts = int(datetime(2022, 1, 1, 0, 0, 0).timestamp()) +end_ts = int(datetime(2026, 12, 31, 23, 59, 59).timestamp()) + + +def random_range(min_value: int, max_value: int): + results = random.sample(range(min_value, max_value + 1), k=2) + min, max = sorted(results) + return min, max + + +def get_or_create_guest_user(db: Session): + """게스트 사용자 조회 및 생성""" + return create_guest_user(db)[0] + + +def generate_seed_users( + db: Session, + suffix: int, +): + name = f"{SEED_USERNAME_PREFIX}{suffix}" + user = User( + name=name, + email=f"{name}@example.com", + password="1234", + ) + db.add(user) + return user + +def generate_seed_recommendations( + db: Session, + suffix: int, + users: list[User], + regions: list[dict], + infra_types: list[str], + infras: list[Infrastructure], + properties: list[Property], +): + n = min(5, len(users)) + max_request_users = random.choice([m for m in range(1, n + 1) for _ in range((n ** 2) - (m ** 2) + 1)]) + request_users = random.sample(users, k=max_request_users) + + selected_region = random.choice(regions) + has_sale_price = random.choice([True, False]) + has_jeonse_price = random.choice([True, False]) + selected_sale_price = random_range(0, 999999999999) if has_sale_price else (None, None) + selected_jeonse_price = random_range(0, 999999999999) if has_jeonse_price else (None, None) + created_at, finished_at = random_range(start_ts, end_ts) + created_at = datetime.fromtimestamp(created_at) + failed_at = (created_at + timedelta(minutes=random.randint(1, 3))) if random.choice([True] + [False] * 4) else None + finished_at = datetime.fromtimestamp(finished_at) if not failed_at and random.choice([True, False]) else None + updated_at = finished_at + timedelta(minutes=random.randint(100, 1000)) if finished_at and random.choice([True, [False] * 4]) else None + + selected_infra_types = random.sample(infra_types, k=random.randint(1, len(infra_types))) + random.shuffle(selected_infra_types) + + selected_properties = random.sample(properties, k=random.randint(5, 10)) + scores = [random.uniform(0.01, 100.0) for _ in selected_properties] + selected_properties = sorted(selected_properties, key=lambda x: scores[selected_properties.index(x)], reverse=True) + top_properties = [ + { + "id": prop.id, + "name": prop.name, + "score": scores[selected_properties.index(prop)], + "region": prop.region.name, + "sale_price": sum(random_range(0, 999999999999))/2 if has_sale_price else None, + "jeonse_price": sum(random_range(0, 999999999999))/2 if has_jeonse_price else None, + } + for prop in selected_properties[:5] + ] + + rec = Recommendation( + task_id=f"{SEED_TASK_ID_PREFIX}{suffix}", + infrastructure_priorities=selected_infra_types, + sale_price_min=selected_sale_price[0], + sale_price_max=selected_sale_price[1], + jeonse_price_min=selected_jeonse_price[0], + jeonse_price_max=selected_jeonse_price[1], + region=selected_region["name"], + top_properties=top_properties, + created_at=created_at, + finished_at=finished_at, + updated_at=updated_at, + failed_at=failed_at, + ) + db.add(rec) + + is_last_viewed = random.choice([True, False]) + last_viewed_at = rec.created_at + timedelta(minutes=random.randint(1, 1000)) if is_last_viewed else None + search_logs = [ + SearchLog( + task_id=rec.task_id, + user=user, + recommendation=rec, + requested_at=rec.created_at, + last_viewed_at=last_viewed_at, + name=None if random.choice([True, False]) else f"추천 {'x' * random.randint(1, 10)}", + ) + for user in request_users + ] + db.add_all(search_logs) + + for curr_property in selected_properties: + for curr_infra in selected_infra_types: + infra = random.choice(list(filter(lambda x: x.type.value == curr_infra, infras))) + create_property_infrastructure_score(db, curr_property, infra, random.uniform(0.01, 100.0), random.randint(10, 1000)) + + return rec + +def create_property_infrastructure_score( + db: Session, + property: Property, + infrastructure: Infrastructure, + score: float, + distance: int, +): + ins = db.query(PropertyInfrastructure).filter( + PropertyInfrastructure.property_id == property.id, + PropertyInfrastructure.infrastructure_id == infrastructure.id + ).first() + if not ins: + ins = PropertyInfrastructure( + property=property, + property_type=property.type, + infrastructure=infrastructure, + infrastructure_type=infrastructure.type.value, + score=score, + distance=distance, + ) + db.add(ins) + return ins + + +def _run( + db: Session, + num_recommendations: int, + num_users: int, + *, + silent: bool = False, +): + guest_user = get_or_create_guest_user(db) + regions = get_regions(redis) + infra_types = INFRASTRUCTURE_TYPE_VALUES + infras = db.query(Infrastructure).all() + properties = db.query(Property).all() + + db_users = db.query(User).filter(User.name.like(f"{SEED_USERNAME_PREFIX}%")).all() + last_user_suffix = len(db_users) + last_task_id_suffix = db.query(Recommendation).filter(Recommendation.task_id.like(f"{SEED_TASK_ID_PREFIX}%")).count() + + new_users = run_with_progress( + range(num_users), + "시드 사용자 생성 중...", + lambda idx, _: generate_seed_users( + db, + last_user_suffix + idx + 1, + ), + silent=silent, + ) + + users = [guest_user] + db_users + new_users + run_with_progress( + range(num_recommendations), + "시드 데이터 삽입 중...", + lambda idx, _: generate_seed_recommendations( + db, + last_task_id_suffix + idx + 1, + users, + regions, + infra_types, + infras, + properties, + ), + silent=silent, + ) + return num_recommendations + + +def run( + num_recommendations: int | None = None, + num_users: int | None = None, + *, + silent: bool = False, +): + num_recs = DEFAULT_INSERT_SEED_RECOMMENDATIONS if num_recommendations is None else num_recommendations + num_users = DEFAULT_INSERT_SEED_USERS if num_users is None else num_users + + db = SessionLocal() + try: + total = _run(db, num_recs, num_users, silent=silent) + except Exception as e: + db.rollback() + raise e + else: + db.commit() + finally: + db.close() + + return total diff --git a/app/seeds/router.py b/app/seeds/router.py new file mode 100644 index 0000000..8290cb9 --- /dev/null +++ b/app/seeds/router.py @@ -0,0 +1,67 @@ +"""개발용""" + +from typing import Literal +from pydantic import BaseModel, Field +from fastapi import APIRouter, BackgroundTasks, status + +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] + + +router = APIRouter(prefix="/seeds", tags=["seeds"]) + +@router.post( + "", + summary="시드 데이터 생성", + status_code=status.HTTP_202_ACCEPTED, +) +def generate_seeds( + body: GenerateSeedsRequest, + background_tasks: BackgroundTasks, +) -> SeedsResponse: + """랜덤 시드 데이터 생성""" + + # 백그라운드로 생성 및 즉시 응답 + background_tasks.add_task( + insert_seeds, + num_recommendations=body.recommendations, + num_users=body.users + ) + + return { + "requested": True, + } + +@router.delete( + "", + summary="시드 데이터 삭제", + status_code=status.HTTP_202_ACCEPTED, +) +def delete_seeds( + background_tasks: BackgroundTasks, +) -> SeedsResponse: + """ + 랜덤 시드 데이터 삭제. + 단, property-infra 사이 점수 등 데이터는 삭제되지 않음. + """ + + # 백그라운드로 생성 및 즉시 응답 + background_tasks.add_task( + drop_seeds, + ) + + return { + "requested": True, + } diff --git a/scripts/common.py b/app/utils.py similarity index 80% rename from scripts/common.py rename to app/utils.py index 0ad3f1f..2035b95 100644 --- a/scripts/common.py +++ b/app/utils.py @@ -69,6 +69,7 @@ def progress( total: int | None = None, is_final: bool | None = None, eol: Literal["\n", "\r\n"] = linesep, + silent: bool = False, ) -> None: """ 한 줄을 지우고 진행률을 출력합니다. 마지막이면 개행합니다. @@ -76,9 +77,10 @@ def progress( - `\\x1b[K`로 커서 위치부터 라인 끝까지 지워 남은 글자 문제를 해결합니다. """ - is_final = current == total if is_final is None else is_final - end = eol if is_final else "" - print(f"\r {msg} {current}{' items' if total is None else f'/{total}'}\x1b[K", end=end, flush=True) + if not silent: + is_final = current == total if is_final is None else is_final + end = eol if is_final else "" + print(f"\r {msg} {current}{' items' if total is None else f'/{total}'}\x1b[K", end=end, flush=True) def parse_int(value: str | None) -> int | None: @@ -123,6 +125,7 @@ def run_with_progress( total: int | None = None, interval: int = 1, eol: Literal["\n", "\r\n"] = linesep, + silent: bool = False, ) -> list[R]: """ 작업을 수행하면서 진행 상황을 터미널에 출력합니다. @@ -133,17 +136,22 @@ def run_with_progress( :param total: 총 반복 횟수, items이 시퀀스인 경우 자동 탐지 :param interval: 진행률을 갱신할 주기 (기본값: 1) :param eol: 작업 완료 후 개행 문자 + :param silent: True로 설정하면 진행률 출력 없이 조용히 실행 """ - has_len = hasattr(items, "__len__") - if has_len and total is None: - total = len(items) - - idx = -1 results = [] - for idx, item in enumerate(items): - if idx == 0 or idx % interval == 0: - progress(msg, idx, total=total) - results.append(callback(idx, item)) - progress(msg, idx + 1, total=total, is_final=True, eol=eol) + if silent: + for idx, item in enumerate(items): + results.append(callback(idx, item)) + else: + has_len = hasattr(items, "__len__") + if has_len and total is None: + total = len(items) + + idx = -1 + for idx, item in enumerate(items): + if idx == 0 or idx % interval == 0: + progress(msg, idx, total=total) + results.append(callback(idx, item)) + progress(msg, idx + 1, total=total, is_final=True, eol=eol) return results diff --git a/scripts/csv_parser.py b/scripts/csv_parser.py index 77b6673..ad390fe 100644 --- a/scripts/csv_parser.py +++ b/scripts/csv_parser.py @@ -1,7 +1,7 @@ import csv from typing import TextIO, Generator -from scripts.common import sniff_encoding +from app.utils import sniff_encoding def iter_from_header(f: TextIO) -> Generator[str, None, None]: diff --git a/scripts/df_parser.py b/scripts/df_parser.py index 7ee2073..881df82 100644 --- a/scripts/df_parser.py +++ b/scripts/df_parser.py @@ -1,7 +1,7 @@ import pandas as pd import geopandas as gpd -from scripts.common import sniff_encoding +from app.utils import sniff_encoding def read_pandas( diff --git a/scripts/drop_seeds.py b/scripts/drop_seeds.py index dd9c1db..c76a928 100755 --- a/scripts/drop_seeds.py +++ b/scripts/drop_seeds.py @@ -9,30 +9,7 @@ def usage(): print("사용법: python drop-seeds.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 SessionLocal - from app.models import User, Recommendation - from scripts.common import SEED_TASK_ID_PREFIX, SEED_USERNAME_PREFIX - - db = SessionLocal() - db.query(User).filter(User.name.like(f"{SEED_USERNAME_PREFIX}%")).delete() - db.query(Recommendation).filter(Recommendation.task_id.like(f"{SEED_TASK_ID_PREFIX}%")).delete() - db.commit() - db.close() - print("모든 테이블이 삭제되었습니다.") - else: - print("작업이 취소되었습니다.") + print(" - 모든 시드 데이터를 삭제합니다.") if __name__ == "__main__": @@ -42,7 +19,18 @@ 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) + 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) + + from app.seeds.drop import run as drop_seeds + try: + drop_seeds() + except Exception as e: + print("시드 데이터 삭제 중 오류가 발생했습니다.") + raise e else: - main() + print("모든 시드 데이터가 삭제되었습니다.") diff --git a/scripts/insert_data.py b/scripts/insert_data.py index a03686f..22a3f62 100644 --- a/scripts/insert_data.py +++ b/scripts/insert_data.py @@ -18,8 +18,8 @@ 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.common 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 diff --git a/scripts/insert_seeds.py b/scripts/insert_seeds.py index 66a06ce..9901116 100755 --- a/scripts/insert_seeds.py +++ b/scripts/insert_seeds.py @@ -2,207 +2,20 @@ import os import sys -import random -from sqlalchemy.orm import Session -from datetime import datetime, timedelta # backend 디렉터리를 Python 경로에 추가 sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from app.core.enums import INFRASTRUCTURE_TYPE_VALUES -from app.redis import redis -from app.demo import create_guest_user -from app.crud.service import get_regions_by_depth -from app.models import ( - User, - SearchLog, - Recommendation, - Infrastructure, - Property, - PropertyInfrastructure, -) -from scripts.common import SEED_TASK_ID_PREFIX, SEED_USERNAME_PREFIX, run_with_progress - - -DEFAULT_NUM_RECOMMENDATIONS = 30 -DEFAULT_NUM_USERS = 0 - -start_ts = int(datetime(2022, 1, 1, 0, 0, 0).timestamp()) -end_ts = int(datetime(2026, 12, 31, 23, 59, 59).timestamp()) +from app.config import DEFAULT_INSERT_SEED_RECOMMENDATIONS, DEFAULT_INSERT_SEED_USERS def usage(): print(f"사용법: python insert_seeds.py [생성할 추천 수] [생성할 사용자 수]") print(f" - 데이터베이스 내 데이터를 기반으로 랜덤 추천 데이터를 생성하여 삽입합니다.") - print(f" - 생성할 추천 수 기본 값: {DEFAULT_NUM_RECOMMENDATIONS}") - print(f" - 생성할 사용자 수 기본 값: {DEFAULT_NUM_USERS}") - - -def random_range(min_value: int, max_value: int): - results = random.sample(range(min_value, max_value + 1), k=2) - min, max = sorted(results) - return min, max - - -def get_or_create_guest_user(db: Session): - """게스트 사용자 조회 및 생성""" - return create_guest_user(db)[0] - - -def generate_seed_users( - db: Session, - suffix: int, -): - name = f"{SEED_USERNAME_PREFIX}{suffix}" - user = User( - name=name, - email=f"{name}@example.com", - password="1234", - ) - db.add(user) - return user - -def generate_seed_recommendations( - db: Session, - suffix: int, - users: list[User], - regions: list[dict], - infra_types: list[str], - infras: list[Infrastructure], - properties: list[Property], -): - n = min(5, len(users)) - max_request_users = random.choice([m for m in range(1, n + 1) for _ in range((n ** 2) - (m ** 2) + 1)]) - request_users = random.sample(users, k=max_request_users) - - selected_region = random.choice(regions) - has_sale_price = random.choice([True, False]) - has_jeonse_price = random.choice([True, False]) - selected_sale_price = random_range(0, 999999999999) if has_sale_price else (None, None) - selected_jeonse_price = random_range(0, 999999999999) if has_jeonse_price else (None, None) - created_at, finished_at = random_range(start_ts, end_ts) - created_at = datetime.fromtimestamp(created_at) - failed_at = (created_at + timedelta(minutes=random.randint(1, 3))) if random.choice([True] + [False] * 4) else None - finished_at = datetime.fromtimestamp(finished_at) if not failed_at and random.choice([True, False]) else None - updated_at = finished_at + timedelta(minutes=random.randint(100, 1000)) if finished_at and random.choice([True, [False] * 4]) else None - - selected_infra_types = random.sample(infra_types, k=random.randint(1, len(infra_types))) - random.shuffle(selected_infra_types) - - selected_properties = random.sample(properties, k=random.randint(5, 10)) - scores = [random.uniform(0.01, 100.0) for _ in selected_properties] - selected_properties = sorted(selected_properties, key=lambda x: scores[selected_properties.index(x)], reverse=True) - top_properties = [ - { - "id": prop.id, - "name": prop.name, - "score": scores[selected_properties.index(prop)], - "region": prop.region.name, - "sale_price": sum(random_range(0, 999999999999))/2 if has_sale_price else None, - "jeonse_price": sum(random_range(0, 999999999999))/2 if has_jeonse_price else None, - } - for prop in selected_properties[:5] - ] - - rec = Recommendation( - task_id=f"{SEED_TASK_ID_PREFIX}{suffix}", - infrastructure_priorities=selected_infra_types, - sale_price_min=selected_sale_price[0], - sale_price_max=selected_sale_price[1], - jeonse_price_min=selected_jeonse_price[0], - jeonse_price_max=selected_jeonse_price[1], - region=selected_region["name"], - top_properties=top_properties, - created_at=created_at, - finished_at=finished_at, - updated_at=updated_at, - failed_at=failed_at, - ) - db.add(rec) - - is_last_viewed = random.choice([True, False]) - last_viewed_at = rec.created_at + timedelta(minutes=random.randint(1, 1000)) if is_last_viewed else None - search_logs = [ - SearchLog( - task_id=rec.task_id, - user=user, - recommendation=rec, - requested_at=rec.created_at, - last_viewed_at=last_viewed_at, - name=None if random.choice([True, False]) else f"추천 {'x' * random.randint(1, 10)}", - ) - for user in request_users - ] - db.add_all(search_logs) - - for curr_property in selected_properties: - for curr_infra in selected_infra_types: - infra = random.choice(list(filter(lambda x: x.type.value == curr_infra, infras))) - create_property_infrastructure_score(db, curr_property, infra, random.uniform(0.01, 100.0), random.randint(10, 1000)) - - return rec - -def create_property_infrastructure_score( - db: Session, - property: Property, - infrastructure: Infrastructure, - score: float, - distance: int, -): - ins = db.query(PropertyInfrastructure).filter( - PropertyInfrastructure.property_id == property.id, - PropertyInfrastructure.infrastructure_id == infrastructure.id - ).first() - if not ins: - ins = PropertyInfrastructure( - property=property, - property_type=property.type, - infrastructure=infrastructure, - infrastructure_type=infrastructure.type.value, - score=score, - distance=distance, - ) - db.add(ins) - return ins - - -def main(db: Session, num_recommendations: int = DEFAULT_NUM_RECOMMENDATIONS, num_users: int = DEFAULT_NUM_USERS): - guest_user = get_or_create_guest_user(db) - regions = get_regions_by_depth(redis, 2) - infra_types = INFRASTRUCTURE_TYPE_VALUES - infras = db.query(Infrastructure).all() - properties = db.query(Property).all() - - db_users = db.query(User).filter(User.name.like(f"{SEED_USERNAME_PREFIX}%")).all() - last_user_suffix = len(db_users) - last_task_id_suffix = db.query(Recommendation).filter(Recommendation.task_id.like(f"{SEED_TASK_ID_PREFIX}%")).count() - - new_users = run_with_progress( - range(num_users), - "시드 사용자 생성 중...", - lambda idx, _: generate_seed_users( - db, - last_user_suffix + idx + 1, - ), - ) - - users = [guest_user] + db_users + new_users - run_with_progress( - range(num_recommendations), - "시드 데이터 삽입 중...", - lambda idx, _: generate_seed_recommendations( - db, - last_task_id_suffix + idx + 1, - users, - regions, - infra_types, - infras, - properties, - ), - ) - - return num_recommendations + print(f" - 생성할 추천 수 기본 값: {DEFAULT_INSERT_SEED_RECOMMENDATIONS}") + print(f" - 생성할 사용자 수 기본 값: {DEFAULT_INSERT_SEED_USERS}") + print(f" - 유저를 먼저 생성한 후 추천 데이터를 생성합니다.") if __name__ == "__main__": @@ -213,20 +26,13 @@ def main(db: Session, num_recommendations: int = DEFAULT_NUM_RECOMMENDATIONS, nu usage() sys.exit(1) - - total = 0 - from app.database import SessionLocal - db = SessionLocal() + from app.seeds.insert import run as insert_seeds try: - num_recs = int(sys.argv[1]) if len(sys.argv) >= 2 else DEFAULT_NUM_RECOMMENDATIONS - num_users = int(sys.argv[2]) if len(sys.argv) >= 3 else DEFAULT_NUM_USERS - total = main(db, num_recs, num_users) + num_recs = int(sys.argv[1]) if len(sys.argv) >= 2 else DEFAULT_INSERT_SEED_RECOMMENDATIONS + num_users = int(sys.argv[2]) if len(sys.argv) >= 3 else DEFAULT_INSERT_SEED_USERS + insert_seeds(num_recs, num_users) except Exception as e: - db.rollback() + print("시드 데이터 삽입 중 오류가 발생했습니다.") raise e else: - # db.rollback() - db.commit() - print(f"{total}개의 시드 데이터가 생성되었습니다.") - finally: - db.close() + print("모든 시드 데이터가 삽입되었습니다.") From bac8bf1dedeedde815359f827551afacce2fedd9 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Thu, 28 May 2026 17:55:55 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20progress=EC=97=90=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20scripts=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/utils.py | 11 ++++++++++- scripts/insert_data.py | 15 +++++++-------- scripts/insert_seeds.py | 10 +++++++--- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/app/utils.py b/app/utils.py index 2035b95..bd307bd 100644 --- a/app/utils.py +++ b/app/utils.py @@ -70,6 +70,9 @@ def progress( is_final: bool | None = None, eol: Literal["\n", "\r\n"] = linesep, silent: bool = False, + unit: str = "items", + unit_always: bool = False, + percentage: bool = True, ) -> None: """ 한 줄을 지우고 진행률을 출력합니다. 마지막이면 개행합니다. @@ -80,7 +83,13 @@ def progress( if not silent: is_final = current == total if is_final is None else is_final end = eol if is_final else "" - print(f"\r {msg} {current}{' items' if total is None else f'/{total}'}\x1b[K", end=end, flush=True) + + 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 "" + + print(f"\r {prefix} {status} {suffix}\x1b[K", end=end, flush=True) def parse_int(value: str | None) -> int | None: diff --git a/scripts/insert_data.py b/scripts/insert_data.py index 22a3f62..bcf4562 100644 --- a/scripts/insert_data.py +++ b/scripts/insert_data.py @@ -1124,7 +1124,7 @@ def run(idx: int, ins): run_with_progress(items, f"[{model.__tablename__}]", run, interval=PROGRESS_PRINT) -def main(directory_path: str): +def main(directory_path: str | None = None): """ 디렉토리 내의 CSV 파일을 읽어 데이터를 데이터베이스에 삽입하는 메인 함수입니다. @@ -1134,17 +1134,17 @@ def main(directory_path: str): None """ + folder = Path(directory_path) if directory_path else Path(__file__).resolve().parent.parent / "data" + Base.metadata.create_all(bind=engine) db = SessionLocal() try: - path = Path(directory_path) - - if not path.is_dir(): - print(f"폴더가 아닙니다: {directory_path}") + if not folder.is_dir(): + print(f"폴더가 아닙니다: {str(folder)}") return files = list(get_files( - directory_path, + str(folder), extensions=[".csv", ".txt", ".shp"], recursive=True, )) @@ -1170,5 +1170,4 @@ def main(directory_path: str): usage() sys.exit(1) else: - data_dir = Path(__file__).resolve().parent.parent / "data" - main(str(data_dir)) + main() diff --git a/scripts/insert_seeds.py b/scripts/insert_seeds.py index 9901116..c763c7a 100755 --- a/scripts/insert_seeds.py +++ b/scripts/insert_seeds.py @@ -26,11 +26,15 @@ def usage(): usage() sys.exit(1) + try: + args = [int(arg) for arg in sys.argv[1:]] + except: + usage() + sys.exit(1) + from app.seeds.insert import run as insert_seeds try: - num_recs = int(sys.argv[1]) if len(sys.argv) >= 2 else DEFAULT_INSERT_SEED_RECOMMENDATIONS - num_users = int(sys.argv[2]) if len(sys.argv) >= 3 else DEFAULT_INSERT_SEED_USERS - insert_seeds(num_recs, num_users) + insert_seeds(*args) except Exception as e: print("시드 데이터 삽입 중 오류가 발생했습니다.") raise e From ead9d19db96e8be726852a6842b9cc022f72e058 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Thu, 28 May 2026 17:55:36 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20drop=20to=20seeds=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8,=20bash=20script=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20python=20script=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/drop-to-seeds.sh | 19 ---------------- scripts/drop_to_seeds.py | 47 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 19 deletions(-) delete mode 100755 scripts/drop-to-seeds.sh create mode 100644 scripts/drop_to_seeds.py diff --git a/scripts/drop-to-seeds.sh b/scripts/drop-to-seeds.sh deleted file mode 100755 index 788ff40..0000000 --- a/scripts/drop-to-seeds.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/with-contenv bash - -source ./functions.sh - -set -e - -WARNING "!!! 3초 후 모든 데이터를 삭제하고 다시 생성합니다 !!!" -INFO "중단하려면 Ctrl+C를 누르세요." - -sleep 3 - -INFO "작업을 시작합니다." -echo -INFO "--- drop-tables ---" -drop-tables --yes -INFO "--- insert-data ---" -insert-data -INFO "--- insert-seeds ---" -insert-seeds diff --git a/scripts/drop_to_seeds.py b/scripts/drop_to_seeds.py new file mode 100644 index 0000000..e185a7d --- /dev/null +++ b/scripts/drop_to_seeds.py @@ -0,0 +1,47 @@ +import os +import sys +import time + +# backend 디렉터리를 Python 경로에 추가 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + + +def usage(): + print("사용법: python drop_to_seeds.py [생성할 추천 수] [생성할 사용자 수]") + print(" - drop_tables.py, insert_data.py, insert_seeds.py를 순차적으로 실행합니다.") + + +if __name__ == "__main__": + if "-h" in sys.argv or "--help" in sys.argv: + usage() + sys.exit(0) + elif len(sys.argv) > 3: + usage() + sys.exit(1) + + print("!!! 3초 후 모든 데이터를 삭제하고 다시 생성합니다 !!!") + print("중단하려면 Ctrl+C를 누르세요.") + try: + time.sleep(3) + except KeyboardInterrupt: + print("\n작업이 중단되었습니다.") + sys.exit(0) + + try: + args = [int(arg) for arg in sys.argv[1:]] + except: + usage() + sys.exit(1) + + from scripts import drop_tables, insert_data + from app.seeds.insert import run as insert_seeds + try: + drop_tables.main(force=True) + insert_data.main() + insert_seeds(*args) + except Exception as e: + print("작업 중 오류가 발생했습니다.") + raise e + else: + print("작업이 완료되었습니다.") From 3146242c12c22ca78ce1f116947178934fd43483 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Thu, 28 May 2026 02:55:58 +0900 Subject: [PATCH 7/9] =?UTF-8?q?chore:=20scripts/download=5Fdata.py=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - url.txt 파일 기반 구글 드라이브 공유폴더 파일 버전 탐지 및 일괄 다온로드 --- app/utils.py | 20 ++++++ requirements-dev.txt | 1 + scripts/download_data.py | 150 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 scripts/download_data.py diff --git a/app/utils.py b/app/utils.py index bd307bd..c4e6161 100644 --- a/app/utils.py +++ b/app/utils.py @@ -62,6 +62,26 @@ def get_files( yield from get_files(str(path), extensions=extensions, recursive=recursive) +def read_file(file_path: str, *, strip: bool = False) -> str | None: + try: + enc = sniff_encoding(file_path) + with open(file_path, encoding=enc) as f: + content = f.read() + if strip: content = content.strip() + return content + except: + pass + +def write_file(file_path: str, content: str, *, encoding: str | None = None, append: bool = False) -> None: + if not encoding and Path(file_path).exists(): + encoding = sniff_encoding(file_path) + elif not encoding: + encoding = "utf-8" + + with open(file_path, mode="a" if append else "w", encoding=encoding) as f: + f.write(content) + + def progress( msg: str, current: int, diff --git a/requirements-dev.txt b/requirements-dev.txt index 1a0a5b7..49cf2a6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,3 +3,4 @@ pytest pytest-cov geopandas +gdown diff --git a/scripts/download_data.py b/scripts/download_data.py new file mode 100644 index 0000000..3cce71a --- /dev/null +++ b/scripts/download_data.py @@ -0,0 +1,150 @@ +import os +import sys +import re +import shutil +import gdown +from time import time +from pathlib import Path + +# 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") + print(" - 현재 다운로드된 데이터의 버전을 확인하고 구글 드라이브에서 데이터를 다운로드합니다.") + 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: + usage() + sys.exit(0) + elif len(sys.argv) != 1: + usage() + sys.exit(1) + + run() From dca50c66f1fa1024d62a316edeaaf58dcbb85b21 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Thu, 28 May 2026 17:20:51 +0900 Subject: [PATCH 8/9] =?UTF-8?q?docs:=20readme=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0a26c59..5d0c091 100644 --- a/README.md +++ b/README.md @@ -50,14 +50,14 @@ fastapi dev app # 또는 python3 -m fastapi dev app ``` -## Docker +## scripts > [!NOTE] > 도커 컨테이너 실행 방법은 > [team-overclock/monorepo#docker](https://github.com/team-overclock/monorepo#docker) > 참고 -내장 명령어 목록 및 설명 +scripts 폴더 내 실행 가능한 스크립트 파일 목록 및 설명 ### create user @@ -68,14 +68,19 @@ fastapi dev app docker exec -it create-user [name] ``` -### insert data +### download data -> [!IMPORTANT] -> 도커 이미지 경량화를 위해 제거되었으며, -> 파이썬으로 직접 파일을 실행할 수 있습니다. +구글 드라이브 [공유폴더](https://drive.google.com/drive/folders/1w9TUhiq_XT-wl8xMRIegWvoWW65IyxC0?usp=sharing) +내 데이터 다운로드 + +```shell +python3 scripts/download_data.py +``` + +### insert data > [!IMPORTANT] -> [url.txt](data/url.txt) 파일 내 구글 드라이브에서 모든 파일 다운로드 +> `download data`를 먼저 실행하거나 직접 다운로드 필요 `data` 폴더 내 csv 파일 내 데이터를 데이터베이스에 삽입 (테이블 자동 생성) @@ -87,7 +92,7 @@ python3 scripts/insert_data.py ### insert seeds > [!IMPORTANT] -> `insert-data`가 먼저 수행되어야 함 +> `insert data`가 먼저 수행되어야 함 > [!NOTE] > 게스트 유저는 항상 생성되며, @@ -132,10 +137,11 @@ docker exec -it drop-tables [-y|--yes] ### drop to seeds -`drop-tables`, `insert-data`, `insert-seeds`를 연속으로 실행하는 스크립트 +`drop tables`, `insert data`, `insert seeds`를 연속으로 실행하는 스크립트 ```shell -docker exec -it drop-to-seeds +python3 scripts/drop_to_seeds.py [num_of_recs] [num_of_users] +# docker exec -it drop-to-seeds # deprecated ``` ## 테스트 From 70bbc9b6b8efe4fb43cd5884a24f7c7679294392 Mon Sep 17 00:00:00 2001 From: kimzuni Date: Thu, 28 May 2026 17:05:23 +0900 Subject: [PATCH 9/9] refactor: set return type and remove unused import --- app/crud/service.py | 4 ++-- tests/test_auth.py | 1 - tests/test_docs.py | 11 ++--------- tests/test_health.py | 9 +-------- tests/test_recommendations.py | 1 - tests/test_users.py | 1 - 6 files changed, 5 insertions(+), 22 deletions(-) diff --git a/app/crud/service.py b/app/crud/service.py index e588235..753fe59 100644 --- a/app/crud/service.py +++ b/app/crud/service.py @@ -50,7 +50,7 @@ def fetch_regions(redis: Redis, hkey: str, sort: bool = True, **extra_dict): if sort: result.sort(key=lambda x: x["id"]) return result -def get_regions(redis: Redis, include_depth: bool = True): +def get_regions(redis: Redis, include_depth: bool = True) -> list[dict]: """모든 동네 목록을 반환하는 함수""" if not include_depth: return fetch_regions(redis, REGIONS_ALL_KEY) @@ -64,7 +64,7 @@ def get_regions(redis: Redis, include_depth: bool = True): items.sort(key=lambda x: x["id"]) return items -def get_region_by_id(redis: Redis, region_id: int): +def get_region_by_id(redis: Redis, region_id: int) -> dict | None: """주어진 id에 해당하는 동네를 반환하는 함수""" value = redis.hget(REGIONS_ALL_KEY, region_id) if value is None: diff --git a/tests/test_auth.py b/tests/test_auth.py index d3178bd..21c46f6 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,5 @@ """인증(auth) 라우터 엔드포인트 테스트.""" -import pytest from unittest.mock import MagicMock, patch from app.models import User diff --git a/tests/test_docs.py b/tests/test_docs.py index a6e3c07..a3d0cef 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,14 +1,7 @@ """문서 엔드포인트 테스트.""" -from fastapi.testclient import TestClient -from app.main import app - - -client = TestClient(app) - - -def test_builtin_docs() -> None: +def test_builtin_docs(client) -> None: """Swagger 문서 엔드포인트 응답 확인.""" response = client.get("/docs") @@ -16,7 +9,7 @@ def test_builtin_docs() -> None: assert "Swagger UI" in response.text -def test_scalar_docs() -> None: +def test_scalar_docs(client) -> None: """Scalar 문서 엔드포인트 응답 확인.""" response = client.get("/scalar") diff --git a/tests/test_health.py b/tests/test_health.py index 5cf1d94..3e3e833 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -1,14 +1,7 @@ """헬스 체크 엔드포인트 테스트.""" -from fastapi.testclient import TestClient -from app.main import app - - -client = TestClient(app) - - -def test_health_root() -> None: +def test_health_root(client) -> None: """루트 엔드포인트가 200과 기대 JSON을 반환하는지 확인.""" response = client.get("/") diff --git a/tests/test_recommendations.py b/tests/test_recommendations.py index 48a3f3b..430c738 100644 --- a/tests/test_recommendations.py +++ b/tests/test_recommendations.py @@ -1,6 +1,5 @@ """추천(recommendations) 라우터 엔드포인트 테스트.""" -import pytest from unittest.mock import MagicMock, patch from app.models import User, Recommendation, SearchLog diff --git a/tests/test_users.py b/tests/test_users.py index 27f3073..dd0dbd7 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -1,6 +1,5 @@ """사용자(users) 라우터 엔드포인트 테스트.""" -import pytest from unittest.mock import MagicMock, patch from app.models import User