feat(events): PR-1 Events Core — schema + ORM + 최소 API #5
@@ -0,0 +1,646 @@
|
||||
"""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_, 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
|
||||
|
||||
|
||||
# ─── 헬퍼 ───
|
||||
|
||||
|
||||
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))
|
||||
total_q = await session.execute(
|
||||
select(Event.id).where(and_(*where))
|
||||
)
|
||||
total = len(total_q.scalars().all())
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ─── 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)
|
||||
@@ -14,6 +14,7 @@ from api.digest import router as digest_router
|
||||
from api.document_notes import router as document_notes_router
|
||||
from api.document_reads import router as document_reads_router
|
||||
from api.documents import router as documents_router
|
||||
from api.events import router as events_router
|
||||
from api.library import router as library_router
|
||||
from api.memos import router as memos_router
|
||||
from api.news import router as news_router
|
||||
@@ -129,6 +130,7 @@ app.include_router(document_notes_router, prefix="/api/documents", tags=["docume
|
||||
app.include_router(search_router, prefix="/api/search", tags=["search"])
|
||||
|
||||
app.include_router(memos_router, prefix="/api/memos", tags=["memos"])
|
||||
app.include_router(events_router, prefix="/api/events", tags=["events"])
|
||||
app.include_router(dashboard_router, prefix="/api/dashboard", tags=["dashboard"])
|
||||
app.include_router(library_router, prefix="/api/library", tags=["library"])
|
||||
app.include_router(news_router, prefix="/api/news", tags=["news"])
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""events 1차 컨테이너 ORM (개인 운영 로그 / 일정 / 할 일 / 회고)
|
||||
|
||||
PR-1 (migrations 239~247) 의 본체. kind enum 으로 task/calendar_event/activity_log
|
||||
세 변형을 통합 관리. memo_document_id 는 메모 link (optional).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
SmallInteger,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PgEnum
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
# Postgres enum 재선언 X (create_type=False) — migration 239~243 이 권위.
|
||||
EventKindEnum = PgEnum(
|
||||
"task",
|
||||
"calendar_event",
|
||||
"activity_log",
|
||||
name="event_kind",
|
||||
create_type=False,
|
||||
)
|
||||
EventStatusEnum = PgEnum(
|
||||
"inbox",
|
||||
"next",
|
||||
"scheduled",
|
||||
"in_progress",
|
||||
"done",
|
||||
"cancelled",
|
||||
"deferred",
|
||||
name="event_status",
|
||||
create_type=False,
|
||||
)
|
||||
EventSourceEnum = PgEnum(
|
||||
"manual",
|
||||
"memo",
|
||||
"email",
|
||||
"chat",
|
||||
"webhook",
|
||||
"git_commit",
|
||||
"claude_code",
|
||||
name="event_source",
|
||||
create_type=False,
|
||||
)
|
||||
EventActorEnum = PgEnum(
|
||||
"manual",
|
||||
"eid",
|
||||
"email_ingest",
|
||||
"system",
|
||||
name="event_actor",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
|
||||
class Event(Base):
|
||||
__tablename__ = "events"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
title: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
kind: Mapped[str] = mapped_column(EventKindEnum, nullable=False)
|
||||
status: Mapped[str] = mapped_column(EventStatusEnum, nullable=False, default="inbox")
|
||||
|
||||
# 시간 필드 — kind 별 의미가 다름 (CHECK 제약은 migration 244)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
start_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
all_day: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
timezone: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# lifecycle
|
||||
defer_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
priority: Mapped[int | None] = mapped_column(SmallInteger)
|
||||
project_tag: Mapped[str | None] = mapped_column(String(64))
|
||||
tags: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
|
||||
# 출처 / 외부 식별자
|
||||
source: Mapped[str] = mapped_column(EventSourceEnum, nullable=False, default="manual")
|
||||
source_ref: Mapped[str | None] = mapped_column(Text)
|
||||
raw_metadata: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
|
||||
# 메모 link (optional, ON DELETE SET NULL)
|
||||
memo_document_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("documents.id", ondelete="SET NULL")
|
||||
)
|
||||
|
||||
# 인증 / actor
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(EventActorEnum, nullable=False, default="manual")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now, nullable=False
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""events_history ORM — events 의 lifecycle 변경 이력 (append-only).
|
||||
|
||||
PR-1 (migrations 248~249). FK ON DELETE RESTRICT 로 부모 events row 직접 삭제 차단
|
||||
(feedback_history_table_fk_restrict.md — 이력은 시점 사실).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PgEnum
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
from models.event import EventActorEnum
|
||||
|
||||
HistoryChangeKindEnum = PgEnum(
|
||||
"create",
|
||||
"reschedule",
|
||||
"defer",
|
||||
"reactivate",
|
||||
"complete",
|
||||
"cancel",
|
||||
name="history_change_kind",
|
||||
create_type=False,
|
||||
)
|
||||
|
||||
|
||||
class EventHistory(Base):
|
||||
__tablename__ = "events_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
event_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("events.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
changed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, nullable=False
|
||||
)
|
||||
changed_by: Mapped[str] = mapped_column(EventActorEnum, nullable=False)
|
||||
change_kind: Mapped[str] = mapped_column(HistoryChangeKindEnum, nullable=False)
|
||||
before: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
||||
after: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
@@ -0,0 +1,6 @@
|
||||
-- events 도메인 PR-1 (1/11) — event_kind enum
|
||||
-- task: 해야 할 일 (due_at/start_at/end_at 자유 조합 허용)
|
||||
-- calendar_event: 시간 블록이 있는 일정 (start_at 필수)
|
||||
-- activity_log: 이미 한 행동 기록 (started_at 또는 ended_at 필수)
|
||||
|
||||
CREATE TYPE event_kind AS ENUM ('task', 'calendar_event', 'activity_log');
|
||||
@@ -0,0 +1,18 @@
|
||||
-- events 도메인 PR-1 (2/11) — event_status enum
|
||||
-- inbox: 아직 정리 안 됨
|
||||
-- next: 다음 행동으로 선정됨 (시간 미정)
|
||||
-- scheduled: 시간/날짜가 잡힘
|
||||
-- in_progress: 진행 중
|
||||
-- done: 완료
|
||||
-- cancelled: 취소
|
||||
-- deferred: 특정 시점 전까지 숨김 (defer_until 사용)
|
||||
|
||||
CREATE TYPE event_status AS ENUM (
|
||||
'inbox',
|
||||
'next',
|
||||
'scheduled',
|
||||
'in_progress',
|
||||
'done',
|
||||
'cancelled',
|
||||
'deferred'
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- events 도메인 PR-1 (3/11) — event_source enum
|
||||
-- 데이터가 어디서 왔는가 (created_by 와는 별 축)
|
||||
|
||||
CREATE TYPE event_source AS ENUM (
|
||||
'manual',
|
||||
'memo',
|
||||
'email',
|
||||
'chat',
|
||||
'webhook',
|
||||
'git_commit',
|
||||
'claude_code'
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- events 도메인 PR-1 (4/11) — event_actor enum
|
||||
-- 어떤 actor/process 가 row 를 만들었는가 (created_by + events_history.changed_by 공용)
|
||||
-- source 와 분리: source=email + created_by=email_ingest 같은 직교 축
|
||||
|
||||
CREATE TYPE event_actor AS ENUM (
|
||||
'manual',
|
||||
'eid',
|
||||
'email_ingest',
|
||||
'system'
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- events 도메인 PR-1 (5/11) — history_change_kind enum
|
||||
-- events_history 의 lifecycle 변경 유형
|
||||
-- create: 신규 생성
|
||||
-- reschedule: 시간 필드 변경 (due_at/start_at/end_at/started_at/ended_at/timezone/all_day)
|
||||
-- defer: /defer endpoint 호출 (defer_until 설정)
|
||||
-- reactivate: 완료/취소/연기 해제
|
||||
-- complete: /complete endpoint 호출
|
||||
-- cancel: /cancel endpoint 호출
|
||||
|
||||
CREATE TYPE history_change_kind AS ENUM (
|
||||
'create',
|
||||
'reschedule',
|
||||
'defer',
|
||||
'reactivate',
|
||||
'complete',
|
||||
'cancel'
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
-- events 도메인 PR-1 (6/11) — events 1차 컨테이너 테이블
|
||||
-- 개인 운영 로그 / 일정 / 할 일 / 회고용 activity database 의 본체.
|
||||
-- 기존 documents/tasks/ask_events 와 직교한 새 도메인.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT, -- markdown
|
||||
kind event_kind NOT NULL,
|
||||
status event_status NOT NULL DEFAULT 'inbox',
|
||||
due_at TIMESTAMPTZ, -- task 위주
|
||||
start_at TIMESTAMPTZ, -- calendar_event 위주
|
||||
end_at TIMESTAMPTZ, -- calendar_event 위주
|
||||
started_at TIMESTAMPTZ, -- 실제 수행 시각 (activity_log 위주)
|
||||
ended_at TIMESTAMPTZ, -- 실제 수행 종료
|
||||
all_day BOOLEAN NOT NULL DEFAULT false,
|
||||
timezone TEXT,
|
||||
defer_until TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
cancelled_at TIMESTAMPTZ,
|
||||
priority SMALLINT CHECK (priority BETWEEN 1 AND 4),
|
||||
project_tag VARCHAR(64),
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
source event_source NOT NULL DEFAULT 'manual',
|
||||
source_ref TEXT, -- Message-ID 등 (TEXT, 충분한 길이)
|
||||
raw_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
memo_document_id BIGINT REFERENCES documents(id) ON DELETE SET NULL,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
created_by event_actor NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
-- CHECK 제약 (라운드 10: task 제약 제거 + activity_log 완화 — 미래 확장 막지 않게)
|
||||
CONSTRAINT events_calendar_event_requires_start
|
||||
CHECK (kind <> 'calendar_event' OR start_at IS NOT NULL),
|
||||
CONSTRAINT events_activity_log_requires_time
|
||||
CHECK (kind <> 'activity_log' OR started_at IS NOT NULL OR ended_at IS NOT NULL)
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- events 도메인 PR-1 (7/11) — partial unique on (source, source_ref)
|
||||
-- 외부 source dedup (이메일 Message-ID, git commit hash, webhook event_id 등).
|
||||
-- PR-1 에서는 schema 만 박고 dedup 동작 검증은 PR-4 MailPlus ingest 시.
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS events_source_ref_uq
|
||||
ON events (source, source_ref)
|
||||
WHERE source_ref IS NOT NULL;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- events 도메인 PR-1 (8/11) — active events partial index
|
||||
-- Today/Upcoming/Inbox view 의 핵심 인덱스.
|
||||
-- done/cancelled 는 활성 list 에서 빠지므로 partial 로 사이즈 절감.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_active
|
||||
ON events (user_id, due_at, start_at)
|
||||
WHERE status IN ('inbox', 'next', 'scheduled', 'deferred');
|
||||
@@ -0,0 +1,7 @@
|
||||
-- events 도메인 PR-1 (9/11) — activity_log timeline 전용 partial index
|
||||
-- Activity 탭 (한 일 / 회고) view 핵심.
|
||||
-- events_history 와 이름 분리 (라운드 11 — idx_events_history_user 가 아닌 events 본 테이블 index).
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_activity_user_started
|
||||
ON events (user_id, started_at DESC)
|
||||
WHERE kind = 'activity_log';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- events 도메인 PR-1 (10/11) — events_history 변경 이력 테이블
|
||||
-- lifecycle op 자동 기록 (create / reschedule / defer / reactivate / complete / cancel).
|
||||
-- 일반 PATCH (title/description 등) 의 history 는 v1 X (폭증 회피).
|
||||
-- ON DELETE RESTRICT: 이력은 시점 사실 → 부모 events row 직접 삭제 차단
|
||||
-- (feedback_history_table_fk_restrict.md). events 자체는 /cancel 로 soft-cancel.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE RESTRICT,
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
changed_by event_actor NOT NULL,
|
||||
change_kind history_change_kind NOT NULL,
|
||||
before JSONB, -- create 시 NULL
|
||||
after JSONB NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- events 도메인 PR-1 (11/11) — events_history (event_id, changed_at) index
|
||||
-- 일정 상세 페이지의 history timeline 조회 핵심.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_history_event
|
||||
ON events_history (event_id, changed_at);
|
||||
Reference in New Issue
Block a user