지역 정보(맛집·관광지·축제)는 행정구역 코드(local_contents.region_code) 단위로 캐시돼 "그 시군구에 있는 것"을 줬다. 양양군 업장 옆 5km 속초 관광지는 빠지고 같은 군 반대편 30km 맛집이 붙는 구조라, 캔버스의 지역 정보 섹션은 늘 "준비 중"이었다. 업장 좌표로 TourAPI 를 직접 물어 업장 단위(place_contents)에 담고, 캔버스는 스크린샷으로 받은 형식(도보 시간 필터 + 카드 캐러셀)으로 통일했다.
432 lines
22 KiB
Python
432 lines
22 KiB
Python
import re
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import Decimal
|
|
|
|
from sqlalchemy import select
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import local_contents, place_contents, place_links, places
|
|
from common.enums import DBWRType, ErrorType, LocalContentStatus, LocalContentType, LocalSource, PlaceCategory
|
|
from common.logger import LOG
|
|
from crud.local_content_crud import LocalContentCRUD
|
|
from crud.place_content_crud import PlaceContentCRUD
|
|
from crud.place_crud import PlaceCRUD
|
|
from services.external.kakao import KakaoLocalClient, KakaoNotConfigured, KakaoRequestFailed
|
|
from services.external.naver import region_key
|
|
from router.v1.local.protocol import (
|
|
ResLocalContentList, ResLocalGuide, ResPlaceContentList, ResSyncPlace, ResWeather, WeatherData,
|
|
)
|
|
from services.external import tour_api
|
|
from services.external.open_meteo import OpenMeteoRequestFailed, fetch_current_weather
|
|
from services.external.tour_api import TourApiNotConfigured, TourApiRequestFailed
|
|
from services.place_category import guess_food_class
|
|
|
|
# collect_service.discover_tour_api 가 등록하는 업장 자신의 TourAPI 링크. (contentTypeId, contentId)
|
|
_TOUR_LINK = re.compile(r"^tour://(\d+)/(\d+)$", re.I)
|
|
# 업종별 기본 중분류 — 외부 분류도 TourAPI 링크도 없을 때의 마지막 폴백. 카페는 카페(FD05)를 뺀다.
|
|
# 음식점은 어떤 음식인지 모르면 아무것도 빼지 않는다(한식당에서 양식집을 빼면 안 된다).
|
|
_DEFAULT_FOOD_CLASS = {PlaceCategory.CAFE.value: "FD05"}
|
|
|
|
# 축제 노출 종료를 KST 그 날 자정으로 잡기 위한 시간대. 행사 날짜는 한국 날짜다.
|
|
_KST = timezone(timedelta(hours=9))
|
|
|
|
# 업장 반경(m). 2026-09-04 실측(군산 절골길 18)으로 정했다 — specs/2026-09-04-tourapi-radius-spike.md
|
|
# 관광지·축제·여행코스 10km: 5km 는 관광지 16건, 10km 는 38건. 원도심 밖 명소가 10km 에서 잡힌다.
|
|
# 맛집 5km: 10km 에서도 66건 중 59건이 5km 안이다. 밥은 동네에서 먹는다.
|
|
# 종류마다 반경이 다르다(2026-09-08) — 걸어갈 맛집과 차로 갈 관광지를 같은 반경으로 재지 않는다.
|
|
# 축제는 반경이 없다 — 업장이 속한 시도 전체를 그대로 싣는다(tour_api.fetch_festivals_in_sido).
|
|
RESTAURANT_RADIUS_M = 5_000
|
|
ATTRACTION_RADIUS_M = 10_000
|
|
|
|
|
|
def _festival_display_end(body: dict) -> datetime | None:
|
|
"""eventenddate(YYYYMMDD) → 그 날 KST 자정(다음날 00:00) UTC.
|
|
|
|
★ 이 값이 있어야 끝난 축제가 발행본에서 저절로 빠진다 —
|
|
스냅샷의 노출창 필터(snapshot._local_contents)가 display_end_at 을 본다."""
|
|
raw = str(body.get("eventenddate") or "").strip()
|
|
if len(raw) != 8 or not raw.isdigit():
|
|
return None
|
|
try:
|
|
end_day = datetime(int(raw[:4]), int(raw[4:6]), int(raw[6:]), tzinfo=_KST)
|
|
except ValueError:
|
|
return None
|
|
return (end_day + timedelta(days=1)).astimezone(timezone.utc)
|
|
|
|
|
|
def _as_float(value) -> float | None:
|
|
try:
|
|
return float(value) if value is not None else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
class LocalContentService:
|
|
def __init__(self):
|
|
self.crud = LocalContentCRUD()
|
|
self.place_crud = PlaceContentCRUD()
|
|
|
|
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_place(self, place) -> ResSyncPlace:
|
|
"""업장 좌표 반경의 맛집·관광지·축제를 TourAPI 에서 받아 place_contents 를 맞춘다.
|
|
종류마다 따로 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 2026-09-08).
|
|
여행코스(25)는 뺐다 — 반경을 넓혀도 데이터가 거의 없다(전북 전체 3건 실측).
|
|
|
|
빌드가 매번 부른다(services/build_service.run_build) — 발행본은 정적이라 이때 채운 값이 실린다.
|
|
★ 실패해도 기존 행을 지우지 않는다 — 직전 값 유지가 이 캐시의 규약이다(모델 주석).
|
|
★ 응답에 없는 행은 소프트 삭제한다 — 반경 밖으로 밀렸거나 TourAPI 가 내린 것이다.
|
|
hidden(운영자 숨김)은 재수집이 덮어쓰지 않는다(crud.upsert). 이 변경으로 기존에 저장된
|
|
여행코스 행도 다음 재수집 때 자연스레 소프트 삭제된다(더는 keep 목록에 없으므로).
|
|
★ 공공데이터는 검수 없이 그대로 싣는다(2026-09-03 결정). 틀린 항목은 운영자가 숨긴다.
|
|
"""
|
|
res = ResSyncPlace()
|
|
place_id = getattr(place, "place_id", None)
|
|
if place_id is None:
|
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
|
return res
|
|
lat, lng = _as_float(getattr(place, "latitude", None)), _as_float(getattr(place, "longitude", None))
|
|
if lat is None or lng is None:
|
|
# ★ 좌표가 비면 주소로 한 번 더 찾는다(카카오 주소검색). 주변 정보는 TourAPI 에 이 업소가
|
|
# 등록돼 있느냐와 무관하다 — 필요한 건 좌표뿐이다. 찾으면 places 에 박제해 다음부터는 안 부른다.
|
|
found = await self._geocode_and_store(place)
|
|
if found is None:
|
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
|
res.msg = "사업장 좌표가 없어 주변 정보를 받을 수 없습니다(주소로도 찾지 못함)."
|
|
return res
|
|
lat, lng = found
|
|
|
|
try:
|
|
async with tour_api.make_client() as client:
|
|
# 종류마다 반경이 달라 따로 부른다 — 맛집은 걸어갈 거리, 축제는 차로 갈 거리다.
|
|
restaurants = await tour_api.fetch_nearby(
|
|
client, lat, lng, radius_m=RESTAURANT_RADIUS_M, content_type_id="39")
|
|
# ★ 업종별 제외(2026-09-08 결정): 음식점·카페 업장은 **같은 중분류(경쟁 업소)** 를 뺀다 —
|
|
# 카페 사이트에 옆 카페를, 한식당 사이트에 옆 한식당을 추천할 이유가 없다.
|
|
# 숙박 업장은 숙박(32)을 빼야 하는데 애초에 요청하지 않으므로 여기서 할 일이 없다.
|
|
own_class = await self._own_food_class(client, place)
|
|
if own_class:
|
|
before = len(restaurants)
|
|
restaurants = [r for r in restaurants if r.get("lclsSystm2") != own_class]
|
|
LOG.i(f"[local] place={place_id} 같은 업태({own_class}) 맛집 {before - len(restaurants)}건 제외")
|
|
attractions = await tour_api.fetch_nearby(
|
|
client, lat, lng, radius_m=ATTRACTION_RADIUS_M, content_type_id="12")
|
|
|
|
# ★ 축제는 locationBasedList2 가 아니라 searchFestival2 를 쓴다(2026-09-08 교체) —
|
|
# 위치 색인을 못 믿는다(실측: 반경 20km 를 넓혀도 몇 년 전에 끝난 전시만 잡히고,
|
|
# 500m 옆 진행 예정 축제는 안 잡혔다). 시도 코드가 있어야 부를 수 있다.
|
|
sido_code = str(getattr(place, "region_code", None) or "")[:2] or None
|
|
if not sido_code:
|
|
address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "")
|
|
derived = region_key(address)
|
|
sido_code = derived[:2] if derived else None
|
|
today = datetime.now(_KST).date()
|
|
if sido_code:
|
|
festivals = await tour_api.fetch_festivals_in_sido(
|
|
client, lat, lng, sido_code=sido_code, today=today)
|
|
else:
|
|
LOG.w(f"[local] place={place_id} 시도 코드를 못 구해 축제는 건너뜀")
|
|
festivals = []
|
|
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
|
|
|
|
kept: list[dict] = restaurants + attractions + festivals
|
|
|
|
err, rows = await DB_SESSION_MNG.execute_lambda(
|
|
place_contents.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: self.place_crud.list_by_place(s, place_id),
|
|
)
|
|
if err != ErrorType.SUCCESS:
|
|
res.result.SetResult(err)
|
|
return res
|
|
existing = {(int(r.content_type), r.external_id): r for r in (rows or [])}
|
|
|
|
now = datetime.now(timezone.utc)
|
|
changed = 0
|
|
for body in kept:
|
|
key = (body["content_type"], body["contentid"])
|
|
prev = existing.get(key)
|
|
# 원문이 그대로면 쓰지 않는다 — collected_at 만 밀리면 '갱신된 척'이 된다.
|
|
if prev is not None and prev.body == body:
|
|
continue
|
|
values = {
|
|
"place_id": place_id,
|
|
"content_type": body["content_type"],
|
|
"external_id": body["contentid"],
|
|
"title": body["title"],
|
|
"body": body,
|
|
"distance_m": body["distance_m"],
|
|
"has_image": bool(body.get("firstimage")),
|
|
"display_end_at": (
|
|
_festival_display_end(body) if body["content_type"] == LocalContentType.FESTIVAL.value else None
|
|
),
|
|
"collected_at": now,
|
|
}
|
|
write_err = await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_contents.DBType()], [lambda s, v=values: self.place_crud.upsert(s, v)]
|
|
)
|
|
if write_err == ErrorType.SUCCESS:
|
|
changed += 1
|
|
|
|
keep_keys = {(b["content_type"], b["contentid"]) for b in kept}
|
|
_, removed = await DB_SESSION_MNG.execute_lambda_claim(
|
|
place_contents.DBType(), lambda s: self.place_crud.soft_delete_missing(s, place_id, keep_keys)
|
|
)
|
|
|
|
counts = {k: 0 for k in (LocalContentType.FESTIVAL.value, LocalContentType.ATTRACTION.value,
|
|
LocalContentType.RESTAURANT.value, LocalContentType.COURSE.value)}
|
|
for b in kept:
|
|
counts[b["content_type"]] += 1
|
|
res.festivals = counts[LocalContentType.FESTIVAL.value]
|
|
res.attractions = counts[LocalContentType.ATTRACTION.value]
|
|
res.restaurants = counts[LocalContentType.RESTAURANT.value]
|
|
res.courses = counts[LocalContentType.COURSE.value]
|
|
res.changed = changed > 0 or bool(removed)
|
|
LOG.i(f"[local] place={place_id} 주변정보 {len(kept)}건(갱신 {changed} · 제거 {removed or 0})")
|
|
return res
|
|
|
|
async def _own_food_class(self, client, place) -> str | None:
|
|
"""음식점·카페 업장 자신의 TourAPI 중분류(FD01~FD05). 숙박·병원은 None(제외할 게 없다).
|
|
|
|
우선순위 — 정확한 쪽부터:
|
|
1. 업장이 TourAPI 에 등록돼 있으면(place_links 의 tour:// 링크) 그 콘텐츠의 lclsSystm2.
|
|
주변 항목과 **같은 체계**라 오차가 없다. 링크는 수집(COLLECT)이 상호+좌표 검증을 거쳐 붙인다.
|
|
2. 검증 때 박제한 외부 분류 문자열(places.external_category)을 키워드로 매핑.
|
|
3. 그래도 모르면 업종 기본값 — 카페는 FD05. 음식점은 None(무엇을 빼야 할지 모른다).
|
|
실패는 전부 '제외 없음'으로 떨어진다 — 경쟁 업소가 섞이는 것이 맛집 섹션이 통째로 비는 것보다 낫다.
|
|
"""
|
|
category = getattr(place, "category", None)
|
|
if category not in (PlaceCategory.CAFE.value, PlaceCategory.RESTAURANT.value):
|
|
return None
|
|
|
|
err, links = await DB_SESSION_MNG.execute_lambda(
|
|
place_links.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: PlaceCRUD().list_links(s, place.place_id, confirmed_only=True),
|
|
)
|
|
for link in (links or []) if err == ErrorType.SUCCESS else []:
|
|
m = _TOUR_LINK.match(str(getattr(link, "url", "") or ""))
|
|
if not m:
|
|
continue
|
|
try:
|
|
code = await tour_api.fetch_content_class(client, m.group(2))
|
|
except TourApiRequestFailed as ex:
|
|
LOG.w(f"[local] 업장 TourAPI 분류 조회 실패(외부 분류로 폴백): {ex}")
|
|
code = None
|
|
if code:
|
|
return code
|
|
break
|
|
|
|
guessed = guess_food_class(getattr(place, "external_category", None))
|
|
if guessed:
|
|
return guessed
|
|
return _DEFAULT_FOOD_CLASS.get(category)
|
|
|
|
async def _geocode_and_store(self, place) -> tuple[float, float] | None:
|
|
"""주소 → 좌표(카카오). 찾으면 places.latitude/longitude 에 박제한다. 키가 없거나 실패하면 None."""
|
|
address = str(getattr(place, "road_address", None) or getattr(place, "address", None) or "").strip()
|
|
if not address:
|
|
return None
|
|
client = KakaoLocalClient()
|
|
if not client.enabled:
|
|
LOG.w("[local] 좌표 없는 사업장인데 KAKAO_REST_API_KEY 가 없어 주소로 찾지 못한다")
|
|
return None
|
|
try:
|
|
found = await client.geocode_address(address)
|
|
except (KakaoNotConfigured, KakaoRequestFailed) as ex:
|
|
LOG.w(f"[local] 주소 → 좌표 실패(계속): {ex}")
|
|
return None
|
|
finally:
|
|
await client.aclose()
|
|
if found is None:
|
|
return None
|
|
|
|
lat, lng = found
|
|
company_id = getattr(place, "company_id", None)
|
|
if company_id is not None:
|
|
data = {"latitude": Decimal(str(lat)), "longitude": Decimal(str(lng))}
|
|
err, _ = await DB_SESSION_MNG.execute_lambda_claim(
|
|
places.DBType(),
|
|
lambda s: PlaceCRUD().update_place(s, company_id, place.place_id, data),
|
|
)
|
|
if err != ErrorType.SUCCESS:
|
|
LOG.w(f"[local] 좌표 박제 실패(이번 수집엔 그대로 씀): {err.name}")
|
|
else:
|
|
place.latitude, place.longitude = data["latitude"], data["longitude"]
|
|
LOG.i(f"[local] place={place.place_id} 주소로 좌표 확보 ({lat:.5f}, {lng:.5f}) — {address[:40]}")
|
|
return lat, lng
|
|
|
|
async def _load_place(self, place_id):
|
|
"""회사 스코프 없이 사업장 1건. ★ 공개 조회(guide)와 운영자 화면이 쓴다 — 사장님 API 는 place_service 를 탄다."""
|
|
err, rows = await DB_SESSION_MNG.execute_lambda(
|
|
places.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: DB_SESSION_MNG.execute(
|
|
s, select(places).where(places.place_id == place_id, places.deleted == False).limit(1) # noqa: E712
|
|
),
|
|
)
|
|
return (rows[0] if rows else None) if err == ErrorType.SUCCESS else None
|
|
|
|
async def sync_place_by_id(self, place_id: uuid.UUID) -> ResSyncPlace:
|
|
place = await self._load_place(place_id)
|
|
if place is None:
|
|
res = ResSyncPlace()
|
|
res.result.SetResult(ErrorType.DB_EMPTY_DATA)
|
|
return res
|
|
return await self.sync_place(place)
|
|
|
|
async def list_place_contents(self, place_id: uuid.UUID) -> ResPlaceContentList:
|
|
res = ResPlaceContentList()
|
|
err, rows = await DB_SESSION_MNG.execute_lambda(
|
|
place_contents.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: self.place_crud.list_by_place(s, place_id, include_hidden=True),
|
|
)
|
|
res.result.SetResult(err)
|
|
res.contents = list(rows) if err == ErrorType.SUCCESS else []
|
|
return res
|
|
|
|
async def set_hidden(self, place_content_id: uuid.UUID, hidden: bool) -> ResPlaceContentList:
|
|
res = ResPlaceContentList()
|
|
err, count = await DB_SESSION_MNG.execute_lambda_claim(
|
|
place_contents.DBType(), lambda s: self.place_crud.set_hidden(s, place_content_id, hidden)
|
|
)
|
|
res.result.SetResult(err if count else ErrorType.DB_EMPTY_DATA)
|
|
return res
|
|
|
|
async def get_guide(self, place_id: uuid.UUID) -> ResLocalGuide:
|
|
"""에디터 캔버스용 주변 가이드(맛집·명소·축제·코스).
|
|
|
|
★ 스냅샷 필터(숨김·노출창)와 payload 변환을 **그대로 재사용**한다 —
|
|
캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다"고 읽는다.
|
|
그래서 여기서 DB 를 따로 읽지 않고 발행 파이프라인의 두 함수를 잇기만 한다.
|
|
★ 일정(itineraries)은 payload 가 만들어도 여기선 내려보내지 않는다 — 캔버스에 그릴 자리가 아직 없다.
|
|
"""
|
|
# 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다.
|
|
from services.site_payload import _local
|
|
from services.snapshot import _local_contents
|
|
|
|
res = ResLocalGuide()
|
|
place = await self._load_place(place_id)
|
|
if place is None:
|
|
res.result.SetResult(ErrorType.DB_EMPTY_DATA)
|
|
return res
|
|
|
|
# ★ 아직 한 번도 수집하지 않은 사업장은 **지금** 채운다(cache-aside — 날씨와 같은 규약).
|
|
# 빌드 때만 채우면 방금 만든 사업장은 첫 빌드 전까지 캔버스가 계속 "준비 중"이다(2026-09-07 실측).
|
|
# 행이 하나라도 있으면 부르지 않는다 — 갱신은 빌드·운영자 재수집이 맡는다.
|
|
err, rows = await DB_SESSION_MNG.execute_lambda(
|
|
place_contents.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: self.place_crud.list_by_place(s, place_id),
|
|
)
|
|
if err == ErrorType.SUCCESS and not rows:
|
|
synced = await self.sync_place(place)
|
|
if not synced.result.success:
|
|
LOG.w(f"[local] place={place_id} 첫 조회 수집 실패(빈 채로 응답): {synced.msg}")
|
|
|
|
snapshot_local = await _local_contents(place)
|
|
local, synced_at = _local(snapshot_local, _as_float(place.latitude), _as_float(place.longitude))
|
|
res.attractions = local.get("attractions") or []
|
|
res.restaurants = local.get("restaurants") or []
|
|
res.festivals = local.get("festivals") or []
|
|
res.courses = local.get("courses") or []
|
|
res.synced_at = synced_at
|
|
return res
|
|
|
|
# ── 지역 캐시(local_contents) — 운영자 수기 항목·날씨 ────────────────
|
|
|
|
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
|