from datetime import date, datetime, timedelta, timezone from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import local_contents from common.enums import DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource from crud.local_content_crud import LocalContentCRUD from router.v1.local.protocol import ResLocalContentList, ResSyncFestivals, ResWeather, WeatherData from services.external.open_meteo import OpenMeteoRequestFailed, fetch_current_weather from services.external.tour_api import TourApiNotConfigured, TourApiRequestFailed, search_festivals class LocalContentService: def __init__(self): self.crud = LocalContentCRUD() async def list(self, status=None, region_code=None): res = ResLocalContentList() err, rows = await DB_SESSION_MNG.execute_lambda( local_contents.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list(s, status, region_code) ) res.result.SetResult(err) res.contents = list(rows) if err == ErrorType.SUCCESS else [] return res async def sync_festivals(self, req): res = ResSyncFestivals() try: start = datetime.strptime(req.start_date, "%Y%m%d").date() if req.start_date else date.today() items = await search_festivals(start_date=start, area_code=req.area_code) except TourApiNotConfigured: res.result.SetResult(ErrorType.LOCAL_NOT_CONFIGURED) res.msg = "TOUR_API_KEY를 먼저 설정해주세요." return res except TourApiRequestFailed as ex: res.result.SetResult(ErrorType.LOCAL_FETCH_FAILED) res.msg = str(ex) return res collected = skipped = 0 for item in items: external_id = str(item.get("contentid") or "") if not external_id: skipped += 1 continue _, existing = await DB_SESSION_MNG.execute_lambda( local_contents.DBType(), DBWRType.DB_READ.value, lambda s, eid=external_id: self.crud.get_by_external_id(s, req.region_code, eid), ) if existing: skipped += 1 continue row = local_contents( region_code=req.region_code, content_type=LocalContentType.FESTIVAL.value, source=LocalSource.TOUR_API.value, external_id=external_id, title=item.get("title"), body=item, status=LocalContentStatus.REVIEW.value, collected_at=datetime.now(timezone.utc), ) err = await DB_SESSION_MNG.execute_lambda_run([row.DBType()], [lambda s, r=row: self.crud.insert(s, r)]) collected += int(err == ErrorType.SUCCESS) skipped += int(err != ErrorType.SUCCESS) res.collected, res.skipped = collected, skipped return res async def publish(self, ids, user_id): res = ResLocalContentList() err, _ = await DB_SESSION_MNG.execute_lambda_claim( local_contents.DBType(), lambda s: self.crud.publish(s, ids, user_id) ) res.result.SetResult(err) return res async def update(self, content_id, req): res = ResLocalContentList() data = req.model_dump(exclude_none=True) start, end = data.get("display_start_at"), data.get("display_end_at") if start and end and start >= end: res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) res.msg = "노출 종료는 시작보다 뒤여야 합니다." return res err, count = await DB_SESSION_MNG.execute_lambda_claim( local_contents.DBType(), lambda s: self.crud.update(s, content_id, data) ) res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA) return res async def end(self, content_id): res = ResLocalContentList() err, count = await DB_SESSION_MNG.execute_lambda_claim( local_contents.DBType(), lambda s: self.crud.end(s, content_id) ) res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA) return res async def get_weather(self, region_code: str, latitude: float, longitude: float): """Cache-aside weather lookup; stale data survives upstream failures.""" res = ResWeather() err, cached = await DB_SESSION_MNG.execute_lambda( local_contents.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.get_weather(s, region_code), ) if err != ErrorType.SUCCESS: res.result.SetResult(err) return res now = datetime.now(timezone.utc) if cached and cached.expires_at and cached.expires_at > now: res.weather = WeatherData.model_validate(cached.body) res.cached = True return res try: weather = await fetch_current_weather(latitude, longitude) except OpenMeteoRequestFailed as ex: if cached: res.weather = WeatherData.model_validate(cached.body) res.cached = True res.stale = True res.msg = "최신 날씨를 가져오지 못해 직전 정보를 반환합니다." return res res.result.SetResult(ErrorType.LOCAL_FETCH_FAILED) res.msg = str(ex) return res expires_at = now + timedelta(hours=1) values = { "region_code": region_code, "content_type": LocalContentType.WEATHER.value, "source": LocalSource.OPEN_METEO.value, "external_id": None, "title": None, "body": weather, "status": LocalContentStatus.PUBLISHED.value, "collected_at": now, "expires_at": expires_at, "updated_at": now, } write_err = await DB_SESSION_MNG.execute_lambda_run( [local_contents.DBType()], [lambda s: self.crud.upsert_weather(s, values)] ) if write_err != ErrorType.SUCCESS: res.result.SetResult(write_err) return res res.weather = WeatherData.model_validate(weather) return res