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