Create betting application API with FastAPI and SQLite

- Set up project structure with FastAPI and SQLite
- Implement user authentication with JWT
- Create database models for users, events, bets, and transactions
- Add API endpoints for user management
- Add API endpoints for events and betting functionality
- Add wallet management for deposits and withdrawals
- Configure Alembic for database migrations
- Add linting with Ruff
- Add documentation in README
This commit is contained in:
Automated Action 2025-06-02 15:02:41 +00:00
parent 72c80dc7cc
commit 4cfc9775ae
40 changed files with 2184 additions and 2 deletions

183
README.md
View File

@ -1,3 +1,182 @@
# FastAPI Application
# Betting Application API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
A FastAPI-based backend for a betting application. This API provides functionality for user management, betting on events, wallet operations, and more.
## Features
- User authentication and management
- Event creation and management
- Betting functionality
- Wallet management (deposits, withdrawals)
- Transaction history
- Admin features for event management and bet settlement
## Requirements
- Python 3.9+
- SQLite database
## Installation
1. Clone the repository:
```bash
git clone https://github.com/yourusername/betting-application-api.git
cd betting-application-api
```
2. Set up a virtual environment:
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. Install dependencies:
```bash
pip install -r requirements.txt
```
4. Create a `.env` file in the root directory with the following content (customize as needed):
```
# App configuration
SECRET_KEY=yoursecretkey
API_V1_STR=/api/v1
PROJECT_NAME=betting-application-api
ENVIRONMENT=development
# JWT Settings
ACCESS_TOKEN_EXPIRE_MINUTES=30
JWT_ALGORITHM=HS256
JWT_SECRET_KEY=your_jwt_secret_key
# Security
ALLOWED_HOSTS=["*"]
# Admin user
FIRST_SUPERUSER_EMAIL=admin@example.com
FIRST_SUPERUSER_PASSWORD=admin123
```
5. Initialize the database and run migrations:
```bash
alembic upgrade head
```
## Running the API
Start the API server:
```bash
uvicorn main:app --reload
```
The API will be available at http://localhost:8000.
API documentation is available at:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## API Endpoints
### Authentication
- `POST /api/v1/auth/login` - Get access token (OAuth2)
### Users
- `POST /api/v1/users` - Register a new user
- `GET /api/v1/users/me` - Get current user information
- `PUT /api/v1/users/me` - Update current user information
- `GET /api/v1/users/{user_id}` - Get user by ID (admin or self)
- `GET /api/v1/users` - List all users (admin only)
### Events
- `GET /api/v1/events` - List all events (filter by status)
- `POST /api/v1/events` - Create a new event (admin only)
- `GET /api/v1/events/{event_id}` - Get event by ID
- `PUT /api/v1/events/{event_id}` - Update event (admin only)
- `DELETE /api/v1/events/{event_id}` - Delete event (admin only)
- `POST /api/v1/events/outcomes/{outcome_id}/settle` - Settle an outcome (admin only)
### Bets
- `GET /api/v1/bets` - List user's bets (filter by status)
- `POST /api/v1/bets` - Place a new bet
- `GET /api/v1/bets/{bet_id}` - Get bet by ID
- `POST /api/v1/bets/{bet_id}/cancel` - Cancel a pending bet
### Wallet
- `GET /api/v1/wallet/balance` - Get user's balance
- `GET /api/v1/wallet/transactions` - Get user's transaction history
- `POST /api/v1/wallet/deposit` - Deposit funds
- `POST /api/v1/wallet/withdraw` - Withdraw funds
### Health Check
- `GET /health` - API health check
## Models
### User
- id: Unique identifier
- email: User's email (unique)
- full_name: User's full name
- is_active: User account status
- is_admin: Admin privileges flag
- balance: User's wallet balance
### Event
- id: Unique identifier
- name: Event name
- description: Event description
- start_time: When the event starts
- end_time: When the event ends
- status: upcoming, live, finished, or cancelled
### Market
- id: Unique identifier
- event_id: Associated event
- name: Market name
- is_active: Market availability status
### Outcome
- id: Unique identifier
- market_id: Associated market
- name: Outcome name
- odds: Betting odds
- is_winner: Result status
- is_active: Outcome availability status
### Bet
- id: Unique identifier
- user_id: User who placed the bet
- outcome_id: The selected outcome
- amount: Bet amount
- odds: Odds at time of bet
- potential_win: Potential winnings
- status: pending, won, lost, cancelled, or voided
### Transaction
- id: Unique identifier
- user_id: Associated user
- amount: Transaction amount
- transaction_type: deposit, withdrawal, bet_placed, bet_won, bet_lost, or bet_refund
- status: pending, completed, failed, or cancelled
- reference: External reference (optional)
- bet_id: Associated bet (optional)
## License
MIT

85
alembic.ini Normal file
View File

@ -0,0 +1,85 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s
# timezone to use when rendering the date within the migration file
# as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# SQLite URL example
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks=black
# black.type=console_scripts
# black.entrypoint=black
# black.options=-l 79
# Logging configuration
[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

0
app/api/__init__.py Normal file
View File

61
app/api/deps.py Normal file
View File

@ -0,0 +1,61 @@
from collections.abc import Generator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.core.config import settings
from app.db.session import SessionLocal
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login",
)
def get_db() -> Generator:
try:
db = SessionLocal()
yield db
finally:
db.close()
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme),
) -> models.User:
try:
payload = jwt.decode(
token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM],
)
token_data = schemas.TokenPayload(**payload)
except (jwt.JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = crud.get_user(db, user_id=token_data.sub)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
def get_current_active_user(
current_user: models.User = Depends(get_current_user),
) -> models.User:
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
def get_current_admin_user(
current_user: models.User = Depends(get_current_user),
) -> models.User:
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions",
)
return current_user

