feat(mcp): 6 outils shopping + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,12 +4,13 @@ from datetime import datetime, timedelta, timezone, date as date_type
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
from sqlalchemy import select, and_, text
|
from sqlalchemy import select, and_, text, or_
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.core.database import AsyncSessionLocal
|
from app.core.database import AsyncSessionLocal
|
||||||
from app.models.todos import TodoItem
|
from app.models.todos import TodoItem
|
||||||
from app.models.notes import NoteItem
|
from app.models.notes import NoteItem
|
||||||
|
from app.models.shopping import ShoppingList, ListItem, Product
|
||||||
|
|
||||||
_VALID_STATUSES = {"pending", "done", "cancelled"}
|
_VALID_STATUSES = {"pending", "done", "cancelled"}
|
||||||
_VALID_PRIORITIES = {"low", "medium", "high"}
|
_VALID_PRIORITIES = {"low", "medium", "high"}
|
||||||
@@ -332,3 +333,176 @@ async def delete_note(id: str) -> str:
|
|||||||
await session.delete(note)
|
await session.delete(note)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return _dumps({"deleted": id})
|
return _dumps({"deleted": id})
|
||||||
|
|
||||||
|
|
||||||
|
# ── SHOPPING ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_shopping_lists() -> str:
|
||||||
|
"""Retourne toutes les listes de courses avec compteurs d'articles cochés/total."""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = (
|
||||||
|
select(ShoppingList)
|
||||||
|
.options(selectinload(ShoppingList.items))
|
||||||
|
.order_by(ShoppingList.created_at.desc())
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
lists = result.scalars().all()
|
||||||
|
return _dumps([{
|
||||||
|
"id": lst.id,
|
||||||
|
"name": lst.name,
|
||||||
|
"status": lst.status,
|
||||||
|
"week_date": lst.week_date,
|
||||||
|
"created_at": lst.created_at,
|
||||||
|
"item_count": len(lst.items),
|
||||||
|
"checked_count": sum(1 for i in lst.items if i.is_checked),
|
||||||
|
} for lst in lists])
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_active_shopping_list() -> str:
|
||||||
|
"""Retourne la première liste en statut 'draft' avec tous ses articles triés."""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = (
|
||||||
|
select(ShoppingList)
|
||||||
|
.where(ShoppingList.status == "draft")
|
||||||
|
.options(selectinload(ShoppingList.items).selectinload(ListItem.product))
|
||||||
|
.order_by(ShoppingList.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
lst = result.scalar_one_or_none()
|
||||||
|
if not lst:
|
||||||
|
return _dumps({"error": "Aucune liste active (statut draft)"})
|
||||||
|
sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id)))
|
||||||
|
return _dumps({
|
||||||
|
"id": lst.id,
|
||||||
|
"name": lst.name,
|
||||||
|
"status": lst.status,
|
||||||
|
"item_count": len(lst.items),
|
||||||
|
"checked_count": sum(1 for i in lst.items if i.is_checked),
|
||||||
|
"items": [{
|
||||||
|
"id": item.id,
|
||||||
|
"name": item.custom_name or (item.product.name if item.product else "Article inconnu"),
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"unit": item.unit,
|
||||||
|
"is_checked": item.is_checked,
|
||||||
|
} for item in sorted_items],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_products(q: str) -> str:
|
||||||
|
"""Recherche dans le catalogue produits par nom, description ou catégorie."""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = (
|
||||||
|
select(Product)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
Product.name.ilike(f"%{q}%"),
|
||||||
|
Product.description.ilike(f"%{q}%"),
|
||||||
|
Product.category.ilike(f"%{q}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Product.frequency_score.desc())
|
||||||
|
.limit(20)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
products = result.scalars().all()
|
||||||
|
return _dumps([{
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"brand": p.brand,
|
||||||
|
"category": p.category,
|
||||||
|
"default_unit": p.default_unit,
|
||||||
|
"frequency_score": p.frequency_score,
|
||||||
|
"last_purchased_at": p.last_purchased_at,
|
||||||
|
} for p in products])
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def create_shopping_list(name: str | None = None) -> str:
|
||||||
|
"""Crée une liste de courses. name auto = semaine ISO courante si absent (ex: 'S22 2026')."""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
lst = ShoppingList(name=name or _iso_week_label())
|
||||||
|
session.add(lst)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(lst)
|
||||||
|
return _dumps({
|
||||||
|
"id": lst.id,
|
||||||
|
"name": lst.name,
|
||||||
|
"status": lst.status,
|
||||||
|
"created_at": lst.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def add_shopping_item(
|
||||||
|
list_id: str,
|
||||||
|
name: str,
|
||||||
|
quantity: float | None = 1.0,
|
||||||
|
unit: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Ajoute un article à une liste de courses."""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
lid = uuid.UUID(list_id)
|
||||||
|
except ValueError:
|
||||||
|
return _dumps({"error": f"UUID de liste invalide : {list_id}"})
|
||||||
|
lst = await session.get(ShoppingList, lid)
|
||||||
|
if not lst:
|
||||||
|
return _dumps({"error": f"Liste introuvable : {list_id}"})
|
||||||
|
item = ListItem(
|
||||||
|
list_id=lid,
|
||||||
|
custom_name=name,
|
||||||
|
quantity=Decimal(str(quantity)) if quantity is not None else None,
|
||||||
|
unit=unit,
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(item)
|
||||||
|
return _dumps({
|
||||||
|
"id": item.id,
|
||||||
|
"name": item.custom_name,
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"unit": item.unit,
|
||||||
|
"is_checked": item.is_checked,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def check_shopping_item(list_id: str, item_id: str) -> str:
|
||||||
|
"""Coche un article (marque comme acheté). Met à jour les stats du produit lié si présent."""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
lid = uuid.UUID(list_id)
|
||||||
|
iid = uuid.UUID(item_id)
|
||||||
|
except ValueError:
|
||||||
|
return _dumps({"error": "UUID invalide (list_id ou item_id)"})
|
||||||
|
stmt = (
|
||||||
|
select(ListItem)
|
||||||
|
.where(ListItem.id == iid, ListItem.list_id == lid)
|
||||||
|
.options(selectinload(ListItem.product))
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
if not item:
|
||||||
|
return _dumps({"error": f"Article introuvable : {item_id} dans liste {list_id}"})
|
||||||
|
was_checked = item.is_checked
|
||||||
|
item.is_checked = True
|
||||||
|
if not was_checked and item.product_id:
|
||||||
|
product = await session.get(Product, item.product_id)
|
||||||
|
if product:
|
||||||
|
today = date_type.today()
|
||||||
|
if product.last_purchased_at and product.last_purchased_at < today:
|
||||||
|
days = (today - product.last_purchased_at).days
|
||||||
|
if product.avg_interval_days is None:
|
||||||
|
product.avg_interval_days = Decimal(str(days))
|
||||||
|
else:
|
||||||
|
product.avg_interval_days = Decimal(str(
|
||||||
|
round(float(product.avg_interval_days) * 0.7 + days * 0.3, 1)
|
||||||
|
))
|
||||||
|
product.last_purchased_at = today
|
||||||
|
product.frequency_score += 1
|
||||||
|
await session.commit()
|
||||||
|
return _dumps({"id": item.id, "is_checked": item.is_checked})
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ from app.api.mcp_server import (
|
|||||||
from app.api.mcp_server import (
|
from app.api.mcp_server import (
|
||||||
search_notes, get_note, create_note, update_note, delete_note,
|
search_notes, get_note, create_note, update_note, delete_note,
|
||||||
)
|
)
|
||||||
|
from app.api.mcp_server import (
|
||||||
|
get_shopping_lists, get_active_shopping_list, search_products,
|
||||||
|
create_shopping_list, add_shopping_item, check_shopping_item,
|
||||||
|
)
|
||||||
from app.models.todos import TodoItem
|
from app.models.todos import TodoItem
|
||||||
from app.models.notes import NoteItem
|
from app.models.notes import NoteItem
|
||||||
|
from app.models.shopping import ShoppingList, ListItem
|
||||||
|
|
||||||
pytestmark = pytest.mark.usefixtures("mcp_nullpool_session")
|
pytestmark = pytest.mark.usefixtures("mcp_nullpool_session")
|
||||||
|
|
||||||
@@ -21,6 +26,7 @@ async def cleanup_mcp_todos():
|
|||||||
async with mcp_server_module.AsyncSessionLocal() as session:
|
async with mcp_server_module.AsyncSessionLocal() as session:
|
||||||
await session.execute(delete(TodoItem).where(TodoItem.title.like("TEST_MCP_%")))
|
await session.execute(delete(TodoItem).where(TodoItem.title.like("TEST_MCP_%")))
|
||||||
await session.execute(delete(NoteItem).where(NoteItem.title.like("TEST_MCP_%")))
|
await session.execute(delete(NoteItem).where(NoteItem.title.like("TEST_MCP_%")))
|
||||||
|
await session.execute(delete(ShoppingList).where(ShoppingList.name.like("TEST_MCP_%")))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -128,3 +134,72 @@ async def test_get_note_introuvable():
|
|||||||
result = await get_note(id="00000000-0000-0000-0000-000000000000")
|
result = await get_note(id="00000000-0000-0000-0000-000000000000")
|
||||||
data = json.loads(result)
|
data = json.loads(result)
|
||||||
assert "error" in data
|
assert "error" in data
|
||||||
|
|
||||||
|
|
||||||
|
# ── SHOPPING ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_get_shopping_lists_retourne_liste():
|
||||||
|
result = await get_shopping_lists()
|
||||||
|
data = json.loads(result)
|
||||||
|
assert isinstance(data, list)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_shopping_list_outil():
|
||||||
|
result = await create_shopping_list(name="TEST_MCP_liste")
|
||||||
|
data = json.loads(result)
|
||||||
|
assert data["name"] == "TEST_MCP_liste"
|
||||||
|
assert data["status"] == "draft"
|
||||||
|
assert "id" in data
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_shopping_list_nom_auto():
|
||||||
|
result = await create_shopping_list()
|
||||||
|
data = json.loads(result)
|
||||||
|
assert data["name"].startswith("S")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_shopping_item_outil():
|
||||||
|
liste = json.loads(await create_shopping_list(name="TEST_MCP_liste_item"))
|
||||||
|
result = await add_shopping_item(
|
||||||
|
list_id=str(liste["id"]),
|
||||||
|
name="TEST_MCP_article",
|
||||||
|
quantity=2.0,
|
||||||
|
unit="kg",
|
||||||
|
)
|
||||||
|
data = json.loads(result)
|
||||||
|
assert data["name"] == "TEST_MCP_article"
|
||||||
|
assert data["is_checked"] is False
|
||||||
|
assert float(data["quantity"]) == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_shopping_item_outil():
|
||||||
|
liste = json.loads(await create_shopping_list(name="TEST_MCP_liste_check"))
|
||||||
|
article = json.loads(await add_shopping_item(
|
||||||
|
list_id=str(liste["id"]),
|
||||||
|
name="TEST_MCP_article_check",
|
||||||
|
))
|
||||||
|
result = await check_shopping_item(
|
||||||
|
list_id=str(liste["id"]),
|
||||||
|
item_id=str(article["id"]),
|
||||||
|
)
|
||||||
|
data = json.loads(result)
|
||||||
|
assert data["is_checked"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_products_retourne_liste():
|
||||||
|
result = await search_products(q="inexistant_xyz_abc_999")
|
||||||
|
data = json.loads(result)
|
||||||
|
assert isinstance(data, list)
|
||||||
|
assert data == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_active_shopping_list_structure():
|
||||||
|
result = await get_active_shopping_list()
|
||||||
|
data = json.loads(result)
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_item_liste_invalide():
|
||||||
|
result = await add_shopping_item(list_id="pas-un-uuid", name="article")
|
||||||
|
data = json.loads(result)
|
||||||
|
assert "error" in data
|
||||||
|
|||||||
Reference in New Issue
Block a user