"""place_posts 접근. 미니 블로그 글의 일생을 이 표 하나로 본다(docs/MINI_BLOG.md).""" from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.model.models import place_posts from common.enums import ErrorType, PostStatus from common.utils.gtime import GTime class PostCRUD: async def add_many(self, cdb: AsyncSession, rows: list[dict]) -> ErrorType: """생성분 적재. 같은 주제가 이미 있거나 같은 날짜를 이미 썼으면 그 건만 건너뛴다 — 회차 전체를 버리지 않는다(topic_key 유니크와 scheduled_date 유니크가 각각 막는다).""" for row in rows: try: cdb.add(place_posts(**row)) await cdb.flush() except Exception: # noqa: BLE001 — 유니크 충돌 = 이미 쓴 주제거나 이미 찬 날짜 await cdb.rollback() return ErrorType.SUCCESS async def add_one(self, cdb: AsyncSession, row: dict) -> dict | None: """개별 생성(빈 날짜 하나 채우기) 전용 — `add_many` 와 달리 성공하면 삽입된 값 (post_id 포함)을 그대로 돌려준다. 사장님이 콕 집은 날짜라 "이미 있어서 조용히 건너뜀" 으로 끝내면 안 된다. ★ ORM 객체를 그대로 돌려주지 않는다 — 호출측이 commit 뒤에 속성을 읽으면 detached 라 깨진다. flush() 직후(아직 세션이 살아있을 때) 값만 뽑아 dict 로 준다.""" try: obj = place_posts(**row) cdb.add(obj) await cdb.flush() return { "post_id": obj.post_id, "place_id": obj.place_id, "body": obj.body, "topic_kind": obj.topic_kind, "topic_key": obj.topic_key, "status": obj.status, "scheduled_date": obj.scheduled_date, "generation_meta": obj.generation_meta, } except Exception: # noqa: BLE001 — 유니크 충돌(그 날짜 이미 있음 등) await cdb.rollback() return None async def max_scheduled_date(self, cdb: AsyncSession, place_id): """이 업장이 이미 배정한 가장 늦은 날짜. 없으면 None(오늘부터 채운다).""" result = await cdb.execute( select(func.max(place_posts.scheduled_date)) .where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712 ) return result.scalar() async def due_for_mail(self, cdb: AsyncSession, status: int, today, limit: int): """배정일이 오늘까지 온 것 중 업장당 1건만, 이른 날짜순. 업장 하나가 밀려 있어도 하루 한 통만 나간다(규모가 작아 DISTINCT ON 결과를 파이썬에서 정렬해도 무리 없다).""" result = await cdb.execute( select(place_posts) .where( place_posts.status == status, place_posts.deleted == False, # noqa: E712 place_posts.scheduled_date <= today, ) .distinct(place_posts.place_id) .order_by(place_posts.place_id, place_posts.scheduled_date) ) rows = sorted(result.scalars(), key=lambda row: row.scheduled_date) return ErrorType.SUCCESS, rows[:limit] async def next_due_for_mail(self, cdb: AsyncSession, place_id, status: int, today): """이 업장의 오늘 몫 글 하나 — 사장님이 '승인 알림보내기'를 눌렀을 때 쓴다. 없으면 None. due_for_mail 과 같은 조건(배정일이 오늘까지 온 것)을 이 업장 하나로 좁힌 것뿐이다.""" result = await cdb.execute( select(place_posts) .where( place_posts.place_id == place_id, place_posts.status == status, place_posts.deleted == False, # noqa: E712 place_posts.scheduled_date <= today, ) .order_by(place_posts.scheduled_date) .limit(1) ) return result.scalars().first() async def list_for_place(self, cdb: AsyncSession, place_id, since, until): """사장님 빌더 화면 — 이번 달(또는 고른 달)에 배정된 글 전체, 날짜순.""" result = await cdb.execute( select(place_posts) .where( place_posts.place_id == place_id, place_posts.deleted == False, # noqa: E712 place_posts.scheduled_date >= since, place_posts.scheduled_date < until, ) .order_by(place_posts.scheduled_date.desc()) ) return ErrorType.SUCCESS, list(result.scalars()) async def by_id(self, cdb: AsyncSession, post_id): """메일의 '수정하기' 링크(자동 로그인) 전용 — postId 하나로 바로 찾는다.""" result = await cdb.execute( select(place_posts).where(place_posts.post_id == post_id, place_posts.deleted == False) # noqa: E712 ) return result.scalars().first() async def generation_batches(self, cdb: AsyncSession, place_id, limit: int = 30): """생성 이력 — 한 번의 생성 스윕(같은 트랜잭션의 created_at)을 한 회차로 묶는다. 새 컬럼 없이 기존 created_at 만으로 센다 — add_many 가 한 트랜잭션 안에서 넣으므로 같은 회차의 created_at 은 DB now() 기준으로 전부 같다. 모델명은 같은 회차 안에서도 전부 같아야 정상이지만(한 스윕 = 한 모델), `max()` 로 대표값 하나만 뽑는다.""" result = await cdb.execute( select( place_posts.created_at, func.count().label("count"), func.max(place_posts.generation_meta["model"].astext).label("model"), ) .where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712 .group_by(place_posts.created_at) .order_by(place_posts.created_at.desc()) .limit(limit) ) return result.all() async def used_topic_keys(self, cdb: AsyncSession, place_id) -> list[str]: result = await cdb.execute( select(place_posts.topic_key) .where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712 ) return [row[0] for row in result.all()] async def published(self, cdb: AsyncSession, place_id, limit: int = 200): """화면에 나갈 글. 최신순이고, 게재된 것만.""" result = await cdb.execute( select(place_posts) .where( place_posts.place_id == place_id, place_posts.status == PostStatus.PUBLISHED.value, place_posts.deleted == False, # noqa: E712 ) .order_by(place_posts.published_at.desc()) .limit(limit) ) return list(result.scalars()) async def by_token_hash(self, cdb: AsyncSession, token_hash: str): result = await cdb.execute( select(place_posts) .where(place_posts.approve_token_hash == token_hash, place_posts.deleted == False) # noqa: E712 ) return result.scalars().first() async def mark_sent(self, cdb: AsyncSession, post_id, token_hash: str, expires_at) -> ErrorType: await cdb.execute( update(place_posts) .where(place_posts.post_id == post_id) .values(status=PostStatus.SENT.value, approve_token_hash=token_hash, token_expires_at=expires_at, sent_at=GTime.UTC(), updated_at=GTime.UTC()) ) return ErrorType.SUCCESS # 메일(SENT)뿐 아니라 아직 안 보낸 재고(REVIEWED)도 고칠·승인할 수 있다 — 사장님이 # 빌더 앱에 로그인해 이번 달 글 목록에서 직접 고를 때는 메일이 먼저 나가 있을 필요가 없다. _EDITABLE = (PostStatus.SENT.value, PostStatus.REVIEWED.value) async def update_body(self, cdb: AsyncSession, post_id, body: str) -> ErrorType: """수정하기 — 이미 승인·게재·반려된 글은 못 고친다.""" await cdb.execute( update(place_posts) .where(place_posts.post_id == post_id, place_posts.status.in_(self._EDITABLE)) .values(body=body, updated_at=GTime.UTC()) ) return ErrorType.SUCCESS async def approve(self, cdb: AsyncSession, post_id) -> ErrorType: """★ 토큰을 지우면서 승인한다 — 같은 링크를 두 번 눌러도 두 번 게재되지 않는다.""" await cdb.execute( update(place_posts) .where(place_posts.post_id == post_id, place_posts.status.in_(self._EDITABLE)) .values(status=PostStatus.APPROVED.value, approved_at=GTime.UTC(), approve_token_hash=None, updated_at=GTime.UTC()) ) return ErrorType.SUCCESS async def skip(self, cdb: AsyncSession, post_id) -> ErrorType: await cdb.execute( update(place_posts) .where(place_posts.post_id == post_id) .values(status=PostStatus.SKIPPED.value, approve_token_hash=None, updated_at=GTime.UTC()) ) return ErrorType.SUCCESS async def mark_published(self, cdb: AsyncSession, place_id, version_id) -> ErrorType: """재발행이 끝나면 그 업장의 승인분을 한꺼번에 게재로 옮긴다.""" await cdb.execute( update(place_posts) .where( place_posts.place_id == place_id, place_posts.status == PostStatus.APPROVED.value, place_posts.deleted == False, # noqa: E712 — 승인 후 삭제된 글까지 게재로 옮기지 않는다 ) .values(status=PostStatus.PUBLISHED.value, published_at=GTime.UTC(), published_version_id=version_id, updated_at=GTime.UTC()) ) return ErrorType.SUCCESS async def delete(self, cdb: AsyncSession, post_id) -> ErrorType: """소프트 삭제. (place_id, topic_key)·(place_id, scheduled_date) 유니크가 deleted=false 행만 보므로, 지우면 그 날짜·주제가 바로 재생성 대상으로 풀린다.""" await cdb.execute( update(place_posts) .where(place_posts.post_id == post_id) .values(deleted=True, updated_at=GTime.UTC()) ) return ErrorType.SUCCESS