db fixing

This commit is contained in:
smile 2026-06-08 23:13:38 +02:00
parent 2de97e544b
commit 40f0eb89a9
4 changed files with 113 additions and 7 deletions

View file

@ -13,6 +13,7 @@ from flask_cors import CORS
from flask_compress import Compress from flask_compress import Compress
from config import load_config from config import load_config
import database as db
# Bunyan level mapping # Bunyan level mapping
_BUNYAN_LEVELS = { _BUNYAN_LEVELS = {
@ -81,6 +82,9 @@ def create_app(config_path=None):
app.cteward_config = load_config(config_path) app.cteward_config = load_config(config_path)
_setup_logging(app) _setup_logging(app)
# Initialize database with MSSQL config (after logging so db logs work)
_init_database(app)
_setup_extensions(app) _setup_extensions(app)
_register_prehandlers(app) _register_prehandlers(app)
_register_blueprints(app) _register_blueprints(app)
@ -88,12 +92,33 @@ def create_app(config_path=None):
return app return app
def _init_database(app):
"""Initialize the database connection pool from config.
Mirrors database.init(config.mssql) from startup.js.
"""
mssql_config = app.cteward_config.get('mssql', {})
# Get logger from app, or use module-level logger as fallback
logger = getattr(app, 'logger', None)
if logger is None:
import logging
logger = logging.getLogger('cteward_ng.database')
try:
db.init(mssql_config)
logger.info("Database initialized successfully")
except Exception as exc:
logger.error("Failed to initialize database: %s", exc)
raise
def _setup_logging(app): def _setup_logging(app):
"""Setup structured JSON logging similar to bunyan.""" """Setup structured JSON logging similar to bunyan."""
log_level_name = app.cteward_config.get('loglevel', 'info').lower() log_level_name = app.cteward_config.get('loglevel', 'info').lower()
logfile = app.cteward_config.get('logfile') logfile = app.cteward_config.get('logfile')
log_level = _LEVEL_NAMES.get(log_level_name, logging.INFO) log_level = _LEVEL_NAMES.get(log_level_name, logging.INFO)
log_level = "DEBUG"
handler = ( handler = (
RotatingFileHandler(logfile) RotatingFileHandler(logfile)

View file

@ -31,8 +31,10 @@ def check_password(password, hash_value):
Supports plaintext and apr1 (MD5 crypt) formats. Supports plaintext and apr1 (MD5 crypt) formats.
Replaces Node.js apache-md5 module. Replaces Node.js apache-md5 module.
""" """
logger.debug("Verifying password for hash type: %s", hash_value[:5] if hash_value else 'None')
# Plaintext # Plaintext
if not hash_value.startswith('$'): if not hash_value.startswith('$'):
logger.debug("Plaintext password check")
return password == hash_value return password == hash_value
# Parse algorithm tag: $apr1$... # Parse algorithm tag: $apr1$...
@ -42,6 +44,7 @@ def check_password(password, hash_value):
raise ValueError("Password hashing algorithm not selected") raise ValueError("Password hashing algorithm not selected")
if algo == 'apr1': if algo == 'apr1':
logger.debug("apr1 MD5 crypt verification for hash %s...", hash_value[:8])
try: try:
return apr_md5_crypt.verify(password, hash_value) return apr_md5_crypt.verify(password, hash_value)
except Exception as exc: except Exception as exc:
@ -58,15 +61,19 @@ def _parse_basic_auth(request):
""" """
auth_header = request.headers.get('Authorization', '') auth_header = request.headers.get('Authorization', '')
if not auth_header: if not auth_header:
logger.debug("No Authorization header present")
return None return None
parts = auth_header.split(' ', 1) parts = auth_header.split(' ', 1)
if len(parts) != 2 or parts[0] != 'Basic': if len(parts) != 2 or parts[0] != 'Basic':
logger.debug("Invalid Authorization header format")
return None return None
try: try:
decoded = base64.b64decode(parts[1]).decode('utf-8') decoded = base64.b64decode(parts[1]).decode('utf-8')
username, _, password = decoded.partition(':') username, _, password = decoded.partition(':')
logger.debug("Basic auth parsed: username=%s", username)
return (username, password) return (username, password)
except Exception: except Exception:
logger.warning("Failed to decode Basic auth header")
return None return None
@ -79,10 +86,12 @@ def find_botuser(ctx):
""" """
if ctx.get('username') is not None: if ctx.get('username') is not None:
# Already authenticated as bot or LDAP user # Already authenticated as bot or LDAP user
logger.debug("Username already set: %s, skipping bot auth", ctx.get('username'))
return ctx return ctx
auth = _parse_basic_auth(ctx['request']) auth = _parse_basic_auth(ctx['request'])
if auth is None: if auth is None:
logger.debug("No Basic auth found, skipping bot auth")
return ctx return ctx
username, password = auth username, password = auth
@ -91,17 +100,21 @@ def find_botuser(ctx):
if bot_pass is None: if bot_pass is None:
# Not a known bot — pass through to LDAP # Not a known bot — pass through to LDAP
logger.debug("User '%s' not found in bot config, trying LDAP", username)
return ctx return ctx
logger.info("Attempting bot auth for user '%s'", username)
try: try:
match = check_password(password, bot_pass) match = check_password(password, bot_pass)
except Exception as exc: except Exception as exc:
logger.error("Bot password check error: %s", exc) logger.error("Bot password check error for user '%s': %s", username, exc)
abort(500, description=str(exc)) abort(500, description=str(exc))
if not match: if not match:
logger.warning("Bot auth failed for user '%s' - invalid password", username)
abort(401, description="Not authorized. #2") abort(401, description="Not authorized. #2")
logger.info("Bot auth successful for user '%s'", username)
ctx['username'] = username ctx['username'] = username
return ctx return ctx
@ -116,26 +129,32 @@ def find_ldapuser(ctx):
it just means "not this identity"). it just means "not this identity").
""" """
if ctx.get('username') is not None: if ctx.get('username') is not None:
logger.debug("Username already set: %s, skipping LDAP auth", ctx.get('username'))
return ctx return ctx
auth = _parse_basic_auth(ctx['request']) auth = _parse_basic_auth(ctx['request'])
if auth is None: if auth is None:
logger.debug("No Basic auth found, skipping LDAP auth")
return ctx return ctx
ldap_config = ctx['config'].get('ldap') ldap_config = ctx['config'].get('ldap')
if ldap_config is None: if ldap_config is None:
# No LDAP configured — can't authenticate this way # No LDAP configured — can't authenticate this way
logger.critical("NO LDAP") logger.critical("NO LDAP configuration found")
return ctx return ctx
username, password = auth username, password = auth
logger.info("Attempting LDAP auth for user '%s'", username)
try: try:
authenticated = _authenticate_ldap(ldap_config, username, password) authenticated = _authenticate_ldap(ldap_config, username, password)
except ConnectionError as exc: except ConnectionError as exc:
logger.error("LDAP connection failed: %s", exc) logger.error("LDAP connection failed for user '%s': %s", username, exc)
abort(500, description="LDAP connection failed.") abort(500, description="LDAP connection failed.")
if authenticated: if authenticated:
logger.info("LDAP auth successful for user '%s'", username)
ctx['username'] = username ctx['username'] = username
else:
logger.debug("LDAP auth failed for user '%s' - invalid credentials", username)
return ctx return ctx
@ -148,6 +167,9 @@ def _authenticate_ldap(config, username, password):
""" """
from ldap3 import Server, Connection, ALL, SUBTREE from ldap3 import Server, Connection, ALL, SUBTREE
server_url = config.get('url', 'ldap://localhost')
logger.debug("LDAP bind attempt: server=%s, bindDN=%s", server_url, config.get('bindDN', '').format(username=username))
try: try:
server = Server( server = Server(
config.get('url', 'ldap://localhost'), config.get('url', 'ldap://localhost'),
@ -166,14 +188,15 @@ def _authenticate_ldap(config, username, password):
if config.get('searchBase'): if config.get('searchBase'):
# Verify the user actually exists in the directory # Verify the user actually exists in the directory
logger.critical("searchBase: %s", config.get('searchFilter', '(uid={})').format(username=username)) search_filter = config.get('searchFilter', '(uid={})').format(username=username)
logger.debug("LDAP search: base=%s, filter=%s", config['searchBase'], search_filter)
conn.search( conn.search(
search_base=config['searchBase'], search_base=config['searchBase'],
search_filter=config.get('searchFilter', '(uid={})').format(username=username), search_filter=search_filter,
search_scope=SUBTREE, search_scope=SUBTREE,
attributes=config.get('attributes', []), attributes=config.get('attributes', []),
) )
logger.critical("search done") logger.debug("LDAP search returned %d entries", len(conn.entries))
if not conn.entries: if not conn.entries:
return False return False
@ -195,19 +218,25 @@ def authorize(ctx):
perms = ctx.get('permissions', {}) perms = ctx.get('permissions', {})
auth = _parse_basic_auth(req) auth = _parse_basic_auth(req)
logger.debug("Authorization started, credentials present: %s", auth is not None)
# No credentials at all — allow only if anonymous access is permitted # No credentials at all — allow only if anonymous access is permitted
if auth is None: if auth is None:
if '_anonymous_' not in perms: if '_anonymous_' not in perms:
logger.warning("Authorization failed: no credentials and anonymous access prohibited")
abort(401, description="Not authorized, anonymous access prohibited.") abort(401, description="Not authorized, anonymous access prohibited.")
# Anonymous path — set a sentinel username # Anonymous path — set a sentinel username
logger.info("Anonymous access granted")
ctx['username'] = 'anonymous' ctx['username'] = 'anonymous'
ctx['flags'] = ['_anonymous_'] ctx['flags'] = ['_anonymous_']
else: else:
# Pipeline: bot → LDAP → config flags → DB flags → impersonate → permissions # Pipeline: bot → LDAP → config flags → DB flags → impersonate → permissions
logger.debug("Running auth pipeline for user '%s'", auth[0] if auth else 'unknown')
find_botuser(ctx) find_botuser(ctx)
find_ldapuser(ctx) find_ldapuser(ctx)
if ctx.get('username') is None: if ctx.get('username') is None:
logger.warning("Authorization failed: authentication not successful")
abort(401, description="Not authorized. #5") abort(401, description="Not authorized. #5")
# Config-level flags # Config-level flags
@ -217,4 +246,5 @@ def authorize(ctx):
impersonate(ctx) impersonate(ctx)
effective_permissions(ctx) effective_permissions(ctx)
logger.info("Authorization complete for user '%s'", ctx.get('username', 'unknown'))
return ctx return ctx

View file

@ -29,17 +29,25 @@ def init(config=None):
Args: Args:
config: dict with MSSQL connection parameters. config: dict with MSSQL connection parameters.
If None, uses safe defaults that will fail on first query. If None, uses safe defaults that will fail on first query.
If config['fakedata'] is True, skips actual connection (for tests).
""" """
global _pool global _pool
if config is None: if config is None:
config = {} config = {}
# Support fake data mode for testing
if config.get('fakedata'):
logger.info("Fake data mode enabled - skipping database connection")
return
user = config.get('user', 'readonly') user = config.get('user', 'readonly')
password = config.get('password', 'XXXXXXXXXXXXXXXX') password = config.get('password', 'XXXXXXXXXXXXXXXX')
server = config.get('server', 'localhost') server = config.get('server', 'localhost')
port = str(config.get('port', '1433')) port = str(config.get('port', '1433'))
database = config.get('database', 'Linear') database = config.get('database', 'Linear')
logger.info("Initializing MSSQL connection pool: server=%s, database=%s", server, database)
dsn = ( dsn = (
f"DRIVER={{ODBC Driver 18 for SQL Server}};" f"DRIVER={{ODBC Driver 18 for SQL Server}};"
f"SERVER={server},{port};" f"SERVER={server},{port};"
@ -50,6 +58,8 @@ def init(config=None):
"TrustServerCertificate=yes;" "TrustServerCertificate=yes;"
) )
logger.debug("ODBC DSN: %s...", dsn[:50])
try: try:
_pool = PooledDB( _pool = PooledDB(
creator=pyodbc, creator=pyodbc,
@ -78,13 +88,16 @@ def init(config=None):
def _get_connection(): def _get_connection():
"""Get a connection from the pool, creating it if needed.""" """Get a connection from the pool, creating it if needed."""
if _pool is None: if _pool is None:
logger.error("Database not initialized. Call init() first.")
raise RuntimeError("Database not initialized. Call init() first.") raise RuntimeError("Database not initialized. Call init() first.")
logger.debug("Acquiring connection from pool")
return _pool.connection() return _pool.connection()
def connected(): def connected():
"""Check if the connection pool is alive and reachable.""" """Check if the connection pool is alive and reachable."""
if _pool is None: if _pool is None:
logger.debug("Connection pool not initialized")
return False return False
try: try:
conn = _pool.connection() conn = _pool.connection()
@ -93,8 +106,10 @@ def connected():
cursor.fetchone() cursor.fetchone()
cursor.close() cursor.close()
conn.close() conn.close()
logger.debug("Database connectivity check passed")
return True return True
except Exception: except Exception as exc:
logger.error("Database connectivity check failed: %s", exc)
return False return False
@ -107,6 +122,7 @@ def check_backend_okay():
- Member count >= 7 - Member count >= 7
- No duplicate crewnames - No duplicate crewnames
""" """
logger.info("Running backend health check")
conn = _get_connection() conn = _get_connection()
cursor = conn.cursor() cursor = conn.cursor()
@ -114,6 +130,7 @@ def check_backend_okay():
# Member count check # Member count check
cursor.execute("SELECT COUNT(*) AS MemberCount FROM Adresse") cursor.execute("SELECT COUNT(*) AS MemberCount FROM Adresse")
row = cursor.fetchone() row = cursor.fetchone()
logger.debug("Member count: %d", row.MemberCount)
if row.MemberCount < 7: if row.MemberCount < 7:
raise RuntimeError("Too few members.") raise RuntimeError("Too few members.")
@ -125,12 +142,15 @@ def check_backend_okay():
"HAVING COUNT(*) > 1" "HAVING COUNT(*) > 1"
) )
duplicates = cursor.fetchall() duplicates = cursor.fetchall()
logger.debug("Duplicate crewname check returned %d results", len(duplicates))
if duplicates: if duplicates:
raise RuntimeError("Duplicate membernames.") raise RuntimeError("Duplicate membernames.")
finally: finally:
cursor.close() cursor.close()
conn.close() conn.close()
logger.info("Backend health check passed")
def _exec_query(statement, params_list): def _exec_query(statement, params_list):
"""Execute a parameterized query and return rows as list of dicts. """Execute a parameterized query and return rows as list of dicts.
@ -142,12 +162,14 @@ def _exec_query(statement, params_list):
Returns: Returns:
list of dict rows keyed by column name. list of dict rows keyed by column name.
""" """
logger.debug("Executing query: %s with params %s", statement[:80], params_list)
conn = _get_connection() conn = _get_connection()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
cursor.execute(statement, params_list) cursor.execute(statement, params_list)
columns = [desc[0] for desc in cursor.description] columns = [desc[0] for desc in cursor.description]
rows = [dict(zip(columns, row)) for row in cursor.fetchall()] rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
logger.debug("Query returned %d rows", len(rows))
return rows return rows
finally: finally:
cursor.close() cursor.close()
@ -171,16 +193,21 @@ def run_query(query_def, params):
RuntimeError: if query parameters are missing. RuntimeError: if query parameters are missing.
Exception: on database errors. Exception: on database errors.
""" """
logger.debug("run_query called with special=%s", query_def.get('special'))
# Handle special stats queries # Handle special stats queries
special = query_def.get('special') special = query_def.get('special')
if special: if special:
if special == QUERY_STATS_MEMBERS['special']: if special == QUERY_STATS_MEMBERS['special']:
logger.info("Executing stats query: members")
return run_query_stats_members() return run_query_stats_members()
if special == QUERY_STATS_CONTRACTS['special']: if special == QUERY_STATS_CONTRACTS['special']:
logger.info("Executing stats query: contracts")
return run_query_stats_contracts() return run_query_stats_contracts()
if special == QUERY_STATS_GENDERS['special']: if special == QUERY_STATS_GENDERS['special']:
logger.info("Executing stats query: genders")
return run_query_stats_genders() return run_query_stats_genders()
if special == QUERY_STATS_AGES['special']: if special == QUERY_STATS_AGES['special']:
logger.info("Executing stats query: ages")
return run_query_stats_ages(params) return run_query_stats_ages(params)
raise ValueError(f"Unknown special query: {special}") raise ValueError(f"Unknown special query: {special}")
@ -188,14 +215,17 @@ def run_query(query_def, params):
param_names = query_def.get('params', []) param_names = query_def.get('params', [])
if not param_names: if not param_names:
logger.debug("Executing query with no params")
return _exec_query(statement, []) return _exec_query(statement, [])
# Validate all required params are provided # Validate all required params are provided
missing = [p for p in param_names if p not in params] missing = [p for p in param_names if p not in params]
if missing: if missing:
logger.error("Missing query parameters: %s", missing)
raise RuntimeError(f"Missing query parameters: {missing}") raise RuntimeError(f"Missing query parameters: {missing}")
params_list = [params[p] for p in param_names] params_list = [params[p] for p in param_names]
logger.debug("Executing query with params: %s", params_list)
return _exec_query(statement, params_list) return _exec_query(statement, params_list)
@ -211,6 +241,7 @@ def _get_date_range(table, min_col, max_col):
Returns (mindate, maxdate) as datetime objects. Returns (mindate, maxdate) as datetime objects.
""" """
logger.debug("Getting date range for table=%s, min_col=%s, max_col=%s", table, min_col, max_col)
conn = _get_connection() conn = _get_connection()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
@ -220,7 +251,9 @@ def _get_date_range(table, min_col, max_col):
) )
row = cursor.fetchone() row = cursor.fetchone()
if not row: if not row:
logger.debug("No data found in %s, using epoch dates", table)
return datetime(1970, 1, 1), datetime.now() return datetime(1970, 1, 1), datetime.now()
logger.debug("Date range for %s: %s to %s", table, row[0], row[1])
return row[0] or datetime(1970, 1, 1), row[1] or datetime.now() return row[0] or datetime(1970, 1, 1), row[1] or datetime.now()
finally: finally:
cursor.close() cursor.close()
@ -238,7 +271,9 @@ def run_query_stats_members():
Returns list of dicts: [{'Year': int, 'Month': int, 'Members': int}, ...] Returns list of dicts: [{'Year': int, 'Month': int, 'Members': int}, ...]
Counts active members (joined before month-end, not yet left) per month. Counts active members (joined before month-end, not yet left) per month.
""" """
logger.info("Starting members stats query")
mindate, maxdate = _get_date_range('Adresse', 'Eintritt', 'Austritt') mindate, maxdate = _get_date_range('Adresse', 'Eintritt', 'Austritt')
logger.debug("Members date range: %s to %s", mindate, maxdate)
results = [] results = []
year = mindate.year year = mindate.year
@ -264,6 +299,7 @@ def run_query_stats_members():
month = 1 month = 1
year += 1 year += 1
logger.info("Members stats query complete: %d months returned", len(results))
return results return results
@ -273,7 +309,9 @@ def run_query_stats_contracts():
Returns list of dicts: Returns list of dicts:
[{'Year': int, 'Month': int, 'Contracts': [{'Type': str, 'Count': int}, ...]}, ...] [{'Year': int, 'Month': int, 'Contracts': [{'Type': str, 'Count': int}, ...]}, ...]
""" """
logger.info("Starting contracts stats query")
mindate, maxdate = _get_date_range('MgVert', 'VertragBegin', 'VertragEnde') mindate, maxdate = _get_date_range('MgVert', 'VertragBegin', 'VertragEnde')
logger.debug("Contracts date range: %s to %s", mindate, maxdate)
results = [] results = []
year = mindate.year year = mindate.year
@ -306,6 +344,7 @@ def run_query_stats_contracts():
month = 1 month = 1
year += 1 year += 1
logger.info("Contracts stats query complete: %d months returned", len(results))
return results return results
@ -315,7 +354,9 @@ def run_query_stats_genders():
Returns list of dicts: Returns list of dicts:
[{'Year': int, 'Month': int, 'Male': int, 'Female': int, 'Business': int, 'Other': int}, ...] [{'Year': int, 'Month': int, 'Male': int, 'Female': int, 'Business': int, 'Other': int}, ...]
""" """
logger.info("Starting gender stats query")
mindate, maxdate = _get_date_range('Adresse', 'Eintritt', 'Austritt') mindate, maxdate = _get_date_range('Adresse', 'Eintritt', 'Austritt')
logger.debug("Gender stats date range: %s to %s", mindate, maxdate)
results = [] results = []
year = mindate.year year = mindate.year
@ -367,6 +408,7 @@ def run_query_stats_genders():
month = 1 month = 1
year += 1 year += 1
logger.info("Gender stats query complete: %d months returned", len(results))
return results return results
@ -379,10 +421,12 @@ def run_query_stats_ages(params=None):
params: optional dict with query params 'step', 'min', 'max' for params: optional dict with query params 'step', 'min', 'max' for
age bucket configuration. Defaults: step=10, min=20, max=60. age bucket configuration. Defaults: step=10, min=20, max=60.
""" """
logger.info("Starting age stats query")
if params is None: if params is None:
params = {} params = {}
mindate, maxdate = _get_date_range('Adresse', 'Eintritt', 'Austritt') mindate, maxdate = _get_date_range('Adresse', 'Eintritt', 'Austritt')
logger.debug("Age stats date range: %s to %s", mindate, maxdate)
# Parse age bucket parameters # Parse age bucket parameters
try: try:
@ -398,6 +442,8 @@ def run_query_stats_ages(params=None):
except (ValueError, TypeError): except (ValueError, TypeError):
limit_max = 60 limit_max = 60
logger.debug("Age stats params: step=%d, min=%d, max=%d", step, limit_min, limit_max)
# Convert year-based limits to age-based # Convert year-based limits to age-based
thisyear = datetime.now().year thisyear = datetime.now().year
if limit_min > 200: if limit_min > 200:
@ -473,6 +519,7 @@ def run_query_stats_ages(params=None):
month = 1 month = 1
year += 1 year += 1
logger.info("Age stats query complete: %d months returned", len(results))
return results return results
@ -490,13 +537,17 @@ def member_lookup(crewname):
Raises: Raises:
RuntimeError: if not found or multiple rows returned. RuntimeError: if not found or multiple rows returned.
""" """
logger.debug("Member lookup for crewname: %s", crewname)
rows = _exec_query( rows = _exec_query(
"SELECT Kurzname, Kennung3, Eintritt, Austritt " "SELECT Kurzname, Kennung3, Eintritt, Austritt "
"FROM Adresse WHERE Kurzname = ?", "FROM Adresse WHERE Kurzname = ?",
[crewname], [crewname],
) )
logger.debug("Member lookup returned %d rows", len(rows))
if len(rows) != 1: if len(rows) != 1:
logger.error("Member lookup for '%s': expected 1 row, got %d", crewname, len(rows))
raise RuntimeError(f"Member lookup for '{crewname}': expected 1 row, got {len(rows)}") raise RuntimeError(f"Member lookup for '{crewname}': expected 1 row, got {len(rows)}")
logger.info("Member lookup successful for '%s'", crewname)
return rows[0] return rows[0]