first commit

This commit is contained in:
Furqan-14
2026-01-17 14:18:00 +05:30
commit c303d336e4
56 changed files with 4137 additions and 0 deletions
+66
View File
@@ -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
View File
@@ -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
+66
View File
@@ -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
+66
View File
@@ -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
View File
@@ -0,0 +1,14 @@
# Virtual Environment
venv/
# Python
__pycache__/
*.pyc
# Environment
.env
node_modules
# IDE
.vscode/
.idea/
+306
View File
@@ -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
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+117
View File
@@ -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()
+28
View File
@@ -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
View File
@@ -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()
+53
View File
@@ -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()
+166
View File
@@ -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()
+149
View File
@@ -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)
+62
View File
@@ -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
)
+88
View File
@@ -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,
)
+43
View File
@@ -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,
)
+68
View File
@@ -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)
+124
View File
@@ -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
+30
View File
@@ -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
+5
View File
@@ -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
+22
View File
@@ -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}>"
+25
View File
@@ -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}>"
+31
View File
@@ -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}>"
+27
View File
@@ -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}>"
+32
View File
@@ -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}>"
+14
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
from .auth import router
from .tenant import router
from .role import router
+19
View File
@@ -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)
+101
View File
@@ -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)
+87
View File
@@ -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,
)
+88
View File
@@ -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,
)
+75
View File
@@ -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,
)
+58
View File
@@ -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)
+17
View File
@@ -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
+76
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
from pydantic import BaseModel
class UpdateLanguageRequest(BaseModel):
preferred_language: str
+44
View File
@@ -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
+34
View File
@@ -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
+44
View File
@@ -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
+60
View File
@@ -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
+19
View File
@@ -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]
+287
View File
@@ -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"}
+41
View File
@@ -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
+157
View File
@@ -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,
)
+119
View File
@@ -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,
)
+145
View File
@@ -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
+105
View File
@@ -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"
}
}
}
}
+29
View File
@@ -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"
}
}
+10
View File
@@ -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
+13
View File
@@ -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"
)
+150
View File
@@ -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()
+269
View File
@@ -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()