124b50af53
전체 Event.id 를 메모리 로딩 후 len() 하던 것을 select(func.count(Event.id)) 로 전환 — 행 수에 선형이던 메모리/전송 비용 제거. 결과 동등(단순 카운트라 golden-diff 불요). 검증: py_compile 통과. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
681 lines
20 KiB
Python
681 lines
20 KiB
Python
"""events API — 개인 운영 로그 / 일정 / 할 일 / 회고 (PR-1).
|
|
|
|
PR-1 scope (plan beszel-tingly-sloth.md v6):
|
|
- POST /api/events (kind=task/calendar_event/activity_log)
|
|
- GET /api/events/{id}
|
|
- GET /api/events?kind&status&from&to&project_tag&source
|
|
- PATCH /api/events/{id} (허용 필드만, 시간 필드 변경 시 reschedule history)
|
|
- POST /api/events/{id}/complete | /cancel | /defer | /reactivate
|
|
- GET /api/events/today (timezone 정책 적용)
|
|
- GET /api/events/inbox
|
|
- GET /api/events/activity?from&to
|
|
|
|
PR-1 제외: DELETE / log shortcut / upcoming / ingest / iCal / ntfy.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from typing import Annotated, Any
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import and_, func, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user
|
|
from core.database import get_session
|
|
from models.event import Event
|
|
from models.event_history import EventHistory
|
|
from models.user import User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
DEFAULT_TIMEZONE = "Asia/Seoul"
|
|
|
|
# PATCH 허용 필드 — status/completed_at/cancelled_at/defer_until/source/source_ref/
|
|
# raw_metadata/user_id/created_by 는 lifecycle endpoint 또는 시스템 결정.
|
|
PATCH_ALLOWED_FIELDS = {
|
|
"title",
|
|
"description",
|
|
"due_at",
|
|
"start_at",
|
|
"end_at",
|
|
"started_at",
|
|
"ended_at",
|
|
"all_day",
|
|
"timezone",
|
|
"priority",
|
|
"project_tag",
|
|
"tags",
|
|
"memo_document_id",
|
|
}
|
|
# 시간 필드 변경 시 reschedule history 1건 자동 기록 (defer_until 은 /defer 전용).
|
|
RESCHEDULE_TIME_FIELDS = {
|
|
"due_at",
|
|
"start_at",
|
|
"end_at",
|
|
"started_at",
|
|
"ended_at",
|
|
"all_day",
|
|
"timezone",
|
|
}
|
|
|
|
|
|
# ─── 스키마 ───
|
|
|
|
|
|
class EventCreate(BaseModel):
|
|
title: str
|
|
description: str | None = None
|
|
kind: str # task | calendar_event | activity_log
|
|
status: str | None = None # 미지정 시 kind 별 default
|
|
due_at: datetime | None = None
|
|
start_at: datetime | None = None
|
|
end_at: datetime | None = None
|
|
started_at: datetime | None = None
|
|
ended_at: datetime | None = None
|
|
all_day: bool = False
|
|
timezone: str | None = None
|
|
priority: int | None = None
|
|
project_tag: str | None = None
|
|
tags: list[Any] = Field(default_factory=list)
|
|
memo_document_id: int | None = None
|
|
source: str = "manual"
|
|
source_ref: str | None = None
|
|
raw_metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class EventPatch(BaseModel):
|
|
"""PATCH 허용 필드만. status/completed_at 등 lifecycle 필드는 명시 거부."""
|
|
|
|
title: str | None = None
|
|
description: str | None = None
|
|
due_at: datetime | None = None
|
|
start_at: datetime | None = None
|
|
end_at: datetime | None = None
|
|
started_at: datetime | None = None
|
|
ended_at: datetime | None = None
|
|
all_day: bool | None = None
|
|
timezone: str | None = None
|
|
priority: int | None = None
|
|
project_tag: str | None = None
|
|
tags: list[Any] | None = None
|
|
memo_document_id: int | None = None
|
|
|
|
model_config = {"extra": "forbid"} # 허용 외 필드 → 422
|
|
|
|
|
|
class DeferRequest(BaseModel):
|
|
defer_until: datetime
|
|
|
|
|
|
class EventResponse(BaseModel):
|
|
id: int
|
|
title: str
|
|
description: str | None
|
|
kind: str
|
|
status: str
|
|
due_at: datetime | None
|
|
start_at: datetime | None
|
|
end_at: datetime | None
|
|
started_at: datetime | None
|
|
ended_at: datetime | None
|
|
all_day: bool
|
|
timezone: str | None
|
|
defer_until: datetime | None
|
|
completed_at: datetime | None
|
|
cancelled_at: datetime | None
|
|
priority: int | None
|
|
project_tag: str | None
|
|
tags: list[Any]
|
|
source: str
|
|
source_ref: str | None
|
|
raw_metadata: dict[str, Any]
|
|
memo_document_id: int | None
|
|
user_id: int
|
|
created_by: str
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class EventListResponse(BaseModel):
|
|
items: list[EventResponse]
|
|
total: int
|
|
|
|
|
|
class EventHistoryResponse(BaseModel):
|
|
id: int
|
|
event_id: int
|
|
changed_at: datetime
|
|
changed_by: str
|
|
change_kind: str
|
|
before: dict[str, Any] | None
|
|
after: dict[str, Any]
|
|
|
|
|
|
class EventHistoryListResponse(BaseModel):
|
|
items: list[EventHistoryResponse]
|
|
|
|
|
|
# ─── 헬퍼 ───
|
|
|
|
|
|
def _to_response(ev: Event) -> EventResponse:
|
|
return EventResponse.model_validate(ev, from_attributes=True)
|
|
|
|
|
|
def _serialize_for_history(ev: Event) -> dict[str, Any]:
|
|
"""events_history.before/after 용 dict snapshot (JSON 친화)."""
|
|
payload: dict[str, Any] = {}
|
|
for col in (
|
|
"id",
|
|
"title",
|
|
"description",
|
|
"kind",
|
|
"status",
|
|
"due_at",
|
|
"start_at",
|
|
"end_at",
|
|
"started_at",
|
|
"ended_at",
|
|
"all_day",
|
|
"timezone",
|
|
"defer_until",
|
|
"completed_at",
|
|
"cancelled_at",
|
|
"priority",
|
|
"project_tag",
|
|
"tags",
|
|
"source",
|
|
"source_ref",
|
|
"raw_metadata",
|
|
"memo_document_id",
|
|
"user_id",
|
|
"created_by",
|
|
):
|
|
v = getattr(ev, col, None)
|
|
if isinstance(v, datetime):
|
|
payload[col] = v.isoformat()
|
|
else:
|
|
payload[col] = v
|
|
return payload
|
|
|
|
|
|
def _actor_for_user(user: User) -> str:
|
|
"""사용자 직접 호출 = manual. 향후 이드/email_ingest 는 service token 분기 (PR-3)."""
|
|
return "manual"
|
|
|
|
|
|
async def _record_history(
|
|
session: AsyncSession,
|
|
*,
|
|
event: Event,
|
|
change_kind: str,
|
|
changed_by: str,
|
|
before: dict[str, Any] | None,
|
|
after: dict[str, Any],
|
|
) -> None:
|
|
history = EventHistory(
|
|
event_id=event.id,
|
|
changed_by=changed_by,
|
|
change_kind=change_kind,
|
|
before=before,
|
|
after=after,
|
|
)
|
|
session.add(history)
|
|
|
|
|
|
async def _load_owned(
|
|
session: AsyncSession, event_id: int, user: User
|
|
) -> Event:
|
|
ev = await session.get(Event, event_id)
|
|
if ev is None or ev.user_id != user.id:
|
|
raise HTTPException(status_code=404, detail="event not found")
|
|
return ev
|
|
|
|
|
|
def _resolve_timezone(tz_name: str | None) -> ZoneInfo:
|
|
try:
|
|
return ZoneInfo(tz_name or DEFAULT_TIMEZONE)
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail=f"invalid timezone: {tz_name}")
|
|
|
|
|
|
def _local_day_bounds(tz_name: str | None) -> tuple[datetime, datetime, datetime]:
|
|
"""today 의 [start_utc, end_utc) + now_utc 반환."""
|
|
tz = _resolve_timezone(tz_name)
|
|
now_local = datetime.now(tz)
|
|
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
tomorrow_local = today_local + timedelta(days=1)
|
|
return (
|
|
today_local.astimezone(timezone.utc),
|
|
tomorrow_local.astimezone(timezone.utc),
|
|
now_local.astimezone(timezone.utc),
|
|
)
|
|
|
|
|
|
def _apply_activity_log_defaults(payload: dict[str, Any]) -> None:
|
|
"""빠른 행동 기록 5초 UX — kind=activity_log 시 status/시간 default."""
|
|
if payload.get("kind") != "activity_log":
|
|
return
|
|
now = datetime.now(timezone.utc)
|
|
if not payload.get("status"):
|
|
payload["status"] = "done"
|
|
if payload.get("ended_at") is None:
|
|
payload["ended_at"] = now
|
|
if payload.get("started_at") is None:
|
|
payload["started_at"] = payload["ended_at"]
|
|
if payload.get("status") == "done":
|
|
payload.setdefault("completed_at", now)
|
|
|
|
|
|
def _apply_kind_default_status(payload: dict[str, Any]) -> None:
|
|
"""kind 별 status default 보정."""
|
|
if payload.get("status"):
|
|
return
|
|
kind = payload.get("kind")
|
|
if kind == "calendar_event":
|
|
payload["status"] = "scheduled"
|
|
elif kind == "task":
|
|
payload["status"] = "inbox"
|
|
|
|
|
|
# ─── Create ───
|
|
|
|
|
|
@router.post("/", response_model=EventResponse, status_code=201)
|
|
async def create_event(
|
|
body: EventCreate,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""events 생성. kind=activity_log 면 status=done/ended_at=now() default."""
|
|
payload = body.model_dump(exclude_none=False)
|
|
_apply_activity_log_defaults(payload)
|
|
_apply_kind_default_status(payload)
|
|
|
|
if payload["kind"] not in ("task", "calendar_event", "activity_log"):
|
|
raise HTTPException(status_code=400, detail="invalid kind")
|
|
|
|
actor = _actor_for_user(user)
|
|
ev = Event(
|
|
title=payload["title"],
|
|
description=payload.get("description"),
|
|
kind=payload["kind"],
|
|
status=payload.get("status") or "inbox",
|
|
due_at=payload.get("due_at"),
|
|
start_at=payload.get("start_at"),
|
|
end_at=payload.get("end_at"),
|
|
started_at=payload.get("started_at"),
|
|
ended_at=payload.get("ended_at"),
|
|
all_day=payload.get("all_day") or False,
|
|
timezone=payload.get("timezone"),
|
|
completed_at=payload.get("completed_at"),
|
|
priority=payload.get("priority"),
|
|
project_tag=payload.get("project_tag"),
|
|
tags=payload.get("tags") or [],
|
|
source=payload.get("source") or "manual",
|
|
source_ref=payload.get("source_ref"),
|
|
raw_metadata=payload.get("raw_metadata") or {},
|
|
memo_document_id=payload.get("memo_document_id"),
|
|
user_id=user.id,
|
|
created_by=actor,
|
|
)
|
|
session.add(ev)
|
|
await session.flush()
|
|
|
|
await _record_history(
|
|
session,
|
|
event=ev,
|
|
change_kind="create",
|
|
changed_by=actor,
|
|
before=None,
|
|
after=_serialize_for_history(ev),
|
|
)
|
|
await session.commit()
|
|
await session.refresh(ev)
|
|
return _to_response(ev)
|
|
|
|
|
|
# ─── List / Get ───
|
|
|
|
|
|
@router.get("/", response_model=EventListResponse)
|
|
async def list_events(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
kind: str | None = Query(None),
|
|
status: str | None = Query(None, description="comma-separated list"),
|
|
from_: datetime | None = Query(None, alias="from"),
|
|
to: datetime | None = Query(None),
|
|
project_tag: str | None = Query(None),
|
|
source: str | None = Query(None),
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(50, ge=1, le=200),
|
|
):
|
|
"""events 목록 — current_user.id 자동 필터. upcoming 은 ?from=now&to=now+7d 로."""
|
|
where = [Event.user_id == user.id]
|
|
if kind:
|
|
where.append(Event.kind == kind)
|
|
if status:
|
|
statuses = [s.strip() for s in status.split(",") if s.strip()]
|
|
if statuses:
|
|
where.append(Event.status.in_(statuses))
|
|
if project_tag:
|
|
where.append(Event.project_tag == project_tag)
|
|
if source:
|
|
where.append(Event.source == source)
|
|
if from_ is not None:
|
|
# task: due_at, calendar_event: start_at, activity_log: started_at
|
|
where.append(
|
|
or_(
|
|
Event.due_at >= from_,
|
|
Event.start_at >= from_,
|
|
Event.started_at >= from_,
|
|
)
|
|
)
|
|
if to is not None:
|
|
where.append(
|
|
or_(
|
|
Event.due_at < to,
|
|
Event.start_at < to,
|
|
Event.started_at < to,
|
|
)
|
|
)
|
|
|
|
base = select(Event).where(and_(*where))
|
|
# R10: 전체 ID 로딩 후 len() 대신 DB COUNT 푸시다운 (행 수 선형 메모리/전송 비용 제거).
|
|
total = (
|
|
await session.execute(select(func.count(Event.id)).where(and_(*where)))
|
|
).scalar() or 0
|
|
|
|
rows = await session.execute(
|
|
base.order_by(Event.created_at.desc())
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
)
|
|
items = [_to_response(e) for e in rows.scalars().all()]
|
|
return EventListResponse(items=items, total=total)
|
|
|
|
|
|
@router.get("/today", response_model=EventListResponse)
|
|
async def list_today(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
timezone: str | None = Query(None, description="기본 Asia/Seoul"),
|
|
):
|
|
"""오늘 해야 할 것 / 예정된 것. timezone 적용.
|
|
|
|
포함: task(due_at today) / calendar_event(start_at today) / activity_log(started_at today)
|
|
status: inbox/next/scheduled/in_progress 또는 deferred (defer_until <= now() 일 때만).
|
|
"""
|
|
start_utc, end_utc, now_utc = _local_day_bounds(timezone)
|
|
|
|
today_clause = or_(
|
|
and_(Event.kind == "task", Event.due_at >= start_utc, Event.due_at < end_utc),
|
|
and_(
|
|
Event.kind == "calendar_event",
|
|
Event.start_at >= start_utc,
|
|
Event.start_at < end_utc,
|
|
),
|
|
and_(
|
|
Event.kind == "activity_log",
|
|
Event.started_at >= start_utc,
|
|
Event.started_at < end_utc,
|
|
),
|
|
)
|
|
active_clause = or_(
|
|
Event.status.in_(("inbox", "next", "scheduled", "in_progress")),
|
|
and_(Event.status == "deferred", Event.defer_until <= now_utc),
|
|
)
|
|
rows = await session.execute(
|
|
select(Event)
|
|
.where(Event.user_id == user.id, today_clause, active_clause)
|
|
.order_by(Event.start_at.asc(), Event.due_at.asc(), Event.started_at.asc())
|
|
)
|
|
items = [_to_response(e) for e in rows.scalars().all()]
|
|
return EventListResponse(items=items, total=len(items))
|
|
|
|
|
|
@router.get("/inbox", response_model=EventListResponse)
|
|
async def list_inbox(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""Inbox — 아직 정리 안 된 것."""
|
|
rows = await session.execute(
|
|
select(Event)
|
|
.where(Event.user_id == user.id, Event.status == "inbox")
|
|
.order_by(Event.created_at.desc())
|
|
)
|
|
items = [_to_response(e) for e in rows.scalars().all()]
|
|
return EventListResponse(items=items, total=len(items))
|
|
|
|
|
|
@router.get("/activity", response_model=EventListResponse)
|
|
async def list_activity(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
from_: datetime | None = Query(None, alias="from"),
|
|
to: datetime | None = Query(None),
|
|
):
|
|
"""Activity timeline — 한 일 (kind=activity_log + status=done). Today 와 분리."""
|
|
where = [
|
|
Event.user_id == user.id,
|
|
Event.kind == "activity_log",
|
|
Event.status == "done",
|
|
]
|
|
if from_ is not None:
|
|
where.append(Event.started_at >= from_)
|
|
if to is not None:
|
|
where.append(Event.started_at < to)
|
|
rows = await session.execute(
|
|
select(Event).where(and_(*where)).order_by(Event.started_at.desc())
|
|
)
|
|
items = [_to_response(e) for e in rows.scalars().all()]
|
|
return EventListResponse(items=items, total=len(items))
|
|
|
|
|
|
@router.get("/{event_id}", response_model=EventResponse)
|
|
async def get_event(
|
|
event_id: int,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
ev = await _load_owned(session, event_id, user)
|
|
return _to_response(ev)
|
|
|
|
|
|
@router.get("/{event_id}/history", response_model=EventHistoryListResponse)
|
|
async def get_event_history(
|
|
event_id: int,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""events_history 조회 — 상세 페이지 timeline. lifecycle op 자동 기록만 (v1)."""
|
|
await _load_owned(session, event_id, user) # owner 검증
|
|
rows = await session.execute(
|
|
select(EventHistory)
|
|
.where(EventHistory.event_id == event_id)
|
|
.order_by(EventHistory.changed_at.desc())
|
|
)
|
|
items = [
|
|
EventHistoryResponse.model_validate(h, from_attributes=True)
|
|
for h in rows.scalars().all()
|
|
]
|
|
return EventHistoryListResponse(items=items)
|
|
|
|
|
|
# ─── PATCH ───
|
|
|
|
|
|
@router.patch("/{event_id}", response_model=EventResponse)
|
|
async def patch_event(
|
|
event_id: int,
|
|
body: EventPatch,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""PATCH — 허용 필드만. 시간 필드 변경 시 reschedule history 자동 기록.
|
|
|
|
status/completed_at/cancelled_at/defer_until 등 lifecycle 필드는 별 endpoint 강제.
|
|
"""
|
|
ev = await _load_owned(session, event_id, user)
|
|
|
|
patch = body.model_dump(exclude_unset=True)
|
|
if not patch:
|
|
return _to_response(ev)
|
|
|
|
# 안전 검사 — extra=forbid 로 막혀 있지만 한 번 더.
|
|
for k in patch:
|
|
if k not in PATCH_ALLOWED_FIELDS:
|
|
raise HTTPException(status_code=400, detail=f"field not patchable: {k}")
|
|
|
|
time_changed = any(k in RESCHEDULE_TIME_FIELDS for k in patch)
|
|
before_snapshot = _serialize_for_history(ev) if time_changed else None
|
|
|
|
for k, v in patch.items():
|
|
setattr(ev, k, v)
|
|
await session.flush()
|
|
|
|
if time_changed:
|
|
actor = _actor_for_user(user)
|
|
await _record_history(
|
|
session,
|
|
event=ev,
|
|
change_kind="reschedule",
|
|
changed_by=actor,
|
|
before=before_snapshot,
|
|
after=_serialize_for_history(ev),
|
|
)
|
|
|
|
await session.commit()
|
|
await session.refresh(ev)
|
|
return _to_response(ev)
|
|
|
|
|
|
# ─── Lifecycle ───
|
|
|
|
|
|
async def _transition(
|
|
session: AsyncSession,
|
|
*,
|
|
event: Event,
|
|
change_kind: str,
|
|
new_status: str,
|
|
user: User,
|
|
extra_apply: dict[str, Any] | None = None,
|
|
) -> Event:
|
|
actor = _actor_for_user(user)
|
|
before = _serialize_for_history(event)
|
|
event.status = new_status
|
|
if extra_apply:
|
|
for k, v in extra_apply.items():
|
|
setattr(event, k, v)
|
|
await session.flush()
|
|
await _record_history(
|
|
session,
|
|
event=event,
|
|
change_kind=change_kind,
|
|
changed_by=actor,
|
|
before=before,
|
|
after=_serialize_for_history(event),
|
|
)
|
|
return event
|
|
|
|
|
|
@router.post("/{event_id}/complete", response_model=EventResponse)
|
|
async def complete_event(
|
|
event_id: int,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
ev = await _load_owned(session, event_id, user)
|
|
now = datetime.now(timezone.utc)
|
|
await _transition(
|
|
session,
|
|
event=ev,
|
|
change_kind="complete",
|
|
new_status="done",
|
|
user=user,
|
|
extra_apply={"completed_at": now},
|
|
)
|
|
await session.commit()
|
|
await session.refresh(ev)
|
|
return _to_response(ev)
|
|
|
|
|
|
@router.post("/{event_id}/cancel", response_model=EventResponse)
|
|
async def cancel_event(
|
|
event_id: int,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
ev = await _load_owned(session, event_id, user)
|
|
now = datetime.now(timezone.utc)
|
|
await _transition(
|
|
session,
|
|
event=ev,
|
|
change_kind="cancel",
|
|
new_status="cancelled",
|
|
user=user,
|
|
extra_apply={"cancelled_at": now},
|
|
)
|
|
await session.commit()
|
|
await session.refresh(ev)
|
|
return _to_response(ev)
|
|
|
|
|
|
@router.post("/{event_id}/defer", response_model=EventResponse)
|
|
async def defer_event(
|
|
event_id: int,
|
|
body: DeferRequest,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
ev = await _load_owned(session, event_id, user)
|
|
await _transition(
|
|
session,
|
|
event=ev,
|
|
change_kind="defer",
|
|
new_status="deferred",
|
|
user=user,
|
|
extra_apply={"defer_until": body.defer_until},
|
|
)
|
|
await session.commit()
|
|
await session.refresh(ev)
|
|
return _to_response(ev)
|
|
|
|
|
|
@router.post("/{event_id}/reactivate", response_model=EventResponse)
|
|
async def reactivate_event(
|
|
event_id: int,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""완료/취소/연기 해제 — kind 따라 기본 status 복귀.
|
|
|
|
task: inbox, calendar_event: scheduled, activity_log: done 유지 안 함 (activity_log 는 done 이 자연 상태이므로 reactivate 적용 X → 400).
|
|
"""
|
|
ev = await _load_owned(session, event_id, user)
|
|
if ev.kind == "activity_log":
|
|
raise HTTPException(
|
|
status_code=400, detail="activity_log 는 reactivate 대상 아님"
|
|
)
|
|
new_status = "scheduled" if ev.kind == "calendar_event" else "inbox"
|
|
await _transition(
|
|
session,
|
|
event=ev,
|
|
change_kind="reactivate",
|
|
new_status=new_status,
|
|
user=user,
|
|
extra_apply={"completed_at": None, "cancelled_at": None, "defer_until": None},
|
|
)
|
|
await session.commit()
|
|
await session.refresh(ev)
|
|
return _to_response(ev)
|