working basic site admin
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user