0
app/api/v1/__init__.py Normal file
View File

11
app/api/v1/api.py Normal file
View File

@ -0,0 +1,11 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, bets, events, users, wallet
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(events.router, prefix="/events", tags=["Events"])
api_router.include_router(bets.router, prefix="/bets", tags=["Bets"])
api_router.include_router(wallet.router, prefix="/wallet", tags=["Wallet"])

View File

View File

@ -0,0 +1,41 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app import crud, schemas
from app.api.deps import get_db
from app.core.config import settings
from app.core.security import create_access_token
router = APIRouter()
@router.post("/login", response_model=schemas.Token)
def login_access_token(
db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends(),
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests.
"""
user = crud.authenticate(db, email=form_data.username, password=form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
elif not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user",
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": create_access_token(
user.id, expires_delta=access_token_expires,
),
"token_type": "bearer",
}

View File

@ -0,0 +1,137 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
from app.models.bet import BetStatus
router = APIRouter()
@router.get("/", response_model=list[schemas.Bet])
def read_bets(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
status: Optional[BetStatus] = None,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve bets for current user.
"""
bets = crud.get_user_bets(
db, user_id=current_user.id, skip=skip, limit=limit, status=status,
)
return bets
@router.post("/", response_model=schemas.Bet)
def create_bet(
*,
db: Session = Depends(deps.get_db),
bet_in: schemas.BetCreate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Create a new bet.
"""
# Check if user has enough balance
if current_user.balance < bet_in.amount:
raise HTTPException(
status_code=400, detail="Insufficient balance for this bet",
)
# Check if amount is positive
if bet_in.amount <= 0:
raise HTTPException(
status_code=400, detail="Bet amount must be greater than 0",
)
# Check if outcome exists and is active
outcome = crud.get_outcome(db, outcome_id=bet_in.outcome_id)
if not outcome:
raise HTTPException(status_code=404, detail="Outcome not found")
if not outcome.is_active:
raise HTTPException(status_code=400, detail="This outcome is not available for betting")
# Check if market is active
if not outcome.market.is_active:
raise HTTPException(status_code=400, detail="This market is not available for betting")
# Check if event is upcoming or live
event = outcome.market.event
if event.status not in [models.event.EventStatus.UPCOMING, models.event.EventStatus.LIVE]:
raise HTTPException(
status_code=400,
detail=f"Cannot place bets on events with status {event.status}",
)
bet = crud.create_bet(db, bet_in=bet_in, user_id=current_user.id)
if not bet:
raise HTTPException(status_code=400, detail="Could not create bet")
return bet
@router.get("/{bet_id}", response_model=schemas.Bet)
def read_bet(
*,
db: Session = Depends(deps.get_db),
bet_id: int,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get specific bet by ID.
"""
bet = crud.get_bet(db, bet_id=bet_id)
if not bet:
raise HTTPException(status_code=404, detail="Bet not found")
# Only allow user to see their own bets (or admin)
if bet.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not enough permissions")
return bet
@router.post("/{bet_id}/cancel", response_model=schemas.Bet)
def cancel_bet(
*,
db: Session = Depends(deps.get_db),
bet_id: int,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Cancel a pending bet.
"""
bet = crud.get_bet(db, bet_id=bet_id)
if not bet:
raise HTTPException(status_code=404, detail="Bet not found")
# Only allow user to cancel their own bets (or admin)
if bet.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not enough permissions")
# Only pending bets can be cancelled
if bet.status != BetStatus.PENDING:
raise HTTPException(
status_code=400,
detail=f"Cannot cancel a bet with status {bet.status}",
)
# Get the outcome and check if event has started
outcome = crud.get_outcome(db, outcome_id=bet.outcome_id)
event = outcome.market.event
# Only allow cancellation if event hasn't started yet
if event.status != models.event.EventStatus.UPCOMING:
raise HTTPException(
status_code=400,
detail="Cannot cancel bets on events that have already started",
)
bet = crud.update_bet_status(db, bet_id=bet_id, status=BetStatus.CANCELLED)
return bet

View File

@ -0,0 +1,124 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
from app.models.event import EventStatus
router = APIRouter()
@router.get("/", response_model=list[schemas.Event])
def read_events(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
status: Optional[EventStatus] = None,
) -> Any:
"""
Retrieve events.
"""
events = crud.get_events(db, skip=skip, limit=limit, status=status)
return events
@router.post("/", response_model=schemas.Event)
def create_event(
*,
db: Session = Depends(deps.get_db),
event_in: schemas.EventCreate,
_: models.User = Depends(deps.get_current_admin_user), # Admin check only
) -> Any:
"""
Create new event. Admin only.
"""
event = crud.create_event(db, event_in=event_in)
return event
@router.get("/{event_id}", response_model=schemas.Event)
def read_event(
*,
db: Session = Depends(deps.get_db),
event_id: int,
) -> Any:
"""
Get specific event by ID.
"""
event = crud.get_event(db, event_id=event_id)
if not event:
raise HTTPException(status_code=404, detail="Event not found")
return event
@router.put("/{event_id}", response_model=schemas.Event)
def update_event(
*,
db: Session = Depends(deps.get_db),
event_id: int,
event_in: schemas.EventUpdate,
_: models.User = Depends(deps.get_current_admin_user), # Admin check only
) -> Any:
"""
Update an event. Admin only.
"""
event = crud.get_event(db, event_id=event_id)
if not event:
raise HTTPException(status_code=404, detail="Event not found")
event = crud.update_event(db, db_event=event, event_in=event_in)
return event
@router.delete("/{event_id}", status_code=204, response_model=None)
def delete_event(
*,
db: Session = Depends(deps.get_db),
event_id: int,
_: models.User = Depends(deps.get_current_admin_user), # Admin check only
) -> None:
"""
Delete an event. Admin only.
"""
event = crud.get_event(db, event_id=event_id)
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Check if there are any bets placed
if event.markets:
for market in event.markets:
if market.outcomes:
for outcome in market.outcomes:
if outcome.bets:
raise HTTPException(
status_code=400,
detail="Cannot delete event with existing bets"
)
crud.delete_event(db, event_id=event_id)
@router.post("/outcomes/{outcome_id}/settle", response_model=schemas.Outcome)
def settle_outcome(
*,
db: Session = Depends(deps.get_db),
outcome_id: int,
is_winner: bool,
_: models.User = Depends(deps.get_current_admin_user), # Admin check only
) -> Any:
"""
Settle an outcome as win or lose. Admin only.
This will also settle all related bets.
"""
outcome = crud.get_outcome(db, outcome_id=outcome_id)
if not outcome:
raise HTTPException(status_code=404, detail="Outcome not found")
# Set the outcome as winner or loser
outcome = crud.settle_outcome(db, outcome_id=outcome_id, is_winner=is_winner)
# Settle all bets for this outcome
crud.settle_bets_for_outcome(db, outcome_id=outcome_id, is_winner=is_winner)
return outcome

View File

@ -0,0 +1,97 @@
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.encoders import jsonable_encoder
from pydantic import EmailStr
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
router = APIRouter()
@router.post("/", response_model=schemas.User)
def create_user(
*,
db: Session = Depends(deps.get_db),
user_in: schemas.UserCreate,
) -> Any:
"""
Create new user.
"""
user = crud.get_user_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=400,
detail="The user with this email already exists in the system.",
)
user = crud.create_user(db, user=user_in)
return user
@router.get("/me", response_model=schemas.User)
def read_user_me(
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get current user.
"""
return current_user
@router.put("/me", response_model=schemas.User)
def update_user_me(
*,
db: Session = Depends(deps.get_db),
full_name: str = Body(None),
email: EmailStr = Body(None),
password: str = Body(None),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update own user.
"""
current_user_data = jsonable_encoder(current_user)
user_in = schemas.UserUpdate(**current_user_data)
if full_name is not None:
user_in.full_name = full_name
if email is not None:
user_in.email = email
if password is not None:
user_in.password = password
user = crud.update_user(db, db_user=current_user, user_in=user_in)
return user
@router.get("/{user_id}", response_model=schemas.User)
def read_user_by_id(
user_id: int,
current_user: models.User = Depends(deps.get_current_active_user),
db: Session = Depends(deps.get_db),
) -> Any:
"""
Get a specific user by id.
"""
user = crud.get_user(db, user_id=user_id)
if user == current_user:
return user
if not current_user.is_admin:
raise HTTPException(
status_code=403, detail="The user doesn't have enough privileges"
)
return user
@router.get("/", response_model=list[schemas.User])
def read_users(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
_: models.User = Depends(deps.get_current_admin_user), # Admin check only
) -> Any:
"""
Retrieve users. Admin only.
"""
users = crud.get_users(db, skip=skip, limit=limit)
return users

View File

@ -0,0 +1,91 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
from app.models.transaction import TransactionType
router = APIRouter()
@router.get("/balance", response_model=float)
def get_balance(
current_user: models.User = Depends(deps.get_current_active_user),
) -> float:
"""
Get current user's balance.
"""
return current_user.balance
@router.get("/transactions", response_model=list[schemas.Transaction])
def read_transactions(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
transaction_type: Optional[TransactionType] = None,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve transactions for current user.
"""
transactions = crud.get_user_transactions(
db, user_id=current_user.id, skip=skip, limit=limit,
transaction_type=transaction_type,
)
return transactions
@router.post("/deposit", response_model=schemas.Transaction)
def deposit_funds(
*,
db: Session = Depends(deps.get_db),
transaction_in: schemas.TransactionCreate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Deposit funds into user's account.
"""
if transaction_in.amount <= 0:
raise HTTPException(
status_code=400, detail="Deposit amount must be greater than 0",
)
transaction = crud.create_deposit(
db, user_id=current_user.id, transaction_in=transaction_in,
)
return transaction
@router.post("/withdraw", response_model=schemas.Transaction)
def withdraw_funds(
*,
db: Session = Depends(deps.get_db),
transaction_in: schemas.TransactionCreate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Withdraw funds from user's account.
"""
if transaction_in.amount <= 0:
raise HTTPException(
status_code=400, detail="Withdrawal amount must be greater than 0",
)
if current_user.balance < transaction_in.amount:
raise HTTPException(
status_code=400, detail="Insufficient balance for this withdrawal",
)
transaction = crud.create_withdrawal(
db, user_id=current_user.id, transaction_in=transaction_in,
)
if not transaction:
raise HTTPException(
status_code=400, detail="Could not process withdrawal",
)
return transaction

70
app/auth/deps.py Normal file
View File

@ -0,0 +1,70 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_db
from app.models.user import User
from app.schemas.token import TokenPayload
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login",
)
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme),
) -> User:
"""
Get the current user from the token.
"""
try:
payload = jwt.decode(
token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM],
)
token_data = TokenPayload(**payload)
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.query(User).filter(User.id == token_data.sub).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user",
)
return user
def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Get the current active user.
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user",
)
return current_user
def get_current_admin_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Get the current admin user.
"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions",
)
return current_user

