mirror of
https://github.com/sstent/foodplanner.git
synced 2026-01-25 03:01:35 +00:00
Migrate to PostgreSQL and add Config Status page
This commit is contained in:
63
MIGRATION.md
Normal file
63
MIGRATION.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Database Migration Guide
|
||||||
|
|
||||||
|
This guide outlines the offline workflow to migrate your `meal_planner` data from SQLite to PostgreSQL.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Docker Compose installed.
|
||||||
|
- The application running (or capable of running) via `docker-compose`.
|
||||||
|
|
||||||
|
## Migration Steps
|
||||||
|
|
||||||
|
### 1. Backup your SQLite Database
|
||||||
|
First, create a safety copy of your current database.
|
||||||
|
```bash
|
||||||
|
cp data/meal_planner.db meal_planner_backup.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Stop the Application
|
||||||
|
Stop the running application container to ensure no new data is written.
|
||||||
|
```bash
|
||||||
|
docker-compose stop foodtracker
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start PostgreSQL
|
||||||
|
Ensure the new PostgreSQL service is running.
|
||||||
|
```bash
|
||||||
|
docker-compose up -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run the Migration
|
||||||
|
Use a temporary container to run the migration script. We mount your backup file and connect to the postgres service.
|
||||||
|
```bash
|
||||||
|
# Syntax: python migrate_to_postgres.py --sqlite-path <path_to_db> --pg-url <postgres_url>
|
||||||
|
|
||||||
|
docker-compose run --rm \
|
||||||
|
-v $(pwd)/meal_planner_backup.db:/backup.db \
|
||||||
|
-v $(pwd)/migrate_to_postgres.py:/app/migrate_to_postgres.py \
|
||||||
|
foodtracker \
|
||||||
|
python migrate_to_postgres.py \
|
||||||
|
--sqlite-path /backup.db \
|
||||||
|
--pg-url postgresql://user:password@postgres/meal_planner
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Update Configuration
|
||||||
|
Edit `docker-compose.yml` to switch the active database.
|
||||||
|
1. Comment out the SQLite `DATABASE_URL`.
|
||||||
|
2. Uncomment the PostgreSQL `DATABASE_URL`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
# - DATABASE_URL=sqlite:////app/data/meal_planner.db
|
||||||
|
- DATABASE_URL=postgresql://user:password@postgres/meal_planner
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Restart the Application
|
||||||
|
Rebuild and start the application to use the new database.
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build foodtracker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
1. Log in to the application.
|
||||||
|
2. Verify your Foods, Meals, and Plans are present.
|
||||||
|
3. Check `docker logs foodplanner-foodtracker-1` to ensure no database connection errors.
|
||||||
@@ -187,6 +187,45 @@ async def restore_backup(request: Request, backup_file: str = Form(...)):
|
|||||||
# You might want to add some user-facing error feedback here
|
# You might want to add some user-facing error feedback here
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Redirect back to the backups page
|
||||||
# Redirect back to the backups page
|
# Redirect back to the backups page
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
return RedirectResponse(url="/admin/backups", status_code=303)
|
return RedirectResponse(url="/admin/backups", status_code=303)
|
||||||
|
|
||||||
|
@router.get("/admin/config-status", response_class=HTMLResponse)
|
||||||
|
async def admin_config_status_page(request: Request):
|
||||||
|
"""Display current system configuration and database status."""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
# Analyze DATABASE_URL securely
|
||||||
|
db_url = DATABASE_URL
|
||||||
|
masked_url = db_url
|
||||||
|
db_host = "Unknown"
|
||||||
|
db_type = "Unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Simple parsing logic to avoid exposing credentials if urlparse fails or acts unexpectedly
|
||||||
|
if "sqlite" in db_url:
|
||||||
|
db_type = "SQLite"
|
||||||
|
db_host = db_url.replace("sqlite:///", "")
|
||||||
|
masked_url = "sqlite:///" + db_host
|
||||||
|
elif "postgresql" in db_url:
|
||||||
|
db_type = "PostgreSQL"
|
||||||
|
parsed = urlparse(db_url)
|
||||||
|
db_host = parsed.hostname
|
||||||
|
# Mask password
|
||||||
|
if parsed.password:
|
||||||
|
masked_url = db_url.replace(parsed.password, "******")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error parsing database URL: {e}")
|
||||||
|
masked_url = "Error parsing URL"
|
||||||
|
|
||||||
|
config_data = {
|
||||||
|
"database_url": db_url,
|
||||||
|
"database_url_masked": masked_url,
|
||||||
|
"database_type": db_type,
|
||||||
|
"database_host": db_host,
|
||||||
|
"debug": True
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates.TemplateResponse(request, "admin/config.html", {"request": request, "config": config_data})
|
||||||
@@ -4,11 +4,13 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8999:8999"
|
- "8999:8999"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite:////app/data/meal_planner.db
|
#- DATABASE_URL=sqlite:////app/data/meal_planner.db
|
||||||
|
- DATABASE_URL=postgresql://postgres:postgres@master.postgres.service.dc1.consul/meal_planner
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
volumes:
|
volumes:
|
||||||
- ./alembic:/app/alembic
|
- ./alembic:/app/alembic
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
- ./backups:/app/backups
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
- ./templates:/app/templates
|
- ./templates:/app/templates
|
||||||
- ./main.py:/app/main.py
|
- ./main.py:/app/main.py
|
||||||
|
|||||||
150
migrate_to_postgres.py
Normal file
150
migrate_to_postgres.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from sqlalchemy import create_engine, text, inspect
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
# Import models to ensure simple table discovery if needed,
|
||||||
|
# though we will mostly work with raw tables or inspection.
|
||||||
|
from app.database import Base, Food, Meal, MealFood, Plan, Template, TemplateMeal, WeeklyMenu, WeeklyMenuDay, TrackedDay, TrackedMeal, TrackedMealFood, FitbitConfig, WeightLog
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description='Migrate data from SQLite to PostgreSQL')
|
||||||
|
parser.add_argument('--sqlite-path', help='Path to source SQLite database file', default=os.getenv('SQLITE_PATH', '/app/data/meal_planner.db'))
|
||||||
|
parser.add_argument('--pg-url', help='PostgreSQL connection URL', default=os.getenv('PG_DATABASE_URL'))
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
# Source: SQLite
|
||||||
|
sqlite_path = args.sqlite_path
|
||||||
|
sqlite_url = f"sqlite:///{sqlite_path}"
|
||||||
|
|
||||||
|
# Destination: Postgres
|
||||||
|
if args.pg_url:
|
||||||
|
pg_url = args.pg_url
|
||||||
|
else:
|
||||||
|
# update this if running externally
|
||||||
|
pg_user = os.getenv('POSTGRES_USER', 'user')
|
||||||
|
pg_password = os.getenv('POSTGRES_PASSWORD', 'password')
|
||||||
|
pg_host = os.getenv('POSTGRES_HOST', 'postgres')
|
||||||
|
pg_db = os.getenv('POSTGRES_DB', 'meal_planner')
|
||||||
|
pg_url = f"postgresql://{pg_user}:{pg_password}@{pg_host}/{pg_db}"
|
||||||
|
|
||||||
|
logger.info(f"Source SQLite: {sqlite_url}")
|
||||||
|
logger.info(f"Destination Postgres: {pg_url}")
|
||||||
|
|
||||||
|
# Create Engines
|
||||||
|
try:
|
||||||
|
sqlite_engine = create_engine(sqlite_url)
|
||||||
|
pg_engine = create_engine(pg_url)
|
||||||
|
|
||||||
|
# Test connections
|
||||||
|
with sqlite_engine.connect() as conn:
|
||||||
|
pass
|
||||||
|
logger.info("Connected to SQLite.")
|
||||||
|
|
||||||
|
with pg_engine.connect() as conn:
|
||||||
|
pass
|
||||||
|
logger.info("Connected to Postgres.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to connect to databases: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create tables in Postgres if they don't exist
|
||||||
|
# Using the Base metadata from the app
|
||||||
|
logger.info("Creating tables in Postgres...")
|
||||||
|
Base.metadata.drop_all(pg_engine) # Clean start to avoid conflicts
|
||||||
|
Base.metadata.create_all(pg_engine)
|
||||||
|
logger.info("Tables created.")
|
||||||
|
|
||||||
|
# Define table order to respect Foreign Keys
|
||||||
|
tables_ordered = [
|
||||||
|
'foods',
|
||||||
|
'meals',
|
||||||
|
'meal_foods',
|
||||||
|
'templates',
|
||||||
|
'template_meals',
|
||||||
|
'weekly_menus',
|
||||||
|
'weekly_menu_days',
|
||||||
|
'plans',
|
||||||
|
'tracked_days',
|
||||||
|
'tracked_meals',
|
||||||
|
'tracked_meal_foods',
|
||||||
|
'fitbit_config',
|
||||||
|
'weight_logs'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Migration Loop
|
||||||
|
with sqlite_engine.connect() as sqlite_conn, pg_engine.connect() as pg_conn:
|
||||||
|
for table_name in tables_ordered:
|
||||||
|
logger.info(f"Migrating table: {table_name}")
|
||||||
|
|
||||||
|
# Read from SQLite
|
||||||
|
try:
|
||||||
|
# Use raw SQL to get all data, handling potential missing tables gracefully if app changed
|
||||||
|
result = sqlite_conn.execute(text(f"SELECT * FROM {table_name}"))
|
||||||
|
rows = result.fetchall()
|
||||||
|
keys = result.keys()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
logger.info(f" No data in {table_name}, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Insert into Postgres
|
||||||
|
# We simply create a list of dicts
|
||||||
|
data = [dict(zip(keys, row)) for row in rows]
|
||||||
|
|
||||||
|
# Setup insert statement
|
||||||
|
# We use SQLAlchemy core to make it db-agnostic enough
|
||||||
|
table_obj = Base.metadata.tables[table_name]
|
||||||
|
|
||||||
|
pg_conn.execute(table_obj.insert(), data)
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
logger.info(f" Migrated {len(rows)} rows.")
|
||||||
|
|
||||||
|
# Reset Sequence for Serial ID columns
|
||||||
|
# Postgres sequences usually named table_id_seq
|
||||||
|
if 'id' in keys:
|
||||||
|
# Find max id
|
||||||
|
max_id = max(row[0] for row in rows) # Assuming 'id' is first or we can look it up.
|
||||||
|
# Safer:
|
||||||
|
max_id_val = 0
|
||||||
|
for d in data:
|
||||||
|
if d['id'] > max_id_val:
|
||||||
|
max_id_val = d['id']
|
||||||
|
|
||||||
|
if max_id_val > 0:
|
||||||
|
seq_name = f"{table_name}_id_seq"
|
||||||
|
# Check if sequence exists (it should for Serial)
|
||||||
|
try:
|
||||||
|
pg_conn.execute(text(f"SELECT setval('{seq_name}', {max_id_val})"))
|
||||||
|
pg_conn.commit()
|
||||||
|
logger.info(f" Sequence {seq_name} reset to {max_id_val}")
|
||||||
|
except Exception as seq_err:
|
||||||
|
logger.warn(f" Could not reset sequence {seq_name} (might not exist): {seq_err}")
|
||||||
|
pg_conn.rollback()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Check for "no such table" specific error which is common if a feature isn't used
|
||||||
|
if "no such table" in str(e):
|
||||||
|
logger.warning(f" Table {table_name} not found in source SQLite. Skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.error(f"Error migrating {table_name}: {e}")
|
||||||
|
pg_conn.rollback()
|
||||||
|
# Decide whether to stop or continue. Stopping is safer.
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Migration completed successfully.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
98
planner.nomad
Normal file
98
planner.nomad
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
job "foodplanner" {
|
||||||
|
datacenters = ["dc1"]
|
||||||
|
|
||||||
|
type = "service"
|
||||||
|
|
||||||
|
group "app" {
|
||||||
|
count = 1
|
||||||
|
|
||||||
|
network {
|
||||||
|
port "http" {
|
||||||
|
to = 8999
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service {
|
||||||
|
name = "foodplanner"
|
||||||
|
port = "http"
|
||||||
|
|
||||||
|
check {
|
||||||
|
type = "http"
|
||||||
|
path = "/"
|
||||||
|
interval = "10s"
|
||||||
|
timeout = "2s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prestart restore task
|
||||||
|
task "restore" {
|
||||||
|
driver = "docker"
|
||||||
|
lifecycle {
|
||||||
|
hook = "prestart"
|
||||||
|
sidecar = false
|
||||||
|
}
|
||||||
|
config {
|
||||||
|
# image = "litestream/litestream:latest"
|
||||||
|
image = "litestream/litestream:0.3"
|
||||||
|
args = [
|
||||||
|
"restore",
|
||||||
|
# "-if-replica-exists",
|
||||||
|
#"-if-db-not-exists",
|
||||||
|
"-o", "/alloc/tmp/meal_planner.db",
|
||||||
|
"sftp://root:odroid@192.168.4.63/mnt/Shares/litestream/foodplanner.db"
|
||||||
|
]
|
||||||
|
volumes = [
|
||||||
|
"/opt/nomad/data:/data"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task "app" {
|
||||||
|
driver = "docker"
|
||||||
|
|
||||||
|
config {
|
||||||
|
image = "ghcr.io/sstent/foodplanner:main"
|
||||||
|
ports = ["http"]
|
||||||
|
|
||||||
|
# Mount the SQLite database file to persist data
|
||||||
|
# Adjust the source path as needed for your environment
|
||||||
|
volumes = [
|
||||||
|
"/mnt/Public/configs/FoodPlanner_backups:/app/backups/",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
DATABASE_PATH = "/alloc/tmp"
|
||||||
|
DATABASE_URL = "sqlite:////alloc/tmp/meal_planner.db"
|
||||||
|
}
|
||||||
|
resources {
|
||||||
|
cpu = 500
|
||||||
|
memory = 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
# Restart policy
|
||||||
|
restart {
|
||||||
|
attempts = 3
|
||||||
|
interval = "10m"
|
||||||
|
delay = "15s"
|
||||||
|
mode = "fail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Litestream sidecar for continuous replication
|
||||||
|
task "litestream" {
|
||||||
|
driver = "docker"
|
||||||
|
lifecycle {
|
||||||
|
hook = "poststart" # runs after main task starts
|
||||||
|
sidecar = true
|
||||||
|
}
|
||||||
|
config {
|
||||||
|
# image = "litestream/litestream:0.5.0-test.10"
|
||||||
|
image = "litestream/litestream:0.3"
|
||||||
|
args = [
|
||||||
|
"replicate",
|
||||||
|
"/alloc/tmp/meal_planner.db",
|
||||||
|
"sftp://root:odroid@192.168.4.63/mnt/Shares/litestream/foodplanner.db"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ starlette==0.37.2
|
|||||||
anyio==4.4.0
|
anyio==4.4.0
|
||||||
uvicorn[standard]==0.24.0
|
uvicorn[standard]==0.24.0
|
||||||
sqlalchemy>=2.0.24
|
sqlalchemy>=2.0.24
|
||||||
#psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
python-multipart>=0.0.7
|
python-multipart>=0.0.7
|
||||||
jinja2==3.1.2
|
jinja2==3.1.2
|
||||||
openfoodfacts>=0.2.0
|
openfoodfacts>=0.2.0
|
||||||
|
|||||||
73
templates/admin/config.html
Normal file
73
templates/admin/config.html
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends "admin/index.html" %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">System Configuration Status</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Current system environment and database connection details.</p>
|
||||||
|
|
||||||
|
<table class="table table-striped table-bordered">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 30%">Setting</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Database Type</strong></td>
|
||||||
|
<td>
|
||||||
|
{% if 'sqlite' in config.database_url %}
|
||||||
|
<span class="badge bg-secondary">SQLite</span>
|
||||||
|
{% elif 'postgresql' in config.database_url %}
|
||||||
|
<span class="badge bg-primary">PostgreSQL</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark">{{ config.database_type }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Connection URL</strong></td>
|
||||||
|
<td><code>{{ config.database_url_masked }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Database Host/Path</strong></td>
|
||||||
|
<td>{{ config.database_host }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Environment</strong></td>
|
||||||
|
<td>
|
||||||
|
{% if config.debug %}
|
||||||
|
<span class="badge bg-warning text-dark">Debug Mode</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success">Production</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-3">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
{% if 'sqlite' in config.database_url %}
|
||||||
|
Running in portable SQLite mode. To switch to PostgreSQL, please refer to the migration guide.
|
||||||
|
{% else %}
|
||||||
|
Running in PostgreSQL mode. Database is hosted at <strong>{{ config.database_host }}</strong>.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Activate the correct tab
|
||||||
|
const tabLink = document.getElementById('config-status-tab');
|
||||||
|
if (tabLink) {
|
||||||
|
tabLink.classList.add('active');
|
||||||
|
tabLink.setAttribute('aria-selected', 'true');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<a class="nav-link" id="fitbit-tab" href="/admin/fitbit">Fitbit</a>
|
<a class="nav-link" id="fitbit-tab" href="/admin/fitbit">Fitbit</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<a class="nav-link" id="config-status-tab" href="/admin/config-status">Config Status</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content mt-3">
|
<div class="tab-content mt-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user