working 3 tier approach

This commit is contained in:
kfj001 committed 2026-06-11 22:34:55 -07:00
1 parent 4482a30793
commit e5fef8b181
47 files changed
+1288 -200

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "FastAPI (uvicorn)",
"type": "python",
"request": "launch",
"module": "uvicorn",
"args": ["app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"],
"cwd": "${workspaceFolder}",
"env": {
"DATABASE_URL": "postgresql+asyncpg://postgres:postgres@localhost:5432/kmountain"
},
"django": false,
"console": "integratedTerminal"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"python.linting.enabled": true,
"python.linting.flake8Enabled": true,
"python.linting.pylanceEnabled": true,
"python.formatting.provider": "black",
"editor.formatOnSave": true,
"editor.tabSize": 4,
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.tabSize": 4
}
}
+12
View File
@@ -0,0 +1,12 @@
FROM docker.io/library/python:3.12-slim AS base
WORKDIR /app
RUN pip install --no-cache-dir gunicorn uvicorn psycopg2-binary
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
Binary file not shown.
View File
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+70
View File
@@ -0,0 +1,70 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_session
from app.models import Event
from app.schemas import EventCreate, EventUpdate, EventResponse
router = APIRouter()
@router.get("/", response_model=list[EventResponse])
async def list_events(
active: Optional[bool] = Query(None, description="Filter by active status"),
session: AsyncSession = Depends(get_session),
):
query = select(Event).order_by(Event.date)
if active is not None:
query = query.where(Event.active == active)
result = await session.execute(query)
return list(result.scalars().all())
@router.get("/{event_id}", response_model=EventResponse)
async def get_event(event_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if not event:
return {"error": "Event not found"}
return event
@router.post("/", response_model=EventResponse, status_code=201)
async def create_event(
payload: EventCreate, session: AsyncSession = Depends(get_session)
):
event = Event(**payload.model_dump(), active=True)
session.add(event)
await session.commit()
await session.refresh(event)
return event
@router.put("/{event_id}", response_model=EventResponse)
async def update_event(
event_id: int,
payload: EventUpdate,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if not event:
return {"error": "Event not found"}
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(event, key, value)
await session.commit()
await session.refresh(event)
return event
@router.delete("/{event_id}", status_code=204)
async def delete_event(event_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if not event:
return {"error": "Event not found"}
await session.delete(event)
await session.commit()
+75
View File
@@ -0,0 +1,75 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_session
from app.models import Program
from app.schemas import ProgramCreate, ProgramUpdate, ProgramResponse
router = APIRouter()
def _day_filter(day: int):
"""Return the SQLAlchemy filter for a given day_of_week."""
return Program.day_of_week == day
@router.get("/", response_model=list[ProgramResponse])
async def list_programs(
day: Optional[int] = Query(None, description="Filter by day_of_week (1=Mon … 7=Sun)"),
session: AsyncSession = Depends(get_session),
):
query = select(Program).order_by(Program.day_of_week, Program.time)
if day is not None:
query = query.filter(_day_filter(day))
result = await session.execute(query)
return list(result.scalars().all())
@router.get("/{program_id}", response_model=ProgramResponse)
async def get_program(program_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Program).where(Program.id == program_id))
program = result.scalar_one_or_none()
if not program:
return {"error": "Program not found"}
return program
@router.post("/", response_model=ProgramResponse, status_code=201)
async def create_program(
payload: ProgramCreate, session: AsyncSession = Depends(get_session)
):
program = Program(**payload.model_dump())
session.add(program)
await session.commit()
await session.refresh(program)
return program
@router.put("/{program_id}", response_model=ProgramResponse)
async def update_program(
program_id: int,
payload: ProgramUpdate,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Program).where(Program.id == program_id))
program = result.scalar_one_or_none()
if not program:
return {"error": "Program not found"}
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(program, key, value)
await session.commit()
await session.refresh(program)
return program
@router.delete("/{program_id}", status_code=204)
async def delete_program(program_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Program).where(Program.id == program_id))
program = result.scalar_one_or_none()
if not program:
return {"error": "Program not found"}
await session.delete(program)
await session.commit()
+66
View File
@@ -0,0 +1,66 @@
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_session
from app.models import DonationTier
from app.schemas import TierCreate, TierUpdate, TierResponse
router = APIRouter()
@router.get("/", response_model=list[TierResponse])
async def list_tiers(session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(DonationTier).order_by(DonationTier.display_order)
)
return list(result.scalars().all())
@router.get("/{tier_id}", response_model=TierResponse)
async def get_tier(tier_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
tier = result.scalar_one_or_none()
if not tier:
return {"error": "Tier not found"}
return tier
@router.post("/", response_model=TierResponse, status_code=201)
async def create_tier(
payload: TierCreate, session: AsyncSession = Depends(get_session)
):
tier = DonationTier(**payload.model_dump())
session.add(tier)
await session.commit()
await session.refresh(tier)
return tier
@router.put("/{tier_id}", response_model=TierResponse)
async def update_tier(
tier_id: int,
payload: TierUpdate,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
tier = result.scalar_one_or_none()
if not tier:
return {"error": "Tier not found"}
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(tier, key, value)
await session.commit()
await session.refresh(tier)
return tier
@router.delete("/{tier_id}", status_code=204)
async def delete_tier(tier_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
tier = result.scalar_one_or_none()
if not tier:
return {"error": "Tier not found"}
await session.delete(tier)
await session.commit()
+11
View File
@@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/kmountain"
CORS_ORIGINS: list[str] = ["http://localhost:4200", "http://localhost:3000"]
model_config = {"env_prefix": "KMTN_"}
settings = Settings()
+11
View File
@@ -0,0 +1,11 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_session() -> AsyncSession:
async with async_session() as session:
yield session
+38
View File
@@ -0,0 +1,38 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import engine
from app.models import Base
from app.api import programs, events, tiers
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create tables on startup (seed.py handles data seeding)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
app = FastAPI(
title="KMountain Flower Radio API",
description="Backend API for the KMountain Flower Radio Station website.",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routers
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"])
+51
View File
@@ -0,0 +1,51 @@
from sqlalchemy import (
Column,
Integer,
String,
Text,
Numeric,
Boolean,
Date,
ARRAY,
)
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
class Program(Base):
__tablename__ = "programs"
id = Column(Integer, primary_key=True, autoincrement=True)
day_of_week = Column(Integer, nullable=False) # 1=Mon … 7=Sun
day_label = Column(String(10), nullable=False) # "Monday", …
time = Column(String(10), nullable=False) # "6:00 AM"
title = Column(String(200), nullable=False)
host = Column(String(100), nullable=False)
genre = Column(String(80), nullable=False)
class Event(Base):
__tablename__ = "events"
id = Column(Integer, primary_key=True, autoincrement=True)
date = Column(Date, nullable=False)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=False)
location = Column(String(200), nullable=False)
icon = Column(String(10), nullable=False)
rsvp_url = Column(String(500), nullable=True)
active = Column(Boolean, default=True)
class DonationTier(Base):
__tablename__ = "donation_tiers"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), unique=True, nullable=False)
amount = Column(Numeric(10, 2), nullable=False)
icon = Column(String(10), nullable=False)
benefits = Column(ARRAY(Text), nullable=False)
display_order = Column(Integer, nullable=False)
+99
View File
@@ -0,0 +1,99 @@
from datetime import date
from typing import Optional
from pydantic import BaseModel, field_validator
# ── Program ──────────────────────────────────────────────
class ProgramCreate(BaseModel):
day_of_week: int
day_label: str
time: str
title: str
host: str
genre: str
class ProgramUpdate(BaseModel):
day_of_week: Optional[int] = None
day_label: Optional[str] = None
time: Optional[str] = None
title: Optional[str] = None
host: Optional[str] = None
genre: Optional[str] = None
class ProgramResponse(BaseModel):
id: int
day_of_week: int
day_label: str
time: str
title: str
host: str
genre: str
model_config = {"from_attributes": True}
# ── Event ────────────────────────────────────────────────
class EventCreate(BaseModel):
date: date
title: str
description: str
location: str
icon: str
rsvp_url: Optional[str] = None
class EventUpdate(BaseModel):
date: Optional[date] = None
title: Optional[str] = None
description: Optional[str] = None
location: Optional[str] = None
icon: Optional[str] = None
rsvp_url: Optional[str] = None
active: Optional[bool] = None
class EventResponse(BaseModel):
id: int
date: date
title: str
description: str
location: str
icon: str
rsvp_url: Optional[str]
active: bool
model_config = {"from_attributes": True}
# ── DonationTier ─────────────────────────────────────────
class TierCreate(BaseModel):
name: str
amount: float
icon: str
benefits: list[str]
display_order: int
class TierUpdate(BaseModel):
name: Optional[str] = None
amount: Optional[float] = None
icon: Optional[str] = None
benefits: Optional[list[str]] = None
display_order: Optional[int] = None
class TierResponse(BaseModel):
id: int
name: str
amount: float
icon: str
benefits: list[str]
display_order: int
model_config = {"from_attributes": True}
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
psycopg2-binary==2.9.10
asyncpg==0.30.0
sqlalchemy[asyncio]==2.0.36
alembic==1.14.1
python-multipart==0.0.20
pydantic==2.10.4
pydantic-settings==2.7.1
+118
View File
@@ -0,0 +1,118 @@
"""
Seed script: populates the database with station data.
Usage:
DATABASE_URL=postgresql://... python -m app.seed
(or from the repo root: cd backend && python seed.py)
"""
import asyncio
from datetime import date
from app.database import async_session
from app.models import Program, Event, DonationTier
# ── Seed data ──────────────────────────────────────────────
PROGRAMS: list[dict] = [
{"day_of_week": 1, "day_label": "Monday", "time": "6:00 AM", "title": "Morning Mountain Mist", "host": "Diana Walsh", "genre": "Ambient / Nature"},
{"day_of_week": 1, "day_label": "Monday", "time": "8:00 AM", "title": "Community Roundup", "host": "Tom Breen", "genre": "News / Talk"},
{"day_of_week": 1, "day_label": "Monday", "time": "10:00 AM", "title": "Bluegrass Trails", "host": "Sarah Lynn", "genre": "Bluegrass"},
{"day_of_week": 1, "day_label": "Monday", "time": "12:00 PM", "title": "Lunchtime Jazz", "host": "Mike Darrow", "genre": "Jazz"},
{"day_of_week": 1, "day_label": "Monday", "time": "2:00 PM", "title": "Folk Roots Hour", "host": "Nadia Cole", "genre": "Folk"},
{"day_of_week": 1, "day_label": "Monday", "time": "4:00 PM", "title": "Afternoon Acoustics", "host": "Jen Reeves", "genre": "Acoustic"},
{"day_of_week": 1, "day_label": "Monday", "time": "6:00 PM", "title": "Evening Echoes", "host": "Carlos Mendez", "genre": "Indie / Alternative"},
{"day_of_week": 1, "day_label": "Monday", "time": "8:00 PM", "title": "Classical Mountains", "host": "Elena Cross", "genre": "Classical"},
{"day_of_week": 1, "day_label": "Monday", "time": "10:00 PM", "title": "Night Owl Session", "host": "DJ Kofi", "genre": "Electronic"},
{"day_of_week": 1, "day_label": "Monday", "time": "12:00 AM", "title": "Late Night Jazz", "host": "Mike Darrow", "genre": "Jazz"},
]
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": "🌙",
},
]
TIERS: list[dict] = [
{
"name": "Seed",
"amount": 5.00,
"icon": "🌱",
"benefits": ["Digital thank-you card", "Name listed on our website", "Quarterly newsletter"],
"display_order": 1,
},
{
"name": "Stream",
"amount": 15.00,
"icon": "🎵",
"benefits": ["All Seed benefits", "KMountain Flower pin", "Priority show-request line", "Annual station update call"],
"display_order": 2,
},
{
"name": "Ridge",
"amount": 30.00,
"icon": "🏔️",
"benefits": ["All Stream benefits", "Official member patch", "Exclusive show invitations", "Annual station tour"],
"display_order": 3,
},
{
"name": "Summit",
"amount": 60.00,
"icon": "⭐",
"benefits": ["All Ridge benefits", "Featured on the Summit Wall", "Name on-air during anniversary special", "Lifetime patron recognition"],
"display_order": 4,
},
]
# ── Main ──────────────────────────────────────────────────
async def seed():
async with async_session() as session:
# Programs
for data in PROGRAMS:
session.add(Program(**data))
await session.commit()
print(f" ✓ Seeded {len(PROGRAMS)} programs")
# Events
for data in EVENTS:
event = Event(**data, active=True)
session.add(event)
await session.commit()
print(f" ✓ Seeded {len(EVENTS)} events")
# Tiers
for data in TIERS:
session.add(DonationTier(**data))
await session.commit()
print(f" ✓ Seeded {len(TIERS)} donation tiers")
if __name__ == "__main__":
asyncio.run(seed())