38
app/core/config.py Normal file
View File

@ -0,0 +1,38 @@
import os
from pathlib import Path
from typing import List
from pydantic import EmailStr
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
API_V1_STR: str = "/api/v1"
SECRET_KEY: str
PROJECT_NAME: str
ENVIRONMENT: str
# JWT Settings
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
JWT_ALGORITHM: str = "HS256"
JWT_SECRET_KEY: str
# Security
ALLOWED_HOSTS: List[str] = ["*"]
# Admin user
FIRST_SUPERUSER_EMAIL: EmailStr
FIRST_SUPERUSER_PASSWORD: str
# Database
DB_DIR: Path = Path("/app") / "storage" / "db"
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()
# Ensure database directory exists
os.makedirs(settings.DB_DIR, exist_ok=True)

30
app/core/init_app.py Normal file
View File

@ -0,0 +1,30 @@
import logging
from app.core.config import settings
from app.crud.user import create_user, get_user_by_email
from app.db.session import SessionLocal
from app.schemas.user import UserCreate
logger = logging.getLogger(__name__)
def init_db() -> None:
"""Initialize database with first superuser."""
db = SessionLocal()
try:
# Check if there's already a superuser
user = get_user_by_email(db, email=settings.FIRST_SUPERUSER_EMAIL)
if not user:
logger.info("Creating first superuser")
user_in = UserCreate(
email=settings.FIRST_SUPERUSER_EMAIL,
password=settings.FIRST_SUPERUSER_PASSWORD,
is_admin=True,
)
create_user(db, user=user_in)
logger.info(f"Superuser {settings.FIRST_SUPERUSER_EMAIL} created")
else:
logger.info(f"Superuser {settings.FIRST_SUPERUSER_EMAIL} already exists")
except Exception as e:
logger.error(f"Error creating superuser: {e}")
finally:
db.close()

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

