Compare commits
7 commits
214e3e0111
...
ad3da57174
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad3da57174 | ||
|
|
2411fc5b99 | ||
|
|
c37543b009 | ||
|
|
40c8485aa7 | ||
|
|
df39a8dc08 | ||
|
|
a6e6e2f54f | ||
|
|
0ce1463de8 |
89 changed files with 4280 additions and 1061 deletions
34
.env.example
34
.env.example
|
|
@ -1,19 +1,23 @@
|
||||||
# === REQUIRED (generated automatically by setup.sh) ===
|
# Diligence Configuration
|
||||||
SECRET_KEY=
|
# Copy this to .env and fill in values, or run ./setup.sh to auto-generate.
|
||||||
|
|
||||||
|
# Required — auto-generated by setup.sh
|
||||||
|
SECRET_KEY=change-me-in-production
|
||||||
API_TOKEN=
|
API_TOKEN=
|
||||||
|
|
||||||
# === APP URL (change for production) ===
|
# Database — leave empty for SQLite (pip install path)
|
||||||
BASE_URL=http://localhost
|
# Set for PostgreSQL (Docker path): postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards
|
||||||
|
DATABASE_URL=
|
||||||
|
|
||||||
# === DATABASE (defaults work out of the box) ===
|
# App
|
||||||
DB_USER=fitness
|
BASE_URL=http://localhost:8000
|
||||||
DB_NAME=fitness_rewards
|
|
||||||
|
|
||||||
# === MCP AGENT AUTH (generated automatically by setup.sh) ===
|
# Optional integrations
|
||||||
# The API_TOKEN authenticates the MCP connector with the backend.
|
STRAVA_CLIENT_ID=
|
||||||
# It is auto-configured — you don't need to touch it unless you're
|
STRAVA_CLIENT_SECRET=
|
||||||
# connecting an agent from outside Docker.
|
POLAR_CLIENT_ID=
|
||||||
|
POLAR_CLIENT_SECRET=
|
||||||
# Everything else — Strava, Polar, Garmin, Fitbit, Telegram,
|
GROQ_API_KEY=
|
||||||
# USDA, Groq, etc. — is configured through the app UI or your AI agent.
|
TELEGRAM_BOT_TOKEN=
|
||||||
# No container restart needed.
|
TELEGRAM_CHAT_ID=
|
||||||
|
CRAWL_ENABLED=false
|
||||||
|
|
|
||||||
31
.gitignore
vendored
31
.gitignore
vendored
|
|
@ -1,12 +1,31 @@
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.env
|
|
||||||
.venv/
|
|
||||||
node_modules/
|
|
||||||
dist/
|
dist/
|
||||||
.DS_Store
|
build/
|
||||||
*.log
|
*.egg
|
||||||
|
|
||||||
# Excluded from open-source (scraped third-party content)
|
# Node
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Data
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Content (scraped recipes — not for distribution)
|
||||||
content/
|
content/
|
||||||
|
|
|
||||||
376
README.md
376
README.md
|
|
@ -1,237 +1,211 @@
|
||||||
# Diligence — Self-Hosted Fitness Rewards with AI Agent Support
|
# Diligence
|
||||||
|
|
||||||
Points-based fitness accountability app. Log workouts and food, earn points, unlock rewards. Connect any AI agent via MCP for logging, coaching, and meal planning. Self-hosted, open source, zero cloud dependencies.
|
Self-hosted fitness rewards platform with AI agent integration. Points-based behavioral economy: earn points through workouts and food logging, spend them on rewards you choose. Science-based onboarding. 14-tool MCP connector for AI agents. Your data stays on your machine.
|
||||||
|
|
||||||
Built by [DiligenceWorks](https://diligenceworks.online).
|
**By [DiligenceWorks Pte. Ltd.](https://diligenceworks.online) — MIT License**
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- [Docker Desktop](https://www.docker.com/products/docker-desktop/) — that's it. No Python, Node.js, or database install needed.
|
|
||||||
- Docker Desktop runs natively on **Windows 10/11**, **macOS**, and **Linux**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Windows 11 Setup
|
## Which install is right for you?
|
||||||
|
|
||||||
**1. Install Docker Desktop**
|
| | **pip install** | **Docker** |
|
||||||
|
|---|---|---|
|
||||||
|
| **Who it's for** | You want a fitness app on your laptop. No servers, no DevOps, no fuss. | You're self-hosting for a household, a team, or you want PostgreSQL and a production-grade setup. |
|
||||||
|
| **What you need** | Python 3.11+ | Docker and Docker Compose |
|
||||||
|
| **Database** | SQLite (zero config, file on disk) | PostgreSQL 16 (runs in a container) |
|
||||||
|
| **Runs as** | Single process on localhost | 4 containers behind nginx |
|
||||||
|
| **Setup time** | 2 minutes | 5 minutes |
|
||||||
|
| **Best for** | Personal use on a laptop or desktop | Always-on server, multiple users, backups |
|
||||||
|
|
||||||
Download from [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/).
|
---
|
||||||
|
|
||||||
During installation, the installer presents a checkbox: **"Use WSL 2 instead of Hyper-V (recommended)"**. This is checked by default.
|
## Install — Personal Laptop (pip)
|
||||||
|
|
||||||
- **WSL 2 backend (default):** Works on all Windows editions including Home. Lower resource usage, better performance. Requires WSL 2 to be installed first — open PowerShell as Administrator and run `wsl --install`, then reboot.
|
**Prerequisites:** Python 3.11 or newer.
|
||||||
- **Hyper-V backend:** If WSL 2 causes issues (black screen on reboot, kernel errors, or containers won't start), uncheck the WSL 2 box during installation to use Hyper-V instead. Requires Windows Pro, Enterprise, or Education.
|
|
||||||
|
|
||||||
**Important:** Hardware virtualization must be enabled in your BIOS/UEFI settings (Intel VT-x or AMD-V). This is the most common cause of "Docker won't start" issues. Check your laptop/PC manufacturer's documentation for how to access BIOS settings (usually F2, F12, or Del during boot).
|
```bash
|
||||||
|
# Clone and install
|
||||||
|
git clone https://github.com/DiligenceWorks/Diligence.git
|
||||||
|
cd Diligence
|
||||||
|
pip install .
|
||||||
|
|
||||||
**2. Clone and run**
|
# Run
|
||||||
|
diligence
|
||||||
Open PowerShell:
|
|
||||||
```powershell
|
|
||||||
git clone https://github.com/diligenceworks/diligence
|
|
||||||
cd diligence
|
|
||||||
powershell -ExecutionPolicy Bypass -File setup.ps1
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or without the script:
|
That's it. The app opens in your browser at `http://localhost:8000`. Your data lives in `~/.diligence/`.
|
||||||
```powershell
|
|
||||||
git clone https://github.com/diligenceworks/diligence
|
On first run, Diligence generates a secret key and stores your config in `~/.diligence/.env`. The SQLite database is created automatically at `~/.diligence/data.db`.
|
||||||
cd diligence
|
|
||||||
copy .env.example .env
|
### Options
|
||||||
# Edit .env and set SECRET_KEY to any random string
|
|
||||||
docker compose up -d
|
```bash
|
||||||
|
diligence --port 9000 # Different port
|
||||||
|
diligence --no-browser # Don't auto-open browser
|
||||||
|
diligence --data-dir /my/path # Custom data directory
|
||||||
|
python -m diligence # Alternative way to run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building the frontend from source
|
||||||
|
|
||||||
|
The pip package includes a pre-built frontend. If you want to modify the UI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cp -r dist/ ../diligence/frontend/
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### macOS Setup
|
## Install — Server (Docker)
|
||||||
|
|
||||||
**1. Install Docker Desktop**
|
**Prerequisites:** Docker and Docker Compose.
|
||||||
|
|
||||||
Download from [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/). Make sure you pick the right installer for your chip:
|
|
||||||
|
|
||||||
- **Apple Silicon** (M1, M2, M3, M4) — download the Apple Silicon .dmg
|
|
||||||
- **Intel** — download the Intel .dmg
|
|
||||||
|
|
||||||
To check: Apple menu → About This Mac. Look for "Chip" (Apple Silicon) or "Processor" (Intel).
|
|
||||||
|
|
||||||
Drag Docker.app to Applications and launch it. Requires macOS 13 Ventura or later.
|
|
||||||
|
|
||||||
Alternatively, install via Homebrew: `brew install --cask docker`
|
|
||||||
|
|
||||||
**2. Clone and run**
|
|
||||||
|
|
||||||
Open Terminal:
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/diligenceworks/diligence
|
git clone https://github.com/DiligenceWorks/Diligence.git
|
||||||
cd diligence
|
cd Diligence
|
||||||
./setup.sh
|
./setup.sh # generates .env with secrets
|
||||||
docker compose up -d
|
docker compose up -d # starts 4 containers
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost` (or your server's IP). Register your account — the first user gets admin.
|
||||||
|
|
||||||
|
### What's running
|
||||||
|
|
||||||
|
| Container | Role | Port |
|
||||||
|
|-----------|------|------|
|
||||||
|
| frontend | React app + nginx reverse proxy | 80 |
|
||||||
|
| backend | FastAPI application server | 8000 (internal) |
|
||||||
|
| mcp-connector | MCP SSE server for AI agents | 3001 |
|
||||||
|
| fitness-db | PostgreSQL 16 | 5432 (internal) |
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` (or let `setup.sh` do it). Key variables:
|
||||||
|
|
||||||
|
| Variable | Docker default | Description |
|
||||||
|
|----------|---------------|-------------|
|
||||||
|
| `SECRET_KEY` | (generated) | JWT signing key |
|
||||||
|
| `API_TOKEN` | (generated) | MCP connector auth token |
|
||||||
|
| `DATABASE_URL` | `postgresql+asyncpg://...` | Database connection |
|
||||||
|
| `BASE_URL` | `http://localhost` | Public URL for OAuth callbacks |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI Agent Integration (MCP)
|
||||||
|
|
||||||
|
Diligence exposes 14 tools via the [Model Context Protocol](https://modelcontextprotocol.io). Any MCP-compatible AI agent (Claude, custom agents) can log workouts, track food, manage meal plans, and check progress.
|
||||||
|
|
||||||
|
### Connect your agent
|
||||||
|
|
||||||
|
**pip install path:**
|
||||||
|
```
|
||||||
|
URL: http://localhost:8000/mcp
|
||||||
|
Token: (shown on first run, or check ~/.diligence/.env)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker path:**
|
||||||
|
```
|
||||||
|
URL: http://localhost:3001/sse
|
||||||
|
Token: (in your .env file, API_TOKEN)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available tools
|
||||||
|
|
||||||
|
| Tool | What it does |
|
||||||
|
|------|-------------|
|
||||||
|
| `get_context()` | Full profile, motivation type, programs, rules, rewards |
|
||||||
|
| `get_today()` | Daily points, gate status, activities |
|
||||||
|
| `get_week()` | Weekly summary and target progress |
|
||||||
|
| `log_activity(...)` | Log a workout, earn points |
|
||||||
|
| `log_food(...)` | Log food with macros |
|
||||||
|
| `search_food(query)` | Search Open Food Facts + USDA (400K+ foods) |
|
||||||
|
| `get_program_schedule()` | Today's scheduled workout |
|
||||||
|
| `redeem_reward(name)` | Spend points on a reward |
|
||||||
|
| `load_meal_plan(...)` | Create a meal plan |
|
||||||
|
| `get_meal_plan()` | View today's meals |
|
||||||
|
| `update_meal_compliance(...)` | Mark meals as followed/skipped |
|
||||||
|
| `get_plan_progress()` | Compliance stats |
|
||||||
|
| `configure_integration(...)` | Store encrypted credentials |
|
||||||
|
| `get_integration_status()` | Check provider connections |
|
||||||
|
|
||||||
|
See `AGENT_GUIDE.md` for behavioral guidelines including BREQ-2 tone calibration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's inside
|
||||||
|
|
||||||
|
### Science-based onboarding
|
||||||
|
- **PAR-Q+** physical activity readiness screening
|
||||||
|
- **TTM** (Transtheoretical Model) stage assessment
|
||||||
|
- **BREQ-2** motivation profiling — the app adapts its tone to your motivation type
|
||||||
|
|
||||||
|
### Points engine
|
||||||
|
- Earn points for workouts, food logging, fasting, meal compliance
|
||||||
|
- Daily gate (minimum to unlock rewards)
|
||||||
|
- Weekly targets with reset
|
||||||
|
- Configurable reward shop — you decide what points are worth
|
||||||
|
|
||||||
|
### Integrations
|
||||||
|
Strava and Polar sync are built in. The provider registry supports Garmin, Fitbit, Withings, WHOOP, and Oura (OAuth flows ready, need provider approval). USDA FoodData Central for nutrition lookup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Diligence/
|
||||||
|
pyproject.toml # pip install config
|
||||||
|
docker-compose.yml # Docker config
|
||||||
|
diligence/ # Python package
|
||||||
|
cli.py # Entry point for pip path
|
||||||
|
main.py # FastAPI app
|
||||||
|
config.py # Settings (auto-detects SQLite vs PostgreSQL)
|
||||||
|
database.py # Dialect-agnostic database layer
|
||||||
|
models/ # 15 SQLAlchemy models
|
||||||
|
routers/ # 12 API routers
|
||||||
|
services/ # Points engine, food lookup, sync, crypto
|
||||||
|
utils/ # Auth helpers
|
||||||
|
mcp/ # MCP connector (14 tools)
|
||||||
|
frontend/ # Pre-built React app (pip path serves this)
|
||||||
|
frontend/ # React source code
|
||||||
|
backend/ # Dockerfile + requirements.txt (Docker path)
|
||||||
|
mcp-connector/ # MCP Dockerfile (Docker path)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Linux Setup
|
## Development
|
||||||
|
|
||||||
Install Docker Engine and Docker Compose via your distro's package manager, or install Docker Desktop for Linux. Then:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/diligenceworks/diligence
|
# Clone
|
||||||
cd diligence
|
git clone https://github.com/DiligenceWorks/Diligence.git
|
||||||
./setup.sh
|
cd Diligence
|
||||||
docker compose up -d
|
|
||||||
|
# Backend
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
diligence --port 8000
|
||||||
|
|
||||||
|
# Frontend (separate terminal)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Open http://localhost and create your account. First user gets admin.
|
## Data and privacy
|
||||||
|
|
||||||
### Verify
|
Your data never leaves your machine. There is no telemetry, no analytics, no cloud sync. The database is a single file (`~/.diligence/data.db` for pip, a Docker volume for Docker). Back it up however you back up your files.
|
||||||
|
|
||||||
```bash
|
Integration credentials (Strava, Polar, etc.) are encrypted at rest using Fernet with HKDF key derivation from your SECRET_KEY.
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see 4 containers, all healthy: `frontend`, `backend`, `mcp-connector`, `fitness-db`.
|
---
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Points economy** — earn from workouts, food logging, step goals. Daily gate locks rewards until you earn enough. Weekly reset.
|
|
||||||
- **Science-based onboarding** — PAR-Q+ safety screening, TTM stages of change, BREQ-2 motivation profiling.
|
|
||||||
- **AI agent integration** — 14 MCP tools for logging, coaching, meal planning, and device configuration.
|
|
||||||
- **Meal plans** — AI-generated plans with compliance tracking and points integration.
|
|
||||||
- **Activity sync** — Strava, Polar (OAuth 2.0). Garmin, Fitbit, Withings, WHOOP, Oura configurable in-app.
|
|
||||||
- **Food search** — Open Food Facts (4M+ products) + USDA FoodData Central (400K+ research-grade).
|
|
||||||
- **Program tracking** — 90-day structured programs (StrongLifts, Darebee, etc.) with day-by-day progression.
|
|
||||||
- **Configurable rewards** — you define what's worth earning. Gaming time, screen time, treats — your rules.
|
|
||||||
|
|
||||||
## Built-in AI Coach
|
|
||||||
|
|
||||||
Diligence includes a built-in AI coaching chat. Configure any LLM provider in **Settings → Integrations**, then open the **Coach** tab.
|
|
||||||
|
|
||||||
Supported providers (one API key, that's it):
|
|
||||||
|
|
||||||
| Provider | Free tier | What you get |
|
|
||||||
|----------|-----------|-------------|
|
|
||||||
| **OpenRouter** | 26 free models | 300+ models from every major provider, one key |
|
|
||||||
| **Hugging Face** | Yes | Thousands of open-source models |
|
|
||||||
| **Groq** | Yes | Ultra-fast Llama inference |
|
|
||||||
| **Ollama** | Local, free | Run any model on your own machine |
|
|
||||||
| **OpenAI** | No | GPT-4o, GPT-4o-mini |
|
|
||||||
| **Claude** | No | Claude Sonnet 4.6, Opus 4.8 |
|
|
||||||
| **Gemini** | Yes | Gemini 2.0 Flash, 2.5 Pro |
|
|
||||||
| **Custom** | — | Any OpenAI-compatible endpoint (vLLM, TGI, LiteLLM) |
|
|
||||||
|
|
||||||
The AI coach has access to your profile, points, program schedule, and motivation type. It can log workouts, search food, and create meal plans through the chat.
|
|
||||||
|
|
||||||
### Connecting external AI agents (MCP)
|
|
||||||
|
|
||||||
The MCP connector at port 3001 works with any MCP-compatible agent. The built-in chat and external agents coexist — use whichever fits your workflow.
|
|
||||||
|
|
||||||
**Claude Desktop** — add to `claude_desktop_config.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"diligence": { "url": "http://localhost:3001/sse" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Claude Code** (CLI):
|
|
||||||
```bash
|
|
||||||
claude mcp add diligence --transport sse http://localhost:3001/sse
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cursor** — add to `.cursor/mcp.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"diligence": { "url": "http://localhost:3001/sse" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windsurf** — add to MCP settings, same format as Cursor.
|
|
||||||
|
|
||||||
**COROS watch owners** — add both Diligence and COROS MCP servers to your agent. The agent bridges your watch data with your fitness log:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"diligence": { "url": "http://localhost:3001/sse" },
|
|
||||||
"coros": { "url": "https://your-coros-mcp-url/sse" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Copy the contents of [AGENT_GUIDE.md](AGENT_GUIDE.md) into your agent's system instructions for motivation-aware coaching.
|
|
||||||
|
|
||||||
## Configuring Integrations
|
|
||||||
|
|
||||||
All integrations are configured through the app UI (**Settings → Integrations**) or through your AI agent. No `.env` file editing or container restarts needed.
|
|
||||||
|
|
||||||
Tell your agent: *"I want to connect my Strava"* — it will walk you through getting API credentials and store them encrypted.
|
|
||||||
|
|
||||||
## Data Sovereignty
|
|
||||||
|
|
||||||
Diligence is self-hosted software. Your data never leaves your server. No cloud dependency, no vendor lock-in, no telemetry. MIT license — fork it, modify it, keep it forever.
|
|
||||||
|
|
||||||
Your fitness data — heart rate, body composition, food intake, GPS traces — is biometric data that deserves sovereignty.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ nginx │
|
|
||||||
│ (frontend, /api proxy, /mcp proxy) │
|
|
||||||
├──────────┬─────────────────┬────────────────────┤
|
|
||||||
│ React │ FastAPI │ MCP Connector │
|
|
||||||
│ SPA │ Backend │ (14 tools) │
|
|
||||||
│ │ │ port 3001 │
|
|
||||||
│ ├──────────────────┤ │
|
|
||||||
│ │ PostgreSQL 16 │ │
|
|
||||||
│ │ (internal only) │ │
|
|
||||||
└──────────┴──────────────────┴────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- Python 3.12, FastAPI, SQLAlchemy (async)
|
|
||||||
- React 18, Vite
|
|
||||||
- PostgreSQL 16
|
|
||||||
- FastMCP (Streamable HTTP/SSE)
|
|
||||||
- Docker Compose
|
|
||||||
|
|
||||||
## Updating
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git pull
|
|
||||||
docker compose build
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Database migrations run automatically on startup. Your data is preserved.
|
|
||||||
|
|
||||||
## Backing Up
|
|
||||||
|
|
||||||
Your data lives in the `fitness_db_data` Docker volume:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec fitness-db pg_dump -U fitness fitness_rewards > backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
To restore:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec -i fitness-db psql -U fitness fitness_rewards < backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions welcome. Please open an issue to discuss before submitting a PR.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT. See `LICENSE`.
|
||||||
|
|
||||||
|
Built by [DiligenceWorks Pte. Ltd.](https://diligenceworks.online)
|
||||||
|
|
|
||||||
240
TESTER-GUIDE.md
Normal file
240
TESTER-GUIDE.md
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
# Diligence — Beta Tester Guide
|
||||||
|
|
||||||
|
Thanks for helping us test Diligence! This guide walks you through setup, what to test, and how to give us useful feedback. No technical experience needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is Diligence?
|
||||||
|
|
||||||
|
Diligence is a fitness app that runs on your own computer. You earn points for workouts, food logging, and healthy habits, then spend them on rewards you choose. Everything stays on your machine — no cloud accounts, no data collection, no subscriptions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setting Up (10 minutes)
|
||||||
|
|
||||||
|
### Step 1 — Install Python
|
||||||
|
|
||||||
|
Diligence needs Python (version 3.11 or newer) installed on your computer. If you're not sure whether you have it, open a terminal and type:
|
||||||
|
|
||||||
|
```
|
||||||
|
python --version
|
||||||
|
```
|
||||||
|
|
||||||
|
If it says `Python 3.11` or higher, you're good — skip to Step 2.
|
||||||
|
|
||||||
|
**If you need to install Python:**
|
||||||
|
|
||||||
|
- **Windows:** Go to https://www.python.org/downloads/ and click the big yellow "Download" button. During installation, **check the box that says "Add Python to PATH"** — this is important.
|
||||||
|
- **Mac:** Go to https://www.python.org/downloads/ and download the macOS installer. Run it and follow the prompts.
|
||||||
|
|
||||||
|
After installing, close and reopen your terminal, then check again with `python --version`.
|
||||||
|
|
||||||
|
> **Note for Mac users:** You may need to type `python3` instead of `python` throughout this guide.
|
||||||
|
|
||||||
|
### Step 2 — Download Diligence
|
||||||
|
|
||||||
|
Open a terminal (on Windows, search for "Command Prompt" or "PowerShell"; on Mac, search for "Terminal") and run these commands one at a time:
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/DiligenceWorks/Diligence.git
|
||||||
|
cd Diligence
|
||||||
|
pip install .
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Don't have git?** You can also download Diligence as a ZIP file from https://github.com/DiligenceWorks/Diligence — click the green "Code" button, then "Download ZIP." Unzip it, open a terminal in that folder, and run `pip install .`
|
||||||
|
|
||||||
|
### Step 3 — Run It
|
||||||
|
|
||||||
|
```
|
||||||
|
diligence
|
||||||
|
```
|
||||||
|
|
||||||
|
Your browser will open to `http://localhost:8000`. Create an account — pick any username and password you like. That's it, you're in.
|
||||||
|
|
||||||
|
> **If `diligence` doesn't work,** try `python -m diligence` instead.
|
||||||
|
|
||||||
|
### Stopping the App
|
||||||
|
|
||||||
|
Go back to your terminal and press `Ctrl+C`. Your data is saved automatically — next time you run `diligence`, everything will be right where you left it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to Test
|
||||||
|
|
||||||
|
We need you to actually use the app as if it were your real fitness tracker. Below are the areas we'd love you to explore. You don't have to test everything — focus on whatever interests you, and spend at least a few days with it so you get a feel for the daily experience.
|
||||||
|
|
||||||
|
### 1. First-Time Setup (Onboarding)
|
||||||
|
|
||||||
|
When you first create an account, the app walks you through a health screening and motivation assessment. Pay attention to:
|
||||||
|
|
||||||
|
- Were the questions clear and easy to understand?
|
||||||
|
- Did any question feel confusing, irrelevant, or uncomfortable?
|
||||||
|
- Did the process feel too long, too short, or about right?
|
||||||
|
- At the end, did the app's summary of your fitness profile feel accurate?
|
||||||
|
|
||||||
|
### 2. Logging Workouts
|
||||||
|
|
||||||
|
Try logging different types of exercise — a walk, a gym session, a bike ride, yoga, whatever you actually do. Notice:
|
||||||
|
|
||||||
|
- Was it easy to find and log your activity?
|
||||||
|
- Did the points awarded feel fair for the effort?
|
||||||
|
- Did anything feel clunky or take too many clicks?
|
||||||
|
- Try logging something unusual — did the app handle it?
|
||||||
|
|
||||||
|
### 3. Food Logging
|
||||||
|
|
||||||
|
Log what you eat for a few days. The app searches a database of 400,000+ foods. Pay attention to:
|
||||||
|
|
||||||
|
- Could you find the foods you actually eat?
|
||||||
|
- Were the portion sizes and nutritional info reasonable?
|
||||||
|
- Was logging a meal quick enough that you'd actually do it daily?
|
||||||
|
- Try searching for a brand name, a generic food, and a homemade dish — how did each go?
|
||||||
|
|
||||||
|
### 4. Points and Rewards
|
||||||
|
|
||||||
|
The app has a points system with daily gates (minimums) and a reward shop. Test:
|
||||||
|
|
||||||
|
- Do you understand how points are earned?
|
||||||
|
- Does the daily gate make sense — is the minimum too easy, too hard?
|
||||||
|
- Try setting up a reward and redeeming it. Was the process clear?
|
||||||
|
- Does the points balance update correctly after activities and redemptions?
|
||||||
|
|
||||||
|
### 5. Meal Plans
|
||||||
|
|
||||||
|
If you use the meal planning feature, notice:
|
||||||
|
|
||||||
|
- Were the suggested meals realistic for your diet?
|
||||||
|
- Could you mark meals as followed or skipped easily?
|
||||||
|
- Did the compliance tracking (how well you stuck to the plan) feel accurate?
|
||||||
|
|
||||||
|
### 6. Programs (90-Day Training)
|
||||||
|
|
||||||
|
If you enroll in a training program:
|
||||||
|
|
||||||
|
- Were the scheduled workouts appropriate for your level?
|
||||||
|
- Was it clear what you were supposed to do each day?
|
||||||
|
- Did progress tracking work as expected?
|
||||||
|
|
||||||
|
### 7. General App Feel
|
||||||
|
|
||||||
|
Beyond specific features, we want to know:
|
||||||
|
|
||||||
|
- Is the app visually appealing and easy to navigate?
|
||||||
|
- Does it load quickly?
|
||||||
|
- Are there any pages that feel empty, broken, or confusing?
|
||||||
|
- Would you actually use this app day-to-day? Why or why not?
|
||||||
|
- Is there anything you expected to find but couldn't?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Give Feedback
|
||||||
|
|
||||||
|
Good feedback is specific. Here's what helps us most:
|
||||||
|
|
||||||
|
### The Format
|
||||||
|
|
||||||
|
For each thing you notice, tell us:
|
||||||
|
|
||||||
|
1. **What you were doing** — "I was trying to log a 30-minute walk"
|
||||||
|
2. **What happened** — "The app showed 0 points even though I completed the log"
|
||||||
|
3. **What you expected** — "I expected to earn points for the activity"
|
||||||
|
4. **How it made you feel** — "Frustrating — felt like the effort wasn't recognized"
|
||||||
|
|
||||||
|
### Examples of Helpful Feedback
|
||||||
|
|
||||||
|
> "I searched for 'pad thai' in food logging and got zero results. I expected at least a generic entry. I ended up not logging my dinner because I couldn't find anything close."
|
||||||
|
|
||||||
|
> "The onboarding questions about motivation were great — I've never had a fitness app ask me WHY I exercise. The summary at the end nailed my personality. Made me want to keep using it."
|
||||||
|
|
||||||
|
> "I logged a workout and a meal on the same day but the points display only showed the workout points. After refreshing the page, both showed up. Minor but confusing."
|
||||||
|
|
||||||
|
> "The reward shop is a cool idea but I didn't understand what to put there. Maybe some example rewards would help new users get started?"
|
||||||
|
|
||||||
|
### What We Especially Want to Hear
|
||||||
|
|
||||||
|
- **Things that don't work** — errors, blank screens, buttons that do nothing
|
||||||
|
- **Things that are confusing** — where you had to guess or weren't sure what to do
|
||||||
|
- **Things that are missing** — features you looked for but couldn't find
|
||||||
|
- **Things you loved** — so we know what to keep and build on
|
||||||
|
- **Honest reactions** — if something feels pointless or annoying, say so
|
||||||
|
|
||||||
|
### How to Send It
|
||||||
|
|
||||||
|
Send your feedback to **hello@diligenceworks.online** with the subject line **"Diligence Beta Feedback"**. You can write it however you like — bullet points, paragraphs, voice-to-text, whatever works for you. Screenshots are very welcome if you can take them (on most computers: Windows key + Shift + S on Windows, Cmd + Shift + 4 on Mac).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Connecting Claude to Diligence (Optional but Recommended)
|
||||||
|
|
||||||
|
You can connect Claude Desktop to Diligence so Claude can log your workouts, track your food, manage meal plans, and check your progress through conversation — just tell Claude what you did and it handles the rest.
|
||||||
|
|
||||||
|
### What You Need
|
||||||
|
|
||||||
|
- **Claude Desktop** (free) — download from https://claude.ai/download
|
||||||
|
- A Claude account (free tier works)
|
||||||
|
- Diligence running on your computer
|
||||||
|
|
||||||
|
### Setup Steps
|
||||||
|
|
||||||
|
1. Make sure Diligence is running (`python -m diligence` in your terminal)
|
||||||
|
2. Open your browser to `http://localhost:8000/agent`
|
||||||
|
3. You'll see a **Claude Desktop** section with a JSON config block — click **Copy**
|
||||||
|
4. Find your Claude Desktop config file:
|
||||||
|
- **Windows:** Press `Win + R`, paste `%APPDATA%\Claude\claude_desktop_config.json`, press Enter
|
||||||
|
- **Mac:** Open Finder, press `Cmd + Shift + G`, paste `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||||
|
5. Open that file in Notepad (Windows) or TextEdit (Mac)
|
||||||
|
6. Replace everything in the file with the JSON you copied. If the file doesn't exist, create it.
|
||||||
|
7. Save the file and **restart Claude Desktop**
|
||||||
|
|
||||||
|
### How to Use It
|
||||||
|
|
||||||
|
Once connected, you can talk to Claude naturally:
|
||||||
|
|
||||||
|
- *"I went for a 30-minute walk this morning"* — Claude logs the activity and tells you the points earned
|
||||||
|
- *"I had scrambled eggs and avocado for breakfast"* — Claude searches the food database and logs it with full nutrition info
|
||||||
|
- *"How am I doing today?"* — Claude shows your points, daily gate status, and what's left to earn
|
||||||
|
- *"What's my meal plan for today?"* — Claude pulls up your planned meals
|
||||||
|
|
||||||
|
### Important
|
||||||
|
|
||||||
|
- Diligence must be running in your terminal for Claude to connect. If you close the terminal, Claude loses the connection until you start Diligence again.
|
||||||
|
- You don't need Claude Desktop to use the app — the web interface works on its own. Claude is an optional power-up.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Questions
|
||||||
|
|
||||||
|
**Where is my data stored?**
|
||||||
|
In a folder called `.diligence` in your home directory. On Windows that's `C:\Users\YourName\.diligence\`, on Mac it's `/Users/YourName/.diligence/`. It's a single file called `data.db`.
|
||||||
|
|
||||||
|
**Can I use it on my phone?**
|
||||||
|
Not as a native app, but if you run it on your computer you can open `http://your-computer-ip:8000` from your phone's browser while on the same Wi-Fi network. Run `diligence --host 0.0.0.0` to allow connections from other devices.
|
||||||
|
|
||||||
|
**Will I lose my data if I close the terminal?**
|
||||||
|
No. Your data is saved to disk continuously. Closing the terminal stops the app, but next time you run `diligence` everything is still there.
|
||||||
|
|
||||||
|
**Something broke and I want to start fresh.**
|
||||||
|
Delete the `.diligence` folder in your home directory and run `diligence` again. You'll go through onboarding as a new user.
|
||||||
|
|
||||||
|
**I got an error when installing.**
|
||||||
|
Make sure your Python version is 3.11 or newer (`python --version`). If you see "pip not found," try `python -m pip install .` instead of `pip install .`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
If you want to remove Diligence completely:
|
||||||
|
|
||||||
|
```
|
||||||
|
pip uninstall diligence
|
||||||
|
```
|
||||||
|
|
||||||
|
Then delete the `.diligence` folder from your home directory if you want to remove your data too.
|
||||||
|
|
||||||
|
To remove the downloaded source code, just delete the `Diligence` folder wherever you cloned or unzipped it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Thank you for testing. Every piece of feedback makes the app better for everyone.
|
||||||
|
|
||||||
|
*— The Diligence Team at DiligenceWorks Pte. Ltd.*
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||||
COPY requirements.txt .
|
|
||||||
|
COPY backend/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
COPY diligence/ ./diligence/
|
||||||
|
|
||||||
|
CMD ["uvicorn", "diligence.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
# Database - hostname 'fitness-db' avoids collision with other 'db' containers on Coolify network
|
|
||||||
database_url: str = "postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards"
|
|
||||||
|
|
||||||
# Auth
|
|
||||||
secret_key: str = "change-me-in-production"
|
|
||||||
crawl_enabled: bool = False # Set CRAWL_ENABLED=true to start program crawl scheduler
|
|
||||||
access_token_expire_minutes: int = 1440 # 24 hours
|
|
||||||
api_token: str = "" # MCP connector auth — generated by setup.sh
|
|
||||||
|
|
||||||
# Strava
|
|
||||||
strava_client_id: str = ""
|
|
||||||
strava_client_secret: str = ""
|
|
||||||
|
|
||||||
# Polar
|
|
||||||
polar_client_id: str = ""
|
|
||||||
polar_client_secret: str = ""
|
|
||||||
|
|
||||||
# Groq (program extraction)
|
|
||||||
groq_api_key: str = ""
|
|
||||||
|
|
||||||
# Telegram (support notifications — outbound only)
|
|
||||||
telegram_bot_token: str = ""
|
|
||||||
telegram_chat_id: str = ""
|
|
||||||
|
|
||||||
# App
|
|
||||||
base_url: str = "http://localhost"
|
|
||||||
timezone: str = "Asia/Bangkok"
|
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
return Settings()
|
|
||||||
|
|
||||||
|
|
||||||
# Module-level shortcut used by services
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
@ -1,428 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import sys
|
|
||||||
import logging
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logger = logging.getLogger("fitness-rewards")
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
# Retry DB init — container may start before postgres is fully accepting connections
|
|
||||||
for attempt in range(10):
|
|
||||||
try:
|
|
||||||
from app.database import init_db
|
|
||||||
await init_db()
|
|
||||||
logger.info("Database tables created successfully")
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"DB init attempt {attempt + 1}/10 failed: {e}")
|
|
||||||
if attempt < 9:
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
else:
|
|
||||||
logger.error("Could not initialize database after 10 attempts — starting without tables")
|
|
||||||
|
|
||||||
|
|
||||||
# SEC-05: Fail fast if SECRET_KEY not configured
|
|
||||||
from app.config import get_settings
|
|
||||||
_s = get_settings()
|
|
||||||
if _s.secret_key == "change-me-in-production":
|
|
||||||
logger.error("CRITICAL: SECRET_KEY not set. Run ./setup.sh or set SECRET_KEY in .env")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Run lightweight migrations for schema changes
|
|
||||||
try:
|
|
||||||
await run_migrations()
|
|
||||||
logger.info("Migrations completed")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Migration failed (non-fatal): {e}")
|
|
||||||
|
|
||||||
# Seed resources (non-fatal if it fails)
|
|
||||||
try:
|
|
||||||
await seed_resources()
|
|
||||||
logger.info("Resource library seeded")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Resource seeding failed (non-fatal): {e}")
|
|
||||||
|
|
||||||
# Start background crawl queue scheduler (gated on CRAWL_ENABLED)
|
|
||||||
crawl_task = None
|
|
||||||
if _s.crawl_enabled:
|
|
||||||
try:
|
|
||||||
from app.services.crawl_scheduler import crawl_queue_loop
|
|
||||||
crawl_task = asyncio.create_task(crawl_queue_loop())
|
|
||||||
logger.info("Crawl queue scheduler started")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Crawl scheduler failed to start (non-fatal): {e}")
|
|
||||||
else:
|
|
||||||
logger.info("Crawl scheduler disabled (set CRAWL_ENABLED=true to enable)")
|
|
||||||
|
|
||||||
logger.info("Fitness Rewards backend started")
|
|
||||||
yield
|
|
||||||
|
|
||||||
# Shutdown
|
|
||||||
if crawl_task:
|
|
||||||
crawl_task.cancel()
|
|
||||||
try:
|
|
||||||
await crawl_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
logger.info("Fitness Rewards backend shutting down")
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Fitness Rewards", version="1.0.0", lifespan=lifespan)
|
|
||||||
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=False, # Bearer tokens don't need credentials mode
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Import routers after app creation to avoid circular imports
|
|
||||||
from app.routers.auth import router as auth_router
|
|
||||||
from app.routers.onboarding import router as onboarding_router
|
|
||||||
from app.routers.activities import router as activities_router
|
|
||||||
from app.routers.food import router as food_router
|
|
||||||
from app.routers.points import router as points_router, rewards_router
|
|
||||||
from app.routers.integrations import router as integrations_router
|
|
||||||
from app.routers.programs import router as programs_router
|
|
||||||
from app.routers.catalog import router as catalog_router
|
|
||||||
from app.routers.support import router as support_router
|
|
||||||
from app.routers.nutrition import router as nutrition_router
|
|
||||||
from app.routers.meal_plans import router as meal_plans_router
|
|
||||||
from app.routers.ai_chat import router as ai_chat_router
|
|
||||||
|
|
||||||
app.include_router(auth_router)
|
|
||||||
app.include_router(onboarding_router)
|
|
||||||
app.include_router(activities_router)
|
|
||||||
app.include_router(food_router)
|
|
||||||
app.include_router(points_router)
|
|
||||||
app.include_router(rewards_router)
|
|
||||||
app.include_router(integrations_router)
|
|
||||||
app.include_router(catalog_router)
|
|
||||||
app.include_router(support_router)
|
|
||||||
app.include_router(programs_router)
|
|
||||||
app.include_router(nutrition_router)
|
|
||||||
app.include_router(meal_plans_router)
|
|
||||||
app.include_router(ai_chat_router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
|
||||||
async def health():
|
|
||||||
return {"status": "ok", "version": "1.0.0"}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def run_migrations():
|
|
||||||
"""Add missing columns to existing tables (lightweight schema migration)."""
|
|
||||||
from app.database import engine
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
# v1: Add equipment_list JSONB column if missing
|
|
||||||
await conn.execute(text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS equipment_list JSONB DEFAULT '[]';
|
|
||||||
EXCEPTION WHEN undefined_table THEN NULL;
|
|
||||||
END $$;
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# v2: Add catalog columns to programs table
|
|
||||||
await conn.execute(text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE programs ADD COLUMN IF NOT EXISTS catalog_id UUID REFERENCES program_catalog(id);
|
|
||||||
ALTER TABLE programs ADD COLUMN IF NOT EXISTS current_week INTEGER DEFAULT 1;
|
|
||||||
ALTER TABLE programs ADD COLUMN IF NOT EXISTS current_day INTEGER DEFAULT 1;
|
|
||||||
EXCEPTION WHEN undefined_table THEN NULL;
|
|
||||||
END $$;
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# v2.1: Add week_number to workout_logs for template rotation tracking
|
|
||||||
await conn.execute(text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE workout_logs ADD COLUMN IF NOT EXISTS week_number INTEGER NOT NULL DEFAULT 1;
|
|
||||||
EXCEPTION WHEN undefined_table THEN NULL;
|
|
||||||
END $$;
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# v3: Seed keto point rules (fast_completed, keto_day, meal_logged)
|
|
||||||
# NOTE: Keto rules below are from v2. v3+ migrations (is_admin, integration_configs,
|
|
||||||
# meal plans) are added after this block.
|
|
||||||
await conn.execute(text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
|
||||||
SELECT gen_random_uuid(), u.id, 'fast_completed', 200, 'per_event', TRUE
|
|
||||||
FROM users u
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM point_rules pr
|
|
||||||
WHERE pr.user_id = u.id AND pr.category = 'fast_completed'
|
|
||||||
);
|
|
||||||
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
|
||||||
SELECT gen_random_uuid(), u.id, 'keto_compliant_day', 100, 'per_event', TRUE
|
|
||||||
FROM users u
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM point_rules pr
|
|
||||||
WHERE pr.user_id = u.id AND pr.category = 'keto_compliant_day'
|
|
||||||
);
|
|
||||||
EXCEPTION WHEN undefined_table THEN NULL;
|
|
||||||
END $$;
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# v4: Add is_admin column to users
|
|
||||||
await conn.execute(text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE;
|
|
||||||
EXCEPTION WHEN undefined_table THEN NULL;
|
|
||||||
END $$;
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# v4.1: Grant admin to first registered user if no admin exists
|
|
||||||
await conn.execute(text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM users WHERE is_admin = TRUE) THEN
|
|
||||||
UPDATE users SET is_admin = TRUE
|
|
||||||
WHERE id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1);
|
|
||||||
END IF;
|
|
||||||
EXCEPTION WHEN undefined_table THEN NULL;
|
|
||||||
WHEN undefined_column THEN NULL;
|
|
||||||
END $$;
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# v5: Create integration_configs table
|
|
||||||
await conn.execute(text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS integration_configs (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
|
||||||
provider VARCHAR(50) NOT NULL,
|
|
||||||
config_key VARCHAR(100) NOT NULL,
|
|
||||||
config_value TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(user_id, provider, config_key)
|
|
||||||
);
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# v6: Create meal plan tables
|
|
||||||
await conn.execute(text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS meal_plans (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
|
||||||
name VARCHAR(200) NOT NULL,
|
|
||||||
diet_type VARCHAR(50),
|
|
||||||
daily_calories INTEGER,
|
|
||||||
daily_protein_g INTEGER,
|
|
||||||
daily_carbs_g INTEGER,
|
|
||||||
daily_fat_g INTEGER,
|
|
||||||
restrictions JSONB DEFAULT '[]',
|
|
||||||
duration_days INTEGER NOT NULL,
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
status VARCHAR(20) DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
"""))
|
|
||||||
|
|
||||||
await conn.execute(text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS meal_plan_items (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
plan_id UUID NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
|
|
||||||
day_number INTEGER NOT NULL,
|
|
||||||
meal_type VARCHAR(20) NOT NULL,
|
|
||||||
food_name VARCHAR(300) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
calories INTEGER,
|
|
||||||
protein_g DECIMAL(6,1),
|
|
||||||
carbs_g DECIMAL(6,1),
|
|
||||||
fat_g DECIMAL(6,1),
|
|
||||||
fiber_g DECIMAL(6,1),
|
|
||||||
serving_size VARCHAR(100),
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
"""))
|
|
||||||
|
|
||||||
await conn.execute(text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS meal_compliance (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id),
|
|
||||||
plan_id UUID NOT NULL REFERENCES meal_plans(id),
|
|
||||||
plan_item_id UUID,
|
|
||||||
compliance_date DATE NOT NULL,
|
|
||||||
status VARCHAR(20) NOT NULL,
|
|
||||||
substitution TEXT,
|
|
||||||
food_log_id UUID,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
"""))
|
|
||||||
|
|
||||||
await conn.execute(text("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_meal_plan_items_day ON meal_plan_items(plan_id, day_number);
|
|
||||||
"""))
|
|
||||||
await conn.execute(text("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_meal_compliance_date ON meal_compliance(user_id, compliance_date);
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# v7: Seed meal plan point rules
|
|
||||||
await conn.execute(text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
|
||||||
SELECT gen_random_uuid(), u.id, 'meal_plan_followed', 40, 'per_event', TRUE
|
|
||||||
FROM users u
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM point_rules pr
|
|
||||||
WHERE pr.user_id = u.id AND pr.category = 'meal_plan_followed'
|
|
||||||
);
|
|
||||||
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
|
||||||
SELECT gen_random_uuid(), u.id, 'meal_plan_partial', 20, 'per_event', TRUE
|
|
||||||
FROM users u
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM point_rules pr
|
|
||||||
WHERE pr.user_id = u.id AND pr.category = 'meal_plan_partial'
|
|
||||||
);
|
|
||||||
EXCEPTION WHEN undefined_table THEN NULL;
|
|
||||||
END $$;
|
|
||||||
"""))
|
|
||||||
|
|
||||||
|
|
||||||
async def seed_resources():
|
|
||||||
"""Seed the resource library with curated fitness programs."""
|
|
||||||
from app.database import async_session
|
|
||||||
from app.models.resource import Resource
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
async with async_session() as db:
|
|
||||||
existing = await db.execute(select(Resource).limit(1))
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
return # Already seeded
|
|
||||||
|
|
||||||
resources = [
|
|
||||||
Resource(
|
|
||||||
name="Darebee Foundation Program",
|
|
||||||
source="darebee",
|
|
||||||
url="https://darebee.com/programs/foundation-program.html",
|
|
||||||
description="30-day beginner program. Bodyweight exercises, no equipment needed. Perfect starting point.",
|
|
||||||
goal_tags=["get_active", "feel_better"],
|
|
||||||
activity_tags=["bodyweight"],
|
|
||||||
equipment_needed="none",
|
|
||||||
ttm_stages=["precontemplation", "contemplation", "preparation"],
|
|
||||||
difficulty="beginner",
|
|
||||||
duration_days=30,
|
|
||||||
),
|
|
||||||
Resource(
|
|
||||||
name="Darebee 30 Days of Change",
|
|
||||||
source="darebee",
|
|
||||||
url="https://darebee.com/programs/30-days-of-change.html",
|
|
||||||
description="30-day progressive program for building fitness habits. Bodyweight only.",
|
|
||||||
goal_tags=["get_active", "lose_weight", "feel_better"],
|
|
||||||
activity_tags=["bodyweight"],
|
|
||||||
equipment_needed="none",
|
|
||||||
ttm_stages=["contemplation", "preparation"],
|
|
||||||
difficulty="beginner",
|
|
||||||
duration_days=30,
|
|
||||||
),
|
|
||||||
Resource(
|
|
||||||
name="Darebee IRONHEART",
|
|
||||||
source="darebee",
|
|
||||||
url="https://darebee.com/programs/ironheart.html",
|
|
||||||
description="Strength-focused bodyweight program. No equipment, all levels.",
|
|
||||||
goal_tags=["build_strength"],
|
|
||||||
activity_tags=["bodyweight"],
|
|
||||||
equipment_needed="none",
|
|
||||||
ttm_stages=["preparation", "action"],
|
|
||||||
difficulty="intermediate",
|
|
||||||
duration_days=30,
|
|
||||||
),
|
|
||||||
Resource(
|
|
||||||
name="Darebee Total Body Strength",
|
|
||||||
source="darebee",
|
|
||||||
url="https://darebee.com/programs/total-body-strength.html",
|
|
||||||
description="Comprehensive strength building program using bodyweight.",
|
|
||||||
goal_tags=["build_strength"],
|
|
||||||
activity_tags=["bodyweight"],
|
|
||||||
equipment_needed="none",
|
|
||||||
ttm_stages=["action", "maintenance"],
|
|
||||||
difficulty="intermediate",
|
|
||||||
duration_days=30,
|
|
||||||
),
|
|
||||||
Resource(
|
|
||||||
name="Darebee 8 Weeks to 5K",
|
|
||||||
source="darebee",
|
|
||||||
url="https://darebee.com/programs/8-weeks-to-5k-program.html",
|
|
||||||
description="Progressive running program from beginner to 5K in 8 weeks.",
|
|
||||||
goal_tags=["get_active", "lose_weight"],
|
|
||||||
activity_tags=["running"],
|
|
||||||
equipment_needed="none",
|
|
||||||
ttm_stages=["preparation", "action"],
|
|
||||||
difficulty="beginner",
|
|
||||||
duration_days=56,
|
|
||||||
),
|
|
||||||
Resource(
|
|
||||||
name="StrongLifts 5x5",
|
|
||||||
source="stronglifts",
|
|
||||||
url="https://stronglifts.com/5x5/",
|
|
||||||
description="Simple barbell strength program. 3 days/week, 5 exercises, progressive overload.",
|
|
||||||
goal_tags=["build_strength"],
|
|
||||||
activity_tags=["weights"],
|
|
||||||
equipment_needed="full_gym",
|
|
||||||
ttm_stages=["preparation", "action", "maintenance"],
|
|
||||||
difficulty="beginner",
|
|
||||||
duration_days=90,
|
|
||||||
),
|
|
||||||
Resource(
|
|
||||||
name="Darebee 30 Days of Cardio",
|
|
||||||
source="darebee",
|
|
||||||
url="https://darebee.com/programs/30-days-of-cardio.html",
|
|
||||||
description="30-day cardio program, no equipment. Great for weight loss.",
|
|
||||||
goal_tags=["lose_weight", "get_active"],
|
|
||||||
activity_tags=["bodyweight"],
|
|
||||||
equipment_needed="none",
|
|
||||||
ttm_stages=["preparation", "action"],
|
|
||||||
difficulty="beginner",
|
|
||||||
duration_days=30,
|
|
||||||
),
|
|
||||||
Resource(
|
|
||||||
name="Darebee POWERBUILDER",
|
|
||||||
source="darebee",
|
|
||||||
url="https://darebee.com/programs/powerbuilder-program.html",
|
|
||||||
description="Advanced strength and power program using bodyweight.",
|
|
||||||
goal_tags=["build_strength"],
|
|
||||||
activity_tags=["bodyweight"],
|
|
||||||
equipment_needed="none",
|
|
||||||
ttm_stages=["action", "maintenance"],
|
|
||||||
difficulty="advanced",
|
|
||||||
duration_days=30,
|
|
||||||
),
|
|
||||||
Resource(
|
|
||||||
name="Fitness Blender (YouTube)",
|
|
||||||
source="youtube",
|
|
||||||
url="https://www.youtube.com/@fitnessblender",
|
|
||||||
description="Free workout videos for all levels. Huge variety: HIIT, strength, yoga, pilates.",
|
|
||||||
goal_tags=["lose_weight", "build_strength", "get_active", "feel_better"],
|
|
||||||
activity_tags=["bodyweight", "yoga"],
|
|
||||||
equipment_needed="none",
|
|
||||||
ttm_stages=["contemplation", "preparation", "action", "maintenance"],
|
|
||||||
difficulty="beginner",
|
|
||||||
duration_days=None,
|
|
||||||
),
|
|
||||||
Resource(
|
|
||||||
name="Darebee Yoga Flexibility Program",
|
|
||||||
source="darebee",
|
|
||||||
url="https://darebee.com/programs/flexibility-program.html",
|
|
||||||
description="30-day flexibility and yoga program for beginners.",
|
|
||||||
goal_tags=["feel_better"],
|
|
||||||
activity_tags=["yoga"],
|
|
||||||
equipment_needed="none",
|
|
||||||
ttm_stages=["precontemplation", "contemplation", "preparation"],
|
|
||||||
difficulty="beginner",
|
|
||||||
duration_days=30,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
for r in resources:
|
|
||||||
db.add(r)
|
|
||||||
await db.commit()
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
from app.models.user import User
|
|
||||||
from app.models.profile import UserProfile
|
|
||||||
from app.models.program import Program
|
|
||||||
from app.models.activity import ActivityLog
|
|
||||||
from app.models.food import FoodLog
|
|
||||||
from app.models.points import PointRule, DailyTarget
|
|
||||||
from app.models.reward import Reward, RewardRedemption
|
|
||||||
from app.models.oauth import OAuthToken
|
|
||||||
from app.models.resource import Resource
|
|
||||||
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
|
|
||||||
from app.models.support import SupportThread, SupportMessage
|
|
||||||
from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
|
|
||||||
from app.models.integration_config import IntegrationConfig
|
|
||||||
from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
|
|
||||||
"PointRule", "DailyTarget", "Reward", "RewardRedemption",
|
|
||||||
"OAuthToken", "Resource",
|
|
||||||
"ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog",
|
|
||||||
"SupportThread", "SupportMessage",
|
|
||||||
"NutritionGoal", "Fast", "ElectrolyteLog",
|
|
||||||
"IntegrationConfig", "MealPlan", "MealPlanItem", "MealCompliance",
|
|
||||||
]
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, timedelta
|
|
||||||
|
|
||||||
|
|
||||||
def get_week_boundaries(d: date) -> tuple[date, date]:
|
|
||||||
"""Return Monday and Sunday of the week containing date d."""
|
|
||||||
monday = d - timedelta(days=d.weekday())
|
|
||||||
sunday = monday + timedelta(days=6)
|
|
||||||
return monday, sunday
|
|
||||||
|
|
||||||
|
|
||||||
def get_month_boundaries(year: int, month: int) -> tuple[date, date]:
|
|
||||||
"""Return first and last day of a month."""
|
|
||||||
first = date(year, month, 1)
|
|
||||||
if month == 12:
|
|
||||||
last = date(year + 1, 1, 1) - timedelta(days=1)
|
|
||||||
else:
|
|
||||||
last = date(year, month + 1, 1) - timedelta(days=1)
|
|
||||||
return first, last
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
# Backend
|
# Core
|
||||||
fastapi==0.115.6
|
fastapi==0.115.6
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
sqlalchemy[asyncio]==2.0.36
|
sqlalchemy[asyncio]==2.0.36
|
||||||
asyncpg==0.30.0
|
|
||||||
alembic==1.14.0
|
|
||||||
pydantic==2.10.3
|
pydantic==2.10.3
|
||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
bcrypt==4.2.1
|
bcrypt==4.2.1
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
python-multipart==0.0.18
|
python-multipart==0.0.18
|
||||||
|
|
||||||
|
# Database drivers
|
||||||
|
asyncpg==0.30.0
|
||||||
|
aiosqlite==0.20.0
|
||||||
|
|
|
||||||
3
diligence/__init__.py
Normal file
3
diligence/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Diligence — self-hosted fitness rewards platform with AI agent integration."""
|
||||||
|
|
||||||
|
__version__ = "2.0.0"
|
||||||
4
diligence/__main__.py
Normal file
4
diligence/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
"""Allow running as: python -m diligence"""
|
||||||
|
from diligence.cli import main
|
||||||
|
|
||||||
|
main()
|
||||||
122
diligence/cli.py
Normal file
122
diligence/cli.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""Diligence CLI — run the fitness app from the command line."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import secrets
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="diligence",
|
||||||
|
description="Diligence — self-hosted fitness rewards platform",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port", type=int, default=8000,
|
||||||
|
help="Port to run on (default: 8000)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host", type=str, default="127.0.0.1",
|
||||||
|
help="Host to bind to (default: 127.0.0.1)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-browser", action="store_true",
|
||||||
|
help="Don't open the browser automatically",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-mcp", action="store_true",
|
||||||
|
help="Disable the MCP connector",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mcp-port", type=int, default=3001,
|
||||||
|
help="Port for MCP SSE server (default: 3001)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--data-dir", type=str, default=None,
|
||||||
|
help="Data directory (default: ~/.diligence)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Set up data directory
|
||||||
|
data_dir = Path(args.data_dir) if args.data_dir else Path.home() / ".diligence"
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Auto-generate secrets on first run
|
||||||
|
env_file = data_dir / ".env"
|
||||||
|
api_token = ""
|
||||||
|
if not env_file.exists():
|
||||||
|
secret_key = secrets.token_hex(32)
|
||||||
|
api_token = secrets.token_hex(32)
|
||||||
|
env_file.write_text(
|
||||||
|
f"SECRET_KEY={secret_key}\n"
|
||||||
|
f"API_TOKEN={api_token}\n"
|
||||||
|
f"BASE_URL=http://localhost:{args.port}\n"
|
||||||
|
f"DATA_DIR={data_dir}\n"
|
||||||
|
)
|
||||||
|
print(f"Created config at {env_file}")
|
||||||
|
|
||||||
|
# Point pydantic-settings at the env file
|
||||||
|
os.environ.setdefault("DATA_DIR", str(data_dir))
|
||||||
|
|
||||||
|
# Load existing env file
|
||||||
|
if env_file.exists():
|
||||||
|
for line in env_file.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
os.environ.setdefault(key.strip(), value.strip())
|
||||||
|
if key.strip() == "API_TOKEN":
|
||||||
|
api_token = value.strip()
|
||||||
|
|
||||||
|
if args.no_mcp:
|
||||||
|
os.environ["MCP_ENABLED"] = "false"
|
||||||
|
|
||||||
|
# Start MCP server in background
|
||||||
|
mcp_started = False
|
||||||
|
if not args.no_mcp:
|
||||||
|
try:
|
||||||
|
from diligence.mcp import start_mcp_background
|
||||||
|
mcp_started = start_mcp_background(
|
||||||
|
api_url=f"http://localhost:{args.port}",
|
||||||
|
api_token=api_token,
|
||||||
|
port=args.mcp_port,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Open browser after a short delay
|
||||||
|
url = f"http://localhost:{args.port}"
|
||||||
|
if not args.no_browser:
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def _open():
|
||||||
|
import time
|
||||||
|
time.sleep(2)
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
threading.Thread(target=_open, daemon=True).start()
|
||||||
|
|
||||||
|
print(f"\n Diligence running at {url}")
|
||||||
|
print(f" Data: {data_dir}")
|
||||||
|
if mcp_started:
|
||||||
|
print(f" MCP: http://localhost:{args.mcp_port}/sse")
|
||||||
|
elif not args.no_mcp:
|
||||||
|
print(f" MCP: not available (install with: pip install diligence[mcp])")
|
||||||
|
print(f" Press Ctrl+C to stop\n")
|
||||||
|
|
||||||
|
# Run uvicorn
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(
|
||||||
|
"diligence.main:app",
|
||||||
|
host=args.host,
|
||||||
|
port=args.port,
|
||||||
|
log_level="info",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
71
diligence/config.py
Normal file
71
diligence/config.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _default_data_dir() -> Path:
|
||||||
|
"""~/.diligence on all platforms."""
|
||||||
|
d = Path.home() / ".diligence"
|
||||||
|
d.mkdir(exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# Database — empty string triggers SQLite auto-detect (pip install path)
|
||||||
|
# Set DATABASE_URL explicitly for PostgreSQL (Docker path)
|
||||||
|
database_url: str = ""
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
secret_key: str = "change-me-in-production"
|
||||||
|
crawl_enabled: bool = False
|
||||||
|
access_token_expire_minutes: int = 1440 # 24 hours
|
||||||
|
api_token: str = "" # MCP connector auth
|
||||||
|
|
||||||
|
# Strava
|
||||||
|
strava_client_id: str = ""
|
||||||
|
strava_client_secret: str = ""
|
||||||
|
|
||||||
|
# Polar
|
||||||
|
polar_client_id: str = ""
|
||||||
|
polar_client_secret: str = ""
|
||||||
|
|
||||||
|
# Groq (program extraction)
|
||||||
|
groq_api_key: str = ""
|
||||||
|
|
||||||
|
# Telegram (support notifications)
|
||||||
|
telegram_bot_token: str = ""
|
||||||
|
telegram_chat_id: str = ""
|
||||||
|
|
||||||
|
# App
|
||||||
|
base_url: str = "http://localhost:8000"
|
||||||
|
timezone: str = "UTC"
|
||||||
|
data_dir: str = str(_default_data_dir())
|
||||||
|
|
||||||
|
# MCP
|
||||||
|
mcp_enabled: bool = True
|
||||||
|
mcp_port: int = 3001
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_database_url(self) -> str:
|
||||||
|
"""Return the database URL, falling back to SQLite if not set."""
|
||||||
|
if self.database_url:
|
||||||
|
return self.database_url
|
||||||
|
db_path = Path(self.data_dir) / "data.db"
|
||||||
|
return f"sqlite+aiosqlite:///{db_path}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_sqlite(self) -> bool:
|
||||||
|
return self.effective_database_url.startswith("sqlite")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level shortcut used by services
|
||||||
|
settings = get_settings()
|
||||||
|
|
@ -2,11 +2,17 @@ from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from app.config import get_settings
|
from diligence.config import get_settings
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
engine = create_async_engine(settings.database_url, echo=False, pool_size=5, max_overflow=10)
|
# Build engine with dialect-appropriate settings
|
||||||
|
_engine_kwargs: dict = {"echo": False}
|
||||||
|
if not settings.is_sqlite:
|
||||||
|
# PostgreSQL — connection pooling
|
||||||
|
_engine_kwargs.update(pool_size=5, max_overflow=10)
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.effective_database_url, **_engine_kwargs)
|
||||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -28,7 +34,7 @@ async def get_db():
|
||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
# Import all models so their tables are registered on Base.metadata
|
# Import all models so their tables are registered on Base.metadata
|
||||||
import app.models # noqa: F401
|
import diligence.models # noqa: F401
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
102
diligence/frontend/.well-known/agent-card.json
Normal file
102
diligence/frontend/.well-known/agent-card.json
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
{
|
||||||
|
"name": "Diligence Fitness Rewards",
|
||||||
|
"description": "Self-hosted fitness rewards platform with points-based behavioral economy. Your fitness data stays on your hardware. AI agents can log activities, query progress, manage meal plans, and configure device integrations via MCP.",
|
||||||
|
"url": "{BASE_URL}/mcp",
|
||||||
|
"provider": {
|
||||||
|
"organization": "DiligenceWorks Pte. Ltd.",
|
||||||
|
"url": "https://diligenceworks.online"
|
||||||
|
},
|
||||||
|
"version": "1.0.0",
|
||||||
|
"documentationUrl": "https://github.com/diligenceworks/diligence",
|
||||||
|
"capabilities": {
|
||||||
|
"streaming": true,
|
||||||
|
"pushNotifications": false,
|
||||||
|
"stateTransitionHistory": false
|
||||||
|
},
|
||||||
|
"authentication": {
|
||||||
|
"schemes": ["apiKey"],
|
||||||
|
"credentials": null
|
||||||
|
},
|
||||||
|
"defaultInputModes": ["text/plain", "application/json"],
|
||||||
|
"defaultOutputModes": ["application/json"],
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"id": "context-status",
|
||||||
|
"name": "Context & Status",
|
||||||
|
"description": "Get full user profile with motivation type (BREQ-2), active program, points status, rewards, and integration connections. Includes get_context (full brief), get_today (daily points/gate), and get_week (weekly summary).",
|
||||||
|
"tags": ["fitness", "status", "profile", "motivation"],
|
||||||
|
"examples": [
|
||||||
|
"How am I doing today?",
|
||||||
|
"What's my weekly progress?",
|
||||||
|
"Show me my fitness dashboard"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "activity-logging",
|
||||||
|
"name": "Activity Logging",
|
||||||
|
"description": "Record workouts, runs, walks, cycling, yoga, or any physical activity. Awards points based on activity type and duration. Supports structured program workouts via program_day parameter.",
|
||||||
|
"tags": ["fitness", "tracking", "points", "workout"],
|
||||||
|
"examples": [
|
||||||
|
"I just did a 30-minute run",
|
||||||
|
"Log my StrongLifts Workout B today",
|
||||||
|
"I did 45 minutes of yoga this morning"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nutrition-tracking",
|
||||||
|
"name": "Food & Nutrition",
|
||||||
|
"description": "Log food intake with calorie and macro tracking. Search Open Food Facts (4M+ products) and USDA FoodData Central (400K+ research-grade items). Awards points for consistent food logging.",
|
||||||
|
"tags": ["nutrition", "food", "tracking", "barcode", "search"],
|
||||||
|
"examples": [
|
||||||
|
"I had 2 eggs and toast for breakfast",
|
||||||
|
"Search for chicken breast nutrition",
|
||||||
|
"Log a salad for lunch, about 400 calories"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "program-schedule",
|
||||||
|
"name": "Program Schedule",
|
||||||
|
"description": "View today's scheduled workout from the active program including exercises, sets, reps, and weight progression. Shows upcoming workouts and overall program completion percentage.",
|
||||||
|
"tags": ["program", "schedule", "workout", "planning"],
|
||||||
|
"examples": [
|
||||||
|
"What's my workout today?",
|
||||||
|
"Show me this week's program schedule",
|
||||||
|
"How far through my program am I?"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rewards",
|
||||||
|
"name": "Rewards",
|
||||||
|
"description": "View available rewards with point costs and spend earned points. Rewards are user-configurable (gaming time, screen time, treats, etc.). Points gate must be passed before redemption.",
|
||||||
|
"tags": ["rewards", "points", "spending", "gamification"],
|
||||||
|
"examples": [
|
||||||
|
"What rewards can I unlock?",
|
||||||
|
"Spend points on 30 minutes of gaming",
|
||||||
|
"Do I have enough points for a movie?"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "meal-planning",
|
||||||
|
"name": "Meal Planning",
|
||||||
|
"description": "Create, view, and track AI-generated meal plans. The agent generates plan content; the app stores and tracks compliance. Includes load_meal_plan, get_meal_plan, update_meal_compliance, and get_plan_progress.",
|
||||||
|
"tags": ["nutrition", "meal-plan", "diet", "compliance"],
|
||||||
|
"examples": [
|
||||||
|
"Create a 7-day keto meal plan",
|
||||||
|
"What am I supposed to eat today?",
|
||||||
|
"I followed my breakfast plan",
|
||||||
|
"How's my meal plan compliance?"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "device-integration",
|
||||||
|
"name": "Device Integration",
|
||||||
|
"description": "Configure connections to fitness devices and services (Strava, Polar, Garmin, Fitbit, WHOOP, Oura, Withings). Write-only credential storage — agent can set but never read credentials back.",
|
||||||
|
"tags": ["integration", "strava", "garmin", "fitbit", "sync"],
|
||||||
|
"examples": [
|
||||||
|
"Connect my Strava account",
|
||||||
|
"What integrations are available?",
|
||||||
|
"I want to set up my Garmin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Diligence — Agent Skill Definition
|
||||||
|
|
||||||
|
## What Is Diligence?
|
||||||
|
|
||||||
|
Diligence is a self-hosted fitness rewards platform. Users earn points through fitness activities (workouts, food logging, step goals) and spend them on configurable rewards (gaming time, screen time, etc.). A daily gate keeps rewards locked until enough points are earned. Points reset weekly.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Backend:** FastAPI (Python 3.12) + PostgreSQL 16
|
||||||
|
- **Frontend:** React 18 + Vite, served by nginx
|
||||||
|
- **MCP Connector:** FastMCP (Streamable HTTP/SSE) on port 3001, proxied via nginx at `/mcp`
|
||||||
|
- **Deployment:** Docker Compose (4 services: frontend, backend, mcp-connector, fitness-db)
|
||||||
|
|
||||||
|
## Connecting
|
||||||
|
|
||||||
|
Point your MCP client to:
|
||||||
|
- **Development:** `http://localhost:3001/sse`
|
||||||
|
- **Production:** `https://your-domain/mcp`
|
||||||
|
|
||||||
|
## Available Tools (14)
|
||||||
|
|
||||||
|
| Tool | Category | Description |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| `get_context()` | Status | Full profile, motivation, program, rules, rewards |
|
||||||
|
| `get_today(date?)` | Status | Daily points, gate status, activities |
|
||||||
|
| `get_week(start_date?)` | Status | Weekly summary, active days |
|
||||||
|
| `log_activity(category, title, duration_minutes, ...)` | Activity | Log workout, earn points |
|
||||||
|
| `log_food(meal_type, food_name, ...)` | Food | Log food with macros |
|
||||||
|
| `search_food(query)` | Food | Search Open Food Facts + USDA |
|
||||||
|
| `get_program_schedule(date?)` | Programs | Today's workout from active program |
|
||||||
|
| `redeem_reward(reward_name)` | Rewards | Spend points on a reward |
|
||||||
|
| `load_meal_plan(name, duration_days, meals, ...)` | Meal Plans | Create a full meal plan |
|
||||||
|
| `get_meal_plan(date?)` | Meal Plans | View today's planned meals |
|
||||||
|
| `update_meal_compliance(plan_item_id, status, ...)` | Meal Plans | Mark meal followed/skipped |
|
||||||
|
| `get_plan_progress(plan_id?)` | Meal Plans | Compliance statistics |
|
||||||
|
| `configure_integration(provider, credentials)` | Integrations | Store encrypted credentials (write-only) |
|
||||||
|
| `get_integration_status()` | Integrations | Check provider connection status |
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
- The **agent generates meal plans** — the app only stores and tracks them. Zero AI API cost.
|
||||||
|
- Integration credentials are **write-only through MCP** — agents can set but never read them back.
|
||||||
|
- The app collects **BREQ-2 motivation data** during onboarding. Use `get_context()` to read the user's motivation profile and calibrate tone accordingly.
|
||||||
|
- Points reset weekly. Each week is a fresh start. No guilt carry-over.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- **Repository:** https://github.com/diligenceworks/diligence
|
||||||
|
- **License:** MIT
|
||||||
|
- **Built by:** DiligenceWorks Pte. Ltd. (https://diligenceworks.online)
|
||||||
68
diligence/frontend/assets/index-CsKoBN0D.js
Normal file
68
diligence/frontend/assets/index-CsKoBN0D.js
Normal file
File diff suppressed because one or more lines are too long
1
diligence/frontend/assets/index-ciTnvXdZ.css
Normal file
1
diligence/frontend/assets/index-ciTnvXdZ.css
Normal file
File diff suppressed because one or more lines are too long
16
diligence/frontend/index.html
Normal file
16
diligence/frontend/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta name="theme-color" content="#1a1a2e" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<title>Diligence</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-CsKoBN0D.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-ciTnvXdZ.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
diligence/frontend/llms.txt
Normal file
31
diligence/frontend/llms.txt
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Diligence — Self-Hosted Fitness Rewards Platform
|
||||||
|
|
||||||
|
> Data sovereignty for people. Self-hosted fitness tracker with a points-based
|
||||||
|
> behavioral economy and an MCP connector for AI agent integration.
|
||||||
|
> MIT-licensed. Deploy with a single `docker compose up -d`.
|
||||||
|
|
||||||
|
Diligence is a self-hosted fitness web app built around a points-based reward
|
||||||
|
system. Complete fitness activities and food logging to earn points, spend
|
||||||
|
points to unlock configurable real-world rewards. Science-based onboarding
|
||||||
|
(PAR-Q+, TTM stages of change, BREQ-2 motivation assessment).
|
||||||
|
|
||||||
|
## For AI Agents
|
||||||
|
- MCP Server: /mcp (Streamable HTTP/SSE, 14 tools)
|
||||||
|
- Agent Card: /.well-known/agent-card.json
|
||||||
|
- Skills: /.well-known/skills/diligence-fitness/SKILL.md
|
||||||
|
|
||||||
|
## MCP Tools
|
||||||
|
- get_context, get_today, get_week (status)
|
||||||
|
- log_activity, log_food, search_food (tracking)
|
||||||
|
- get_program_schedule (programs)
|
||||||
|
- redeem_reward (rewards)
|
||||||
|
- load_meal_plan, get_meal_plan, update_meal_compliance, get_plan_progress (meal plans)
|
||||||
|
- configure_integration, get_integration_status (device sync)
|
||||||
|
|
||||||
|
## Technical
|
||||||
|
- Source: https://github.com/diligenceworks/diligence (MIT)
|
||||||
|
- Stack: FastAPI + PostgreSQL + React/Vite + FastMCP
|
||||||
|
|
||||||
|
## About
|
||||||
|
Built by DiligenceWorks (https://diligenceworks.online) — adversarial AI
|
||||||
|
deal-diligence platform for institutional investors.
|
||||||
402
diligence/main.py
Normal file
402
diligence/main.py
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("diligence")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Retry DB init — container may start before postgres is ready
|
||||||
|
for attempt in range(10):
|
||||||
|
try:
|
||||||
|
from diligence.database import init_db
|
||||||
|
await init_db()
|
||||||
|
logger.info("Database tables created successfully")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"DB init attempt {attempt + 1}/10 failed: {e}")
|
||||||
|
if attempt < 9:
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
else:
|
||||||
|
logger.error("Could not initialize database after 10 attempts")
|
||||||
|
|
||||||
|
# Fail fast if SECRET_KEY not configured
|
||||||
|
from diligence.config import get_settings
|
||||||
|
_s = get_settings()
|
||||||
|
if _s.secret_key == "change-me-in-production":
|
||||||
|
logger.error("CRITICAL: SECRET_KEY not set. Run ./setup.sh or set SECRET_KEY in .env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Run lightweight migrations for schema changes
|
||||||
|
try:
|
||||||
|
await run_migrations()
|
||||||
|
logger.info("Migrations completed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Migration failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
# Seed resources
|
||||||
|
try:
|
||||||
|
await seed_resources()
|
||||||
|
logger.info("Resource library seeded")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Resource seeding failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
# Start background crawl queue scheduler
|
||||||
|
crawl_task = None
|
||||||
|
if _s.crawl_enabled:
|
||||||
|
try:
|
||||||
|
from diligence.services.crawl_scheduler import crawl_queue_loop
|
||||||
|
crawl_task = asyncio.create_task(crawl_queue_loop())
|
||||||
|
logger.info("Crawl queue scheduler started")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Crawl scheduler failed to start (non-fatal): {e}")
|
||||||
|
else:
|
||||||
|
logger.info("Crawl scheduler disabled (set CRAWL_ENABLED=true to enable)")
|
||||||
|
|
||||||
|
logger.info("Diligence backend started")
|
||||||
|
yield
|
||||||
|
|
||||||
|
if crawl_task:
|
||||||
|
crawl_task.cancel()
|
||||||
|
try:
|
||||||
|
await crawl_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
logger.info("Diligence backend shutting down")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Diligence", version="2.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import routers after app creation to avoid circular imports
|
||||||
|
from diligence.routers.auth import router as auth_router
|
||||||
|
from diligence.routers.onboarding import router as onboarding_router
|
||||||
|
from diligence.routers.activities import router as activities_router
|
||||||
|
from diligence.routers.food import router as food_router
|
||||||
|
from diligence.routers.points import router as points_router, rewards_router
|
||||||
|
from diligence.routers.integrations import router as integrations_router
|
||||||
|
from diligence.routers.programs import router as programs_router
|
||||||
|
from diligence.routers.catalog import router as catalog_router
|
||||||
|
from diligence.routers.support import router as support_router
|
||||||
|
from diligence.routers.nutrition import router as nutrition_router
|
||||||
|
from diligence.routers.meal_plans import router as meal_plans_router
|
||||||
|
from diligence.routers.ai_chat import router as ai_chat_router
|
||||||
|
from diligence.routers.agent import router as agent_router
|
||||||
|
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(onboarding_router)
|
||||||
|
app.include_router(activities_router)
|
||||||
|
app.include_router(food_router)
|
||||||
|
app.include_router(points_router)
|
||||||
|
app.include_router(rewards_router)
|
||||||
|
app.include_router(integrations_router)
|
||||||
|
app.include_router(catalog_router)
|
||||||
|
app.include_router(support_router)
|
||||||
|
app.include_router(programs_router)
|
||||||
|
app.include_router(nutrition_router)
|
||||||
|
app.include_router(meal_plans_router)
|
||||||
|
app.include_router(ai_chat_router)
|
||||||
|
app.include_router(agent_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "version": "2.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Static frontend serving (pip install path) ---
|
||||||
|
# When running without nginx, serve the pre-built React app directly.
|
||||||
|
# The frontend/ directory is bundled with the pip package.
|
||||||
|
_frontend_dir = Path(__file__).parent / "frontend"
|
||||||
|
if _frontend_dir.is_dir() and (_frontend_dir / "index.html").exists():
|
||||||
|
# B2A discovery files
|
||||||
|
@app.get("/llms.txt")
|
||||||
|
async def llms_txt():
|
||||||
|
p = _frontend_dir / "llms.txt"
|
||||||
|
if p.exists():
|
||||||
|
return FileResponse(p, media_type="text/plain")
|
||||||
|
|
||||||
|
@app.get("/.well-known/{path:path}")
|
||||||
|
async def well_known(path: str):
|
||||||
|
p = _frontend_dir / ".well-known" / path
|
||||||
|
if p.exists():
|
||||||
|
media = "text/plain" if path.endswith(".md") else "application/json"
|
||||||
|
return FileResponse(p, media_type=media)
|
||||||
|
|
||||||
|
# Static assets (js, css, images)
|
||||||
|
app.mount("/assets", StaticFiles(directory=_frontend_dir / "assets"), name="assets")
|
||||||
|
|
||||||
|
# SPA catch-all — must be last
|
||||||
|
@app.get("/{path:path}")
|
||||||
|
async def spa_catchall(path: str):
|
||||||
|
# Don't intercept /api routes (already handled above)
|
||||||
|
if path.startswith("api/"):
|
||||||
|
return
|
||||||
|
file_path = _frontend_dir / path
|
||||||
|
if file_path.is_file():
|
||||||
|
return FileResponse(file_path)
|
||||||
|
return FileResponse(_frontend_dir / "index.html")
|
||||||
|
|
||||||
|
logger.info(f"Serving frontend from {_frontend_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Cross-dialect migrations ---
|
||||||
|
|
||||||
|
async def run_migrations():
|
||||||
|
"""Add missing columns/tables. Works on both PostgreSQL and SQLite."""
|
||||||
|
from diligence.database import engine
|
||||||
|
from diligence.config import get_settings
|
||||||
|
from sqlalchemy import text, inspect as sa_inspect
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
# Get table inspector
|
||||||
|
def _get_tables_and_columns(connection):
|
||||||
|
inspector = sa_inspect(connection)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
columns = {}
|
||||||
|
for t in tables:
|
||||||
|
columns[t] = [c["name"] for c in inspector.get_columns(t)]
|
||||||
|
return tables, columns
|
||||||
|
|
||||||
|
tables, columns = await conn.run_sync(_get_tables_and_columns)
|
||||||
|
|
||||||
|
# v1: Add equipment_list column if missing
|
||||||
|
if "user_profiles" in tables and "equipment_list" not in columns.get("user_profiles", []):
|
||||||
|
if settings.is_sqlite:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE user_profiles ADD COLUMN equipment_list TEXT DEFAULT '[]'"
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE user_profiles ADD COLUMN equipment_list JSONB DEFAULT '[]'"
|
||||||
|
))
|
||||||
|
|
||||||
|
# v2: Add catalog columns to programs table
|
||||||
|
if "programs" in tables:
|
||||||
|
prog_cols = columns.get("programs", [])
|
||||||
|
if "catalog_id" not in prog_cols:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE programs ADD COLUMN catalog_id VARCHAR(36)"
|
||||||
|
))
|
||||||
|
if "current_week" not in prog_cols:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE programs ADD COLUMN current_week INTEGER DEFAULT 1"
|
||||||
|
))
|
||||||
|
if "current_day" not in prog_cols:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE programs ADD COLUMN current_day INTEGER DEFAULT 1"
|
||||||
|
))
|
||||||
|
|
||||||
|
# v2.1: Add week_number to workout_logs
|
||||||
|
if "workout_logs" in tables and "week_number" not in columns.get("workout_logs", []):
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE workout_logs ADD COLUMN week_number INTEGER DEFAULT 1"
|
||||||
|
))
|
||||||
|
|
||||||
|
# v4: Add is_admin to users
|
||||||
|
if "users" in tables and "is_admin" not in columns.get("users", []):
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT FALSE"
|
||||||
|
))
|
||||||
|
|
||||||
|
# v4.1: Grant admin to first registered user if none exists
|
||||||
|
if "users" in tables and "is_admin" in columns.get("users", []):
|
||||||
|
result = await conn.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE is_admin = TRUE"
|
||||||
|
))
|
||||||
|
admin_count = result.scalar()
|
||||||
|
if admin_count == 0:
|
||||||
|
await conn.execute(text(
|
||||||
|
"UPDATE users SET is_admin = TRUE "
|
||||||
|
"WHERE id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1)"
|
||||||
|
))
|
||||||
|
|
||||||
|
# v5: integration_configs table — handled by create_all
|
||||||
|
# v6: meal plan tables — handled by create_all
|
||||||
|
# v7: point rules seeding
|
||||||
|
if "point_rules" in tables and "users" in tables:
|
||||||
|
for category, points in [
|
||||||
|
("fast_completed", 200),
|
||||||
|
("keto_compliant_day", 100),
|
||||||
|
("meal_plan_followed", 40),
|
||||||
|
("meal_plan_partial", 20),
|
||||||
|
]:
|
||||||
|
# Check if any user is missing this rule
|
||||||
|
result = await conn.execute(text(
|
||||||
|
"SELECT u.id FROM users u "
|
||||||
|
"WHERE NOT EXISTS ("
|
||||||
|
" SELECT 1 FROM point_rules pr "
|
||||||
|
" WHERE pr.user_id = u.id AND pr.category = :cat"
|
||||||
|
")"
|
||||||
|
), {"cat": category})
|
||||||
|
missing_users = result.fetchall()
|
||||||
|
for (user_id,) in missing_users:
|
||||||
|
# Use Python uuid for SQLite compatibility
|
||||||
|
import uuid
|
||||||
|
await conn.execute(text(
|
||||||
|
"INSERT INTO point_rules (id, user_id, category, points, unit, is_active) "
|
||||||
|
"VALUES (:id, :uid, :cat, :pts, 'per_event', TRUE)"
|
||||||
|
), {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"uid": str(user_id),
|
||||||
|
"cat": category,
|
||||||
|
"pts": points,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_resources():
|
||||||
|
"""Seed the resource library with curated fitness programs."""
|
||||||
|
from diligence.database import async_session
|
||||||
|
from diligence.models.resource import Resource
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
existing = await db.execute(select(Resource).limit(1))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
return
|
||||||
|
|
||||||
|
resources = [
|
||||||
|
Resource(
|
||||||
|
name="Darebee Foundation Program",
|
||||||
|
source="darebee",
|
||||||
|
url="https://darebee.com/programs/foundation-program.html",
|
||||||
|
description="30-day beginner program. Bodyweight exercises, no equipment needed. Perfect starting point.",
|
||||||
|
goal_tags=["get_active", "feel_better"],
|
||||||
|
activity_tags=["bodyweight"],
|
||||||
|
equipment_needed="none",
|
||||||
|
ttm_stages=["precontemplation", "contemplation", "preparation"],
|
||||||
|
difficulty="beginner",
|
||||||
|
duration_days=30,
|
||||||
|
),
|
||||||
|
Resource(
|
||||||
|
name="Darebee 30 Days of Change",
|
||||||
|
source="darebee",
|
||||||
|
url="https://darebee.com/programs/30-days-of-change.html",
|
||||||
|
description="30-day progressive program for building fitness habits. Bodyweight only.",
|
||||||
|
goal_tags=["get_active", "lose_weight", "feel_better"],
|
||||||
|
activity_tags=["bodyweight"],
|
||||||
|
equipment_needed="none",
|
||||||
|
ttm_stages=["contemplation", "preparation"],
|
||||||
|
difficulty="beginner",
|
||||||
|
duration_days=30,
|
||||||
|
),
|
||||||
|
Resource(
|
||||||
|
name="Darebee IRONHEART",
|
||||||
|
source="darebee",
|
||||||
|
url="https://darebee.com/programs/ironheart.html",
|
||||||
|
description="Strength-focused bodyweight program. No equipment, all levels.",
|
||||||
|
goal_tags=["build_strength"],
|
||||||
|
activity_tags=["bodyweight"],
|
||||||
|
equipment_needed="none",
|
||||||
|
ttm_stages=["preparation", "action"],
|
||||||
|
difficulty="intermediate",
|
||||||
|
duration_days=30,
|
||||||
|
),
|
||||||
|
Resource(
|
||||||
|
name="Darebee Total Body Strength",
|
||||||
|
source="darebee",
|
||||||
|
url="https://darebee.com/programs/total-body-strength.html",
|
||||||
|
description="Comprehensive strength building program using bodyweight.",
|
||||||
|
goal_tags=["build_strength"],
|
||||||
|
activity_tags=["bodyweight"],
|
||||||
|
equipment_needed="none",
|
||||||
|
ttm_stages=["action", "maintenance"],
|
||||||
|
difficulty="intermediate",
|
||||||
|
duration_days=30,
|
||||||
|
),
|
||||||
|
Resource(
|
||||||
|
name="Darebee 8 Weeks to 5K",
|
||||||
|
source="darebee",
|
||||||
|
url="https://darebee.com/programs/8-weeks-to-5k-program.html",
|
||||||
|
description="Progressive running program from beginner to 5K in 8 weeks.",
|
||||||
|
goal_tags=["get_active", "lose_weight"],
|
||||||
|
activity_tags=["running"],
|
||||||
|
equipment_needed="none",
|
||||||
|
ttm_stages=["preparation", "action"],
|
||||||
|
difficulty="beginner",
|
||||||
|
duration_days=56,
|
||||||
|
),
|
||||||
|
Resource(
|
||||||
|
name="StrongLifts 5x5",
|
||||||
|
source="stronglifts",
|
||||||
|
url="https://stronglifts.com/5x5/",
|
||||||
|
description="Simple barbell strength program. 3 days/week, 5 exercises, progressive overload.",
|
||||||
|
goal_tags=["build_strength"],
|
||||||
|
activity_tags=["weights"],
|
||||||
|
equipment_needed="full_gym",
|
||||||
|
ttm_stages=["preparation", "action", "maintenance"],
|
||||||
|
difficulty="beginner",
|
||||||
|
duration_days=90,
|
||||||
|
),
|
||||||
|
Resource(
|
||||||
|
name="Darebee 30 Days of Cardio",
|
||||||
|
source="darebee",
|
||||||
|
url="https://darebee.com/programs/30-days-of-cardio.html",
|
||||||
|
description="30-day cardio program, no equipment. Great for weight loss.",
|
||||||
|
goal_tags=["lose_weight", "get_active"],
|
||||||
|
activity_tags=["bodyweight"],
|
||||||
|
equipment_needed="none",
|
||||||
|
ttm_stages=["preparation", "action"],
|
||||||
|
difficulty="beginner",
|
||||||
|
duration_days=30,
|
||||||
|
),
|
||||||
|
Resource(
|
||||||
|
name="Darebee POWERBUILDER",
|
||||||
|
source="darebee",
|
||||||
|
url="https://darebee.com/programs/powerbuilder-program.html",
|
||||||
|
description="Advanced strength and power program using bodyweight.",
|
||||||
|
goal_tags=["build_strength"],
|
||||||
|
activity_tags=["bodyweight"],
|
||||||
|
equipment_needed="none",
|
||||||
|
ttm_stages=["action", "maintenance"],
|
||||||
|
difficulty="advanced",
|
||||||
|
duration_days=30,
|
||||||
|
),
|
||||||
|
Resource(
|
||||||
|
name="Fitness Blender (YouTube)",
|
||||||
|
source="youtube",
|
||||||
|
url="https://www.youtube.com/@fitnessblender",
|
||||||
|
description="Free workout videos for all levels. Huge variety: HIIT, strength, yoga, pilates.",
|
||||||
|
goal_tags=["lose_weight", "build_strength", "get_active", "feel_better"],
|
||||||
|
activity_tags=["bodyweight", "yoga"],
|
||||||
|
equipment_needed="none",
|
||||||
|
ttm_stages=["contemplation", "preparation", "action", "maintenance"],
|
||||||
|
difficulty="beginner",
|
||||||
|
duration_days=None,
|
||||||
|
),
|
||||||
|
Resource(
|
||||||
|
name="Darebee Yoga Flexibility Program",
|
||||||
|
source="darebee",
|
||||||
|
url="https://darebee.com/programs/flexibility-program.html",
|
||||||
|
description="30-day flexibility and yoga program for beginners.",
|
||||||
|
goal_tags=["feel_better"],
|
||||||
|
activity_tags=["yoga"],
|
||||||
|
equipment_needed="none",
|
||||||
|
ttm_stages=["precontemplation", "contemplation", "preparation"],
|
||||||
|
difficulty="beginner",
|
||||||
|
duration_days=30,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for r in resources:
|
||||||
|
db.add(r)
|
||||||
|
await db.commit()
|
||||||
41
diligence/mcp/__init__.py
Normal file
41
diligence/mcp/__init__.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""MCP server startup for the pip install path.
|
||||||
|
|
||||||
|
Starts FastMCP in a background thread so AI agents can connect
|
||||||
|
to the same machine running Diligence. Requires: pip install diligence[mcp]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("diligence.mcp")
|
||||||
|
|
||||||
|
|
||||||
|
def start_mcp_background(api_url: str, api_token: str = "", port: int = 3001):
|
||||||
|
"""Start the MCP SSE server in a background thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_url: Backend API base URL (e.g. http://localhost:8000)
|
||||||
|
api_token: Bearer token for API auth
|
||||||
|
port: Port for the MCP SSE server (default 3001)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from diligence.mcp.server import create_mcp_server
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("MCP package not installed. Install with: pip install diligence[mcp]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
import asyncio
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
try:
|
||||||
|
mcp = create_mcp_server(api_url=api_url, api_token=api_token, port=port)
|
||||||
|
mcp.run(transport="sse")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MCP server failed: {e}")
|
||||||
|
|
||||||
|
thread = threading.Thread(target=_run, daemon=True, name="mcp-server")
|
||||||
|
thread.start()
|
||||||
|
logger.info(f"MCP server starting on port {port}")
|
||||||
|
return True
|
||||||
223
diligence/mcp/server.py
Normal file
223
diligence/mcp/server.py
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
"""Diligence MCP Server — 14 tools for AI agent integration.
|
||||||
|
|
||||||
|
For the pip install path, this runs in-process as a background thread.
|
||||||
|
For the Docker path, this runs as a separate container (mcp-connector/).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def create_mcp_server(api_url: str = "http://localhost:8000", api_token: str = "", port: int = 3001) -> FastMCP:
|
||||||
|
"""Create and configure the MCP server with all 14 tools."""
|
||||||
|
|
||||||
|
mcp = FastMCP("Diligence Fitness", port=port)
|
||||||
|
|
||||||
|
async def api(method: str, path: str, **kwargs) -> dict:
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
headers = {}
|
||||||
|
if api_token:
|
||||||
|
headers["Authorization"] = f"Bearer {api_token}"
|
||||||
|
_client = httpx.AsyncClient(
|
||||||
|
base_url=api_url, timeout=30, headers=headers
|
||||||
|
)
|
||||||
|
resp = await getattr(_client, method)(f"/api{path}", **kwargs)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
# --- CONTEXT & STATUS (3 tools) ---
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_context() -> str:
|
||||||
|
"""Get full user profile, motivation type, program status, point rules, rewards, and integration status. Call this at the start of each conversation to understand the user's current state."""
|
||||||
|
data = await api("get", "/points/today")
|
||||||
|
profile = await api("get", "/onboarding/profile")
|
||||||
|
rewards = await api("get", "/points/rewards")
|
||||||
|
integrations = await api("get", "/integrations/status")
|
||||||
|
meal_plan = None
|
||||||
|
try:
|
||||||
|
meal_plan = await api("get", "/meal-plans/today")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return json.dumps({
|
||||||
|
"user": profile, "today": data, "rewards": rewards,
|
||||||
|
"integrations": integrations, "active_meal_plan": meal_plan,
|
||||||
|
}, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_today(date_str: str | None = None) -> str:
|
||||||
|
"""Get today's points earned, daily gate status, breakdown by category, and activities logged. Optionally pass a date (YYYY-MM-DD)."""
|
||||||
|
params = {"date": date_str} if date_str else {}
|
||||||
|
data = await api("get", "/points/today", params=params)
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_week(start_date: str | None = None) -> str:
|
||||||
|
"""Get weekly summary including total points, active days count, target progress, and day-by-day breakdown."""
|
||||||
|
params = {"start_date": start_date} if start_date else {}
|
||||||
|
data = await api("get", "/points/week", params=params)
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
# --- ACTIVITY LOGGING (1 tool) ---
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def log_activity(
|
||||||
|
category: str, title: str, duration_minutes: int,
|
||||||
|
notes: str | None = None, date_str: str | None = None,
|
||||||
|
program_day: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Log a workout or activity. Returns points earned and daily total.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Type of activity (workout, cardio, yoga, walking, cycling, swimming, other)
|
||||||
|
title: Description of what was done
|
||||||
|
duration_minutes: How long the activity lasted
|
||||||
|
notes: Optional additional notes
|
||||||
|
date_str: Optional date (YYYY-MM-DD), defaults to today
|
||||||
|
program_day: Optional program day number
|
||||||
|
"""
|
||||||
|
payload = {"category": category, "title": title, "duration_minutes": duration_minutes}
|
||||||
|
if notes: payload["notes"] = notes
|
||||||
|
if date_str: payload["date"] = date_str
|
||||||
|
if program_day: payload["program_day"] = program_day
|
||||||
|
data = await api("post", "/activities/log", json=payload)
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
# --- FOOD & NUTRITION (2 tools) ---
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def log_food(
|
||||||
|
meal_type: str, food_name: str, calories: int | None = None,
|
||||||
|
protein_g: float | None = None, carbs_g: float | None = None,
|
||||||
|
fat_g: float | None = None, servings: float | None = None,
|
||||||
|
plan_item_id: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Log a food item with optional nutrition data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
meal_type: One of breakfast, lunch, dinner, snack
|
||||||
|
food_name: What was eaten
|
||||||
|
calories: Estimated calories
|
||||||
|
protein_g: Grams of protein
|
||||||
|
carbs_g: Grams of carbs
|
||||||
|
fat_g: Grams of fat
|
||||||
|
servings: Number of servings (default 1)
|
||||||
|
plan_item_id: Optional meal plan item ID for compliance
|
||||||
|
"""
|
||||||
|
payload = {"meal_type": meal_type, "food_name": food_name}
|
||||||
|
for k, v in [("calories", calories), ("protein_g", protein_g), ("carbs_g", carbs_g),
|
||||||
|
("fat_g", fat_g), ("servings", servings), ("plan_item_id", plan_item_id)]:
|
||||||
|
if v is not None: payload[k] = v
|
||||||
|
data = await api("post", "/food/log", json=payload)
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_food(query: str) -> str:
|
||||||
|
"""Search for food items in Open Food Facts and USDA FoodData Central. Returns nutrition data for matching items."""
|
||||||
|
data = await api("get", "/food/search", params={"q": query})
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
# --- PROGRAMS (1 tool) ---
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_program_schedule(date_str: str | None = None) -> str:
|
||||||
|
"""Get today's scheduled workout from the active program, including exercises with sets, reps, and weight progression."""
|
||||||
|
params = {"date": date_str} if date_str else {}
|
||||||
|
data = await api("get", "/programs/schedule", params=params)
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
# --- REWARDS (1 tool) ---
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def redeem_reward(reward_name: str) -> str:
|
||||||
|
"""Spend points on a configured reward. Returns success/failure and remaining points."""
|
||||||
|
data = await api("post", "/points/rewards/redeem", json={"name": reward_name})
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
# --- MEAL PLANS (4 tools) ---
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def load_meal_plan(
|
||||||
|
name: str, duration_days: int, meals: list[dict],
|
||||||
|
diet_type: str | None = None, daily_calories: int | None = None,
|
||||||
|
restrictions: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Create a complete meal plan with all items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Plan name (e.g. "7-Day Dairy-Free Keto")
|
||||||
|
duration_days: How many days the plan covers
|
||||||
|
meals: List of meal items with day_number, meal_type, food_name, calories, protein_g, carbs_g, fat_g
|
||||||
|
diet_type: Optional diet type (keto, paleo, balanced, custom)
|
||||||
|
daily_calories: Optional daily calorie target
|
||||||
|
restrictions: Optional list of dietary restrictions
|
||||||
|
"""
|
||||||
|
payload = {"name": name, "duration_days": duration_days, "meals": meals}
|
||||||
|
if diet_type: payload["diet_type"] = diet_type
|
||||||
|
if daily_calories: payload["daily_calories"] = daily_calories
|
||||||
|
if restrictions: payload["restrictions"] = restrictions
|
||||||
|
data = await api("post", "/meal-plans", json=payload)
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_meal_plan(date_str: str | None = None) -> str:
|
||||||
|
"""View the active meal plan's meals for today or a specified date."""
|
||||||
|
data = await api("get", "/meal-plans/today")
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def update_meal_compliance(
|
||||||
|
plan_item_id: str, status: str, substitution: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Mark a planned meal as followed, substituted, or skipped.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plan_item_id: The meal plan item ID
|
||||||
|
status: One of 'followed', 'substituted', 'skipped'
|
||||||
|
substitution: What was eaten instead (required if 'substituted')
|
||||||
|
"""
|
||||||
|
payload = {"plan_item_id": plan_item_id, "status": status}
|
||||||
|
if substitution: payload["substitution"] = substitution
|
||||||
|
data = await api("post", "/meal-plans/compliance", json=payload)
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_plan_progress(plan_id: str | None = None) -> str:
|
||||||
|
"""Get overall compliance stats for a meal plan."""
|
||||||
|
if plan_id:
|
||||||
|
data = await api("get", f"/meal-plans/{plan_id}/progress")
|
||||||
|
else:
|
||||||
|
plans = await api("get", "/meal-plans")
|
||||||
|
active = next((p for p in plans if p.get("status") == "active"), None)
|
||||||
|
if not active:
|
||||||
|
return json.dumps({"error": "No active meal plan"})
|
||||||
|
data = await api("get", f"/meal-plans/{active['id']}/progress")
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
# --- INTEGRATIONS (2 tools) ---
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def configure_integration(provider: str, credentials: dict) -> str:
|
||||||
|
"""Store encrypted integration credentials. Write-only.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: Provider name (strava, polar, garmin, fitbit, usda, telegram, groq, etc.)
|
||||||
|
credentials: Dict of credential fields
|
||||||
|
"""
|
||||||
|
data = await api("post", "/integrations/configure", json={
|
||||||
|
"provider": provider, "credentials": credentials,
|
||||||
|
})
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_integration_status() -> str:
|
||||||
|
"""Check connection status of all integration providers."""
|
||||||
|
data = await api("get", "/integrations/status")
|
||||||
|
return json.dumps(data, indent=2, default=str)
|
||||||
|
|
||||||
|
return mcp
|
||||||
24
diligence/models/__init__.py
Normal file
24
diligence/models/__init__.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
from diligence.models.user import User
|
||||||
|
from diligence.models.profile import UserProfile
|
||||||
|
from diligence.models.program import Program
|
||||||
|
from diligence.models.activity import ActivityLog
|
||||||
|
from diligence.models.food import FoodLog
|
||||||
|
from diligence.models.points import PointRule, DailyTarget
|
||||||
|
from diligence.models.reward import Reward, RewardRedemption
|
||||||
|
from diligence.models.oauth import OAuthToken
|
||||||
|
from diligence.models.resource import Resource
|
||||||
|
from diligence.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
|
||||||
|
from diligence.models.support import SupportThread, SupportMessage
|
||||||
|
from diligence.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
|
||||||
|
from diligence.models.integration_config import IntegrationConfig
|
||||||
|
from diligence.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
|
||||||
|
"PointRule", "DailyTarget", "Reward", "RewardRedemption",
|
||||||
|
"OAuthToken", "Resource",
|
||||||
|
"ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog",
|
||||||
|
"SupportThread", "SupportMessage",
|
||||||
|
"NutritionGoal", "Fast", "ElectrolyteLog",
|
||||||
|
"IntegrationConfig", "MealPlan", "MealPlanItem", "MealCompliance",
|
||||||
|
]
|
||||||
|
|
@ -2,17 +2,16 @@ from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, date, timezone
|
from datetime import datetime, date, timezone
|
||||||
from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey, Index
|
from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey, Index, Uuid, JSON
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityLog(Base):
|
class ActivityLog(Base):
|
||||||
__tablename__ = "activity_log"
|
__tablename__ = "activity_log"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||||
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
title: Mapped[str | None] = mapped_column(String(200))
|
title: Mapped[str | None] = mapped_column(String(200))
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
@ -20,12 +19,12 @@ class ActivityLog(Base):
|
||||||
source: Mapped[str] = mapped_column(String(30), default="manual")
|
source: Mapped[str] = mapped_column(String(30), default="manual")
|
||||||
external_id: Mapped[str | None] = mapped_column(String(100))
|
external_id: Mapped[str | None] = mapped_column(String(100))
|
||||||
points_earned: Mapped[int] = mapped_column(Integer, default=0)
|
points_earned: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
rule_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("point_rules.id"), nullable=True)
|
rule_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("point_rules.id"), nullable=True)
|
||||||
activity_date: Mapped[date] = mapped_column(Date, nullable=False)
|
activity_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
program_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("programs.id"), nullable=True)
|
program_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("programs.id"), nullable=True)
|
||||||
program_day: Mapped[int | None] = mapped_column(Integer)
|
program_day: Mapped[int | None] = mapped_column(Integer)
|
||||||
metadata_json: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
|
metadata_json: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_activity_log_user_date", "user_id", "activity_date"),
|
Index("idx_activity_log_user_date", "user_id", "activity_date"),
|
||||||
|
|
@ -2,28 +2,27 @@ from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import String, Integer, Text, Boolean, DateTime, ForeignKey, UniqueConstraint
|
from sqlalchemy import String, Integer, Text, Boolean, DateTime, ForeignKey, UniqueConstraint, Uuid, JSON
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class ProgramCatalog(Base):
|
class ProgramCatalog(Base):
|
||||||
"""Shared program definitions — one per program, reusable by all users."""
|
"""Shared program definitions — one per program, reusable by all users."""
|
||||||
__tablename__ = "program_catalog"
|
__tablename__ = "program_catalog"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
slug: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
|
slug: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
source_url: Mapped[str | None] = mapped_column(Text)
|
source_url: Mapped[str | None] = mapped_column(Text)
|
||||||
duration_weeks: Mapped[int | None] = mapped_column(Integer)
|
duration_weeks: Mapped[int | None] = mapped_column(Integer)
|
||||||
frequency_per_week: Mapped[int | None] = mapped_column(Integer)
|
frequency_per_week: Mapped[int | None] = mapped_column(Integer)
|
||||||
equipment: Mapped[dict] = mapped_column(JSONB, default=list)
|
equipment: Mapped[dict] = mapped_column(JSON, default=list)
|
||||||
difficulty: Mapped[str | None] = mapped_column(String(20))
|
difficulty: Mapped[str | None] = mapped_column(String(20))
|
||||||
category: Mapped[str | None] = mapped_column(String(50))
|
category: Mapped[str | None] = mapped_column(String(50))
|
||||||
progression_rules: Mapped[str | None] = mapped_column(Text)
|
progression_rules: Mapped[str | None] = mapped_column(Text)
|
||||||
structured_data: Mapped[dict | None] = mapped_column(JSONB)
|
structured_data: Mapped[dict | None] = mapped_column(JSON)
|
||||||
crawl_status: Mapped[str] = mapped_column(String(20), default="pending")
|
crawl_status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||||
crawl_error: Mapped[str | None] = mapped_column(Text)
|
crawl_error: Mapped[str | None] = mapped_column(Text)
|
||||||
crawled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
crawled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
@ -41,14 +40,14 @@ class CatalogWorkout(Base):
|
||||||
"""Individual workout within a catalog program."""
|
"""Individual workout within a catalog program."""
|
||||||
__tablename__ = "catalog_workouts"
|
__tablename__ = "catalog_workouts"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
catalog_id: Mapped[uuid.UUID] = mapped_column(
|
catalog_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("program_catalog.id", ondelete="CASCADE"), nullable=False
|
Uuid, ForeignKey("program_catalog.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
week_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
week_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
day_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
day_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
workout_name: Mapped[str | None] = mapped_column(String(200))
|
workout_name: Mapped[str | None] = mapped_column(String(200))
|
||||||
exercises: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
exercises: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
notes: Mapped[str | None] = mapped_column(Text)
|
notes: Mapped[str | None] = mapped_column(Text)
|
||||||
rest_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
rest_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
|
@ -63,14 +62,14 @@ class CrawlQueue(Base):
|
||||||
"""Job queue for program research crawls."""
|
"""Job queue for program research crawls."""
|
||||||
__tablename__ = "crawl_queue"
|
__tablename__ = "crawl_queue"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
catalog_id: Mapped[uuid.UUID | None] = mapped_column(
|
catalog_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("program_catalog.id"), nullable=True
|
Uuid, ForeignKey("program_catalog.id"), nullable=True
|
||||||
)
|
)
|
||||||
search_query: Mapped[str] = mapped_column(String(500), nullable=False)
|
search_query: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
priority: Mapped[str] = mapped_column(String(10), default="low")
|
priority: Mapped[str] = mapped_column(String(10), default="low")
|
||||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||||
urls_to_crawl: Mapped[dict] = mapped_column(JSONB, default=list)
|
urls_to_crawl: Mapped[dict] = mapped_column(JSON, default=list)
|
||||||
crawled_content: Mapped[str | None] = mapped_column(Text)
|
crawled_content: Mapped[str | None] = mapped_column(Text)
|
||||||
error: Mapped[str | None] = mapped_column(Text)
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
|
@ -84,20 +83,20 @@ class WorkoutLog(Base):
|
||||||
"""Per-user workout completion tracking against catalog workouts."""
|
"""Per-user workout completion tracking against catalog workouts."""
|
||||||
__tablename__ = "workout_logs"
|
__tablename__ = "workout_logs"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
|
Uuid, ForeignKey("users.id"), nullable=False
|
||||||
)
|
)
|
||||||
program_id: Mapped[uuid.UUID] = mapped_column(
|
program_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("programs.id"), nullable=False
|
Uuid, ForeignKey("programs.id"), nullable=False
|
||||||
)
|
)
|
||||||
catalog_workout_id: Mapped[uuid.UUID] = mapped_column(
|
catalog_workout_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("catalog_workouts.id"), nullable=False
|
Uuid, ForeignKey("catalog_workouts.id"), nullable=False
|
||||||
)
|
)
|
||||||
week_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
week_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
completed_at: Mapped[datetime] = mapped_column(
|
completed_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
exercises_completed: Mapped[dict | None] = mapped_column(JSONB)
|
exercises_completed: Mapped[dict | None] = mapped_column(JSON)
|
||||||
points_earned: Mapped[int] = mapped_column(Integer, default=0)
|
points_earned: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
notes: Mapped[str | None] = mapped_column(Text)
|
notes: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
@ -3,17 +3,16 @@ from __future__ import annotations
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, date, timezone
|
from datetime import datetime, date, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from sqlalchemy import String, Numeric, Date, DateTime, Text, ForeignKey, Index
|
from sqlalchemy import String, Numeric, Date, DateTime, Text, ForeignKey, Index, Uuid, JSON
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class FoodLog(Base):
|
class FoodLog(Base):
|
||||||
__tablename__ = "food_log"
|
__tablename__ = "food_log"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||||
meal_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
meal_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
food_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
food_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||||
brand: Mapped[str | None] = mapped_column(String(200))
|
brand: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
|
@ -30,7 +29,7 @@ class FoodLog(Base):
|
||||||
food_date: Mapped[date] = mapped_column(Date, nullable=False)
|
food_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
logged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
off_product_id: Mapped[str | None] = mapped_column(String(50))
|
off_product_id: Mapped[str | None] = mapped_column(String(50))
|
||||||
off_data: Mapped[dict] = mapped_column(JSONB, default=dict)
|
off_data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_food_log_user_date", "user_id", "food_date"),
|
Index("idx_food_log_user_date", "user_id", "food_date"),
|
||||||
|
|
@ -3,17 +3,16 @@ from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import String, DateTime, Text, UniqueConstraint
|
from sqlalchemy import String, DateTime, Text, UniqueConstraint, Uuid
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class IntegrationConfig(Base):
|
class IntegrationConfig(Base):
|
||||||
__tablename__ = "integration_configs"
|
__tablename__ = "integration_configs"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||||
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
config_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
config_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
config_value: Mapped[str] = mapped_column(Text, nullable=False) # Fernet encrypted
|
config_value: Mapped[str] = mapped_column(Text, nullable=False) # Fernet encrypted
|
||||||
|
|
@ -4,24 +4,23 @@ from __future__ import annotations
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, date, timezone
|
from datetime import datetime, date, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from sqlalchemy import String, Integer, Date, DateTime, Text, ForeignKey, DECIMAL
|
from sqlalchemy import String, Integer, Date, DateTime, Text, ForeignKey, DECIMAL, Uuid, JSON
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class MealPlan(Base):
|
class MealPlan(Base):
|
||||||
__tablename__ = "meal_plans"
|
__tablename__ = "meal_plans"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
diet_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
diet_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
daily_calories: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
daily_calories: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
daily_protein_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
daily_protein_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
daily_carbs_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
daily_carbs_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
daily_fat_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
daily_fat_g: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
restrictions: Mapped[dict] = mapped_column(JSONB, default=list)
|
restrictions: Mapped[dict] = mapped_column(JSON, default=list)
|
||||||
duration_days: Mapped[int] = mapped_column(Integer, nullable=False)
|
duration_days: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String(20), default="active")
|
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||||
|
|
@ -33,8 +32,8 @@ class MealPlan(Base):
|
||||||
class MealPlanItem(Base):
|
class MealPlanItem(Base):
|
||||||
__tablename__ = "meal_plan_items"
|
__tablename__ = "meal_plan_items"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
plan_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("meal_plans.id", ondelete="CASCADE"), nullable=False)
|
plan_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("meal_plans.id", ondelete="CASCADE"), nullable=False)
|
||||||
day_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
day_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
meal_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
meal_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
food_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
food_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||||
|
|
@ -54,12 +53,12 @@ class MealPlanItem(Base):
|
||||||
class MealCompliance(Base):
|
class MealCompliance(Base):
|
||||||
__tablename__ = "meal_compliance"
|
__tablename__ = "meal_compliance"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||||
plan_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("meal_plans.id"), nullable=False)
|
plan_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("meal_plans.id"), nullable=False)
|
||||||
plan_item_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
plan_item_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
|
||||||
compliance_date: Mapped[date] = mapped_column(Date, nullable=False)
|
compliance_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String(20), nullable=False) # followed, substituted, skipped
|
status: Mapped[str] = mapped_column(String(20), nullable=False) # followed, substituted, skipped
|
||||||
substitution: Mapped[str | None] = mapped_column(Text, nullable=True)
|
substitution: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
food_log_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
food_log_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
@ -3,18 +3,17 @@ from __future__ import annotations
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from sqlalchemy import String, Integer, Numeric, DateTime, Boolean, Text, ForeignKey, Index
|
from sqlalchemy import String, Integer, Numeric, DateTime, Boolean, Text, ForeignKey, Index, Uuid
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class NutritionGoal(Base):
|
class NutritionGoal(Base):
|
||||||
"""Per-user macro + eating-window targets. One active per user."""
|
"""Per-user macro + eating-window targets. One active per user."""
|
||||||
__tablename__ = "nutrition_goals"
|
__tablename__ = "nutrition_goals"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False, unique=True)
|
||||||
|
|
||||||
# Macro targets (grams / kcal)
|
# Macro targets (grams / kcal)
|
||||||
calorie_target: Mapped[int] = mapped_column(Integer, default=2400)
|
calorie_target: Mapped[int] = mapped_column(Integer, default=2400)
|
||||||
|
|
@ -33,7 +32,7 @@ class NutritionGoal(Base):
|
||||||
default_fast_hours: Mapped[int] = mapped_column(Integer, default=16) # 16:8
|
default_fast_hours: Mapped[int] = mapped_column(Integer, default=16) # 16:8
|
||||||
|
|
||||||
diet_style: Mapped[str] = mapped_column(String(30), default="strict_keto")
|
diet_style: Mapped[str] = mapped_column(String(30), default="strict_keto")
|
||||||
timezone_str: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok")
|
timezone_str: Mapped[str] = mapped_column(String(50), default="UTC")
|
||||||
|
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
@ -44,8 +43,8 @@ class Fast(Base):
|
||||||
"""A single fasting bout — start/end + type + compliance flag."""
|
"""A single fasting bout — start/end + type + compliance flag."""
|
||||||
__tablename__ = "fasts"
|
__tablename__ = "fasts"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||||
|
|
||||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
@ -60,7 +59,7 @@ class Fast(Base):
|
||||||
|
|
||||||
# Points granted (for idempotency — don't double-credit)
|
# Points granted (for idempotency — don't double-credit)
|
||||||
points_awarded: Mapped[int] = mapped_column(Integer, default=0)
|
points_awarded: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
activity_log_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("activity_log.id"))
|
activity_log_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("activity_log.id"))
|
||||||
|
|
||||||
notes: Mapped[str | None] = mapped_column(Text)
|
notes: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
|
@ -75,8 +74,8 @@ class ElectrolyteLog(Base):
|
||||||
"""Daily electrolyte dosing checklist — lightweight tracker."""
|
"""Daily electrolyte dosing checklist — lightweight tracker."""
|
||||||
__tablename__ = "electrolyte_log"
|
__tablename__ = "electrolyte_log"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||||
log_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
log_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
sodium_mg: Mapped[int] = mapped_column(Integer, default=0)
|
sodium_mg: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
@ -2,17 +2,16 @@ from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import String, Text, DateTime, ForeignKey, UniqueConstraint
|
from sqlalchemy import String, Text, DateTime, ForeignKey, UniqueConstraint, Uuid
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthToken(Base):
|
class OAuthToken(Base):
|
||||||
__tablename__ = "oauth_tokens"
|
__tablename__ = "oauth_tokens"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||||
provider: Mapped[str] = mapped_column(String(20), nullable=False)
|
provider: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
access_token: Mapped[str] = mapped_column(Text, nullable=False)
|
access_token: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
refresh_token: Mapped[str | None] = mapped_column(Text)
|
refresh_token: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
@ -2,17 +2,16 @@ from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey
|
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Uuid
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class PointRule(Base):
|
class PointRule(Base):
|
||||||
__tablename__ = "point_rules"
|
__tablename__ = "point_rules"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||||
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
description: Mapped[str] = mapped_column(String(200), nullable=False)
|
description: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
points: Mapped[int] = mapped_column(Integer, nullable=False)
|
points: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
|
@ -26,8 +25,8 @@ class PointRule(Base):
|
||||||
class DailyTarget(Base):
|
class DailyTarget(Base):
|
||||||
__tablename__ = "daily_targets"
|
__tablename__ = "daily_targets"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False, unique=True)
|
||||||
daily_minimum_pts: Mapped[int] = mapped_column(Integer, default=80)
|
daily_minimum_pts: Mapped[int] = mapped_column(Integer, default=80)
|
||||||
weekly_target_pts: Mapped[int] = mapped_column(Integer, default=500)
|
weekly_target_pts: Mapped[int] = mapped_column(Integer, default=500)
|
||||||
weekly_bonus_pts: Mapped[int] = mapped_column(Integer, default=50)
|
weekly_bonus_pts: Mapped[int] = mapped_column(Integer, default=50)
|
||||||
|
|
@ -3,17 +3,16 @@ from __future__ import annotations
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from sqlalchemy import String, Boolean, Integer, Numeric, DateTime, Text, ForeignKey
|
from sqlalchemy import String, Boolean, Integer, Numeric, DateTime, Text, ForeignKey, Uuid, JSON
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfile(Base):
|
class UserProfile(Base):
|
||||||
__tablename__ = "user_profiles"
|
__tablename__ = "user_profiles"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False, unique=True)
|
||||||
|
|
||||||
# Phase 1
|
# Phase 1
|
||||||
primary_goal: Mapped[str | None] = mapped_column(String(50))
|
primary_goal: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
|
@ -41,9 +40,9 @@ class UserProfile(Base):
|
||||||
motivation_rai: Mapped[Decimal | None] = mapped_column(Numeric(5, 1))
|
motivation_rai: Mapped[Decimal | None] = mapped_column(Numeric(5, 1))
|
||||||
|
|
||||||
# Preferences
|
# Preferences
|
||||||
activity_preferences: Mapped[dict] = mapped_column(JSONB, default=list)
|
activity_preferences: Mapped[dict] = mapped_column(JSON, default=list)
|
||||||
equipment_access: Mapped[str | None] = mapped_column(String(50)) # legacy — kept for compat
|
equipment_access: Mapped[str | None] = mapped_column(String(50)) # legacy — kept for compat
|
||||||
equipment_list: Mapped[dict] = mapped_column(JSONB, default=list) # new: list of equipment strings
|
equipment_list: Mapped[dict] = mapped_column(JSON, default=list) # new: list of equipment strings
|
||||||
days_per_week: Mapped[int] = mapped_column(Integer, default=3)
|
days_per_week: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
minutes_per_session: Mapped[int] = mapped_column(Integer, default=30)
|
minutes_per_session: Mapped[int] = mapped_column(Integer, default=30)
|
||||||
|
|
||||||
|
|
@ -2,17 +2,16 @@ from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, date, timezone
|
from datetime import datetime, date, timezone
|
||||||
from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey
|
from sqlalchemy import String, Integer, Text, Date, DateTime, ForeignKey, Uuid
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Program(Base):
|
class Program(Base):
|
||||||
__tablename__ = "programs"
|
__tablename__ = "programs"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
source: Mapped[str | None] = mapped_column(String(50))
|
source: Mapped[str | None] = mapped_column(String(50))
|
||||||
source_url: Mapped[str | None] = mapped_column(Text)
|
source_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
@ -24,7 +23,7 @@ class Program(Base):
|
||||||
|
|
||||||
# v2: Link to catalog program
|
# v2: Link to catalog program
|
||||||
catalog_id: Mapped[uuid.UUID | None] = mapped_column(
|
catalog_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("program_catalog.id"), nullable=True
|
Uuid, ForeignKey("program_catalog.id"), nullable=True
|
||||||
)
|
)
|
||||||
current_week: Mapped[int] = mapped_column(Integer, default=1)
|
current_week: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
current_day: Mapped[int] = mapped_column(Integer, default=1)
|
current_day: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
|
@ -2,25 +2,24 @@ from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import String, Integer, Boolean, Text, DateTime
|
from sqlalchemy import String, Integer, Boolean, Text, DateTime, Uuid, JSON
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Resource(Base):
|
class Resource(Base):
|
||||||
__tablename__ = "resource_library"
|
__tablename__ = "resource_library"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
source: Mapped[str] = mapped_column(String(50), nullable=False)
|
source: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
url: Mapped[str] = mapped_column(Text, nullable=False)
|
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
thumbnail_url: Mapped[str | None] = mapped_column(Text)
|
thumbnail_url: Mapped[str | None] = mapped_column(Text)
|
||||||
goal_tags: Mapped[dict] = mapped_column(JSONB, default=list)
|
goal_tags: Mapped[dict] = mapped_column(JSON, default=list)
|
||||||
activity_tags: Mapped[dict] = mapped_column(JSONB, default=list)
|
activity_tags: Mapped[dict] = mapped_column(JSON, default=list)
|
||||||
equipment_needed: Mapped[str | None] = mapped_column(String(50))
|
equipment_needed: Mapped[str | None] = mapped_column(String(50))
|
||||||
ttm_stages: Mapped[dict] = mapped_column(JSONB, default=list)
|
ttm_stages: Mapped[dict] = mapped_column(JSON, default=list)
|
||||||
difficulty: Mapped[str | None] = mapped_column(String(20))
|
difficulty: Mapped[str | None] = mapped_column(String(20))
|
||||||
duration_days: Mapped[int | None] = mapped_column(Integer)
|
duration_days: Mapped[int | None] = mapped_column(Integer)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
@ -2,17 +2,16 @@ from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, date, timezone
|
from datetime import datetime, date, timezone
|
||||||
from sqlalchemy import String, Integer, Boolean, Text, Date, DateTime, ForeignKey, Index
|
from sqlalchemy import String, Integer, Boolean, Text, Date, DateTime, ForeignKey, Index, Uuid
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Reward(Base):
|
class Reward(Base):
|
||||||
__tablename__ = "rewards"
|
__tablename__ = "rewards"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
point_cost: Mapped[int] = mapped_column(Integer, nullable=False)
|
point_cost: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
|
@ -25,9 +24,9 @@ class Reward(Base):
|
||||||
class RewardRedemption(Base):
|
class RewardRedemption(Base):
|
||||||
__tablename__ = "reward_redemptions"
|
__tablename__ = "reward_redemptions"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||||
reward_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("rewards.id"), nullable=False)
|
reward_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("rewards.id"), nullable=False)
|
||||||
points_spent: Mapped[int] = mapped_column(Integer, nullable=False)
|
points_spent: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
redeemed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
redeemed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
redemption_date: Mapped[date] = mapped_column(Date, nullable=False)
|
redemption_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
|
@ -2,19 +2,18 @@ from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Index, UniqueConstraint
|
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Index, UniqueConstraint, Uuid, JSON
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class SupportThread(Base):
|
class SupportThread(Base):
|
||||||
"""One thread per user — private conversation with admin."""
|
"""One thread per user — private conversation with admin."""
|
||||||
__tablename__ = "support_threads"
|
__tablename__ = "support_threads"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("users.id"), unique=True, nullable=False
|
Uuid, ForeignKey("users.id"), unique=True, nullable=False
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
|
@ -35,13 +34,13 @@ class SupportMessage(Base):
|
||||||
"""Individual message within a support thread."""
|
"""Individual message within a support thread."""
|
||||||
__tablename__ = "support_messages"
|
__tablename__ = "support_messages"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
thread_id: Mapped[uuid.UUID] = mapped_column(
|
thread_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("support_threads.id", ondelete="CASCADE"), nullable=False
|
Uuid, ForeignKey("support_threads.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
sender: Mapped[str] = mapped_column(String(10), nullable=False) # 'user' or 'admin'
|
sender: Mapped[str] = mapped_column(String(10), nullable=False) # 'user' or 'admin'
|
||||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
context: Mapped[dict | None] = mapped_column(JSONB)
|
context: Mapped[dict | None] = mapped_column(JSON)
|
||||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
|
@ -2,23 +2,22 @@ from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy import String, Boolean, DateTime
|
from sqlalchemy import String, Boolean, DateTime, Uuid
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from diligence.database import Base
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
||||||
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok")
|
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
|
||||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|
@ -6,12 +6,12 @@ from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select, delete
|
from sqlalchemy import select, delete
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from diligence.database import get_db
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
from app.models.activity import ActivityLog
|
from diligence.models.activity import ActivityLog
|
||||||
from app.schemas.activity import ActivityCreate, ActivityResponse
|
from diligence.schemas.activity import ActivityCreate, ActivityResponse
|
||||||
from app.utils.auth import get_current_user
|
from diligence.utils.auth import get_current_user
|
||||||
from app.services.points_engine import log_activity_with_points
|
from diligence.services.points_engine import log_activity_with_points
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/activities", tags=["activities"])
|
router = APIRouter(prefix="/api/activities", tags=["activities"])
|
||||||
|
|
||||||
38
diligence/routers/agent.py
Normal file
38
diligence/routers/agent.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Agent connection endpoint — returns MCP config for AI agent setup."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from diligence.config import get_settings
|
||||||
|
from diligence.utils.auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/agent", tags=["agent"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def agent_config(user=Depends(get_current_user)):
|
||||||
|
"""Return MCP connection details for the authenticated user."""
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
base = settings.base_url.rstrip("/")
|
||||||
|
|
||||||
|
if settings.is_sqlite:
|
||||||
|
# pip install path — MCP runs on separate port (same machine)
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
parsed = urlparse(base)
|
||||||
|
host = parsed.hostname or "localhost"
|
||||||
|
mcp_url = f"http://{host}:{settings.mcp_port}/sse"
|
||||||
|
else:
|
||||||
|
# Docker path — MCP on separate container port 3001
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
parsed = urlparse(base)
|
||||||
|
docker_host = parsed.hostname or "localhost"
|
||||||
|
mcp_url = f"http://{docker_host}:3001/sse"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mcp_url": mcp_url,
|
||||||
|
"api_token": settings.api_token if getattr(user, "is_admin", False) else None,
|
||||||
|
"api_token_set": bool(settings.api_token),
|
||||||
|
"deployment": "local" if settings.is_sqlite else "docker",
|
||||||
|
"base_url": base,
|
||||||
|
"tools_count": 14,
|
||||||
|
}
|
||||||
|
|
@ -7,10 +7,10 @@ from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from diligence.database import get_db
|
||||||
from app.utils.auth import get_current_user
|
from diligence.utils.auth import get_current_user
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
from app.services import ai_provider
|
from diligence.services import ai_provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/ai", tags=["AI Coaching"])
|
router = APIRouter(prefix="/api/ai", tags=["AI Coaching"])
|
||||||
|
|
@ -7,12 +7,12 @@ from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from diligence.database import get_db
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
from app.models.profile import UserProfile
|
from diligence.models.profile import UserProfile
|
||||||
from app.models.points import PointRule, DailyTarget, DEFAULT_POINT_RULES, TTM_DAILY_TARGETS
|
from diligence.models.points import PointRule, DailyTarget, DEFAULT_POINT_RULES, TTM_DAILY_TARGETS
|
||||||
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse
|
from diligence.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse
|
||||||
from app.utils.auth import hash_password, verify_password, create_access_token, get_current_user
|
from diligence.utils.auth import hash_password, verify_password, create_access_token, get_current_user
|
||||||
|
|
||||||
logger = logging.getLogger("fitness-rewards")
|
logger = logging.getLogger("fitness-rewards")
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
@ -34,6 +34,7 @@ async def register(req: RegisterRequest, db: Annotated[AsyncSession, Depends(get
|
||||||
display_name=req.display_name,
|
display_name=req.display_name,
|
||||||
password_hash=hash_password(req.password),
|
password_hash=hash_password(req.password),
|
||||||
email=req.email,
|
email=req.email,
|
||||||
|
timezone=req.timezone or "UTC",
|
||||||
is_admin=is_first_user,
|
is_admin=is_first_user,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
|
|
@ -11,13 +11,14 @@ from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.database import get_db
|
from diligence.database import get_db
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
from app.models.program import Program
|
from diligence.models.program import Program
|
||||||
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
|
from diligence.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
|
||||||
from app.services.program_research import slugify, find_urls_for_program
|
from diligence.services.program_research import slugify, find_urls_for_program
|
||||||
from app.services.points_engine import log_activity_with_points
|
from diligence.services.points_engine import log_activity_with_points
|
||||||
from app.utils.auth import get_current_user
|
from diligence.utils.auth import get_current_user
|
||||||
|
from diligence.utils.dates import today_for_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/programs", tags=["programs-v2"])
|
router = APIRouter(prefix="/api/programs", tags=["programs-v2"])
|
||||||
|
|
||||||
|
|
@ -337,7 +338,7 @@ async def get_program_schedule(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate current real week from start date
|
# Calculate current real week from start date
|
||||||
today = date.today()
|
today = today_for_user(user.timezone)
|
||||||
days_elapsed = max(0, (today - program.start_date).days)
|
days_elapsed = max(0, (today - program.start_date).days)
|
||||||
current_week = min(total_weeks, (days_elapsed // 7) + 1)
|
current_week = min(total_weeks, (days_elapsed // 7) + 1)
|
||||||
|
|
||||||
|
|
@ -491,7 +492,7 @@ async def complete_workout(
|
||||||
db=db,
|
db=db,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
category="workout",
|
category="workout",
|
||||||
activity_date=date.today(),
|
activity_date=today_for_user(user.timezone),
|
||||||
title=f"{program.name}: Wk{real_week} {workout.workout_name or f'Day {workout.day_number}'}",
|
title=f"{program.name}: Wk{real_week} {workout.workout_name or f'Day {workout.day_number}'}",
|
||||||
description=f"Completed program workout ({len(workout.exercises)} exercises)",
|
description=f"Completed program workout ({len(workout.exercises)} exercises)",
|
||||||
source="program",
|
source="program",
|
||||||
|
|
@ -556,7 +557,7 @@ async def check_weekly_bonus(
|
||||||
db=db,
|
db=db,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
category="bonus",
|
category="bonus",
|
||||||
activity_date=date.today(),
|
activity_date=today_for_user(user.timezone),
|
||||||
title=f"{program.name}: Week {real_week} Complete!",
|
title=f"{program.name}: Week {real_week} Complete!",
|
||||||
source="program",
|
source="program",
|
||||||
program_id=program.id,
|
program_id=program.id,
|
||||||
|
|
@ -599,7 +600,7 @@ async def check_completion_bonus(
|
||||||
db=db,
|
db=db,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
category="bonus",
|
category="bonus",
|
||||||
activity_date=date.today(),
|
activity_date=today_for_user(user.timezone),
|
||||||
title=f"{program.name}: Program Complete!",
|
title=f"{program.name}: Program Complete!",
|
||||||
source="program",
|
source="program",
|
||||||
program_id=program.id,
|
program_id=program.id,
|
||||||
|
|
@ -654,7 +655,7 @@ async def get_program_progress(
|
||||||
)
|
)
|
||||||
total_points = result.scalar() or 0
|
total_points = result.scalar() or 0
|
||||||
|
|
||||||
today = date.today()
|
today = today_for_user(user.timezone)
|
||||||
days_elapsed = max(0, (today - program.start_date).days)
|
days_elapsed = max(0, (today - program.start_date).days)
|
||||||
current_week = min(total_weeks, (days_elapsed // 7) + 1)
|
current_week = min(total_weeks, (days_elapsed // 7) + 1)
|
||||||
|
|
||||||
|
|
@ -7,12 +7,13 @@ from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from diligence.database import get_db
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
from app.models.food import FoodLog
|
from diligence.models.food import FoodLog
|
||||||
from app.schemas.food import FoodCreate, FoodSearchResult
|
from diligence.schemas.food import FoodCreate, FoodSearchResult
|
||||||
from app.utils.auth import get_current_user
|
from diligence.utils.auth import get_current_user
|
||||||
from app.services.food_lookup import lookup_barcode, search_food
|
from diligence.utils.dates import today_for_user
|
||||||
|
from diligence.services.food_lookup import lookup_barcode, search_food
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/food", tags=["food"])
|
router = APIRouter(prefix="/api/food", tags=["food"])
|
||||||
|
|
||||||
|
|
@ -49,7 +50,7 @@ async def list_food(
|
||||||
if item.calories:
|
if item.calories:
|
||||||
total_cals += float(item.calories) * float(item.servings or 1)
|
total_cals += float(item.calories) * float(item.servings or 1)
|
||||||
|
|
||||||
return {"date": (food_date or date.today()).isoformat(), "meals": meals, "total_calories": round(total_cals, 1)}
|
return {"date": (food_date or today_for_user(user.timezone)).isoformat(), "meals": meals, "total_calories": round(total_cals, 1)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
|
|
@ -8,14 +8,14 @@ from fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from diligence.database import get_db
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
from app.models.oauth import OAuthToken
|
from diligence.models.oauth import OAuthToken
|
||||||
from app.utils.auth import get_current_user
|
from diligence.utils.auth import get_current_user
|
||||||
from app.services.strava_sync import (
|
from diligence.services.strava_sync import (
|
||||||
get_strava_auth_url, exchange_strava_code, sync_strava_activities,
|
get_strava_auth_url, exchange_strava_code, sync_strava_activities,
|
||||||
)
|
)
|
||||||
from app.services.polar_sync import (
|
from diligence.services.polar_sync import (
|
||||||
get_polar_auth_url, exchange_polar_code, register_polar_user, sync_polar_activities,
|
get_polar_auth_url, exchange_polar_code, register_polar_user, sync_polar_activities,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ async def integration_status(
|
||||||
# --- Strava ---
|
# --- Strava ---
|
||||||
@router.get("/strava/auth")
|
@router.get("/strava/auth")
|
||||||
async def strava_auth(user: Annotated[User, Depends(get_current_user)]):
|
async def strava_auth(user: Annotated[User, Depends(get_current_user)]):
|
||||||
from app.utils.auth import create_access_token
|
from diligence.utils.auth import create_access_token
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
|
state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
|
||||||
return {"auth_url": get_strava_auth_url(state_token)}
|
return {"auth_url": get_strava_auth_url(state_token)}
|
||||||
|
|
@ -52,7 +52,7 @@ async def strava_callback(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
from jose import JWTError, jwt as jose_jwt
|
from jose import JWTError, jwt as jose_jwt
|
||||||
from app.config import get_settings
|
from diligence.config import get_settings
|
||||||
_settings = get_settings()
|
_settings = get_settings()
|
||||||
try:
|
try:
|
||||||
payload = jose_jwt.decode(state, _settings.secret_key, algorithms=["HS256"])
|
payload = jose_jwt.decode(state, _settings.secret_key, algorithms=["HS256"])
|
||||||
|
|
@ -85,7 +85,7 @@ async def strava_callback(
|
||||||
db.add(token)
|
db.add(token)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
from app.config import get_settings
|
from diligence.config import get_settings
|
||||||
return RedirectResponse(url=f"{get_settings().base_url}/settings/integrations?strava=connected")
|
return RedirectResponse(url=f"{get_settings().base_url}/settings/integrations?strava=connected")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -101,7 +101,7 @@ async def strava_sync(
|
||||||
# --- Polar ---
|
# --- Polar ---
|
||||||
@router.get("/polar/auth")
|
@router.get("/polar/auth")
|
||||||
async def polar_auth(user: Annotated[User, Depends(get_current_user)]):
|
async def polar_auth(user: Annotated[User, Depends(get_current_user)]):
|
||||||
from app.utils.auth import create_access_token
|
from diligence.utils.auth import create_access_token
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
|
state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
|
||||||
return {"auth_url": get_polar_auth_url(state_token)}
|
return {"auth_url": get_polar_auth_url(state_token)}
|
||||||
|
|
@ -114,7 +114,7 @@ async def polar_callback(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
from jose import JWTError, jwt as jose_jwt
|
from jose import JWTError, jwt as jose_jwt
|
||||||
from app.config import get_settings
|
from diligence.config import get_settings
|
||||||
_settings = get_settings()
|
_settings = get_settings()
|
||||||
try:
|
try:
|
||||||
payload = jose_jwt.decode(state, _settings.secret_key, algorithms=["HS256"])
|
payload = jose_jwt.decode(state, _settings.secret_key, algorithms=["HS256"])
|
||||||
|
|
@ -144,7 +144,7 @@ async def polar_callback(
|
||||||
db.add(token)
|
db.add(token)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
from app.config import get_settings
|
from diligence.config import get_settings
|
||||||
return RedirectResponse(url=f"{get_settings().base_url}/settings/integrations?polar=connected")
|
return RedirectResponse(url=f"{get_settings().base_url}/settings/integrations?polar=connected")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -178,10 +178,10 @@ async def disconnect(
|
||||||
# --- Dynamic Integration Config (v3) ---
|
# --- Dynamic Integration Config (v3) ---
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from app.models.integration_config import IntegrationConfig
|
from diligence.models.integration_config import IntegrationConfig
|
||||||
from app.services.provider_registry import PROVIDER_REGISTRY
|
from diligence.services.provider_registry import PROVIDER_REGISTRY
|
||||||
from app.services.crypto import encrypt_value, decrypt_value
|
from diligence.services.crypto import encrypt_value, decrypt_value
|
||||||
from app.config import settings
|
from diligence.config import settings
|
||||||
|
|
||||||
|
|
||||||
class ConfigureRequest(BaseModel):
|
class ConfigureRequest(BaseModel):
|
||||||
|
|
@ -10,10 +10,11 @@ from pydantic import BaseModel
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from diligence.database import get_db
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
|
from diligence.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
|
||||||
from app.utils.auth import get_current_user
|
from diligence.utils.auth import get_current_user
|
||||||
|
from diligence.utils.dates import today_for_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])
|
router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])
|
||||||
|
|
||||||
|
|
@ -94,7 +95,7 @@ async def create_meal_plan(
|
||||||
daily_fat_g=body.daily_fat_g,
|
daily_fat_g=body.daily_fat_g,
|
||||||
restrictions=body.restrictions,
|
restrictions=body.restrictions,
|
||||||
duration_days=body.duration_days,
|
duration_days=body.duration_days,
|
||||||
start_date=body.start_date or date.today(),
|
start_date=body.start_date or today_for_user(user.timezone),
|
||||||
)
|
)
|
||||||
db.add(plan)
|
db.add(plan)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
@ -131,7 +132,7 @@ async def get_today_meals(
|
||||||
if not plan:
|
if not plan:
|
||||||
return {"active_plan": None}
|
return {"active_plan": None}
|
||||||
|
|
||||||
day_num = (date.today() - plan.start_date).days + 1
|
day_num = (today_for_user(user.timezone) - plan.start_date).days + 1
|
||||||
if day_num < 1 or day_num > plan.duration_days:
|
if day_num < 1 or day_num > plan.duration_days:
|
||||||
return {"active_plan": plan.name, "day": day_num, "meals": [], "message": "No meals planned for today"}
|
return {"active_plan": plan.name, "day": day_num, "meals": [], "message": "No meals planned for today"}
|
||||||
|
|
||||||
|
|
@ -217,7 +218,7 @@ async def log_compliance(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
plan_id=plan.id,
|
plan_id=plan.id,
|
||||||
plan_item_id=uuid_mod.UUID(body.plan_item_id) if body.plan_item_id else None,
|
plan_item_id=uuid_mod.UUID(body.plan_item_id) if body.plan_item_id else None,
|
||||||
compliance_date=body.compliance_date or date.today(),
|
compliance_date=body.compliance_date or today_for_user(user.timezone),
|
||||||
status=body.status,
|
status=body.status,
|
||||||
substitution=body.substitution,
|
substitution=body.substitution,
|
||||||
)
|
)
|
||||||
|
|
@ -250,7 +251,7 @@ async def get_plan_progress(
|
||||||
substituted = sum(1 for e in entries if e.status == "substituted")
|
substituted = sum(1 for e in entries if e.status == "substituted")
|
||||||
skipped = sum(1 for e in entries if e.status == "skipped")
|
skipped = sum(1 for e in entries if e.status == "skipped")
|
||||||
|
|
||||||
days_elapsed = (date.today() - plan.start_date).days + 1
|
days_elapsed = (today_for_user(user.timezone) - plan.start_date).days + 1
|
||||||
compliance_pct = (followed + substituted) / total * 100 if total > 0 else 0
|
compliance_pct = (followed + substituted) / total * 100 if total > 0 else 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -10,15 +10,16 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select, func, and_
|
from sqlalchemy import select, func, and_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from diligence.database import get_db
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
|
from diligence.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
|
||||||
from app.models.food import FoodLog
|
from diligence.models.food import FoodLog
|
||||||
from app.schemas.nutrition import (
|
from diligence.schemas.nutrition import (
|
||||||
NutritionGoalIn, NutritionGoalOut, FastStart, FastUpdate, FastOut, ElectrolyteIn,
|
NutritionGoalIn, NutritionGoalOut, FastStart, FastUpdate, FastOut, ElectrolyteIn,
|
||||||
)
|
)
|
||||||
from app.utils.auth import get_current_user
|
from diligence.utils.auth import get_current_user
|
||||||
from app.services.points_engine import log_activity_with_points
|
from diligence.services.points_engine import log_activity_with_points
|
||||||
|
from diligence.utils.dates import today_for_user, now_for_user, day_start_utc
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
||||||
|
|
||||||
|
|
@ -87,7 +88,7 @@ async def get_today(
|
||||||
):
|
):
|
||||||
"""Today's macros vs target, eating-window state, active fast, compliance."""
|
"""Today's macros vs target, eating-window state, active fast, compliance."""
|
||||||
goal = await _get_or_create_goal(db, user.id)
|
goal = await _get_or_create_goal(db, user.id)
|
||||||
today = date.today()
|
today = today_for_user(user.timezone)
|
||||||
|
|
||||||
# Sum today's food
|
# Sum today's food
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|
@ -103,8 +104,8 @@ async def get_today(
|
||||||
cals, prot, carbs, fat, fiber = float(cals), float(prot), float(carbs), float(fat), float(fiber)
|
cals, prot, carbs, fat, fiber = float(cals), float(prot), float(carbs), float(fat), float(fiber)
|
||||||
net_carbs = max(0.0, carbs - fiber)
|
net_carbs = max(0.0, carbs - fiber)
|
||||||
|
|
||||||
# Eating window (local time, using goal timezone_str — simplified: assume server in UTC, user sends local)
|
# Eating window (user's local time)
|
||||||
now_local = datetime.now(timezone.utc) + timedelta(hours=7) # Asia/Bangkok offset hack
|
now_local = now_for_user(user.timezone)
|
||||||
hour = now_local.hour
|
hour = now_local.hour
|
||||||
in_window = goal.eating_window_start_hour <= hour < goal.eating_window_end_hour
|
in_window = goal.eating_window_start_hour <= hour < goal.eating_window_end_hour
|
||||||
window_str = f"{goal.eating_window_start_hour:02d}:00–{goal.eating_window_end_hour:02d}:00"
|
window_str = f"{goal.eating_window_start_hour:02d}:00–{goal.eating_window_end_hour:02d}:00"
|
||||||
|
|
@ -352,7 +353,7 @@ async def get_electrolytes_today(
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
):
|
):
|
||||||
today_start = datetime.combine(date.today(), datetime.min.time()).replace(tzinfo=timezone.utc)
|
today_start = day_start_utc(user.timezone)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(
|
select(
|
||||||
func.coalesce(func.sum(ElectrolyteLog.sodium_mg), 0),
|
func.coalesce(func.sum(ElectrolyteLog.sodium_mg), 0),
|
||||||
|
|
@ -6,13 +6,13 @@ from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from diligence.database import get_db
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
from app.models.profile import UserProfile
|
from diligence.models.profile import UserProfile
|
||||||
from app.models.points import DailyTarget, TTM_DAILY_TARGETS
|
from diligence.models.points import DailyTarget, TTM_DAILY_TARGETS
|
||||||
from app.schemas.onboarding import Phase1Request, Phase2Request, OnboardingStatusResponse
|
from diligence.schemas.onboarding import Phase1Request, Phase2Request, OnboardingStatusResponse
|
||||||
from app.utils.auth import get_current_user
|
from diligence.utils.auth import get_current_user
|
||||||
from app.services.resource_matcher import get_recommendations
|
from diligence.services.resource_matcher import get_recommendations
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/onboarding", tags=["onboarding"])
|
router = APIRouter(prefix="/api/onboarding", tags=["onboarding"])
|
||||||
|
|
||||||
|
|
@ -7,14 +7,15 @@ from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from diligence.database import get_db
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
from app.models.points import PointRule, DailyTarget
|
from diligence.models.points import PointRule, DailyTarget
|
||||||
from app.models.reward import Reward
|
from diligence.models.reward import Reward
|
||||||
from app.schemas.points import PointRuleUpdate, DailyTargetUpdate
|
from diligence.schemas.points import PointRuleUpdate, DailyTargetUpdate
|
||||||
from app.schemas.reward import RewardCreate, RewardRedeemRequest
|
from diligence.schemas.reward import RewardCreate, RewardRedeemRequest
|
||||||
from app.utils.auth import get_current_user
|
from diligence.utils.auth import get_current_user
|
||||||
from app.services.points_engine import get_today_status, get_weekly_summary, redeem_reward
|
from diligence.utils.dates import today_for_user
|
||||||
|
from diligence.services.points_engine import get_today_status, get_weekly_summary, redeem_reward
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/points", tags=["points"])
|
router = APIRouter(prefix="/api/points", tags=["points"])
|
||||||
|
|
||||||
|
|
@ -24,7 +25,7 @@ async def today_status(
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
):
|
):
|
||||||
return await get_today_status(db, user.id)
|
return await get_today_status(db, user.id, tz_str=user.timezone)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/week")
|
@router.get("/week")
|
||||||
|
|
@ -33,7 +34,7 @@ async def week_summary(
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
start: date | None = None,
|
start: date | None = None,
|
||||||
):
|
):
|
||||||
d = start or date.today()
|
d = start or today_for_user(user.timezone)
|
||||||
return await get_weekly_summary(db, user.id, d)
|
return await get_weekly_summary(db, user.id, d)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -143,7 +144,7 @@ async def redeem(
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
):
|
):
|
||||||
result = await redeem_reward(db, user.id, uuid_mod.UUID(reward_id), req.redemption_date)
|
result = await redeem_reward(db, user.id, uuid_mod.UUID(reward_id), req.redemption_date, tz_str=user.timezone)
|
||||||
if not result["success"]:
|
if not result["success"]:
|
||||||
raise HTTPException(status_code=400, detail=result["reason"])
|
raise HTTPException(status_code=400, detail=result["reason"])
|
||||||
return result
|
return result
|
||||||
|
|
@ -7,11 +7,12 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from diligence.database import get_db
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
from app.models.program import Program
|
from diligence.models.program import Program
|
||||||
from app.models.activity import ActivityLog
|
from diligence.models.activity import ActivityLog
|
||||||
from app.utils.auth import get_current_user
|
from diligence.utils.auth import get_current_user
|
||||||
|
from diligence.utils.dates import today_for_user
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/programs", tags=["programs"])
|
router = APIRouter(prefix="/api/programs", tags=["programs"])
|
||||||
|
|
@ -37,7 +38,7 @@ async def list_programs(
|
||||||
programs = result.scalars().all()
|
programs = result.scalars().all()
|
||||||
out = []
|
out = []
|
||||||
for p in programs:
|
for p in programs:
|
||||||
today = date.today()
|
today = today_for_user(user.timezone)
|
||||||
day_num = (today - p.start_date).days + 1
|
day_num = (today - p.start_date).days + 1
|
||||||
total = (p.end_date - p.start_date).days + 1
|
total = (p.end_date - p.start_date).days + 1
|
||||||
out.append({
|
out.append({
|
||||||
|
|
@ -90,7 +91,7 @@ async def get_program(
|
||||||
)
|
)
|
||||||
logged = days_logged.scalar() or 0
|
logged = days_logged.scalar() or 0
|
||||||
|
|
||||||
today = date.today()
|
today = today_for_user(user.timezone)
|
||||||
day_num = (today - p.start_date).days + 1
|
day_num = (today - p.start_date).days + 1
|
||||||
total = (p.end_date - p.start_date).days + 1
|
total = (p.end_date - p.start_date).days + 1
|
||||||
|
|
||||||
|
|
@ -13,13 +13,14 @@ from sqlalchemy import select, func, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.database import get_db
|
from diligence.database import get_db
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
from app.models.support import SupportThread, SupportMessage
|
from diligence.models.support import SupportThread, SupportMessage
|
||||||
from app.models.activity import ActivityLog
|
from diligence.models.activity import ActivityLog
|
||||||
from app.services.points_engine import get_active_program, get_today_status
|
from diligence.services.points_engine import get_active_program, get_today_status
|
||||||
from app.utils.auth import get_current_user
|
from diligence.utils.auth import get_current_user
|
||||||
from app.config import settings
|
from diligence.utils.dates import day_start_utc
|
||||||
|
from diligence.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/support", tags=["support"])
|
router = APIRouter(prefix="/api/support", tags=["support"])
|
||||||
|
|
@ -45,7 +46,7 @@ async def gather_user_context(db: AsyncSession, user: User) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
program = await get_active_program(db, user.id)
|
program = await get_active_program(db, user.id, tz_str=user.timezone)
|
||||||
if program:
|
if program:
|
||||||
context["program_name"] = program.get("name")
|
context["program_name"] = program.get("name")
|
||||||
context["program_day"] = program.get("day")
|
context["program_day"] = program.get("day")
|
||||||
|
|
@ -57,7 +58,7 @@ async def gather_user_context(db: AsyncSession, user: User) -> dict:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status = await get_today_status(db, user.id)
|
status = await get_today_status(db, user.id, tz_str=user.timezone)
|
||||||
context["points_today"] = status.get("points_earned", 0)
|
context["points_today"] = status.get("points_earned", 0)
|
||||||
context["daily_target"] = status.get("daily_minimum", 80)
|
context["daily_target"] = status.get("daily_minimum", 80)
|
||||||
context["gate_passed"] = status.get("gate_passed", False)
|
context["gate_passed"] = status.get("gate_passed", False)
|
||||||
|
|
@ -219,7 +220,7 @@ async def send_message(
|
||||||
raise HTTPException(status_code=400, detail="Message too long (max 2000 characters)")
|
raise HTTPException(status_code=400, detail="Message too long (max 2000 characters)")
|
||||||
|
|
||||||
# Rate limit check
|
# Rate limit check
|
||||||
today_start = datetime.combine(date.today(), datetime.min.time(), tzinfo=timezone.utc)
|
today_start = day_start_utc(user.timezone)
|
||||||
thread = await get_or_create_thread(db, user.id)
|
thread = await get_or_create_thread(db, user.id)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|
@ -301,7 +302,7 @@ async def list_threads(
|
||||||
out = []
|
out = []
|
||||||
for t in threads:
|
for t in threads:
|
||||||
# Get the user's display name
|
# Get the user's display name
|
||||||
from app.models.user import User as UserModel
|
from diligence.models.user import User as UserModel
|
||||||
user_result = await db.execute(
|
user_result = await db.execute(
|
||||||
select(UserModel).where(UserModel.id == t.user_id)
|
select(UserModel).where(UserModel.id == t.user_id)
|
||||||
)
|
)
|
||||||
|
|
@ -360,7 +361,7 @@ async def get_admin_thread(
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# Get user info
|
# Get user info
|
||||||
from app.models.user import User as UserModel
|
from diligence.models.user import User as UserModel
|
||||||
user_result = await db.execute(
|
user_result = await db.execute(
|
||||||
select(UserModel).where(UserModel.id == thread.user_id)
|
select(UserModel).where(UserModel.id == thread.user_id)
|
||||||
)
|
)
|
||||||
|
|
@ -13,6 +13,7 @@ class RegisterRequest(BaseModel):
|
||||||
password: str = Field(min_length=8, max_length=128)
|
password: str = Field(min_length=8, max_length=128)
|
||||||
display_name: str = Field(min_length=1, max_length=100)
|
display_name: str = Field(min_length=1, max_length=100)
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
|
timezone: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
|
|
@ -18,9 +18,9 @@ import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import get_settings
|
from diligence.config import get_settings
|
||||||
from app.services.crypto import decrypt_value
|
from diligence.services.crypto import decrypt_value
|
||||||
from app.models.integration_config import IntegrationConfig
|
from diligence.models.integration_config import IntegrationConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
@ -101,8 +101,8 @@ async def get_active_ai_provider(db: AsyncSession, user_id=None) -> dict | None:
|
||||||
|
|
||||||
async def build_context(db: AsyncSession, user_id) -> str:
|
async def build_context(db: AsyncSession, user_id) -> str:
|
||||||
"""Build live context string from user's current state."""
|
"""Build live context string from user's current state."""
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
from app.models.profile import UserProfile
|
from diligence.models.profile import UserProfile
|
||||||
|
|
||||||
context_parts = []
|
context_parts = []
|
||||||
|
|
||||||
|
|
@ -122,7 +122,7 @@ async def build_context(db: AsyncSession, user_id) -> str:
|
||||||
|
|
||||||
# Today's points (best effort)
|
# Today's points (best effort)
|
||||||
try:
|
try:
|
||||||
from app.services.points_engine import get_daily_summary
|
from diligence.services.points_engine import get_daily_summary
|
||||||
summary = await get_daily_summary(db, user_id)
|
summary = await get_daily_summary(db, user_id)
|
||||||
context_parts.append(f"Today's points: {summary.get('earned', 0)}/{summary.get('target', 100)}")
|
context_parts.append(f"Today's points: {summary.get('earned', 0)}/{summary.get('target', 100)}")
|
||||||
context_parts.append(f"Daily gate: {'PASSED' if summary.get('gate_passed') else 'NOT YET'}")
|
context_parts.append(f"Daily gate: {'PASSED' if summary.get('gate_passed') else 'NOT YET'}")
|
||||||
|
|
@ -8,9 +8,9 @@ from datetime import datetime, timezone
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import async_session
|
from diligence.database import async_session
|
||||||
from app.models.catalog import CrawlQueue
|
from diligence.models.catalog import CrawlQueue
|
||||||
from app.services.program_research import process_crawl_job
|
from diligence.services.program_research import process_crawl_job
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -17,11 +17,11 @@ import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import get_settings
|
from diligence.config import get_settings
|
||||||
from app.models.oauth import OAuthToken
|
from diligence.models.oauth import OAuthToken
|
||||||
from app.models.integration_config import IntegrationConfig
|
from diligence.models.integration_config import IntegrationConfig
|
||||||
from app.services.crypto import decrypt_value
|
from diligence.services.crypto import decrypt_value
|
||||||
from app.services.points_engine import log_activity_with_points
|
from diligence.services.points_engine import log_activity_with_points
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
@ -146,7 +146,7 @@ class DeviceSyncBase(abc.ABC):
|
||||||
|
|
||||||
Deduplicates by (user_id, provider, external_id).
|
Deduplicates by (user_id, provider, external_id).
|
||||||
"""
|
"""
|
||||||
from app.models.activity import ActivityLog
|
from diligence.models.activity import ActivityLog
|
||||||
|
|
||||||
if external_id:
|
if external_id:
|
||||||
existing = await db.execute(
|
existing = await db.execute(
|
||||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
"""Open Food Facts API client for barcode scanning and food search."""
|
"""Open Food Facts API client for barcode scanning and food search."""
|
||||||
import httpx
|
import httpx
|
||||||
from app.schemas.food import FoodSearchResult
|
from diligence.schemas.food import FoodSearchResult
|
||||||
|
|
||||||
OFF_BASE = "https://world.openfoodfacts.org"
|
OFF_BASE = "https://world.openfoodfacts.org"
|
||||||
USER_AGENT = "Diligence/1.0"
|
USER_AGENT = "Diligence/1.0"
|
||||||
|
|
@ -6,11 +6,11 @@ from datetime import date, timedelta
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.activity import ActivityLog
|
from diligence.models.activity import ActivityLog
|
||||||
from app.models.points import PointRule, DailyTarget
|
from diligence.models.points import PointRule, DailyTarget
|
||||||
from app.models.reward import Reward, RewardRedemption
|
from diligence.models.reward import Reward, RewardRedemption
|
||||||
from app.models.program import Program
|
from diligence.models.program import Program
|
||||||
from app.utils.dates import get_week_boundaries
|
from diligence.utils.dates import get_week_boundaries
|
||||||
|
|
||||||
|
|
||||||
async def get_daily_points_earned(db: AsyncSession, user_id: uuid.UUID, d: date) -> int:
|
async def get_daily_points_earned(db: AsyncSession, user_id: uuid.UUID, d: date) -> int:
|
||||||
|
|
@ -81,7 +81,7 @@ async def get_available_rewards(db: AsyncSession, user_id: uuid.UUID) -> list[di
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | None:
|
async def get_active_program(db: AsyncSession, user_id: uuid.UUID, tz_str: str = "UTC") -> dict | None:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Program)
|
select(Program)
|
||||||
.where(Program.user_id == user_id, Program.status == "active")
|
.where(Program.user_id == user_id, Program.status == "active")
|
||||||
|
|
@ -90,7 +90,7 @@ async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | Non
|
||||||
program = result.scalar_one_or_none()
|
program = result.scalar_one_or_none()
|
||||||
if not program:
|
if not program:
|
||||||
return None
|
return None
|
||||||
today = date.today()
|
today = today_for_user(tz_str)
|
||||||
day_num = (today - program.start_date).days + 1
|
day_num = (today - program.start_date).days + 1
|
||||||
total = (program.end_date - program.start_date).days + 1
|
total = (program.end_date - program.start_date).days + 1
|
||||||
return {
|
return {
|
||||||
|
|
@ -104,8 +104,8 @@ async def get_active_program(db: AsyncSession, user_id: uuid.UUID) -> dict | Non
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_today_status(db: AsyncSession, user_id: uuid.UUID) -> dict:
|
async def get_today_status(db: AsyncSession, user_id: uuid.UUID, tz_str: str = "UTC") -> dict:
|
||||||
today = date.today()
|
today = today_for_user(tz_str)
|
||||||
earned = await get_daily_points_earned(db, user_id, today)
|
earned = await get_daily_points_earned(db, user_id, today)
|
||||||
target = await get_daily_target(db, user_id)
|
target = await get_daily_target(db, user_id)
|
||||||
daily_min = target.daily_minimum_pts if target else 80
|
daily_min = target.daily_minimum_pts if target else 80
|
||||||
|
|
@ -189,9 +189,9 @@ async def log_activity_with_points(
|
||||||
|
|
||||||
async def redeem_reward(
|
async def redeem_reward(
|
||||||
db: AsyncSession, user_id: uuid.UUID, reward_id: uuid.UUID, redemption_date: date | None = None
|
db: AsyncSession, user_id: uuid.UUID, reward_id: uuid.UUID, redemption_date: date | None = None
|
||||||
) -> dict:
|
, tz_str: str = "UTC") -> dict:
|
||||||
"""Attempt to redeem a reward. Returns success/failure with details."""
|
"""Attempt to redeem a reward. Returns success/failure with details."""
|
||||||
d = redemption_date or date.today()
|
d = redemption_date or today_for_user(tz_str)
|
||||||
earned = await get_daily_points_earned(db, user_id, d)
|
earned = await get_daily_points_earned(db, user_id, d)
|
||||||
target = await get_daily_target(db, user_id)
|
target = await get_daily_target(db, user_id)
|
||||||
daily_min = target.daily_minimum_pts if target else 80
|
daily_min = target.daily_minimum_pts if target else 80
|
||||||
|
|
@ -3,14 +3,15 @@ from __future__ import annotations
|
||||||
"""Polar AccessLink API client for activity sync."""
|
"""Polar AccessLink API client for activity sync."""
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from diligence.utils.dates import today_for_user
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import get_settings
|
from diligence.config import get_settings
|
||||||
from app.models.oauth import OAuthToken
|
from diligence.models.oauth import OAuthToken
|
||||||
from app.models.activity import ActivityLog
|
from diligence.models.activity import ActivityLog
|
||||||
from app.services.points_engine import log_activity_with_points
|
from diligence.services.points_engine import log_activity_with_points
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
@ -108,7 +109,7 @@ async def sync_polar_activities(db: AsyncSession, user_id: uuid.UUID) -> list[di
|
||||||
activity_date = datetime.fromisoformat(start).date()
|
activity_date = datetime.fromisoformat(start).date()
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
from datetime import date
|
from datetime import date
|
||||||
activity_date = date.today()
|
activity_date = today_for_user()
|
||||||
|
|
||||||
# Parse duration ISO 8601 (e.g., PT1H30M)
|
# Parse duration ISO 8601 (e.g., PT1H30M)
|
||||||
duration_str = ex.get("duration", "")
|
duration_str = ex.get("duration", "")
|
||||||
|
|
@ -12,8 +12,8 @@ import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue
|
from diligence.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue
|
||||||
from app.config import settings
|
from diligence.config import settings
|
||||||
|
|
||||||
|
|
||||||
# ── Known program sources (skip search for these) ──────────────────────────
|
# ── Known program sources (skip search for these) ──────────────────────────
|
||||||
|
|
@ -4,8 +4,8 @@ from __future__ import annotations
|
||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.models.resource import Resource
|
from diligence.models.resource import Resource
|
||||||
from app.models.profile import UserProfile
|
from diligence.models.profile import UserProfile
|
||||||
|
|
||||||
|
|
||||||
# Equipment categories that satisfy each resource requirement
|
# Equipment categories that satisfy each resource requirement
|
||||||
|
|
@ -7,10 +7,10 @@ import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import get_settings
|
from diligence.config import get_settings
|
||||||
from app.models.oauth import OAuthToken
|
from diligence.models.oauth import OAuthToken
|
||||||
from app.models.activity import ActivityLog
|
from diligence.models.activity import ActivityLog
|
||||||
from app.services.points_engine import log_activity_with_points
|
from diligence.services.points_engine import log_activity_with_points
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
@ -9,8 +9,8 @@ from fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.config import get_settings
|
from diligence.config import get_settings
|
||||||
from app.database import get_db
|
from diligence.database import get_db
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
ALGORITHM = "HS256" # Hardcoded — not configurable for security
|
ALGORITHM = "HS256" # Hardcoded — not configurable for security
|
||||||
|
|
@ -44,7 +44,7 @@ async def get_current_user(
|
||||||
if not token:
|
if not token:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
|
||||||
from app.models.user import User
|
from diligence.models.user import User
|
||||||
|
|
||||||
# Check if this is an API token (MCP connector auth)
|
# Check if this is an API token (MCP connector auth)
|
||||||
if settings.api_token and token == settings.api_token:
|
if settings.api_token and token == settings.api_token:
|
||||||
42
diligence/utils/dates.py
Normal file
42
diligence/utils/dates.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"""Timezone-aware date helpers.
|
||||||
|
|
||||||
|
All date calculations should use these helpers instead of date.today()
|
||||||
|
so the result reflects the user's local date, not the server's.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
||||||
|
def today_for_user(tz_str: str = "UTC") -> date:
|
||||||
|
"""Return today's date in the user's timezone."""
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_str)
|
||||||
|
except (KeyError, Exception):
|
||||||
|
tz = ZoneInfo("UTC")
|
||||||
|
return datetime.now(tz).date()
|
||||||
|
|
||||||
|
|
||||||
|
def now_for_user(tz_str: str = "UTC") -> datetime:
|
||||||
|
"""Return the current datetime in the user's timezone (tz-aware)."""
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_str)
|
||||||
|
except (KeyError, Exception):
|
||||||
|
tz = ZoneInfo("UTC")
|
||||||
|
return datetime.now(tz)
|
||||||
|
|
||||||
|
|
||||||
|
def day_start_utc(tz_str: str = "UTC") -> datetime:
|
||||||
|
"""Return the start of today (midnight) in the user's timezone, converted to UTC.
|
||||||
|
|
||||||
|
Useful for database queries that store timestamps in UTC but need
|
||||||
|
to filter by the user's local day.
|
||||||
|
"""
|
||||||
|
user_today = today_for_user(tz_str)
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_str)
|
||||||
|
except (KeyError, Exception):
|
||||||
|
tz = ZoneInfo("UTC")
|
||||||
|
local_midnight = datetime(user_today.year, user_today.month, user_today.day, tzinfo=tz)
|
||||||
|
return local_midnight.astimezone(ZoneInfo("UTC"))
|
||||||
|
|
@ -15,10 +15,13 @@ services:
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards
|
||||||
- BASE_URL=${BASE_URL:-http://localhost}
|
- BASE_URL=${BASE_URL:-http://localhost}
|
||||||
- API_TOKEN=${API_TOKEN:-}
|
- API_TOKEN=${API_TOKEN:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<meta name="theme-color" content="#1a1a2e" />
|
<meta name="theme-color" content="#1a1a2e" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<title>Fitness Rewards</title>
|
<title>Diligence</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
1865
frontend/package-lock.json
generated
Normal file
1865
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -19,6 +19,7 @@ import CatalogDetail from './pages/CatalogDetail'
|
||||||
import Support from './pages/Support'
|
import Support from './pages/Support'
|
||||||
import SupportAdmin from './pages/SupportAdmin'
|
import SupportAdmin from './pages/SupportAdmin'
|
||||||
import ChatCoach from './pages/ChatCoach'
|
import ChatCoach from './pages/ChatCoach'
|
||||||
|
import AgentConnect from './pages/AgentConnect'
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
if (!hasToken()) return <Navigate to="/login" />
|
if (!hasToken()) return <Navigate to="/login" />
|
||||||
|
|
@ -95,6 +96,9 @@ function NavBar() {
|
||||||
<NavLink to="/chat" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
<NavLink to="/chat" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
<span className="nav-icon">🧠</span> Coach
|
<span className="nav-icon">🧠</span> Coach
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/agent" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
|
<span className="nav-icon">🔌</span> Agent
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +127,7 @@ export default function App() {
|
||||||
<Route path="/settings/integrations" element={<ProtectedRoute><SettingsIntegrations /></ProtectedRoute>} />
|
<Route path="/settings/integrations" element={<ProtectedRoute><SettingsIntegrations /></ProtectedRoute>} />
|
||||||
<Route path="/meal-plan" element={<ProtectedRoute><MealPlan /></ProtectedRoute>} />
|
<Route path="/meal-plan" element={<ProtectedRoute><MealPlan /></ProtectedRoute>} />
|
||||||
<Route path="/chat" element={<ProtectedRoute><ChatCoach /></ProtectedRoute>} />
|
<Route path="/chat" element={<ProtectedRoute><ChatCoach /></ProtectedRoute>} />
|
||||||
|
<Route path="/agent" element={<ProtectedRoute><AgentConnect /></ProtectedRoute>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
{hasToken() && <NavBar />}
|
{hasToken() && <NavBar />}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export const api = {
|
||||||
login: (username, password) =>
|
login: (username, password) =>
|
||||||
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||||
register: (username, password, display_name) =>
|
register: (username, password, display_name) =>
|
||||||
request('/auth/register', { method: 'POST', body: JSON.stringify({ username, password, display_name }) }),
|
request('/auth/register', { method: 'POST', body: JSON.stringify({ username, password, display_name, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }) }),
|
||||||
me: () => request('/auth/me'),
|
me: () => request('/auth/me'),
|
||||||
|
|
||||||
// Onboarding
|
// Onboarding
|
||||||
|
|
@ -145,6 +145,9 @@ export const api = {
|
||||||
|
|
||||||
// Resources
|
// Resources
|
||||||
getResourceRecommendations: () => request('/onboarding/recommendations'),
|
getResourceRecommendations: () => request('/onboarding/recommendations'),
|
||||||
|
|
||||||
|
// Agent
|
||||||
|
agentConfig: () => request('/agent/config'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function setToken(token) {
|
export function setToken(token) {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,12 @@
|
||||||
--r-lg: 12px;
|
--r-lg: 12px;
|
||||||
--r-full: 999px;
|
--r-full: 999px;
|
||||||
|
|
||||||
|
/* Backwards-compat aliases */
|
||||||
|
--surface: var(--card);
|
||||||
|
--surface-2: var(--card);
|
||||||
|
--border: var(--card-border);
|
||||||
|
--r-md: var(--r);
|
||||||
|
|
||||||
--font: 'Instrument Sans', -apple-system, sans-serif;
|
--font: 'Instrument Sans', -apple-system, sans-serif;
|
||||||
--font-display: 'Instrument Sans', -apple-system, sans-serif;
|
--font-display: 'Instrument Sans', -apple-system, sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', monospace;
|
--font-mono: 'IBM Plex Mono', monospace;
|
||||||
|
|
@ -233,7 +239,7 @@ input::placeholder, textarea::placeholder { color: var(--text-3); font-weight: 4
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
padding: 6px 14px;
|
padding: 6px 10px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
|
|
|
||||||
208
frontend/src/pages/AgentConnect.jsx
Normal file
208
frontend/src/pages/AgentConnect.jsx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
function CopyButton({ text, label }) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch { /* fallback for non-HTTPS */ }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="btn-outline btn-sm"
|
||||||
|
style={{ fontSize: '0.75rem', padding: '6px 12px', whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied' : (label || 'Copy')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeBlock({ children, copyText }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg)', border: '1px solid var(--divider)',
|
||||||
|
borderRadius: 'var(--r-sm)', padding: '14px 16px',
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: '0.78rem', lineHeight: 1.6,
|
||||||
|
overflowX: 'auto', position: 'relative', whiteSpace: 'pre',
|
||||||
|
}}>
|
||||||
|
{copyText && (
|
||||||
|
<div style={{ position: 'absolute', top: '8px', right: '8px' }}>
|
||||||
|
<CopyButton text={copyText} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentConnect() {
|
||||||
|
const [config, setConfig] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showToken, setShowToken] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.agentConfig()
|
||||||
|
.then(setConfig)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
|
||||||
|
if (!config) return <div className="page"><div className="error-msg">Failed to load agent config</div></div>
|
||||||
|
|
||||||
|
const maskedToken = config.api_token
|
||||||
|
? config.api_token.slice(0, 8) + '\u2026' + config.api_token.slice(-4)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const claudeDesktopConfig = JSON.stringify({
|
||||||
|
"mcpServers": {
|
||||||
|
"diligence": {
|
||||||
|
"url": config.mcp_url,
|
||||||
|
...(config.api_token ? { "headers": { "Authorization": `Bearer ${config.api_token}` } } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, null, 2)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<h1 style={{
|
||||||
|
fontFamily: 'var(--font-display)', fontWeight: 800,
|
||||||
|
fontSize: '1.3rem', marginBottom: '4px',
|
||||||
|
}}>
|
||||||
|
Connect Your AI Agent
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: 'var(--text-3)', fontSize: '0.85rem' }}>
|
||||||
|
Use any MCP-compatible AI to log workouts, track food, and manage your fitness.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Details */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-label">Connection</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '14px' }}>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-3)', fontWeight: 600, marginBottom: '4px' }}>
|
||||||
|
MCP Endpoint
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
background: 'var(--bg)', borderRadius: 'var(--r-sm)',
|
||||||
|
padding: '10px 12px', border: '1px solid var(--divider)',
|
||||||
|
}}>
|
||||||
|
<code style={{
|
||||||
|
flex: 1, fontFamily: 'var(--font-mono)', fontSize: '0.82rem',
|
||||||
|
color: 'var(--accent)', wordBreak: 'break-all',
|
||||||
|
}}>
|
||||||
|
{config.mcp_url}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={config.mcp_url} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.api_token && (
|
||||||
|
<div style={{ marginBottom: '14px' }}>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-3)', fontWeight: 600, marginBottom: '4px' }}>
|
||||||
|
API Token
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
background: 'var(--bg)', borderRadius: 'var(--r-sm)',
|
||||||
|
padding: '10px 12px', border: '1px solid var(--divider)',
|
||||||
|
}}>
|
||||||
|
<code
|
||||||
|
onClick={() => setShowToken(!showToken)}
|
||||||
|
style={{
|
||||||
|
flex: 1, fontFamily: 'var(--font-mono)', fontSize: '0.82rem',
|
||||||
|
cursor: 'pointer', wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showToken ? config.api_token : maskedToken}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={config.api_token} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: 'var(--text-3)', marginTop: '4px' }}>
|
||||||
|
Tap token to reveal. Only visible to admins.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!config.api_token && config.api_token_set && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.8rem', color: 'var(--text-3)', fontStyle: 'italic',
|
||||||
|
padding: '8px 0',
|
||||||
|
}}>
|
||||||
|
API token is set but only visible to admin users.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: '12px', flexWrap: 'wrap',
|
||||||
|
fontSize: '0.78rem', color: 'var(--text-3)',
|
||||||
|
}}>
|
||||||
|
<span>{config.tools_count} tools available</span>
|
||||||
|
<span style={{ color: 'var(--divider)' }}>|</span>
|
||||||
|
<span>{config.deployment === 'local' ? 'Local (SQLite)' : 'Docker (PostgreSQL)'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Claude Desktop Setup */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-label">Claude Desktop</div>
|
||||||
|
<p style={{ fontSize: '0.82rem', color: 'var(--text-2)', marginBottom: '12px' }}>
|
||||||
|
Add this to your Claude Desktop MCP config:
|
||||||
|
</p>
|
||||||
|
<CodeBlock copyText={claudeDesktopConfig}>
|
||||||
|
{claudeDesktopConfig}
|
||||||
|
</CodeBlock>
|
||||||
|
<p style={{ fontSize: '0.75rem', color: 'var(--text-3)', marginTop: '10px' }}>
|
||||||
|
On macOS: <code style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem' }}>
|
||||||
|
~/Library/Application Support/Claude/claude_desktop_config.json
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Claude Code Setup */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-label">Claude Code</div>
|
||||||
|
<p style={{ fontSize: '0.82rem', color: 'var(--text-2)', marginBottom: '12px' }}>
|
||||||
|
Add the MCP server from your terminal:
|
||||||
|
</p>
|
||||||
|
<CodeBlock copyText={`claude mcp add diligence --transport sse ${config.mcp_url}`}>
|
||||||
|
{`claude mcp add diligence --transport sse ${config.mcp_url}`}
|
||||||
|
</CodeBlock>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What your agent can do */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-label">What Your Agent Can Do</div>
|
||||||
|
<div style={{ display: 'grid', gap: '8px' }}>
|
||||||
|
{[
|
||||||
|
['Log workouts', 'Track any activity and earn points automatically'],
|
||||||
|
['Track food', 'Search 400K+ foods, log meals with full macros'],
|
||||||
|
['Manage meal plans', 'Create plans, track compliance, adjust portions'],
|
||||||
|
['Check progress', 'Daily points, weekly summary, program status'],
|
||||||
|
['Redeem rewards', 'Spend earned points on rewards you set up'],
|
||||||
|
].map(([title, desc]) => (
|
||||||
|
<div key={title} style={{
|
||||||
|
display: 'flex', gap: '10px', alignItems: 'flex-start',
|
||||||
|
padding: '8px 0', borderBottom: '1px solid var(--divider)',
|
||||||
|
}}>
|
||||||
|
<span style={{ color: 'var(--green)', fontSize: '0.9rem', marginTop: '1px' }}>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '0.85rem' }}>{title}</div>
|
||||||
|
<div style={{ fontSize: '0.78rem', color: 'var(--text-3)' }}>{desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -36,12 +36,12 @@ export default function Login() {
|
||||||
<div style={{ width: '100%', maxWidth: '380px' }}>
|
<div style={{ width: '100%', maxWidth: '380px' }}>
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
<div style={{ textAlign: 'center', marginBottom: '32px', color: '#fff' }}>
|
<div style={{ textAlign: 'center', marginBottom: '32px', color: '#fff' }}>
|
||||||
<div style={{ fontSize: '3.2rem', marginBottom: '8px', filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.2))' }}>🔥</div>
|
<div style={{ fontSize: '3.2rem', marginBottom: '8px', filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.2))' }}>💪</div>
|
||||||
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: '2.2rem', fontWeight: 900, letterSpacing: '-0.03em' }}>
|
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: '2.2rem', fontWeight: 900, letterSpacing: '-0.03em' }}>
|
||||||
Fitness Rewards
|
Diligence
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ opacity: 0.8, marginTop: '4px', fontSize: '0.95rem', fontWeight: 500 }}>
|
<p style={{ opacity: 0.8, marginTop: '4px', fontSize: '0.95rem', fontWeight: 500 }}>
|
||||||
Earn your rewards. Every single day.
|
Your fitness. Your data. Your rewards.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
65
pyproject.toml
Normal file
65
pyproject.toml
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "diligence"
|
||||||
|
version = "2.0.0"
|
||||||
|
description = "Self-hosted fitness rewards platform with AI agent integration"
|
||||||
|
readme = "README.md"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
authors = [
|
||||||
|
{name = "DiligenceWorks Pte. Ltd.", email = "hello@diligenceworks.online"},
|
||||||
|
]
|
||||||
|
keywords = ["fitness", "self-hosted", "mcp", "ai-agent", "rewards"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"uvicorn[standard]>=0.30.0",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.30",
|
||||||
|
"aiosqlite>=0.20.0",
|
||||||
|
"pydantic>=2.10.0",
|
||||||
|
"pydantic-settings>=2.7.0",
|
||||||
|
"python-jose[cryptography]>=3.3.0",
|
||||||
|
"bcrypt>=4.2.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"python-multipart>=0.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
postgres = [
|
||||||
|
"asyncpg>=0.30.0",
|
||||||
|
]
|
||||||
|
mcp = [
|
||||||
|
"mcp[cli]>=1.0.0",
|
||||||
|
]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.23",
|
||||||
|
"ruff>=0.8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
diligence = "diligence.cli:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/DiligenceWorks/Diligence"
|
||||||
|
Documentation = "https://github.com/DiligenceWorks/Diligence#readme"
|
||||||
|
Repository = "https://github.com/DiligenceWorks/Diligence"
|
||||||
|
Issues = "https://github.com/DiligenceWorks/Diligence/issues"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["diligence*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
diligence = ["frontend/**/*"]
|
||||||
78
setup.ps1
78
setup.ps1
|
|
@ -1,29 +1,71 @@
|
||||||
# Diligence — Windows Setup
|
# Diligence Setup — Windows
|
||||||
# Generate API_TOKEN for MCP connector auth
|
Write-Host ""
|
||||||
$apiToken = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 32 | ForEach-Object {[char]$_})
|
Write-Host " Diligence Setup"
|
||||||
(Get-Content .env) -replace '^API_TOKEN=.*', "API_TOKEN=$apiToken" | Set-Content .env
|
Write-Host ""
|
||||||
|
|
||||||
Write-Host "`n`e[36m💪 Diligence — Setup`e[0m`n"
|
function New-RandomHex { -join ((1..32) | ForEach-Object { '{0:x2}' -f (Get-Random -Max 256) }) }
|
||||||
|
|
||||||
if (Test-Path .env) {
|
$Secret = New-RandomHex
|
||||||
Write-Host "`e[33m.env already exists. Delete it first to regenerate.`e[0m"
|
$Token = New-RandomHex
|
||||||
|
|
||||||
|
# Detect mode — Docker if docker compose is available and docker-compose.yml exists
|
||||||
|
$DockerAvailable = $false
|
||||||
|
try { docker compose version 2>$null | Out-Null; $DockerAvailable = $true } catch {}
|
||||||
|
|
||||||
|
if ($DockerAvailable -and (Test-Path "docker-compose.yml")) {
|
||||||
|
Write-Host " Mode: Docker (PostgreSQL)"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
if (Test-Path ".env") {
|
||||||
|
Write-Host " .env already exists. Delete it first to regenerate."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Generate random SECRET_KEY (64 hex chars)
|
Copy-Item ".env.example" ".env"
|
||||||
$bytes = New-Object byte[] 32
|
(Get-Content ".env") -replace '^SECRET_KEY=.*', "SECRET_KEY=$Secret" `
|
||||||
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
|
-replace '^API_TOKEN=.*', "API_TOKEN=$Token" `
|
||||||
$secret = ($bytes | ForEach-Object { $_.ToString("x2") }) -join ""
|
-replace '^DATABASE_URL=.*', 'DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards' `
|
||||||
|
-replace '^BASE_URL=.*', 'BASE_URL=http://localhost' |
|
||||||
|
Set-Content ".env"
|
||||||
|
|
||||||
# Create .env from template
|
Write-Host " .env created"
|
||||||
Copy-Item .env.example .env
|
|
||||||
(Get-Content .env) -replace "^SECRET_KEY=.*", "SECRET_KEY=$secret" | Set-Content .env
|
|
||||||
|
|
||||||
Write-Host "`e[32m✅ .env created with random SECRET_KEY`e[0m"
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " Next steps:"
|
Write-Host " Next steps:"
|
||||||
Write-Host " docker compose up -d"
|
Write-Host " docker compose up -d"
|
||||||
Write-Host " Open http://localhost"
|
Write-Host " Open http://localhost"
|
||||||
Write-Host " Register your account"
|
|
||||||
Write-Host " Configure integrations via Settings or your AI agent"
|
} else {
|
||||||
|
Write-Host " Mode: Local (SQLite)"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$DataDir = Join-Path $env:USERPROFILE ".diligence"
|
||||||
|
if (-not (Test-Path $DataDir)) { New-Item -ItemType Directory -Path $DataDir | Out-Null }
|
||||||
|
$EnvFile = Join-Path $DataDir ".env"
|
||||||
|
|
||||||
|
if (Test-Path $EnvFile) {
|
||||||
|
Write-Host " Config already exists at $EnvFile"
|
||||||
|
Write-Host " Delete it to regenerate."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@"
|
||||||
|
SECRET_KEY=$Secret
|
||||||
|
API_TOKEN=$Token
|
||||||
|
BASE_URL=http://localhost:8000
|
||||||
|
DATA_DIR=$DataDir
|
||||||
|
"@ | Set-Content $EnvFile
|
||||||
|
|
||||||
|
Write-Host " Config created at $EnvFile"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Next steps:"
|
||||||
|
Write-Host " pip install ."
|
||||||
|
Write-Host " diligence"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Data stored in: $DataDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " MCP agent connection:"
|
||||||
|
Write-Host " URL: http://localhost:3001/sse"
|
||||||
|
Write-Host " Header: Authorization: Bearer $Token"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
|
||||||
67
setup.sh
67
setup.sh
|
|
@ -1,26 +1,81 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
echo "💪 Diligence — Setup"
|
set -e
|
||||||
|
echo ""
|
||||||
|
echo " Diligence Setup"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Detect install mode
|
||||||
|
if command -v docker compose &> /dev/null && [ -f docker-compose.yml ]; then
|
||||||
|
MODE="docker"
|
||||||
|
else
|
||||||
|
MODE="local"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate secrets
|
||||||
|
SECRET=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||||
|
TOKEN=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||||
|
|
||||||
|
if [ "$MODE" = "docker" ]; then
|
||||||
|
echo " Mode: Docker (PostgreSQL)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
if [ -f .env ]; then
|
if [ -f .env ]; then
|
||||||
echo " .env already exists. Delete it first to regenerate."
|
echo " .env already exists. Delete it first to regenerate."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SECRET=$(openssl rand -hex 32)
|
|
||||||
TOKEN=$(openssl rand -hex 32)
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
sed -i.bak "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env
|
sed -i.bak "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env
|
||||||
sed -i.bak "s/^API_TOKEN=.*/API_TOKEN=${TOKEN}/" .env
|
sed -i.bak "s/^API_TOKEN=.*/API_TOKEN=${TOKEN}/" .env
|
||||||
|
sed -i.bak "s|^DATABASE_URL=.*|DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards|" .env
|
||||||
|
sed -i.bak "s|^BASE_URL=.*|BASE_URL=http://localhost|" .env
|
||||||
rm -f .env.bak
|
rm -f .env.bak
|
||||||
|
|
||||||
echo "✅ .env created with random SECRET_KEY and API_TOKEN"
|
echo " .env created"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Next steps:"
|
echo " Next steps:"
|
||||||
echo " docker compose up -d"
|
echo " docker compose up -d"
|
||||||
echo " Open http://localhost"
|
echo " Open http://localhost"
|
||||||
echo " Register your account"
|
|
||||||
echo " Configure integrations via Settings or your AI agent"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " MCP agent connection:"
|
echo " MCP agent connection:"
|
||||||
echo " URL: http://localhost:3001/sse"
|
echo " URL: http://localhost:3001/sse"
|
||||||
echo " Header: Authorization: Bearer ${TOKEN}"
|
echo " Header: Authorization: Bearer ${TOKEN}"
|
||||||
|
|
||||||
|
else
|
||||||
|
echo " Mode: Local (SQLite)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
DATA_DIR="${HOME}/.diligence"
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
ENV_FILE="${DATA_DIR}/.env"
|
||||||
|
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
echo " Config already exists at ${ENV_FILE}"
|
||||||
|
echo " Delete it to regenerate."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$ENV_FILE" << EOF
|
||||||
|
SECRET_KEY=${SECRET}
|
||||||
|
API_TOKEN=${TOKEN}
|
||||||
|
BASE_URL=http://localhost:8000
|
||||||
|
DATA_DIR=${DATA_DIR}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo " Config created at ${ENV_FILE}"
|
||||||
|
echo ""
|
||||||
|
echo " Next steps:"
|
||||||
|
echo " pip install ."
|
||||||
|
echo " diligence"
|
||||||
|
echo ""
|
||||||
|
echo " Or run directly:"
|
||||||
|
echo " python -m diligence"
|
||||||
|
echo ""
|
||||||
|
echo " Data stored in: ${DATA_DIR}"
|
||||||
|
echo ""
|
||||||
|
echo " MCP agent connection:"
|
||||||
|
echo " URL: http://localhost:3001/sse"
|
||||||
|
echo " Header: Authorization: Bearer ${TOKEN}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue