Deliver Step 9: agent-card.json, SKILL.md, USDA lookup, integrations dynamic config, Welcome/Integrations/MealPlan pages, API methods, README, routes
This commit is contained in:
parent
6fb5d759ef
commit
c133a463ec
10 changed files with 751 additions and 33 deletions
|
|
@ -153,3 +153,117 @@ async def disconnect(
|
|||
if token:
|
||||
await db.delete(token)
|
||||
return {"status": "disconnected", "provider": provider}
|
||||
|
||||
|
||||
# --- Dynamic Integration Config (v3) ---
|
||||
|
||||
from pydantic import BaseModel
|
||||
from app.models.integration_config import IntegrationConfig
|
||||
from app.services.provider_registry import PROVIDER_REGISTRY
|
||||
from app.services.crypto import encrypt_value, decrypt_value
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class ConfigureRequest(BaseModel):
|
||||
provider: str
|
||||
credentials: dict[str, str]
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def full_integration_status(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Return connection status for all providers. Never returns actual credentials."""
|
||||
# OAuth tokens (Strava, Polar)
|
||||
oauth_result = await db.execute(select(OAuthToken).where(OAuthToken.user_id == user.id))
|
||||
oauth_tokens = {t.provider: True for t in oauth_result.scalars().all()}
|
||||
|
||||
# Dynamic config entries
|
||||
config_result = await db.execute(
|
||||
select(IntegrationConfig.provider)
|
||||
.where(IntegrationConfig.user_id == user.id)
|
||||
.distinct()
|
||||
)
|
||||
configured_providers = {row[0] for row in config_result.all()}
|
||||
|
||||
# Also check env vars for backward compatibility
|
||||
env_providers = set()
|
||||
if settings.strava_client_id:
|
||||
env_providers.add("strava")
|
||||
if settings.polar_client_id:
|
||||
env_providers.add("polar")
|
||||
if settings.telegram_bot_token:
|
||||
env_providers.add("telegram")
|
||||
if settings.groq_api_key:
|
||||
env_providers.add("groq")
|
||||
|
||||
status = {}
|
||||
for key, info in PROVIDER_REGISTRY.items():
|
||||
if key in oauth_tokens:
|
||||
status[key] = "connected"
|
||||
elif key in configured_providers or key in env_providers:
|
||||
status[key] = "configured"
|
||||
else:
|
||||
status[key] = "not_configured"
|
||||
|
||||
return status
|
||||
|
||||
|
||||
@router.get("/providers")
|
||||
async def list_providers():
|
||||
"""Return the provider registry with setup instructions."""
|
||||
result = {}
|
||||
for key, info in PROVIDER_REGISTRY.items():
|
||||
result[key] = {
|
||||
"name": info["name"],
|
||||
"type": info["type"],
|
||||
"fields": info["fields"],
|
||||
"help_url": info.get("help_url", ""),
|
||||
"help_text": info.get("help_text", "").replace("{BASE_URL}", settings.base_url),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/configure")
|
||||
async def configure_integration(
|
||||
body: ConfigureRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Store encrypted integration credentials. Write-only — values can never be read back."""
|
||||
if body.provider not in PROVIDER_REGISTRY:
|
||||
raise HTTPException(400, f"Unknown provider: {body.provider}")
|
||||
|
||||
info = PROVIDER_REGISTRY[body.provider]
|
||||
expected_fields = set(info["fields"])
|
||||
provided_fields = set(body.credentials.keys())
|
||||
|
||||
missing = expected_fields - provided_fields
|
||||
if missing:
|
||||
raise HTTPException(400, f"Missing required fields: {', '.join(missing)}")
|
||||
|
||||
for key, value in body.credentials.items():
|
||||
encrypted = encrypt_value(settings.secret_key, value)
|
||||
|
||||
# Upsert
|
||||
existing = await db.execute(
|
||||
select(IntegrationConfig).where(
|
||||
IntegrationConfig.user_id == user.id,
|
||||
IntegrationConfig.provider == body.provider,
|
||||
IntegrationConfig.config_key == key,
|
||||
)
|
||||
)
|
||||
row = existing.scalar_one_or_none()
|
||||
if row:
|
||||
row.config_value = encrypted
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
db.add(IntegrationConfig(
|
||||
user_id=user.id,
|
||||
provider=body.provider,
|
||||
config_key=key,
|
||||
config_value=encrypted,
|
||||
))
|
||||
|
||||
return {"status": "configured", "provider": body.provider}
|
||||
|
|
|
|||
59
backend/app/services/usda_lookup.py
Normal file
59
backend/app/services/usda_lookup.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""USDA FoodData Central lookup service.
|
||||
|
||||
Free API with 400K+ foods and research-grade nutrition data.
|
||||
API key required (free, instant signup at https://fdc.nal.usda.gov/api-key-signup).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USDA_BASE = "https://api.nal.usda.gov/fdc/v1"
|
||||
|
||||
|
||||
async def search_usda(query: str, api_key: str, page_size: int = 10) -> list[dict]:
|
||||
"""Search USDA FoodData Central for food items.
|
||||
|
||||
Returns a list of dicts with: fdcId, description, brandName, calories,
|
||||
protein_g, carbs_g, fat_g, serving_size.
|
||||
"""
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{USDA_BASE}/foods/search",
|
||||
params={
|
||||
"api_key": api_key,
|
||||
"query": query,
|
||||
"pageSize": page_size,
|
||||
"dataType": "Foundation,SR Legacy,Branded",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = []
|
||||
for food in data.get("foods", []):
|
||||
nutrients = {n["nutrientName"]: n["value"] for n in food.get("foodNutrients", [])}
|
||||
results.append({
|
||||
"fdcId": food.get("fdcId"),
|
||||
"description": food.get("description", ""),
|
||||
"brandName": food.get("brandName"),
|
||||
"source": "usda",
|
||||
"calories": nutrients.get("Energy"),
|
||||
"protein_g": nutrients.get("Protein"),
|
||||
"carbs_g": nutrients.get("Carbohydrate, by difference"),
|
||||
"fat_g": nutrients.get("Total lipid (fat)"),
|
||||
"fiber_g": nutrients.get("Fiber, total dietary"),
|
||||
"serving_size": food.get("servingSize"),
|
||||
"serving_unit": food.get("servingSizeUnit"),
|
||||
})
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"USDA search failed: {e}")
|
||||
return []
|
||||
Loading…
Add table
Add a link
Reference in a new issue