@ -0,0 +1,42 @@
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def create_access_token(
subject: Union[str, Any], expires_delta: Optional[timedelta] = None,
) -> str:
"""
Create a JWT access token.
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(
to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM,
)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against a hash.
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Generate a hash for a password.
"""
return pwd_context.hash(password)

33
app/crud/__init__.py Normal file
View File

@ -0,0 +1,33 @@
from app.crud.bet import (
create_bet,
get_bet,
get_user_bets,
settle_bets_for_outcome,
update_bet_status,
)
from app.crud.event import (
create_event,
delete_event,
get_event,
get_events,
get_market,
get_outcome,
settle_outcome,
update_event,
)
from app.crud.transaction import (
create_deposit,
create_withdrawal,
get_transaction,
get_user_transactions,
update_transaction_status,
)
from app.crud.user import (
authenticate,
create_user,
get_user,
get_user_by_email,
get_users,
update_user,
update_user_balance,
)

128
app/crud/bet.py Normal file
View File

@ -0,0 +1,128 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.event import get_outcome
from app.crud.user import update_user_balance
from app.models.bet import Bet, BetStatus
from app.models.transaction import Transaction, TransactionStatus, TransactionType
from app.schemas.bet import BetCreate
def get_bet(db: Session, bet_id: int) -> Optional[Bet]:
return db.query(Bet).filter(Bet.id == bet_id).first()
def get_user_bets(
db: Session, user_id: int, skip: int = 0, limit: int = 100, status: Optional[BetStatus] = None,
) -> List[Bet]:
query = db.query(Bet).filter(Bet.user_id == user_id)
if status:
query = query.filter(Bet.status == status)
return query.order_by(Bet.created_at.desc()).offset(skip).limit(limit).all()
def create_bet(db: Session, bet_in: BetCreate, user_id: int) -> Optional[Bet]:
# Get the outcome to check if it exists and get odds
outcome = get_outcome(db, bet_in.outcome_id)
if not outcome or not outcome.is_active:
return None
# Calculate potential win
potential_win = bet_in.amount * outcome.odds
# Create bet
db_bet = Bet(
user_id=user_id,
outcome_id=bet_in.outcome_id,
amount=bet_in.amount,
odds=outcome.odds,
potential_win=potential_win,
status=BetStatus.PENDING,
)
# Update user balance
update_user_balance(db, user_id, -bet_in.amount)
# Create transaction record
transaction = Transaction(
user_id=user_id,
amount=-bet_in.amount,
transaction_type=TransactionType.BET_PLACED,
status=TransactionStatus.COMPLETED,
bet_id=db_bet.id,
)
db.add(db_bet)
db.commit()
db.refresh(db_bet)
# Update transaction with bet_id
transaction.bet_id = db_bet.id
db.add(transaction)
db.commit()
return db_bet
def update_bet_status(
db: Session, bet_id: int, status: BetStatus,
) -> Optional[Bet]:
bet = get_bet(db, bet_id)
if bet and bet.status == BetStatus.PENDING:
bet.status = status
bet.settled_at = datetime.utcnow()
# If bet is won, create a transaction and update user balance
if status == BetStatus.WON:
transaction = Transaction(
user_id=bet.user_id,
amount=bet.potential_win,
transaction_type=TransactionType.BET_WON,
status=TransactionStatus.COMPLETED,
bet_id=bet.id,
)
db.add(transaction)
update_user_balance(db, bet.user_id, bet.potential_win)
# If bet is cancelled or voided, refund the amount
if status in [BetStatus.CANCELLED, BetStatus.VOIDED]:
transaction = Transaction(
user_id=bet.user_id,
amount=bet.amount,
transaction_type=TransactionType.BET_REFUND,
status=TransactionStatus.COMPLETED,
bet_id=bet.id,
)
db.add(transaction)
update_user_balance(db, bet.user_id, bet.amount)
db.add(bet)
db.commit()
db.refresh(bet)
return bet
def settle_bets_for_outcome(
db: Session, outcome_id: int, is_winner: bool,
) -> int:
"""
Settle all pending bets for a specific outcome.
Returns the number of bets settled.
"""
bets = db.query(Bet).filter(
Bet.outcome_id == outcome_id,
Bet.status == BetStatus.PENDING,
).all()
count = 0
for bet in bets:
if is_winner:
update_bet_status(db, bet.id, BetStatus.WON)
else:
update_bet_status(db, bet.id, BetStatus.LOST)
count += 1
return count

109
app/crud/event.py Normal file
View File

@ -0,0 +1,109 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.event import Event, EventStatus, Market, Outcome
from app.schemas.event import EventCreate, EventUpdate
def get_event(db: Session, event_id: int) -> Optional[Event]:
return db.query(Event).filter(Event.id == event_id).first()
def get_events(
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[EventStatus] = None,
) -> List[Event]:
query = db.query(Event)
if status:
query = query.filter(Event.status == status)
return query.offset(skip).limit(limit).all()
def create_event(db: Session, event_in: EventCreate) -> Event:
event_data = event_in.model_dump(exclude={"markets"})
db_event = Event(**event_data)
db.add(db_event)
db.commit()
db.refresh(db_event)
# Add markets
for market_in in event_in.markets:
market_data = market_in.model_dump(exclude={"outcomes"})
db_market = Market(**market_data, event_id=db_event.id)
db.add(db_market)
db.commit()
db.refresh(db_market)
# Add outcomes
for outcome_in in market_in.outcomes:
db_outcome = Outcome(**outcome_in.model_dump(), market_id=db_market.id)
db.add(db_outcome)
db.commit()
db.refresh(db_event)
return db_event
def update_event(db: Session, db_event: Event, event_in: EventUpdate) -> Event:
update_data = event_in.model_dump(exclude={"markets"}, exclude_unset=True)
for field, value in update_data.items():
setattr(db_event, field, value)
db.add(db_event)
db.commit()
db.refresh(db_event)
# Update markets if provided
if event_in.markets:
for market_update in event_in.markets:
market = next((m for m in db_event.markets if m.id == market_update.id), None)
if market:
market_data = market_update.model_dump(exclude={"outcomes"}, exclude_unset=True)
for field, value in market_data.items():
setattr(market, field, value)
db.add(market)
# Update outcomes if provided
if market_update.outcomes:
for outcome_update in market_update.outcomes:
outcome = next((o for o in market.outcomes if o.id == outcome_update.id), None)
if outcome:
for field, value in outcome_update.model_dump(exclude_unset=True).items():
setattr(outcome, field, value)
db.add(outcome)
db.commit()
db.refresh(db_event)
return db_event
def delete_event(db: Session, event_id: int) -> bool:
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
return False
db.delete(event)
db.commit()
return True
def get_market(db: Session, market_id: int) -> Optional[Market]:
return db.query(Market).filter(Market.id == market_id).first()
def get_outcome(db: Session, outcome_id: int) -> Optional[Outcome]:
return db.query(Outcome).filter(Outcome.id == outcome_id).first()
def settle_outcome(
db: Session, outcome_id: int, is_winner: bool,
) -> Optional[Outcome]:
outcome = get_outcome(db, outcome_id)
if outcome:
outcome.is_winner = is_winner
db.add(outcome)
db.commit()
db.refresh(outcome)
return outcome

92
app/crud/transaction.py Normal file
View File

