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