You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

230 lines
7.4 KiB
Python

# backend/app/routers/calendar.py
import re
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ..connectors import get_calendar_connector
from ..db import get_session
from ..models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction, Task
from ..schemas import (
CalendarOut,
CalEventOut,
DayBundleOut,
EventActionOut,
FocusBlockOut,
FocusSuggestRequest,
FocusSuggestResponse,
MaterializeResponse,
MeetingActionOut,
MeetingOut,
WeekBundleOut,
)
from ..services.focus import suggest_focus
from ..services.meetings import assemble_meeting, materialize_action, materialize_all
from .tasks import to_node # phase-2 TaskNode 빌더 재사용
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
WEEK = [7, 8, 9, 10, 11, 12, 13]
WEEKDAYS = ["", "", "", "", "", "", ""]
TODAY = 8
def _end_from(start: str, dur: int) -> str:
h, m = map(int, start.split(":"))
total = h * 60 + m + dur
return f"{total // 60:02d}:{total % 60:02d}"
def meet_label(m: Meeting | None) -> str:
if m is None:
return ""
phase = getattr(m.phase, "value", m.phase)
if m.one_on_one:
return "1:1 어시스턴트 브리핑"
if phase == "upcoming":
return "사전 브리핑 보기"
if phase == "live":
return "실시간 기록 보기"
return "회의 노트 · 액션 보기"
def _parse_day(ev: dict) -> int:
"""ev 에서 6월 날짜(int) 추출. 'date''M/D'면 일(D), 'day'가 숫자면 그 값, 아니면 TODAY."""
date_str = str(ev.get("date") or "")
if "/" in date_str:
try:
return int(date_str.split("/")[1])
except (ValueError, IndexError):
pass
try:
return int(ev.get("day"))
except (TypeError, ValueError):
return TODAY
def create_event_from_extract(s: Session, ev: dict, source: str) -> str:
"""메일→일정 federation 헬퍼. ev = {title, date|day, time, dur, place?} →
CalEvent 생성(연합 출처 source 기록) 후 id 반환. phase-9 routers/mail.py 가 import.
메일 AI 이벤트(date='6/10', day='오늘', time='14:00'|'종일')도 견고하게 처리."""
eid = "ce-" + uuid.uuid4().hex[:8]
day = _parse_day(ev)
m = re.match(r"(\d{1,2}):(\d{2})", str(ev.get("time") or ""))
if m:
start = f"{int(m.group(1)):02d}:{m.group(2)}"
dur_digits = re.sub(r"\D", "", str(ev.get("dur") or "")) or "60"
end = _end_from(start, int(dur_digits))
else: # 종일/미정 시간
start, end = "09:00", "09:30"
conn = get_calendar_connector()
conn.write_event(
s,
dict(
id=eid,
day=day,
start=start,
end=end,
title=ev["title"],
cal="meeting",
loc=ev.get("place", ""),
note=f"연합 출처: {source}",
soon=False,
people="",
sort_order=0,
),
)
return eid
def _event_out(s: Session, e: CalEvent) -> CalEventOut:
m = s.get(Meeting, e.id)
eacts = s.exec(
select(MeetingAction)
.where(MeetingAction.event_id == e.id, MeetingAction.source == "event")
.order_by(MeetingAction.idx)
).all()
return CalEventOut(
id=e.id,
day=e.day,
start=e.start,
end=e.end,
title=e.title,
cal=e.cal,
loc=e.loc,
note=e.note,
soon=e.soon,
people=[p.strip() for p in e.people.split(",") if p.strip()],
actions=[EventActionOut(text=a.text, when=a.when_text) for a in eacts],
has_meeting=m is not None,
meet_label=meet_label(m),
)
@router.get("/calendar/week", response_model=WeekBundleOut)
def get_week(s: Session = Depends(get_session)):
conn = get_calendar_connector() # mock-first
rows = [s.get(CalEvent, r["id"]) for r in conn.list_events(s)]
cals = s.exec(select(Calendar).order_by(Calendar.sort_order)).all()
counts: dict[str, int] = {}
for e in rows:
counts[e.cal] = counts.get(e.cal, 0) + 1
fbs = s.exec(select(FocusBlock).order_by(FocusBlock.sort_order)).all()
return WeekBundleOut(
today=TODAY,
week=WEEK,
weekdays=WEEKDAYS,
calendars=[
CalendarOut(id=c.id, name=c.name, tone=c.tone, on=c.on, count=counts.get(c.id, 0))
for c in cals
],
events=[_event_out(s, e) for e in rows],
focus_blocks=[FocusBlockOut(**f.model_dump()) for f in fbs],
)
@router.get("/calendar/day/{day}", response_model=DayBundleOut)
def get_day(day: int, s: Session = Depends(get_session)):
evs = s.exec(select(CalEvent).where(CalEvent.day == day).order_by(CalEvent.start)).all()
fbs = s.exec(select(FocusBlock).where(FocusBlock.day == day).order_by(FocusBlock.start)).all()
return DayBundleOut(
day=day,
events=[_event_out(s, e) for e in evs],
focus_blocks=[FocusBlockOut(**f.model_dump()) for f in fbs],
)
@router.get("/calendar/meetings/{mid}", response_model=MeetingOut)
def get_meeting(mid: str, s: Session = Depends(get_session)):
data = assemble_meeting(s, mid)
if data is None:
raise HTTPException(404, "meeting not found")
return MeetingOut(**data)
@router.post(
"/calendar/meetings/{mid}/actions/{idx}/materialize", response_model=MaterializeResponse
)
def materialize_one(mid: str, idx: int, s: Session = Depends(get_session)):
try:
action, task = materialize_action(s, mid, idx)
except ValueError as e:
raise HTTPException(404, "action not found") from e
remaining = s.exec(
select(MeetingAction).where(
MeetingAction.meeting_id == mid, MeetingAction.added == False # noqa: E712
)
).all()
all_tasks = s.exec(select(Task)).all()
return MaterializeResponse(
action=MeetingActionOut(
id=action.id,
idx=action.idx,
text=action.text,
who=action.who,
when=action.when_text,
added=action.added,
materialized_task_id=action.materialized_task_id,
),
task=to_node(s, task, all_tasks),
all_added=len(remaining) == 0,
)
@router.post(
"/calendar/meetings/{mid}/actions/materialize-all",
response_model=list[MaterializeResponse],
)
def materialize_remaining(mid: str, s: Session = Depends(get_session)):
pairs = materialize_all(s, mid)
all_tasks = s.exec(select(Task)).all()
return [
MaterializeResponse(
action=MeetingActionOut(
id=a.id,
idx=a.idx,
text=a.text,
who=a.who,
when=a.when_text,
added=a.added,
materialized_task_id=a.materialized_task_id,
),
task=to_node(s, t, all_tasks),
all_added=True,
)
for a, t in pairs
]
@router.post("/calendar/focus/suggest", response_model=FocusSuggestResponse)
def focus_suggest(body: FocusSuggestRequest, s: Session = Depends(get_session)):
blocks = suggest_focus(s, body.day, body.min_minutes)
# commit 전에 스냅샷(커밋 후 객체 expire 방지)
out = [FocusBlockOut(**b.model_dump()) for b in blocks]
if body.create:
for b in blocks:
s.add(b)
s.commit()
return FocusSuggestResponse(day=body.day, created=body.create, suggestions=out)