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:
parent
0ce1463de8
commit
a6e6e2f54f
77 changed files with 1537 additions and 1007 deletions
34
.env.example
34
.env.example
|
|
@ -1,19 +1,23 @@
|
|||
# === REQUIRED (generated automatically by setup.sh) ===
|
||||
SECRET_KEY=
|
||||
# Diligence Configuration
|
||||
# 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=
|
||||
|
||||
# === APP URL (change for production) ===
|
||||
BASE_URL=http://localhost
|
||||
# Database — leave empty for SQLite (pip install path)
|
||||
# Set for PostgreSQL (Docker path): postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards
|
||||
DATABASE_URL=
|
||||
|
||||
# === DATABASE (defaults work out of the box) ===
|
||||
DB_USER=fitness
|
||||
DB_NAME=fitness_rewards
|
||||
# App
|
||||
BASE_URL=http://localhost:8000
|
||||
|
||||
# === MCP AGENT AUTH (generated automatically by setup.sh) ===
|
||||
# The API_TOKEN authenticates the MCP connector with the backend.
|
||||
# It is auto-configured — you don't need to touch it unless you're
|
||||
# connecting an agent from outside Docker.
|
||||
|
||||
# Everything else — Strava, Polar, Garmin, Fitbit, Telegram,
|
||||
# USDA, Groq, etc. — is configured through the app UI or your AI agent.
|
||||
# No container restart needed.
|
||||
# Optional integrations
|
||||
STRAVA_CLIENT_ID=
|
||||
STRAVA_CLIENT_SECRET=
|
||||
POLAR_CLIENT_ID=
|
||||
POLAR_CLIENT_SECRET=
|
||||
GROQ_API_KEY=
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
CRAWL_ENABLED=false
|
||||
|
|
|
|||
31
.gitignore
vendored
31
.gitignore
vendored
|
|
@ -1,12 +1,31 @@
|
|||
# Environment
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.env
|
||||
.venv/
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
build/
|
||||
*.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/
|
||||
|
|
|
|||
376
README.md
376
README.md
|
|
@ -1,237 +1,211 @@
|
|||
# Diligence — Self-Hosted Fitness Rewards with AI Agent Support
|
||||
# Diligence
|
||||
|
||||
Points-based fitness accountability app. Log workouts and food, earn points, unlock rewards. Connect any AI agent via MCP for logging, coaching, and meal planning. Self-hosted, open source, zero cloud dependencies.
|
||||
Self-hosted fitness rewards platform with AI agent integration. Points-based behavioral economy: earn points through workouts and food logging, spend them on rewards you choose. Science-based onboarding. 14-tool MCP connector for AI agents. Your data stays on your machine.
|
||||
|
||||
Built by [DiligenceWorks](https://diligenceworks.online).
|
||||
|
||||
## 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**.
|
||||
**By [DiligenceWorks Pte. Ltd.](https://diligenceworks.online) — MIT License**
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
**Prerequisites:** Python 3.11 or newer.
|
||||
|
||||
**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**
|
||||
|
||||
Open PowerShell:
|
||||
```powershell
|
||||
git clone https://github.com/diligenceworks/diligence
|
||||
cd diligence
|
||||
powershell -ExecutionPolicy Bypass -File setup.ps1
|
||||
docker compose up -d
|
||||
# Run
|
||||
diligence
|
||||
```
|
||||
|
||||
Or without the script:
|
||||
```powershell
|
||||
git clone https://github.com/diligenceworks/diligence
|
||||
cd diligence
|
||||
copy .env.example .env
|
||||
# Edit .env and set SECRET_KEY to any random string
|
||||
docker compose up -d
|
||||
That's it. The app opens in your browser at `http://localhost:8000`. Your data lives in `~/.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`.
|
||||
|
||||
### Options
|
||||
|
||||
```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
|
||||
git clone https://github.com/diligenceworks/diligence
|
||||
cd diligence
|
||||
./setup.sh
|
||||
docker compose up -d
|
||||
git clone https://github.com/DiligenceWorks/Diligence.git
|
||||
cd Diligence
|
||||
./setup.sh # generates .env with secrets
|
||||
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
|
||||
|
||||
Install Docker Engine and Docker Compose via your distro's package manager, or install Docker Desktop for Linux. Then:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/diligenceworks/diligence
|
||||
cd diligence
|
||||
./setup.sh
|
||||
docker compose up -d
|
||||
# Clone
|
||||
git clone https://github.com/DiligenceWorks/Diligence.git
|
||||
cd Diligence
|
||||
|
||||
# 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
|
||||
docker compose ps
|
||||
```
|
||||
Integration credentials (Strava, Polar, etc.) are encrypted at rest using Fernet with HKDF key derivation from your SECRET_KEY.
|
||||
|
||||
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
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
MIT. See `LICENSE`.
|
||||
|
||||
Built by [DiligenceWorks Pte. Ltd.](https://diligenceworks.online)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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
|
||||
COPY . .
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
COPY diligence/ ./diligence/
|
||||
|
||||
CMD ["uvicorn", "diligence.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Database - hostname 'fitness-db' avoids collision with other 'db' containers on Coolify network
|
||||
database_url: str = "postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards"
|
||||
|
||||
# Auth
|
||||
secret_key: str = "change-me-in-production"
|
||||
crawl_enabled: bool = False # Set CRAWL_ENABLED=true to start program crawl scheduler
|
||||
access_token_expire_minutes: int = 1440 # 24 hours
|
||||
api_token: str = "" # MCP connector auth — generated by setup.sh
|
||||
|
||||
# Strava
|
||||
strava_client_id: str = ""
|
||||
strava_client_secret: str = ""
|
||||
|
||||
# Polar
|
||||
polar_client_id: str = ""
|
||||
polar_client_secret: str = ""
|
||||
|
||||
# Groq (program extraction)
|
||||
groq_api_key: str = ""
|
||||
|
||||
# Telegram (support notifications — outbound only)
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
|
||||
# App
|
||||
base_url: str = "http://localhost"
|
||||
timezone: str = "Asia/Bangkok"
|
||||
|
||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
# Module-level shortcut used by services
|
||||
settings = get_settings()
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("fitness-rewards")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Retry DB init — container may start before postgres is fully accepting connections
|
||||
for attempt in range(10):
|
||||
try:
|
||||
from app.database import init_db
|
||||
await init_db()
|
||||
logger.info("Database tables created successfully")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"DB init attempt {attempt + 1}/10 failed: {e}")
|
||||
if attempt < 9:
|
||||
await asyncio.sleep(3)
|
||||
else:
|
||||
logger.error("Could not initialize database after 10 attempts — starting without tables")
|
||||
|
||||
|
||||
# SEC-05: Fail fast if SECRET_KEY not configured
|
||||
from app.config import get_settings
|
||||
_s = get_settings()
|
||||
if _s.secret_key == "change-me-in-production":
|
||||
logger.error("CRITICAL: SECRET_KEY not set. Run ./setup.sh or set SECRET_KEY in .env")
|
||||
sys.exit(1)
|
||||
|
||||
# Run lightweight migrations for schema changes
|
||||
try:
|
||||
await run_migrations()
|
||||
logger.info("Migrations completed")
|
||||
except Exception as e:
|
||||
logger.warning(f"Migration failed (non-fatal): {e}")
|
||||
|
||||
# Seed resources (non-fatal if it fails)
|
||||
try:
|
||||
await seed_resources()
|
||||
logger.info("Resource library seeded")
|
||||
except Exception as e:
|
||||
logger.warning(f"Resource seeding failed (non-fatal): {e}")
|
||||
|
||||
# Start background crawl queue scheduler (gated on CRAWL_ENABLED)
|
||||
crawl_task = None
|
||||
if _s.crawl_enabled:
|
||||
try:
|
||||
from app.services.crawl_scheduler import crawl_queue_loop
|
||||
crawl_task = asyncio.create_task(crawl_queue_loop())
|
||||
logger.info("Crawl queue scheduler started")
|
||||
except Exception as e:
|
||||
logger.warning(f"Crawl scheduler failed to start (non-fatal): {e}")
|
||||
else:
|
||||
logger.info("Crawl scheduler disabled (set CRAWL_ENABLED=true to enable)")
|
||||
|
||||
logger.info("Fitness Rewards backend started")
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
if crawl_task:
|
||||
crawl_task.cancel()
|
||||
try:
|
||||
await crawl_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Fitness Rewards backend shutting down")
|
||||
|
||||
|
||||
app = FastAPI(title="Fitness Rewards", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False, # Bearer tokens don't need credentials mode
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Import routers after app creation to avoid circular imports
|
||||
from app.routers.auth import router as auth_router
|
||||
from app.routers.onboarding import router as onboarding_router
|
||||
from app.routers.activities import router as activities_router
|
||||
from app.routers.food import router as food_router
|
||||
from app.routers.points import router as points_router, rewards_router
|
||||
from app.routers.integrations import router as integrations_router
|
||||
from app.routers.programs import router as programs_router
|
||||
from app.routers.catalog import router as catalog_router
|
||||
from app.routers.support import router as support_router
|
||||
from app.routers.nutrition import router as nutrition_router
|
||||
from app.routers.meal_plans import router as meal_plans_router
|
||||
from app.routers.ai_chat import router as ai_chat_router
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(onboarding_router)
|
||||
app.include_router(activities_router)
|
||||
app.include_router(food_router)
|
||||
app.include_router(points_router)
|
||||
app.include_router(rewards_router)
|
||||
app.include_router(integrations_router)
|
||||
app.include_router(catalog_router)
|
||||
app.include_router(support_router)
|
||||
app.include_router(programs_router)
|
||||
app.include_router(nutrition_router)
|
||||
app.include_router(meal_plans_router)
|
||||
app.include_router(ai_chat_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": "1.0.0"}
|
||||
|
||||
|
||||
|
||||
async def run_migrations():
|
||||
"""Add missing columns to existing tables (lightweight schema migration)."""
|
||||
from app.database import engine
|
||||
from sqlalchemy import text
|
||||
|
||||
async with engine.begin() as conn:
|
||||
# v1: Add equipment_list JSONB column if missing
|
||||
await conn.execute(text("""
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS equipment_list JSONB DEFAULT '[]';
|
||||
EXCEPTION WHEN undefined_table THEN NULL;
|
||||
END $$;
|
||||
"""))
|
||||
|
||||
# v2: Add catalog columns to programs table
|
||||
await conn.execute(text("""
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE programs ADD COLUMN IF NOT EXISTS catalog_id UUID REFERENCES program_catalog(id);
|
||||
ALTER TABLE programs ADD COLUMN IF NOT EXISTS current_week INTEGER DEFAULT 1;
|
||||
ALTER TABLE programs ADD COLUMN IF NOT EXISTS current_day INTEGER DEFAULT 1;
|
||||
EXCEPTION WHEN undefined_table THEN NULL;
|
||||
END $$;
|
||||
"""))
|
||||
|
||||
# v2.1: Add week_number to workout_logs for template rotation tracking
|
||||
await conn.execute(text("""
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE workout_logs ADD COLUMN IF NOT EXISTS week_number INTEGER NOT NULL DEFAULT 1;
|
||||
EXCEPTION WHEN undefined_table THEN NULL;
|
||||
END $$;
|
||||
"""))
|
||||
|
||||
# v3: Seed keto point rules (fast_completed, keto_day, meal_logged)
|
||||
# NOTE: Keto rules below are from v2. v3+ migrations (is_admin, integration_configs,
|
||||
# meal plans) are added after this block.
|
||||
await conn.execute(text("""
|
||||
DO $$ BEGIN
|
||||
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
||||
SELECT gen_random_uuid(), u.id, 'fast_completed', 200, 'per_event', TRUE
|
||||
FROM users u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM point_rules pr
|
||||
WHERE pr.user_id = u.id AND pr.category = 'fast_completed'
|
||||
);
|
||||
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
||||
SELECT gen_random_uuid(), u.id, 'keto_compliant_day', 100, 'per_event', TRUE
|
||||
FROM users u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM point_rules pr
|
||||
WHERE pr.user_id = u.id AND pr.category = 'keto_compliant_day'
|
||||
);
|
||||
EXCEPTION WHEN undefined_table THEN NULL;
|
||||
END $$;
|
||||
"""))
|
||||
|
||||
# v4: Add is_admin column to users
|
||||
await conn.execute(text("""
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE;
|
||||
EXCEPTION WHEN undefined_table THEN NULL;
|
||||
END $$;
|
||||
"""))
|
||||
|
||||
# v4.1: Grant admin to first registered user if no admin exists
|
||||
await conn.execute(text("""
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM users WHERE is_admin = TRUE) THEN
|
||||
UPDATE users SET is_admin = TRUE
|
||||
WHERE id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1);
|
||||
END IF;
|
||||
EXCEPTION WHEN undefined_table THEN NULL;
|
||||
WHEN undefined_column THEN NULL;
|
||||
END $$;
|
||||
"""))
|
||||
|
||||
# v5: Create integration_configs table
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS integration_configs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
provider VARCHAR(50) NOT NULL,
|
||||
config_key VARCHAR(100) NOT NULL,
|
||||
config_value TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, provider, config_key)
|
||||
);
|
||||
"""))
|
||||
|
||||
# v6: Create meal plan tables
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS meal_plans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
diet_type VARCHAR(50),
|
||||
daily_calories INTEGER,
|
||||
daily_protein_g INTEGER,
|
||||
daily_carbs_g INTEGER,
|
||||
daily_fat_g INTEGER,
|
||||
restrictions JSONB DEFAULT '[]',
|
||||
duration_days INTEGER NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
"""))
|
||||
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS meal_plan_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plan_id UUID NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
|
||||
day_number INTEGER NOT NULL,
|
||||
meal_type VARCHAR(20) NOT NULL,
|
||||
food_name VARCHAR(300) NOT NULL,
|
||||
description TEXT,
|
||||
calories INTEGER,
|
||||
protein_g DECIMAL(6,1),
|
||||
carbs_g DECIMAL(6,1),
|
||||
fat_g DECIMAL(6,1),
|
||||
fiber_g DECIMAL(6,1),
|
||||
serving_size VARCHAR(100),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
"""))
|
||||
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS meal_compliance (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
plan_id UUID NOT NULL REFERENCES meal_plans(id),
|
||||
plan_item_id UUID,
|
||||
compliance_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
substitution TEXT,
|
||||
food_log_id UUID,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
"""))
|
||||
|
||||
await conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_meal_plan_items_day ON meal_plan_items(plan_id, day_number);
|
||||
"""))
|
||||
await conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_meal_compliance_date ON meal_compliance(user_id, compliance_date);
|
||||
"""))
|
||||
|
||||
# v7: Seed meal plan point rules
|
||||
await conn.execute(text("""
|
||||
DO $$ BEGIN
|
||||
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
||||
SELECT gen_random_uuid(), u.id, 'meal_plan_followed', 40, 'per_event', TRUE
|
||||
FROM users u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM point_rules pr
|
||||
WHERE pr.user_id = u.id AND pr.category = 'meal_plan_followed'
|
||||
);
|
||||
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
||||
SELECT gen_random_uuid(), u.id, 'meal_plan_partial', 20, 'per_event', TRUE
|
||||
FROM users u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM point_rules pr
|
||||
WHERE pr.user_id = u.id AND pr.category = 'meal_plan_partial'
|
||||
);
|
||||
EXCEPTION WHEN undefined_table THEN NULL;
|
||||
END $$;
|
||||
"""))
|
||||
|
||||
|
||||
async def seed_resources():
|
||||
"""Seed the resource library with curated fitness programs."""
|
||||
from app.database import async_session
|
||||
from app.models.resource import Resource
|
||||
from sqlalchemy import select
|
||||
|
||||
async with async_session() as db:
|
||||
existing = await db.execute(select(Resource).limit(1))
|
||||
if existing.scalar_one_or_none():
|
||||
return # Already seeded
|
||||
|
||||
resources = [
|
||||
Resource(
|
||||
name="Darebee Foundation Program",
|
||||
source="darebee",
|
||||
url="https://darebee.com/programs/foundation-program.html",
|
||||
description="30-day beginner program. Bodyweight exercises, no equipment needed. Perfect starting point.",
|
||||
goal_tags=["get_active", "feel_better"],
|
||||
activity_tags=["bodyweight"],
|
||||
equipment_needed="none",
|
||||
ttm_stages=["precontemplation", "contemplation", "preparation"],
|
||||
difficulty="beginner",
|
||||
duration_days=30,
|
||||
),
|
||||
Resource(
|
||||
name="Darebee 30 Days of Change",
|
||||
source="darebee",
|
||||
url="https://darebee.com/programs/30-days-of-change.html",
|
||||
description="30-day progressive program for building fitness habits. Bodyweight only.",
|
||||
goal_tags=["get_active", "lose_weight", "feel_better"],
|
||||
activity_tags=["bodyweight"],
|
||||
equipment_needed="none",
|
||||
ttm_stages=["contemplation", "preparation"],
|
||||
difficulty="beginner",
|
||||
duration_days=30,
|
||||
),
|
||||
Resource(
|
||||
name="Darebee IRONHEART",
|
||||
source="darebee",
|
||||
url="https://darebee.com/programs/ironheart.html",
|
||||
description="Strength-focused bodyweight program. No equipment, all levels.",
|
||||
goal_tags=["build_strength"],
|
||||
activity_tags=["bodyweight"],
|
||||
equipment_needed="none",
|
||||
ttm_stages=["preparation", "action"],
|
||||
difficulty="intermediate",
|
||||
duration_days=30,
|
||||
),
|
||||
Resource(
|
||||
name="Darebee Total Body Strength",
|
||||
source="darebee",
|
||||
url="https://darebee.com/programs/total-body-strength.html",
|
||||
description="Comprehensive strength building program using bodyweight.",
|
||||
goal_tags=["build_strength"],
|
||||
activity_tags=["bodyweight"],
|
||||
equipment_needed="none",
|
||||
ttm_stages=["action", "maintenance"],
|
||||
difficulty="intermediate",
|
||||
duration_days=30,
|
||||
),
|
||||
Resource(
|
||||
name="Darebee 8 Weeks to 5K",
|
||||
source="darebee",
|
||||
url="https://darebee.com/programs/8-weeks-to-5k-program.html",
|
||||
description="Progressive running program from beginner to 5K in 8 weeks.",
|
||||
goal_tags=["get_active", "lose_weight"],
|
||||
activity_tags=["running"],
|
||||
equipment_needed="none",
|
||||
ttm_stages=["preparation", "action"],
|
||||
difficulty="beginner",
|
||||
duration_days=56,
|
||||
),
|
||||
Resource(
|
||||
name="StrongLifts 5x5",
|
||||
source="stronglifts",
|
||||
url="https://stronglifts.com/5x5/",
|
||||
description="Simple barbell strength program. 3 days/week, 5 exercises, progressive overload.",
|
||||
goal_tags=["build_strength"],
|
||||
activity_tags=["weights"],
|
||||
equipment_needed="full_gym",
|
||||
ttm_stages=["preparation", "action", "maintenance"],
|
||||
difficulty="beginner",
|
||||
duration_days=90,
|
||||
),
|
||||
Resource(
|
||||
name="Darebee 30 Days of Cardio",
|
||||
source="darebee",
|
||||
url="https://darebee.com/programs/30-days-of-cardio.html",
|
||||
description="30-day cardio program, no equipment. Great for weight loss.",
|
||||
goal_tags=["lose_weight", "get_active"],
|
||||
activity_tags=["bodyweight"],
|
||||
equipment_needed="none",
|
||||
ttm_stages=["preparation", "action"],
|
||||
difficulty="beginner",
|
||||
duration_days=30,
|
||||
),
|
||||
Resource(
|
||||
name="Darebee POWERBUILDER",
|
||||
source="darebee",
|
||||
url="https://darebee.com/programs/powerbuilder-program.html",
|
||||
description="Advanced strength and power program using bodyweight.",
|
||||
goal_tags=["build_strength"],
|
||||
activity_tags=["bodyweight"],
|
||||
equipment_needed="none",
|
||||
ttm_stages=["action", "maintenance"],
|
||||
difficulty="advanced",
|
||||
duration_days=30,
|
||||
),
|
||||
Resource(
|
||||
name="Fitness Blender (YouTube)",
|
||||
source="youtube",
|
||||
url="https://www.youtube.com/@fitnessblender",
|
||||
description="Free workout videos for all levels. Huge variety: HIIT, strength, yoga, pilates.",
|
||||
goal_tags=["lose_weight", "build_strength", "get_active", "feel_better"],
|
||||
activity_tags=["bodyweight", "yoga"],
|
||||
equipment_needed="none",
|
||||
ttm_stages=["contemplation", "preparation", "action", "maintenance"],
|
||||
difficulty="beginner",
|
||||
duration_days=None,
|
||||
),
|
||||
Resource(
|
||||
name="Darebee Yoga Flexibility Program",
|
||||
source="darebee",
|
||||
url="https://darebee.com/programs/flexibility-program.html",
|
||||
description="30-day flexibility and yoga program for beginners.",
|
||||
goal_tags=["feel_better"],
|
||||
activity_tags=["yoga"],
|
||||
equipment_needed="none",
|
||||
ttm_stages=["precontemplation", "contemplation", "preparation"],
|
||||
difficulty="beginner",
|
||||
duration_days=30,
|
||||
),
|
||||
]
|
||||
|
||||
for r in resources:
|
||||
db.add(r)
|
||||
await db.commit()
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
from app.models.user import User
|
||||
from app.models.profile import UserProfile
|
||||
from app.models.program import Program
|
||||
from app.models.activity import ActivityLog
|
||||
from app.models.food import FoodLog
|
||||
from app.models.points import PointRule, DailyTarget
|
||||
from app.models.reward import Reward, RewardRedemption
|
||||
from app.models.oauth import OAuthToken
|
||||
from app.models.resource import Resource
|
||||
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
|
||||
from app.models.support import SupportThread, SupportMessage
|
||||
from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
|
||||
from app.models.integration_config import IntegrationConfig
|
||||
from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
|
||||
|
||||
__all__ = [
|
||||
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
|
||||
"PointRule", "DailyTarget", "Reward", "RewardRedemption",
|
||||
"OAuthToken", "Resource",
|
||||
"ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog",
|
||||
"SupportThread", "SupportMessage",
|
||||
"NutritionGoal", "Fast", "ElectrolyteLog",
|
||||
"IntegrationConfig", "MealPlan", "MealPlanItem", "MealCompliance",
|
||||
]
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
# Backend
|
||||
# Core
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
alembic==1.14.0
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
bcrypt==4.2.1
|
||||
httpx==0.28.1
|
||||
python-multipart==0.0.18
|
||||
|
||||
# Database drivers
|
||||
asyncpg==0.30.0
|
||||
aiosqlite==0.20.0
|
||||
|
|
|
|||
3
diligence/__init__.py
Normal file
3
diligence/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Diligence — self-hosted fitness rewards platform with AI agent integration."""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
4
diligence/__main__.py
Normal file
4
diligence/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Allow running as: python -m diligence"""
|
||||
from diligence.cli import main
|
||||
|
||||
main()
|
||||
99
diligence/cli.py
Normal file
99
diligence/cli.py
Normal 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
71
diligence/config.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
def _default_data_dir() -> Path:
|
||||
"""~/.diligence on all platforms."""
|
||||
d = Path.home() / ".diligence"
|
||||
d.mkdir(exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Database — empty string triggers SQLite auto-detect (pip install path)
|
||||
# Set DATABASE_URL explicitly for PostgreSQL (Docker path)
|
||||
database_url: str = ""
|
||||
|
||||
# Auth
|
||||
secret_key: str = "change-me-in-production"
|
||||
crawl_enabled: bool = False
|
||||
access_token_expire_minutes: int = 1440 # 24 hours
|
||||
api_token: str = "" # MCP connector auth
|
||||
|
||||
# Strava
|
||||
strava_client_id: str = ""
|
||||
strava_client_secret: str = ""
|
||||
|
||||
# Polar
|
||||
polar_client_id: str = ""
|
||||
polar_client_secret: str = ""
|
||||
|
||||
# Groq (program extraction)
|
||||
groq_api_key: str = ""
|
||||
|
||||
# Telegram (support notifications)
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
|
||||
# App
|
||||
base_url: str = "http://localhost:8000"
|
||||
timezone: str = "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()
|
||||
|
|
@ -2,11 +2,17 @@ from __future__ import annotations
|
|||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.config import get_settings
|
||||
from diligence.config import 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)
|
||||
|
||||
|
||||
|
|
@ -28,7 +34,7 @@ async def get_db():
|
|||
|
||||
async def init_db():
|
||||
# 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:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
102
diligence/frontend/.well-known/agent-card.json
Normal file
102
diligence/frontend/.well-known/agent-card.json
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
"name": "Diligence Fitness Rewards",
|
||||
"description": "Self-hosted fitness rewards platform with points-based behavioral economy. Your fitness data stays on your hardware. AI agents can log activities, query progress, manage meal plans, and configure device integrations via MCP.",
|
||||
"url": "{BASE_URL}/mcp",
|
||||
"provider": {
|
||||
"organization": "DiligenceWorks Pte. Ltd.",
|
||||
"url": "https://diligenceworks.online"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"documentationUrl": "https://github.com/diligenceworks/diligence",
|
||||
"capabilities": {
|
||||
"streaming": true,
|
||||
"pushNotifications": false,
|
||||
"stateTransitionHistory": false
|
||||
},
|
||||
"authentication": {
|
||||
"schemes": ["apiKey"],
|
||||
"credentials": null
|
||||
},
|
||||
"defaultInputModes": ["text/plain", "application/json"],
|
||||
"defaultOutputModes": ["application/json"],
|
||||
"skills": [
|
||||
{
|
||||
"id": "context-status",
|
||||
"name": "Context & Status",
|
||||
"description": "Get full user profile with motivation type (BREQ-2), active program, points status, rewards, and integration connections. Includes get_context (full brief), get_today (daily points/gate), and get_week (weekly summary).",
|
||||
"tags": ["fitness", "status", "profile", "motivation"],
|
||||
"examples": [
|
||||
"How am I doing today?",
|
||||
"What's my weekly progress?",
|
||||
"Show me my fitness dashboard"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "activity-logging",
|
||||
"name": "Activity Logging",
|
||||
"description": "Record workouts, runs, walks, cycling, yoga, or any physical activity. Awards points based on activity type and duration. Supports structured program workouts via program_day parameter.",
|
||||
"tags": ["fitness", "tracking", "points", "workout"],
|
||||
"examples": [
|
||||
"I just did a 30-minute run",
|
||||
"Log my StrongLifts Workout B today",
|
||||
"I did 45 minutes of yoga this morning"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nutrition-tracking",
|
||||
"name": "Food & Nutrition",
|
||||
"description": "Log food intake with calorie and macro tracking. Search Open Food Facts (4M+ products) and USDA FoodData Central (400K+ research-grade items). Awards points for consistent food logging.",
|
||||
"tags": ["nutrition", "food", "tracking", "barcode", "search"],
|
||||
"examples": [
|
||||
"I had 2 eggs and toast for breakfast",
|
||||
"Search for chicken breast nutrition",
|
||||
"Log a salad for lunch, about 400 calories"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "program-schedule",
|
||||
"name": "Program Schedule",
|
||||
"description": "View today's scheduled workout from the active program including exercises, sets, reps, and weight progression. Shows upcoming workouts and overall program completion percentage.",
|
||||
"tags": ["program", "schedule", "workout", "planning"],
|
||||
"examples": [
|
||||
"What's my workout today?",
|
||||
"Show me this week's program schedule",
|
||||
"How far through my program am I?"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rewards",
|
||||
"name": "Rewards",
|
||||
"description": "View available rewards with point costs and spend earned points. Rewards are user-configurable (gaming time, screen time, treats, etc.). Points gate must be passed before redemption.",
|
||||
"tags": ["rewards", "points", "spending", "gamification"],
|
||||
"examples": [
|
||||
"What rewards can I unlock?",
|
||||
"Spend points on 30 minutes of gaming",
|
||||
"Do I have enough points for a movie?"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "meal-planning",
|
||||
"name": "Meal Planning",
|
||||
"description": "Create, view, and track AI-generated meal plans. The agent generates plan content; the app stores and tracks compliance. Includes load_meal_plan, get_meal_plan, update_meal_compliance, and get_plan_progress.",
|
||||
"tags": ["nutrition", "meal-plan", "diet", "compliance"],
|
||||
"examples": [
|
||||
"Create a 7-day keto meal plan",
|
||||
"What am I supposed to eat today?",
|
||||
"I followed my breakfast plan",
|
||||
"How's my meal plan compliance?"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "device-integration",
|
||||
"name": "Device Integration",
|
||||
"description": "Configure connections to fitness devices and services (Strava, Polar, Garmin, Fitbit, WHOOP, Oura, Withings). Write-only credential storage — agent can set but never read credentials back.",
|
||||
"tags": ["integration", "strava", "garmin", "fitbit", "sync"],
|
||||
"examples": [
|
||||
"Connect my Strava account",
|
||||
"What integrations are available?",
|
||||
"I want to set up my Garmin"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Diligence — Agent Skill Definition
|
||||
|
||||
## What Is Diligence?
|
||||
|
||||
Diligence is a self-hosted fitness rewards platform. Users earn points through fitness activities (workouts, food logging, step goals) and spend them on configurable rewards (gaming time, screen time, etc.). A daily gate keeps rewards locked until enough points are earned. Points reset weekly.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Backend:** FastAPI (Python 3.12) + PostgreSQL 16
|
||||
- **Frontend:** React 18 + Vite, served by nginx
|
||||
- **MCP Connector:** FastMCP (Streamable HTTP/SSE) on port 3001, proxied via nginx at `/mcp`
|
||||
- **Deployment:** Docker Compose (4 services: frontend, backend, mcp-connector, fitness-db)
|
||||
|
||||
## Connecting
|
||||
|
||||
Point your MCP client to:
|
||||
- **Development:** `http://localhost:3001/sse`
|
||||
- **Production:** `https://your-domain/mcp`
|
||||
|
||||
## Available Tools (14)
|
||||
|
||||
| Tool | Category | Description |
|
||||
|------|----------|-------------|
|
||||
| `get_context()` | Status | Full profile, motivation, program, rules, rewards |
|
||||
| `get_today(date?)` | Status | Daily points, gate status, activities |
|
||||
| `get_week(start_date?)` | Status | Weekly summary, active days |
|
||||
| `log_activity(category, title, duration_minutes, ...)` | Activity | Log workout, earn points |
|
||||
| `log_food(meal_type, food_name, ...)` | Food | Log food with macros |
|
||||
| `search_food(query)` | Food | Search Open Food Facts + USDA |
|
||||
| `get_program_schedule(date?)` | Programs | Today's workout from active program |
|
||||
| `redeem_reward(reward_name)` | Rewards | Spend points on a reward |
|
||||
| `load_meal_plan(name, duration_days, meals, ...)` | Meal Plans | Create a full meal plan |
|
||||
| `get_meal_plan(date?)` | Meal Plans | View today's planned meals |
|
||||
| `update_meal_compliance(plan_item_id, status, ...)` | Meal Plans | Mark meal followed/skipped |
|
||||
| `get_plan_progress(plan_id?)` | Meal Plans | Compliance statistics |
|
||||
| `configure_integration(provider, credentials)` | Integrations | Store encrypted credentials (write-only) |
|
||||
| `get_integration_status()` | Integrations | Check provider connection status |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- The **agent generates meal plans** — the app only stores and tracks them. Zero AI API cost.
|
||||
- Integration credentials are **write-only through MCP** — agents can set but never read them back.
|
||||
- The app collects **BREQ-2 motivation data** during onboarding. Use `get_context()` to read the user's motivation profile and calibrate tone accordingly.
|
||||
- Points reset weekly. Each week is a fresh start. No guilt carry-over.
|
||||
|
||||
## Source
|
||||
|
||||
- **Repository:** https://github.com/diligenceworks/diligence
|
||||
- **License:** MIT
|
||||
- **Built by:** DiligenceWorks Pte. Ltd. (https://diligenceworks.online)
|
||||
1
diligence/frontend/assets/index-CRHpiAro.css
Normal file
1
diligence/frontend/assets/index-CRHpiAro.css
Normal file
File diff suppressed because one or more lines are too long
68
diligence/frontend/assets/index-DIoo6DtB.js
Normal file
68
diligence/frontend/assets/index-DIoo6DtB.js
Normal file
File diff suppressed because one or more lines are too long
16
diligence/frontend/index.html
Normal file
16
diligence/frontend/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#1a1a2e" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<title>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>
|
||||
31
diligence/frontend/llms.txt
Normal file
31
diligence/frontend/llms.txt
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Diligence — Self-Hosted Fitness Rewards Platform
|
||||
|
||||
> Data sovereignty for people. Self-hosted fitness tracker with a points-based
|
||||
> behavioral economy and an MCP connector for AI agent integration.
|
||||
> MIT-licensed. Deploy with a single `docker compose up -d`.
|
||||
|
||||
Diligence is a self-hosted fitness web app built around a points-based reward
|
||||
system. Complete fitness activities and food logging to earn points, spend
|
||||
points to unlock configurable real-world rewards. Science-based onboarding
|
||||
(PAR-Q+, TTM stages of change, BREQ-2 motivation assessment).
|
||||
|
||||
## For AI Agents
|
||||
- MCP Server: /mcp (Streamable HTTP/SSE, 14 tools)
|
||||
- Agent Card: /.well-known/agent-card.json
|
||||
- Skills: /.well-known/skills/diligence-fitness/SKILL.md
|
||||
|
||||
## MCP Tools
|
||||
- get_context, get_today, get_week (status)
|
||||
- log_activity, log_food, search_food (tracking)
|
||||
- get_program_schedule (programs)
|
||||
- redeem_reward (rewards)
|
||||
- load_meal_plan, get_meal_plan, update_meal_compliance, get_plan_progress (meal plans)
|
||||
- configure_integration, get_integration_status (device sync)
|
||||
|
||||
## Technical
|
||||
- Source: https://github.com/diligenceworks/diligence (MIT)
|
||||
- Stack: FastAPI + PostgreSQL + React/Vite + FastMCP
|
||||
|
||||
## About
|
||||
Built by DiligenceWorks (https://diligenceworks.online) — adversarial AI
|
||||
deal-diligence platform for institutional investors.
|
||||
400
diligence/main.py
Normal file
400
diligence/main.py
Normal 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()
|
||||
24
diligence/models/__init__.py
Normal file
24
diligence/models/__init__.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from diligence.models.user import User
|
||||
from diligence.models.profile import UserProfile
|
||||
from diligence.models.program import Program
|
||||
from diligence.models.activity import ActivityLog
|
||||
from diligence.models.food import FoodLog
|
||||
from diligence.models.points import PointRule, DailyTarget
|
||||
from diligence.models.reward import Reward, RewardRedemption
|
||||
from diligence.models.oauth import OAuthToken
|
||||
from diligence.models.resource import Resource
|
||||
from diligence.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
|
||||
from diligence.models.support import SupportThread, SupportMessage
|
||||
from diligence.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
|
||||
from diligence.models.integration_config import IntegrationConfig
|
||||
from diligence.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
|
||||
|
||||
__all__ = [
|
||||
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
|
||||
"PointRule", "DailyTarget", "Reward", "RewardRedemption",
|
||||
"OAuthToken", "Resource",
|
||||
"ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog",
|
||||
"SupportThread", "SupportMessage",
|
||||
"NutritionGoal", "Fast", "ElectrolyteLog",
|
||||
"IntegrationConfig", "MealPlan", "MealPlanItem", "MealCompliance",
|
||||
]
|
||||
|
|
@ -2,17 +2,16 @@ from __future__ import annotations
|
|||
|
||||
import uuid
|
||||
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.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class ActivityLog(Base):
|
||||
__tablename__ = "activity_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
title: Mapped[str | None] = mapped_column(String(200))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
|
|
@ -20,12 +19,12 @@ class ActivityLog(Base):
|
|||
source: Mapped[str] = mapped_column(String(30), default="manual")
|
||||
external_id: Mapped[str | None] = mapped_column(String(100))
|
||||
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)
|
||||
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)
|
||||
metadata_json: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
|
||||
metadata_json: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_activity_log_user_date", "user_id", "activity_date"),
|
||||
|
|
@ -2,28 +2,27 @@ from __future__ import annotations
|
|||
|
||||
import uuid
|
||||
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.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class ProgramCatalog(Base):
|
||||
"""Shared program definitions — one per program, reusable by all users."""
|
||||
__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)
|
||||
slug: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
source_url: Mapped[str | None] = mapped_column(Text)
|
||||
duration_weeks: 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))
|
||||
category: Mapped[str | None] = mapped_column(String(50))
|
||||
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_error: Mapped[str | None] = mapped_column(Text)
|
||||
crawled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
|
@ -41,14 +40,14 @@ class CatalogWorkout(Base):
|
|||
"""Individual workout within a catalog program."""
|
||||
__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(
|
||||
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)
|
||||
day_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
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)
|
||||
rest_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
|
@ -63,14 +62,14 @@ class CrawlQueue(Base):
|
|||
"""Job queue for program research crawls."""
|
||||
__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(
|
||||
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)
|
||||
priority: Mapped[str] = mapped_column(String(10), default="low")
|
||||
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)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
|
@ -84,20 +83,20 @@ class WorkoutLog(Base):
|
|||
"""Per-user workout completion tracking against catalog workouts."""
|
||||
__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(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), nullable=False
|
||||
Uuid, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
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(
|
||||
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)
|
||||
completed_at: Mapped[datetime] = mapped_column(
|
||||
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)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
|
|
@ -3,17 +3,16 @@ from __future__ import annotations
|
|||
import uuid
|
||||
from datetime import datetime, date, timezone
|
||||
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.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class FoodLog(Base):
|
||||
__tablename__ = "food_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||
meal_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
food_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
brand: Mapped[str | None] = mapped_column(String(200))
|
||||
|
|
@ -30,7 +29,7 @@ class FoodLog(Base):
|
|||
food_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
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_data: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
off_data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_food_log_user_date", "user_id", "food_date"),
|
||||
|
|
@ -3,17 +3,16 @@ from __future__ import annotations
|
|||
|
||||
import uuid
|
||||
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.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class IntegrationConfig(Base):
|
||||
__tablename__ = "integration_configs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
config_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
config_value: Mapped[str] = mapped_column(Text, nullable=False) # Fernet encrypted
|
||||
|
|
@ -4,24 +4,23 @@ from __future__ import annotations
|
|||
import uuid
|
||||
from datetime import datetime, date, timezone
|
||||
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.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class MealPlan(Base):
|
||||
__tablename__ = "meal_plans"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
diet_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
daily_calories: 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_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)
|
||||
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||
|
|
@ -33,8 +32,8 @@ class MealPlan(Base):
|
|||
class MealPlanItem(Base):
|
||||
__tablename__ = "meal_plan_items"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), 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)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
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)
|
||||
meal_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
food_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
|
|
@ -54,12 +53,12 @@ class MealPlanItem(Base):
|
|||
class MealCompliance(Base):
|
||||
__tablename__ = "meal_compliance"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
plan_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("meal_plans.id"), nullable=False)
|
||||
plan_item_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, 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, nullable=True)
|
||||
compliance_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False) # followed, substituted, skipped
|
||||
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))
|
||||
|
|
@ -3,18 +3,17 @@ from __future__ import annotations
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
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.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class NutritionGoal(Base):
|
||||
"""Per-user macro + eating-window targets. One active per user."""
|
||||
__tablename__ = "nutrition_goals"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False, unique=True)
|
||||
|
||||
# Macro targets (grams / kcal)
|
||||
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."""
|
||||
__tablename__ = "fasts"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
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_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)
|
||||
|
||||
|
|
@ -75,8 +74,8 @@ class ElectrolyteLog(Base):
|
|||
"""Daily electrolyte dosing checklist — lightweight tracker."""
|
||||
__tablename__ = "electrolyte_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||
log_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
sodium_mg: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
|
@ -2,17 +2,16 @@ from __future__ import annotations
|
|||
|
||||
import uuid
|
||||
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.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class OAuthToken(Base):
|
||||
__tablename__ = "oauth_tokens"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
access_token: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
refresh_token: Mapped[str | None] = mapped_column(Text)
|
||||
|
|
@ -2,17 +2,16 @@ from __future__ import annotations
|
|||
|
||||
import uuid
|
||||
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.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class PointRule(Base):
|
||||
__tablename__ = "point_rules"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
description: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
points: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
|
@ -26,8 +25,8 @@ class PointRule(Base):
|
|||
class DailyTarget(Base):
|
||||
__tablename__ = "daily_targets"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
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)
|
||||
weekly_target_pts: Mapped[int] = mapped_column(Integer, default=500)
|
||||
weekly_bonus_pts: Mapped[int] = mapped_column(Integer, default=50)
|
||||
|
|
@ -3,17 +3,16 @@ from __future__ import annotations
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
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.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class UserProfile(Base):
|
||||
__tablename__ = "user_profiles"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False, unique=True)
|
||||
|
||||
# Phase 1
|
||||
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))
|
||||
|
||||
# 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_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)
|
||||
minutes_per_session: Mapped[int] = mapped_column(Integer, default=30)
|
||||
|
||||
|
|
@ -2,17 +2,16 @@ from __future__ import annotations
|
|||
|
||||
import uuid
|
||||
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.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class Program(Base):
|
||||
__tablename__ = "programs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
source: Mapped[str | None] = mapped_column(String(50))
|
||||
source_url: Mapped[str | None] = mapped_column(Text)
|
||||
|
|
@ -24,7 +23,7 @@ class Program(Base):
|
|||
|
||||
# v2: Link to catalog program
|
||||
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_day: Mapped[int] = mapped_column(Integer, default=1)
|
||||
|
|
@ -2,25 +2,24 @@ from __future__ import annotations
|
|||
|
||||
import uuid
|
||||
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.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class Resource(Base):
|
||||
__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)
|
||||
source: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
thumbnail_url: Mapped[str | None] = mapped_column(Text)
|
||||
goal_tags: Mapped[dict] = mapped_column(JSONB, default=list)
|
||||
activity_tags: Mapped[dict] = mapped_column(JSONB, default=list)
|
||||
goal_tags: Mapped[dict] = mapped_column(JSON, default=list)
|
||||
activity_tags: Mapped[dict] = mapped_column(JSON, default=list)
|
||||
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))
|
||||
duration_days: Mapped[int | None] = mapped_column(Integer)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
|
@ -2,17 +2,16 @@ from __future__ import annotations
|
|||
|
||||
import uuid
|
||||
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.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class Reward(Base):
|
||||
__tablename__ = "rewards"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
point_cost: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
|
@ -25,9 +24,9 @@ class Reward(Base):
|
|||
class RewardRedemption(Base):
|
||||
__tablename__ = "reward_redemptions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
reward_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("rewards.id"), nullable=False)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.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)
|
||||
redeemed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
redemption_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
|
|
@ -2,19 +2,18 @@ from __future__ import annotations
|
|||
|
||||
import uuid
|
||||
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.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class SupportThread(Base):
|
||||
"""One thread per user — private conversation with admin."""
|
||||
__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(
|
||||
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(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
|
|
@ -35,13 +34,13 @@ class SupportMessage(Base):
|
|||
"""Individual message within a support thread."""
|
||||
__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(
|
||||
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'
|
||||
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))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
|
|
@ -2,16 +2,15 @@ from __future__ import annotations
|
|||
|
||||
import uuid
|
||||
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.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
from diligence.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__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)
|
||||
display_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
|
@ -6,12 +6,12 @@ from fastapi import APIRouter, Depends, Query
|
|||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.activity import ActivityLog
|
||||
from app.schemas.activity import ActivityCreate, ActivityResponse
|
||||
from app.utils.auth import get_current_user
|
||||
from app.services.points_engine import log_activity_with_points
|
||||
from diligence.database import get_db
|
||||
from diligence.models.user import User
|
||||
from diligence.models.activity import ActivityLog
|
||||
from diligence.schemas.activity import ActivityCreate, ActivityResponse
|
||||
from diligence.utils.auth import get_current_user
|
||||
from diligence.services.points_engine import log_activity_with_points
|
||||
|
||||
router = APIRouter(prefix="/api/activities", tags=["activities"])
|
||||
|
||||
|
|
@ -7,10 +7,10 @@ from fastapi import APIRouter, Depends, Request
|
|||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.utils.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services import ai_provider
|
||||
from diligence.database import get_db
|
||||
from diligence.utils.auth import get_current_user
|
||||
from diligence.models.user import User
|
||||
from diligence.services import ai_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/ai", tags=["AI Coaching"])
|
||||
|
|
@ -7,12 +7,12 @@ from fastapi.responses import JSONResponse
|
|||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.profile import UserProfile
|
||||
from app.models.points import PointRule, DailyTarget, DEFAULT_POINT_RULES, TTM_DAILY_TARGETS
|
||||
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse
|
||||
from app.utils.auth import hash_password, verify_password, create_access_token, get_current_user
|
||||
from diligence.database import get_db
|
||||
from diligence.models.user import User
|
||||
from diligence.models.profile import UserProfile
|
||||
from diligence.models.points import PointRule, DailyTarget, DEFAULT_POINT_RULES, TTM_DAILY_TARGETS
|
||||
from diligence.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserResponse
|
||||
from diligence.utils.auth import hash_password, verify_password, create_access_token, get_current_user
|
||||
|
||||
logger = logging.getLogger("fitness-rewards")
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
|
@ -11,13 +11,13 @@ from sqlalchemy import select, func
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.program import Program
|
||||
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
|
||||
from app.services.program_research import slugify, find_urls_for_program
|
||||
from app.services.points_engine import log_activity_with_points
|
||||
from app.utils.auth import get_current_user
|
||||
from diligence.database import get_db
|
||||
from diligence.models.user import User
|
||||
from diligence.models.program import Program
|
||||
from diligence.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
|
||||
from diligence.services.program_research import slugify, find_urls_for_program
|
||||
from diligence.services.points_engine import log_activity_with_points
|
||||
from diligence.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/programs", tags=["programs-v2"])
|
||||
|
||||
|
|
@ -7,12 +7,12 @@ from fastapi import APIRouter, Depends, Query, HTTPException
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.food import FoodLog
|
||||
from app.schemas.food import FoodCreate, FoodSearchResult
|
||||
from app.utils.auth import get_current_user
|
||||
from app.services.food_lookup import lookup_barcode, search_food
|
||||
from diligence.database import get_db
|
||||
from diligence.models.user import User
|
||||
from diligence.models.food import FoodLog
|
||||
from diligence.schemas.food import FoodCreate, FoodSearchResult
|
||||
from diligence.utils.auth import get_current_user
|
||||
from diligence.services.food_lookup import lookup_barcode, search_food
|
||||
|
||||
router = APIRouter(prefix="/api/food", tags=["food"])
|
||||
|
||||
|
|
@ -8,14 +8,14 @@ from fastapi.responses import RedirectResponse
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.oauth import OAuthToken
|
||||
from app.utils.auth import get_current_user
|
||||
from app.services.strava_sync import (
|
||||
from diligence.database import get_db
|
||||
from diligence.models.user import User
|
||||
from diligence.models.oauth import OAuthToken
|
||||
from diligence.utils.auth import get_current_user
|
||||
from diligence.services.strava_sync import (
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ async def integration_status(
|
|||
# --- Strava ---
|
||||
@router.get("/strava/auth")
|
||||
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
|
||||
state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
|
||||
return {"auth_url": get_strava_auth_url(state_token)}
|
||||
|
|
@ -52,7 +52,7 @@ async def strava_callback(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from jose import JWTError, jwt as jose_jwt
|
||||
from app.config import get_settings
|
||||
from diligence.config import get_settings
|
||||
_settings = get_settings()
|
||||
try:
|
||||
payload = jose_jwt.decode(state, _settings.secret_key, algorithms=["HS256"])
|
||||
|
|
@ -85,7 +85,7 @@ async def strava_callback(
|
|||
db.add(token)
|
||||
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")
|
||||
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ async def strava_sync(
|
|||
# --- Polar ---
|
||||
@router.get("/polar/auth")
|
||||
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
|
||||
state_token = create_access_token(str(user.id), expires_delta=timedelta(minutes=10))
|
||||
return {"auth_url": get_polar_auth_url(state_token)}
|
||||
|
|
@ -114,7 +114,7 @@ async def polar_callback(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from jose import JWTError, jwt as jose_jwt
|
||||
from app.config import get_settings
|
||||
from diligence.config import get_settings
|
||||
_settings = get_settings()
|
||||
try:
|
||||
payload = jose_jwt.decode(state, _settings.secret_key, algorithms=["HS256"])
|
||||
|
|
@ -144,7 +144,7 @@ async def polar_callback(
|
|||
db.add(token)
|
||||
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")
|
||||
|
||||
|
||||
|
|
@ -178,10 +178,10 @@ async def disconnect(
|
|||
# --- Dynamic Integration Config (v3) ---
|
||||
|
||||
from pydantic import BaseModel
|
||||
from app.models.integration_config import IntegrationConfig
|
||||
from app.services.provider_registry import PROVIDER_REGISTRY
|
||||
from app.services.crypto import encrypt_value, decrypt_value
|
||||
from app.config import settings
|
||||
from diligence.models.integration_config import IntegrationConfig
|
||||
from diligence.services.provider_registry import PROVIDER_REGISTRY
|
||||
from diligence.services.crypto import encrypt_value, decrypt_value
|
||||
from diligence.config import settings
|
||||
|
||||
|
||||
class ConfigureRequest(BaseModel):
|
||||
|
|
@ -10,10 +10,10 @@ from pydantic import BaseModel
|
|||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
|
||||
from app.utils.auth import get_current_user
|
||||
from diligence.database import get_db
|
||||
from diligence.models.user import User
|
||||
from diligence.models.meal_plan import MealPlan, MealPlanItem, MealCompliance
|
||||
from diligence.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"])
|
||||
|
||||
|
|
@ -10,15 +10,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
|
||||
from app.models.food import FoodLog
|
||||
from app.schemas.nutrition import (
|
||||
from diligence.database import get_db
|
||||
from diligence.models.user import User
|
||||
from diligence.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
|
||||
from diligence.models.food import FoodLog
|
||||
from diligence.schemas.nutrition import (
|
||||
NutritionGoalIn, NutritionGoalOut, FastStart, FastUpdate, FastOut, ElectrolyteIn,
|
||||
)
|
||||
from app.utils.auth import get_current_user
|
||||
from app.services.points_engine import log_activity_with_points
|
||||
from diligence.utils.auth import get_current_user
|
||||
from diligence.services.points_engine import log_activity_with_points
|
||||
|
||||
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
||||
|
||||
|
|
@ -6,13 +6,13 @@ from fastapi import APIRouter, Depends
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.profile import UserProfile
|
||||
from app.models.points import DailyTarget, TTM_DAILY_TARGETS
|
||||
from app.schemas.onboarding import Phase1Request, Phase2Request, OnboardingStatusResponse
|
||||
from app.utils.auth import get_current_user
|
||||
from app.services.resource_matcher import get_recommendations
|
||||
from diligence.database import get_db
|
||||
from diligence.models.user import User
|
||||
from diligence.models.profile import UserProfile
|
||||
from diligence.models.points import DailyTarget, TTM_DAILY_TARGETS
|
||||
from diligence.schemas.onboarding import Phase1Request, Phase2Request, OnboardingStatusResponse
|
||||
from diligence.utils.auth import get_current_user
|
||||
from diligence.services.resource_matcher import get_recommendations
|
||||
|
||||
router = APIRouter(prefix="/api/onboarding", tags=["onboarding"])
|
||||
|
||||
|
|
@ -7,14 +7,14 @@ from fastapi import APIRouter, Depends, Query, HTTPException
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.points import PointRule, DailyTarget
|
||||
from app.models.reward import Reward
|
||||
from app.schemas.points import PointRuleUpdate, DailyTargetUpdate
|
||||
from app.schemas.reward import RewardCreate, RewardRedeemRequest
|
||||
from app.utils.auth import get_current_user
|
||||
from app.services.points_engine import get_today_status, get_weekly_summary, redeem_reward
|
||||
from diligence.database import get_db
|
||||
from diligence.models.user import User
|
||||
from diligence.models.points import PointRule, DailyTarget
|
||||
from diligence.models.reward import Reward
|
||||
from diligence.schemas.points import PointRuleUpdate, DailyTargetUpdate
|
||||
from diligence.schemas.reward import RewardCreate, RewardRedeemRequest
|
||||
from diligence.utils.auth import get_current_user
|
||||
from diligence.services.points_engine import get_today_status, get_weekly_summary, redeem_reward
|
||||
|
||||
router = APIRouter(prefix="/api/points", tags=["points"])
|
||||
|
||||
|
|
@ -7,11 +7,11 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.program import Program
|
||||
from app.models.activity import ActivityLog
|
||||
from app.utils.auth import get_current_user
|
||||
from diligence.database import get_db
|
||||
from diligence.models.user import User
|
||||
from diligence.models.program import Program
|
||||
from diligence.models.activity import ActivityLog
|
||||
from diligence.utils.auth import get_current_user
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api/programs", tags=["programs"])
|
||||
|
|
@ -13,13 +13,13 @@ from sqlalchemy import select, func, update
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.support import SupportThread, SupportMessage
|
||||
from app.models.activity import ActivityLog
|
||||
from app.services.points_engine import get_active_program, get_today_status
|
||||
from app.utils.auth import get_current_user
|
||||
from app.config import settings
|
||||
from diligence.database import get_db
|
||||
from diligence.models.user import User
|
||||
from diligence.models.support import SupportThread, SupportMessage
|
||||
from diligence.models.activity import ActivityLog
|
||||
from diligence.services.points_engine import get_active_program, get_today_status
|
||||
from diligence.utils.auth import get_current_user
|
||||
from diligence.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/support", tags=["support"])
|
||||
|
|
@ -301,7 +301,7 @@ async def list_threads(
|
|||
out = []
|
||||
for t in threads:
|
||||
# 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(
|
||||
select(UserModel).where(UserModel.id == t.user_id)
|
||||
)
|
||||
|
|
@ -360,7 +360,7 @@ async def get_admin_thread(
|
|||
await db.flush()
|
||||
|
||||
# Get user info
|
||||
from app.models.user import User as UserModel
|
||||
from diligence.models.user import User as UserModel
|
||||
user_result = await db.execute(
|
||||
select(UserModel).where(UserModel.id == thread.user_id)
|
||||
)
|
||||
|
|
@ -18,9 +18,9 @@ import httpx
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services.crypto import decrypt_value
|
||||
from app.models.integration_config import IntegrationConfig
|
||||
from diligence.config import get_settings
|
||||
from diligence.services.crypto import decrypt_value
|
||||
from diligence.models.integration_config import IntegrationConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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:
|
||||
"""Build live context string from user's current state."""
|
||||
from app.models.user import User
|
||||
from app.models.profile import UserProfile
|
||||
from diligence.models.user import User
|
||||
from diligence.models.profile import UserProfile
|
||||
|
||||
context_parts = []
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ async def build_context(db: AsyncSession, user_id) -> str:
|
|||
|
||||
# Today's points (best effort)
|
||||
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)
|
||||
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'}")
|
||||
|
|
@ -8,9 +8,9 @@ from datetime import datetime, timezone
|
|||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import async_session
|
||||
from app.models.catalog import CrawlQueue
|
||||
from app.services.program_research import process_crawl_job
|
||||
from diligence.database import async_session
|
||||
from diligence.models.catalog import CrawlQueue
|
||||
from diligence.services.program_research import process_crawl_job
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -17,11 +17,11 @@ import httpx
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.oauth import OAuthToken
|
||||
from app.models.integration_config import IntegrationConfig
|
||||
from app.services.crypto import decrypt_value
|
||||
from app.services.points_engine import log_activity_with_points
|
||||
from diligence.config import get_settings
|
||||
from diligence.models.oauth import OAuthToken
|
||||
from diligence.models.integration_config import IntegrationConfig
|
||||
from diligence.services.crypto import decrypt_value
|
||||
from diligence.services.points_engine import log_activity_with_points
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
|
@ -146,7 +146,7 @@ class DeviceSyncBase(abc.ABC):
|
|||
|
||||
Deduplicates by (user_id, provider, external_id).
|
||||
"""
|
||||
from app.models.activity import ActivityLog
|
||||
from diligence.models.activity import ActivityLog
|
||||
|
||||
if external_id:
|
||||
existing = await db.execute(
|
||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||
|
||||
"""Open Food Facts API client for barcode scanning and food search."""
|
||||
import httpx
|
||||
from app.schemas.food import FoodSearchResult
|
||||
from diligence.schemas.food import FoodSearchResult
|
||||
|
||||
OFF_BASE = "https://world.openfoodfacts.org"
|
||||
USER_AGENT = "Diligence/1.0"
|
||||
|
|
@ -6,11 +6,11 @@ from datetime import date, timedelta
|
|||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.activity import ActivityLog
|
||||
from app.models.points import PointRule, DailyTarget
|
||||
from app.models.reward import Reward, RewardRedemption
|
||||
from app.models.program import Program
|
||||
from app.utils.dates import get_week_boundaries
|
||||
from diligence.models.activity import ActivityLog
|
||||
from diligence.models.points import PointRule, DailyTarget
|
||||
from diligence.models.reward import Reward, RewardRedemption
|
||||
from diligence.models.program import Program
|
||||
from diligence.utils.dates import get_week_boundaries
|
||||
|
||||
|
||||
async def get_daily_points_earned(db: AsyncSession, user_id: uuid.UUID, d: date) -> int:
|
||||
|
|
@ -7,10 +7,10 @@ import httpx
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.oauth import OAuthToken
|
||||
from app.models.activity import ActivityLog
|
||||
from app.services.points_engine import log_activity_with_points
|
||||
from diligence.config import get_settings
|
||||
from diligence.models.oauth import OAuthToken
|
||||
from diligence.models.activity import ActivityLog
|
||||
from diligence.services.points_engine import log_activity_with_points
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
|
@ -12,8 +12,8 @@ import httpx
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue
|
||||
from app.config import settings
|
||||
from diligence.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue
|
||||
from diligence.config import settings
|
||||
|
||||
|
||||
# ── Known program sources (skip search for these) ──────────────────────────
|
||||
|
|
@ -4,8 +4,8 @@ from __future__ import annotations
|
|||
import uuid
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.resource import Resource
|
||||
from app.models.profile import UserProfile
|
||||
from diligence.models.resource import Resource
|
||||
from diligence.models.profile import UserProfile
|
||||
|
||||
|
||||
# Equipment categories that satisfy each resource requirement
|
||||
|
|
@ -7,10 +7,10 @@ import httpx
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.oauth import OAuthToken
|
||||
from app.models.activity import ActivityLog
|
||||
from app.services.points_engine import log_activity_with_points
|
||||
from diligence.config import get_settings
|
||||
from diligence.models.oauth import OAuthToken
|
||||
from diligence.models.activity import ActivityLog
|
||||
from diligence.services.points_engine import log_activity_with_points
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
|
@ -9,8 +9,8 @@ from fastapi.security import OAuth2PasswordBearer
|
|||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from diligence.config import get_settings
|
||||
from diligence.database import get_db
|
||||
|
||||
settings = get_settings()
|
||||
ALGORITHM = "HS256" # Hardcoded — not configurable for security
|
||||
|
|
@ -44,7 +44,7 @@ async def get_current_user(
|
|||
if not token:
|
||||
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)
|
||||
if settings.api_token and token == settings.api_token:
|
||||
|
|
@ -15,10 +15,13 @@ services:
|
|||
start_period: 10s
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards
|
||||
- BASE_URL=${BASE_URL:-http://localhost}
|
||||
- API_TOKEN=${API_TOKEN:-}
|
||||
depends_on:
|
||||
|
|
|
|||
65
pyproject.toml
Normal file
65
pyproject.toml
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "diligence"
|
||||
version = "2.0.0"
|
||||
description = "Self-hosted fitness rewards platform with AI agent integration"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
{name = "DiligenceWorks Pte. Ltd.", email = "hello@diligenceworks.online"},
|
||||
]
|
||||
keywords = ["fitness", "self-hosted", "mcp", "ai-agent", "rewards"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"sqlalchemy[asyncio]>=2.0.30",
|
||||
"aiosqlite>=0.20.0",
|
||||
"pydantic>=2.10.0",
|
||||
"pydantic-settings>=2.7.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"bcrypt>=4.2.0",
|
||||
"httpx>=0.27.0",
|
||||
"python-multipart>=0.0.18",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
postgres = [
|
||||
"asyncpg>=0.30.0",
|
||||
]
|
||||
mcp = [
|
||||
"mcp[cli]>=1.0.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.8.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
diligence = "diligence.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/DiligenceWorks/Diligence"
|
||||
Documentation = "https://github.com/DiligenceWorks/Diligence#readme"
|
||||
Repository = "https://github.com/DiligenceWorks/Diligence"
|
||||
Issues = "https://github.com/DiligenceWorks/Diligence/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["diligence*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
diligence = ["frontend/**/*"]
|
||||
89
setup.ps1
89
setup.ps1
|
|
@ -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) {
|
||||
Write-Host "`e[33m.env already exists. Delete it first to regenerate.`e[0m"
|
||||
$Secret = New-RandomHex
|
||||
$Token = New-RandomHex
|
||||
|
||||
# Detect mode — Docker if docker compose is available and docker-compose.yml exists
|
||||
$DockerAvailable = $false
|
||||
try { docker compose version 2>$null | Out-Null; $DockerAvailable = $true } catch {}
|
||||
|
||||
if ($DockerAvailable -and (Test-Path "docker-compose.yml")) {
|
||||
Write-Host " Mode: Docker (PostgreSQL)"
|
||||
Write-Host ""
|
||||
|
||||
if (Test-Path ".env") {
|
||||
Write-Host " .env already exists. Delete it first to regenerate."
|
||||
exit 1
|
||||
}
|
||||
|
||||
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 "Next steps:"
|
||||
Write-Host " docker compose up -d"
|
||||
Write-Host " Open http://localhost"
|
||||
Write-Host " Register your account"
|
||||
Write-Host " Configure integrations via Settings or your AI agent"
|
||||
Write-Host ""
|
||||
Write-Host "MCP agent connection:"
|
||||
Write-Host " MCP agent connection:"
|
||||
Write-Host " URL: http://localhost:3001/sse"
|
||||
Write-Host " Header: Authorization: Bearer $apiToken"
|
||||
Write-Host " Header: Authorization: Bearer $Token"
|
||||
Write-Host ""
|
||||
|
|
|
|||
95
setup.sh
95
setup.sh
|
|
@ -1,26 +1,81 @@
|
|||
#!/bin/bash
|
||||
echo "💪 Diligence — Setup"
|
||||
set -e
|
||||
echo ""
|
||||
echo " Diligence Setup"
|
||||
echo ""
|
||||
|
||||
if [ -f .env ]; then
|
||||
echo ".env already exists. Delete it first to regenerate."
|
||||
exit 1
|
||||
# Detect install mode
|
||||
if command -v docker compose &> /dev/null && [ -f docker-compose.yml ]; then
|
||||
MODE="docker"
|
||||
else
|
||||
MODE="local"
|
||||
fi
|
||||
|
||||
SECRET=$(openssl rand -hex 32)
|
||||
TOKEN=$(openssl rand -hex 32)
|
||||
cp .env.example .env
|
||||
sed -i.bak "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env
|
||||
sed -i.bak "s/^API_TOKEN=.*/API_TOKEN=${TOKEN}/" .env
|
||||
rm -f .env.bak
|
||||
# Generate secrets
|
||||
SECRET=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||
TOKEN=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||
|
||||
if [ "$MODE" = "docker" ]; then
|
||||
echo " Mode: Docker (PostgreSQL)"
|
||||
echo ""
|
||||
|
||||
if [ -f .env ]; then
|
||||
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 "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}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue