first commit
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# Project Configuration
|
||||
PROJECT_NAME=SaaS Architecture
|
||||
VERSION=1.0.0
|
||||
PORT=11001
|
||||
APP_ENV=development
|
||||
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
|
||||
ALLOWED_HOSTS=*
|
||||
HOST=127.0.0.1
|
||||
FRONTEND_URL=https://fulfilment-dev.maskantech.in
|
||||
CORS_ALLOWED_ORIGINS=https://fulfilment-dev.maskantech.in
|
||||
|
||||
# Security
|
||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||
|
||||
# Super Admin Configuration
|
||||
SUPER_ADMIN_EMAIL=admin@maskantech.in
|
||||
SUPER_ADMIN_PASSWORD=SuperAdmin@123
|
||||
SUPER_ADMIN_FIRST_NAME=Super
|
||||
SUPER_ADMIN_LAST_NAME=Admin
|
||||
|
||||
#Database Configuration
|
||||
DB_SSL=False
|
||||
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_URL=""
|
||||
# port=""
|
||||
# host=""
|
||||
# password=""
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=info@maskantech.in
|
||||
SMTP_PASSWORD=Infomaskan@123
|
||||
EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||
|
||||
# External SaaS Webhook
|
||||
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
|
||||
|
||||
# AWS S3 Configuration
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
AWS_REGION=""
|
||||
S3_BUCKET_NAME=""
|
||||
|
||||
# PayPal Integration
|
||||
PAYPAL_CLIENT_ID=""
|
||||
PAYPAL_CLIENT_SECRET=""
|
||||
PAYPAL_MODE=sandbox
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Development flags
|
||||
DEBUG_MODE=true
|
||||
ENABLE_DEBUG_LOGGING=true
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# Project Configuration
|
||||
PROJECT_NAME=Fulfillment And Logistics
|
||||
VERSION=1.0.0
|
||||
PORT=8000
|
||||
APP_ENV=local
|
||||
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
|
||||
ALLOWED_HOSTS=*
|
||||
HOST=127.0.0.1
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
|
||||
# Security
|
||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||
|
||||
# Super Admin Configuration
|
||||
SUPER_ADMIN_EMAIL=admin@maskantech.in
|
||||
SUPER_ADMIN_PASSWORD=SuperAdmin@123
|
||||
SUPER_ADMIN_FIRST_NAME=Super
|
||||
SUPER_ADMIN_LAST_NAME=Admin
|
||||
|
||||
#Database Configuration
|
||||
DB_SSL=False
|
||||
DATABASE_URL=postgresql://saas_user:nMCuFvGoG%28%23Q%40Q4%5E@106.51.104.95:5432/saas_local
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_URL=""
|
||||
# port=""
|
||||
# host=""
|
||||
# password=""
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=info@maskantech.in
|
||||
SMTP_PASSWORD=Infomaskan@123
|
||||
EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||
|
||||
# External SaaS Webhook
|
||||
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
|
||||
|
||||
# AWS S3 Configuration
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
AWS_REGION=""
|
||||
S3_BUCKET_NAME=""
|
||||
|
||||
# PayPal Integration
|
||||
PAYPAL_CLIENT_ID=""
|
||||
PAYPAL_CLIENT_SECRET=""
|
||||
PAYPAL_MODE=sandbox
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Development flags
|
||||
DEBUG_MODE=true
|
||||
ENABLE_DEBUG_LOGGING=true
|
||||
@@ -0,0 +1,66 @@
|
||||
# Project Configuration
|
||||
PROJECT_NAME=SaaS Architecture
|
||||
VERSION=1.0.0
|
||||
PORT=8000
|
||||
APP_ENV=production
|
||||
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
|
||||
ALLOWED_HOSTS=*
|
||||
HOST=127.0.0.1
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
|
||||
# Security
|
||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||
|
||||
# Super Admin Configuration
|
||||
SUPER_ADMIN_EMAIL=admin@maskantech.in
|
||||
SUPER_ADMIN_PASSWORD=SuperAdmin@123
|
||||
SUPER_ADMIN_FIRST_NAME=Super
|
||||
SUPER_ADMIN_LAST_NAME=Admin
|
||||
|
||||
#Database Configuration
|
||||
DB_SSL=False
|
||||
DATABASE_URL=postgresql://postgres:azeem3105@localhost:5434/
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_URL=""
|
||||
# port=""
|
||||
# host=""
|
||||
# password=""
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=info@maskantech.in
|
||||
SMTP_PASSWORD=Infomaskan@123
|
||||
EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||
|
||||
# External SaaS Webhook
|
||||
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
|
||||
|
||||
# AWS S3 Configuration
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
AWS_REGION=""
|
||||
S3_BUCKET_NAME=""
|
||||
|
||||
# PayPal Integration
|
||||
PAYPAL_CLIENT_ID=""
|
||||
PAYPAL_CLIENT_SECRET=""
|
||||
PAYPAL_MODE=sandbox
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Development flags
|
||||
DEBUG_MODE=true
|
||||
ENABLE_DEBUG_LOGGING=true
|
||||
@@ -0,0 +1,66 @@
|
||||
# Project Configuration
|
||||
PROJECT_NAME=SaaS Architecture
|
||||
VERSION=1.0.0
|
||||
PORT=11002
|
||||
APP_ENV=testing
|
||||
SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9hPa5bYwQUoDhyRlzv_w"
|
||||
ALLOWED_HOSTS=*
|
||||
HOST=127.0.0.1
|
||||
FRONTEND_URL=https://fulfilment-test.maskantech.in
|
||||
CORS_ALLOWED_ORIGINS=https://fulfilment-test.maskantech.in
|
||||
|
||||
# Security
|
||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||
|
||||
# Super Admin Configuration
|
||||
SUPER_ADMIN_EMAIL=admin@maskantech.in
|
||||
SUPER_ADMIN_PASSWORD=SuperAdmin@123
|
||||
SUPER_ADMIN_FIRST_NAME=Super
|
||||
SUPER_ADMIN_LAST_NAME=Admin
|
||||
|
||||
#Database Configuration
|
||||
DB_SSL=False
|
||||
DATABASE_URL=postgresql://fl_user:R9!Kf7^XmP5$LQ8*Z2_vH3D@106.51.104.95:5432/
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_URL=""
|
||||
# port=""
|
||||
# host=""
|
||||
# password=""
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=info@maskantech.in
|
||||
SMTP_PASSWORD=Infomaskan@123
|
||||
EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||
|
||||
# External SaaS Webhook
|
||||
EXTERNAL_SAAS_WEBHOOK_SECRET=your-webhook-secret-key-change-in-production
|
||||
|
||||
# AWS S3 Configuration
|
||||
AWS_ACCESS_KEY_ID=""
|
||||
AWS_SECRET_ACCESS_KEY=""
|
||||
AWS_REGION=""
|
||||
S3_BUCKET_NAME=""
|
||||
|
||||
# PayPal Integration
|
||||
PAYPAL_CLIENT_ID=""
|
||||
PAYPAL_CLIENT_SECRET=""
|
||||
PAYPAL_MODE=sandbox
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Development flags
|
||||
DEBUG_MODE=true
|
||||
ENABLE_DEBUG_LOGGING=true
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# Virtual Environment
|
||||
venv/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Environment
|
||||
.env
|
||||
node_modules
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,306 @@
|
||||
# SaaS Architecture Backend
|
||||
|
||||
A multi-tenant SaaS backend built with FastAPI, PostgreSQL, and SQLAlchemy.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **Authentication & Authorization**: JWT-based authentication with role-based access control (RBAC)
|
||||
- 👥 **Multi-Tenancy**: Complete tenant isolation with tenant-scoped data
|
||||
- 🎨 **Theming**: Color palette management per tenant
|
||||
- 🔄 **Database Migrations**: Alembic for schema version control
|
||||
- 🌍 **Multi-Environment**: Support for local, development, production, and testing environments
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: FastAPI 0.122.0
|
||||
- **Database**: PostgreSQL with SQLAlchemy 2.0.44
|
||||
- **Migrations**: Alembic 1.17.2
|
||||
- **Authentication**: JWT (PyJWT) + bcrypt
|
||||
- **Server**: Uvicorn
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- PostgreSQL 12+
|
||||
- Node.js (for npm scripts)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Clone and Setup
|
||||
|
||||
```bash
|
||||
cd "c:/Users/furqa/OneDrive/Work/Maskan/SaaS Architecture/App/backend"
|
||||
```
|
||||
|
||||
### 2. Create Virtual Environment
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
.\venv\Scripts\activate # Windows
|
||||
# source venv/bin/activate # Linux/Mac
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
npm install # For cross-env support in npm scripts
|
||||
```
|
||||
|
||||
### 4. Environment Configuration
|
||||
|
||||
Create environment-specific configuration files:
|
||||
|
||||
- `.env.local` - Local development
|
||||
- `.env.development` - Development server
|
||||
- `.env.production` - Production
|
||||
- `.env.testing` - Testing environment
|
||||
|
||||
**Minimum required variables** (see `app/config/settings.py` for all options):
|
||||
|
||||
```env
|
||||
# Server
|
||||
APP_ENV=local
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
||||
|
||||
# Security
|
||||
SECRET_KEY=your-secret-key-here
|
||||
ACCESS_TOKEN_SECRET=your-access-token-secret
|
||||
REFRESH_TOKEN_SECRET=your-refresh-token-secret
|
||||
|
||||
# Frontend
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# Email
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASSWORD=your-app-password
|
||||
EMAIL_FROM=noreply@yourapp.com
|
||||
|
||||
# Super Admin (for initial setup)
|
||||
SUPER_ADMIN_EMAIL=admin@yourapp.com
|
||||
SUPER_ADMIN_PASSWORD=SecurePassword123!
|
||||
SUPER_ADMIN_FIRST_NAME=Admin
|
||||
SUPER_ADMIN_LAST_NAME=User
|
||||
```
|
||||
|
||||
### 5. Database Migrations
|
||||
|
||||
Alembic is configured to work with your multi-environment setup. It automatically:
|
||||
|
||||
- Loads the correct `.env.{APP_ENV}` file
|
||||
- Uses the `DATABASE_URL` from your settings
|
||||
- Imports all models for autogenerate support
|
||||
|
||||
#### Create Initial Migration
|
||||
|
||||
```bash
|
||||
# Set environment (local, development, production, testing)
|
||||
$env:APP_ENV="local" # Windows PowerShell
|
||||
# export APP_ENV=local # Linux/Mac
|
||||
|
||||
# Create initial migration
|
||||
alembic revision --autogenerate -m "Initial schema"
|
||||
```
|
||||
|
||||
#### Run Migrations
|
||||
|
||||
```bash
|
||||
# Using npm scripts (recommended - handles APP_ENV automatically)
|
||||
npm run migrate:local
|
||||
npm run migrate:dev
|
||||
npm run migrate:prod
|
||||
npm run migrate:test
|
||||
|
||||
# Or using alembic directly
|
||||
$env:APP_ENV="local" # Set environment first
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
#### Other Migration Commands
|
||||
|
||||
```bash
|
||||
# Check current migration version
|
||||
alembic current
|
||||
|
||||
# View migration history
|
||||
alembic history
|
||||
|
||||
# Downgrade one version
|
||||
alembic downgrade -1
|
||||
|
||||
# Downgrade to specific version
|
||||
alembic downgrade <revision_id>
|
||||
|
||||
# View SQL without running
|
||||
alembic upgrade head --sql
|
||||
```
|
||||
|
||||
### 6. Seed Database
|
||||
|
||||
After running migrations, seed the database with initial data:
|
||||
|
||||
```bash
|
||||
# Seed super admin user
|
||||
npm run seed:superadmin:local
|
||||
|
||||
# Seed default color palettes
|
||||
npm run seed:palettes:local
|
||||
```
|
||||
|
||||
### 7. Run the Application
|
||||
|
||||
```bash
|
||||
# Using npm scripts (recommended)
|
||||
npm run local # Local environment
|
||||
npm run dev # Development environment
|
||||
npm run prod # Production environment
|
||||
npm run test # Testing environment
|
||||
|
||||
# Or using Python directly
|
||||
python run.py
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8000`
|
||||
|
||||
## API Documentation
|
||||
|
||||
Once the application is running, visit:
|
||||
|
||||
- **Swagger UI**: http://localhost:8000/docs
|
||||
- **ReDoc**: http://localhost:8000/redoc
|
||||
- **OpenAPI JSON**: http://localhost:8000/openapi.json
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── alembic/ # Database migrations
|
||||
│ ├── versions/ # Migration scripts
|
||||
│ └── env.py # Alembic environment config
|
||||
├── app/
|
||||
│ ├── config/ # Configuration
|
||||
│ │ ├── database.py # Database connection
|
||||
│ │ └── settings.py # Application settings
|
||||
│ ├── controllers/ # Business logic controllers
|
||||
│ │ ├── auth/
|
||||
│ │ └── theme/
|
||||
│ ├── middleware/ # Custom middleware
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ │ ├── auth/ # User, Tenant, Role, Access
|
||||
│ │ └── theme/ # ColorPalette
|
||||
│ ├── routes/ # API endpoints
|
||||
│ │ ├── auth/
|
||||
│ │ └── theme/
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ │ ├── auth/
|
||||
│ │ └── theme/
|
||||
│ ├── services/ # Service layer
|
||||
│ │ ├── auth/
|
||||
│ │ └── theme/
|
||||
│ └── __init__.py # FastAPI app factory
|
||||
├── scripts/ # Utility scripts
|
||||
│ ├── seed_palettes.py
|
||||
│ └── seed_superadmin.py
|
||||
├── alembic.ini # Alembic configuration
|
||||
├── package.json # NPM scripts
|
||||
├── requirements.txt # Python dependencies
|
||||
└── run.py # Application entry point
|
||||
```
|
||||
|
||||
## Database Models
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- **Tenant**: Multi-tenant isolation
|
||||
- **User**: User accounts (tenant-scoped)
|
||||
- **Role**: User roles (tenant-scoped)
|
||||
- **Access**: Permission definitions (hierarchical)
|
||||
- **RoleAccess**: Role-to-permission mapping
|
||||
|
||||
### Theming
|
||||
|
||||
- **ColorPalette**: Tenant color themes
|
||||
|
||||
## Health Check
|
||||
|
||||
The application includes a health check endpoint:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"environment": "local",
|
||||
"database": "healthy",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Environment Variables Loading Order
|
||||
|
||||
The application loads environment variables in this order (later overrides earlier):
|
||||
|
||||
1. Root `.env`
|
||||
2. Backend `.env`
|
||||
3. Root `.env.{APP_ENV}`
|
||||
4. Backend `.env.{APP_ENV}`
|
||||
|
||||
### Multi-Tenancy
|
||||
|
||||
The system implements tenant isolation at the database level:
|
||||
|
||||
- Each tenant has their own users and roles
|
||||
- Color palettes can be tenant-specific or global
|
||||
- The super admin user is tenant-independent
|
||||
|
||||
### Role-Based Access Control
|
||||
|
||||
The RBAC system supports:
|
||||
|
||||
- Hierarchical permissions (Access has parent-child relationships)
|
||||
- Category-based organization
|
||||
- Flexible role-to-permission mapping
|
||||
- Tenant-scoped roles
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
1. Verify PostgreSQL is running
|
||||
2. Check `DATABASE_URL` in your `.env.{APP_ENV}` file
|
||||
3. Ensure database exists: `createdb your_database_name`
|
||||
4. Check database user permissions
|
||||
|
||||
### Migration Issues
|
||||
|
||||
1. Ensure `APP_ENV` is set correctly
|
||||
2. Verify database connection works
|
||||
3. Check that all models are imported in `alembic/env.py`
|
||||
4. Delete `alembic/versions/*.py` and recreate if needed
|
||||
|
||||
### Import Errors
|
||||
|
||||
1. Ensure virtual environment is activated
|
||||
2. Install all dependencies: `pip install -r requirements.txt`
|
||||
3. Check Python version (3.10+ required)
|
||||
|
||||
## License
|
||||
|
||||
[Your License Here]
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions, please contact [your-email@example.com]
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/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
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# 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. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
# sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[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
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Add parent directory to path to import app modules
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
# Load environment variables before importing app
|
||||
from dotenv import load_dotenv
|
||||
|
||||
app_env = os.getenv("APP_ENV", "local")
|
||||
env_filename = f".env.{app_env}"
|
||||
|
||||
# Define paths
|
||||
base_path = Path(__file__).resolve().parent.parent
|
||||
backend_path = base_path
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv(dotenv_path=base_path / '.env')
|
||||
load_dotenv(dotenv_path=backend_path / '.env')
|
||||
|
||||
# Override with specific environment config
|
||||
if (base_path / env_filename).exists():
|
||||
load_dotenv(dotenv_path=base_path / env_filename, override=True)
|
||||
if (backend_path / env_filename).exists():
|
||||
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
|
||||
|
||||
# Import app settings and database
|
||||
from app.config.settings import settings
|
||||
from app.config.database import Base
|
||||
|
||||
# Import all models for autogenerate support
|
||||
import app.models.auth.user_model
|
||||
import app.models.auth.role_model
|
||||
import app.models.auth.tenant_model
|
||||
import app.models.auth.access_model
|
||||
import app.models.auth.role_access_model
|
||||
import app.models.theme.color_palette_model
|
||||
|
||||
# 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)
|
||||
|
||||
# Set the database URL from app settings
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> 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()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPBearer
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
from app.config.settings import settings
|
||||
from app.config.database import engine
|
||||
|
||||
# Import models for Alembic
|
||||
import app.models.auth.user_model
|
||||
import app.models.auth.role_model
|
||||
import app.models.auth.tenant_model
|
||||
import app.models.theme.color_palette_model
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=settings.LOG_LEVEL.upper(),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=settings.VERSION,
|
||||
description="SaaS Architecture API",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
)
|
||||
|
||||
# === OpenAPI Security Scheme ===
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
|
||||
def custom_openapi():
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
openapi_schema = get_openapi(
|
||||
title=app.title,
|
||||
version=app.version,
|
||||
description=app.description,
|
||||
routes=app.routes,
|
||||
)
|
||||
openapi_schema["components"]["securitySchemes"] = {
|
||||
"BearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
}
|
||||
}
|
||||
app.openapi_schema = openapi_schema
|
||||
return app.openapi_schema
|
||||
|
||||
app.openapi = custom_openapi
|
||||
|
||||
# === CORS ===
|
||||
origins = []
|
||||
if settings.CORS_ALLOWED_ORIGINS:
|
||||
origins = [
|
||||
origin.strip()
|
||||
for origin in settings.CORS_ALLOWED_ORIGINS.split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
|
||||
if not origins:
|
||||
raise RuntimeError(
|
||||
"CORS_ALLOWED_ORIGINS must be set when allow_credentials=True"
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# === Include Routers ===
|
||||
from app.routes.auth.auth import router as auth_router
|
||||
from app.routes.auth.tenant import router as tenant_router
|
||||
from app.routes.auth.role import router as role_router
|
||||
from app.routes.auth.access import router as access_router
|
||||
from app.routes.auth.user import router as user_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["Authentication"])
|
||||
app.include_router(tenant_router, prefix="/api/tenant", tags=["Tenant Management"])
|
||||
app.include_router(role_router, prefix="/api/role", tags=["Role Management"])
|
||||
app.include_router(access_router, prefix="/api/access", tags=["Access Management"])
|
||||
app.include_router(user_router, prefix="/api/user", tags=["User Management"])
|
||||
|
||||
|
||||
# === Startup: Test DB Connection (Sync + SQLAlchemy 2.0 compatible) ===
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
logger.info("Testing database connection...")
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
conn.commit()
|
||||
logger.info("Database connection successful!")
|
||||
except Exception as e:
|
||||
logger.error(f"Database connection failed: {e}")
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
|
||||
)
|
||||
|
||||
# === Basic Routes ===
|
||||
@app.get("/", tags=["Root"])
|
||||
def root():
|
||||
"""
|
||||
Root endpoint - API information.
|
||||
|
||||
Returns basic information about the API including version and documentation links.
|
||||
"""
|
||||
return {
|
||||
"message": "Welcome to SaaS Architecture Backend API",
|
||||
"version": settings.VERSION,
|
||||
"docs": "/docs",
|
||||
"redoc": "/redoc",
|
||||
}
|
||||
|
||||
@app.get("/health", tags=["Health"])
|
||||
def health():
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns the health status of the API and database connection.
|
||||
Used by monitoring tools and load balancers.
|
||||
"""
|
||||
db_status = "connected"
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
db_status = "healthy"
|
||||
except Exception as e:
|
||||
db_status = f"unhealthy: {str(e)}"
|
||||
|
||||
return {
|
||||
"status": "healthy" if db_status == "healthy" else "degraded",
|
||||
"environment": settings.APP_ENV,
|
||||
"database": db_status,
|
||||
"version": settings.VERSION,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,53 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from fastapi import HTTPException
|
||||
from .settings import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATABASE_URL = settings.DATABASE_URL
|
||||
|
||||
# Build connection arguments based on SSL setting
|
||||
connect_args = {"connect_timeout": 10}
|
||||
if settings.DB_SSL:
|
||||
connect_args["sslmode"] = "require"
|
||||
|
||||
# Create the SQLAlchemy engine with optimized connection pool
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=300, # Recycle connections every 5 minutes
|
||||
pool_size=20, # Increased from 5 to handle higher concurrency
|
||||
max_overflow=30, # Increased from 10 for peak load handling
|
||||
pool_timeout=30, # Connection acquisition timeout
|
||||
pool_reset_on_return='commit', # Reset connections on return
|
||||
connect_args=connect_args,
|
||||
echo=False, # Disable SQL logging in production
|
||||
future=True # Use SQLAlchemy 2.0 style
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
except HTTPException:
|
||||
# Re-raise HTTPExceptions without logging as database errors
|
||||
# These are application-level errors, not database errors
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
# Log actual database errors
|
||||
logger.error(f"Database error: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
# Log other unexpected errors
|
||||
logger.error(f"Unexpected database session error: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Security utilities for authentication and authorization.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import HTTPException, status
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from app.config.settings import settings
|
||||
|
||||
|
||||
class SecurityUtils:
|
||||
"""Security utility class for authentication and authorization."""
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
salt = bcrypt.gensalt(rounds=settings.BCRYPT_ROUNDS)
|
||||
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
|
||||
|
||||
@staticmethod
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
||||
|
||||
@staticmethod
|
||||
def generate_access_token(data: Dict[str, Any], tenant_id: Optional[Any] = None) -> str:
|
||||
"""Generate JWT access token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRES)
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
|
||||
# Include tenant_id if provided
|
||||
if tenant_id:
|
||||
to_encode["tenant_id"] = str(tenant_id)
|
||||
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.ACCESS_TOKEN_SECRET,
|
||||
algorithm="HS256"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def generate_refresh_token(data: Dict[str, Any], tenant_id: Optional[Any] = None) -> str:
|
||||
"""Generate JWT refresh token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.REFRESH_TOKEN_EXPIRES)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
|
||||
# Include tenant_id if provided
|
||||
if tenant_id:
|
||||
to_encode["tenant_id"] = str(tenant_id)
|
||||
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.REFRESH_TOKEN_SECRET,
|
||||
algorithm="HS256"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_access_token(token: str) -> Dict[str, Any]:
|
||||
"""Verify and decode JWT access token."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.ACCESS_TOKEN_SECRET,
|
||||
algorithms=["HS256"]
|
||||
)
|
||||
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type"
|
||||
)
|
||||
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Access token has expired"
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid access token"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_refresh_token(token: str) -> Dict[str, Any]:
|
||||
"""Verify and decode JWT refresh token."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.REFRESH_TOKEN_SECRET,
|
||||
algorithms=["HS256"]
|
||||
)
|
||||
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type"
|
||||
)
|
||||
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh token has expired"
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def generate_otp(length: int = 6) -> str:
|
||||
"""Generate a random OTP."""
|
||||
return ''.join(secrets.choice(string.digits) for _ in range(length))
|
||||
|
||||
@staticmethod
|
||||
def validate_password_strength(password: str) -> bool:
|
||||
"""Validate password strength."""
|
||||
if len(password) < 8:
|
||||
return False
|
||||
|
||||
# Check for at least one uppercase letter
|
||||
if not re.search(r'[A-Z]', password):
|
||||
return False
|
||||
|
||||
# Check for at least one lowercase letter
|
||||
if not re.search(r'[a-z]', password):
|
||||
return False
|
||||
|
||||
# Check for at least one digit
|
||||
if not re.search(r'\d', password):
|
||||
return False
|
||||
|
||||
# Check for at least one special character
|
||||
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def validate_email(email: str) -> bool:
|
||||
"""Validate email format."""
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return re.match(pattern, email) is not None
|
||||
|
||||
@staticmethod
|
||||
def validate_ip_address(ip: str) -> bool:
|
||||
"""Validate IP address format (IPv4 and IPv6)."""
|
||||
ipv4_pattern = r'^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$'
|
||||
ipv6_pattern = r'^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$'
|
||||
|
||||
return re.match(ipv4_pattern, ip) is not None or re.match(ipv6_pattern, ip) is not None
|
||||
|
||||
|
||||
# Create instance for easy importing
|
||||
security = SecurityUtils()
|
||||
@@ -0,0 +1,149 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
import os
|
||||
|
||||
# Load environment variables from .env files
|
||||
app_env = os.getenv("APP_ENV", "local")
|
||||
env_filename = f".env.{app_env}"
|
||||
|
||||
# Define paths
|
||||
base_path = Path(__file__).resolve().parent.parent.parent
|
||||
backend_path = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Load specific environment file (e.g., .env.development)
|
||||
# Priority: Backend folder specific env -> Root specific env -> Backend .env -> Root .env
|
||||
load_dotenv(dotenv_path=base_path / '.env') # Load base .env first as fallback
|
||||
load_dotenv(dotenv_path=backend_path / '.env')
|
||||
|
||||
# Override with specific environment config
|
||||
if (base_path / env_filename).exists():
|
||||
load_dotenv(dotenv_path=base_path / env_filename, override=True)
|
||||
if (backend_path / env_filename).exists():
|
||||
load_dotenv(dotenv_path=backend_path / env_filename, override=True)
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Project
|
||||
PROJECT_NAME: str = "SaaS Architecture"
|
||||
VERSION: str = "1.0.0"
|
||||
|
||||
# FastAPI
|
||||
PORT: int
|
||||
HOST: str
|
||||
APP_ENV: str
|
||||
SECRET_KEY: str
|
||||
ALLOWED_HOSTS: str = "*"
|
||||
|
||||
# Frontend
|
||||
FRONTEND_URL: str
|
||||
# CORS (comma-separated origins). Example: "http://localhost:5173,https://app.example.com"
|
||||
CORS_ALLOWED_ORIGINS: Optional[str] = None
|
||||
# Optional CORS regex for advanced matching. Example: r"https://.*\\.example\\.com"
|
||||
CORS_ALLOW_ORIGIN_REGEX: Optional[str] = None
|
||||
|
||||
# Security
|
||||
ENCRYPTION_KEY: Optional[str] = None
|
||||
BCRYPT_ROUNDS: int = 12
|
||||
|
||||
# Database settings
|
||||
DATABASE_URL: str
|
||||
DB_SSL: bool = False
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
REDIS_ENABLED: bool = False # Disable Redis to avoid timeout warnings in development
|
||||
REDIS_TIMEOUT: int = 2 # Connection timeout in seconds
|
||||
REDIS_MAX_CONNECTIONS: int = 10 # Max connections in the pool
|
||||
|
||||
# Email
|
||||
SMTP_HOST: str
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_SECURE: bool = True
|
||||
SMTP_USER: str
|
||||
SMTP_PASSWORD: str
|
||||
EMAIL_FROM: str
|
||||
|
||||
# JWT settings
|
||||
ACCESS_TOKEN_SECRET: str
|
||||
ACCESS_TOKEN_EXPIRES: int = 86400
|
||||
REFRESH_TOKEN_SECRET: str
|
||||
REFRESH_TOKEN_EXPIRES: int = 864000
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
|
||||
# Super Admin Setup
|
||||
SUPER_ADMIN_EMAIL: str
|
||||
SUPER_ADMIN_PASSWORD: str
|
||||
SUPER_ADMIN_FIRST_NAME: str = "Super"
|
||||
SUPER_ADMIN_LAST_NAME: str = "Admin"
|
||||
|
||||
# External SaaS Integration
|
||||
EXTERNAL_SAAS_WEBHOOK_SECRET: str = "change-this-secret-key"
|
||||
|
||||
# PayPal Integration
|
||||
PAYPAL_CLIENT_ID: str
|
||||
PAYPAL_CLIENT_SECRET: str
|
||||
PAYPAL_MODE: str = "sandbox"
|
||||
PAYPAL_API_URL: str = "https://api-m.sandbox.paypal.com"
|
||||
|
||||
# AWS S3 settings
|
||||
AWS_SECRET_ACCESS_KEY: Optional[str] = None
|
||||
AWS_ACCESS_KEY_ID: Optional[str] = None
|
||||
S3_BUCKET_NAME: Optional[str] = None
|
||||
AWS_REGION: Optional[str] = "us-east-1"
|
||||
|
||||
# Property to use existing S3_BUCKET_NAME for AWS_S3_BUCKET
|
||||
@property
|
||||
def AWS_S3_BUCKET(self) -> Optional[str]:
|
||||
return self.S3_BUCKET_NAME
|
||||
|
||||
# S3 Dataset Processing Settings
|
||||
S3_PROCESSING_WORKERS: int = 4
|
||||
DOCUMENT_CHUNK_SIZE: int = 1000
|
||||
DOCUMENT_CHUNK_OVERLAP: int = 200
|
||||
PINECONE_BATCH_SIZE: int = 100
|
||||
|
||||
# Redis Chat Settings
|
||||
REDIS_CHAT_TTL: int = 86400 # 24 hours
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = "info"
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_REQUESTS: int = 100
|
||||
RATE_LIMIT_WINDOW: int = 60
|
||||
|
||||
# Integration Settings (Optional for development)
|
||||
# Test_BASE_URL: Optional[str] = "http://localhost:8001"
|
||||
# Test2_BASE_URL: Optional[str] = "http://localhost:8002"
|
||||
# Test3_BASE_URL: Optional[str] = "http://localhost:8003"
|
||||
# INTEGRATION_TIMEOUT: int = 30
|
||||
|
||||
# Properties for FastAPI Mail compatibility
|
||||
@property
|
||||
def MAIL_USERNAME(self) -> str:
|
||||
return self.SMTP_USER
|
||||
|
||||
@property
|
||||
def MAIL_PASSWORD(self) -> str:
|
||||
return self.SMTP_PASSWORD
|
||||
|
||||
@property
|
||||
def MAIL_PORT(self) -> int:
|
||||
return self.SMTP_PORT
|
||||
|
||||
@property
|
||||
def MAIL_SERVER(self) -> str:
|
||||
return self.SMTP_HOST
|
||||
|
||||
@property
|
||||
def MAIL_FROM(self) -> str:
|
||||
return self.EMAIL_FROM
|
||||
|
||||
model_config = {
|
||||
"case_sensitive": True,
|
||||
"extra": "ignore",
|
||||
}
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,9 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.services.auth.access_service import AccessService
|
||||
from app.schemas.auth.access_schema import AccessResponse
|
||||
|
||||
class AccessController:
|
||||
@staticmethod
|
||||
def get_all_accesses(db: Session, category: str = None) -> List[AccessResponse]:
|
||||
return AccessService.get_all_accesses(db, category)
|
||||
@@ -0,0 +1,62 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from app.schemas.auth.auth_schema import (
|
||||
UserSignup,
|
||||
UserSignin,
|
||||
UserUpdate,
|
||||
ResetPassword,
|
||||
RefreshTokenRequest,
|
||||
ForgotPasswordRequest,
|
||||
VerifyOTPRequest,
|
||||
ResetPasswordWithOTP,
|
||||
)
|
||||
from app.services.auth.auth_service import AuthService
|
||||
from app.models.auth.user_model import User
|
||||
import uuid
|
||||
|
||||
class AuthController:
|
||||
|
||||
@staticmethod
|
||||
def signup(db: Session, user_data: UserSignup, tenant_id: uuid.UUID = None):
|
||||
return AuthService.create_user(db, user_data, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def signin(db: Session, signin_data: UserSignin):
|
||||
return AuthService.signin(db, signin_data)
|
||||
|
||||
@staticmethod
|
||||
def refresh_token(db: Session, token_data: RefreshTokenRequest):
|
||||
return AuthService.refresh_access_token(db, token_data.refresh_token)
|
||||
|
||||
@staticmethod
|
||||
def update_user(
|
||||
db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User
|
||||
):
|
||||
return AuthService.update_user(db, user_id, user_data, current_user)
|
||||
|
||||
@staticmethod
|
||||
def reset_password(db: Session, user: User, password_data: ResetPassword):
|
||||
return AuthService.reset_password(
|
||||
db, user, password_data.old_password, password_data.new_password
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def logout(current_user: User):
|
||||
return AuthService.logout(current_user)
|
||||
|
||||
@staticmethod
|
||||
def me(db: Session, current_user: User):
|
||||
return AuthService.me(db, current_user)
|
||||
|
||||
@staticmethod
|
||||
def forgot_password(db: Session, request: ForgotPasswordRequest):
|
||||
return AuthService.forgot_password(db, request.email)
|
||||
|
||||
@staticmethod
|
||||
def verify_otp(db: Session, request: VerifyOTPRequest):
|
||||
return AuthService.verify_otp(db, request.email, request.otp)
|
||||
|
||||
@staticmethod
|
||||
def reset_password_with_otp(db: Session, request: ResetPasswordWithOTP):
|
||||
return AuthService.reset_password_with_otp(
|
||||
db, request.email, request.otp, request.new_password
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
from app.models.auth.role_model import Role
|
||||
from app.schemas.auth.role_schema import (
|
||||
RoleCreate,
|
||||
RoleUpdate,
|
||||
RoleResponse,
|
||||
RoleWithAccessesResponse,
|
||||
RolePaginatedResponse,
|
||||
)
|
||||
from app.services.auth.role_service import RoleService
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
class RoleController:
|
||||
@staticmethod
|
||||
def create_role(
|
||||
db: Session, role_data: RoleCreate, tenant_id: uuid.UUID = None
|
||||
) -> Role:
|
||||
if tenant_id:
|
||||
role_data.tenant_id = tenant_id
|
||||
return RoleService.create_role(db, role_data)
|
||||
|
||||
@staticmethod
|
||||
def get_all_roles(db: Session, tenant_id: uuid.UUID = None) -> List[Role]:
|
||||
return RoleService.get_all_roles(db, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def get_role_by_id(db: Session, role_id: uuid.UUID) -> Role:
|
||||
return RoleService.get_role_by_id(db, role_id)
|
||||
|
||||
@staticmethod
|
||||
def update_role(
|
||||
db: Session,
|
||||
role_id: uuid.UUID,
|
||||
role_data: RoleUpdate,
|
||||
is_superadmin: bool = False,
|
||||
) -> Role:
|
||||
return RoleService.update_role(
|
||||
db, role_id, role_data, is_superadmin=is_superadmin
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_role(db: Session, role_id: uuid.UUID, is_superadmin: bool = False):
|
||||
return RoleService.delete_role(db, role_id, is_superadmin=is_superadmin)
|
||||
|
||||
@staticmethod
|
||||
def get_role_with_accesses(
|
||||
db: Session, role_id: uuid.UUID
|
||||
) -> RoleWithAccessesResponse:
|
||||
role = RoleService.get_role_by_id(db, role_id)
|
||||
|
||||
accesses = [
|
||||
{
|
||||
"id": str(ra.access.id),
|
||||
"access_code": ra.access.access_code,
|
||||
"category": ra.access.category,
|
||||
"name": ra.access.name,
|
||||
"parent_id": str(ra.access.parent_id) if ra.access.parent_id else None,
|
||||
}
|
||||
for ra in role.role_accesses
|
||||
]
|
||||
|
||||
return RoleWithAccessesResponse(
|
||||
id=role.id,
|
||||
role_name=role.role_name,
|
||||
tenant_id=role.tenant_id,
|
||||
created_at=role.created_at,
|
||||
updated_at=role.updated_at,
|
||||
accesses=accesses,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_roles_paginated(
|
||||
db: Session,
|
||||
current_user_tenant_id: Optional[uuid.UUID],
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
) -> RolePaginatedResponse:
|
||||
tenant_id = None if current_user_tenant_id is None else current_user_tenant_id
|
||||
return RoleService.get_roles_paginated(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate
|
||||
from app.services.auth.tenant_service import TenantService
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
class TenantController:
|
||||
|
||||
@staticmethod
|
||||
def create_tenant(db: Session, tenant_data: TenantCreate):
|
||||
return TenantService.create_tenant(db, tenant_data)
|
||||
|
||||
@staticmethod
|
||||
def get_all_tenants(db: Session):
|
||||
return TenantService.get_all_tenants(db)
|
||||
|
||||
@staticmethod
|
||||
def get_tenant_by_id(db: Session, tenant_id: uuid.UUID):
|
||||
return TenantService.get_tenant_by_id(db, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def update_tenant(db: Session, tenant_id: uuid.UUID, tenant_data: TenantUpdate):
|
||||
return TenantService.update_tenant(db, tenant_id, tenant_data)
|
||||
|
||||
@staticmethod
|
||||
def delete_tenant(db: Session, tenant_id: uuid.UUID):
|
||||
return TenantService.delete_tenant(db, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def get_tenants_paginated(
|
||||
db: Session,
|
||||
page: int,
|
||||
page_size: int,
|
||||
search: Optional[str],
|
||||
is_active: Optional[bool],
|
||||
):
|
||||
return TenantService.get_tenants_paginated(
|
||||
db=db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
is_active=is_active,
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
from app.models.auth.user_model import User
|
||||
from app.schemas.auth.user_schema import UserCreate, UserUpdate
|
||||
from app.services.auth.user_service import UserService
|
||||
|
||||
class UserController:
|
||||
@staticmethod
|
||||
def _resolve_tenant_id(current_user: User, requested_tenant_id: Optional[uuid.UUID]) -> Optional[uuid.UUID]:
|
||||
if current_user.tenant_id is None:
|
||||
return requested_tenant_id
|
||||
|
||||
if requested_tenant_id and requested_tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to access this tenant"
|
||||
)
|
||||
|
||||
return current_user.tenant_id
|
||||
|
||||
@staticmethod
|
||||
def create_user(db: Session, user_data: UserCreate, current_user: User) -> User:
|
||||
tenant_id = UserController._resolve_tenant_id(current_user, user_data.tenant_id)
|
||||
return UserService.create_user(db, user_data, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def get_all_users(db: Session, current_user: User) -> List[User]:
|
||||
tenant_id = current_user.tenant_id
|
||||
return UserService.get_all_users(db, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_id(db: Session, user_id: uuid.UUID, current_user: User) -> User:
|
||||
tenant_id = current_user.tenant_id
|
||||
return UserService.get_user_by_id(db, user_id, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User) -> User:
|
||||
if current_user.tenant_id is not None and user_data.tenant_id is not None:
|
||||
UserController._resolve_tenant_id(current_user, user_data.tenant_id)
|
||||
|
||||
tenant_id = current_user.tenant_id
|
||||
return UserService.update_user(db, user_id, user_data, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def delete_user(db: Session, user_id: uuid.UUID, current_user: User):
|
||||
tenant_id = current_user.tenant_id
|
||||
return UserService.delete_user(db, user_id, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def get_users_paginated(
|
||||
db: Session,
|
||||
current_user: User,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
):
|
||||
tenant_id = current_user.tenant_id
|
||||
return UserService.get_users_paginated(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
status=status,
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
from typing import List, Optional
|
||||
from app.models.auth.user_models import User
|
||||
from app.models.theme.color_palette_models import ColorPalette
|
||||
from app.schemas.theme.palette_schema import ColorPaletteCreate, ColorPaletteUpdate
|
||||
from app.services.theme.palette_service import PaletteService
|
||||
|
||||
class PaletteController:
|
||||
@staticmethod
|
||||
def get_all_palettes(db: Session, current_user: User) -> List[ColorPalette]:
|
||||
|
||||
tenant_id = current_user.tenant_id
|
||||
return PaletteService.get_all_palettes(db, tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def get_palette(db: Session, palette_id: UUID) -> ColorPalette:
|
||||
return PaletteService.get_palette_by_id(db, palette_id)
|
||||
|
||||
@staticmethod
|
||||
def create_palette(db: Session, data: ColorPaletteCreate, current_user: User) -> ColorPalette:
|
||||
return PaletteService.create_palette(db, data, tenant_id=None)
|
||||
|
||||
@staticmethod
|
||||
def update_palette(db: Session, palette_id: UUID, data: ColorPaletteUpdate) -> ColorPalette:
|
||||
return PaletteService.update_palette(db, palette_id, data)
|
||||
|
||||
@staticmethod
|
||||
def delete_palette(db: Session, palette_id: UUID):
|
||||
return PaletteService.delete_palette(db, palette_id)
|
||||
@@ -0,0 +1,124 @@
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.config.database import get_db
|
||||
from app.config.security import security
|
||||
from app.models.auth.user_model import User
|
||||
|
||||
security_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
token = credentials.credentials if credentials else request.cookies.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated"
|
||||
)
|
||||
try:
|
||||
payload = security.verify_access_token(token)
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials"
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User is inactive"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
def require_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user"
|
||||
)
|
||||
return current_user
|
||||
|
||||
def has_access(user: User, access_code: str) -> bool:
|
||||
if not user.role:
|
||||
return False
|
||||
|
||||
user_access_codes = {ra.access.access_code for ra in user.role.role_accesses}
|
||||
|
||||
return access_code in user_access_codes
|
||||
|
||||
def can_access(user: User, access_code: str, db: Session) -> bool:
|
||||
if not user.role:
|
||||
return False
|
||||
|
||||
user_access_codes = {ra.access.access_code for ra in user.role.role_accesses}
|
||||
|
||||
if access_code in user_access_codes:
|
||||
return True
|
||||
|
||||
from app.models.auth.access_models import Access
|
||||
|
||||
requested_access = db.query(Access).filter(
|
||||
Access.access_code == access_code
|
||||
).first()
|
||||
|
||||
if not requested_access:
|
||||
return False
|
||||
|
||||
current = requested_access
|
||||
while current.parent:
|
||||
if current.parent.access_code in user_access_codes:
|
||||
return True
|
||||
current = current.parent
|
||||
|
||||
return False
|
||||
|
||||
def get_user_accesses(user: User) -> List[str]:
|
||||
if not user.role:
|
||||
return []
|
||||
|
||||
return [ra.access.access_code for ra in user.role.role_accesses]
|
||||
|
||||
def require_access(access_code: str):
|
||||
def check_permission(current_user: User = Depends(get_current_user)) -> bool:
|
||||
if not has_access(current_user, access_code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Insufficient permissions. Required: {access_code}"
|
||||
)
|
||||
return True
|
||||
|
||||
return check_permission
|
||||
|
||||
def require_access_hierarchical(access_code: str):
|
||||
def check_permission(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
) -> bool:
|
||||
if not can_access(current_user, access_code, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Insufficient permissions. Required: {access_code}"
|
||||
)
|
||||
return True
|
||||
|
||||
return check_permission
|
||||
@@ -0,0 +1,30 @@
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from typing import Optional
|
||||
import uuid
|
||||
from app.middleware.auth_middleware import get_current_user
|
||||
|
||||
def get_tenant_from_header(x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID")) -> Optional[uuid.UUID]:
|
||||
if not x_tenant_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
return uuid.UUID(x_tenant_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid X-Tenant-ID format. Must be a valid UUID."
|
||||
)
|
||||
|
||||
def get_tenant_id_from_user(user) -> Optional[uuid.UUID]:
|
||||
return user.tenant_id
|
||||
|
||||
def is_superadmin(user) -> bool:
|
||||
return user.tenant_id is None
|
||||
|
||||
def require_superadmin(user=Depends(get_current_user)):
|
||||
if not is_superadmin(user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Superadmin access required"
|
||||
)
|
||||
return user
|
||||
@@ -0,0 +1,5 @@
|
||||
from app.models.auth.access_model import Access
|
||||
from app.models.auth.role_model import Role
|
||||
from app.models.auth.role_access_model import RoleAccess
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
from app.models.auth.user_model import User
|
||||
@@ -0,0 +1,22 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, DateTime, func, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class Access(Base):
|
||||
__tablename__ = "accesses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
access_code = Column(String, unique=True, nullable=False, index=True)
|
||||
category = Column(String, nullable=False, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey('accesses.id'), nullable=True, index=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
parent = relationship("Access", remote_side=[id], backref="children")
|
||||
role_accesses = relationship("RoleAccess", back_populates="access")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Access {self.access_code}>"
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, DateTime, func, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class RoleAccess(Base):
|
||||
__tablename__ = "role_accesses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=False, index=True)
|
||||
access_id = Column(UUID(as_uuid=True), ForeignKey("accesses.id"), nullable=False, index=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
role = relationship("Role", back_populates="role_accesses")
|
||||
access = relationship("Access", back_populates="role_accesses")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('role_id', 'access_id', name='uq_role_access'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RoleAccess role_id={self.role_id} access_id={self.access_id}>"
|
||||
@@ -0,0 +1,31 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Boolean
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
role_name = Column(String, nullable=False)
|
||||
tenant_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
|
||||
)
|
||||
is_default = Column(Boolean, default=False)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant = relationship("Tenant", back_populates="roles")
|
||||
users = relationship("User", back_populates="role")
|
||||
role_accesses = relationship(
|
||||
"RoleAccess", back_populates="role", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role {self.role_name}>"
|
||||
@@ -0,0 +1,27 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
tenant_name = Column(String, unique=True, nullable=False, index=True)
|
||||
tenant_domain = Column(String, unique=True, nullable=False, index=True)
|
||||
tenant_logo_url = Column(String, nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
users = relationship("User", back_populates="tenant")
|
||||
roles = relationship("Role", back_populates="tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tenant {self.tenant_name}>"
|
||||
@@ -0,0 +1,32 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
password = Column(String, nullable=False)
|
||||
first_name = Column(String, nullable=False)
|
||||
last_name = Column(String, nullable=True)
|
||||
phone_number = Column(String, nullable=True)
|
||||
preferred_language = Column(String, default="en", nullable=True)
|
||||
status = Column(String, default="active", nullable=False)
|
||||
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True)
|
||||
|
||||
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id"), nullable=True, index=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
|
||||
password_updated_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
tenant = relationship("Tenant", back_populates="users")
|
||||
role = relationship("Role", back_populates="users")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.email}>"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import Column, String, Boolean, JSON, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
from app.config.database import Base
|
||||
|
||||
class ColorPalette(Base):
|
||||
__tablename__ = "color_palettes"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
is_default = Column(Boolean, default=False)
|
||||
colors = Column(JSON, nullable=False)
|
||||
@@ -0,0 +1,3 @@
|
||||
from .auth import router
|
||||
from .tenant import router
|
||||
from .role import router
|
||||
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from app.config.database import get_db
|
||||
from app.controllers.auth.access_controller import AccessController
|
||||
from app.schemas.auth.access_schema import AccessResponse
|
||||
from app.middleware.auth_middleware import get_current_user, require_access
|
||||
from app.models.auth.user_model import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/get", response_model=List[AccessResponse])
|
||||
def get_accesses(
|
||||
category: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("admin.role.read")),
|
||||
):
|
||||
return AccessController.get_all_accesses(db, category)
|
||||
@@ -0,0 +1,101 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config.database import get_db
|
||||
from app.controllers.auth.auth_controller import AuthController
|
||||
from app.schemas.auth.auth_schema import (
|
||||
UserSignup,
|
||||
UserSignin,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
ResetPassword,
|
||||
RefreshTokenRequest,
|
||||
ForgotPasswordRequest,
|
||||
VerifyOTPRequest,
|
||||
ResetPasswordWithOTP,
|
||||
)
|
||||
from app.middleware.auth_middleware import get_current_user
|
||||
from app.middleware.tenant_middleware import get_tenant_from_header
|
||||
from app.models.auth.user_model import User
|
||||
import uuid
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def signup(
|
||||
user_data: UserSignup,
|
||||
db: Session = Depends(get_db),
|
||||
tenant_id: uuid.UUID = Depends(get_tenant_from_header),
|
||||
):
|
||||
return AuthController.signup(db, user_data, tenant_id)
|
||||
|
||||
@router.post("/signin", response_model=TokenResponse)
|
||||
def signin(signin_data: UserSignin, db: Session = Depends(get_db)):
|
||||
return AuthController.signin(db, signin_data)
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
def refresh_token(token_data: RefreshTokenRequest, db: Session = Depends(get_db)):
|
||||
return AuthController.refresh_token(db, token_data)
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return AuthController.me(db, current_user)
|
||||
|
||||
@router.put("/update/{user_id}", response_model=UserResponse)
|
||||
def update_user(
|
||||
user_id: uuid.UUID,
|
||||
user_data: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return AuthController.update_user(db, user_id, user_data, current_user)
|
||||
|
||||
@router.patch("/update/{user_id}/language", response_model=UserResponse)
|
||||
def update_language(
|
||||
user_id: uuid.UUID,
|
||||
language_data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
supported_languages = ["en", "ar"]
|
||||
preferred_language = language_data.get("preferred_language")
|
||||
|
||||
if preferred_language not in supported_languages:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported language. Supported languages: {', '.join(supported_languages)}"
|
||||
)
|
||||
|
||||
user_update = UserUpdate(preferred_language=preferred_language)
|
||||
return AuthController.update_user(db, user_id, user_update, current_user)
|
||||
|
||||
@router.post("/reset-password")
|
||||
def reset_password(
|
||||
password_data: ResetPassword,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return AuthController.reset_password(db, current_user, password_data)
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(current_user: User = Depends(get_current_user)):
|
||||
return AuthController.logout(current_user)
|
||||
|
||||
@router.post("/forgot-password")
|
||||
def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)):
|
||||
return AuthController.forgot_password(db, request)
|
||||
|
||||
@router.post("/verify-otp")
|
||||
def verify_otp(request: VerifyOTPRequest, db: Session = Depends(get_db)):
|
||||
return AuthController.verify_otp(db, request)
|
||||
|
||||
|
||||
@router.post("/reset-password-otp")
|
||||
def reset_password_with_otp(
|
||||
request: ResetPasswordWithOTP, db: Session = Depends(get_db)
|
||||
):
|
||||
return AuthController.reset_password_with_otp(db, request)
|
||||
@@ -0,0 +1,87 @@
|
||||
from fastapi import APIRouter, Depends, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
from app.config.database import get_db
|
||||
from app.controllers.auth.role_controller import RoleController
|
||||
from app.schemas.auth.role_schema import (
|
||||
RoleCreate,
|
||||
RoleUpdate,
|
||||
RoleResponse,
|
||||
RoleWithAccessesResponse,
|
||||
RolePaginatedResponse,
|
||||
)
|
||||
from app.middleware.auth_middleware import get_current_user, require_access
|
||||
from app.models.auth.user_model import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/create", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_role(
|
||||
role_data: RoleCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(require_access("admin.role.create")),
|
||||
):
|
||||
return RoleController.create_role(
|
||||
db, role_data, current_user.tenant_id if current_user.tenant_id else None
|
||||
)
|
||||
|
||||
@router.get("/get", response_model=List[RoleResponse])
|
||||
def get_all_roles(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(require_access("admin.role.read")),
|
||||
):
|
||||
return RoleController.get_all_roles(
|
||||
db, current_user.tenant_id if current_user.tenant_id else None
|
||||
)
|
||||
|
||||
@router.get("/get/{role_id}", response_model=RoleWithAccessesResponse)
|
||||
def get_role(
|
||||
role_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(require_access("admin.role.read")),
|
||||
):
|
||||
return RoleController.get_role_with_accesses(db, role_id)
|
||||
|
||||
@router.put("/update/{role_id}", response_model=RoleResponse)
|
||||
def update_role(
|
||||
role_id: uuid.UUID,
|
||||
role_data: RoleUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(require_access("admin.role.update")),
|
||||
):
|
||||
is_superadmin = current_user.tenant_id is None
|
||||
return RoleController.update_role(
|
||||
db, role_id, role_data, is_superadmin=is_superadmin
|
||||
)
|
||||
|
||||
@router.delete("/delete/{role_id}")
|
||||
def delete_role(
|
||||
role_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(require_access("admin.role.delete")),
|
||||
):
|
||||
is_superadmin = current_user.tenant_id is None
|
||||
return RoleController.delete_role(db, role_id, is_superadmin=is_superadmin)
|
||||
|
||||
@router.get("/list", response_model=RolePaginatedResponse)
|
||||
def list_roles(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(10, ge=1, le=100, description="Items per page"),
|
||||
search: Optional[str] = Query(None, description="Search in role name or ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(require_access("admin.role.read")),
|
||||
):
|
||||
return RoleController.get_roles_paginated(
|
||||
db=db,
|
||||
current_user_tenant_id=current_user.tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
from fastapi import APIRouter, Depends, status, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
from app.config.database import get_db
|
||||
from app.controllers.auth.tenant_controller import TenantController
|
||||
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantResponse, TenantPaginatedResponse
|
||||
from app.middleware.auth_middleware import get_current_user, require_access
|
||||
from app.middleware.tenant_middleware import get_tenant_id_from_user
|
||||
from app.models.auth.user_model import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/create", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_tenant(
|
||||
tenant_data: TenantCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.tenant.create"))
|
||||
):
|
||||
return TenantController.create_tenant(db, tenant_data)
|
||||
|
||||
@router.get("/get", response_model=List[TenantResponse])
|
||||
def get_all_tenants(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.tenant.read"))
|
||||
):
|
||||
return TenantController.get_all_tenants(db)
|
||||
|
||||
@router.get("/get/{tenant_id}", response_model=TenantResponse)
|
||||
def get_tenant(
|
||||
tenant_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.tenant.read"))
|
||||
):
|
||||
return TenantController.get_tenant_by_id(db, tenant_id)
|
||||
|
||||
@router.get("/me", response_model=TenantResponse)
|
||||
def get_my_tenant(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = get_tenant_id_from_user(current_user)
|
||||
if not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found"
|
||||
)
|
||||
return TenantController.get_tenant_by_id(db, tenant_id)
|
||||
|
||||
@router.put("/update/{tenant_id}", response_model=TenantResponse)
|
||||
def update_tenant(
|
||||
tenant_id: uuid.UUID,
|
||||
tenant_data: TenantUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.tenant.update"))
|
||||
):
|
||||
return TenantController.update_tenant(db, tenant_id, tenant_data)
|
||||
|
||||
@router.delete("/delete/{tenant_id}")
|
||||
def delete_tenant(
|
||||
tenant_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.tenant.delete"))
|
||||
):
|
||||
return TenantController.delete_tenant(db, tenant_id)
|
||||
|
||||
@router.get("/list", response_model=TenantPaginatedResponse)
|
||||
def list_tenants(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(10, ge=1, le=100, description="Items per page"),
|
||||
search: Optional[str] = Query(None, description="Search in name, domain, or ID"),
|
||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.tenant.read")),
|
||||
):
|
||||
return TenantController.get_tenants_paginated(
|
||||
db=db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
is_active=is_active,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
from fastapi import APIRouter, Depends, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
from app.config.database import get_db
|
||||
from app.controllers.auth.user_controller import UserController
|
||||
from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
|
||||
from app.middleware.auth_middleware import get_current_user, require_access
|
||||
from app.models.auth.user_model import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/create", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
user_data: UserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("admin.user.create"))
|
||||
):
|
||||
return UserController.create_user(db, user_data, current_user)
|
||||
|
||||
@router.get("/get", response_model=List[UserResponse])
|
||||
def get_all_users(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("admin.user.read"))
|
||||
):
|
||||
return UserController.get_all_users(db, current_user)
|
||||
|
||||
@router.get("/get/{user_id}", response_model=UserResponse)
|
||||
def get_user(
|
||||
user_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("admin.user.read"))
|
||||
):
|
||||
return UserController.get_user_by_id(db, user_id, current_user)
|
||||
|
||||
@router.put("/update/{user_id}", response_model=UserResponse)
|
||||
def update_user(
|
||||
user_id: uuid.UUID,
|
||||
user_data: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("admin.user.update"))
|
||||
):
|
||||
return UserController.update_user(db, user_id, user_data, current_user)
|
||||
|
||||
@router.delete("/delete/{user_id}")
|
||||
def delete_user(
|
||||
user_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("admin.user.delete"))
|
||||
):
|
||||
return UserController.delete_user(db, user_id, current_user)
|
||||
|
||||
@router.get("/list", response_model=UserPaginatedResponse)
|
||||
def list_users(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(10, ge=1, le=100, description="Items per page"),
|
||||
search: Optional[str] = Query(None, description="Search in email, name, phone, or ID"),
|
||||
status: Optional[str] = Query(None, description="Filter by user status (e.g. active, inactive)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("admin.user.read")),
|
||||
):
|
||||
return UserController.get_users_paginated(
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
status=status,
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
from fastapi import APIRouter, Depends, status, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
from app.config.database import get_db
|
||||
from app.controllers.theme.palette_controller import PaletteController
|
||||
from app.schemas.theme.palette_schema import ColorPaletteCreate, ColorPaletteUpdate, ColorPaletteResponse
|
||||
from app.middleware.auth_middleware import get_current_user, require_access
|
||||
from app.models.auth.user_model import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/get", response_model=List[ColorPaletteResponse])
|
||||
def get_all_palettes(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
return PaletteController.get_all_palettes(db, current_user)
|
||||
|
||||
@router.get("/get/{palette_id}", response_model=ColorPaletteResponse)
|
||||
def get_palette(
|
||||
palette_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
return PaletteController.get_palette(db, palette_id)
|
||||
|
||||
@router.post("/create", response_model=ColorPaletteResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_palette(
|
||||
data: ColorPaletteCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.palette.create"))
|
||||
):
|
||||
return PaletteController.create_palette(db, data, current_user)
|
||||
|
||||
@router.put("/update/users/me/preferences", deprecated=True)
|
||||
def update_user_preference():
|
||||
pass
|
||||
|
||||
@router.put("/update/{palette_id}", response_model=ColorPaletteResponse)
|
||||
def update_palette(
|
||||
palette_id: UUID,
|
||||
data: ColorPaletteUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.palette.update"))
|
||||
):
|
||||
return PaletteController.update_palette(db, palette_id, data)
|
||||
|
||||
@router.delete("/delete/{palette_id}", status_code=status.HTTP_200_OK)
|
||||
def delete_palette(
|
||||
palette_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.palette.delete"))
|
||||
):
|
||||
return PaletteController.delete_palette(db, palette_id)
|
||||
@@ -0,0 +1,17 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
class AccessBase(BaseModel):
|
||||
access_code: str
|
||||
category: str
|
||||
name: str
|
||||
|
||||
class AccessResponse(AccessBase):
|
||||
id: uuid.UUID
|
||||
parent_id: Optional[uuid.UUID] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,76 @@
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
first_name: str
|
||||
last_name: Optional[str] = None
|
||||
phone_number: Optional[str] = None
|
||||
status: Optional[str] = "active"
|
||||
|
||||
class UserSignup(UserBase):
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
class UserSignin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class AccessInRole(BaseModel):
|
||||
id: str
|
||||
access_code: str
|
||||
category: str
|
||||
name: str
|
||||
|
||||
class RoleInUser(BaseModel):
|
||||
id: uuid.UUID
|
||||
role_name: str
|
||||
accesses: List[str] = []
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: uuid.UUID
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
tenant_name: Optional[str] = None
|
||||
tenant_logo_url: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
role: Optional[RoleInUser] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
phone_number: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class ResetPassword(BaseModel):
|
||||
old_password: str
|
||||
new_password: str = Field(..., min_length=8)
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class VerifyOTPRequest(BaseModel):
|
||||
email: EmailStr
|
||||
otp: str
|
||||
|
||||
|
||||
class ResetPasswordWithOTP(BaseModel):
|
||||
email: EmailStr
|
||||
otp: str
|
||||
new_password: str = Field(..., min_length=8)
|
||||
@@ -0,0 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class UpdateLanguageRequest(BaseModel):
|
||||
preferred_language: str
|
||||
@@ -0,0 +1,44 @@
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
class RoleBase(BaseModel):
|
||||
role_name: str = Field(..., min_length=2, max_length=100)
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
|
||||
class RoleCreate(RoleBase):
|
||||
access_ids: List[uuid.UUID] = []
|
||||
is_default: Optional[bool] = False
|
||||
|
||||
class RoleUpdate(BaseModel):
|
||||
role_name: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
access_ids: Optional[List[uuid.UUID]] = None
|
||||
is_default: Optional[bool] = None
|
||||
|
||||
class RoleResponse(RoleBase):
|
||||
id: uuid.UUID
|
||||
is_default: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@field_validator("is_default", mode="before")
|
||||
@classmethod
|
||||
def set_is_default(cls, v):
|
||||
return v or False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class RoleWithAccessesResponse(RoleResponse):
|
||||
accesses: List[dict] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class RolePaginatedResponse(BaseModel):
|
||||
items: List[RoleResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
@@ -0,0 +1,34 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
class TenantBase(BaseModel):
|
||||
tenant_name: str = Field(..., min_length=2, max_length=100)
|
||||
tenant_domain: str = Field(..., min_length=3, max_length=255)
|
||||
tenant_logo_url: Optional[str] = None
|
||||
|
||||
class TenantCreate(TenantBase):
|
||||
pass
|
||||
|
||||
class TenantUpdate(BaseModel):
|
||||
tenant_name: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
tenant_domain: Optional[str] = Field(None, min_length=3, max_length=255)
|
||||
tenant_logo_url: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class TenantResponse(TenantBase):
|
||||
id: uuid.UUID
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TenantPaginatedResponse(BaseModel):
|
||||
items: List[TenantResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
@@ -0,0 +1,44 @@
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
first_name: str
|
||||
last_name: Optional[str] = None
|
||||
phone_number: Optional[str] = None
|
||||
preferred_language: Optional[str] = "en"
|
||||
status: Optional[str] = "active"
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
role_id: Optional[uuid.UUID] = None
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
phone_number: Optional[str] = None
|
||||
preferred_language: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
role_id: Optional[uuid.UUID] = None
|
||||
password: Optional[str] = Field(None, min_length=8)
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: uuid.UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
password_updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class UserPaginatedResponse(BaseModel):
|
||||
items: List[UserResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
@@ -0,0 +1,60 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
class ColorSet(BaseModel):
|
||||
primary: str
|
||||
primary_hover: str
|
||||
|
||||
sidebar_bg: str
|
||||
sidebar_text: str
|
||||
sidebar_text_muted: Optional[str] = None
|
||||
sidebar_active_bg: str
|
||||
sidebar_active_text: str
|
||||
sidebar_hover_bg: str
|
||||
sidebar_border: str
|
||||
|
||||
header_bg: str
|
||||
header_text: str
|
||||
header_border: str
|
||||
|
||||
background: str
|
||||
background_secondary: str
|
||||
|
||||
text_primary: str
|
||||
text_secondary: str
|
||||
|
||||
card_bg: str
|
||||
card_border: str
|
||||
|
||||
table_header_bg: str
|
||||
table_row_hover: str
|
||||
table_border: str
|
||||
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
class ColorPaletteBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
colors: ColorSet
|
||||
is_default: bool = False
|
||||
|
||||
class ColorPaletteCreate(ColorPaletteBase):
|
||||
pass
|
||||
|
||||
class ColorPaletteUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
colors: Optional[ColorSet] = None
|
||||
is_default: Optional[bool] = None
|
||||
|
||||
class ColorPaletteResponse(ColorPaletteBase):
|
||||
id: UUID
|
||||
tenant_id: Optional[UUID] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.auth.access_model import Access
|
||||
from typing import List
|
||||
|
||||
class AccessService:
|
||||
|
||||
@staticmethod
|
||||
def get_all_accesses(db: Session, category: str = None) -> List[Access]:
|
||||
query = db.query(Access)
|
||||
|
||||
if category:
|
||||
query = query.filter(Access.category == category)
|
||||
|
||||
return query.all()
|
||||
|
||||
@staticmethod
|
||||
def get_access_categories(db: Session) -> List[str]:
|
||||
categories = db.query(Access.category).distinct().all()
|
||||
return [cat[0] for cat in categories]
|
||||
@@ -0,0 +1,287 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
from app.models.auth.user_model import User
|
||||
from app.schemas.auth.auth_schema import UserSignup, UserSignin, UserUpdate
|
||||
from app.config.security import security
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
import uuid
|
||||
from app.services.auth.email_service import EmailService
|
||||
|
||||
class AuthService:
|
||||
|
||||
@staticmethod
|
||||
def create_user(
|
||||
db: Session, user_data: UserSignup, tenant_id: uuid.UUID = None
|
||||
) -> User:
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
if not security.validate_password_strength(user_data.password):
|
||||
raise HTTPException(status_code=400, detail="Password too weak")
|
||||
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
password=security.hash_password(user_data.password),
|
||||
first_name=user_data.first_name,
|
||||
last_name=user_data.last_name,
|
||||
phone_number=user_data.phone_number,
|
||||
status=user_data.status or "active",
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def signin(db: Session, signin_data: UserSignin):
|
||||
user = db.query(User).filter(User.email == signin_data.email).first()
|
||||
|
||||
if not user or not security.verify_password(
|
||||
signin_data.password, user.password
|
||||
):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="User inactive")
|
||||
|
||||
role_data = None
|
||||
if user.role:
|
||||
role_data = {
|
||||
"id": str(user.role.id),
|
||||
"role_name": user.role.role_name,
|
||||
"accesses": [ra.access.access_code for ra in user.role.role_accesses],
|
||||
}
|
||||
|
||||
return {
|
||||
"access_token": security.generate_access_token(
|
||||
{"sub": str(user.id)}, user.tenant_id
|
||||
),
|
||||
"refresh_token": security.generate_refresh_token(
|
||||
{"sub": str(user.id)}, user.tenant_id
|
||||
),
|
||||
"token_type": "bearer",
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"phone_number": user.phone_number,
|
||||
"status": user.status,
|
||||
"tenant_id": user.tenant_id,
|
||||
"tenant_name": user.tenant.tenant_name if user.tenant else None,
|
||||
"tenant_logo_url": user.tenant.tenant_logo_url if user.tenant else None,
|
||||
"created_at": user.created_at,
|
||||
"updated_at": user.updated_at,
|
||||
"role": role_data,
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def refresh_access_token(db: Session, refresh_token: str):
|
||||
payload = security.verify_refresh_token(refresh_token)
|
||||
user_id = payload.get("sub")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found"
|
||||
)
|
||||
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
|
||||
)
|
||||
|
||||
new_access_token = security.generate_access_token(
|
||||
{"sub": str(user.id)}, tenant_id=user.tenant_id
|
||||
)
|
||||
new_refresh_token = security.generate_refresh_token(
|
||||
{"sub": str(user.id)}, tenant_id=user.tenant_id
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": new_access_token,
|
||||
"refresh_token": new_refresh_token,
|
||||
"token_type": "bearer",
|
||||
"user": user,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update_user(
|
||||
db: Session, user_id: uuid.UUID, update_data: UserUpdate, current_user: User
|
||||
):
|
||||
|
||||
if current_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to update this profile",
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
if "email" in update_dict and update_dict["email"] != user.email:
|
||||
if db.query(User).filter(User.email == update_dict["email"]).first():
|
||||
raise HTTPException(status_code=400, detail="Email already used")
|
||||
|
||||
for key, value in update_dict.items():
|
||||
setattr(user, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def reset_password(
|
||||
db: Session, current_user: User, old_password: str, new_password: str
|
||||
):
|
||||
if not security.verify_password(old_password, current_user.password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Incorrect old password"
|
||||
)
|
||||
|
||||
if not security.validate_password_strength(new_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Password too weak"
|
||||
)
|
||||
|
||||
current_user.password = security.hash_password(new_password)
|
||||
current_user.password_updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return {"message": "Password updated successfully"}
|
||||
|
||||
@staticmethod
|
||||
def logout(current_user: User):
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
@staticmethod
|
||||
def me(db: Session, current_user: User):
|
||||
role_data = None
|
||||
|
||||
if current_user.role:
|
||||
role_data = {
|
||||
"id": str(current_user.role.id),
|
||||
"role_name": current_user.role.role_name,
|
||||
"accesses": [
|
||||
ra.access.access_code for ra in current_user.role.role_accesses
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"id": str(current_user.id),
|
||||
"email": current_user.email,
|
||||
"first_name": current_user.first_name,
|
||||
"last_name": current_user.last_name,
|
||||
"phone_number": current_user.phone_number,
|
||||
"status": current_user.status,
|
||||
"tenant_id": current_user.tenant_id,
|
||||
"tenant_name": (
|
||||
current_user.tenant.tenant_name if current_user.tenant else None
|
||||
),
|
||||
"tenant_logo_url": (
|
||||
current_user.tenant.tenant_logo_url if current_user.tenant else None
|
||||
),
|
||||
"created_at": current_user.created_at,
|
||||
"updated_at": current_user.updated_at,
|
||||
"role": role_data,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def forgot_password(db: Session, email: str):
|
||||
print(f"DEBUG: Processing forgot_password for email: {email}")
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
print(f"DEBUG: User not found for email: {email}")
|
||||
return {"message": "If the email is registered, an OTP has been sent."}
|
||||
|
||||
print(f"DEBUG: User found: {user.id}")
|
||||
|
||||
otp_code = "".join([str(random.randint(0, 9)) for _ in range(6)])
|
||||
expires_at = datetime.utcnow() + timedelta(minutes=10)
|
||||
print(f"DEBUG: Generated OTP: {otp_code}, Expires: {expires_at}")
|
||||
|
||||
db.query(PasswordResetOTP).filter(
|
||||
PasswordResetOTP.email == email, PasswordResetOTP.is_used == False
|
||||
).update({"is_used": True})
|
||||
|
||||
try:
|
||||
otp_entry = PasswordResetOTP(
|
||||
email=email, otp=otp_code, expires_at=expires_at
|
||||
)
|
||||
db.add(otp_entry)
|
||||
db.commit()
|
||||
print("DEBUG: OTP stored in database successfully")
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Database error saving OTP: {e}")
|
||||
db.rollback()
|
||||
raise e
|
||||
|
||||
# Send Email
|
||||
print("DEBUG: Attempting to send email...")
|
||||
email_sent = EmailService.send_otp(email, otp_code)
|
||||
print(f"DEBUG: Email sending result: {email_sent}")
|
||||
|
||||
return {"message": "If the email is registered, an OTP has been sent."}
|
||||
|
||||
@staticmethod
|
||||
def verify_otp(db: Session, email: str, otp: str):
|
||||
otp_entry = (
|
||||
db.query(PasswordResetOTP)
|
||||
.filter(
|
||||
PasswordResetOTP.email == email,
|
||||
PasswordResetOTP.otp == otp,
|
||||
PasswordResetOTP.is_used == False,
|
||||
PasswordResetOTP.expires_at > datetime.utcnow(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not otp_entry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OTP"
|
||||
)
|
||||
|
||||
return {"message": "OTP verified successfully"}
|
||||
|
||||
@staticmethod
|
||||
def reset_password_with_otp(db: Session, email: str, otp: str, new_password: str):
|
||||
otp_entry = (
|
||||
db.query(PasswordResetOTP)
|
||||
.filter(
|
||||
PasswordResetOTP.email == email,
|
||||
PasswordResetOTP.otp == otp,
|
||||
PasswordResetOTP.is_used == False,
|
||||
PasswordResetOTP.expires_at > datetime.utcnow(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not otp_entry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OTP"
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
if not security.validate_password_strength(new_password):
|
||||
raise HTTPException(status_code=400, detail="Password too weak")
|
||||
|
||||
user.password = security.hash_password(new_password)
|
||||
user.password_updated_at = datetime.utcnow()
|
||||
|
||||
otp_entry.is_used = True
|
||||
|
||||
db.commit()
|
||||
return {"message": "Password updated successfully"}
|
||||
@@ -0,0 +1,41 @@
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class EmailService:
|
||||
SMTP_HOST = os.getenv("SMTP_HOST")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
|
||||
SMTP_USER = os.getenv("SMTP_USER")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
|
||||
EMAIL_FROM = os.getenv("EMAIL_FROM")
|
||||
SMTP_SECURE = os.getenv("SMTP_SECURE", "true").lower() == "true"
|
||||
|
||||
@staticmethod
|
||||
def send_otp(to_email: str, otp: str):
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = EmailService.EMAIL_FROM
|
||||
msg['To'] = to_email
|
||||
msg['Subject'] = "Password Reset OTP"
|
||||
|
||||
body = f"Your OTP for password reset is: {otp}. It expires in 10 minutes."
|
||||
msg.attach(MIMEText(body, 'plain'))
|
||||
|
||||
if EmailService.SMTP_SECURE:
|
||||
server = smtplib.SMTP_SSL(EmailService.SMTP_HOST, EmailService.SMTP_PORT)
|
||||
else:
|
||||
server = smtplib.SMTP(EmailService.SMTP_HOST, EmailService.SMTP_PORT)
|
||||
server.starttls()
|
||||
|
||||
server.login(EmailService.SMTP_USER, EmailService.SMTP_PASSWORD)
|
||||
text = msg.as_string()
|
||||
server.sendmail(EmailService.EMAIL_FROM, to_email, text)
|
||||
server.quit()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to send email: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,157 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, cast, String
|
||||
from fastapi import HTTPException, status
|
||||
from app.models.auth.role_model import Role
|
||||
from app.models.auth.role_access_model import RoleAccess
|
||||
from app.models.auth.access_model import Access
|
||||
from app.schemas.auth.role_schema import RoleCreate, RoleUpdate, RoleResponse, RolePaginatedResponse
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
|
||||
class RoleService:
|
||||
|
||||
@staticmethod
|
||||
def create_role(db: Session, role_data: RoleCreate) -> Role:
|
||||
existing = (
|
||||
db.query(Role)
|
||||
.filter(
|
||||
Role.role_name == role_data.role_name,
|
||||
Role.tenant_id == role_data.tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Role name already exists for this tenant",
|
||||
)
|
||||
|
||||
role = Role(
|
||||
role_name=role_data.role_name,
|
||||
tenant_id=role_data.tenant_id,
|
||||
is_default=role_data.is_default or False,
|
||||
)
|
||||
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
|
||||
if role_data.access_ids:
|
||||
RoleService.assign_accesses(db, role.id, role_data.access_ids)
|
||||
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
def assign_accesses(db: Session, role_id: uuid.UUID, access_ids: List[uuid.UUID]):
|
||||
db.query(RoleAccess).filter(RoleAccess.role_id == role_id).delete()
|
||||
|
||||
for access_id in access_ids:
|
||||
access = db.query(Access).filter(Access.id == access_id).first()
|
||||
if not access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Access {access_id} not found",
|
||||
)
|
||||
|
||||
role_access = RoleAccess(role_id=role_id, access_id=access_id)
|
||||
db.add(role_access)
|
||||
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_all_roles(db: Session, tenant_id: uuid.UUID = None):
|
||||
query = db.query(Role)
|
||||
if tenant_id:
|
||||
query = query.filter(Role.tenant_id == tenant_id)
|
||||
return query.all()
|
||||
|
||||
@staticmethod
|
||||
def get_role_by_id(db: Session, role_id: uuid.UUID) -> Role:
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Role not found"
|
||||
)
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
def update_role(
|
||||
db: Session,
|
||||
role_id: uuid.UUID,
|
||||
role_data: RoleUpdate,
|
||||
is_superadmin: bool = False,
|
||||
) -> Role:
|
||||
role = RoleService.get_role_by_id(db, role_id)
|
||||
|
||||
update_dict = role_data.model_dump(exclude_unset=True)
|
||||
|
||||
if role.is_default and not is_superadmin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Default roles can only be modified by superadmins.",
|
||||
)
|
||||
|
||||
if "access_ids" in update_dict:
|
||||
access_ids = update_dict.pop("access_ids")
|
||||
if access_ids is not None:
|
||||
RoleService.assign_accesses(db, role_id, access_ids)
|
||||
|
||||
for key, value in update_dict.items():
|
||||
setattr(role, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
def delete_role(db: Session, role_id: uuid.UUID, is_superadmin: bool = False):
|
||||
role = RoleService.get_role_by_id(db, role_id)
|
||||
|
||||
if role.is_default and not is_superadmin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Default roles can only be deleted by superadmins.",
|
||||
)
|
||||
|
||||
db.delete(role)
|
||||
db.commit()
|
||||
return {"message": "Role deleted successfully"}
|
||||
|
||||
@staticmethod
|
||||
def get_roles_paginated(
|
||||
db: Session,
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
) -> RolePaginatedResponse:
|
||||
|
||||
query = db.query(Role)
|
||||
|
||||
if tenant_id is not None:
|
||||
query = query.filter(Role.tenant_id == tenant_id)
|
||||
|
||||
if search and search.strip():
|
||||
search_term = search.strip()
|
||||
query = query.filter(
|
||||
or_(
|
||||
Role.role_name.ilike(f"%{search_term}%"),
|
||||
cast(Role.id, String).ilike(f"%{search_term}%"),
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
roles = query.offset(offset).limit(page_size).all()
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
||||
|
||||
return RolePaginatedResponse(
|
||||
items=[RoleResponse.model_validate(role) for role in roles],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, cast, String
|
||||
from fastapi import HTTPException, status
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantPaginatedResponse, TenantResponse
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
class TenantService:
|
||||
|
||||
@staticmethod
|
||||
def create_tenant(db: Session, tenant_data: TenantCreate) -> Tenant:
|
||||
existing = db.query(Tenant).filter(Tenant.tenant_name == tenant_data.tenant_name).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant name already exists"
|
||||
)
|
||||
|
||||
existing = db.query(Tenant).filter(Tenant.tenant_domain == tenant_data.tenant_domain).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant domain already exists"
|
||||
)
|
||||
|
||||
tenant = Tenant(
|
||||
tenant_name=tenant_data.tenant_name,
|
||||
tenant_domain=tenant_data.tenant_domain,
|
||||
tenant_logo_url=tenant_data.tenant_logo_url
|
||||
)
|
||||
|
||||
db.add(tenant)
|
||||
db.commit()
|
||||
db.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
@staticmethod
|
||||
def get_all_tenants(db: Session):
|
||||
return db.query(Tenant).all()
|
||||
|
||||
@staticmethod
|
||||
def get_tenant_by_id(db: Session, tenant_id: uuid.UUID) -> Tenant:
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
if not tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found"
|
||||
)
|
||||
return tenant
|
||||
|
||||
@staticmethod
|
||||
def update_tenant(db: Session, tenant_id: uuid.UUID, tenant_data: TenantUpdate) -> Tenant:
|
||||
tenant = TenantService.get_tenant_by_id(db, tenant_id)
|
||||
|
||||
update_dict = tenant_data.model_dump(exclude_unset=True)
|
||||
|
||||
if "tenant_name" in update_dict and update_dict["tenant_name"] != tenant.tenant_name:
|
||||
existing = db.query(Tenant).filter(Tenant.tenant_name == update_dict["tenant_name"]).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Tenant name already exists")
|
||||
|
||||
if "tenant_domain" in update_dict and update_dict["tenant_domain"] != tenant.tenant_domain:
|
||||
existing = db.query(Tenant).filter(Tenant.tenant_domain == update_dict["tenant_domain"]).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Tenant domain already exists")
|
||||
|
||||
for key, value in update_dict.items():
|
||||
setattr(tenant, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
@staticmethod
|
||||
def delete_tenant(db: Session, tenant_id: uuid.UUID):
|
||||
tenant = TenantService.get_tenant_by_id(db, tenant_id)
|
||||
db.delete(tenant)
|
||||
db.commit()
|
||||
return {"message": "Tenant deleted successfully"}
|
||||
|
||||
@staticmethod
|
||||
def get_tenants_paginated(
|
||||
db: Session,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> TenantPaginatedResponse:
|
||||
|
||||
query = db.query(Tenant)
|
||||
|
||||
if search and search.strip():
|
||||
search_term = search.strip()
|
||||
query = query.filter(
|
||||
or_(
|
||||
Tenant.tenant_name.ilike(f"%{search_term}%"),
|
||||
Tenant.tenant_domain.ilike(f"%{search_term}%"),
|
||||
cast(Tenant.id, String).ilike(f"%{search_term}%"),
|
||||
)
|
||||
)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(Tenant.is_active == is_active)
|
||||
|
||||
total = query.count()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
tenants = query.offset(offset).limit(page_size).all()
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
||||
|
||||
return TenantPaginatedResponse(
|
||||
items=[TenantResponse.from_orm(tenant) for tenant in tenants],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, cast, String
|
||||
from fastapi import HTTPException, status
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from app.models.auth.user_model import User
|
||||
from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
|
||||
from app.config.security import security
|
||||
|
||||
class UserService:
|
||||
|
||||
@staticmethod
|
||||
def create_user(db: Session, user_data: UserCreate, tenant_id: uuid.UUID = None) -> User:
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
if not security.validate_password_strength(user_data.password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password too weak"
|
||||
)
|
||||
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
password=security.hash_password(user_data.password),
|
||||
first_name=user_data.first_name,
|
||||
last_name=user_data.last_name,
|
||||
phone_number=user_data.phone_number,
|
||||
status=user_data.status or "active",
|
||||
tenant_id=tenant_id,
|
||||
role_id=user_data.role_id
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def get_all_users(db: Session, tenant_id: uuid.UUID = None):
|
||||
query = db.query(User)
|
||||
if tenant_id:
|
||||
query = query.filter(User.tenant_id == tenant_id)
|
||||
return query.all()
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_id(db: Session, user_id: uuid.UUID, tenant_id: uuid.UUID = None) -> User:
|
||||
query = db.query(User).filter(User.id == user_id)
|
||||
if tenant_id:
|
||||
query = query.filter(User.tenant_id == tenant_id)
|
||||
user = query.first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, tenant_id: uuid.UUID = None) -> User:
|
||||
user = UserService.get_user_by_id(db, user_id, tenant_id)
|
||||
|
||||
update_dict = user_data.model_dump(exclude_unset=True)
|
||||
if tenant_id:
|
||||
update_dict.pop("tenant_id", None)
|
||||
|
||||
if "email" in update_dict and update_dict["email"] != user.email:
|
||||
if db.query(User).filter(User.email == update_dict["email"]).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already used"
|
||||
)
|
||||
|
||||
password = update_dict.pop("password", None)
|
||||
if password:
|
||||
if not security.validate_password_strength(password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password too weak"
|
||||
)
|
||||
user.password = security.hash_password(password)
|
||||
user.password_updated_at = datetime.utcnow()
|
||||
|
||||
for key, value in update_dict.items():
|
||||
setattr(user, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def delete_user(db: Session, user_id: uuid.UUID, tenant_id: uuid.UUID = None):
|
||||
user = UserService.get_user_by_id(db, user_id, tenant_id)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"message": "User deleted successfully"}
|
||||
|
||||
@staticmethod
|
||||
def get_users_paginated(
|
||||
db: Session,
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
) -> UserPaginatedResponse:
|
||||
|
||||
query = db.query(User)
|
||||
|
||||
if tenant_id:
|
||||
query = query.filter(User.tenant_id == tenant_id)
|
||||
|
||||
if search and search.strip():
|
||||
search_term = search.strip()
|
||||
query = query.filter(
|
||||
or_(
|
||||
User.email.ilike(f"%{search_term}%"),
|
||||
User.first_name.ilike(f"%{search_term}%"),
|
||||
User.last_name.ilike(f"%{search_term}%"),
|
||||
User.phone_number.ilike(f"%{search_term}%"),
|
||||
cast(User.id, String).ilike(f"%{search_term}%"),
|
||||
)
|
||||
)
|
||||
|
||||
if status is not None:
|
||||
query = query.filter(User.status == status)
|
||||
|
||||
total = query.count()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
users = query.offset(offset).limit(page_size).all()
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
||||
|
||||
return UserPaginatedResponse(
|
||||
items=[UserResponse.model_validate(user) for user in users],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
from typing import List, Optional
|
||||
from fastapi import HTTPException, status
|
||||
from app.models.theme.color_palette_models import ColorPalette
|
||||
from app.schemas.theme.palette_schema import ColorPaletteCreate, ColorPaletteUpdate
|
||||
|
||||
class PaletteService:
|
||||
@staticmethod
|
||||
def get_all_palettes(db: Session, tenant_id: Optional[UUID] = None) -> List[ColorPalette]:
|
||||
query = db.query(ColorPalette)
|
||||
if tenant_id:
|
||||
query = query.filter((ColorPalette.tenant_id == None) | (ColorPalette.tenant_id == tenant_id))
|
||||
else:
|
||||
pass
|
||||
return query.all()
|
||||
|
||||
@staticmethod
|
||||
def get_palette_by_id(db: Session, palette_id: UUID) -> ColorPalette:
|
||||
palette = db.query(ColorPalette).filter(ColorPalette.id == palette_id).first()
|
||||
if not palette:
|
||||
raise HTTPException(status_code=404, detail="Color palette not found")
|
||||
return palette
|
||||
|
||||
@staticmethod
|
||||
def create_palette(db: Session, data: ColorPaletteCreate, tenant_id: Optional[UUID] = None) -> ColorPalette:
|
||||
if data.is_default:
|
||||
if tenant_id:
|
||||
db.query(ColorPalette).filter(ColorPalette.tenant_id == tenant_id).update({"is_default": False})
|
||||
else:
|
||||
db.query(ColorPalette).filter(ColorPalette.tenant_id == None).update({"is_default": False})
|
||||
|
||||
db_palette = ColorPalette(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
colors=data.colors.model_dump(),
|
||||
is_default=data.is_default,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
db.add(db_palette)
|
||||
db.commit()
|
||||
db.refresh(db_palette)
|
||||
return db_palette
|
||||
|
||||
@staticmethod
|
||||
def update_palette(db: Session, palette_id: UUID, data: ColorPaletteUpdate) -> ColorPalette:
|
||||
palette = PaletteService.get_palette_by_id(db, palette_id)
|
||||
|
||||
if data.name is not None:
|
||||
palette.name = data.name
|
||||
if data.description is not None:
|
||||
palette.description = data.description
|
||||
if data.colors is not None:
|
||||
palette.colors = data.colors.model_dump()
|
||||
if data.is_default is not None:
|
||||
if data.is_default:
|
||||
tenant_id = palette.tenant_id
|
||||
if tenant_id:
|
||||
db.query(ColorPalette).filter(ColorPalette.tenant_id == tenant_id, ColorPalette.id != palette_id).update({"is_default": False})
|
||||
else:
|
||||
db.query(ColorPalette).filter(ColorPalette.tenant_id == None, ColorPalette.id != palette_id).update({"is_default": False})
|
||||
|
||||
palette.is_default = data.is_default
|
||||
|
||||
db.commit()
|
||||
db.refresh(palette)
|
||||
return palette
|
||||
|
||||
@staticmethod
|
||||
def delete_palette(db: Session, palette_id: UUID):
|
||||
palette = PaletteService.get_palette_by_id(db, palette_id)
|
||||
|
||||
db.delete(palette)
|
||||
db.commit()
|
||||
return True
|
||||
Generated
+105
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "saas-backend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "saas-backend",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "src/bin/cross-env.js",
|
||||
"cross-env-shell": "src/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "saas-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend for SaaS architecture",
|
||||
"main": "run.py",
|
||||
"scripts": {
|
||||
"start": "python run.py",
|
||||
"local": "cross-env APP_ENV=local python run.py",
|
||||
"dev": "cross-env APP_ENV=development python run.py",
|
||||
"prod": "cross-env APP_ENV=production python run.py",
|
||||
"test": "cross-env APP_ENV=testing python run.py",
|
||||
"migrate:local": "cross-env APP_ENV=local python -m alembic upgrade head",
|
||||
"migrate:dev": "cross-env APP_ENV=development python -m alembic upgrade head",
|
||||
"migrate:prod": "cross-env APP_ENV=production python -m alembic upgrade head",
|
||||
"migrate:test": "cross-env APP_ENV=testing python -m alembic upgrade head",
|
||||
"seed:palettes:local": "cross-env APP_ENV=local python scripts/seed_palettes.py",
|
||||
"seed:palettes:dev": "cross-env APP_ENV=development python scripts/seed_palettes.py",
|
||||
"seed:palettes:prod": "cross-env APP_ENV=production python scripts/seed_palettes.py",
|
||||
"seed:palettes:test": "cross-env APP_ENV=testing python scripts/seed_palettes.py",
|
||||
"seed:superadmin:local": "cross-env APP_ENV=local python scripts/seed_superadmin.py",
|
||||
"seed:superadmin:dev": "cross-env APP_ENV=development python scripts/seed_superadmin.py",
|
||||
"seed:superadmin:prod": "cross-env APP_ENV=production python scripts/seed_superadmin.py",
|
||||
"seed:superadmin:test": "cross-env APP_ENV=testing python scripts/seed_superadmin.py"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
fastapi==0.122.0
|
||||
sqlalchemy==2.0.44
|
||||
psycopg2-binary==2.9.11
|
||||
uvicorn==0.38.0
|
||||
python-dotenv==1.2.1
|
||||
alembic==1.17.2
|
||||
pydantic-settings==2.12.0
|
||||
bcrypt>=4.0.1
|
||||
pyjwt>=2.8.0
|
||||
email-validator>=2.1.0
|
||||
@@ -0,0 +1,13 @@
|
||||
import uvicorn
|
||||
from app import create_app
|
||||
from app.config.settings import settings
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"run:app",
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
reload=settings.APP_ENV == "development"
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Seed default color palettes
|
||||
Usage: python scripts/seed_palettes.py
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
backend_dir = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
from app.config.database import SessionLocal
|
||||
from app.models.theme.color_palette_models import ColorPalette
|
||||
|
||||
# 1. Default Palette (Dark Navy Sidebar)
|
||||
DEFAULT_PALETTE = {
|
||||
"name": "Default Navy",
|
||||
"description": "The default theme with navy blue sidebar",
|
||||
"is_default": True,
|
||||
"colors": {
|
||||
"primary": "#3B82F6",
|
||||
"primary_hover": "#2563EB",
|
||||
|
||||
"sidebar_bg": "#1D2A4B",
|
||||
"sidebar_text": "#F1F5F9",
|
||||
"sidebar_text_muted": "#94A3B8",
|
||||
"sidebar_active_bg": "#0077FF",
|
||||
"sidebar_active_text": "#FFFFFF",
|
||||
"sidebar_hover_bg": "rgba(255,255,255,0.2)",
|
||||
"sidebar_border": "#334155",
|
||||
|
||||
"header_bg": "#FFFFFF",
|
||||
"header_text": "#1F2937",
|
||||
"header_border": "#E5E7EB",
|
||||
|
||||
"background": "#F9FAFB",
|
||||
"background_secondary": "#FFFFFF",
|
||||
|
||||
"text_primary": "#1F2937",
|
||||
"text_secondary": "#6B7280",
|
||||
|
||||
"card_bg": "#FFFFFF",
|
||||
"card_border": "#E2E8F0",
|
||||
|
||||
"table_header_bg": "#F8FAFC",
|
||||
"table_row_hover": "#F1F5F9",
|
||||
"table_border": "#E2E8F0"
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Light Palette
|
||||
LIGHT_PALETTE = {
|
||||
"name": "Clean White",
|
||||
"description": "Minimalist white theme",
|
||||
"is_default": False,
|
||||
"colors": {
|
||||
"primary": "#2563EB",
|
||||
"primary_hover": "#1D4ED8",
|
||||
|
||||
"sidebar_bg": "#FFFFFF",
|
||||
"sidebar_text": "#374151",
|
||||
"sidebar_text_muted": "#6B7280",
|
||||
"sidebar_active_bg": "#EFF6FF",
|
||||
"sidebar_active_text": "#2563EB",
|
||||
"sidebar_hover_bg": "#F3F4F6",
|
||||
"sidebar_border": "#E5E7EB",
|
||||
|
||||
"header_bg": "#FFFFFF",
|
||||
"header_text": "#1F2937",
|
||||
"header_border": "#E5E7EB",
|
||||
|
||||
"background": "#FFFFFF",
|
||||
"background_secondary": "#F9FAFB",
|
||||
|
||||
"text_primary": "#111827",
|
||||
"text_secondary": "#6B7280",
|
||||
|
||||
"card_bg": "#FFFFFF",
|
||||
"card_border": "#E5E7EB",
|
||||
|
||||
"table_header_bg": "#F9FAFB",
|
||||
"table_row_hover": "#F3F4F6",
|
||||
"table_border": "#E5E7EB"
|
||||
}
|
||||
}
|
||||
|
||||
# 3. Dark Palette
|
||||
DARK_PALETTE = {
|
||||
"name": "Dark Mode",
|
||||
"description": "Dark theme for low light environments",
|
||||
"is_default": False,
|
||||
"colors": {
|
||||
"primary": "#60A5FA",
|
||||
"primary_hover": "#3B82F6",
|
||||
|
||||
"sidebar_bg": "#111827",
|
||||
"sidebar_text": "#E5E7EB",
|
||||
"sidebar_text_muted": "#9CA3AF",
|
||||
"sidebar_active_bg": "#3B82F6",
|
||||
"sidebar_active_text": "#FFFFFF",
|
||||
"sidebar_hover_bg": "rgba(255,255,255,0.1)",
|
||||
"sidebar_border": "#374151",
|
||||
|
||||
"header_bg": "#1F2937",
|
||||
"header_text": "#F9FAFB",
|
||||
"header_border": "#374151",
|
||||
|
||||
"background": "#111827",
|
||||
"background_secondary": "#1F2937",
|
||||
|
||||
"text_primary": "#F9FAFB",
|
||||
"text_secondary": "#9CA3AF",
|
||||
|
||||
"card_bg": "#1F2937",
|
||||
"card_border": "#374151",
|
||||
|
||||
"table_header_bg": "#111827",
|
||||
"table_row_hover": "#374151",
|
||||
"table_border": "#374151"
|
||||
}
|
||||
}
|
||||
|
||||
def seed_palettes():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
palettes = [DEFAULT_PALETTE, LIGHT_PALETTE, DARK_PALETTE]
|
||||
|
||||
for p_data in palettes:
|
||||
existing = db.query(ColorPalette).filter(ColorPalette.name == p_data["name"]).first()
|
||||
if not existing:
|
||||
palette = ColorPalette(
|
||||
name=p_data["name"],
|
||||
description=p_data["description"],
|
||||
is_default=p_data["is_default"],
|
||||
colors=p_data["colors"],
|
||||
tenant_id=None
|
||||
)
|
||||
db.add(palette)
|
||||
else:
|
||||
existing.colors = p_data["colors"]
|
||||
existing.is_default = p_data["is_default"]
|
||||
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_palettes()
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Creates:
|
||||
1. All predefined accesses
|
||||
2. Superadmin role with all accesses
|
||||
3. Superadmin user with the role
|
||||
|
||||
Usage:
|
||||
python scripts/seed_superadmin.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
backend_dir = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config.database import SessionLocal
|
||||
from app.config.settings import settings
|
||||
from app.config.security import security
|
||||
from app.models.auth.user_models import User
|
||||
from app.models.auth.access_models import Access
|
||||
from app.models.auth.role_models import Role
|
||||
from app.models.auth.role_access_models import RoleAccess
|
||||
|
||||
# Predefined accesses (access_code, category, name, parent_code)
|
||||
PREDEFINED_ACCESSES = [
|
||||
# Superadmin category
|
||||
("superadmin.main.view", "Superadmin", "Allow access to superadmin view", None),
|
||||
("superadmin.tenant.create", "Superadmin", "Allow access to create tenants", None),
|
||||
("superadmin.tenant.read", "Superadmin", "Allow access to view all tenants", None),
|
||||
("superadmin.tenant.update", "Superadmin", "Allow access to update tenants", None),
|
||||
("superadmin.tenant.delete", "Superadmin", "Allow access to delete tenants", None),
|
||||
("superadmin.role.create", "Superadmin", "Allow access to create roles", None),
|
||||
("superadmin.role.read", "Superadmin", "Allow access to view all roles", None),
|
||||
("superadmin.role.update", "Superadmin", "Allow access to update any role", None),
|
||||
("superadmin.role.delete", "Superadmin", "Allow access to delete any role", None),
|
||||
("superadmin.user.create", "Superadmin", "Allow access to create any user", None),
|
||||
("superadmin.user.read", "Superadmin", "Allow access to view all users", None),
|
||||
("superadmin.user.update", "Superadmin", "Allow access to update any user", None),
|
||||
("superadmin.user.delete", "Superadmin", "Allow access to delete any user", None),
|
||||
("superadmin.access.read", "Superadmin", "Allow access to view all accesses", None),
|
||||
|
||||
# Theme/Palette
|
||||
("superadmin.palette.read", "Superadmin", "Allow access to view color palettes", None),
|
||||
("superadmin.palette.create", "Superadmin", "Allow access to create color palettes", None),
|
||||
("superadmin.palette.update", "Superadmin", "Allow access to update color palettes", None),
|
||||
("superadmin.palette.delete", "Superadmin", "Allow access to delete color palettes", None),
|
||||
|
||||
# All Accesses hereafter are applicable for a Tenant Admin
|
||||
|
||||
# Administration category
|
||||
("admin.role.create", "Administration", "Allow access to create roles", None),
|
||||
("admin.role.read", "Administration", "Allow access to view roles", None),
|
||||
("admin.role.update", "Administration", "Allow access to update roles", None),
|
||||
("admin.role.delete", "Administration", "Allow access to delete roles", None),
|
||||
("admin.user.create", "Administration", "Allow access to create users", None),
|
||||
("admin.user.read", "Administration", "Allow access to view users", None),
|
||||
("admin.user.update", "Administration", "Allow access to update users", None),
|
||||
("admin.user.delete", "Administration", "Allow access to delete users", None),
|
||||
("admin.access.read", "Administration", "Allow access to view accesses", None),
|
||||
|
||||
]
|
||||
|
||||
|
||||
def seed_accesses(db: Session):
|
||||
"""Create all predefined accesses with hierarchy"""
|
||||
print("Seeding accesses...")
|
||||
created_count = 0
|
||||
|
||||
# First pass: Create all accesses without parents
|
||||
for access_code, category, name, parent_code in PREDEFINED_ACCESSES:
|
||||
existing = db.query(Access).filter(Access.access_code == access_code).first()
|
||||
if not existing:
|
||||
access = Access(
|
||||
access_code=access_code, category=category, name=name, parent_id=None
|
||||
)
|
||||
db.add(access)
|
||||
created_count += 1
|
||||
|
||||
db.commit()
|
||||
print(
|
||||
f" ✓ Created {created_count} new accesses (total: {len(PREDEFINED_ACCESSES)})"
|
||||
)
|
||||
|
||||
# Second pass: Set parent relationships
|
||||
parent_count = 0
|
||||
for access_code, category, name, parent_code in PREDEFINED_ACCESSES:
|
||||
if parent_code:
|
||||
child = db.query(Access).filter(Access.access_code == access_code).first()
|
||||
parent = db.query(Access).filter(Access.access_code == parent_code).first()
|
||||
|
||||
if child and parent:
|
||||
if child.parent_id != parent.id:
|
||||
child.parent_id = parent.id
|
||||
parent_count += 1
|
||||
elif child and not parent:
|
||||
print(
|
||||
f" ⚠ Warning: Parent '{parent_code}' not found for '{access_code}'"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
if parent_count > 0:
|
||||
print(f" ✓ Set {parent_count} parent relationships")
|
||||
|
||||
|
||||
def seed_superadmin_role(db: Session) -> Role:
|
||||
"""Create superadmin role with ALL accesses"""
|
||||
print("Creating superadmin role...")
|
||||
|
||||
role = (
|
||||
db.query(Role)
|
||||
.filter(Role.role_name == "superadmin", Role.tenant_id == None)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not role:
|
||||
role = Role(role_name="superadmin", tenant_id=None)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
print(" ✓ Superadmin role created")
|
||||
else:
|
||||
print(" ✓ Superadmin role already exists")
|
||||
|
||||
all_accesses = db.query(Access).all()
|
||||
existing_access_ids = {ra.access_id for ra in role.role_accesses}
|
||||
|
||||
new_accesses_count = 0
|
||||
for access in all_accesses:
|
||||
if access.id not in existing_access_ids:
|
||||
role_access = RoleAccess(role_id=role.id, access_id=access.id)
|
||||
db.add(role_access)
|
||||
new_accesses_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
if new_accesses_count > 0:
|
||||
print(f" ✓ Added {new_accesses_count} accesses to superadmin role")
|
||||
|
||||
print(f" ✓ Superadmin role has {len(all_accesses)} total accesses")
|
||||
|
||||
return role
|
||||
|
||||
|
||||
def create_superadmin_user(db: Session, role: Role) -> bool:
|
||||
print("Creating superadmin user...")
|
||||
|
||||
existing_superadmin = (
|
||||
db.query(User).filter(User.email == settings.SUPER_ADMIN_EMAIL).first()
|
||||
)
|
||||
|
||||
if existing_superadmin:
|
||||
print(f" ✓ Superadmin already exists")
|
||||
print(f" Email: {existing_superadmin.email}")
|
||||
print(
|
||||
f" Role: {existing_superadmin.role.role_name if existing_superadmin.role else 'None'}"
|
||||
)
|
||||
print(
|
||||
f" Tenant: {'None (Superadmin)' if existing_superadmin.tenant_id is None else existing_superadmin.tenant_id}"
|
||||
)
|
||||
|
||||
if not existing_superadmin.role_id or existing_superadmin.role_id != role.id:
|
||||
existing_superadmin.role_id = role.id
|
||||
db.commit()
|
||||
print(" ✓ Updated superadmin role")
|
||||
|
||||
return False
|
||||
|
||||
if not security.validate_password_strength(settings.SUPER_ADMIN_PASSWORD):
|
||||
print(" ✗ Error: Superadmin password does not meet strength requirements")
|
||||
print(
|
||||
" Password must be 8+ characters with uppercase, lowercase, digit, and special character"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
hashed_password = security.hash_password(settings.SUPER_ADMIN_PASSWORD)
|
||||
|
||||
superadmin = User(
|
||||
email=settings.SUPER_ADMIN_EMAIL,
|
||||
password=hashed_password,
|
||||
first_name=settings.SUPER_ADMIN_FIRST_NAME,
|
||||
last_name=settings.SUPER_ADMIN_LAST_NAME,
|
||||
tenant_id=None,
|
||||
role_id=role.id,
|
||||
status="active",
|
||||
)
|
||||
|
||||
db.add(superadmin)
|
||||
db.commit()
|
||||
db.refresh(superadmin)
|
||||
|
||||
print(f" ✓ Superadmin created successfully!")
|
||||
print(f" Email: {superadmin.email}")
|
||||
print(f" Name: {superadmin.first_name} {superadmin.last_name}")
|
||||
print(f" Role: {superadmin.role.role_name}")
|
||||
print(f" Accesses: {len(superadmin.role.role_accesses)}")
|
||||
print(f" Tenant: None (Superadmin)")
|
||||
print(f" Status: {superadmin.status}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Main execution function."""
|
||||
print("=" * 60)
|
||||
print("Superadmin Seed Script with RBAC")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
required_vars = [
|
||||
"SUPER_ADMIN_EMAIL",
|
||||
"SUPER_ADMIN_PASSWORD",
|
||||
"SUPER_ADMIN_FIRST_NAME",
|
||||
"SUPER_ADMIN_LAST_NAME",
|
||||
]
|
||||
|
||||
missing_vars = []
|
||||
for var in required_vars:
|
||||
if not getattr(settings, var, None):
|
||||
missing_vars.append(var)
|
||||
|
||||
if missing_vars:
|
||||
print("✗ Error: Missing required environment variables:")
|
||||
for var in missing_vars:
|
||||
print(f" - {var}")
|
||||
print()
|
||||
print("Please add these to your .env file and try again.")
|
||||
sys.exit(1)
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
seed_accesses(db)
|
||||
print()
|
||||
|
||||
superadmin_role = seed_superadmin_role(db)
|
||||
print()
|
||||
|
||||
created = create_superadmin_user(db, superadmin_role)
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
if created:
|
||||
print("Setup complete! You can now login with:")
|
||||
print(f" Email: {settings.SUPER_ADMIN_EMAIL}")
|
||||
print(f" Password: {settings.SUPER_ADMIN_PASSWORD}")
|
||||
else:
|
||||
print("Seed script completed successfully!")
|
||||
print("All accesses and roles are up to date.")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"✗ Failed to seed data: {str(e)}")
|
||||
print("=" * 60)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user