Files
homekeeper/api/app/database.py
T
friessn 6c9cd67a08 Rebuild auth on Keycloak OIDC, fix rootless Ansible deploy
Replaces the single shared HTTP Basic service-account credential (which
caused a production outage from a username mismatch) with per-user login:
Keycloak (already running on this VM for gcnm, now also fronted on
auth.friessn.de with its own "homekeeper" realm) authenticates the user
once via the landing page, FastAPI verifies the OIDC id_token and mints
its own signed session JWT as a cookie, and both Shiny apps forward that
per-session token as a Bearer credential instead of a static shared one.
Authorization is a simple ALLOWED_USERS allowlist; the old auth.users
table and bcrypt seeding are gone entirely.

Also carries forward the in-progress rootless Podman/Quadlet migration
(gitea, homekeeper, podman roles) and fixes a pre-existing bug where
each role's handlers were malformed inside tasks/main.yml instead of
their own handlers/main.yml, which broke ansible-playbook entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbiCdckkTX2HyAkyi1R39d
2026-07-13 06:31:36 +00:00

230 lines
9.6 KiB
Python

import os
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
_DB_HOST = os.getenv("DB_HOST", "localhost")
_DB_PORT = os.getenv("DB_PORT", "5432")
_DB_NAME = os.getenv("DB_NAME", "homestead")
_DB_USER = os.getenv("DB_USER", "homestead")
_DB_PASSWORD = os.getenv("DB_PASSWORD", "homestead")
DATABASE_URL = (
f"postgresql+psycopg2://{_DB_USER}:{_DB_PASSWORD}@{_DB_HOST}:{_DB_PORT}/{_DB_NAME}"
)
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
def get_session():
with Session(engine) as session:
yield session
def init_db():
"""Create schemas and tables."""
with engine.connect() as conn:
# Create lk schema and tables
conn.execute(text("CREATE SCHEMA IF NOT EXISTS lk"))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS lk.lists (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('shopping', 'todo', 'project')),
t_shirt_size TEXT CHECK (t_shirt_size IN ('XS', 'S', 'M', 'L', 'XL')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
closed_at TIMESTAMPTZ
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS lk.list_items (
id SERIAL PRIMARY KEY,
list_id INTEGER NOT NULL REFERENCES lk.lists(id) ON DELETE CASCADE,
text TEXT NOT NULL,
checked BOOLEAN NOT NULL DEFAULT FALSE,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
)
"""))
# Create public schema beekeeper tables
conn.execute(text("""
CREATE TABLE IF NOT EXISTS apiaries (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
location_text TEXT,
lat REAL,
lng REAL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS colonies (
id SERIAL PRIMARY KEY,
apiary_id INTEGER REFERENCES apiaries(id),
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'wirtschaftsvolk',
parent_colony_id INTEGER REFERENCES colonies(id),
current_queen_id INTEGER,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMPTZ
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS colony_locations (
id SERIAL PRIMARY KEY,
colony_id INTEGER NOT NULL REFERENCES colonies(id),
apiary_id INTEGER NOT NULL REFERENCES apiaries(id),
moved_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS queens (
id SERIAL PRIMARY KEY,
colony_id INTEGER REFERENCES colonies(id),
year INTEGER,
breed TEXT,
origin_colony_id INTEGER REFERENCES colonies(id),
marked_color TEXT,
notes TEXT,
introduced_at TEXT,
superseded_at TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS inspections (
id SERIAL PRIMARY KEY,
colony_id INTEGER NOT NULL REFERENCES colonies(id),
date TEXT NOT NULL,
inspector TEXT,
brood_frames INTEGER,
honey_frames INTEGER,
food_stores_kg REAL,
colony_strength INTEGER,
queen_seen INTEGER NOT NULL DEFAULT 0,
eggs_seen INTEGER NOT NULL DEFAULT 0,
queen_cells_count INTEGER NOT NULL DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS inspection_traits (
id SERIAL PRIMARY KEY,
inspection_id INTEGER NOT NULL REFERENCES inspections(id) ON DELETE CASCADE,
trait TEXT NOT NULL,
score INTEGER NOT NULL CHECK (score BETWEEN 1 AND 4)
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS varroa_counts (
id SERIAL PRIMARY KEY,
colony_id INTEGER NOT NULL REFERENCES colonies(id),
date_from TEXT,
date TEXT NOT NULL,
time TEXT,
method TEXT NOT NULL,
count REAL NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS varroa_treatments (
id SERIAL PRIMARY KEY,
colony_id INTEGER NOT NULL REFERENCES colonies(id),
date TEXT NOT NULL,
time TEXT,
product TEXT NOT NULL,
method TEXT,
dosage TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS varroa_controls (
id SERIAL PRIMARY KEY,
treatment_id INTEGER NOT NULL REFERENCES varroa_treatments(id) ON DELETE CASCADE,
colony_id INTEGER NOT NULL REFERENCES colonies(id),
date TEXT NOT NULL,
time TEXT,
result TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS colony_actions (
id SERIAL PRIMARY KEY,
colony_id INTEGER NOT NULL REFERENCES colonies(id),
date TEXT NOT NULL,
time TEXT,
type TEXT NOT NULL,
notes TEXT,
related_colony_id INTEGER REFERENCES colonies(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS honey_harvests (
id SERIAL PRIMARY KEY,
colony_id INTEGER REFERENCES colonies(id),
apiary_id INTEGER REFERENCES apiaries(id),
date TEXT NOT NULL,
time TEXT,
weight_kg REAL NOT NULL,
type TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS checklists (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'colony',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS checklist_items (
id SERIAL PRIMARY KEY,
checklist_id INTEGER NOT NULL REFERENCES checklists(id) ON DELETE CASCADE,
text TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS checklist_completions (
id SERIAL PRIMARY KEY,
checklist_id INTEGER NOT NULL REFERENCES checklists(id),
item_id INTEGER NOT NULL REFERENCES checklist_items(id),
scope_id INTEGER NOT NULL,
date TEXT NOT NULL,
completed_by TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""))
# Add columns that may be missing from older installs (idempotent)
for stmt in [
"ALTER TABLE varroa_counts ADD COLUMN IF NOT EXISTS date_from TEXT",
"ALTER TABLE varroa_counts ADD COLUMN IF NOT EXISTS time TEXT",
"ALTER TABLE varroa_treatments ADD COLUMN IF NOT EXISTS time TEXT",
"ALTER TABLE varroa_controls ADD COLUMN IF NOT EXISTS time TEXT",
"ALTER TABLE honey_harvests ADD COLUMN IF NOT EXISTS time TEXT",
"ALTER TABLE colony_actions ADD COLUMN IF NOT EXISTS related_colony_id INTEGER REFERENCES colonies(id)",
]:
try:
conn.execute(text(stmt))
except Exception:
pass
conn.commit()