working basic site admin

This commit is contained in:
2026-06-21 02:56:49 +00:00
parent 53d932655e
commit f52625b37b
38 changed files with 1163 additions and 56 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter
from app.config import settings
from app.database import get_session
from app.models import DonationTier, Event, Program
from app.models import DonationTier, Event, Program, StationConfig
router = APIRouter()
@@ -20,6 +20,7 @@ async def reset_database():
await session.execute(Program.__table__.delete())
await session.execute(Event.__table__.delete())
await session.execute(DonationTier.__table__.delete())
await session.execute(StationConfig.__table__.delete())
await session.commit()
await run_seed()
+95
View File
@@ -0,0 +1,95 @@
"""Station config router: read/update the singleton station branding config."""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from app.auth import get_current_admin_user
from app.database import get_session
from app.models import StationConfig
from app.schemas import StationConfigResponse, StationConfigUpdate
from app.user_models import User
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter()
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads")
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
@router.get("", response_model=StationConfigResponse)
async def get_station_config(session: AsyncSession = Depends(get_session)):
"""Return the singleton station config. Public endpoint."""
result = await session.execute(
select(StationConfig).where(StationConfig.key == "default")
)
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Station config not found. Run seed to initialize.",
)
return config
@router.put("", response_model=StationConfigResponse)
async def update_station_config(
payload: StationConfigUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_admin_user),
):
"""Partial update of station config. Admin only."""
result = await session.execute(
select(StationConfig).where(StationConfig.key == "default")
)
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Station config not found")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(config, key, value)
await session.commit()
await session.refresh(config)
return config
@router.post("/upload")
async def upload_image(
file: UploadFile,
current_user: User = Depends(get_current_admin_user),
):
"""Upload an image file for station branding. Admin only.
Saves the file to the uploads/ directory with a UUID filename.
Returns the relative URL path.
"""
# Validate MIME type
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only image files are allowed",
)
# Read and validate size
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File size exceeds 5 MB limit",
)
# Ensure upload directory exists
os.makedirs(UPLOAD_DIR, exist_ok=True)
# Generate unique filename preserving extension
ext = os.path.splitext(file.filename or "upload")[1] if file.filename else ".bin"
filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(UPLOAD_DIR, filename)
with open(filepath, "wb") as f:
f.write(content)
return {"url": f"uploads/{filename}"}
+35 -2
View File
@@ -1,14 +1,31 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import func, select
from app.config import settings
from app.database import async_session, engine
from app.models import Base, Program
from app.models import Base, Program, StationConfig
from app.user_models import User
from app.api import programs, events, tiers, auth
from app.api import programs, events, tiers, auth, station_config
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
@@ -25,6 +42,11 @@ async def lifespan(app: FastAPI):
print(" → Database is empty — running seed...")
from seed import seed as run_seed
await run_seed()
return # Full seed already included station config
# Ensure station config exists (runs even if DB already had data)
async with async_session() as session:
await _ensure_station_config(session)
# Bootstrap admin user if configured
if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD:
@@ -46,6 +68,10 @@ async def lifespan(app: FastAPI):
await session.commit()
print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}")
# Ensure uploads directory exists
uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads")
os.makedirs(uploads_dir, exist_ok=True)
yield
@@ -69,6 +95,13 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(programs.router, prefix="/api/programs", tags=["programs"])
app.include_router(events.router, prefix="/api/events", tags=["events"])
app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"])
app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"])
# Serve uploaded files (dev only — Nginx handles this in production)
if settings.ENV != "production":
uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads")
if os.path.exists(uploads_dir):
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
# Dev-only admin router (disabled in production)
if settings.ENV != "production":
+31
View File
@@ -49,3 +49,34 @@ class DonationTier(Base):
icon = Column(String(10), nullable=False)
benefits = Column(JSON, nullable=False)
display_order = Column(Integer, nullable=False)
class StationConfig(Base):
__tablename__ = "station_config"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(20), unique=True, nullable=False, default="default")
# Identity
callsign = Column(String(10), nullable=False, default="KMTN")
name_primary = Column(String(100), nullable=False, default="KMountain")
name_secondary = Column(String(100), nullable=False, default="Flower Radio")
tagline = Column(String(200), nullable=False, default="Community Radio — Est. 1987")
frequency = Column(String(10), nullable=False, default="98.7 FM")
founded_year = Column(Integer, nullable=False, default=1987)
# Nonprofit
nonprofit_type = Column(String(10), nullable=False, default="501(c)(3)")
ein = Column(String(20), nullable=False, default="84-XXXXXXX")
# Images (URL strings relative to app root)
logo_url = Column(String(500), nullable=False, default="svg/small-flower.svg")
hero_background_url = Column(String(500), nullable=False, default="svg/mountain-landscape.svg")
hero_icon_url = Column(String(500), nullable=False, default="svg/orange-flower.svg")
hero_divider_url = Column(String(500), nullable=False, default="svg/mountain-divider.svg")
# Contact
address = Column(Text, nullable=False, default="42 Pine Street\nMountain View, CO 80424")
phone = Column(String(30), nullable=False, default="(970) 555-0198")
email = Column(String(100), nullable=False, default="hello@kmountainflower.org")
website = Column(String(200), nullable=False, default="kmountainflower.org")
+44
View File
@@ -112,3 +112,47 @@ class TierResponse(BaseModel):
display_order: int
model_config = {"from_attributes": True}
# ── StationConfig ─────────────────────────────────────────
class StationConfigResponse(BaseModel):
id: int
key: str
callsign: str
name_primary: str
name_secondary: str
tagline: str
frequency: str
founded_year: int
nonprofit_type: str
ein: str
logo_url: str
hero_background_url: str
hero_icon_url: str
hero_divider_url: str
address: str
phone: str
email: str
website: str
model_config = {"from_attributes": True}
class StationConfigUpdate(BaseModel):
callsign: Optional[str] = None
name_primary: Optional[str] = None
name_secondary: Optional[str] = None
tagline: Optional[str] = None
frequency: Optional[str] = None
founded_year: Optional[int] = None
nonprofit_type: Optional[str] = None
ein: Optional[str] = None
logo_url: Optional[str] = None
hero_background_url: Optional[str] = None
hero_icon_url: Optional[str] = None
hero_divider_url: Optional[str] = None
address: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
website: Optional[str] = None
+39 -1
View File
@@ -14,7 +14,7 @@ from datetime import date
from sqlalchemy import select
from app.database import async_session
from app.models import Program, Event, DonationTier
from app.models import Program, Event, DonationTier, StationConfig
# ── Seed data ──────────────────────────────────────────────
@@ -93,6 +93,26 @@ TIERS: list[dict] = [
},
]
STATION_CONFIG: dict = {
"key": "default",
"callsign": "KMTN",
"name_primary": "KMountain",
"name_secondary": "Flower Radio",
"tagline": "Community Radio — Est. 1987",
"frequency": "98.7 FM",
"founded_year": 1987,
"nonprofit_type": "501(c)(3)",
"ein": "84-XXXXXXX",
"logo_url": "svg/small-flower.svg",
"hero_background_url": "svg/mountain-landscape.svg",
"hero_icon_url": "svg/orange-flower.svg",
"hero_divider_url": "svg/mountain-divider.svg",
"address": "42 Pine Street\nMountain View, CO 80424",
"phone": "(970) 555-0198",
"email": "hello@kmountainflower.org",
"website": "kmountainflower.org",
}
# ── Helpers ────────────────────────────────────────────────
@@ -141,6 +161,19 @@ async def _upsert_tier(session, data: dict) -> None:
session.add(DonationTier(**data))
async def _upsert_station_config(session, data: dict) -> None:
"""Get-or-create the singleton station config (key='default')."""
existing = await session.execute(
select(StationConfig).where(StationConfig.key == "default")
)
config = existing.scalar_one_or_none()
if config:
for key, value in data.items():
setattr(config, key, value)
else:
session.add(StationConfig(**data))
# ── Main ──────────────────────────────────────────────────
async def seed() -> None:
@@ -161,6 +194,10 @@ async def seed() -> None:
await session.commit()
print(f" ✓ Seeded {len(TIERS)} donation tiers")
await _upsert_station_config(session, STATION_CONFIG)
await session.commit()
print(" ✓ Seeded station config")
async def truncate_all() -> None:
"""Remove all seed data from the database."""
@@ -168,6 +205,7 @@ async def truncate_all() -> None:
await session.execute(Program.__table__.delete())
await session.execute(Event.__table__.delete())
await session.execute(DonationTier.__table__.delete())
await session.execute(StationConfig.__table__.delete())
await session.commit()
print(" ✓ Truncated all tables")