Create comprehensive gym membership management system

Features:
- User registration and authentication with JWT tokens
- Multi-level admin access (Admin and Super Admin)
- Gym management with membership plans
- Subscription management with payment integration
- Stripe and Paystack payment gateway support
- Role-based access control
- SQLite database with Alembic migrations
- Comprehensive API endpoints with FastAPI
- Database models for users, gyms, memberships, subscriptions, and transactions
- Admin endpoints for user management and financial reporting
- Health check and documentation endpoints

Core Components:
- FastAPI application with CORS support
- SQLAlchemy ORM with relationship mapping
- JWT-based authentication with bcrypt password hashing
- Payment service abstraction for multiple gateways
- Pydantic schemas for request/response validation
- Alembic database migration system
- Admin dashboard functionality
- Environment variable configuration
This commit is contained in:
Automated Action 2025-06-20 09:08:21 +00:00
parent 13d822da7d
commit b78ac1f072
40 changed files with 2244 additions and 2 deletions

178
README.md
View File

@ -1,3 +1,177 @@
# FastAPI Application # Gym Membership Management System
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A comprehensive FastAPI-based platform for gyms to manage member data and subscriptions with integrated payment processing.
## Features
- **User Management**: User registration, authentication, and profile management
- **Gym Management**: Multi-gym support with gym registration and management
- **Membership Plans**: Flexible membership plan creation and management
- **Subscription Management**: Handle user subscriptions to membership plans
- **Payment Integration**: Support for Stripe and Paystack payment gateways
- **Multi-level Admin Access**:
- Admin: Can manage users, gyms, memberships, and subscriptions
- Super Admin: Full access including financial data and admin management
- **Role-based Access Control**: Different permission levels for users, admins, and super admins
## Tech Stack
- **Backend**: FastAPI (Python)
- **Database**: SQLite with SQLAlchemy ORM
- **Authentication**: JWT tokens with bcrypt password hashing
- **Migrations**: Alembic for database migrations
- **Payment Gateways**: Stripe and Paystack integration
- **Code Quality**: Ruff for linting and formatting
## Project Structure
```
├── app/
│ ├── api/v1/endpoints/ # API endpoints
│ ├── core/ # Core configurations and security
│ ├── db/ # Database configuration
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic schemas
│ └── services/ # Business logic services
├── alembic/ # Database migrations
├── main.py # FastAPI application entry point
└── requirements.txt # Python dependencies
```
## API Endpoints
### Authentication
- `POST /api/v1/auth/register` - User registration
- `POST /api/v1/auth/login` - User login
### Users
- `GET /api/v1/users/me` - Get current user profile
- `PUT /api/v1/users/me` - Update user profile
- `GET /api/v1/users/me/memberships` - Get user gym memberships
- `GET /api/v1/users/me/subscriptions` - Get user subscriptions
### Gyms
- `GET /api/v1/gyms/` - List all gyms
- `GET /api/v1/gyms/{gym_id}` - Get gym details
- `POST /api/v1/gyms/{gym_id}/join` - Join a gym
- `GET /api/v1/gyms/{gym_id}/membership-plans` - Get gym membership plans
- `POST /api/v1/gyms/` - Create gym (Admin only)
- `PUT /api/v1/gyms/{gym_id}` - Update gym (Admin only)
### Membership Plans
- `GET /api/v1/memberships/plans` - List membership plans
- `GET /api/v1/memberships/plans/{plan_id}` - Get plan details
- `POST /api/v1/memberships/plans` - Create plan (Admin only)
- `PUT /api/v1/memberships/plans/{plan_id}` - Update plan (Admin only)
- `DELETE /api/v1/memberships/plans/{plan_id}` - Deactivate plan (Admin only)
### Subscriptions
- `GET /api/v1/subscriptions/` - Get user subscriptions
- `GET /api/v1/subscriptions/{subscription_id}` - Get subscription details
- `POST /api/v1/subscriptions/` - Create subscription
- `POST /api/v1/subscriptions/{subscription_id}/cancel` - Cancel subscription
- `PUT /api/v1/subscriptions/{subscription_id}` - Update subscription (Admin only)
### Payments
- `POST /api/v1/payments/initialize` - Initialize payment
- `POST /api/v1/payments/verify/{transaction_id}` - Verify payment
- `GET /api/v1/payments/transactions` - Get user transactions
### Admin
- `GET /api/v1/admin/users` - List all users (Admin only)
- `GET /api/v1/admin/users/{user_id}` - Get user details (Admin only)
- `GET /api/v1/admin/users/{user_id}/subscriptions` - Get user subscriptions (Admin only)
- `GET /api/v1/admin/users/{user_id}/transactions` - Get user transactions (Super Admin only)
- `GET /api/v1/admin/stats/overview` - Get overview statistics (Admin only)
- `GET /api/v1/admin/stats/financial` - Get financial statistics (Super Admin only)
- `GET /api/v1/admin/transactions` - Get all transactions (Super Admin only)
- `POST /api/v1/admin/invite-admin` - Invite new admin (Super Admin only)
- `DELETE /api/v1/admin/remove-admin/{admin_id}` - Remove admin (Super Admin only)
- `GET /api/v1/admin/admins` - List all admins (Super Admin only)
## Environment Variables
Set the following environment variables for production use:
```env
# Security
SECRET_KEY=your-secret-key-here
# Stripe Payment Gateway
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key
# Paystack Payment Gateway
PAYSTACK_SECRET_KEY=sk_test_your_paystack_secret_key
PAYSTACK_PUBLIC_KEY=pk_test_your_paystack_public_key
```
## Installation and Setup
1. **Install dependencies**:
```bash
pip install -r requirements.txt
```
2. **Set up environment variables** (see Environment Variables section above)
3. **Run database migrations**:
```bash
alembic upgrade head
```
4. **Start the application**:
```bash
uvicorn main:app --reload
```
5. **Access the API**:
- API Documentation: http://localhost:8000/docs
- Alternative Docs: http://localhost:8000/redoc
- OpenAPI Schema: http://localhost:8000/openapi.json
- Health Check: http://localhost:8000/health
## Database
The application uses SQLite with the database file stored at `/app/storage/db/db.sqlite`. The database includes the following main tables:
- `users` - User accounts with role-based access
- `gyms` - Gym information and details
- `membership_plans` - Available membership plans per gym
- `gym_memberships` - User-gym relationships
- `subscriptions` - User subscriptions to membership plans
- `transactions` - Payment transaction records
## Payment Integration
The system supports two payment gateways:
### Stripe
- Handles payments in USD
- Uses Payment Intents for secure processing
- Requires STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY
### Paystack
- Handles payments in NGN (Nigerian Naira)
- Uses transaction initialization and verification
- Requires PAYSTACK_SECRET_KEY and PAYSTACK_PUBLIC_KEY
## Security Features
- JWT-based authentication
- Password hashing with bcrypt
- Role-based access control (User, Admin, Super Admin)
- CORS configuration for cross-origin requests
- Input validation with Pydantic schemas
## Development
Run the linter to ensure code quality:
```bash
ruff check .
ruff format .
```
## License
This project was generated by BackendIM, an AI-powered backend generation platform.

