v2.0: dual deployment — pip install (SQLite) + Docker (PostgreSQL)

Architecture:
- Moved backend/app/ to diligence/ Python package
- Dialect-agnostic models (Uuid + JSON, no PostgreSQL imports)
- Auto-detect database: empty DATABASE_URL → SQLite at ~/.diligence/data.db
- Cross-dialect migrations (inspector-based, no raw PostgreSQL DDL)
- FastAPI serves pre-built React frontend for pip path
- CLI entry point: diligence [--port] [--no-browser] [--data-dir]
- pyproject.toml with optional deps: [postgres], [mcp], [dev]
- Docker path unchanged: docker compose up -d

pip install path: pip install . && diligence
Docker path: ./setup.sh && docker compose up -d
This commit is contained in:
Claude 2026-07-01 03:30:12 +00:00
parent 0ce1463de8
commit a6e6e2f54f
77 changed files with 1537 additions and 1007 deletions

View file

@ -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
View file

@ -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
View file

@ -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)

View file

@ -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"]

View file

@ -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()

View file

@ -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()

View file

@ -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",
]

View file

@ -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
View 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
View file

@ -0,0 +1,4 @@
"""Allow running as: python -m diligence"""
from diligence.cli import main
main()

99
diligence/cli.py Normal file
View file

@ -0,0 +1,99 @@
"""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(
"--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"
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}")
print(f"MCP token: {api_token}")
# 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 args.no_mcp:
os.environ["MCP_ENABLED"] = "false"
# 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}")
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
View 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 = "Asia/Bangkok"
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()

View file

@ -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)

View 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"
]
}
]
}

View file

@ -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)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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>Fitness Rewards</title>
<script type="module" crossorigin src="/assets/index-DIoo6DtB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CRHpiAro.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View 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.

400
diligence/main.py Normal file
View file

@ -0,0 +1,400 @@
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
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": "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()

View 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",
]

View file

@ -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"),

View file

@ -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)

View file

@ -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"),

View file

@ -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

View file

@ -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))

View file

@ -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)
@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -2,16 +2,15 @@ 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)

View file

@ -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"])

View file

@ -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"])

View file

@ -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"])

View file

@ -11,13 +11,13 @@ 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
router = APIRouter(prefix="/api/programs", tags=["programs-v2"]) router = APIRouter(prefix="/api/programs", tags=["programs-v2"])

View file

@ -7,12 +7,12 @@ 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.services.food_lookup import lookup_barcode, search_food
router = APIRouter(prefix="/api/food", tags=["food"]) router = APIRouter(prefix="/api/food", tags=["food"])

View file

@ -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):

View file

@ -10,10 +10,10 @@ 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
router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"]) router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])

View file

@ -10,15 +10,15 @@ 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
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"]) router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])

View file

@ -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"])

View file

@ -7,14 +7,14 @@ 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.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"])

View file

@ -7,11 +7,11 @@ 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 pydantic import BaseModel from pydantic import BaseModel
router = APIRouter(prefix="/api/programs", tags=["programs"]) router = APIRouter(prefix="/api/programs", tags=["programs"])

View file

@ -13,13 +13,13 @@ 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.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"])
@ -301,7 +301,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 +360,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)
) )

View file

@ -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'}")

View file

@ -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__)

View file

@ -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(

View file

@ -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"

View file

@ -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:

View file

@ -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()

View file

@ -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) ──────────────────────────

View file

@ -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

View file

@ -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()

View file

@ -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:

View file

@ -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:

65
pyproject.toml Normal file
View 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/**/*"]

View file

@ -1,32 +1,71 @@
# Diligence — Windows Setup # Diligence Setup — Windows
Write-Host ""
Write-Host " Diligence Setup"
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
exit 1
# 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
}
Copy-Item ".env.example" ".env"
(Get-Content ".env") -replace '^SECRET_KEY=.*', "SECRET_KEY=$Secret" `
-replace '^API_TOKEN=.*', "API_TOKEN=$Token" `
-replace '^DATABASE_URL=.*', 'DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards' `
-replace '^BASE_URL=.*', 'BASE_URL=http://localhost' |
Set-Content ".env"
Write-Host " .env created"
Write-Host ""
Write-Host " Next steps:"
Write-Host " docker compose up -d"
Write-Host " Open http://localhost"
} 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"
} }
# Generate random SECRET_KEY (64 hex chars)
$bytes = New-Object byte[] 32
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
$secret = ($bytes | ForEach-Object { $_.ToString("x2") }) -join ""
# Generate random API_TOKEN (32 alphanumeric chars)
$apiToken = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 32 | ForEach-Object {[char]$_})
# Create .env from template and replace both keys
Copy-Item .env.example .env
(Get-Content .env) -replace "^SECRET_KEY=.*", "SECRET_KEY=$secret" -replace "^API_TOKEN=.*", "API_TOKEN=$apiToken" | Set-Content .env
Write-Host "`e[32m✅ .env created with random SECRET_KEY and API_TOKEN`e[0m"
Write-Host "" Write-Host ""
Write-Host "Next steps:" Write-Host " MCP agent connection:"
Write-Host " docker compose up -d" Write-Host " URL: http://localhost:3001/sse"
Write-Host " Open http://localhost" Write-Host " Header: Authorization: Bearer $Token"
Write-Host " Register your account"
Write-Host " Configure integrations via Settings or your AI agent"
Write-Host "" Write-Host ""
Write-Host "MCP agent connection:"
Write-Host " URL: http://localhost:3001/sse"
Write-Host " Header: Authorization: Bearer $apiToken"

View file

@ -1,26 +1,81 @@
#!/bin/bash #!/bin/bash
echo "💪 Diligence — Setup" set -e
echo ""
echo " Diligence Setup"
echo ""
if [ -f .env ]; then # Detect install mode
echo ".env already exists. Delete it first to regenerate." if command -v docker compose &> /dev/null && [ -f docker-compose.yml ]; then
exit 1 MODE="docker"
else
MODE="local"
fi fi
SECRET=$(openssl rand -hex 32) # Generate secrets
TOKEN=$(openssl rand -hex 32) SECRET=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))")
cp .env.example .env TOKEN=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))")
sed -i.bak "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env
sed -i.bak "s/^API_TOKEN=.*/API_TOKEN=${TOKEN}/" .env if [ "$MODE" = "docker" ]; then
rm -f .env.bak echo " Mode: Docker (PostgreSQL)"
echo ""
if [ -f .env ]; then
echo " .env already exists. Delete it first to regenerate."
exit 1
fi
cp .env.example .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|^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
echo " .env created"
echo ""
echo " Next steps:"
echo " docker compose up -d"
echo " Open http://localhost"
echo ""
echo " MCP agent connection:"
echo " URL: http://localhost:3001/sse"
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 "✅ .env created with random SECRET_KEY and API_TOKEN"
echo "" echo ""
echo "Next steps:"
echo " docker compose up -d"
echo " Open http://localhost"
echo " Register your account"
echo " Configure integrations via Settings or your AI agent"
echo ""
echo "MCP agent connection:"
echo " URL: http://localhost:3001/sse"
echo " Header: Authorization: Bearer ${TOKEN}"