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