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:
parent
13d822da7d
commit
b78ac1f072
178
README.md
178
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.
|
97
alembic.ini
Normal file
97
alembic.ini
Normal 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
77
alembic/env.py
Normal 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
24
alembic/script.py.mako
Normal 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"}
|
202
alembic/versions/0001_initial_migration.py
Normal file
202
alembic/versions/0001_initial_migration.py
Normal 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
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/endpoints/__init__.py
Normal file
0
app/api/v1/endpoints/__init__.py
Normal file
222
app/api/v1/endpoints/admin.py
Normal file
222
app/api/v1/endpoints/admin.py
Normal 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
|
54
app/api/v1/endpoints/auth.py
Normal file
54
app/api/v1/endpoints/auth.py
Normal 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"}
|
102
app/api/v1/endpoints/gyms.py
Normal file
102
app/api/v1/endpoints/gyms.py
Normal 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
|
93
app/api/v1/endpoints/memberships.py
Normal file
93
app/api/v1/endpoints/memberships.py
Normal 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"}
|
181
app/api/v1/endpoints/payments.py
Normal file
181
app/api/v1/endpoints/payments.py
Normal 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
|
148
app/api/v1/endpoints/subscriptions.py
Normal file
148
app/api/v1/endpoints/subscriptions.py
Normal 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"}
|
55
app/api/v1/endpoints/users.py
Normal file
55
app/api/v1/endpoints/users.py
Normal 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
25
app/api/v1/router.py
Normal 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
0
app/core/__init__.py
Normal file
21
app/core/config.py
Normal file
21
app/core/config.py
Normal 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
58
app/core/deps.py
Normal 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
43
app/core/security.py
Normal 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
0
app/db/__init__.py
Normal file
3
app/db/base.py
Normal file
3
app/db/base.py
Normal file
@ -0,0 +1,3 @@
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
22
app/db/session.py
Normal file
22
app/db/session.py
Normal 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
0
app/models/__init__.py
Normal file
24
app/models/gym.py
Normal file
24
app/models/gym.py
Normal 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
62
app/models/membership.py
Normal 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")
|
34
app/models/subscription.py
Normal file
34
app/models/subscription.py
Normal 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
38
app/models/transaction.py
Normal 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
31
app/models/user.py
Normal 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
0
app/schemas/__init__.py
Normal file
41
app/schemas/gym.py
Normal file
41
app/schemas/gym.py
Normal 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
64
app/schemas/membership.py
Normal 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
|
36
app/schemas/subscription.py
Normal file
36
app/schemas/subscription.py
Normal 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
|
45
app/schemas/transaction.py
Normal file
45
app/schemas/transaction.py
Normal 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
54
app/schemas/user.py
Normal 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
0
app/services/__init__.py
Normal file
153
app/services/payment.py
Normal file
153
app/services/payment.py
Normal 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
48
main.py
Normal 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
11
requirements.txt
Normal 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
|
Loading…
x
Reference in New Issue
Block a user