512 lines
19 KiB
Python
512 lines
19 KiB
Python
"""
|
|
Seed script: populates the database with station data.
|
|
|
|
Idempotent — safe to run multiple times. Uses get-or-create (upsert) for each entity.
|
|
|
|
Usage:
|
|
KMTN_DATABASE_URL=... python seed.py
|
|
(run from the backend/ directory)
|
|
"""
|
|
|
|
import asyncio
|
|
from datetime import date
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.database import async_session
|
|
from app.models import Show, ShowSchedule, Event, StationConfig
|
|
from app.models import HistoryEntry, TeamMember, CommunityHighlight, Underwriter
|
|
|
|
# ── Seed data ──────────────────────────────────────────────
|
|
|
|
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),
|
|
"title": "Summer Sounds Festival",
|
|
"description": "A full day of live local music, community booths, and station meet-and-greet at the foothills park. Free admission — donations welcome.",
|
|
"location": "Foothills Community Park",
|
|
"icon": "🎵",
|
|
},
|
|
{
|
|
"date": date(2026, 8, 5),
|
|
"title": "Annual Fundraiser Gala",
|
|
"description": "Our biggest fundraiser of the year! Enjoy dinner, silent auction, and performances from past guest artists.",
|
|
"location": "Mountain View Ballroom",
|
|
"icon": "🎙️",
|
|
},
|
|
{
|
|
"date": date(2026, 9, 18),
|
|
"title": "Volunteer Open House",
|
|
"description": "Tour the studio, meet the team, and learn how you can help keep our free stream alive. New volunteers always welcome!",
|
|
"location": "KMTN Studio — 42 Pine St",
|
|
"icon": "🤝",
|
|
},
|
|
{
|
|
"date": date(2026, 10, 30),
|
|
"title": "Harvest Moon Broadcast",
|
|
"description": "Special live broadcast from the harvest moon gathering. Featuring folk, bluegrass, and indigenous music.",
|
|
"location": "Ridgefield Amphitheater",
|
|
"icon": "🌙",
|
|
},
|
|
]
|
|
|
|
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",
|
|
"donation_url": "",
|
|
"stream_url": "",
|
|
"stream_metadata_url": "",
|
|
"play_store_icon_url": "",
|
|
"play_store_url": "",
|
|
"app_store_embed_html": "",
|
|
}
|
|
|
|
HISTORY_ENTRIES: list[dict] = [
|
|
{
|
|
"year": 1987,
|
|
"title": "The Idea Takes Root",
|
|
"body": "A small group of mountain residents gathered around a kitchen table with a shared dream: a radio station owned by the community, for the community. With nothing but conviction and a stack of handwritten letters to the FCC, they began the long road toward bringing independent radio to the high country.",
|
|
"display_order": 1,
|
|
},
|
|
{
|
|
"year": 1991,
|
|
"title": "FCC License Granted",
|
|
"body": "After years of paperwork, public hearings, and grassroots fundraising, the Federal Communications Commission approved the construction permit. The call sign KMTN was chosen — a nod to the mountain terrain that defines the region and the people who call it home.",
|
|
"display_order": 2,
|
|
},
|
|
{
|
|
"year": 1995,
|
|
"title": "First Broadcast",
|
|
"body": "On a crisp September morning, KMTN signed on the air for the first time. The opening broadcast featured a live bluegrass set from the studio, followed by interviews with local farmers, teachers, and business owners. The signal reached every valley and ridge within a 40-mile radius.",
|
|
"display_order": 3,
|
|
},
|
|
{
|
|
"year": 2008,
|
|
"title": "Studio Expansion",
|
|
"body": "A generous community-driven capital campaign funded the construction of a new broadcast studio — doubling production space and adding a dedicated live-performance room. The expansion also brought upgraded transmission equipment, extending the station's reach to surrounding counties.",
|
|
"display_order": 4,
|
|
},
|
|
]
|
|
|
|
TEAM_MEMBERS: list[dict] = [
|
|
{
|
|
"name": "Margaret Ellis",
|
|
"title": "Board Chair",
|
|
"bio": "Margaret has served on the KMTN board for over fifteen years. A retired schoolteacher, she believes community radio is the backbone of civic engagement.",
|
|
"photo_url": None,
|
|
"display_order": 1,
|
|
},
|
|
{
|
|
"name": "James Whitfield",
|
|
"title": "General Manager",
|
|
"bio": "James joined KMTN as a volunteer DJ in 2002 and has been steering the station's operations ever since. He oversees programming, finance, and community outreach.",
|
|
"photo_url": None,
|
|
"display_order": 2,
|
|
},
|
|
{
|
|
"name": "Dr. Priya Nair",
|
|
"title": "Program Director",
|
|
"bio": "Dr. Nair brings two decades of media experience to KMTN. She curates the station's diverse lineup and mentors the next generation of local broadcasters.",
|
|
"photo_url": None,
|
|
"display_order": 3,
|
|
},
|
|
{
|
|
"name": "Carlos Mendez",
|
|
"title": "Community Outreach Coordinator",
|
|
"bio": "Carlos organizes station events, volunteer drives, and school partnerships. His Evening Echoes show is one of the station's most-listened-to programs.",
|
|
"photo_url": None,
|
|
"display_order": 4,
|
|
},
|
|
]
|
|
|
|
COMMUNITY_HIGHLIGHTS: list[dict] = [
|
|
{
|
|
"icon": "🎵",
|
|
"title": "Our Music",
|
|
"description": "From bluegrass and folk to jazz and classical, our playlists reflect the rich cultural tapestry of the high country and its neighbors. We feature local artists alongside the world's best voices.",
|
|
"display_order": 1,
|
|
},
|
|
{
|
|
"icon": "🤝",
|
|
"title": "Our Community",
|
|
"description": "Every show is staffed by volunteers who live and work in these mountains. From high school DJs to retired teachers, our team is your community — passionate about sharing what they love.",
|
|
"display_order": 2,
|
|
},
|
|
{
|
|
"icon": "📡",
|
|
"title": "Our Reach",
|
|
"description": "Over the air on 98.7 FM and streaming live worldwide. Whether you're hiking the ridgeline or on the other side of the planet, our signal — and our welcome — goes everywhere.",
|
|
"display_order": 3,
|
|
},
|
|
{
|
|
"icon": "🌱",
|
|
"title": "Our Mission",
|
|
"description": "To keep public radio free, independent, and deeply rooted in place. No corporate advertising, no spin — just music, conversation, and community served with care.",
|
|
"display_order": 4,
|
|
},
|
|
]
|
|
|
|
|
|
UNDERWRITERS: list[dict] = [
|
|
{
|
|
"name": "Mountain View Credit Union",
|
|
"description": "Proudly serving the mountain communities since 1952. Your local financial partner for savings, loans, and community investment.",
|
|
"logo_url": None,
|
|
"website_url": "https://example-mvcu.org",
|
|
"display_order": 1,
|
|
},
|
|
{
|
|
"name": "Highland Medical Center",
|
|
"description": "Compassionate healthcare for every ridge and valley. From urgent care to wellness programs, we keep our community healthy.",
|
|
"logo_url": None,
|
|
"website_url": "https://example-highlandmedical.org",
|
|
"display_order": 2,
|
|
},
|
|
{
|
|
"name": "Pine Street Hardware",
|
|
"description": "Family-owned since 1978. Everything you need for home improvement, outdoor living, and mountain-ready repairs.",
|
|
"logo_url": None,
|
|
"website_url": "https://example-pinestreet.com",
|
|
"display_order": 3,
|
|
},
|
|
{
|
|
"name": "Summit Valley Farm",
|
|
"description": "Organic produce, fresh eggs, and seasonal CSA shares. Bringing farm-to-table flavor to the mountain towns since 2005.",
|
|
"logo_url": None,
|
|
"website_url": "https://example-summitvalley.com",
|
|
"display_order": 4,
|
|
},
|
|
]
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────
|
|
|
|
async def _upsert_event(session, data: dict) -> None:
|
|
"""Get-or-create an event matched on (date, title)."""
|
|
existing = await session.execute(
|
|
select(Event).where(
|
|
Event.date == data["date"],
|
|
Event.title == data["title"],
|
|
)
|
|
)
|
|
event = existing.scalar_one_or_none()
|
|
if event:
|
|
for key, value in data.items():
|
|
setattr(event, key, value)
|
|
else:
|
|
session.add(Event(**data, active=True))
|
|
|
|
|
|
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))
|
|
|
|
|
|
async def _upsert_history_entry(session, data: dict) -> None:
|
|
"""Get-or-create a history entry matched on (year, title)."""
|
|
existing = await session.execute(
|
|
select(HistoryEntry).where(
|
|
HistoryEntry.year == data["year"],
|
|
HistoryEntry.title == data["title"],
|
|
)
|
|
)
|
|
entry = existing.scalar_one_or_none()
|
|
if entry:
|
|
for key, value in data.items():
|
|
setattr(entry, key, value)
|
|
else:
|
|
session.add(HistoryEntry(**data, active=True))
|
|
|
|
|
|
async def _upsert_team_member(session, data: dict) -> None:
|
|
"""Get-or-create a team member matched on name."""
|
|
existing = await session.execute(
|
|
select(TeamMember).where(TeamMember.name == data["name"])
|
|
)
|
|
member = existing.scalar_one_or_none()
|
|
if member:
|
|
for key, value in data.items():
|
|
setattr(member, key, value)
|
|
else:
|
|
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(
|
|
select(CommunityHighlight).where(
|
|
CommunityHighlight.icon == data["icon"],
|
|
CommunityHighlight.title == data["title"],
|
|
)
|
|
)
|
|
highlight = existing.scalar_one_or_none()
|
|
if highlight:
|
|
for key, value in data.items():
|
|
setattr(highlight, key, value)
|
|
else:
|
|
session.add(CommunityHighlight(**data, active=True))
|
|
|
|
|
|
async def _upsert_underwriter(session, data: dict) -> None:
|
|
"""Get-or-create an underwriter matched on name."""
|
|
existing = await session.execute(
|
|
select(Underwriter).where(Underwriter.name == data["name"])
|
|
)
|
|
uw = existing.scalar_one_or_none()
|
|
if uw:
|
|
for key, value in data.items():
|
|
setattr(uw, key, value)
|
|
else:
|
|
session.add(Underwriter(**data, active=True))
|
|
|
|
|
|
# ── Main ──────────────────────────────────────────────────
|
|
|
|
async def seed() -> None:
|
|
"""Populate the database with station data (idempotent)."""
|
|
async with async_session() as session:
|
|
for data in EVENTS:
|
|
await _upsert_event(session, data)
|
|
await session.commit()
|
|
print(f" ✓ Seeded {len(EVENTS)} events")
|
|
|
|
await _upsert_station_config(session, STATION_CONFIG)
|
|
await session.commit()
|
|
print(" ✓ Seeded station config")
|
|
|
|
for data in HISTORY_ENTRIES:
|
|
await _upsert_history_entry(session, data)
|
|
await session.commit()
|
|
print(f" ✓ Seeded {len(HISTORY_ENTRIES)} history entries")
|
|
|
|
for data in TEAM_MEMBERS:
|
|
await _upsert_team_member(session, data)
|
|
await session.commit()
|
|
print(f" ✓ Seeded {len(TEAM_MEMBERS)} team members")
|
|
|
|
for data in COMMUNITY_HIGHLIGHTS:
|
|
await _upsert_community_highlight(session, data)
|
|
await session.commit()
|
|
print(f" ✓ Seeded {len(COMMUNITY_HIGHLIGHTS)} community highlights")
|
|
|
|
for data in UNDERWRITERS:
|
|
await _upsert_underwriter(session, data)
|
|
await session.commit()
|
|
print(f" ✓ Seeded {len(UNDERWRITERS)} underwriters")
|
|
|
|
# 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(Event.__table__.delete())
|
|
await session.execute(StationConfig.__table__.delete())
|
|
await session.execute(HistoryEntry.__table__.delete())
|
|
await session.execute(TeamMember.__table__.delete())
|
|
await session.execute(CommunityHighlight.__table__.delete())
|
|
await session.execute(Underwriter.__table__.delete())
|
|
await session.commit()
|
|
print(" ✓ Truncated all tables")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(seed())
|