Files
kmtnflower/backend/app/main.py
T
Hermes Agent 433bdef5d0
CI Pipeline / backend-test (push) Successful in 4m59s
CI Pipeline / frontend-test (push) Successful in 32s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / backend-test (pull_request) Successful in 5m2s
CI Pipeline / frontend-test (pull_request) Successful in 27s
CI Pipeline / frontend-build (pull_request) Successful in 30s
CI Pipeline / backend-lint (pull_request) Successful in 7s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
fix(auth): remove redundant startup bootstrap user creation
The login endpoint already provisions the first admin user on demand
with a proper password_hash. The startup block in main.py created the
user without a password_hash, causing the login endpoint to see
has_admins=True and reject bootstrap credentials with 403.

Fix: remove the startup user-creation block so the login endpoint's
bootstrap path runs first and provisions the user correctly.
2026-07-31 18:28:26 +00:00

242 lines
10 KiB
Python

from contextlib import asynccontextmanager
import asyncio
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import func, select
from app.config import settings
from app.database import async_session, engine
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter
from app.user_models import User
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds, theme
def _cleanup_orphaned_sequences(sync_conn):
"""Drop model sequences whose backing table no longer exists.
Prevents 'duplicate key value violates unique constraint' errors when
create_all() tries to recreate a SERIAL-backed sequence for a table
whose original sequence was left behind after a drop/recreate cycle.
Only runs on PostgreSQL; no-op for SQLite.
"""
import sqlalchemy as sa
if sync_conn.dialect.name != "postgresql":
return
inspector = sa.inspect(sync_conn)
existing_tables = set(inspector.get_table_names())
for table in Base.metadata.tables.values():
expected_seq = f"{table.name}_id_seq"
if table.name not in existing_tables:
sync_conn.execute(sa.text(f"DROP SEQUENCE IF EXISTS {expected_seq}"))
print(f" → Dropped orphaned sequence {expected_seq}")
def _migrate_add_missing_columns(sync_conn):
"""Add missing columns to existing tables (startup migration — no Alembic).
Handles both SQLite and PostgreSQL. Idempotent: skips columns that already exist.
Receives a sync SQLAlchemy Connection from engine.run_sync().
"""
import sqlalchemy as sa
# Column definitions: (table, name, type, default)
# default=None → nullable column (no DEFAULT clause)
pending = [
("station_config", "stream_url", sa.String(500), ""),
("station_config", "stream_metadata_url", sa.String(500), ""),
("station_config", "play_store_icon_url", sa.String(500), ""),
("station_config", "play_store_url", sa.String(500), ""),
("station_config", "app_store_embed_html", sa.Text, ""),
# Page content columns
("station_config", "hero_subtitle", sa.Text, ""),
("station_config", "about_intro", sa.Text, ""),
("station_config", "about_donation_text", sa.Text, ""),
("station_config", "schedule_intro", sa.Text, ""),
("station_config", "events_intro", sa.Text, ""),
# Color theme columns
("station_config", "color_primary", sa.String(7), "#1a3a5c"),
("station_config", "color_primary_light", sa.String(7), "#2a5a8c"),
("station_config", "color_primary_dark", sa.String(7), "#0e2440"),
("station_config", "color_primary_muted", sa.String(7), "#3a6a9c"),
("station_config", "color_accent", sa.String(7), "#e87a2e"),
("station_config", "color_accent_light", sa.String(7), "#f09a4e"),
("station_config", "color_accent_dark", sa.String(7), "#c85a1e"),
("station_config", "color_accent_muted", sa.String(7), "#f0a86a"),
("station_config", "color_background", sa.String(7), "#faf6f0"),
("station_config", "color_text", sa.String(7), "#3a3632"),
("station_config", "color_success", sa.String(7), "#4a9e4f"),
("station_config", "color_danger", sa.String(7), "#c83030"),
("station_config", "color_info", sa.String(7), "#3a8abf"),
("station_config", "color_white", sa.String(7), "#ffffff"),
("station_config", "color_light", sa.String(7), "#f0ebe3"),
("station_config", "color_medium", sa.String(7), "#8a8580"),
("station_config", "color_black", sa.String(7), "#1a1816"),
# Multi-admin support: password_hash (nullable — existing rows stay NULL)
("users", "password_hash", sa.String(255), None),
]
# Determine dialect
dialect = sync_conn.dialect.name # "sqlite" | "postgresql"
inspector = sa.inspect(sync_conn)
for table_name, col_name, col_type, default in pending:
# Skip tables that don't exist yet (e.g., station_config on fresh DB)
if not inspector.has_table(table_name):
continue
# Check if column already exists
existing = inspector.get_columns(table_name)
if any(c["name"] == col_name for c in existing):
continue
print(f" → Migrating: adding column {table_name}.{col_name}")
# Determine SQL type from the column type object
if isinstance(col_type, sa.Text):
sql_type = "TEXT"
elif isinstance(col_type, sa.String):
sql_type = f"VARCHAR({col_type.length})"
else:
sql_type = "TEXT"
if dialect == "sqlite":
if default is None:
sync_conn.execute(
sa.text(f"ALTER TABLE {table_name} ADD COLUMN \"{col_name}\" {sql_type}")
)
else:
sync_conn.execute(
sa.text(f"ALTER TABLE {table_name} ADD COLUMN \"{col_name}\" {sql_type} NOT NULL DEFAULT '{default}'")
)
else:
if default is None:
sync_conn.execute(
sa.text(f"ALTER TABLE {table_name} ADD COLUMN \"{col_name}\" {sql_type}")
)
else:
sync_conn.execute(
sa.text(f"ALTER TABLE {table_name} ADD COLUMN \"{col_name}\" {sql_type} NOT NULL DEFAULT '{default}'")
)
print(f" ✓ Added {table_name}.{col_name}")
async def _ensure_station_config(session):
"""Ensure the singleton station config row exists (idempotent)."""
result = await session.execute(
select(StationConfig).where(StationConfig.key == "default")
)
if result.scalar_one_or_none() is None:
print(" → Station config missing — seeding defaults...")
from seed import seed as run_seed
# Only seed the station config, not the full dataset
from seed import STATION_CONFIG, _upsert_station_config
await _upsert_station_config(session, STATION_CONFIG)
await session.commit()
print(" ✓ Station config seeded")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create tables on startup, then patch any missing columns
async with engine.begin() as conn:
await conn.run_sync(_cleanup_orphaned_sequences)
try:
await conn.run_sync(Base.metadata.create_all)
except Exception as e:
if "duplicate" in str(e).lower():
print(f" → Tables already exist (partial creation from prior restart): {e}")
else:
raise
await conn.run_sync(_migrate_add_missing_columns)
# Auto-seed if database is empty
async with async_session() as session:
result = await session.execute(select(func.count(Show.id)))
count = result.scalar()
if count == 0:
print(" → Database is empty — running seed...")
from seed import seed as run_seed
await run_seed()
print(" ✓ Full seed completed (skipping station config check)")
# Ensure station config exists (runs even if DB already had data)
async with async_session() as session:
await _ensure_station_config(session)
# Write theme.json on startup
try:
from app.theme_export import write_theme_json
await write_theme_json()
print(f" ✓ Theme JSON written to {settings.THEME_JSON_PATH}")
except Exception as e:
print(f" WARNING: Failed to write theme.json on startup: {e}")
# Check GeoLite2 database
geo_db = settings.GEOLITE2_DB_PATH
if os.path.exists(geo_db):
print(f" ✓ GeoLite2 database loaded from {geo_db}")
else:
print(f" WARNING: GeoLite2 database not found at {geo_db} — geo lookups will be disabled")
# Auto-parse any unprocessed logs on startup (background task)
async def _startup_parse():
try:
from app.log_parser import process_unparsed_logs
result = await process_unparsed_logs()
if result.get("files_processed"):
print(f" ✓ Parsed {result['files_processed']} log files ({result['rows_inserted']} geo rows)")
else:
print(" No unprocessed log files to parse")
except Exception as e:
print(f" ERROR during startup log parse: {e}")
asyncio.create_task(_startup_parse())
yield
app = FastAPI(
title="KMountain Flower Radio API",
description="Backend API for the KMountain Flower Radio Station website.",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routers
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(events.router, prefix="/api/events", tags=["events"])
app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"])
app.include_router(history.router, prefix="/api/history", tags=["history"])
app.include_router(team.router, prefix="/api/team", tags=["team"])
app.include_router(community.router, prefix="/api/community", tags=["community"])
app.include_router(shows.router, prefix="/api/shows", tags=["shows"])
app.include_router(storage.router, prefix="/api/storage", tags=["storage"])
app.include_router(underwriters.router, prefix="/api/underwriters", tags=["underwriters"])
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
app.include_router(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"])
app.include_router(theme.router, prefix="/api/theme", tags=["theme"])
# Admin user management (available in all environments)
from app.api import users
app.include_router(users.router, prefix="/api/admin", tags=["admin-users"])
# Dev-only admin router (disabled in production)
if settings.ENV != "production":
from app.api import admin
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])