@ -0,0 +1,92 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.user import update_user_balance
from app.models.transaction import Transaction, TransactionStatus, TransactionType
from app.schemas.transaction import TransactionCreate
def get_transaction(db: Session, transaction_id: int) -> Optional[Transaction]:
return db.query(Transaction).filter(Transaction.id == transaction_id).first()
def get_user_transactions(
db: Session, user_id: int, skip: int = 0, limit: int = 100,
transaction_type: Optional[TransactionType] = None,
) -> List[Transaction]:
query = db.query(Transaction).filter(Transaction.user_id == user_id)
if transaction_type:
query = query.filter(Transaction.transaction_type == transaction_type)
return query.order_by(Transaction.created_at.desc()).offset(skip).limit(limit).all()
def create_deposit(
db: Session, user_id: int, transaction_in: TransactionCreate,
) -> Transaction:
"""
Create a deposit transaction and update user balance.
"""
# Ensure it's a deposit
transaction_data = transaction_in.model_dump()
transaction_data["transaction_type"] = TransactionType.DEPOSIT
transaction_data["status"] = TransactionStatus.COMPLETED
transaction_data["user_id"] = user_id
# Create transaction
db_transaction = Transaction(**transaction_data)
db.add(db_transaction)
db.commit()
db.refresh(db_transaction)
# Update user balance
update_user_balance(db, user_id, transaction_in.amount)
return db_transaction
def create_withdrawal(
db: Session, user_id: int, transaction_in: TransactionCreate,
) -> Optional[Transaction]:
"""
Create a withdrawal transaction and update user balance.
Returns None if user doesn't have enough balance.
"""
from app.crud.user import get_user
user = get_user(db, user_id)
if not user or user.balance < transaction_in.amount:
return None
# Ensure it's a withdrawal and amount is negative
transaction_data = transaction_in.model_dump()
transaction_data["transaction_type"] = TransactionType.WITHDRAWAL
transaction_data["status"] = TransactionStatus.COMPLETED
transaction_data["user_id"] = user_id
transaction_data["amount"] = -abs(transaction_in.amount) # Ensure it's negative
# Create transaction
db_transaction = Transaction(**transaction_data)
db.add(db_transaction)
db.commit()
db.refresh(db_transaction)
# Update user balance
update_user_balance(db, user_id, transaction_data["amount"])
return db_transaction
def update_transaction_status(
db: Session, transaction_id: int, status: TransactionStatus,
) -> Optional[Transaction]:
"""
Update a transaction's status.
"""
transaction = get_transaction(db, transaction_id)
if transaction:
transaction.status = status
db.add(transaction)
db.commit()
db.refresh(transaction)
return transaction

78
app/crud/user.py Normal file
View File

