New programming screen

This commit is contained in:
2026-06-21 21:55:55 +00:00
parent 96d0fef792
commit e627fe3637
24 changed files with 1297 additions and 122 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -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, StationConfig
from app.models import DonationTier, Event, Program, StationConfig, Show, ShowSchedule
router = APIRouter()
@@ -17,6 +17,8 @@ async def reset_database():
from seed import seed as run_seed
async with async_session() as session:
await session.execute(ShowSchedule.__table__.delete())
await session.execute(Show.__table__.delete())
await session.execute(Program.__table__.delete())
await session.execute(Event.__table__.delete())
await session.execute(DonationTier.__table__.delete())
+118
View File
@@ -0,0 +1,118 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.auth import get_current_admin_user
from app.database import get_session
from app.models import Show, ShowSchedule
from app.schemas import ShowCreate, ShowUpdate, ShowResponse
from app.user_models import User
router = APIRouter()
@router.get("/", response_model=list[ShowResponse])
async def list_shows(session: AsyncSession = Depends(get_session)):
query = (
select(Show)
.where(Show.active == True)
.options(selectinload(Show.schedules))
.order_by(Show.display_order, Show.title)
)
result = await session.execute(query)
return list(result.scalars().all())
@router.get("/{show_id}", response_model=ShowResponse)
async def get_show(
show_id: int,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(Show)
.options(selectinload(Show.schedules))
.where(Show.id == show_id)
)
show = result.scalar_one_or_none()
if not show:
raise HTTPException(status_code=404, detail="Show not found")
return show
@router.post("/", response_model=ShowResponse, status_code=201)
async def create_show(
payload: ShowCreate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
data = payload.model_dump(exclude={"schedules"})
show = Show(**data)
for slot in payload.schedules:
show.schedules.append(ShowSchedule(**slot.model_dump()))
session.add(show)
await session.commit()
await session.refresh(show)
await session.refresh(show)
# Re-query with eager load to return schedules
result = await session.execute(
select(Show)
.options(selectinload(Show.schedules))
.where(Show.id == show.id)
)
return result.scalar_one()
@router.put("/{show_id}", response_model=ShowResponse)
async def update_show(
show_id: int,
payload: ShowUpdate,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(
select(Show)
.options(selectinload(Show.schedules))
.where(Show.id == show_id)
)
show = result.scalar_one_or_none()
if not show:
raise HTTPException(status_code=404, detail="Show not found")
update_data = payload.model_dump(exclude_unset=True, exclude={"schedules"})
for key, value in update_data.items():
setattr(show, key, value)
if payload.schedules is not None:
# Replace all schedule slots
for sched in show.schedules:
await session.delete(sched)
for slot in payload.schedules:
session.add(ShowSchedule(show_id=show.id, **slot.model_dump()))
await session.commit()
await session.refresh(show)
# Re-query with eager load
result = await session.execute(
select(Show)
.options(selectinload(Show.schedules))
.where(Show.id == show.id)
)
return result.scalar_one()
@router.delete("/{show_id}", status_code=204)
async def delete_show(
show_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(select(Show).where(Show.id == show_id))
show = result.scalar_one_or_none()
if not show:
raise HTTPException(status_code=404, detail="Show not found")
await session.delete(show)
await session.commit()
+4 -3
View File
@@ -8,9 +8,9 @@ from sqlalchemy import func, select
from app.config import settings
from app.database import async_session, engine
from app.models import Base, Program, StationConfig, HistoryEntry, TeamMember, CommunityHighlight
from app.models import Base, Program, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight
from app.user_models import User
from app.api import programs, events, tiers, auth, station_config, history, team, community
from app.api import programs, events, tiers, auth, station_config, history, team, community, shows
async def _ensure_station_config(session):
@@ -36,7 +36,7 @@ async def lifespan(app: FastAPI):
# Auto-seed if database is empty
async with async_session() as session:
result = await session.execute(select(func.count(Program.id)))
result = await session.execute(select(func.count(Show.id)))
count = result.scalar()
if count == 0:
print(" → Database is empty — running seed...")
@@ -99,6 +99,7 @@ app.include_router(station_config.router, prefix="/api/station-config", tags=["s
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"])
# Serve uploaded files (dev only — Nginx handles this in production)
if settings.ENV != "production":
+32 -1
View File
@@ -7,8 +7,10 @@ from sqlalchemy import (
Boolean,
Date,
JSON,
ForeignKey,
UniqueConstraint,
)
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import DeclarativeBase, relationship
class Base(DeclarativeBase):
@@ -27,6 +29,35 @@ class Program(Base):
genre = Column(String(80), nullable=False)
class Show(Base):
__tablename__ = "shows"
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=False)
host = Column(String(100), nullable=False)
genre = Column(String(80), nullable=False)
show_art_url = Column(String(500), nullable=True)
hero_image_url = Column(String(500), nullable=True)
display_order = Column(Integer, nullable=False, default=0)
active = Column(Boolean, default=True)
schedules = relationship("ShowSchedule", back_populates="show", cascade="all, delete-orphan")
class ShowSchedule(Base):
__tablename__ = "show_schedules"
id = Column(Integer, primary_key=True, autoincrement=True)
show_id = Column(Integer, ForeignKey("shows.id", ondelete="CASCADE"), nullable=False)
day_of_week = Column(Integer, nullable=False)
time = Column(String(10), nullable=False)
show = relationship("Show", back_populates="schedules")
__table_args__ = (
UniqueConstraint("show_id", "day_of_week", "time", name="uq_show_sched"),
)
class Event(Base):
__tablename__ = "events"
+61
View File
@@ -243,3 +243,64 @@ class CommunityHighlightResponse(BaseModel):
active: bool
model_config = {"from_attributes": True}
# ── ShowSchedule ──────────────────────────────────────────
class ShowScheduleResponse(BaseModel):
id: int
show_id: int
day_of_week: int
time: str
@computed_field
@property
def day_label(self) -> str:
return DAY_NAMES.get(self.day_of_week, "Unknown")
model_config = {"from_attributes": True}
class ShowScheduleCreate(BaseModel):
day_of_week: int
time: str
# ── Show ────────────────────────────────────────────────────
class ShowResponse(BaseModel):
id: int
title: str
description: str
host: str
genre: str
show_art_url: Optional[str]
hero_image_url: Optional[str]
display_order: int
active: bool
schedules: list[ShowScheduleResponse]
model_config = {"from_attributes": True}
class ShowCreate(BaseModel):
title: str
description: str
host: str
genre: str
show_art_url: Optional[str] = None
hero_image_url: Optional[str] = None
display_order: int = 0
schedules: list[ShowScheduleCreate]
class ShowUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
host: Optional[str] = None
genre: Optional[str] = None
show_art_url: Optional[str] = None
hero_image_url: Optional[str] = None
display_order: Optional[int] = None
active: Optional[bool] = None
schedules: Optional[list[ShowScheduleCreate]] = None
+177 -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, StationConfig
from app.models import Program, Show, ShowSchedule, Event, DonationTier, StationConfig
from app.models import HistoryEntry, TeamMember, CommunityHighlight
# ── Seed data ──────────────────────────────────────────────
@@ -32,6 +32,149 @@ PROGRAMS: list[dict] = [
{"day_of_week": 1, "day_label": "Monday", "time": "12:00 AM", "title": "Late Night Jazz", "host": "Mike Darrow", "genre": "Jazz"},
]
SHOWS: list[dict] = [
{
"title": "Morning Mountain Mist",
"description": "Begin your week with ambient soundscapes blended with real mountain field recordings. Birdsong, wind, and gentle melodies to ease you into the day.",
"host": "Diana Walsh",
"genre": "Ambient / Nature",
"show_art_url": None,
"hero_image_url": None,
"display_order": 1,
"schedules": [
{"day_of_week": 1, "time": "6:00 AM"},
{"day_of_week": 3, "time": "6:00 AM"},
{"day_of_week": 5, "time": "6:00 AM"},
],
},
{
"title": "Community Roundup",
"description": "Local news, town hall highlights, and conversations with the people who make this mountain community tick. Your weekly civic check-in.",
"host": "Tom Breen",
"genre": "News / Talk",
"show_art_url": None,
"hero_image_url": None,
"display_order": 2,
"schedules": [
{"day_of_week": 1, "time": "8:00 AM"},
{"day_of_week": 4, "time": "8:00 AM"},
],
},
{
"title": "Bluegrass Trails",
"description": "Flatpickin' banjo, booming bass, and foot-stomping energy. From traditional Appalachian tunes to modern bluegrass fusion.",
"host": "Sarah Lynn",
"genre": "Bluegrass",
"show_art_url": None,
"hero_image_url": None,
"display_order": 3,
"schedules": [
{"day_of_week": 1, "time": "10:00 AM"},
{"day_of_week": 2, "time": "10:00 AM"},
{"day_of_week": 5, "time": "7:00 PM"},
],
},
{
"title": "Lunchtime Jazz",
"description": "Smooth standards, bebop bursts, and everything in between — the perfect soundtrack for your midday break.",
"host": "Mike Darrow",
"genre": "Jazz",
"show_art_url": None,
"hero_image_url": None,
"display_order": 4,
"schedules": [
{"day_of_week": 1, "time": "12:00 PM"},
{"day_of_week": 1, "time": "12:00 AM"},
{"day_of_week": 3, "time": "12:00 PM"},
{"day_of_week": 5, "time": "12:00 PM"},
],
},
{
"title": "Folk Roots Hour",
"description": "Acoustic storytelling from the heart of the mountains. Singer-songwriters, traditional ballads, and the voices that keep our heritage alive.",
"host": "Nadia Cole",
"genre": "Folk",
"show_art_url": None,
"hero_image_url": None,
"display_order": 5,
"schedules": [
{"day_of_week": 2, "time": "2:00 PM"},
{"day_of_week": 4, "time": "2:00 PM"},
],
},
{
"title": "Afternoon Acoustics",
"description": "Stripped-down arrangements and raw vocals. Indie folk, solo artists, and the beauty of unamplified sound.",
"host": "Jen Reeves",
"genre": "Acoustic",
"show_art_url": None,
"hero_image_url": None,
"display_order": 6,
"schedules": [
{"day_of_week": 1, "time": "2:00 PM"},
{"day_of_week": 3, "time": "2:00 PM"},
{"day_of_week": 6, "time": "1:00 PM"},
],
},
{
"title": "Evening Echoes",
"description": "Indie, alternative, and underground tracks from local bands and global artists. Where the next big sound finds its first home.",
"host": "Carlos Mendez",
"genre": "Indie / Alternative",
"show_art_url": None,
"hero_image_url": None,
"display_order": 7,
"schedules": [
{"day_of_week": 1, "time": "6:00 PM"},
{"day_of_week": 2, "time": "6:00 PM"},
{"day_of_week": 4, "time": "6:00 PM"},
{"day_of_week": 6, "time": "7:00 PM"},
],
},
{
"title": "Classical Mountains",
"description": "Symphonies, chamber music, and solo piano performed by the world's greatest musicians. Elevate your evening.",
"host": "Elena Cross",
"genre": "Classical",
"show_art_url": None,
"hero_image_url": None,
"display_order": 8,
"schedules": [
{"day_of_week": 1, "time": "8:00 PM"},
{"day_of_week": 3, "time": "8:00 PM"},
{"day_of_week": 5, "time": "8:00 PM"},
],
},
{
"title": "Night Owl Session",
"description": "Electronic beats, synth waves, and deep house to carry you through the late hours. The mountains never sleep.",
"host": "DJ Kofi",
"genre": "Electronic",
"show_art_url": None,
"hero_image_url": None,
"display_order": 9,
"schedules": [
{"day_of_week": 1, "time": "10:00 PM"},
{"day_of_week": 5, "time": "10:00 PM"},
{"day_of_week": 6, "time": "10:00 PM"},
{"day_of_week": 7, "time": "9:00 PM"},
],
},
{
"title": "Weekend Sunrise",
"description": "A gentle mix of acoustic covers, nature sounds, and listener requests to start your weekend on the right note.",
"host": "Diana Walsh",
"genre": "Acoustic / Ambient",
"show_art_url": None,
"hero_image_url": None,
"display_order": 10,
"schedules": [
{"day_of_week": 6, "time": "7:00 AM"},
{"day_of_week": 7, "time": "7:00 AM"},
],
},
]
EVENTS: list[dict] = [
{
"date": date(2026, 7, 12),
@@ -289,6 +432,29 @@ async def _upsert_team_member(session, data: dict) -> None:
session.add(TeamMember(**data, active=True))
async def _upsert_show(session, data: dict) -> None:
"""Get-or-create a show matched on title, with nested schedule slots."""
schedules = data.pop("schedules", [])
existing = await session.execute(
select(Show).where(Show.title == data["title"])
)
show = existing.scalar_one_or_none()
if show:
for key, value in data.items():
setattr(show, key, value)
# Replace schedule slots
for sched in show.schedules:
session.delete(sched)
for slot in schedules:
session.add(ShowSchedule(show_id=show.id, **slot))
else:
show_data = {**data, "active": True}
show = Show(**show_data)
for slot in schedules:
show.schedules.append(ShowSchedule(**slot))
session.add(show)
async def _upsert_community_highlight(session, data: dict) -> None:
"""Get-or-create a community highlight matched on (icon, title)."""
existing = await session.execute(
@@ -344,10 +510,20 @@ async def seed() -> None:
await session.commit()
print(f" ✓ Seeded {len(COMMUNITY_HIGHLIGHTS)} community highlights")
# Make a copy since _upsert_show mutates the dict (pops 'schedules')
import copy
for data in copy.deepcopy(SHOWS):
await _upsert_show(session, data)
await session.commit()
print(f" ✓ Seeded {len(SHOWS)} shows")
async def truncate_all() -> None:
"""Remove all seed data from the database."""
async with async_session() as session:
# Delete child tables first to respect foreign keys
await session.execute(ShowSchedule.__table__.delete())
await session.execute(Show.__table__.delete())
await session.execute(Program.__table__.delete())
await session.execute(Event.__table__.delete())
await session.execute(DonationTier.__table__.delete())