"""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)