Add persistent PostgreSQL stack and SQLite migration

This commit is contained in:
2026-09-23 10:17:18 +02:00
parent 919df2220e
commit 52bd2a0607
8 changed files with 214 additions and 70 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# The SQLite database lives outside the container - see docker-compose.yml volumes.
# Persistent application state (the generated Flask session key) lives in this volume.
VOLUME ["/app/data"]
ENV TELCO_DATA_DIR=/app/data
+23 -3
View File
@@ -4,7 +4,7 @@ from datetime import date, datetime, timedelta
from functools import wraps
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
from sqlalchemy import text
from sqlalchemy import URL, inspect, text
from werkzeug.security import check_password_hash, generate_password_hash
from models import InternetConnection, Phone, User, db
@@ -59,7 +59,7 @@ def get_or_create_secret_key():
def create_app():
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}"
app.config["SQLALCHEMY_DATABASE_URI"] = get_database_uri()
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["JSON_SORT_KEYS"] = False
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)
@@ -79,7 +79,7 @@ def create_app():
def run_migrations():
"""Lightweight in-place migrations for columns added after the DB was first created.
db.create_all() only creates missing tables, not missing columns on existing tables."""
existing_columns = {row[1] for row in db.session.execute(text("PRAGMA table_info(phones)")).fetchall()}
existing_columns = {column["name"] for column in inspect(db.engine).get_columns("phones")}
if "comment" not in existing_columns:
db.session.execute(text("ALTER TABLE phones ADD COLUMN comment TEXT"))
db.session.commit()
@@ -88,6 +88,26 @@ def run_migrations():
db.session.commit()
def get_database_uri():
"""Use the Docker database service when configured, with SQLite as a local-dev fallback."""
database_url = os.environ.get("DATABASE_URL")
if database_url:
return database_url
database_host = os.environ.get("TELCO_DB_HOST")
if database_host:
return URL.create(
drivername="postgresql+psycopg2",
username=os.environ.get("TELCO_DB_USER", "telco"),
password=os.environ.get("TELCO_DB_PASSWORD", "telco"),
host=database_host,
port=int(os.environ.get("TELCO_DB_PORT", "5432")),
database=os.environ.get("TELCO_DB_NAME", "telco"),
)
return f"sqlite:///{DB_PATH}"
def seed_users():
"""First-run only: create the initial login accounts if no users exist yet at all."""
if User.query.count() > 0:
+95
View File
@@ -0,0 +1,95 @@
"""Import the legacy SQLite database into an empty PostgreSQL database.
The import is intentionally idempotent: if any application rows already exist in the
destination, nothing is changed. This makes it safe to run before every container start.
"""
import os
import sys
from pathlib import Path
from sqlalchemy import URL, MetaData, Table, create_engine, func, inspect, select, text
from models import InternetConnection, Phone, User, db
TABLES = (User.__table__, Phone.__table__, InternetConnection.__table__)
def destination_url():
database_url = os.environ.get("DATABASE_URL")
if database_url:
return database_url
return URL.create(
drivername="postgresql+psycopg2",
username=os.environ.get("TELCO_DB_USER", "telco"),
password=os.environ.get("TELCO_DB_PASSWORD", "telco"),
host=os.environ.get("TELCO_DB_HOST", "db"),
port=int(os.environ.get("TELCO_DB_PORT", "5432")),
database=os.environ.get("TELCO_DB_NAME", "telco"),
)
def main():
sqlite_path = Path(os.environ.get("TELCO_LEGACY_SQLITE_PATH", "/app/legacy-data/telco.db"))
target_engine = create_engine(destination_url())
# Create the current schema first. The data copy below then runs as one transaction.
db.metadata.create_all(target_engine)
try:
with target_engine.connect() as connection:
existing_rows = sum(
connection.execute(select(func.count()).select_from(table)).scalar_one()
for table in TABLES
)
if existing_rows:
print(f"PostgreSQL already contains {existing_rows} application rows; SQLite import skipped.")
return
if not sqlite_path.is_file():
print(
f"Legacy SQLite database not found at {sqlite_path}. "
"Place it there before the first start.",
file=sys.stderr,
)
raise SystemExit(1)
source_engine = create_engine(f"sqlite:///{sqlite_path.as_posix()}")
source_metadata = MetaData()
imported = {}
try:
source_table_names = set(inspect(source_engine).get_table_names())
with target_engine.begin() as destination, source_engine.connect() as source:
for target_table in TABLES:
if target_table.name not in source_table_names:
imported[target_table.name] = 0
continue
source_table = Table(target_table.name, source_metadata, autoload_with=source_engine)
target_columns = {column.name for column in target_table.columns}
rows = [
{key: value for key, value in row.items() if key in target_columns}
for row in source.execute(select(source_table)).mappings()
]
if rows:
destination.execute(target_table.insert(), rows)
imported[target_table.name] = len(rows)
# Explicit IDs were retained, so move PostgreSQL's sequences past them.
if destination.dialect.name == "postgresql":
for table in TABLES:
destination.execute(text(
"SELECT setval(pg_get_serial_sequence(:table_name, 'id'), "
f"COALESCE(MAX(id), 1), MAX(id) IS NOT NULL) FROM {table.name}"
), {"table_name": table.name})
finally:
source_engine.dispose()
summary = ", ".join(f"{name}={count}" for name, count in imported.items())
print(f"SQLite import complete: {summary}")
finally:
target_engine.dispose()
if __name__ == "__main__":
main()
+1
View File
@@ -2,3 +2,4 @@ Flask==3.0.3
Flask-SQLAlchemy==3.1.1
openpyxl==3.1.5
gunicorn==22.0.0
psycopg2-binary==2.9.10