충돌은 이 브랜치의 주석 정리 커밋(11d30bb)과 main 의 기능 변경이 겹친 백엔드 9개 파일 —
main 쪽을 그대로 썼다(기능이 main 에만 있다). AGENTS.md 는 양쪽 링크를 모두 두고,
DEVLOG 는 요약본 위에 main 의 09-28 에이전트 항목 셋을 붙였다.
site eslint 통과 · vitest 123 passed · frontend tsc·eslint 통과 · 백엔드 py_compile 통과
(백엔드 pytest 는 이 기계에 테스트 DB 계정이 없어 실행하지 못함)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
9.2 KiB
Python
205 lines
9.2 KiB
Python
"""place_posts 접근."""
|
|
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:
|
|
"""생성분 적재."""
|
|
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 포함)을 그대로 돌려준다."""
|
|
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):
|
|
"""이 업장이 이미 배정한 가장 늦은 날짜."""
|
|
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건만, 이른 날짜순."""
|
|
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):
|
|
"""이 업장의 오늘 몫 글 하나 — 사장님이 '승인 알림보내기'를 눌렀을 때 쓴다."""
|
|
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)을 한 회차로 묶는다."""
|
|
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 by_edit_token_hash(self, cdb: AsyncSession, token_hash: str):
|
|
result = await cdb.execute(
|
|
select(place_posts)
|
|
.where(place_posts.edit_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,
|
|
edit_token_hash: str | None = None) -> ErrorType:
|
|
await cdb.execute(
|
|
update(place_posts)
|
|
.where(place_posts.post_id == post_id)
|
|
.values(status=PostStatus.SENT.value, approve_token_hash=token_hash,
|
|
edit_token_hash=edit_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:
|
|
"""소프트 삭제."""
|
|
await cdb.execute(
|
|
update(place_posts)
|
|
.where(place_posts.post_id == post_id)
|
|
.values(deleted=True, updated_at=GTime.UTC())
|
|
)
|
|
return ErrorType.SUCCESS
|