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.

453 lines
15 KiB
Python

# backend/app/routers/calendar.py
import re
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ..connectors.calendar import get_calendar_connector, real_google, real_outlook
from ..connectors.calendar.normalize import account_calendar_id
from ..db import get_session
from ..models import (
Calendar,
CalEvent,
ConnectorAccount,
ConnectorDomain,
ConnectorMode,
ConnState,
ExternalLink,
FocusBlock,
Meeting,
MeetingAction,
Task,
)
from ..schemas import (
AttendeeOut,
CalendarOut,
CalEventOut,
DayBundleOut,
EventActionOut,
EventRsvpRequest,
EventWriteRequest,
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
DEMO_TODAY_DATE = "2026-06-08" # 데모(시드) 기준일 — 실연결 없을 때
def _display_date(day: int, real_date: str) -> str:
"""이벤트 표시용 ISO 날짜. 실데이터는 date, 데모(시드)는 6월 day 로 매핑."""
if real_date:
return real_date
try:
return f"2026-06-{int(day):02d}"
except (TypeError, ValueError):
return DEMO_TODAY_DATE
def _calendar_mode(s: Session) -> tuple[str, str]:
"""(mode, today_date). real 캘린더가 연결돼 있으면 실제 오늘, 아니면 데모 기준일."""
has_real = s.exec(
select(ConnectorAccount).where(
ConnectorAccount.domain == ConnectorDomain.calendar,
ConnectorAccount.mode == ConnectorMode.real,
ConnectorAccount.state == ConnState.connected,
)
).first()
return ("real", date.today().isoformat()) if has_real else ("demo", DEMO_TODAY_DATE)
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,
date=_display_date(e.day, e.date),
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()],
rrule=e.rrule or "",
reminders=list(e.reminders or []),
attendees=[
AttendeeOut(
email=a.get("email", ""), name=a.get("name", ""), status=a.get("status", "")
)
for a in (e.attendees or [])
],
response_status=e.response_status or "",
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()
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()
mode, today_date = _calendar_mode(s)
return WeekBundleOut(
today=TODAY,
today_date=today_date,
mode=mode,
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=[_focus_out(f) for f in fbs],
)
def _focus_out(f: FocusBlock) -> FocusBlockOut:
d = f.model_dump()
d["date"] = _display_date(f.day, "") # 집중 블록은 데모 전용 → 6월로 매핑
return FocusBlockOut(**d)
@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=[_focus_out(f) 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)
# ── 일정 쓰기(생성·수정·삭제). 연결된 Google 캘린더에 실제 반영 + 로컬 미러. ──
LOCAL_CAL_ID = "local"
def _ensure_local_calendar(s: Session) -> str:
"""외부 캘린더 미연결 시 사용할 로컬 캘린더 행 보장."""
if not s.get(Calendar, LOCAL_CAL_ID):
s.add(Calendar(id=LOCAL_CAL_ID, name="내 캘린더", tone="green", on=True, sort_order=10))
s.commit()
return LOCAL_CAL_ID
_CAL_WRITERS = {"google_calendar": real_google, "outlook_calendar": real_outlook}
def _cal_writer(account: ConnectorAccount):
"""계정 provider 에 맞는 쓰기 모듈(real_google / real_outlook)."""
return _CAL_WRITERS.get(account.provider)
def _writable_calendar_account(s: Session, cal_id: str = "") -> ConnectorAccount | None:
"""쓰기 가능한 연결 캘린더 계정(Google·Outlook 모두 쓰기 지원).
cal_id 가 특정 캘린더를 가리키면 그 계정, 아니면 첫 연결 캘린더 계정."""
accts = s.exec(
select(ConnectorAccount).where(
ConnectorAccount.domain == ConnectorDomain.calendar,
ConnectorAccount.state == ConnState.connected,
)
).all()
accts = [a for a in accts if _cal_writer(a)] # 쓰기 지원 provider 만
if cal_id:
for a in accts:
if account_calendar_id(a) == cal_id:
return a
return accts[0] if accts else None
def _event_link(s: Session, eid: str) -> ExternalLink | None:
return s.exec(
select(ExternalLink).where(
ExternalLink.entity_id == eid, ExternalLink.entity_type == "event"
)
).first()
def _account_for_event(s: Session, eid: str) -> ConnectorAccount | None:
link = _event_link(s, eid)
return s.get(ConnectorAccount, link.account_id) if link else None
def _local_day(date_iso: str) -> int:
try:
return int(date_iso[8:10])
except (ValueError, IndexError):
return TODAY
@router.post("/calendar/events", response_model=CalEventOut)
def create_event(body: EventWriteRequest, s: Session = Depends(get_session)):
acct = _writable_calendar_account(s, body.cal)
if acct: # 연결된 캘린더(Google/Outlook)에 실제 생성 후 로컬 미러
writer = _cal_writer(acct)
ev_json = writer.create_remote_event(s, acct, body.model_dump())
eid = writer.mirror_event(s, acct, ev_json)
s.commit()
else: # 외부 캘린더 미연결 → 로컬 전용 이벤트
cal_id = _ensure_local_calendar(s)
eid = "ce-" + uuid.uuid4().hex[:8]
end = body.end or (_end_from(body.start, 60) if body.start else "")
s.add(
CalEvent(
id=eid,
day=_local_day(body.date),
date=body.date,
start=body.start, # 빈값 = 종일
end=end,
title=body.title,
cal=cal_id,
loc=body.loc,
note=body.note,
people=", ".join(body.people),
rrule=body.rrule,
reminders=list(body.reminders),
attendees=[{"email": e, "name": "", "status": "needsAction"} for e in body.people],
soon=False,
sort_order=0,
)
)
s.commit()
return _event_out(s, s.get(CalEvent, eid))
@router.patch("/calendar/events/{eid}", response_model=CalEventOut)
def update_event(eid: str, body: EventWriteRequest, s: Session = Depends(get_session)):
e = s.get(CalEvent, eid)
if not e:
raise HTTPException(404, "event not found")
acct = _account_for_event(s, eid)
writer = _cal_writer(acct) if acct else None
if acct and writer: # 연결된 캘린더(Google/Outlook)에 실제 수정
link = _event_link(s, eid)
ev_json = writer.update_remote_event(s, acct, link.external_id, body.model_dump())
writer.mirror_event(s, acct, ev_json)
s.commit()
else: # 로컬 전용 이벤트
e.title = body.title
e.date = body.date
e.day = _local_day(body.date)
e.start = body.start
e.end = body.end if body.start else ""
e.loc = body.loc
e.note = body.note
e.people = ", ".join(body.people)
e.rrule = body.rrule
e.reminders = list(body.reminders)
e.attendees = [{"email": p, "name": "", "status": "needsAction"} for p in body.people]
s.add(e)
s.commit()
return _event_out(s, s.get(CalEvent, eid))
@router.post("/calendar/events/{eid}/rsvp", response_model=CalEventOut)
def rsvp_event(eid: str, body: EventRsvpRequest, s: Session = Depends(get_session)):
"""초대 응답(RSVP). 연결된 캘린더면 Google/Outlook 에 실제 응답 전송 + 로컬 반영."""
if body.status not in ("accepted", "declined", "tentative"):
raise HTTPException(400, "status must be accepted|declined|tentative")
e = s.get(CalEvent, eid)
if not e:
raise HTTPException(404, "event not found")
acct = _account_for_event(s, eid)
writer = _cal_writer(acct) if acct else None
link = _event_link(s, eid)
if acct and writer and link and hasattr(writer, "respond_to_event"):
writer.respond_to_event(s, acct, link.external_id, body.status)
e.response_status = body.status
if acct: # 참석자 목록에서 내 상태도 갱신
myemail = (acct.external_account_id or "").lower()
atts = list(e.attendees or [])
for a in atts:
if a.get("email", "").lower() == myemail:
a["status"] = body.status
e.attendees = atts
s.add(e)
s.commit()
return _event_out(s, s.get(CalEvent, eid))
@router.delete("/calendar/events/{eid}")
def delete_event(eid: str, s: Session = Depends(get_session)):
e = s.get(CalEvent, eid)
if not e:
raise HTTPException(404, "event not found")
acct = _account_for_event(s, eid)
writer = _cal_writer(acct) if acct else None
link = _event_link(s, eid)
if acct and writer and link: # 연결된 캘린더(Google/Outlook)에서 실제 삭제
writer.delete_remote_event(s, acct, link.external_id)
if link:
s.delete(link)
s.delete(e)
s.commit()
return {"ok": True, "id": eid}