New programming screen
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, 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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user