@ -0,0 +1,78 @@
from typing import Any, Dict, Optional, Union
from sqlalchemy.orm import Session
from app.core.security import get_password_hash, verify_password
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
def get_user(db: Session, user_id: int) -> Optional[User]:
return db.query(User).filter(User.id == user_id).first()
def get_user_by_email(db: Session, email: str) -> Optional[User]:
return db.query(User).filter(User.email == email).first()
def get_users(db: Session, skip: int = 0, limit: int = 100) -> list[User]:
return db.query(User).offset(skip).limit(limit).all()
def create_user(db: Session, user: UserCreate) -> User:
hashed_password = get_password_hash(user.password)
db_user = User(
email=user.email,
hashed_password=hashed_password,
full_name=user.full_name,
is_active=user.is_active,
is_admin=user.is_admin,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
def update_user(
db: Session, db_user: User, user_in: Union[UserUpdate, Dict[str, Any]],
) -> User:
if isinstance(user_in, dict):
update_data = user_in
else:
update_data = user_in.model_dump(exclude_unset=True)
if update_data.get("password"):
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
for field, value in update_data.items():
setattr(db_user, field, value)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
def update_user_balance(db: Session, user_id: int, amount: float) -> User:
"""
Update user balance. Positive amount adds to balance, negative amount subtracts.
"""
user = get_user(db, user_id)
if user:
user.balance += amount
db.add(user)
db.commit()
db.refresh(user)
return user
def authenticate(db: Session, email: str, password: str) -> Optional[User]:
user = get_user_by_email(db, email=email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user

0
app/db/__init__.py Normal file
View File

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

@ -0,0 +1,29 @@
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Create DB directory if it doesn't exist
settings.DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{settings.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()
# Dependency
def get_db() -> Generator:
db = SessionLocal()
try:
yield db
finally:
db.close()

4
app/models/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from app.models.bet import Bet, BetStatus
from app.models.event import Event, Market, Outcome
from app.models.transaction import Transaction, TransactionStatus, TransactionType
from app.models.user import User

34
app/models/bet.py Normal file
View File

@ -0,0 +1,34 @@
from enum import Enum as PyEnum
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.session import Base
class BetStatus(str, PyEnum):
PENDING = "pending"
WON = "won"
LOST = "lost"
CANCELLED = "cancelled"
VOIDED = "voided"
class Bet(Base):
__tablename__ = "bets"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
outcome_id = Column(Integer, ForeignKey("outcomes.id"), nullable=False)
amount = Column(Float, nullable=False)
odds = Column(Float, nullable=False) # Stored odds at time of bet
potential_win = Column(Float, nullable=False)
status = Column(Enum(BetStatus), default=BetStatus.PENDING)
settled_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", back_populates="bets")
outcome = relationship("Outcome", back_populates="bets")

71
app/models/event.py Normal file
View File

@ -0,0 +1,71 @@
from enum import Enum as PyEnum
from sqlalchemy import (
Boolean,
Column,
DateTime,
Enum,
Float,
ForeignKey,
Integer,
String,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.session import Base
class EventStatus(str, PyEnum):
UPCOMING = "upcoming"
LIVE = "live"
FINISHED = "finished"
CANCELLED = "cancelled"
class Event(Base):
__tablename__ = "events"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(String, nullable=True)
start_time = Column(DateTime(timezone=True), nullable=False)
end_time = Column(DateTime(timezone=True), nullable=True)
status = Column(Enum(EventStatus), default=EventStatus.UPCOMING)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Relationships
markets = relationship("Market", back_populates="event", cascade="all, delete-orphan")
class Market(Base):
__tablename__ = "markets"
id = Column(Integer, primary_key=True, index=True)
event_id = Column(Integer, ForeignKey("events.id"), nullable=False)
name = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Relationships
event = relationship("Event", back_populates="markets")
outcomes = relationship("Outcome", back_populates="market", cascade="all, delete-orphan")
class Outcome(Base):
__tablename__ = "outcomes"
id = Column(Integer, primary_key=True, index=True)
market_id = Column(Integer, ForeignKey("markets.id"), nullable=False)
name = Column(String, nullable=False)
odds = Column(Float, nullable=False)
is_winner = Column(Boolean, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Relationships
market = relationship("Market", back_populates="outcomes")
bets = relationship("Bet", back_populates="outcome")

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

@ -0,0 +1,41 @@
from enum import Enum as PyEnum
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.session import Base
class TransactionType(str, PyEnum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
BET_PLACED = "bet_placed"
BET_WON = "bet_won"
BET_LOST = "bet_lost"
BET_REFUND = "bet_refund"
class TransactionStatus(str, PyEnum):
PENDING = "pending"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
amount = Column(Float, nullable=False)
transaction_type = Column(Enum(TransactionType), nullable=False)
status = Column(Enum(TransactionStatus), default=TransactionStatus.PENDING)
reference = Column(String, nullable=True) # For external references
bet_id = Column(Integer, ForeignKey("bets.id"), nullable=True) # Optional link to a bet
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Relationships
user = relationship("User", back_populates="transactions")
bet = relationship("Bet", backref="transactions")

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

@ -0,0 +1,23 @@
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.session 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=True)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
balance = Column(Float, default=0.0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Relationships
bets = relationship("Bet", back_populates="user")
transactions = relationship("Transaction", back_populates="user")

15
app/schemas/__init__.py Normal file
View File

@ -0,0 +1,15 @@
from app.schemas.bet import Bet, BetCreate, BetUpdate
from app.schemas.event import (
Event,
EventCreate,
EventUpdate,
Market,
MarketCreate,
MarketUpdate,
Outcome,
OutcomeCreate,
OutcomeUpdate,
)
from app.schemas.token import Token, TokenPayload
from app.schemas.transaction import Transaction, TransactionCreate, TransactionUpdate
from app.schemas.user import User, UserCreate, UserInDB, UserUpdate

33
app/schemas/bet.py Normal file
View File

@ -0,0 +1,33 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from app.models.bet import BetStatus
class BetBase(BaseModel):
amount: float
odds: float
potential_win: float
class BetCreate(BaseModel):
outcome_id: int
amount: float
class BetUpdate(BaseModel):
status: BetStatus
class Bet(BetBase):
id: int
user_id: int
outcome_id: int
status: BetStatus
settled_at: Optional[datetime] = None
created_at: datetime
class Config:
from_attributes = True

85
app/schemas/event.py Normal file
View File

@ -0,0 +1,85 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from app.models.event import EventStatus
class OutcomeBase(BaseModel):
name: str
odds: float
is_active: bool = True
class OutcomeCreate(OutcomeBase):
pass
class OutcomeUpdate(OutcomeBase):
name: Optional[str] = None
odds: Optional[float] = None
is_active: Optional[bool] = None
is_winner: Optional[bool] = None
class Outcome(OutcomeBase):
id: int
market_id: int
is_winner: Optional[bool] = None
class Config:
from_attributes = True
class MarketBase(BaseModel):
name: str
is_active: bool = True
class MarketCreate(MarketBase):
outcomes: list[OutcomeCreate]
class MarketUpdate(MarketBase):
name: Optional[str] = None
is_active: Optional[bool] = None
outcomes: Optional[list[OutcomeUpdate]] = None
class Market(MarketBase):
id: int
event_id: int
outcomes: list[Outcome] = []
class Config:
from_attributes = True
class EventBase(BaseModel):
name: str
description: Optional[str] = None
start_time: datetime
end_time: Optional[datetime] = None
status: EventStatus = EventStatus.UPCOMING
class EventCreate(EventBase):
markets: list[MarketCreate]
class EventUpdate(EventBase):
name: Optional[str] = None
description: Optional[str] = None
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
status: Optional[EventStatus] = None
markets: Optional[list[MarketUpdate]] = None
class Event(EventBase):
id: int
markets: list[Market] = []
class Config:
from_attributes = True

12
app/schemas/token.py Normal file
View File

@ -0,0 +1,12 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
class TokenPayload(BaseModel):
sub: Optional[int] = None

View File

@ -0,0 +1,33 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from app.models.transaction import TransactionStatus, TransactionType
class TransactionBase(BaseModel):
amount: float
transaction_type: TransactionType
status: TransactionStatus = TransactionStatus.PENDING
reference: Optional[str] = None
bet_id: Optional[int] = None
class TransactionCreate(BaseModel):
amount: float
transaction_type: TransactionType = TransactionType.DEPOSIT
reference: Optional[str] = None
class TransactionUpdate(BaseModel):
status: TransactionStatus
class Transaction(TransactionBase):
id: int
user_id: int
created_at: datetime
class Config:
from_attributes = True

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

@ -0,0 +1,35 @@
from typing import Optional
from pydantic import BaseModel, EmailStr
class UserBase(BaseModel):
email: Optional[EmailStr] = None
full_name: Optional[str] = None
is_active: Optional[bool] = True
is_admin: bool = False
class UserCreate(UserBase):
email: EmailStr
password: str
class UserUpdate(UserBase):
password: Optional[str] = None
class UserInDBBase(UserBase):
id: Optional[int] = None
balance: float = 0.0
class Config:
from_attributes = True
class User(UserInDBBase):
pass
class UserInDB(UserInDBBase):
hashed_password: str

33
main.py Normal file
View File

@ -0,0 +1,33 @@
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.api import api_router
from app.core.config import settings
app = FastAPI(
title=settings.PROJECT_NAME,
description="Betting Application API",
version="0.1.0",
openapi_url="/openapi.json",
)
# Set up CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Add API router
app.include_router(api_router, prefix=settings.API_V1_STR)
# Health check endpoint
@app.get("/health", tags=["Health"])
async def health_check() -> dict:
return {"status": "ok"}
if __name__ == "__main__":
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)

89
migrations/env.py Normal file
View File

@ -0,0 +1,89 @@
import os
import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Add the parent directory to the path so we can import from our app
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Import the SQLAlchemy metadata and database URL
from app.db.session import SQLALCHEMY_DATABASE_URL, Base
from app.models import * # This is important to load all models
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# Set the database URL in the alembic config
config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
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():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
is_sqlite = connection.dialect.name == "sqlite"
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=is_sqlite, # This is important for SQLite
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/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,134 @@
"""Initial schema
Revision ID: 6e85b8c2a123
Revises:
Create Date: 2023-06-21 15:45:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision = '6e85b8c2a123'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Users table
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=True),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('is_admin', sa.Boolean(), nullable=True, default=False),
sa.Column('balance', sa.Float(), nullable=True, default=0.0),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
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)
# Events table
op.create_table(
'events',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('start_time', sa.DateTime(timezone=True), nullable=False),
sa.Column('end_time', sa.DateTime(timezone=True), nullable=True),
sa.Column('status', sa.Enum('upcoming', 'live', 'finished', 'cancelled', name='eventstatus'), nullable=True, default='upcoming'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_events_id'), 'events', ['id'], unique=False)
# Markets table
op.create_table(
'markets',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('event_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_markets_id'), 'markets', ['id'], unique=False)
# Outcomes table
op.create_table(
'outcomes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('market_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('odds', sa.Float(), nullable=False),
sa.Column('is_winner', sa.Boolean(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.ForeignKeyConstraint(['market_id'], ['markets.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_outcomes_id'), 'outcomes', ['id'], unique=False)
# Bets table
op.create_table(
'bets',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('outcome_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('odds', sa.Float(), nullable=False),
sa.Column('potential_win', sa.Float(), nullable=False),
sa.Column('status', sa.Enum('pending', 'won', 'lost', 'cancelled', 'voided', name='betstatus'), nullable=True, default='pending'),
sa.Column('settled_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.ForeignKeyConstraint(['outcome_id'], ['outcomes.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_bets_id'), 'bets', ['id'], unique=False)
# Transactions table
op.create_table(
'transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('transaction_type', sa.Enum('deposit', 'withdrawal', 'bet_placed', 'bet_won', 'bet_lost', 'bet_refund', name='transactiontype'), nullable=False),
sa.Column('status', sa.Enum('pending', 'completed', 'failed', 'cancelled', name='transactionstatus'), nullable=True, default='pending'),
sa.Column('reference', sa.String(), nullable=True),
sa.Column('bet_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.ForeignKeyConstraint(['bet_id'], ['bets.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_transactions_id'), 'transactions', ['id'], unique=False)
def downgrade():
op.drop_index(op.f('ix_transactions_id'), table_name='transactions')
op.drop_table('transactions')
op.drop_index(op.f('ix_bets_id'), table_name='bets')
op.drop_table('bets')
op.drop_index(op.f('ix_outcomes_id'), table_name='outcomes')
op.drop_table('outcomes')
op.drop_index(op.f('ix_markets_id'), table_name='markets')
op.drop_table('markets')
op.drop_index(op.f('ix_events_id'), table_name='events')
op.drop_table('events')
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')

28
pyproject.toml Normal file
View File

@ -0,0 +1,28 @@
[tool.ruff]
line-length = 120
select = ["E", "F", "B", "I", "N", "UP", "ANN", "S", "A", "COM", "C90", "T10", "EM", "EXE", "ISC", "ICN", "G", "INP", "PIE", "T20", "PT", "Q", "SIM", "TID", "INT", "ARG", "ERA", "PD", "PGH", "PL", "TRY", "RSE", "SLF", "RUF", "YTT"]
ignore = ["ANN101", "ANN102", "ANN204", "ANN401", "B008", "E501", "INP001"]
target-version = "py39"
exclude = [
".git",
".github",
".mypy_cache",
".pytest_cache",
".venv",
"venv",
"__pycache__",
"dist",
"migrations",
]
[tool.ruff.per-file-ignores]
"__init__.py" = ["F401"]
"app/tests/*" = ["S101"]
"app/models/*" = ["ANN"]
"app/schemas/*" = ["ANN"]
[tool.ruff.isort]
known-third-party = ["fastapi", "pydantic", "sqlalchemy", "alembic", "jose", "passlib"]
[tool.ruff.flake8-tidy-imports]
ban-relative-imports = "all"

13
requirements.txt Normal file
View File

@ -0,0 +1,13 @@
fastapi>=0.95.0
uvicorn>=0.22.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
sqlalchemy>=2.0.0
alembic>=1.10.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
email-validator>=2.0.0
python-dotenv>=1.0.0
ruff>=0.0.270
tenacity>=8.2.2