diff --git a/README.md b/README.md index e8acfba..85d7cde 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..5e6b587 --- /dev/null +++ b/alembic.ini @@ -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 \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..6417983 --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..37d0cac --- /dev/null +++ b/alembic/script.py.mako @@ -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"} \ No newline at end of file diff --git a/alembic/versions/0001_initial_migration.py b/alembic/versions/0001_initial_migration.py new file mode 100644 index 0000000..fafc257 --- /dev/null +++ b/alembic/versions/0001_initial_migration.py @@ -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") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/admin.py b/app/api/v1/endpoints/admin.py new file mode 100644 index 0000000..606ec13 --- /dev/null +++ b/app/api/v1/endpoints/admin.py @@ -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 diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..37342ce --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -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"} diff --git a/app/api/v1/endpoints/gyms.py b/app/api/v1/endpoints/gyms.py new file mode 100644 index 0000000..9d30c19 --- /dev/null +++ b/app/api/v1/endpoints/gyms.py @@ -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 diff --git a/app/api/v1/endpoints/memberships.py b/app/api/v1/endpoints/memberships.py new file mode 100644 index 0000000..d5404e1 --- /dev/null +++ b/app/api/v1/endpoints/memberships.py @@ -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"} diff --git a/app/api/v1/endpoints/payments.py b/app/api/v1/endpoints/payments.py new file mode 100644 index 0000000..09618f0 --- /dev/null +++ b/app/api/v1/endpoints/payments.py @@ -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 diff --git a/app/api/v1/endpoints/subscriptions.py b/app/api/v1/endpoints/subscriptions.py new file mode 100644 index 0000000..a0d2f91 --- /dev/null +++ b/app/api/v1/endpoints/subscriptions.py @@ -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"} diff --git a/app/api/v1/endpoints/users.py b/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..e0aba69 --- /dev/null +++ b/app/api/v1/endpoints/users.py @@ -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 diff --git a/app/api/v1/router.py b/app/api/v1/router.py new file mode 100644 index 0000000..6eb13d5 --- /dev/null +++ b/app/api/v1/router.py @@ -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"]) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..39554eb --- /dev/null +++ b/app/core/config.py @@ -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() diff --git a/app/core/deps.py b/app/core/deps.py new file mode 100644 index 0000000..a4a0632 --- /dev/null +++ b/app/core/deps.py @@ -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 diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..895fa82 --- /dev/null +++ b/app/core/security.py @@ -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 diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..860e542 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..3003b8c --- /dev/null +++ b/app/db/session.py @@ -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() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/gym.py b/app/models/gym.py new file mode 100644 index 0000000..bcbd7b1 --- /dev/null +++ b/app/models/gym.py @@ -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") diff --git a/app/models/membership.py b/app/models/membership.py new file mode 100644 index 0000000..8ed7e61 --- /dev/null +++ b/app/models/membership.py @@ -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") diff --git a/app/models/subscription.py b/app/models/subscription.py new file mode 100644 index 0000000..4148170 --- /dev/null +++ b/app/models/subscription.py @@ -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") diff --git a/app/models/transaction.py b/app/models/transaction.py new file mode 100644 index 0000000..c71ad3b --- /dev/null +++ b/app/models/transaction.py @@ -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") diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..5094181 --- /dev/null +++ b/app/models/user.py @@ -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) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/gym.py b/app/schemas/gym.py new file mode 100644 index 0000000..5f8148e --- /dev/null +++ b/app/schemas/gym.py @@ -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 diff --git a/app/schemas/membership.py b/app/schemas/membership.py new file mode 100644 index 0000000..14507d3 --- /dev/null +++ b/app/schemas/membership.py @@ -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 diff --git a/app/schemas/subscription.py b/app/schemas/subscription.py new file mode 100644 index 0000000..2c0dc68 --- /dev/null +++ b/app/schemas/subscription.py @@ -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 diff --git a/app/schemas/transaction.py b/app/schemas/transaction.py new file mode 100644 index 0000000..1c9f724 --- /dev/null +++ b/app/schemas/transaction.py @@ -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 diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..46a2ed3 --- /dev/null +++ b/app/schemas/user.py @@ -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 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/payment.py b/app/services/payment.py new file mode 100644 index 0000000..bd48208 --- /dev/null +++ b/app/services/payment.py @@ -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() diff --git a/main.py b/main.py new file mode 100644 index 0000000..170f138 --- /dev/null +++ b/main.py @@ -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"} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b9f0adc --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file