feat(scouts): add cloud scout CRUD routes + serializer

This commit is contained in:
Roberto
2026-06-10 15:29:02 +02:00
parent 4cd1ac11cc
commit 1c65bbfe75
2 changed files with 212 additions and 0 deletions

View File

@@ -42,6 +42,9 @@ from app.db import get_session
from app.integrations import encrypt_token from app.integrations import encrypt_token
from app.models import CloudScoutConfig, ScoutRunLog, LocalScoutConfig from app.models import CloudScoutConfig, ScoutRunLog, LocalScoutConfig
from app.schemas import ( from app.schemas import (
CloudScoutCreateRequest,
CloudScoutResponse,
CloudScoutUpdateRequest,
ScoutCatalogItem, ScoutCatalogItem,
ScoutCreationCheckRequest, ScoutCreationCheckRequest,
ScoutCreationCheckResponse, ScoutCreationCheckResponse,
@@ -269,6 +272,109 @@ async def summarize_note(
return NoteSummarizeResponse(summary=summary) return NoteSummarizeResponse(summary=summary)
# ── Cloud scout CRUD ──────────────────────────────────────────────────────────
_DEFAULT_CLOUD_SCHEDULE = "0 */6 * * *"
def _to_cloud_response(scout: CloudScoutConfig) -> dict:
return {
"id": scout.id,
"user_id": scout.user_id,
"provider": scout.provider,
"name": scout.name,
"data_types": scout.data_types or [],
"prompt_template": scout.prompt_template or "",
"schedule_cron": scout.schedule_cron,
"filter_config": scout.filter_config,
"auto_trash_spam": scout.auto_trash_spam,
"enabled": scout.enabled,
"last_run_at": _dt_ms_opt(scout.last_run_at),
"gmail_address": scout.gmail_address,
"oauth_connected": scout.oauth_token_encrypted is not None,
"created_at": _dt_ms(scout.created_at),
"updated_at": _dt_ms(scout.updated_at),
}
@router.get("/cloud", response_model=list[CloudScoutResponse])
async def list_cloud_scouts(
db: AsyncSession = Depends(get_session),
current_user: UserProfile = Depends(get_current_user),
):
rows = (await db.execute(
select(CloudScoutConfig).where(CloudScoutConfig.user_id == current_user.id)
)).scalars().all()
return [_to_cloud_response(s) for s in rows]
@router.post("/cloud", response_model=CloudScoutResponse, status_code=status.HTTP_201_CREATED)
async def create_cloud_scout(
body: CloudScoutCreateRequest,
db: AsyncSession = Depends(get_session),
current_user: UserProfile = Depends(get_current_user),
):
scout = CloudScoutConfig(
id=str(uuid.uuid4()),
user_id=current_user.id,
provider=body.provider,
name=body.name,
data_types=body.data_types,
prompt_template=body.prompt_template,
filter_config=body.filter_config,
schedule_cron=body.schedule_cron or _DEFAULT_CLOUD_SCHEDULE,
auto_trash_spam=body.auto_trash_spam,
enabled=True,
)
db.add(scout)
await db.commit()
await db.refresh(scout)
return _to_cloud_response(scout)
@router.put("/cloud/{scout_id}", response_model=CloudScoutResponse)
async def update_cloud_scout(
scout_id: str,
body: CloudScoutUpdateRequest,
db: AsyncSession = Depends(get_session),
current_user: UserProfile = Depends(get_current_user),
):
scout = await db.get(CloudScoutConfig, scout_id)
if scout is None or scout.user_id != current_user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found")
if body.name is not None:
scout.name = body.name
if body.data_types is not None:
scout.data_types = body.data_types
if body.prompt_template is not None:
scout.prompt_template = body.prompt_template
if body.schedule_cron is not None:
scout.schedule_cron = body.schedule_cron
if body.filter_config is not None:
scout.filter_config = body.filter_config
if body.auto_trash_spam is not None:
scout.auto_trash_spam = body.auto_trash_spam
if body.enabled is not None:
scout.enabled = body.enabled
await db.commit()
await db.refresh(scout)
return _to_cloud_response(scout)
@router.delete("/cloud/{scout_id}")
async def delete_cloud_scout(
scout_id: str,
db: AsyncSession = Depends(get_session),
current_user: UserProfile = Depends(get_current_user),
):
scout = await db.get(CloudScoutConfig, scout_id)
if scout is None or scout.user_id != current_user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found")
await db.delete(scout)
await db.commit()
return {"ok": True}
# ── Gmail OAuth setup (scout-specific) ─────────────────────────────────────── # ── Gmail OAuth setup (scout-specific) ───────────────────────────────────────
# Scopes required for Gmail scout connectivity. # Scopes required for Gmail scout connectivity.

View File

@@ -0,0 +1,106 @@
"""Tests for cloud scout CRUD routes."""
from __future__ import annotations
import uuid
from unittest.mock import AsyncMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from app.db import get_session
from app.main import app
from app.models import CloudScoutConfig
from tests.conftest import _TestSessionLocal, make_jwt
def _auth_headers(tier: str = "power") -> dict:
return {"Authorization": f"Bearer {make_jwt(tier)}"}
async def _test_get_session():
async with _TestSessionLocal() as session:
yield session
@pytest.fixture(autouse=True)
def _override_session():
# FastAPI resolves Depends() by the original function object, so patching the
# module-level name does not take effect — use dependency_overrides instead.
app.dependency_overrides[get_session] = _test_get_session
yield
app.dependency_overrides.pop(get_session, None)
@pytest.mark.asyncio
async def test_create_cloud_scout_defaults_schedule():
payload = {
"name": "Inbox",
"provider": "gmail",
"data_types": [],
"prompt_template": "client requests",
"auto_trash_spam": True,
# schedule_cron omitted → server default
}
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.post("/api/v1/scouts/cloud", json=payload, headers=_auth_headers())
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["name"] == "Inbox"
assert body["provider"] == "gmail"
assert body["auto_trash_spam"] is True
assert body["prompt_template"] == "client requests"
assert body["schedule_cron"] # non-empty default applied
assert body["oauth_connected"] is False
assert body["gmail_address"] is None
@pytest.mark.asyncio
async def test_list_cloud_scouts_returns_only_own():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
await client.post(
"/api/v1/scouts/cloud",
json={"name": "A", "provider": "gmail"},
headers=_auth_headers(),
)
resp = await client.get("/api/v1/scouts/cloud", headers=_auth_headers())
assert resp.status_code == 200
rows = resp.json()
assert all(r["provider"] == "gmail" for r in rows)
assert any(r["name"] == "A" for r in rows)
@pytest.mark.asyncio
async def test_update_cloud_scout_applies_filter_and_autotrash():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
created = (await client.post(
"/api/v1/scouts/cloud",
json={"name": "B", "provider": "gmail"},
headers=_auth_headers(),
)).json()
sid = created["id"]
resp = await client.put(
f"/api/v1/scouts/cloud/{sid}",
json={"filter_config": {"labels": ["INBOX"], "senders": ["@client.co"]}, "auto_trash_spam": True, "prompt_template": "invoices"},
headers=_auth_headers(),
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["filter_config"] == {"labels": ["INBOX"], "senders": ["@client.co"]}
assert body["auto_trash_spam"] is True
assert body["prompt_template"] == "invoices"
@pytest.mark.asyncio
async def test_delete_cloud_scout():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
created = (await client.post(
"/api/v1/scouts/cloud",
json={"name": "C", "provider": "gmail"},
headers=_auth_headers(),
)).json()
sid = created["id"]
resp = await client.delete(f"/api/v1/scouts/cloud/{sid}", headers=_auth_headers())
assert resp.status_code == 200
listing = (await client.get("/api/v1/scouts/cloud", headers=_auth_headers())).json()
assert all(r["id"] != sid for r in listing)