Implement complete beat marketplace API with FastAPI

- Set up FastAPI project structure with main.py and requirements.txt
- Created SQLite database models for users, beats, and transactions
- Implemented Alembic database migrations
- Added user authentication system with JWT tokens
- Created beat management endpoints (CRUD operations)
- Implemented purchase/transaction system
- Added file upload/download functionality for beat files
- Created health endpoint and API documentation
- Updated README with comprehensive documentation
- Fixed all linting issues with Ruff

Environment variables needed:
- SECRET_KEY: JWT secret key for authentication
This commit is contained in:
Automated Action 2025-07-06 17:42:23 +00:00
parent 087df64ae6
commit c5e673d2ea
21 changed files with 974 additions and 2 deletions

View File

@ -1,3 +1,92 @@
# FastAPI Application # Beat Marketplace API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A REST API for selling beats built with FastAPI and SQLite.
## Features
- User registration and authentication
- Beat management (CRUD operations)
- File upload for beats and previews
- Purchase/transaction system
- Producer and buyer roles
- File downloads for purchased beats
- Health check endpoint
## Installation
1. Install dependencies:
```bash
pip install -r requirements.txt
```
2. Run the application:
```bash
uvicorn main:app --reload
```
## Environment Variables
Set the following environment variables for production:
- `SECRET_KEY`: JWT secret key for authentication (required)
## API Endpoints
### Authentication
- `POST /api/v1/auth/register` - Register a new user
- `POST /api/v1/auth/login` - Login and get access token
### Beats
- `GET /api/v1/beats/` - Get all available beats
- `GET /api/v1/beats/{beat_id}` - Get specific beat
- `POST /api/v1/beats/` - Create new beat (producers only)
- `PUT /api/v1/beats/{beat_id}` - Update beat (producers only)
- `DELETE /api/v1/beats/{beat_id}` - Delete beat (producers only)
- `POST /api/v1/beats/{beat_id}/upload-file` - Upload beat file
- `POST /api/v1/beats/{beat_id}/upload-preview` - Upload beat preview
### Transactions
- `GET /api/v1/transactions/` - Get user's transactions
- `GET /api/v1/transactions/{transaction_id}` - Get specific transaction
- `POST /api/v1/transactions/purchase` - Purchase a beat
- `PUT /api/v1/transactions/{transaction_id}/complete` - Complete transaction
- `GET /api/v1/transactions/producer/sales` - Get producer's sales
### Files
- `GET /api/v1/files/download/{beat_id}` - Download purchased beat
- `GET /api/v1/files/preview/{beat_id}` - Get beat preview
### Health
- `GET /health` - Health check endpoint
- `GET /` - API information
## Database
The application uses SQLite database with the following models:
- Users (authentication and profiles)
- Beats (music files and metadata)
- Transactions (purchase records)
Database file is stored at: `/app/storage/db/db.sqlite`
## File Storage
Beat files are stored in: `/app/storage/beats/`
Preview files are stored in: `/app/storage/beats/previews/`
## Documentation
- API documentation available at `/docs`
- ReDoc documentation available at `/redoc`
- OpenAPI schema available at `/openapi.json`
## Development
Run with auto-reload:
```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
## Testing
The application includes a health check endpoint for monitoring.

40
alembic.ini Normal file
View File

@ -0,0 +1,40 @@
[alembic]
script_location = alembic
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
version_path_separator = os
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

51
alembic/env.py Normal file
View File

@ -0,0 +1,51 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
from app.db.base import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
alembic/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,80 @@
"""Initial migration
Revision ID: 001
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('is_producer', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_table('beats',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('price', sa.Float(), nullable=False),
sa.Column('genre', sa.String(), nullable=False),
sa.Column('bpm', sa.Integer(), nullable=True),
sa.Column('key', sa.String(), nullable=True),
sa.Column('file_path', sa.String(), nullable=False),
sa.Column('preview_path', sa.String(), nullable=True),
sa.Column('artwork_path', sa.String(), nullable=True),
sa.Column('is_available', sa.Boolean(), nullable=True),
sa.Column('producer_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['producer_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_beats_id'), 'beats', ['id'], unique=False)
op.create_table('transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('buyer_id', sa.Integer(), nullable=False),
sa.Column('beat_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('status', sa.Enum('PENDING', 'COMPLETED', 'FAILED', 'REFUNDED', name='transactionstatus'), nullable=True),
sa.Column('transaction_reference', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['beat_id'], ['beats.id'], ),
sa.ForeignKeyConstraint(['buyer_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('transaction_reference')
)
op.create_index(op.f('ix_transactions_id'), 'transactions', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_transactions_id'), table_name='transactions')
op.drop_table('transactions')
op.drop_index(op.f('ix_beats_id'), table_name='beats')
op.drop_table('beats')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

46
app/api/auth.py Normal file
View File

@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from datetime import timedelta
from app.db.session import get_db
from app.models.user import User
from app.schemas.user import UserCreate, UserLogin, Token, UserResponse
from app.core.security import verify_password, get_password_hash, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES
router = APIRouter()
@router.post("/register", response_model=UserResponse)
async def register(user: UserCreate, db: Session = Depends(get_db)):
db_user = db.query(User).filter(User.email == user.email).first()
if db_user:
raise HTTPException(
status_code=400,
detail="Email already registered"
)
hashed_password = get_password_hash(user.password)
db_user = User(
email=user.email,
hashed_password=hashed_password,
full_name=user.full_name,
is_producer=user.is_producer
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.post("/login", response_model=Token)
async def login(user_credentials: UserLogin, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == user_credentials.email).first()
if not user or not verify_password(user_credentials.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.email}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

138
app/api/beats.py Normal file
View File

@ -0,0 +1,138 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from typing import List, Optional
from app.db.session import get_db
from app.models.beat import Beat
from app.models.user import User
from app.schemas.beat import BeatCreate, BeatUpdate, BeatResponse
from app.services.auth import get_current_producer
import shutil
from pathlib import Path
router = APIRouter()
@router.get("/", response_model=List[BeatResponse])
async def get_beats(
skip: int = 0,
limit: int = 100,
genre: Optional[str] = None,
producer_id: Optional[int] = None,
db: Session = Depends(get_db)
):
query = db.query(Beat).filter(Beat.is_available)
if genre:
query = query.filter(Beat.genre == genre)
if producer_id:
query = query.filter(Beat.producer_id == producer_id)
beats = query.offset(skip).limit(limit).all()
return beats
@router.get("/{beat_id}", response_model=BeatResponse)
async def get_beat(beat_id: int, db: Session = Depends(get_db)):
beat = db.query(Beat).filter(Beat.id == beat_id, Beat.is_available).first()
if not beat:
raise HTTPException(status_code=404, detail="Beat not found")
return beat
@router.post("/", response_model=BeatResponse)
async def create_beat(
beat: BeatCreate,
current_user: User = Depends(get_current_producer),
db: Session = Depends(get_db)
):
db_beat = Beat(
**beat.dict(),
producer_id=current_user.id,
file_path="", # Will be set when file is uploaded
)
db.add(db_beat)
db.commit()
db.refresh(db_beat)
return db_beat
@router.put("/{beat_id}", response_model=BeatResponse)
async def update_beat(
beat_id: int,
beat_update: BeatUpdate,
current_user: User = Depends(get_current_producer),
db: Session = Depends(get_db)
):
beat = db.query(Beat).filter(Beat.id == beat_id, Beat.producer_id == current_user.id).first()
if not beat:
raise HTTPException(status_code=404, detail="Beat not found")
update_data = beat_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(beat, field, value)
db.commit()
db.refresh(beat)
return beat
@router.delete("/{beat_id}")
async def delete_beat(
beat_id: int,
current_user: User = Depends(get_current_producer),
db: Session = Depends(get_db)
):
beat = db.query(Beat).filter(Beat.id == beat_id, Beat.producer_id == current_user.id).first()
if not beat:
raise HTTPException(status_code=404, detail="Beat not found")
beat.is_available = False
db.commit()
return {"message": "Beat deleted successfully"}
@router.post("/{beat_id}/upload-file")
async def upload_beat_file(
beat_id: int,
file: UploadFile = File(...),
current_user: User = Depends(get_current_producer),
db: Session = Depends(get_db)
):
beat = db.query(Beat).filter(Beat.id == beat_id, Beat.producer_id == current_user.id).first()
if not beat:
raise HTTPException(status_code=404, detail="Beat not found")
if not file.filename.endswith(('.mp3', '.wav', '.flac')):
raise HTTPException(status_code=400, detail="Invalid file format")
storage_dir = Path("/app/storage/beats")
storage_dir.mkdir(parents=True, exist_ok=True)
file_path = storage_dir / f"{beat_id}_{file.filename}"
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
beat.file_path = str(file_path)
db.commit()
return {"message": "File uploaded successfully", "file_path": str(file_path)}
@router.post("/{beat_id}/upload-preview")
async def upload_beat_preview(
beat_id: int,
file: UploadFile = File(...),
current_user: User = Depends(get_current_producer),
db: Session = Depends(get_db)
):
beat = db.query(Beat).filter(Beat.id == beat_id, Beat.producer_id == current_user.id).first()
if not beat:
raise HTTPException(status_code=404, detail="Beat not found")
if not file.filename.endswith(('.mp3', '.wav')):
raise HTTPException(status_code=400, detail="Invalid file format")
storage_dir = Path("/app/storage/beats/previews")
storage_dir.mkdir(parents=True, exist_ok=True)
file_path = storage_dir / f"{beat_id}_preview_{file.filename}"
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
beat.preview_path = str(file_path)
db.commit()
return {"message": "Preview uploaded successfully", "preview_path": str(file_path)}

66
app/api/files.py Normal file
View File

@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from pathlib import Path
from app.db.session import get_db
from app.models.transaction import Transaction, TransactionStatus
from app.models.beat import Beat
from app.models.user import User
from app.services.auth import get_current_active_user
router = APIRouter()
@router.get("/download/{beat_id}")
async def download_beat(
beat_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
# Check if user has purchased this beat
transaction = db.query(Transaction).filter(
Transaction.buyer_id == current_user.id,
Transaction.beat_id == beat_id,
Transaction.status == TransactionStatus.COMPLETED
).first()
if not transaction:
# Check if user is the producer of this beat
beat = db.query(Beat).filter(
Beat.id == beat_id,
Beat.producer_id == current_user.id
).first()
if not beat:
raise HTTPException(status_code=403, detail="Access denied - beat not purchased")
else:
beat = transaction.beat
if not beat.file_path or not Path(beat.file_path).exists():
raise HTTPException(status_code=404, detail="Beat file not found")
return FileResponse(
beat.file_path,
media_type='application/octet-stream',
filename=f"{beat.title}.{beat.file_path.split('.')[-1]}"
)
@router.get("/preview/{beat_id}")
async def get_beat_preview(
beat_id: int,
db: Session = Depends(get_db)
):
beat = db.query(Beat).filter(
Beat.id == beat_id,
Beat.is_available
).first()
if not beat:
raise HTTPException(status_code=404, detail="Beat not found")
if not beat.preview_path or not Path(beat.preview_path).exists():
raise HTTPException(status_code=404, detail="Preview not available")
return FileResponse(
beat.preview_path,
media_type='audio/mpeg',
filename=f"{beat.title}_preview.{beat.preview_path.split('.')[-1]}"
)

117
app/api/transactions.py Normal file
View File

@ -0,0 +1,117 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from app.db.session import get_db
from app.models.transaction import Transaction, TransactionStatus
from app.models.beat import Beat
from app.models.user import User
from app.schemas.transaction import TransactionCreate, TransactionResponse
from app.services.auth import get_current_active_user
import uuid
router = APIRouter()
@router.get("/", response_model=List[TransactionResponse])
async def get_user_transactions(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
transactions = db.query(Transaction).filter(Transaction.buyer_id == current_user.id).all()
return transactions
@router.get("/{transaction_id}", response_model=TransactionResponse)
async def get_transaction(
transaction_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
transaction = db.query(Transaction).filter(
Transaction.id == transaction_id,
Transaction.buyer_id == current_user.id
).first()
if not transaction:
raise HTTPException(status_code=404, detail="Transaction not found")
return transaction
@router.post("/purchase", response_model=TransactionResponse)
async def purchase_beat(
purchase_data: TransactionCreate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
# Check if beat exists and is available
beat = db.query(Beat).filter(
Beat.id == purchase_data.beat_id,
Beat.is_available
).first()
if not beat:
raise HTTPException(status_code=404, detail="Beat not found or not available")
# Check if user is trying to buy their own beat
if beat.producer_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot purchase your own beat")
# Check if user has already purchased this beat
existing_purchase = db.query(Transaction).filter(
Transaction.buyer_id == current_user.id,
Transaction.beat_id == purchase_data.beat_id,
Transaction.status == TransactionStatus.COMPLETED
).first()
if existing_purchase:
raise HTTPException(status_code=400, detail="Beat already purchased")
# Validate amount matches beat price
if purchase_data.amount != beat.price:
raise HTTPException(status_code=400, detail="Amount does not match beat price")
# Create transaction
transaction = Transaction(
buyer_id=current_user.id,
beat_id=purchase_data.beat_id,
amount=purchase_data.amount,
status=TransactionStatus.PENDING,
transaction_reference=str(uuid.uuid4())
)
db.add(transaction)
db.commit()
db.refresh(transaction)
return transaction
@router.put("/{transaction_id}/complete")
async def complete_transaction(
transaction_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
transaction = db.query(Transaction).filter(
Transaction.id == transaction_id,
Transaction.buyer_id == current_user.id,
Transaction.status == TransactionStatus.PENDING
).first()
if not transaction:
raise HTTPException(status_code=404, detail="Transaction not found or not pending")
# In a real application, this would integrate with a payment processor
# For now, we'll just mark it as completed
transaction.status = TransactionStatus.COMPLETED
db.commit()
db.refresh(transaction)
return {"message": "Transaction completed successfully", "transaction": transaction}
@router.get("/producer/sales", response_model=List[TransactionResponse])
async def get_producer_sales(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
if not current_user.is_producer:
raise HTTPException(status_code=403, detail="Not a producer")
# Get all transactions for beats owned by this producer
transactions = db.query(Transaction).join(Beat).filter(
Beat.producer_id == current_user.id,
Transaction.status == TransactionStatus.COMPLETED
).all()
return transactions

37
app/core/security.py Normal file
View File

@ -0,0 +1,37 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
import os
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_token(token: str) -> Optional[str]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
email: str = payload.get("sub")
if email is None:
return None
return email
except JWTError:
return None

18
app/db/base.py Normal file
View File

@ -0,0 +1,18 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from pathlib import Path
DB_DIR = Path("/app/storage/db")
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

16
app/db/session.py Normal file
View File

@ -0,0 +1,16 @@
from app.db.base import SessionLocal, engine, Base
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
# Import models to register them with SQLAlchemy
from app.models.user import User # noqa: F401
from app.models.beat import Beat # noqa: F401
from app.models.transaction import Transaction # noqa: F401
Base.metadata.create_all(bind=engine)

23
app/models/beat.py Normal file
View File

@ -0,0 +1,23 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, func
from sqlalchemy.orm import relationship
from app.db.base import Base
class Beat(Base):
__tablename__ = "beats"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
description = Column(String)
price = Column(Float, nullable=False)
genre = Column(String, nullable=False)
bpm = Column(Integer)
key = Column(String)
file_path = Column(String, nullable=False)
preview_path = Column(String)
artwork_path = Column(String)
is_available = Column(Boolean, default=True)
producer_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
producer = relationship("User", back_populates="beats")

25
app/models/transaction.py Normal file
View File

@ -0,0 +1,25 @@
import enum
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, func, Enum
from sqlalchemy.orm import relationship
from app.db.base import Base
class TransactionStatus(enum.Enum):
PENDING = "pending"
COMPLETED = "completed"
FAILED = "failed"
REFUNDED = "refunded"
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=True, index=True)
buyer_id = Column(Integer, ForeignKey("users.id"), nullable=False)
beat_id = Column(Integer, ForeignKey("beats.id"), nullable=False)
amount = Column(Float, nullable=False)
status = Column(Enum(TransactionStatus), default=TransactionStatus.PENDING)
transaction_reference = Column(String, unique=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
buyer = relationship("User", foreign_keys=[buyer_id])
beat = relationship("Beat")

17
app/models/user.py Normal file
View File

@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
from sqlalchemy.orm import relationship
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
is_producer = Column(Boolean, default=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
beats = relationship("Beat", back_populates="producer")

36
app/schemas/beat.py Normal file
View File

@ -0,0 +1,36 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class BeatBase(BaseModel):
title: str
description: Optional[str] = None
price: float
genre: str
bpm: Optional[int] = None
key: Optional[str] = None
class BeatCreate(BeatBase):
pass
class BeatUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = None
genre: Optional[str] = None
bpm: Optional[int] = None
key: Optional[str] = None
is_available: Optional[bool] = None
class BeatResponse(BeatBase):
id: int
file_path: str
preview_path: Optional[str] = None
artwork_path: Optional[str] = None
is_available: bool
producer_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,25 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from app.models.transaction import TransactionStatus
class TransactionBase(BaseModel):
beat_id: int
amount: float
class TransactionCreate(TransactionBase):
pass
class TransactionUpdate(BaseModel):
status: Optional[TransactionStatus] = None
class TransactionResponse(TransactionBase):
id: int
buyer_id: int
status: TransactionStatus
transaction_reference: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

35
app/schemas/user.py Normal file
View File

@ -0,0 +1,35 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
class UserBase(BaseModel):
email: EmailStr
full_name: str
is_producer: bool = False
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
full_name: Optional[str] = None
is_producer: Optional[bool] = None
class UserResponse(UserBase):
id: int
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class UserLogin(BaseModel):
email: EmailStr
password: str
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: Optional[str] = None

36
app/services/auth.py Normal file
View File

@ -0,0 +1,36 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.user import User
from app.core.security import verify_token
security = HTTPBearer()
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
email = verify_token(token)
if email is None:
raise credentials_exception
user = db.query(User).filter(User.email == email).first()
if user is None:
raise credentials_exception
return user
async def get_current_active_user(current_user: User = Depends(get_current_user)):
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
async def get_current_producer(current_user: User = Depends(get_current_active_user)):
if not current_user.is_producer:
raise HTTPException(status_code=403, detail="Not a producer")
return current_user

44
main.py Normal file
View File

@ -0,0 +1,44 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.db.session import init_db
from app.api.auth import router as auth_router
from app.api.beats import router as beats_router
from app.api.transactions import router as transactions_router
from app.api.files import router as files_router
app = FastAPI(
title="Beat Marketplace API",
description="A REST API for selling beats",
version="1.0.0",
openapi_url="/openapi.json"
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router, prefix="/api/v1/auth", tags=["authentication"])
app.include_router(beats_router, prefix="/api/v1/beats", tags=["beats"])
app.include_router(transactions_router, prefix="/api/v1/transactions", tags=["transactions"])
app.include_router(files_router, prefix="/api/v1/files", tags=["files"])
@app.on_event("startup")
async def startup_event():
init_db()
@app.get("/")
async def root():
return {
"title": "Beat Marketplace API",
"description": "A REST API for selling beats",
"documentation": "/docs",
"health_check": "/health"
}
@app.get("/health")
async def health_check():
return {"status": "healthy", "service": "Beat Marketplace API"}

9
requirements.txt Normal file
View File

@ -0,0 +1,9 @@
fastapi==0.104.1
uvicorn==0.24.0
sqlalchemy==2.0.23
alembic==1.13.0
python-multipart==0.0.6
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.0
ruff==0.1.6