from sqlalchemy import and_, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import area_contents from common.enums import ErrorType, LocalContentType, STORY_KINDS from common.utils.gtime import GTime class LocalContentCRUD: async def list(self, db, status: int | None = None, region_code: str | None = None): """축제·관광지·맛집·날씨 전 종류. ★ 예전엔 FESTIVAL 로 고정돼 있어 sync_region 이 받은 관광지·맛집이 이 목록에 영영 안 보였다(admin 화면이 축제만 검수/발행하는 줄 알게 됨).""" conds = [area_contents.deleted == False] # noqa: E712 if status is not None: conds.append(area_contents.status == status) if region_code: conds.append(area_contents.region_code == region_code) return await DB_SESSION_MNG.execute( db, select(area_contents).where(and_(*conds)).order_by(area_contents.collected_at.desc()) ) async def insert(self, db, row): return await DB_SESSION_MNG.insert(db, row) async def publish(self, db, ids: list, user_id): return await DB_SESSION_MNG.add_with_rowcount( db, update(area_contents).where( area_contents.local_content_id.in_(ids), area_contents.deleted == False # noqa: E712 ).values(status=2, published_at=GTime.UTC(), published_by=user_id, updated_at=GTime.UTC()), ) async def update(self, db, content_id, data: dict): return await DB_SESSION_MNG.add_with_rowcount( db, update(area_contents).where( area_contents.local_content_id == content_id, area_contents.deleted == False # noqa: E712 ).values(**data, updated_at=GTime.UTC()), ) async def end(self, db, content_id): return await self.update(db, content_id, {"status": 3}) async def upsert_kind(self, db, values: dict): """지역 이야기 한 종류(가요·인물·…)의 삽입/갱신. ★ `uq_local_contents_kind`(region_code, kind — kind IS NOT NULL)에 태운다. 이 표의 규약은 **한 지역에 종류당 한 벌**이다(migrations/0004). 항목마다 한 행이 아니라 `body.items` 에 통째로 담긴다 — 사장님이 붙여넣는 같은 종류의 JSON 과 모양을 맞추기 위해서다. 다시 생성하면 그 한 행을 덮어쓴다. ★ external_id 는 넣지 않는다. 넣으면 `uq_local_contents_external`(source, external_id)에도 걸려, 종류가 다른 두 행이 같은 키로 충돌한다.""" stmt = pg_insert(area_contents).values(**values) stmt = stmt.on_conflict_do_update( index_elements=[area_contents.region_code, area_contents.kind], # ★ 조건은 인덱스와 **글자 그대로** 같아야 한다. 포스트그레스는 ON CONFLICT 술어가 # 인덱스 술어를 함의하는지 보고, 아니면 "no unique or exclusion constraint matching" # 으로 거절한다 — 컬럼도 표도 멀쩡해서 눈으로는 원인이 안 보이는 종류다. index_where=and_( area_contents.deleted == False, # noqa: E712 area_contents.kind.isnot(None), area_contents.external_id.is_(None), ), set_={ "title": stmt.excluded.title, "body": stmt.excluded.body, "content_type": stmt.excluded.content_type, "source": stmt.excluded.source, "status": stmt.excluded.status, "collected_at": stmt.excluded.collected_at, "published_at": stmt.excluded.published_at, "updated_at": GTime.UTC(), }, ) return await DB_SESSION_MNG.add(db, stmt) async def list_kinds(self, db, region_code: str): """지역의 **이야기** 행(종류당 1행). cache-aside 판단에 쓴다. ★ `kind IS NOT NULL` 로 고르면 안 된다. kind 는 이야기 전용 칸이 아니다 — 마이그레이션 0008 이 날씨·축제·명소·맛집에도 kind 를 채웠기 때문에(AREA_KIND), 그렇게 고르면 **이야기가 한 건도 없는 지역이 "이미 있다"로 판정된다.** 실측(2026-09-10, 전북 군산시): 주변정보 116건이 들어온 뒤로 `has_stories` 가 늘 참이라 지역 이야기 생성이 영영 건너뛰어졌고, 발행본에서 가요다방·인물열전·시간의 골목· 엽서·퀴즈 다섯 섹션이 통째로 비었다. 잡은 성공으로 끝나고 로그도 조용해서 "생성기가 없는 것" 처럼 보였다. ★ 그래서 STORY_KINDS 를 명시한다. 종류가 늘면 그 상수만 늘린다. """ return await DB_SESSION_MNG.execute( db, select(area_contents).where( area_contents.region_code == region_code, area_contents.kind.in_(STORY_KINDS), area_contents.deleted == False, # noqa: E712 ), ) async def get_weather(self, db, region_code: str): err, rows = await DB_SESSION_MNG.execute( db, select(area_contents).where( area_contents.region_code == region_code, area_contents.content_type == LocalContentType.WEATHER.value, area_contents.external_id.is_(None), area_contents.deleted == False, # noqa: E712 ).limit(1), ) return err, rows[0] if rows else None async def upsert_weather(self, db, values: dict): stmt = pg_insert(area_contents).values(**values) stmt = stmt.on_conflict_do_update( index_elements=[area_contents.region_code, area_contents.content_type], # ★ `kind IS NULL` 이 빠져 있어 이 upsert 가 통째로 실패하고 있었다(0007 이 인덱스에 # 그 조건을 더했다). 날씨는 캐시라 실패해도 화면이 안 죽어서 **로그에만 남았다.** index_where=and_( area_contents.deleted == False, # noqa: E712 area_contents.external_id.is_(None), area_contents.kind.is_(None), ), set_={ "source": stmt.excluded.source, "body": stmt.excluded.body, "status": stmt.excluded.status, "collected_at": stmt.excluded.collected_at, "expires_at": stmt.excluded.expires_at, "updated_at": stmt.excluded.updated_at, }, ) return await DB_SESSION_MNG.add(db, stmt)