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