working 3 tier approach
This commit is contained in:
Vendored
+18
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+13
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
@@ -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())
|
||||
Reference in New Issue
Block a user