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