feat(core): 모델·설정·DB·마이그레이션·워커·LLM 시드 갱신
- models/schemas 확장(메일 본문 HTML·received_at·폴더·inference, 발송 메일 등) 마이그레이션 p17~p22 추가 - db.py WAL+busy_timeout(동시 sync 잠금 방지), config/runtime_config 정리 - llm(ollama/provider) 실모델 경로 정비, 워커 트리거·시드(seed/seed_mail) 갱신 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>main
parent
e0937a7064
commit
c5cf6816f8
@ -0,0 +1,46 @@
|
|||||||
|
"""phase-16: outbound_mail(발송 대기 메일) 테이블
|
||||||
|
|
||||||
|
Revision ID: p17e5f6a7b8c9
|
||||||
|
Revises: p16d4e5f6a7b8
|
||||||
|
Create Date: 2026-06-15 14:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "p17e5f6a7b8c9"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "p16d4e5f6a7b8"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
S = sqlmodel.sql.sqltypes.AutoString
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"outbound_mail",
|
||||||
|
sa.Column("id", S(), nullable=False),
|
||||||
|
sa.Column("approval_id", S(), nullable=False),
|
||||||
|
sa.Column("connector_account_id", S(), nullable=True),
|
||||||
|
sa.Column("mail_account", S(), nullable=False),
|
||||||
|
sa.Column("to", S(), nullable=False),
|
||||||
|
sa.Column("subject", S(), nullable=False),
|
||||||
|
sa.Column("body", S(), nullable=False),
|
||||||
|
sa.Column("status", S(), nullable=False),
|
||||||
|
sa.Column("error_detail", S(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("sent_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["approval_id"], ["approval.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["connector_account_id"], ["connector_account.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_outbound_mail_approval_id", "outbound_mail", ["approval_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_outbound_mail_approval_id", table_name="outbound_mail")
|
||||||
|
op.drop_table("outbound_mail")
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
"""phase-18: 고급 기능용 컬럼(메일 cc/thread, 발송 cc/bcc/첨부, 일정 반복/알림/참석자, 댓글 수정)
|
||||||
|
|
||||||
|
Revision ID: p18f6a7b8c9d0
|
||||||
|
Revises: p17e5f6a7b8c9
|
||||||
|
Create Date: 2026-06-16 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "p18f6a7b8c9d0"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "p17e5f6a7b8c9"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
S = sqlmodel.sql.sqltypes.AutoString
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 메일: 받은 메일 참조 + 대화(스레드) 묶기
|
||||||
|
op.add_column("email", sa.Column("cc", S(), nullable=False, server_default=""))
|
||||||
|
op.add_column("email", sa.Column("thread_id", S(), nullable=False, server_default=""))
|
||||||
|
# 발송 메일: cc/bcc/첨부
|
||||||
|
op.add_column("outbound_mail", sa.Column("cc", S(), nullable=False, server_default=""))
|
||||||
|
op.add_column("outbound_mail", sa.Column("bcc", S(), nullable=False, server_default=""))
|
||||||
|
op.add_column(
|
||||||
|
"outbound_mail", sa.Column("attachments", sa.JSON(), nullable=False, server_default="[]")
|
||||||
|
)
|
||||||
|
# 일정: 반복/알림/참석자/내 RSVP
|
||||||
|
op.add_column("cal_event", sa.Column("rrule", S(), nullable=False, server_default=""))
|
||||||
|
op.add_column(
|
||||||
|
"cal_event", sa.Column("reminders", sa.JSON(), nullable=False, server_default="[]")
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"cal_event", sa.Column("attendees", sa.JSON(), nullable=False, server_default="[]")
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"cal_event", sa.Column("response_status", S(), nullable=False, server_default="")
|
||||||
|
)
|
||||||
|
# 작업 댓글: 수정 시각
|
||||||
|
op.add_column("task_comment", sa.Column("edited_at", sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("task_comment", "edited_at")
|
||||||
|
op.drop_column("cal_event", "response_status")
|
||||||
|
op.drop_column("cal_event", "attendees")
|
||||||
|
op.drop_column("cal_event", "reminders")
|
||||||
|
op.drop_column("cal_event", "rrule")
|
||||||
|
op.drop_column("outbound_mail", "attachments")
|
||||||
|
op.drop_column("outbound_mail", "bcc")
|
||||||
|
op.drop_column("outbound_mail", "cc")
|
||||||
|
op.drop_column("email", "thread_id")
|
||||||
|
op.drop_column("email", "cc")
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
"""phase-19: 원본 HTML 본문(email.body_html) — 샌드박스 iframe 렌더용
|
||||||
|
|
||||||
|
Revision ID: p19a7b8c9d0e1
|
||||||
|
Revises: p18f6a7b8c9d0
|
||||||
|
Create Date: 2026-06-17 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "p19a7b8c9d0e1"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "p18f6a7b8c9d0"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("email", sa.Column("body_html", sa.Text(), nullable=False, server_default=""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("email", "body_html")
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
"""phase-20: 수신 시각(email.received_at) — 목록 최신순 정렬·표시 기준
|
||||||
|
|
||||||
|
Revision ID: p20b8c9d0e1f2
|
||||||
|
Revises: p19a7b8c9d0e1
|
||||||
|
Create Date: 2026-06-18 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "p20b8c9d0e1f2"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "p19a7b8c9d0e1"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("email", sa.Column("received_at", sa.DateTime(), nullable=True))
|
||||||
|
op.create_index("ix_email_received_at", "email", ["received_at"])
|
||||||
|
# 기존 행 백필: ExternalLink.external_updated_at(실제 수신 시각)에서 채운다.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE email SET received_at = (
|
||||||
|
SELECT el.external_updated_at FROM external_link el
|
||||||
|
WHERE el.entity_id = email.id AND el.entity_type = 'email'
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE received_at IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_email_received_at", table_name="email")
|
||||||
|
op.drop_column("email", "received_at")
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
"""phase-21: 계정별 실제 폴더 미러(account_folder) + email.folder 라우팅
|
||||||
|
|
||||||
|
Revision ID: p21c9d0e1f2a3
|
||||||
|
Revises: p20b8c9d0e1f2
|
||||||
|
Create Date: 2026-06-19 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "p21c9d0e1f2a3"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "p20b8c9d0e1f2"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"email",
|
||||||
|
sa.Column("folder", sa.String(), nullable=False, server_default="inbox"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"email",
|
||||||
|
sa.Column("folder_name", sa.String(), nullable=False, server_default=""),
|
||||||
|
)
|
||||||
|
op.create_index("ix_email_folder", "email", ["folder"])
|
||||||
|
op.create_table(
|
||||||
|
"account_folder",
|
||||||
|
sa.Column("id", sa.String(), primary_key=True),
|
||||||
|
sa.Column("mail_account", sa.String(), sa.ForeignKey("mail_account.id"), index=True),
|
||||||
|
sa.Column("slug", sa.String(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(), nullable=False, server_default="system"),
|
||||||
|
sa.Column("icon", sa.String(), nullable=False, server_default="folder"),
|
||||||
|
sa.Column("provider_id", sa.String(), nullable=False, server_default=""),
|
||||||
|
sa.Column("unread", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("total", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("account_folder")
|
||||||
|
op.drop_index("ix_email_folder", table_name="email")
|
||||||
|
op.drop_column("email", "folder_name")
|
||||||
|
op.drop_column("email", "folder")
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
"""phase-22: email.inference(Outlook 집중/기타 focused/other 분류)
|
||||||
|
|
||||||
|
Revision ID: p22d0e1f2a3b4
|
||||||
|
Revises: p21c9d0e1f2a3
|
||||||
|
Create Date: 2026-06-19 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "p22d0e1f2a3b4"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "p21c9d0e1f2a3"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"email",
|
||||||
|
sa.Column("inference", sa.String(), nullable=False, server_default=""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("email", "inference")
|
||||||
@ -1,31 +0,0 @@
|
|||||||
# backend/tests/test_seed_calendar.py
|
|
||||||
# 주의: 이 프로젝트 conftest 의 `session` fixture 는 시드를 적재하지 않는다.
|
|
||||||
# `client` 가 같은 엔진에 시드를 적재하므로, DB 직접 접근 테스트는 (client, session) 둘 다 요청한다.
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from app.models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction
|
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_seed_counts(client, session):
|
|
||||||
s, _ = session
|
|
||||||
assert len(s.exec(select(Calendar)).all()) == 5
|
|
||||||
assert len(s.exec(select(CalEvent)).all()) == 22
|
|
||||||
assert len(s.exec(select(FocusBlock)).all()) == 4
|
|
||||||
assert {m.id for m in s.exec(select(Meeting)).all()} == {"e3", "e4", "e6", "e7"}
|
|
||||||
e3 = [a for a in s.exec(select(MeetingAction)).all() if a.meeting_id == "e3"]
|
|
||||||
assert len(e3) == 3
|
|
||||||
assert sum(1 for a in e3 if not a.added) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_focus_seed_deep(client, session):
|
|
||||||
s, _ = session
|
|
||||||
deep = [f for f in s.exec(select(FocusBlock)).all() if f.type == "deep"]
|
|
||||||
assert len(deep) == 1 and deep[0].id == "f3"
|
|
||||||
assert deep[0].start == "15:10" and deep[0].end == "16:25"
|
|
||||||
|
|
||||||
|
|
||||||
def test_event_actions_seeded(client, session):
|
|
||||||
s, _ = session
|
|
||||||
eacts = [a for a in s.exec(select(MeetingAction)).all() if a.source == "event"]
|
|
||||||
assert {a.event_id for a in eacts} == {"e3", "e4"}
|
|
||||||
assert len(eacts) == 4
|
|
||||||
Loading…
Reference in New Issue