97
alembic.ini Normal file
View File

@ -0,0 +1,97 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# 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 number format
version_num_format = %04d
# version path separator; default is "/"
# version_path_separator = :
# set to 'true' to search source files recursively
# in each "version_locations" directory
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
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 REVISION_SCRIPT_FILENAME
# 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

77
alembic/env.py Normal file
View File

@ -0,0 +1,77 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Import your models here
from app.db.base import Base
# 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.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 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() -> None:
"""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() -> None:
"""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:
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() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,202 @@
"""Initial migration
Revision ID: 0001
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create users table
op.create_table(
"users",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("email", sa.String(), nullable=False),
sa.Column("full_name", sa.String(), nullable=False),
sa.Column("phone", sa.String(), nullable=True),
sa.Column("hashed_password", sa.String(), nullable=False),
sa.Column(
"role",
sa.Enum("user", "admin", "super_admin", name="userrole"),
nullable=False,
),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("invited_by", sa.Integer(), 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)
# Create gyms table
op.create_table(
"gyms",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("address", sa.String(), nullable=False),
sa.Column("city", sa.String(), nullable=False),
sa.Column("state", sa.String(), nullable=False),
sa.Column("phone", sa.String(), nullable=True),
sa.Column("email", sa.String(), nullable=True),
sa.Column("is_active", 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_gyms_id"), "gyms", ["id"], unique=False)
op.create_index(op.f("ix_gyms_name"), "gyms", ["name"], unique=False)
# Create membership_plans table
op.create_table(
"membership_plans",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("gym_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"plan_type",
sa.Enum("basic", "premium", "vip", name="plantype"),
nullable=False,
),
sa.Column("price", sa.Float(), nullable=False),
sa.Column("duration_days", sa.Integer(), nullable=False),
sa.Column("features", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["gym_id"],
["gyms.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_membership_plans_id"), "membership_plans", ["id"], unique=False
)
# Create gym_memberships table
op.create_table(
"gym_memberships",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("gym_id", sa.Integer(), nullable=False),
sa.Column(
"status",
sa.Enum(
"active", "inactive", "expired", "suspended", name="membershipstatus"
),
nullable=True,
),
sa.Column("joined_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["gym_id"],
["gyms.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_gym_memberships_id"), "gym_memberships", ["id"], unique=False
)
# Create subscriptions table
op.create_table(
"subscriptions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("membership_plan_id", sa.Integer(), nullable=False),
sa.Column(
"status",
sa.Enum(
"active", "expired", "cancelled", "pending", name="subscriptionstatus"
),
nullable=True,
),
sa.Column("start_date", sa.DateTime(), nullable=False),
sa.Column("end_date", sa.DateTime(), nullable=False),
sa.Column("amount_paid", sa.Float(), nullable=False),
sa.Column("payment_reference", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["membership_plan_id"],
["membership_plans.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_subscriptions_id"), "subscriptions", ["id"], unique=False)
# Create transactions table
op.create_table(
"transactions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("subscription_id", sa.Integer(), nullable=True),
sa.Column("amount", sa.Float(), nullable=False),
sa.Column("currency", sa.String(), nullable=False),
sa.Column(
"status",
sa.Enum(
"pending", "completed", "failed", "refunded", name="transactionstatus"
),
nullable=True,
),
sa.Column(
"payment_gateway",
sa.Enum("stripe", "paystack", name="paymentgateway"),
nullable=False,
),
sa.Column("gateway_transaction_id", sa.String(), nullable=True),
sa.Column("gateway_reference", sa.String(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["subscription_id"],
["subscriptions.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_transactions_id"), "transactions", ["id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_transactions_id"), table_name="transactions")
op.drop_table("transactions")
op.drop_index(op.f("ix_subscriptions_id"), table_name="subscriptions")
op.drop_table("subscriptions")
op.drop_index(op.f("ix_gym_memberships_id"), table_name="gym_memberships")
op.drop_table("gym_memberships")
op.drop_index(op.f("ix_membership_plans_id"), table_name="membership_plans")
op.drop_table("membership_plans")
op.drop_index(op.f("ix_gyms_name"), table_name="gyms")
op.drop_index(op.f("ix_gyms_id"), table_name="gyms")
op.drop_table("gyms")
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")

0
app/__init__.py Normal file
View File

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

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

View File

View File

@ -0,0 +1,222 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.db.session import get_db
from app.core.deps import get_current_admin_user, get_current_super_admin_user
from app.core.security import get_password_hash
from app.models.user import User, UserRole
from app.models.gym import Gym
from app.models.membership import GymMembership
from app.models.subscription import Subscription
from app.models.transaction import Transaction, TransactionStatus
from app.schemas.user import User as UserSchema, AdminInvite
router = APIRouter()
@router.get("/users", response_model=List[UserSchema])
def get_all_users(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
users = db.query(User).offset(skip).limit(limit).all()
return users
@router.get("/users/{user_id}", response_model=UserSchema)
def get_user_by_id(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.get("/users/{user_id}/subscriptions")
def get_user_subscriptions(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
subscriptions = db.query(Subscription).filter(Subscription.user_id == user_id).all()
return subscriptions
@router.get("/users/{user_id}/transactions")
def get_user_transactions(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(
get_current_super_admin_user
), # Only super admin can view financial data
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
transactions = db.query(Transaction).filter(Transaction.user_id == user_id).all()
return transactions
@router.get("/stats/overview")
def get_overview_stats(
db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)
):
total_users = (
db.query(func.count(User.id)).filter(User.role == UserRole.USER).scalar()
)
total_gyms = db.query(func.count(Gym.id)).filter(Gym.is_active).scalar()
total_memberships = db.query(func.count(GymMembership.id)).scalar()
active_subscriptions = (
db.query(func.count(Subscription.id))
.filter(Subscription.status == "active")
.scalar()
)
return {
"total_users": total_users,
"total_gyms": total_gyms,
"total_memberships": total_memberships,
"active_subscriptions": active_subscriptions,
}
@router.get("/stats/financial")
def get_financial_stats(
db: Session = Depends(get_db),
current_user: User = Depends(
get_current_super_admin_user
), # Only super admin can view financial data
):
total_revenue = (
db.query(func.sum(Transaction.amount))
.filter(Transaction.status == TransactionStatus.COMPLETED)
.scalar()
or 0
)
pending_revenue = (
db.query(func.sum(Transaction.amount))
.filter(Transaction.status == TransactionStatus.PENDING)
.scalar()
or 0
)
failed_transactions = (
db.query(func.count(Transaction.id))
.filter(Transaction.status == TransactionStatus.FAILED)
.scalar()
)
return {
"total_revenue": total_revenue,
"pending_revenue": pending_revenue,
"failed_transactions": failed_transactions,
}
@router.get("/transactions")
def get_all_transactions(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(
get_current_super_admin_user
), # Only super admin can view all transactions
):
transactions = db.query(Transaction).offset(skip).limit(limit).all()
return transactions
@router.post("/invite-admin")
def invite_admin(
admin_data: AdminInvite,
db: Session = Depends(get_db),
current_user: User = Depends(
get_current_super_admin_user
), # Only super admin can invite admins
):
existing_user = db.query(User).filter(User.email == admin_data.email).first()
if existing_user:
raise HTTPException(
status_code=400, detail="User with this email already exists"
)
if admin_data.role == UserRole.SUPER_ADMIN:
raise HTTPException(status_code=400, detail="Cannot invite super admin")
# Generate a temporary password (in production, send via email)
temp_password = (
"TempPass123!" # In production, generate random password and send via email
)
hashed_password = get_password_hash(temp_password)
new_admin = User(
email=admin_data.email,
full_name=admin_data.full_name,
role=admin_data.role,
hashed_password=hashed_password,
invited_by=current_user.id,
)
db.add(new_admin)
db.commit()
db.refresh(new_admin)
return {
"message": "Admin invited successfully",
"admin_id": new_admin.id,
"temporary_password": temp_password, # In production, don't return this
}
@router.delete("/remove-admin/{admin_id}")
def remove_admin(
admin_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(
get_current_super_admin_user
), # Only super admin can remove admins
):
admin = (
db.query(User)
.filter(
User.id == admin_id, User.role.in_([UserRole.ADMIN, UserRole.SUPER_ADMIN])
)
.first()
)
if not admin:
raise HTTPException(status_code=404, detail="Admin not found")
if admin.role == UserRole.SUPER_ADMIN:
raise HTTPException(status_code=400, detail="Cannot remove super admin")
admin.is_active = False
db.commit()
return {"message": "Admin removed successfully"}
@router.get("/admins")
def get_all_admins(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_super_admin_user),
):
admins = (
db.query(User)
.filter(User.role.in_([UserRole.ADMIN, UserRole.SUPER_ADMIN]))
.all()
)
return admins

View File

@ -0,0 +1,54 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core import security
from app.core.config import settings
from app.models.user import User
from app.schemas.user import Token, UserCreate, User as UserSchema, UserLogin
router = APIRouter()
@router.post("/register", response_model=UserSchema)
def register(user_data: UserCreate, db: Session = Depends(get_db)):
db_user = db.query(User).filter(User.email == user_data.email).first()
if db_user:
raise HTTPException(status_code=400, detail="Email already registered")
hashed_password = security.get_password_hash(user_data.password)
db_user = User(
email=user_data.email,
full_name=user_data.full_name,
phone=user_data.phone,
hashed_password=hashed_password,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.post("/login", response_model=Token)
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 security.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"},
)
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = security.create_access_token(
data={"sub": user.email}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

View File

@ -0,0 +1,102 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.deps import get_current_active_user, get_current_admin_user
from app.models.gym import Gym
from app.models.membership import GymMembership, MembershipPlan
from app.models.user import User
from app.schemas.gym import Gym as GymSchema, GymCreate, GymUpdate
from app.schemas.membership import GymMembership as GymMembershipSchema
router = APIRouter()
@router.get("/", response_model=List[GymSchema])
def read_gyms(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
gyms = db.query(Gym).filter(Gym.is_active).offset(skip).limit(limit).all()
return gyms
@router.get("/{gym_id}", response_model=GymSchema)
def read_gym(gym_id: int, db: Session = Depends(get_db)):
gym = db.query(Gym).filter(Gym.id == gym_id, Gym.is_active).first()
if not gym:
raise HTTPException(status_code=404, detail="Gym not found")
return gym
@router.get("/{gym_id}/membership-plans")
def get_gym_membership_plans(gym_id: int, db: Session = Depends(get_db)):
gym = db.query(Gym).filter(Gym.id == gym_id, Gym.is_active).first()
if not gym:
raise HTTPException(status_code=404, detail="Gym not found")
plans = (
db.query(MembershipPlan)
.filter(MembershipPlan.gym_id == gym_id, MembershipPlan.is_active)
.all()
)
return plans
@router.post("/{gym_id}/join", response_model=GymMembershipSchema)
def join_gym(
gym_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
gym = db.query(Gym).filter(Gym.id == gym_id, Gym.is_active).first()
if not gym:
raise HTTPException(status_code=404, detail="Gym not found")
existing_membership = (
db.query(GymMembership)
.filter(
GymMembership.user_id == current_user.id, GymMembership.gym_id == gym_id
)
.first()
)
if existing_membership:
raise HTTPException(status_code=400, detail="Already a member of this gym")
membership = GymMembership(user_id=current_user.id, gym_id=gym_id)
db.add(membership)
db.commit()
db.refresh(membership)
return membership
@router.post("/", response_model=GymSchema)
def create_gym(
gym_data: GymCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
gym = Gym(**gym_data.dict())
db.add(gym)
db.commit()
db.refresh(gym)
return gym
@router.put("/{gym_id}", response_model=GymSchema)
def update_gym(
gym_id: int,
gym_update: GymUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
gym = db.query(Gym).filter(Gym.id == gym_id).first()
if not gym:
raise HTTPException(status_code=404, detail="Gym not found")
update_data = gym_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(gym, field, value)
db.commit()
db.refresh(gym)
return gym

View File

@ -0,0 +1,93 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.deps import get_current_admin_user
from app.models.membership import MembershipPlan
from app.models.gym import Gym
from app.schemas.membership import (
MembershipPlan as MembershipPlanSchema,
MembershipPlanCreate,
MembershipPlanUpdate,
)
router = APIRouter()
@router.get("/plans", response_model=List[MembershipPlanSchema])
def read_membership_plans(
skip: int = 0, limit: int = 100, db: Session = Depends(get_db)
):
plans = (
db.query(MembershipPlan)
.filter(MembershipPlan.is_active)
.offset(skip)
.limit(limit)
.all()
)
return plans
@router.get("/plans/{plan_id}", response_model=MembershipPlanSchema)
def read_membership_plan(plan_id: int, db: Session = Depends(get_db)):
plan = (
db.query(MembershipPlan)
.filter(MembershipPlan.id == plan_id, MembershipPlan.is_active)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="Membership plan not found")
return plan
@router.post("/plans", response_model=MembershipPlanSchema)
def create_membership_plan(
plan_data: MembershipPlanCreate,
db: Session = Depends(get_db),
current_user=Depends(get_current_admin_user),
):
gym = db.query(Gym).filter(Gym.id == plan_data.gym_id, Gym.is_active).first()
if not gym:
raise HTTPException(status_code=404, detail="Gym not found")
plan = MembershipPlan(**plan_data.dict())
db.add(plan)
db.commit()
db.refresh(plan)
return plan
@router.put("/plans/{plan_id}", response_model=MembershipPlanSchema)
def update_membership_plan(
plan_id: int,
plan_update: MembershipPlanUpdate,
db: Session = Depends(get_db),
current_user=Depends(get_current_admin_user),
):
plan = db.query(MembershipPlan).filter(MembershipPlan.id == plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="Membership plan not found")
update_data = plan_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(plan, field, value)
db.commit()
db.refresh(plan)
return plan
@router.delete("/plans/{plan_id}")
def delete_membership_plan(
plan_id: int,
db: Session = Depends(get_db),
current_user=Depends(get_current_admin_user),
):
plan = db.query(MembershipPlan).filter(MembershipPlan.id == plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="Membership plan not found")
plan.is_active = False
db.commit()
return {"message": "Membership plan deactivated successfully"}

View File

@ -0,0 +1,181 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from app.db.session import get_db
from app.core.deps import get_current_active_user
from app.models.user import User
from app.models.membership import MembershipPlan
from app.models.subscription import Subscription, SubscriptionStatus
from app.models.transaction import Transaction, TransactionStatus
from app.schemas.transaction import PaymentRequest, Transaction as TransactionSchema
from app.services.payment import payment_service
router = APIRouter()
@router.post("/initialize")
def initialize_payment(
payment_request: PaymentRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
plan = (
db.query(MembershipPlan)
.filter(
MembershipPlan.id == payment_request.membership_plan_id,
MembershipPlan.is_active,
)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="Membership plan not found")
existing_active_subscription = (
db.query(Subscription)
.filter(
Subscription.user_id == current_user.id,
Subscription.membership_plan_id == payment_request.membership_plan_id,
Subscription.status == SubscriptionStatus.ACTIVE,
)
.first()
)
if existing_active_subscription:
raise HTTPException(
status_code=400, detail="Active subscription already exists for this plan"
)
# Create transaction record
transaction = Transaction(
user_id=current_user.id,
amount=plan.price,
payment_gateway=payment_request.payment_gateway,
description=f"Subscription to {plan.name}",
)
db.add(transaction)
db.commit()
db.refresh(transaction)
metadata = {
"user_id": str(current_user.id),
"transaction_id": str(transaction.id),
"plan_id": str(plan.id),
}
try:
payment_data = payment_service.initialize_payment(
gateway=payment_request.payment_gateway,
amount=plan.price,
currency="USD"
if payment_request.payment_gateway.value == "stripe"
else "NGN",
email=current_user.email,
metadata=metadata,
)
# Update transaction with gateway reference
if payment_request.payment_gateway.value == "stripe":
transaction.gateway_reference = payment_data.get("payment_intent_id")
else:
transaction.gateway_reference = payment_data.get("reference")
db.commit()
return {"transaction_id": transaction.id, "payment_data": payment_data}
except Exception as e:
transaction.status = TransactionStatus.FAILED
db.commit()
raise e
@router.post("/verify/{transaction_id}")
def verify_payment(
transaction_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
transaction = (
db.query(Transaction)
.filter(
Transaction.id == transaction_id, Transaction.user_id == current_user.id
)
.first()
)
if not transaction:
raise HTTPException(status_code=404, detail="Transaction not found")
if transaction.status == TransactionStatus.COMPLETED:
return {"message": "Payment already verified"}
try:
verification_data = payment_service.verify_payment(
gateway=transaction.payment_gateway, reference=transaction.gateway_reference
)
if verification_data["status"] in ["succeeded", "success"]:
transaction.status = TransactionStatus.COMPLETED
transaction.gateway_transaction_id = verification_data.get(
"reference"
) or verification_data.get("payment_intent_id")
# Create subscription
plan = (
db.query(MembershipPlan)
.filter(MembershipPlan.id == transaction.subscription_id)
.first()
)
if not plan:
# Find plan from metadata or transaction description
# This is a fallback - ideally we'd store plan_id in transaction
raise HTTPException(
status_code=500, detail="Cannot create subscription: plan not found"
)
start_date = datetime.utcnow()
end_date = start_date + timedelta(days=plan.duration_days)
subscription = Subscription(
user_id=current_user.id,
membership_plan_id=plan.id,
start_date=start_date,
end_date=end_date,
amount_paid=transaction.amount,
payment_reference=transaction.gateway_reference,
status=SubscriptionStatus.ACTIVE,
)
transaction.subscription_id = subscription.id
db.add(subscription)
db.commit()
return {"message": "Payment verified and subscription created successfully"}
else:
transaction.status = TransactionStatus.FAILED
db.commit()
raise HTTPException(status_code=400, detail="Payment verification failed")
except Exception as e:
transaction.status = TransactionStatus.FAILED
db.commit()
raise HTTPException(
status_code=400, detail=f"Payment verification error: {str(e)}"
)
@router.get("/transactions", response_model=list[TransactionSchema])
def get_user_transactions(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
transactions = (
db.query(Transaction)
.filter(Transaction.user_id == current_user.id)
.offset(skip)
.limit(limit)
.all()
)
return transactions

View File

@ -0,0 +1,148 @@
from typing import List
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.deps import get_current_active_user, get_current_admin_user
from app.models.subscription import Subscription, SubscriptionStatus
from app.models.membership import MembershipPlan
from app.models.user import User
from app.schemas.subscription import (
Subscription as SubscriptionSchema,
SubscriptionCreate,
SubscriptionUpdate,
)
router = APIRouter()
@router.get("/", response_model=List[SubscriptionSchema])
def read_subscriptions(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
subscriptions = (
db.query(Subscription)
.filter(Subscription.user_id == current_user.id)
.offset(skip)
.limit(limit)
.all()
)
return subscriptions
@router.get("/{subscription_id}", response_model=SubscriptionSchema)
def read_subscription(
subscription_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
subscription = (
db.query(Subscription)
.filter(
Subscription.id == subscription_id, Subscription.user_id == current_user.id
)
.first()
)
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
return subscription
@router.post("/", response_model=SubscriptionSchema)
def create_subscription(
subscription_data: SubscriptionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
plan = (
db.query(MembershipPlan)
.filter(
MembershipPlan.id == subscription_data.membership_plan_id,
MembershipPlan.is_active,
)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="Membership plan not found")
existing_active_subscription = (
db.query(Subscription)
.filter(
Subscription.user_id == current_user.id,
Subscription.membership_plan_id == subscription_data.membership_plan_id,
Subscription.status == SubscriptionStatus.ACTIVE,
)
.first()
)
if existing_active_subscription:
raise HTTPException(
status_code=400, detail="Active subscription already exists for this plan"
)
start_date = datetime.utcnow()
end_date = start_date + timedelta(days=plan.duration_days)
subscription = Subscription(
user_id=current_user.id,
membership_plan_id=subscription_data.membership_plan_id,
start_date=start_date,
end_date=end_date,
amount_paid=plan.price,
status=SubscriptionStatus.PENDING,
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return subscription
@router.put("/{subscription_id}", response_model=SubscriptionSchema)
def update_subscription(
subscription_id: int,
subscription_update: SubscriptionUpdate,
db: Session = Depends(get_db),
current_user=Depends(get_current_admin_user),
):
subscription = (
db.query(Subscription).filter(Subscription.id == subscription_id).first()
)
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
update_data = subscription_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(subscription, field, value)
db.commit()
db.refresh(subscription)
return subscription
@router.post("/{subscription_id}/cancel")
def cancel_subscription(
subscription_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
subscription = (
db.query(Subscription)
.filter(
Subscription.id == subscription_id, Subscription.user_id == current_user.id
)
.first()
)
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
if subscription.status == SubscriptionStatus.CANCELLED:
raise HTTPException(status_code=400, detail="Subscription already cancelled")
subscription.status = SubscriptionStatus.CANCELLED
db.commit()
return {"message": "Subscription cancelled successfully"}

View File

@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.deps import get_current_active_user
from app.models.user import User
from app.models.membership import GymMembership
from app.models.subscription import Subscription
from app.schemas.user import User as UserSchema, UserUpdate
router = APIRouter()
@router.get("/me", response_model=UserSchema)
def read_user_me(current_user: User = Depends(get_current_active_user)):
return current_user
@router.put("/me", response_model=UserSchema)
def update_user_me(
user_update: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
user = db.query(User).filter(User.id == current_user.id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
update_data = user_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
db.commit()
db.refresh(user)
return user
@router.get("/me/memberships")
def get_user_memberships(
db: Session = Depends(get_db), current_user: User = Depends(get_current_active_user)
):
memberships = (
db.query(GymMembership).filter(GymMembership.user_id == current_user.id).all()
)
return memberships
@router.get("/me/subscriptions")
def get_user_subscriptions(
db: Session = Depends(get_db), current_user: User = Depends(get_current_active_user)
):
subscriptions = (
db.query(Subscription).filter(Subscription.user_id == current_user.id).all()
)
return subscriptions

25
app/api/v1/router.py Normal file
View File

@ -0,0 +1,25 @@
from fastapi import APIRouter
from app.api.v1.endpoints import (
auth,
users,
gyms,
memberships,
subscriptions,
payments,
admin,
)
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(gyms.router, prefix="/gyms", tags=["gyms"])
api_router.include_router(
memberships.router, prefix="/memberships", tags=["memberships"]
)
api_router.include_router(
subscriptions.router, prefix="/subscriptions", tags=["subscriptions"]
)
api_router.include_router(payments.router, prefix="/payments", tags=["payments"])
api_router.include_router(admin.router, prefix="/admin", tags=["admin"])

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

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

@ -0,0 +1,21 @@
import os
from typing import Optional
class Settings:
PROJECT_NAME: str = "Gym Membership Management System"
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# Database
DATABASE_URL: str = "sqlite:////app/storage/db/db.sqlite"
# Payment Gateways
STRIPE_SECRET_KEY: Optional[str] = os.getenv("STRIPE_SECRET_KEY")
STRIPE_PUBLISHABLE_KEY: Optional[str] = os.getenv("STRIPE_PUBLISHABLE_KEY")
PAYSTACK_SECRET_KEY: Optional[str] = os.getenv("PAYSTACK_SECRET_KEY")
PAYSTACK_PUBLIC_KEY: Optional[str] = os.getenv("PAYSTACK_PUBLIC_KEY")
settings = Settings()

58
app/core/deps.py Normal file
View File

@ -0,0 +1,58 @@
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.core.security import verify_token
from app.models.user import User, UserRole
security = HTTPBearer()
def get_current_user(
db: Session = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> User:
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
def get_current_active_user(current_user: User = Depends(get_current_user)) -> 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: User = Depends(get_current_active_user),
) -> User:
if current_user.role not in [UserRole.ADMIN, UserRole.SUPER_ADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
return current_user
def get_current_super_admin_user(
current_user: User = Depends(get_current_active_user),
) -> User:
if current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Super admin access required"
)
return current_user

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

@ -0,0 +1,43 @@
from datetime import datetime, timedelta
from typing import Optional, Union
from passlib.context import CryptContext
from jose import JWTError, jwt
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
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 verify_token(token: str) -> Union[str, None]:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
email: str = payload.get("sub")
if email is None:
return None
return email
except JWTError:
return None

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

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

@ -0,0 +1,3 @@
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

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

@ -0,0 +1,22 @@
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
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)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

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

24
app/models/gym.py Normal file
View File

@ -0,0 +1,24 @@
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime
from app.db.base import Base
class Gym(Base):
__tablename__ = "gyms"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
description = Column(Text, nullable=True)
address = Column(String, nullable=False)
city = Column(String, nullable=False)
state = Column(String, nullable=False)
phone = Column(String, nullable=True)
email = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
membership_plans = relationship("MembershipPlan", back_populates="gym")
gym_memberships = relationship("GymMembership", back_populates="gym")

62
app/models/membership.py Normal file
View File

@ -0,0 +1,62 @@
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Float,
ForeignKey,
Boolean,
Enum,
Text,
)
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from app.db.base import Base
class PlanType(str, enum.Enum):
BASIC = "basic"
PREMIUM = "premium"
VIP = "vip"
class MembershipStatus(str, enum.Enum):
ACTIVE = "active"
INACTIVE = "inactive"
EXPIRED = "expired"
SUSPENDED = "suspended"
class MembershipPlan(Base):
__tablename__ = "membership_plans"
id = Column(Integer, primary_key=True, index=True)
gym_id = Column(Integer, ForeignKey("gyms.id"), nullable=False)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
plan_type = Column(Enum(PlanType), nullable=False)
price = Column(Float, nullable=False)
duration_days = Column(Integer, nullable=False)
features = Column(Text, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
gym = relationship("Gym", back_populates="membership_plans")
subscriptions = relationship("Subscription", back_populates="membership_plan")
class GymMembership(Base):
__tablename__ = "gym_memberships"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
gym_id = Column(Integer, ForeignKey("gyms.id"), nullable=False)
status = Column(Enum(MembershipStatus), default=MembershipStatus.ACTIVE)
joined_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="gym_memberships")
gym = relationship("Gym", back_populates="gym_memberships")

View File

@ -0,0 +1,34 @@
from sqlalchemy import Column, Integer, DateTime, ForeignKey, Float, Enum, String
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from app.db.base import Base
class SubscriptionStatus(str, enum.Enum):
ACTIVE = "active"
EXPIRED = "expired"
CANCELLED = "cancelled"
PENDING = "pending"
class Subscription(Base):
__tablename__ = "subscriptions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
membership_plan_id = Column(
Integer, ForeignKey("membership_plans.id"), nullable=False
)
status = Column(Enum(SubscriptionStatus), default=SubscriptionStatus.PENDING)
start_date = Column(DateTime, nullable=False)
end_date = Column(DateTime, nullable=False)
amount_paid = Column(Float, nullable=False)
payment_reference = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="subscriptions")
membership_plan = relationship("MembershipPlan", back_populates="subscriptions")
transactions = relationship("Transaction", back_populates="subscription")

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

@ -0,0 +1,38 @@
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey, Enum, Text
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from app.db.base import Base
class TransactionStatus(str, enum.Enum):
PENDING = "pending"
COMPLETED = "completed"
FAILED = "failed"
REFUNDED = "refunded"
class PaymentGateway(str, enum.Enum):
STRIPE = "stripe"
PAYSTACK = "paystack"
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
subscription_id = Column(Integer, ForeignKey("subscriptions.id"), nullable=True)
amount = Column(Float, nullable=False)
currency = Column(String, default="USD", nullable=False)
status = Column(Enum(TransactionStatus), default=TransactionStatus.PENDING)
payment_gateway = Column(Enum(PaymentGateway), nullable=False)
gateway_transaction_id = Column(String, nullable=True)
gateway_reference = Column(String, nullable=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="transactions")
subscription = relationship("Subscription", back_populates="transactions")

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

@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from app.db.base import Base
class UserRole(str, enum.Enum):
USER = "user"
ADMIN = "admin"
SUPER_ADMIN = "super_admin"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
full_name = Column(String, nullable=False)
phone = Column(String, nullable=True)
hashed_password = Column(String, nullable=False)
role = Column(Enum(UserRole), default=UserRole.USER, nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
gym_memberships = relationship("GymMembership", back_populates="user")
subscriptions = relationship("Subscription", back_populates="user")
transactions = relationship("Transaction", back_populates="user")
invited_by = Column(Integer, nullable=True)

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

41
app/schemas/gym.py Normal file
View File

@ -0,0 +1,41 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
class GymBase(BaseModel):
name: str
description: Optional[str] = None
address: str
city: str
state: str
phone: Optional[str] = None
email: Optional[EmailStr] = None
class GymCreate(GymBase):
pass
class GymUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
address: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
phone: Optional[str] = None
email: Optional[EmailStr] = None
class GymInDB(GymBase):
id: int
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class Gym(GymInDB):
pass

64
app/schemas/membership.py Normal file
View File

@ -0,0 +1,64 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from app.models.membership import PlanType, MembershipStatus
class MembershipPlanBase(BaseModel):
name: str
description: Optional[str] = None
plan_type: PlanType
price: float
duration_days: int
features: Optional[str] = None
class MembershipPlanCreate(MembershipPlanBase):
gym_id: int
class MembershipPlanUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
plan_type: Optional[PlanType] = None
price: Optional[float] = None
duration_days: Optional[int] = None
features: Optional[str] = None
class MembershipPlanInDB(MembershipPlanBase):
id: int
gym_id: int
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class MembershipPlan(MembershipPlanInDB):
pass
class GymMembershipBase(BaseModel):
user_id: int
gym_id: int
class GymMembershipCreate(GymMembershipBase):
pass
class GymMembershipInDB(GymMembershipBase):
id: int
status: MembershipStatus
joined_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class GymMembership(GymMembershipInDB):
pass

View File

@ -0,0 +1,36 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from app.models.subscription import SubscriptionStatus
class SubscriptionBase(BaseModel):
membership_plan_id: int
start_date: datetime
end_date: datetime
amount_paid: float
class SubscriptionCreate(SubscriptionBase):
user_id: int
class SubscriptionUpdate(BaseModel):
status: Optional[SubscriptionStatus] = None
end_date: Optional[datetime] = None
class SubscriptionInDB(SubscriptionBase):
id: int
user_id: int
status: SubscriptionStatus
payment_reference: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class Subscription(SubscriptionInDB):
pass

View File

@ -0,0 +1,45 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from app.models.transaction import TransactionStatus, PaymentGateway
class TransactionBase(BaseModel):
amount: float
currency: str = "USD"
payment_gateway: PaymentGateway
description: Optional[str] = None
class TransactionCreate(TransactionBase):
user_id: int
subscription_id: Optional[int] = None
class TransactionUpdate(BaseModel):
status: Optional[TransactionStatus] = None
gateway_transaction_id: Optional[str] = None
gateway_reference: Optional[str] = None
class TransactionInDB(TransactionBase):
id: int
user_id: int
subscription_id: Optional[int] = None
status: TransactionStatus
gateway_transaction_id: Optional[str] = None
gateway_reference: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class Transaction(TransactionInDB):
pass
class PaymentRequest(BaseModel):
membership_plan_id: int
payment_gateway: PaymentGateway

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

@ -0,0 +1,54 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
from app.models.user import UserRole
class UserBase(BaseModel):
email: EmailStr
full_name: str
phone: Optional[str] = None
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
full_name: Optional[str] = None
phone: Optional[str] = None
class UserInDB(UserBase):
id: int
role: UserRole
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class User(UserInDB):
pass
class UserLogin(BaseModel):
email: EmailStr
password: str
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: Optional[str] = None
class AdminInvite(BaseModel):
email: EmailStr
full_name: str
role: UserRole = UserRole.ADMIN

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

153
app/services/payment.py Normal file
View File

@ -0,0 +1,153 @@
import stripe
import requests
from typing import Dict, Any, Optional
from fastapi import HTTPException
from app.core.config import settings
from app.models.transaction import PaymentGateway
class StripeService:
def __init__(self):
if settings.STRIPE_SECRET_KEY:
stripe.api_key = settings.STRIPE_SECRET_KEY
def create_payment_intent(
self, amount: float, currency: str = "usd", metadata: Optional[Dict] = None
) -> Dict[str, Any]:
if not settings.STRIPE_SECRET_KEY:
raise HTTPException(status_code=500, detail="Stripe not configured")
try:
intent = stripe.PaymentIntent.create(
amount=int(amount * 100), # Stripe expects amount in cents
currency=currency.lower(),
metadata=metadata or {},
)
return {
"client_secret": intent.client_secret,
"payment_intent_id": intent.id,
"status": intent.status,
}
except stripe.error.StripeError as e:
raise HTTPException(status_code=400, detail=str(e))
def confirm_payment(self, payment_intent_id: str) -> Dict[str, Any]:
if not settings.STRIPE_SECRET_KEY:
raise HTTPException(status_code=500, detail="Stripe not configured")
try:
intent = stripe.PaymentIntent.retrieve(payment_intent_id)
return {
"payment_intent_id": intent.id,
"status": intent.status,
"amount": intent.amount / 100, # Convert back from cents
"currency": intent.currency,
}
except stripe.error.StripeError as e:
raise HTTPException(status_code=400, detail=str(e))
class PaystackService:
def __init__(self):
self.base_url = "https://api.paystack.co"
self.headers = {
"Authorization": f"Bearer {settings.PAYSTACK_SECRET_KEY}",
"Content-Type": "application/json",
}
def initialize_transaction(
self,
email: str,
amount: float,
currency: str = "NGN",
metadata: Optional[Dict] = None,
) -> Dict[str, Any]:
if not settings.PAYSTACK_SECRET_KEY:
raise HTTPException(status_code=500, detail="Paystack not configured")
data = {
"email": email,
"amount": int(amount * 100), # Paystack expects amount in kobo
"currency": currency.upper(),
"metadata": metadata or {},
}
try:
response = requests.post(
f"{self.base_url}/transaction/initialize",
json=data,
headers=self.headers,
)
response.raise_for_status()
result = response.json()
if result["status"]:
return {
"authorization_url": result["data"]["authorization_url"],
"access_code": result["data"]["access_code"],
"reference": result["data"]["reference"],
}
else:
raise HTTPException(status_code=400, detail=result["message"])
except requests.RequestException as e:
raise HTTPException(status_code=500, detail=f"Paystack API error: {str(e)}")
def verify_transaction(self, reference: str) -> Dict[str, Any]:
if not settings.PAYSTACK_SECRET_KEY:
raise HTTPException(status_code=500, detail="Paystack not configured")
try:
response = requests.get(
f"{self.base_url}/transaction/verify/{reference}", headers=self.headers
)
response.raise_for_status()
result = response.json()
if result["status"]:
data = result["data"]
return {
"reference": data["reference"],
"status": data["status"],
"amount": data["amount"] / 100, # Convert back from kobo
"currency": data["currency"],
"gateway_response": data["gateway_response"],
}
else:
raise HTTPException(status_code=400, detail=result["message"])
except requests.RequestException as e:
raise HTTPException(status_code=500, detail=f"Paystack API error: {str(e)}")
class PaymentService:
def __init__(self):
self.stripe_service = StripeService()
self.paystack_service = PaystackService()
def initialize_payment(
self,
gateway: PaymentGateway,
amount: float,
currency: str,
email: str,
metadata: Optional[Dict] = None,
) -> Dict[str, Any]:
if gateway == PaymentGateway.STRIPE:
return self.stripe_service.create_payment_intent(amount, currency, metadata)
elif gateway == PaymentGateway.PAYSTACK:
return self.paystack_service.initialize_transaction(
email, amount, currency, metadata
)
else:
raise HTTPException(status_code=400, detail="Unsupported payment gateway")
def verify_payment(self, gateway: PaymentGateway, reference: str) -> Dict[str, Any]:
if gateway == PaymentGateway.STRIPE:
return self.stripe_service.confirm_payment(reference)
elif gateway == PaymentGateway.PAYSTACK:
return self.paystack_service.verify_transaction(reference)
else:
raise HTTPException(status_code=400, detail="Unsupported payment gateway")
payment_service = PaymentService()

48
main.py Normal file
View File

@ -0,0 +1,48 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.api.v1.router import api_router
from app.db.session import engine
from app.db.base import Base
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
yield
app = FastAPI(
title="Gym Membership Management System",
description="A comprehensive platform for gyms to manage member data and subscriptions",
version="1.0.0",
openapi_url="/openapi.json",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix="/api/v1")
@app.get("/")
async def root():
return {
"title": "Gym Membership Management System",
"description": "A comprehensive platform for gyms to manage member data and subscriptions",
"version": "1.0.0",
"documentation": "/docs",
"health_check": "/health",
}
@app.get("/health")
async def health_check():
return {"status": "healthy", "service": "gym-membership-api"}

11
requirements.txt Normal file
View File

@ -0,0 +1,11 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
alembic==1.13.0
pydantic[email]==2.5.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
stripe==7.8.0
requests==2.31.0
ruff==0.1.6