From c303d336e4fd864ca5c084577f9a813021554fd5 Mon Sep 17 00:00:00 2001 From: Furqan-14 Date: Sat, 17 Jan 2026 14:18:00 +0530 Subject: [PATCH] first commit --- .env.development | 66 ++++ .env.local | 66 ++++ .env.production | 66 ++++ .env.testing | 66 ++++ .gitignore | 14 + README.md | 306 ++++++++++++++++++ alembic.ini | 147 +++++++++ alembic/README | 1 + alembic/env.py | 117 +++++++ alembic/script.py.mako | 28 ++ app/__init__.py | 153 +++++++++ app/config/database.py | 53 +++ app/config/security.py | 166 ++++++++++ app/config/settings.py | 149 +++++++++ app/controllers/auth/access_controller.py | 9 + app/controllers/auth/auth_controller.py | 62 ++++ app/controllers/auth/role_controller.py | 88 +++++ app/controllers/auth/tenant_controller.py | 43 +++ app/controllers/auth/user_controller.py | 68 ++++ .../theme/color_palette_controller.py | 30 ++ app/middleware/auth_middleware.py | 124 +++++++ app/middleware/tenant_middleware.py | 30 ++ app/models/auth/__init__.py | 5 + app/models/auth/access_model.py | 22 ++ app/models/auth/role_access_model.py | 25 ++ app/models/auth/role_model.py | 31 ++ app/models/auth/tenant_model.py | 27 ++ app/models/auth/user_model.py | 32 ++ app/models/theme/color_palette_model.py | 14 + app/routes/auth/__init__.py | 3 + app/routes/auth/access.py | 19 ++ app/routes/auth/auth.py | 101 ++++++ app/routes/auth/role.py | 87 +++++ app/routes/auth/tenant.py | 88 +++++ app/routes/auth/user.py | 75 +++++ app/routes/theme/color_palette.py | 58 ++++ app/schemas/auth/access_schema.py | 17 + app/schemas/auth/auth_schema.py | 76 +++++ app/schemas/auth/language_schema.py | 4 + app/schemas/auth/role_schema.py | 44 +++ app/schemas/auth/tenant_schema.py | 34 ++ app/schemas/auth/user_schema.py | 44 +++ app/schemas/theme/color_palette_schema.py | 60 ++++ app/services/auth/access_service.py | 19 ++ app/services/auth/auth_service.py | 287 ++++++++++++++++ app/services/auth/email_service.py | 41 +++ app/services/auth/role_service.py | 157 +++++++++ app/services/auth/tenant_service.py | 119 +++++++ app/services/auth/user_service.py | 145 +++++++++ app/services/theme/color_palette_service.py | 75 +++++ package-lock.json | 105 ++++++ package.json | 29 ++ requirements.txt | 10 + run.py | 13 + scripts/seed_palettes.py | 150 +++++++++ scripts/seed_superadmin.py | 269 +++++++++++++++ 56 files changed, 4137 insertions(+) create mode 100644 .env.development create mode 100644 .env.local create mode 100644 .env.production create mode 100644 .env.testing create mode 100644 .gitignore create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 app/__init__.py create mode 100644 app/config/database.py create mode 100644 app/config/security.py create mode 100644 app/config/settings.py create mode 100644 app/controllers/auth/access_controller.py create mode 100644 app/controllers/auth/auth_controller.py create mode 100644 app/controllers/auth/role_controller.py create mode 100644 app/controllers/auth/tenant_controller.py create mode 100644 app/controllers/auth/user_controller.py create mode 100644 app/controllers/theme/color_palette_controller.py create mode 100644 app/middleware/auth_middleware.py create mode 100644 app/middleware/tenant_middleware.py create mode 100644 app/models/auth/__init__.py create mode 100644 app/models/auth/access_model.py create mode 100644 app/models/auth/role_access_model.py create mode 100644 app/models/auth/role_model.py create mode 100644 app/models/auth/tenant_model.py create mode 100644 app/models/auth/user_model.py create mode 100644 app/models/theme/color_palette_model.py create mode 100644 app/routes/auth/__init__.py create mode 100644 app/routes/auth/access.py create mode 100644 app/routes/auth/auth.py create mode 100644 app/routes/auth/role.py create mode 100644 app/routes/auth/tenant.py create mode 100644 app/routes/auth/user.py create mode 100644 app/routes/theme/color_palette.py create mode 100644 app/schemas/auth/access_schema.py create mode 100644 app/schemas/auth/auth_schema.py create mode 100644 app/schemas/auth/language_schema.py create mode 100644 app/schemas/auth/role_schema.py create mode 100644 app/schemas/auth/tenant_schema.py create mode 100644 app/schemas/auth/user_schema.py create mode 100644 app/schemas/theme/color_palette_schema.py create mode 100644 app/services/auth/access_service.py create mode 100644 app/services/auth/auth_service.py create mode 100644 app/services/auth/email_service.py create mode 100644 app/services/auth/role_service.py create mode 100644 app/services/auth/tenant_service.py create mode 100644 app/services/auth/user_service.py create mode 100644 app/services/theme/color_palette_service.py create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 scripts/seed_palettes.py create mode 100644 scripts/seed_superadmin.py diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..5710323 --- /dev/null +++ b/.env.development @@ -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 \ No newline at end of file diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..1071e05 --- /dev/null +++ b/.env.local @@ -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 \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..6642417 --- /dev/null +++ b/.env.production @@ -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 \ No newline at end of file diff --git a/.env.testing b/.env.testing new file mode 100644 index 0000000..96f3595 --- /dev/null +++ b/.env.testing @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c3cfca --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Virtual Environment +venv/ + +# Python +__pycache__/ +*.pyc + +# Environment +.env +node_modules + +# IDE +.vscode/ +.idea/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6829e6 --- /dev/null +++ b/README.md @@ -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 + +# 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] diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..7f7f01d --- /dev/null +++ b/alembic.ini @@ -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 /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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..7ad9cbe --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..4303815 --- /dev/null +++ b/app/__init__.py @@ -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() diff --git a/app/config/database.py b/app/config/database.py new file mode 100644 index 0000000..19d4893 --- /dev/null +++ b/app/config/database.py @@ -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() \ No newline at end of file diff --git a/app/config/security.py b/app/config/security.py new file mode 100644 index 0000000..794ae2c --- /dev/null +++ b/app/config/security.py @@ -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() \ No newline at end of file diff --git a/app/config/settings.py b/app/config/settings.py new file mode 100644 index 0000000..a5db7ce --- /dev/null +++ b/app/config/settings.py @@ -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() \ No newline at end of file diff --git a/app/controllers/auth/access_controller.py b/app/controllers/auth/access_controller.py new file mode 100644 index 0000000..5e95666 --- /dev/null +++ b/app/controllers/auth/access_controller.py @@ -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) \ No newline at end of file diff --git a/app/controllers/auth/auth_controller.py b/app/controllers/auth/auth_controller.py new file mode 100644 index 0000000..afce2af --- /dev/null +++ b/app/controllers/auth/auth_controller.py @@ -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 + ) \ No newline at end of file diff --git a/app/controllers/auth/role_controller.py b/app/controllers/auth/role_controller.py new file mode 100644 index 0000000..c40d68e --- /dev/null +++ b/app/controllers/auth/role_controller.py @@ -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, + ) \ No newline at end of file diff --git a/app/controllers/auth/tenant_controller.py b/app/controllers/auth/tenant_controller.py new file mode 100644 index 0000000..dfdff4c --- /dev/null +++ b/app/controllers/auth/tenant_controller.py @@ -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, + ) \ No newline at end of file diff --git a/app/controllers/auth/user_controller.py b/app/controllers/auth/user_controller.py new file mode 100644 index 0000000..fbed93c --- /dev/null +++ b/app/controllers/auth/user_controller.py @@ -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, + ) \ No newline at end of file diff --git a/app/controllers/theme/color_palette_controller.py b/app/controllers/theme/color_palette_controller.py new file mode 100644 index 0000000..e2c5cbe --- /dev/null +++ b/app/controllers/theme/color_palette_controller.py @@ -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) \ No newline at end of file diff --git a/app/middleware/auth_middleware.py b/app/middleware/auth_middleware.py new file mode 100644 index 0000000..f355810 --- /dev/null +++ b/app/middleware/auth_middleware.py @@ -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 \ No newline at end of file diff --git a/app/middleware/tenant_middleware.py b/app/middleware/tenant_middleware.py new file mode 100644 index 0000000..2bb851f --- /dev/null +++ b/app/middleware/tenant_middleware.py @@ -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 \ No newline at end of file diff --git a/app/models/auth/__init__.py b/app/models/auth/__init__.py new file mode 100644 index 0000000..c1c54d8 --- /dev/null +++ b/app/models/auth/__init__.py @@ -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 \ No newline at end of file diff --git a/app/models/auth/access_model.py b/app/models/auth/access_model.py new file mode 100644 index 0000000..ccf06c6 --- /dev/null +++ b/app/models/auth/access_model.py @@ -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"" \ No newline at end of file diff --git a/app/models/auth/role_access_model.py b/app/models/auth/role_access_model.py new file mode 100644 index 0000000..c64dbbe --- /dev/null +++ b/app/models/auth/role_access_model.py @@ -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"" diff --git a/app/models/auth/role_model.py b/app/models/auth/role_model.py new file mode 100644 index 0000000..644b941 --- /dev/null +++ b/app/models/auth/role_model.py @@ -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"" diff --git a/app/models/auth/tenant_model.py b/app/models/auth/tenant_model.py new file mode 100644 index 0000000..f3f58b7 --- /dev/null +++ b/app/models/auth/tenant_model.py @@ -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"" diff --git a/app/models/auth/user_model.py b/app/models/auth/user_model.py new file mode 100644 index 0000000..704d971 --- /dev/null +++ b/app/models/auth/user_model.py @@ -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"" + diff --git a/app/models/theme/color_palette_model.py b/app/models/theme/color_palette_model.py new file mode 100644 index 0000000..db68a0a --- /dev/null +++ b/app/models/theme/color_palette_model.py @@ -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) \ No newline at end of file diff --git a/app/routes/auth/__init__.py b/app/routes/auth/__init__.py new file mode 100644 index 0000000..c350d28 --- /dev/null +++ b/app/routes/auth/__init__.py @@ -0,0 +1,3 @@ +from .auth import router +from .tenant import router +from .role import router \ No newline at end of file diff --git a/app/routes/auth/access.py b/app/routes/auth/access.py new file mode 100644 index 0000000..7d88462 --- /dev/null +++ b/app/routes/auth/access.py @@ -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) \ No newline at end of file diff --git a/app/routes/auth/auth.py b/app/routes/auth/auth.py new file mode 100644 index 0000000..79a689e --- /dev/null +++ b/app/routes/auth/auth.py @@ -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) \ No newline at end of file diff --git a/app/routes/auth/role.py b/app/routes/auth/role.py new file mode 100644 index 0000000..bcc12be --- /dev/null +++ b/app/routes/auth/role.py @@ -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, + ) \ No newline at end of file diff --git a/app/routes/auth/tenant.py b/app/routes/auth/tenant.py new file mode 100644 index 0000000..69c93cb --- /dev/null +++ b/app/routes/auth/tenant.py @@ -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, + ) \ No newline at end of file diff --git a/app/routes/auth/user.py b/app/routes/auth/user.py new file mode 100644 index 0000000..3400fb6 --- /dev/null +++ b/app/routes/auth/user.py @@ -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, + ) \ No newline at end of file diff --git a/app/routes/theme/color_palette.py b/app/routes/theme/color_palette.py new file mode 100644 index 0000000..4332508 --- /dev/null +++ b/app/routes/theme/color_palette.py @@ -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) \ No newline at end of file diff --git a/app/schemas/auth/access_schema.py b/app/schemas/auth/access_schema.py new file mode 100644 index 0000000..c110042 --- /dev/null +++ b/app/schemas/auth/access_schema.py @@ -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 diff --git a/app/schemas/auth/auth_schema.py b/app/schemas/auth/auth_schema.py new file mode 100644 index 0000000..a4f2076 --- /dev/null +++ b/app/schemas/auth/auth_schema.py @@ -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) diff --git a/app/schemas/auth/language_schema.py b/app/schemas/auth/language_schema.py new file mode 100644 index 0000000..bbfab70 --- /dev/null +++ b/app/schemas/auth/language_schema.py @@ -0,0 +1,4 @@ +from pydantic import BaseModel + +class UpdateLanguageRequest(BaseModel): + preferred_language: str \ No newline at end of file diff --git a/app/schemas/auth/role_schema.py b/app/schemas/auth/role_schema.py new file mode 100644 index 0000000..078fec7 --- /dev/null +++ b/app/schemas/auth/role_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/auth/tenant_schema.py b/app/schemas/auth/tenant_schema.py new file mode 100644 index 0000000..a8852ba --- /dev/null +++ b/app/schemas/auth/tenant_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/auth/user_schema.py b/app/schemas/auth/user_schema.py new file mode 100644 index 0000000..cb55bb1 --- /dev/null +++ b/app/schemas/auth/user_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/theme/color_palette_schema.py b/app/schemas/theme/color_palette_schema.py new file mode 100644 index 0000000..6a87b64 --- /dev/null +++ b/app/schemas/theme/color_palette_schema.py @@ -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 \ No newline at end of file diff --git a/app/services/auth/access_service.py b/app/services/auth/access_service.py new file mode 100644 index 0000000..31544b6 --- /dev/null +++ b/app/services/auth/access_service.py @@ -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] \ No newline at end of file diff --git a/app/services/auth/auth_service.py b/app/services/auth/auth_service.py new file mode 100644 index 0000000..8a83ec5 --- /dev/null +++ b/app/services/auth/auth_service.py @@ -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"} \ No newline at end of file diff --git a/app/services/auth/email_service.py b/app/services/auth/email_service.py new file mode 100644 index 0000000..4ab2026 --- /dev/null +++ b/app/services/auth/email_service.py @@ -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 \ No newline at end of file diff --git a/app/services/auth/role_service.py b/app/services/auth/role_service.py new file mode 100644 index 0000000..4b236fd --- /dev/null +++ b/app/services/auth/role_service.py @@ -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, + ) \ No newline at end of file diff --git a/app/services/auth/tenant_service.py b/app/services/auth/tenant_service.py new file mode 100644 index 0000000..fa217a6 --- /dev/null +++ b/app/services/auth/tenant_service.py @@ -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, + ) \ No newline at end of file diff --git a/app/services/auth/user_service.py b/app/services/auth/user_service.py new file mode 100644 index 0000000..e47d50a --- /dev/null +++ b/app/services/auth/user_service.py @@ -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, + ) \ No newline at end of file diff --git a/app/services/theme/color_palette_service.py b/app/services/theme/color_palette_service.py new file mode 100644 index 0000000..24df73a --- /dev/null +++ b/app/services/theme/color_palette_service.py @@ -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 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..19fbad1 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..13e0c15 --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..afbeb46 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..d9fcbbc --- /dev/null +++ b/run.py @@ -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" + ) diff --git a/scripts/seed_palettes.py b/scripts/seed_palettes.py new file mode 100644 index 0000000..ace8231 --- /dev/null +++ b/scripts/seed_palettes.py @@ -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() \ No newline at end of file diff --git a/scripts/seed_superadmin.py b/scripts/seed_superadmin.py new file mode 100644 index 0000000..78e03fe --- /dev/null +++ b/scripts/seed_superadmin.py @@ -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() \ No newline at end of file