Skip to content
Merged
6 changes: 2 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,9 @@ REDIS_USER=
REDIS_PASS=
REDIS_DB=0

# 게스트 계정 활성화 여부 및 계정 정보
# 게스트 계정 활성화 여부
# 계정 정보는 app/demo.py 파일 참고
GUEST_LOGIN_ENABLE=true
GUEST_USERNAME=
GUEST_EMAIL=guest@example.com
GUEST_PASSWORD=guest

# production 모드에서 로그인 쿠키 옵션 https only에 사용됨
# IS_HTTPS 우선, 없으면 PROTO이 https인 경우 https only가 적용됨
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
!.env*.example*
data/**
!data/url.txt
.initialized

# virtual environments
venv
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ COPY data/url.txt ./data/url.txt
COPY app ./app
COPY scripts ./scripts
RUN sha256sum "/defaults/requirements.txt" | sed "s|/defaults|/app|" > "$VIRTUAL_ENV/.requirements.lock"
VOLUME /app/data
WORKDIR /app
23 changes: 23 additions & 0 deletions app/core/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
from fastapi import status

from ..crud.service import get_region_by_id, get_regions, get_high_school_map
from ..schemas.service import RecommendationCreateRequest
from ..core.enums import (
AppErrorCodeEnum,
InfrastructureTypeEnum,
)
from ..core.exception import AppException

Expand Down Expand Up @@ -57,3 +59,24 @@ def verify_high_schools(redis: Redis, high_school_ids: list[int]) -> list[dict]:
},
)
return schools


def verify_recommendation_request_data(
redis: Redis,
body: RecommendationCreateRequest,
):
region = verify_region(redis, body.region_id) if body.region_id else {}

return {
"region_id": region.get("id"),
"region_name": region.get("name"),
"infrastructure_types": body.infrastructure_types,
# 학군 유형은 인프라 유형 내 초/중/고등학교 중 1개 이상 포함된 경우에만 사용
"school_district_types": body.school_district_types if any([x in (InfrastructureTypeEnum.ELEMENTARY_SCHOOL, InfrastructureTypeEnum.MIDDLE_SCHOOL, InfrastructureTypeEnum.HIGH_SCHOOL) for x in body.infrastructure_types]) else [],
# 고등학교 목록은 인프라 유형 내 고등학교가 포함된 경우에만 사용
"high_school_ids": [s["id"] for s in verify_high_schools(redis, body.high_school_ids)] if body.high_school_ids and InfrastructureTypeEnum.HIGH_SCHOOL in body.infrastructure_types else [],
"sale_price_min": body.sale_price.min if body.sale_price else None,
"sale_price_max": body.sale_price.max if body.sale_price else None,
"jeonse_price_min": body.jeonse_price.min if body.jeonse_price else None,
"jeonse_price_max": body.jeonse_price.max if body.jeonse_price else None,
}
6 changes: 4 additions & 2 deletions app/crud/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ def get_high_school_map(redis: Redis, sort: bool = True):
def create_recommendation(
db: Session,
task_id: str,
region: str,
region_name: str,
infrastructure_types: list[str] | None,
school_district_types: list[SchoolDistrictTypeEnum] | None,
high_school_ids: list[int] | None,
Expand All @@ -165,7 +165,7 @@ def create_recommendation(

rec = Recommendation(
task_id=task_id,
region=region,
region=region_name,
school_district_types=school_district_types,
high_school_ids=high_school_ids,
sale_price_min=sale_price_min,
Expand Down Expand Up @@ -207,4 +207,6 @@ def get_search_log_by_user_id(db: Session, user_id: int) -> list[SearchLog]:
SearchLog.recommendation_id == Recommendation.id,
).filter(
SearchLog.user_id == user_id,
).order_by(
SearchLog.requested_at.desc()
).all()
31 changes: 16 additions & 15 deletions app/demo.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
"""데모용 함수 모음"""

import os
from dotenv import load_dotenv
from fastapi import APIRouter, Depends, Request, status
from fastapi.exceptions import HTTPException
from sqlalchemy.orm import Session

from .config import GUEST_LOGIN_ENABLE
from .database import get_db
from .models import User
from .core import session
from .core.exception import AppException
from .crud.user import create_user
from .crud.user import get_user_by_cuid
from .schemas.error import NotFoundError
from .schemas.auth import UserCreateRequest
from .schemas.user import UserInfo


load_dotenv()

GUEST_EMAIL = os.getenv("GUEST_EMAIL") or "guest@example.com"
GUEST_USERNAME = os.getenv("GUEST_USERNAME") or None
GUEST_PASSWORD = os.getenv("GUEST_PASSWORD") or "guest"
GUEST_CUID = "guest"
GUEST_EMAIL = "guest@example.com"
GUEST_USERNAME = "guest"
GUEST_PASSWORD = "guest"


# fns
Expand All @@ -35,11 +31,16 @@ def create_guest_user(db: Session):
status_code=status.HTTP_404_NOT_FOUND,
)

return create_user(db, UserCreateRequest(
name=GUEST_USERNAME,
email=GUEST_EMAIL,
password=GUEST_PASSWORD,
))
created = False
guest = get_user_by_cuid(db, GUEST_CUID) or User()
guest.cuid = GUEST_CUID
guest.name = GUEST_USERNAME
guest.email = GUEST_EMAIL
guest.password = GUEST_PASSWORD
db.add(guest)
db.commit()
db.refresh(guest)
return guest, created

def get_guest_user(db: Session):
"""
Expand Down
33 changes: 22 additions & 11 deletions app/manage/seeds/insert.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from ...demo import create_guest_user
from ...crud.service import get_regions, get_high_schools
from ...utils import run_with_progress
from ...schemas.service import RecommendationCreateRequest
from ...models import (
User,
Recommendation,
Expand Down Expand Up @@ -65,36 +66,46 @@ def generate_seed_recommendations(
request_users = random.sample(users, k=max_request_users)

task_id = f"{SEED_TASK_ID_PREFIX}{suffix}"
has_region = random.choice([True] + [False] * 9)
has_sale_price = random.choice([True, False])
has_jeonse_price = random.choice([True, False])
selected_region = random.choice(regions)
selected_region = random.choice(regions) if has_region else None
selected_infra_types = random.sample(infra_types, k=random.randint(1, len(infra_types)))
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)
selected_school_district_types = random.sample(school_district_types, k=random.randint(0, len(school_district_types)))
selected_high_school_ids = random.sample(high_school_ids, k=random.randint(0, min(10, len(high_school_ids))))
selected_high_school_ids = random.sample(high_school_ids, k=random.randint(0, min(5, len(high_school_ids))))

random.shuffle(selected_infra_types)

rec = None
created_at, finished_at = map(datetime.fromtimestamp, random_range(start_ts, end_ts))
updated_at = finished_at + timedelta(minutes=random.randint(100, 1000)) if random.choice([True, [False] * 4]) else None
updated_at = finished_at + timedelta(minutes=random.randint(100, 1000)) if random.choice([True] + [False] * 4) else finished_at

for user in request_users:
request_data = RecommendationCreateRequest(
name=task_id,
region_id=selected_region["id"] if selected_region else None,
infrastructure_types=selected_infra_types,
high_school_ids=selected_high_school_ids,
school_district_types=selected_school_district_types,
sale_price={
"min": selected_sale_price[0],
"max": selected_sale_price[1],
},
jeonse_price={
"min": selected_jeonse_price[0],
"max": selected_jeonse_price[1],
},
)
rec = generate_recommendation(
db,
redis,
background_tasks=None,
task_id=task_id,
request_user=user,
rec_name=None if random.choice([True, False]) else f"추천 {'x' * random.randint(1, 10)}",
region=selected_region,
infrastructure_types=selected_infra_types,
school_district_types=selected_school_district_types,
high_school_ids=selected_high_school_ids,
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],
request_data=request_data,
)
is_last_viewed = random.choice([True, False])
last_viewed_at = created_at + timedelta(minutes=random.randint(1, 1000)) if is_last_viewed else None
Expand Down
7 changes: 6 additions & 1 deletion app/models/property_infrastructure.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import builtins
from typing import TYPE_CHECKING
from sqlalchemy import Column, ForeignKey, Enum, DateTime, String
from sqlalchemy import Column, ForeignKey, Enum, DateTime, String, Index
from sqlalchemy.dialects.mysql import INTEGER, DECIMAL
from sqlalchemy.orm import Mapped, relationship
from sqlalchemy.sql import func
Expand Down Expand Up @@ -40,6 +40,11 @@ class PropertyInfrastructure(Base):
infrastructure: Mapped["Infrastructure"] = relationship("Infrastructure", back_populates="property_scores")
property: Mapped["Property"] = relationship("Property", back_populates="infrastructure_scores")

__table_args__ = (
# window function PARTITION BY property_id, infrastructure_type ORDER BY score DESC 최적화
Index("idx_pi_property_type_score", "property_id", "infrastructure_type", "score"),
)

@builtins.property
def walking_duration(self):
"""distance를 도보 시간(분)으로 환산한 값 (도보 속도 60m/min 기준)"""
Expand Down
24 changes: 4 additions & 20 deletions app/routers/recommendations.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,31 +56,14 @@ def request_generate_recommendation(
응답에 포함된 `task_id`로 추천 결과를 조회할 수 있음
"""

region = verify_region(redis, body.region_id) if body.region_id else None
schools = verify_high_schools(redis, body.high_school_ids) if body.high_school_ids else []

rec_name = (body.name if body.name else "").strip() or None
infrastructure_types = body.infrastructure_types
school_district_types = body.school_district_types or []
high_school_ids = [x["id"] for x in schools]
sale_price_min = body.sale_price.min if body.sale_price else None
sale_price_max = body.sale_price.max if body.sale_price else None
jeonse_price_min = body.jeonse_price.min if body.jeonse_price else None
jeonse_price_max = body.jeonse_price.max if body.jeonse_price else None

rec = generate_recommendation(
db,
redis,
background_tasks,
request_user=user,
rec_name=rec_name,
region=region,
infrastructure_types=infrastructure_types,
school_district_types=school_district_types,
high_school_ids=high_school_ids,
sale_price_min=sale_price_min,
sale_price_max=sale_price_max,
jeonse_price_min=jeonse_price_min,
jeonse_price_max=jeonse_price_max,
request_data=body,
)

return {
Expand Down Expand Up @@ -138,6 +121,7 @@ def get_recommendation_summary(
"max": recommendation.jeonse_price_max,
},
}
request_infra_types: set[str] = set([x.type for x in request_data["infrastructure_types"]])

db_properties = db.query(Property).filter(Property.id.in_([p["id"] for p in top_properties])).all()
property_map = {prop.id: prop for prop in db_properties}
Expand All @@ -164,7 +148,7 @@ def get_recommendation_summary(
{
**InfrastructureTypeEnum[infra["type"]].meta._asdict(),
**infra,
} for infra in p["infrastructure_scores"][:2]
} for infra in p["infrastructure_scores"] if infra["type"] in request_infra_types
],
} for p in top_properties
]
Expand Down
4 changes: 2 additions & 2 deletions app/schemas/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ class RecommendationReportItemSummary(BaseModel):
address: AddressDetails = Field(description="매물 주소 정보")
sale_price: PriceRange = Field(description="매물의 매매 가격 범위")
jeonse_price: PriceRange = Field(description="매물의 전세 가격 범위")
infrastructure: list[RecommendationReportItemInfrastructureSummary] = Field(description="매물 주변 인프라 요약 정보 (최대 2개)", max_length=2)
infrastructure: list[RecommendationReportItemInfrastructureSummary] = Field(description="매물 주변 인프라 요약 정보 (요청 시 선택한 인프라 유형만 포함, 거리순)")

class RecommendationReport(RecommendationCreateResponse):
"""추천 결과"""
Expand Down Expand Up @@ -165,4 +165,4 @@ class UserRecommendations(BaseModel):
"""추천 요청 목록"""

total: int = Field(description="추천 요청 수")
items: list[UserRecommendationsItem] = Field(description="추천 요청 목록")
items: list[UserRecommendationsItem] = Field(description="추천 요청 목록, 최신순")
Loading
Loading