commit c303d336e4fd864ca5c084577f9a813021554fd5 Author: Furqan-14 Date: Sat Jan 17 14:18:00 2026 +0530 first commit 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