Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ab9cc3bee | ||
|
|
d68145effa | ||
|
|
cb81429595 | ||
|
|
276daa7fc7 | ||
|
|
ee62cb4963 | ||
|
|
abf0219587 | ||
|
|
f3deee5479 | ||
|
|
53d6e92399 | ||
|
|
df83ec42d0 | ||
|
|
41c91554f2 | ||
|
|
bab1c3d718 | ||
|
|
f200604ca5 | ||
|
|
9fff9fa9c1 | ||
|
|
a091c11214 | ||
|
|
2689adfb7a | ||
|
|
dc711fe82c | ||
|
|
5fc241e59c | ||
|
|
83def84f15 | ||
|
|
e16f7c30af | ||
|
|
512c39e4f4 | ||
|
|
c3017b81da | ||
|
|
4c9fa8bc45 | ||
|
|
9b41301b09 | ||
|
|
571894b890 | ||
|
|
2678e9548a | ||
|
|
a1dc191a06 | ||
|
|
7c326dea94 | ||
|
|
8a8ecc3d2e | ||
|
|
249e03aa95 | ||
|
|
0f0fcd6de3 | ||
|
|
a9b189b463 | ||
|
|
bcbd0ccc5a | ||
|
|
6e9acc13eb | ||
|
|
bd4f0f1e1d | ||
|
|
1d832b9eda | ||
|
|
e5895d058f | ||
|
|
d6981d7123 | ||
|
|
56408cadbe | ||
|
|
935bbc18f6 |
+8
-12
@@ -1,13 +1,13 @@
|
||||
# Project Configuration
|
||||
PROJECT_NAME=SaaS Architecture
|
||||
VERSION=1.0.0
|
||||
PORT=11001
|
||||
PORT=10001
|
||||
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
|
||||
FRONTEND_URL=https://saas-dev.maskantech.in
|
||||
CORS_ALLOWED_ORIGINS=https://saas-dev.maskantech.in
|
||||
|
||||
# Security
|
||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||
@@ -20,13 +20,12 @@ 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/
|
||||
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_development
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_URL=""
|
||||
# port=""
|
||||
# host=""
|
||||
# password=""
|
||||
REDIS_HOST=106.51.105.22
|
||||
REDIS_PORT=6382
|
||||
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
@@ -38,15 +37,12 @@ EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
ACCESS_TOKEN_EXPIRES=900
|
||||
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=""
|
||||
|
||||
+8
-12
@@ -1,5 +1,5 @@
|
||||
# Project Configuration
|
||||
PROJECT_NAME=Fulfillment And Logistics
|
||||
PROJECT_NAME=SaaS Architecture
|
||||
VERSION=1.0.0
|
||||
PORT=8000
|
||||
APP_ENV=local
|
||||
@@ -7,7 +7,7 @@ SECRET_KEY="Usu9Qmg4ppRexR6Xp657MMMHsoOaiV8cPqlY_THWNaPhGT6DN9Xd8UO4zG3kWjwIqW9h
|
||||
ALLOWED_HOSTS=*
|
||||
HOST=127.0.0.1
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174
|
||||
|
||||
# Security
|
||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||
@@ -20,13 +20,12 @@ 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
|
||||
|
||||
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_local
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_URL=""
|
||||
# port=""
|
||||
# host=""
|
||||
# password=""
|
||||
REDIS_HOST=106.51.105.22
|
||||
REDIS_PORT=6381
|
||||
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
@@ -38,15 +37,12 @@ EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
ACCESS_TOKEN_EXPIRES=900
|
||||
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=""
|
||||
|
||||
+2
-1
@@ -38,10 +38,11 @@ EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
ACCESS_TOKEN_EXPIRES=900
|
||||
REFRESH_TOKEN_SECRET="6Z0yOfkhPjfLH77WTnBh3Iv0JU_gWIfIqpGuGU41GFRV4fnLZMfKN3gAsPTfsqFKv1rRc6szUJRngW8Py0UYUQ"
|
||||
REFRESH_TOKEN_EXPIRES=864000
|
||||
JWT_ALGORITHM=HS256
|
||||
COOKIE_SECURE=true
|
||||
ADMIN_JWT="upRCbNd-3Ex3sG2aEHxcrCx7LZu91BkiNGPIs-vxNXp7YBHyU-0jMpGUYA5dJHLcyOIMLBk4HCw1cpI4WOlIzA"
|
||||
|
||||
# External SaaS Webhook
|
||||
|
||||
+8
-12
@@ -1,13 +1,13 @@
|
||||
# Project Configuration
|
||||
PROJECT_NAME=SaaS Architecture
|
||||
VERSION=1.0.0
|
||||
PORT=11002
|
||||
PORT=10002
|
||||
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
|
||||
FRONTEND_URL=https://saas-test.maskantech.in
|
||||
CORS_ALLOWED_ORIGINS=https://saas-test.maskantech.in
|
||||
|
||||
# Security
|
||||
ENCRYPTION_KEY="1cd1dc2d42afc5606e224df1108162db2d6ca372a45a9b8d278162f6006236d5"
|
||||
@@ -20,13 +20,12 @@ 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/
|
||||
DATABASE_URL=postgresql://saas_user:K9uR3mZpQ7~W4F2YH8A_tLxD@106.51.105.22:5432/saas_test
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_URL=""
|
||||
# port=""
|
||||
# host=""
|
||||
# password=""
|
||||
REDIS_HOST=106.51.105.22
|
||||
REDIS_PORT=6383
|
||||
REDIS_PASSWORD=8haSTisAqop8ChAs
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.hostinger.com
|
||||
@@ -38,15 +37,12 @@ EMAIL_FROM=info@maskantech.in
|
||||
|
||||
# JWT Configuration
|
||||
ACCESS_TOKEN_SECRET="L_ByN0_FIuwsQnDo4sdrOEdJvqlPjKfhVJmqhf76D13v3IWu3mbvzb8hQRnPxHMlr9Y8A9IcOHZZWSs7Kfofpg"
|
||||
ACCESS_TOKEN_EXPIRES=86400
|
||||
ACCESS_TOKEN_EXPIRES=900
|
||||
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=""
|
||||
|
||||
@@ -20,9 +20,10 @@ A multi-tenant SaaS backend built with FastAPI, PostgreSQL, and SQLAlchemy.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.12+
|
||||
- PostgreSQL 12+
|
||||
- Python 3.10+
|
||||
- PostgreSQL 12+
|
||||
- Node.js (for npm scripts)
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -44,7 +45,7 @@ python -m venv venv
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
npm install # For cross-env support in npm scripts
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Environment Configuration
|
||||
@@ -112,11 +113,11 @@ 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
|
||||
# Using manage.py (recommended - handles APP_ENV automatically)
|
||||
python manage.py migrate --env local
|
||||
python manage.py migrate --env development
|
||||
python manage.py migrate --env production
|
||||
python manage.py migrate --env testing
|
||||
|
||||
# Or using alembic directly
|
||||
$env:APP_ENV="local" # Set environment first
|
||||
@@ -148,20 +149,20 @@ After running migrations, seed the database with initial data:
|
||||
|
||||
```bash
|
||||
# Seed super admin user
|
||||
npm run seed:superadmin:local
|
||||
python manage.py seed superadmin --env local
|
||||
|
||||
# Seed default color palettes
|
||||
npm run seed:palettes:local
|
||||
python manage.py seed palettes --env 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
|
||||
# Using manage.py (recommended)
|
||||
python manage.py run --env local # Local environment
|
||||
python manage.py run --env development # Development environment
|
||||
python manage.py run --env production # Production environment
|
||||
python manage.py run --env testing # Testing environment
|
||||
|
||||
# Or using Python directly
|
||||
python run.py
|
||||
@@ -209,7 +210,8 @@ backend/
|
||||
│ ├── seed_palettes.py
|
||||
│ └── seed_superadmin.py
|
||||
├── alembic.ini # Alembic configuration
|
||||
├── package.json # NPM scripts
|
||||
├── alembic.ini # Alembic configuration
|
||||
├── manage.py # Management CLI script
|
||||
├── requirements.txt # Python dependencies
|
||||
└── run.py # Application entry point
|
||||
```
|
||||
|
||||
@@ -42,6 +42,7 @@ 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
|
||||
import app.models.auth.subscription_plan_model
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""create_event_log_model
|
||||
|
||||
Revision ID: 03a1b1f05e99
|
||||
Revises: cd8ba77ffd9e
|
||||
Create Date: 2026-01-20 17:00:41.702680
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '03a1b1f05e99'
|
||||
down_revision: Union[str, Sequence[str], None] = 'cd8ba77ffd9e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('event_logs',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('event_id', sa.UUID(), nullable=False),
|
||||
sa.Column('event_type', sa.String(), nullable=False),
|
||||
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('target_module_id', sa.UUID(), nullable=False),
|
||||
sa.Column('target_environment_slug', sa.String(), nullable=False),
|
||||
sa.Column('target_url', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('retry_count', sa.Integer(), nullable=True),
|
||||
sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('error_log', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_event_logs_event_id'), 'event_logs', ['event_id'], unique=False)
|
||||
op.create_index(op.f('ix_event_logs_next_retry_at'), 'event_logs', ['next_retry_at'], unique=False)
|
||||
op.create_index(op.f('ix_event_logs_status'), 'event_logs', ['status'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_event_logs_status'), table_name='event_logs')
|
||||
op.drop_index(op.f('ix_event_logs_next_retry_at'), table_name='event_logs')
|
||||
op.drop_index(op.f('ix_event_logs_event_id'), table_name='event_logs')
|
||||
op.drop_table('event_logs')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,48 @@
|
||||
"""add tenant lifecycle fields
|
||||
|
||||
Revision ID: 5f2e9c1a7b44
|
||||
Revises: 720027c97104
|
||||
Create Date: 2026-04-18 14:10:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "5f2e9c1a7b44"
|
||||
down_revision: Union[str, Sequence[str], None] = "720027c97104"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS start_date DATE")
|
||||
op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS end_date DATE")
|
||||
op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS status VARCHAR")
|
||||
op.execute("ALTER TABLE tenants ALTER COLUMN status SET DEFAULT 'ACTIVE'")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE tenants
|
||||
SET status = CASE
|
||||
WHEN is_active = true THEN 'ACTIVE'
|
||||
ELSE 'INACTIVE'
|
||||
END
|
||||
WHERE status IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE tenants ALTER COLUMN status SET NOT NULL")
|
||||
op.execute("ALTER TABLE tenants ALTER COLUMN status DROP DEFAULT")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_tenants_status ON tenants (status)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_tenants_status")
|
||||
op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS status")
|
||||
op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS end_date")
|
||||
op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS start_date")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""split_module_access_table
|
||||
|
||||
Revision ID: 63b95ea5b967
|
||||
Revises: 88cfc7dee19d
|
||||
Create Date: 2026-01-23 11:07:09.706000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '63b95ea5b967'
|
||||
down_revision: Union[str, Sequence[str], None] = '88cfc7dee19d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('module_accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_id', sa.UUID(), nullable=False),
|
||||
sa.Column('access_code', sa.String(), nullable=False),
|
||||
sa.Column('category', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('sync_checksum', sa.String(), nullable=True),
|
||||
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_id'], ['modules.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('module_id', 'access_code', name='uq_module_access_code')
|
||||
)
|
||||
op.create_index(op.f('ix_module_accesses_access_code'), 'module_accesses', ['access_code'], unique=False)
|
||||
op.create_index(op.f('ix_module_accesses_category'), 'module_accesses', ['category'], unique=False)
|
||||
op.create_index(op.f('ix_module_accesses_id'), 'module_accesses', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_module_accesses_module_id'), 'module_accesses', ['module_id'], unique=False)
|
||||
op.drop_index(op.f('ix_access_code_module'), table_name='accesses', postgresql_where='(module_id IS NOT NULL)')
|
||||
op.drop_index(op.f('ix_access_code_saas'), table_name='accesses', postgresql_where='(module_id IS NULL)')
|
||||
op.drop_index(op.f('ix_accesses_module_id'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_scope'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
|
||||
op.drop_constraint(op.f('accesses_module_id_fkey'), 'accesses', type_='foreignkey')
|
||||
op.drop_column('accesses', 'module_id')
|
||||
op.drop_column('accesses', 'scope')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('accesses', sa.Column('scope', sa.VARCHAR(), autoincrement=False, nullable=False))
|
||||
op.add_column('accesses', sa.Column('module_id', sa.UUID(), autoincrement=False, nullable=True))
|
||||
op.create_foreign_key(op.f('accesses_module_id_fkey'), 'accesses', 'modules', ['module_id'], ['id'])
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=False)
|
||||
op.create_index(op.f('ix_accesses_scope'), 'accesses', ['scope'], unique=False)
|
||||
op.create_index(op.f('ix_accesses_module_id'), 'accesses', ['module_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_code_saas'), 'accesses', ['access_code'], unique=True, postgresql_where='(module_id IS NULL)')
|
||||
op.create_index(op.f('ix_access_code_module'), 'accesses', ['access_code', 'module_id'], unique=True, postgresql_where='(module_id IS NOT NULL)')
|
||||
op.drop_index(op.f('ix_module_accesses_module_id'), table_name='module_accesses')
|
||||
op.drop_index(op.f('ix_module_accesses_id'), table_name='module_accesses')
|
||||
op.drop_index(op.f('ix_module_accesses_category'), table_name='module_accesses')
|
||||
op.drop_index(op.f('ix_module_accesses_access_code'), table_name='module_accesses')
|
||||
op.drop_table('module_accesses')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add duration days to subscription plans
|
||||
|
||||
Revision ID: 6a1b2c3d4e55
|
||||
Revises: 5f2e9c1a7b44
|
||||
Create Date: 2026-04-18 15:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "6a1b2c3d4e55"
|
||||
down_revision: Union[str, Sequence[str], None] = "5f2e9c1a7b44"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS duration_days INTEGER"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE subscription_plans DROP COLUMN IF EXISTS duration_days"
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add_follow_up_event_to_event_logs
|
||||
|
||||
Revision ID: 720027c97104
|
||||
Revises: 9283c3f52a76
|
||||
Create Date: 2026-04-07 12:56:10.500329
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '720027c97104'
|
||||
down_revision: Union[str, Sequence[str], None] = '9283c3f52a76'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('event_logs', sa.Column('follow_up_event', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('event_logs', 'follow_up_event')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,43 @@
|
||||
"""create audit logs table
|
||||
|
||||
Revision ID: 73b754d5b2c5
|
||||
Revises: c37ba6143f83
|
||||
Create Date: 2026-03-30 15:43:16.849980
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '73b754d5b2c5'
|
||||
down_revision: Union[str, Sequence[str], None] = 'c37ba6143f83'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('audit_logs',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('action_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_audit_logs_id'), 'audit_logs', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_module_name'), 'audit_logs', ['module_name'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_audit_logs_module_name'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_id'), table_name='audit_logs')
|
||||
op.drop_table('audit_logs')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,157 @@
|
||||
"""add_module_registry
|
||||
|
||||
Revision ID: 74b6ccfaee8e
|
||||
Revises: 8acd83604252
|
||||
Create Date: 2026-01-20 15:11:19.596874
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '74b6ccfaee8e'
|
||||
down_revision: Union[str, Sequence[str], None] = '8acd83604252'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('modules',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_id', sa.String(), nullable=False),
|
||||
sa.Column('module_name', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('icon_url', sa.String(), nullable=True),
|
||||
sa.Column('display_order', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_modules_module_id'), 'modules', ['module_id'], unique=True)
|
||||
|
||||
op.create_table('module_environments',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_id', sa.UUID(), nullable=False),
|
||||
sa.Column('slug', sa.String(), nullable=False),
|
||||
sa.Column('frontend_base_url', sa.String(), nullable=False),
|
||||
sa.Column('sso_entry_path', sa.String(), nullable=True),
|
||||
sa.Column('backend_base_url', sa.String(), nullable=False),
|
||||
sa.Column('sso_exchange_endpoint', sa.String(), nullable=True),
|
||||
sa.Column('permission_sync_endpoint', sa.String(), nullable=True),
|
||||
sa.Column('trust_type', sa.String(), nullable=False),
|
||||
sa.Column('trust_credentials', sa.JSON(), nullable=False),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_id'], ['modules.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('module_id', 'slug', name='uq_module_env_slug')
|
||||
)
|
||||
op.create_index(op.f('ix_module_environments_module_id'), 'module_environments', ['module_id'], unique=False)
|
||||
op.create_index(op.f('ix_module_environments_slug'), 'module_environments', ['slug'], unique=False)
|
||||
|
||||
op.create_table('tenant_modules',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_id', sa.UUID(), nullable=False),
|
||||
sa.Column('assigned_environment_slug', sa.String(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('activated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deactivated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('plan_tier', sa.String(), nullable=True),
|
||||
sa.Column('module_config', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_id'], ['modules.id'], ),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant_id', 'module_id', name='uq_tenant_module')
|
||||
)
|
||||
op.create_index(op.f('ix_tenant_modules_module_id'), 'tenant_modules', ['module_id'], unique=False)
|
||||
op.create_index(op.f('ix_tenant_modules_tenant_id'), 'tenant_modules', ['tenant_id'], unique=False)
|
||||
|
||||
op.create_table('sso_grants',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('grant_code', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=True),
|
||||
sa.Column('environment_slug', sa.String(), nullable=False),
|
||||
sa.Column('redirect_url', sa.String(), nullable=False),
|
||||
sa.Column('is_used', sa.Boolean(), nullable=True),
|
||||
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_id'], ['modules.id'], ),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_sso_grants_grant_code'), 'sso_grants', ['grant_code'], unique=True)
|
||||
op.create_index(op.f('ix_sso_grants_module_id'), 'sso_grants', ['module_id'], unique=False)
|
||||
op.create_index(op.f('ix_sso_grants_tenant_id'), 'sso_grants', ['tenant_id'], unique=False)
|
||||
op.create_index(op.f('ix_sso_grants_user_id'), 'sso_grants', ['user_id'], unique=False)
|
||||
|
||||
# Add scope column as nullable first
|
||||
op.add_column('accesses', sa.Column('scope', sa.String(), nullable=True))
|
||||
op.add_column('accesses', sa.Column('module_id', sa.UUID(), nullable=True))
|
||||
op.add_column('accesses', sa.Column('sync_checksum', sa.String(), nullable=True))
|
||||
op.add_column('accesses', sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
# Update existing rows with default scope
|
||||
op.execute("UPDATE accesses SET scope = 'saas' WHERE scope IS NULL")
|
||||
|
||||
# Now make it not null
|
||||
op.alter_column('accesses', 'scope', nullable=False)
|
||||
|
||||
op.create_index(op.f('ix_accesses_module_id'), 'accesses', ['module_id'], unique=False)
|
||||
op.create_index(op.f('ix_accesses_scope'), 'accesses', ['scope'], unique=False)
|
||||
op.create_foreign_key(None, 'accesses', 'modules', ['module_id'], ['id'])
|
||||
# Inspect to see if constraint/column exists to avoid transaction abortion on failure
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
# Check and drop foreign key
|
||||
fks = inspector.get_foreign_keys('users')
|
||||
if any(fk['name'] == 'users_palette_id_fkey' for fk in fks):
|
||||
op.drop_constraint('users_palette_id_fkey', 'users', type_='foreignkey')
|
||||
|
||||
# Check and drop column
|
||||
columns = [c['name'] for c in inspector.get_columns('users')]
|
||||
if 'palette_id' in columns:
|
||||
op.drop_column('users', 'palette_id')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('palette_id', sa.UUID(), autoincrement=False, nullable=True))
|
||||
op.create_foreign_key(op.f('users_palette_id_fkey'), 'users', 'color_palettes', ['palette_id'], ['id'])
|
||||
op.drop_constraint(None, 'accesses', type_='foreignkey')
|
||||
op.drop_index(op.f('ix_accesses_scope'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_module_id'), table_name='accesses')
|
||||
op.drop_column('accesses', 'last_synced_at')
|
||||
op.drop_column('accesses', 'sync_checksum')
|
||||
op.drop_column('accesses', 'module_id')
|
||||
op.drop_column('accesses', 'scope')
|
||||
op.drop_index(op.f('ix_sso_grants_user_id'), table_name='sso_grants')
|
||||
op.drop_index(op.f('ix_sso_grants_tenant_id'), table_name='sso_grants')
|
||||
op.drop_index(op.f('ix_sso_grants_module_id'), table_name='sso_grants')
|
||||
op.drop_index(op.f('ix_sso_grants_grant_code'), table_name='sso_grants')
|
||||
op.drop_table('sso_grants')
|
||||
op.drop_index(op.f('ix_tenant_modules_tenant_id'), table_name='tenant_modules')
|
||||
op.drop_index(op.f('ix_tenant_modules_module_id'), table_name='tenant_modules')
|
||||
op.drop_table('tenant_modules')
|
||||
op.drop_index(op.f('ix_module_environments_slug'), table_name='module_environments')
|
||||
op.drop_index(op.f('ix_module_environments_module_id'), table_name='module_environments')
|
||||
op.drop_table('module_environments')
|
||||
op.drop_index(op.f('ix_modules_module_id'), table_name='modules')
|
||||
op.drop_table('modules')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add max users allowed to subscription plans
|
||||
|
||||
Revision ID: 7b2c4d5e6f77
|
||||
Revises: 6a1b2c3d4e55
|
||||
Create Date: 2026-04-28 10:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "7b2c4d5e6f77"
|
||||
down_revision: Union[str, Sequence[str], None] = "6a1b2c3d4e55"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS max_users_allowed INTEGER"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE subscription_plans DROP COLUMN IF EXISTS max_users_allowed"
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""scoped_access_code_uniqueness
|
||||
|
||||
Revision ID: 88cfc7dee19d
|
||||
Revises: 03a1b1f05e99
|
||||
Create Date: 2026-01-22 19:44:27.431776
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '88cfc7dee19d'
|
||||
down_revision: Union[str, Sequence[str], None] = '03a1b1f05e99'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=False)
|
||||
op.create_index('ix_access_code_module', 'accesses', ['access_code', 'module_id'], unique=True, postgresql_where=sa.text('module_id IS NOT NULL'))
|
||||
op.create_index('ix_access_code_saas', 'accesses', ['access_code'], unique=True, postgresql_where=sa.text('module_id IS NULL'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('ix_access_code_saas', table_name='accesses', postgresql_where=sa.text('module_id IS NULL'))
|
||||
op.drop_index('ix_access_code_module', table_name='accesses', postgresql_where=sa.text('module_id IS NOT NULL'))
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,132 @@
|
||||
"""initial migration
|
||||
|
||||
Revision ID: 8acd83604252
|
||||
Revises:
|
||||
Create Date: 2026-01-17 16:23:50.226790
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '8acd83604252'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('access_code', sa.String(), nullable=False),
|
||||
sa.Column('category', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('parent_id', sa.UUID(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['accesses.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
|
||||
op.create_index(op.f('ix_accesses_category'), 'accesses', ['category'], unique=False)
|
||||
op.create_index(op.f('ix_accesses_id'), 'accesses', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_accesses_parent_id'), 'accesses', ['parent_id'], unique=False)
|
||||
op.create_table('color_palettes',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=True),
|
||||
sa.Column('colors', sa.JSON(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('tenants',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_name', sa.String(), nullable=False),
|
||||
sa.Column('tenant_domain', sa.String(), nullable=False),
|
||||
sa.Column('tenant_logo_url', sa.String(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tenants_id'), 'tenants', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tenants_tenant_domain'), 'tenants', ['tenant_domain'], unique=True)
|
||||
op.create_index(op.f('ix_tenants_tenant_name'), 'tenants', ['tenant_name'], unique=True)
|
||||
op.create_table('roles',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('role_name', sa.String(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_roles_id'), 'roles', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_roles_tenant_id'), 'roles', ['tenant_id'], unique=False)
|
||||
op.create_table('role_accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('role_id', sa.UUID(), nullable=False),
|
||||
sa.Column('access_id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['access_id'], ['accesses.id'], ),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('role_id', 'access_id', name='uq_role_access')
|
||||
)
|
||||
op.create_index(op.f('ix_role_accesses_access_id'), 'role_accesses', ['access_id'], unique=False)
|
||||
op.create_index(op.f('ix_role_accesses_role_id'), 'role_accesses', ['role_id'], unique=False)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=False),
|
||||
sa.Column('password', sa.String(), nullable=False),
|
||||
sa.Column('first_name', sa.String(), nullable=False),
|
||||
sa.Column('last_name', sa.String(), nullable=True),
|
||||
sa.Column('phone_number', sa.String(), nullable=True),
|
||||
sa.Column('preferred_language', sa.String(), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=True),
|
||||
sa.Column('role_id', sa.UUID(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('password_updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_users_role_id'), 'users', ['role_id'], unique=False)
|
||||
op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_users_tenant_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_role_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_role_accesses_role_id'), table_name='role_accesses')
|
||||
op.drop_index(op.f('ix_role_accesses_access_id'), table_name='role_accesses')
|
||||
op.drop_table('role_accesses')
|
||||
op.drop_index(op.f('ix_roles_tenant_id'), table_name='roles')
|
||||
op.drop_index(op.f('ix_roles_id'), table_name='roles')
|
||||
op.drop_table('roles')
|
||||
op.drop_index(op.f('ix_tenants_tenant_name'), table_name='tenants')
|
||||
op.drop_index(op.f('ix_tenants_tenant_domain'), table_name='tenants')
|
||||
op.drop_index(op.f('ix_tenants_id'), table_name='tenants')
|
||||
op.drop_table('tenants')
|
||||
op.drop_table('color_palettes')
|
||||
op.drop_index(op.f('ix_accesses_parent_id'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_id'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_category'), table_name='accesses')
|
||||
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
|
||||
op.drop_table('accesses')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,51 @@
|
||||
"""add_parent_id_to_module_access
|
||||
|
||||
Revision ID: 91cc93992a91
|
||||
Revises: 63b95ea5b967
|
||||
Create Date: 2026-01-23 11:16:02.998798
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '91cc93992a91'
|
||||
down_revision: Union[str, Sequence[str], None] = '63b95ea5b967'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('role_module_accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('role_id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_access_id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_access_id'], ['module_accesses.id'], ),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('role_id', 'module_access_id', name='uq_role_module_access')
|
||||
)
|
||||
op.create_index(op.f('ix_role_module_accesses_module_access_id'), 'role_module_accesses', ['module_access_id'], unique=False)
|
||||
op.create_index(op.f('ix_role_module_accesses_role_id'), 'role_module_accesses', ['role_id'], unique=False)
|
||||
op.add_column('module_accesses', sa.Column('parent_id', sa.UUID(), nullable=True))
|
||||
op.create_index(op.f('ix_module_accesses_parent_id'), 'module_accesses', ['parent_id'], unique=False)
|
||||
op.create_foreign_key(None, 'module_accesses', 'module_accesses', ['parent_id'], ['id'])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'module_accesses', type_='foreignkey')
|
||||
op.drop_index(op.f('ix_module_accesses_parent_id'), table_name='module_accesses')
|
||||
op.drop_column('module_accesses', 'parent_id')
|
||||
op.drop_index(op.f('ix_role_module_accesses_role_id'), table_name='role_module_accesses')
|
||||
op.drop_index(op.f('ix_role_module_accesses_module_access_id'), table_name='role_module_accesses')
|
||||
op.drop_table('role_module_accesses')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Add subscription model
|
||||
|
||||
Revision ID: 9283c3f52a76
|
||||
Revises: f9cf173f48f9
|
||||
Create Date: 2026-04-06 14:26:41.926020
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9283c3f52a76'
|
||||
down_revision: Union[str, Sequence[str], None] = 'f9cf173f48f9'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('subscription_plans',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('is_public', sa.Boolean(), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_subscription_plans_id'), 'subscription_plans', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_subscription_plans_name'), 'subscription_plans', ['name'], unique=True)
|
||||
op.create_table('plan_accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('plan_id', sa.UUID(), nullable=False),
|
||||
sa.Column('access_id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['access_id'], ['accesses.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['plan_id'], ['subscription_plans.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('plan_id', 'access_id', name='uq_plan_access')
|
||||
)
|
||||
op.create_index(op.f('ix_plan_accesses_access_id'), 'plan_accesses', ['access_id'], unique=False)
|
||||
op.create_index(op.f('ix_plan_accesses_id'), 'plan_accesses', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_plan_accesses_plan_id'), 'plan_accesses', ['plan_id'], unique=False)
|
||||
op.create_table('plan_module_accesses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('plan_id', sa.UUID(), nullable=False),
|
||||
sa.Column('module_access_id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['module_access_id'], ['module_accesses.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['plan_id'], ['subscription_plans.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('plan_id', 'module_access_id', name='uq_plan_module_access')
|
||||
)
|
||||
op.create_index(op.f('ix_plan_module_accesses_id'), 'plan_module_accesses', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_plan_module_accesses_module_access_id'), 'plan_module_accesses', ['module_access_id'], unique=False)
|
||||
op.create_index(op.f('ix_plan_module_accesses_plan_id'), 'plan_module_accesses', ['plan_id'], unique=False)
|
||||
op.add_column('tenants', sa.Column('plan_id', sa.UUID(), nullable=True))
|
||||
op.create_index(op.f('ix_tenants_plan_id'), 'tenants', ['plan_id'], unique=False)
|
||||
op.create_foreign_key(None, 'tenants', 'subscription_plans', ['plan_id'], ['id'])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'tenants', type_='foreignkey')
|
||||
op.drop_index(op.f('ix_tenants_plan_id'), table_name='tenants')
|
||||
op.drop_column('tenants', 'plan_id')
|
||||
op.drop_index(op.f('ix_plan_module_accesses_plan_id'), table_name='plan_module_accesses')
|
||||
op.drop_index(op.f('ix_plan_module_accesses_module_access_id'), table_name='plan_module_accesses')
|
||||
op.drop_index(op.f('ix_plan_module_accesses_id'), table_name='plan_module_accesses')
|
||||
op.drop_table('plan_module_accesses')
|
||||
op.drop_index(op.f('ix_plan_accesses_plan_id'), table_name='plan_accesses')
|
||||
op.drop_index(op.f('ix_plan_accesses_id'), table_name='plan_accesses')
|
||||
op.drop_index(op.f('ix_plan_accesses_access_id'), table_name='plan_accesses')
|
||||
op.drop_table('plan_accesses')
|
||||
op.drop_index(op.f('ix_subscription_plans_name'), table_name='subscription_plans')
|
||||
op.drop_index(op.f('ix_subscription_plans_id'), table_name='subscription_plans')
|
||||
op.drop_table('subscription_plans')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add_provisioning_endpoint_to_module_environment
|
||||
|
||||
Revision ID: c37ba6143f83
|
||||
Revises: 91cc93992a91
|
||||
Create Date: 2026-01-24 10:04:06.239899
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c37ba6143f83'
|
||||
down_revision: Union[str, Sequence[str], None] = '91cc93992a91'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('module_environments', sa.Column('provisioning_endpoint', sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('module_environments', 'provisioning_endpoint')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,32 @@
|
||||
"""make_sso_grants_stateless
|
||||
|
||||
Revision ID: cd8ba77ffd9e
|
||||
Revises: 74b6ccfaee8e
|
||||
Create Date: 2026-01-20 16:58:53.362779
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'cd8ba77ffd9e'
|
||||
down_revision: Union[str, Sequence[str], None] = '74b6ccfaee8e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('sso_grants', 'redirect_url')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('sso_grants', sa.Column('redirect_url', sa.VARCHAR(), autoincrement=False, nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,50 @@
|
||||
"""add_audit_log_extra_columns
|
||||
|
||||
Revision ID: f9cf173f48f9
|
||||
Revises: 73b754d5b2c5
|
||||
Create Date: 2026-03-31 13:57:11.328101
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f9cf173f48f9'
|
||||
down_revision: Union[str, Sequence[str], None] = '73b754d5b2c5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('audit_logs', sa.Column('entity_id', sa.String(length=255), nullable=True))
|
||||
op.add_column('audit_logs', sa.Column('entity_name', sa.String(length=255), nullable=True))
|
||||
op.add_column('audit_logs', sa.Column('performed_by_id', sa.UUID(), nullable=True))
|
||||
op.add_column('audit_logs', sa.Column('performed_by_email', sa.String(length=255), nullable=True))
|
||||
op.add_column('audit_logs', sa.Column('ip_address', sa.String(length=45), nullable=True))
|
||||
op.add_column('audit_logs', sa.Column('old_values', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||
op.add_column('audit_logs', sa.Column('new_values', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||
op.create_index(op.f('ix_audit_logs_action_type'), 'audit_logs', ['action_type'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_performed_by_email'), 'audit_logs', ['performed_by_email'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_audit_logs_performed_by_email'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_action_type'), table_name='audit_logs')
|
||||
op.drop_column('audit_logs', 'new_values')
|
||||
op.drop_column('audit_logs', 'old_values')
|
||||
op.drop_column('audit_logs', 'ip_address')
|
||||
op.drop_column('audit_logs', 'performed_by_email')
|
||||
op.drop_column('audit_logs', 'performed_by_id')
|
||||
op.drop_column('audit_logs', 'entity_name')
|
||||
op.drop_column('audit_logs', 'entity_id')
|
||||
# ### end Alembic commands ###
|
||||
+129
-23
@@ -1,3 +1,4 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPBearer
|
||||
@@ -5,12 +6,24 @@ import logging
|
||||
from sqlalchemy import text
|
||||
from app.config.settings import settings
|
||||
from app.config.database import engine
|
||||
|
||||
# Import models for Alembic
|
||||
from app.routes.admin import audit_logs
|
||||
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
|
||||
import app.models.auth.module_model
|
||||
import app.models.auth.module_environment_model
|
||||
import app.models.auth.tenant_module_model
|
||||
import app.models.auth.sso_grant_model
|
||||
import app.models.auth.access_model
|
||||
import app.models.auth.subscription_plan_model
|
||||
import app.models.system.event_log_model
|
||||
import app.models.system.audit_log
|
||||
import asyncio
|
||||
from app.services.auth.event_service import EventService
|
||||
from app.config.database import SessionLocal
|
||||
from app.core.redis import redis_client, sync_redis_client
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -21,6 +34,95 @@ logging.basicConfig(
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def wait_for_db(retries: int = 10, base_delay: float = 3.0, max_delay: float = 30.0):
|
||||
"""Wait for the database to become available with exponential backoff."""
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
logger.info("Database connection successful!")
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt == retries:
|
||||
logger.error(f"Database unreachable after {retries} attempts: {e}")
|
||||
raise
|
||||
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
|
||||
logger.warning(
|
||||
f"Database not ready (attempt {attempt}/{retries}): {e}. "
|
||||
f"Retrying in {delay:.0f}s..."
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Starting SaaS application...")
|
||||
|
||||
await wait_for_db()
|
||||
|
||||
app.state.redis_available = False
|
||||
|
||||
try:
|
||||
await redis_client.connect()
|
||||
await run_in_threadpool(sync_redis_client.connect)
|
||||
app.state.redis_available = True
|
||||
logger.info("Redis connected successfully.")
|
||||
except Exception as e:
|
||||
logger.error(f"Redis unavailable at startup: {e}")
|
||||
logger.warning("Continuing without Redis — event queue disabled.")
|
||||
|
||||
logger.info(
|
||||
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
|
||||
)
|
||||
|
||||
async def redis_event_consumer():
|
||||
logger.info("Redis Event Consumer STARTED")
|
||||
while True:
|
||||
try:
|
||||
if not app.state.redis_available or not redis_client.client:
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
result = await redis_client.client.blpop("saas:events:queue", timeout=5)
|
||||
|
||||
if result:
|
||||
_, event_id = result
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
await run_in_threadpool(EventService.process_queue_item, db, event_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing event {event_id}: {e}")
|
||||
|
||||
except Exception:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def fallback_poller():
|
||||
logger.info("Fallback Event Poller STARTED")
|
||||
while True:
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
await run_in_threadpool(EventService.process_outbox, db)
|
||||
except Exception as e:
|
||||
logger.error(f"Fallback poller error: {e}")
|
||||
|
||||
await asyncio.sleep(60)
|
||||
|
||||
background_tasks = [
|
||||
asyncio.create_task(redis_event_consumer()),
|
||||
asyncio.create_task(fallback_poller()),
|
||||
]
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down...")
|
||||
for task in background_tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*background_tasks, return_exceptions=True)
|
||||
if app.state.redis_available:
|
||||
try:
|
||||
await redis_client.close()
|
||||
await run_in_threadpool(sync_redis_client.close)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
@@ -30,6 +132,7 @@ def create_app() -> FastAPI:
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# === OpenAPI Security Scheme ===
|
||||
@@ -73,7 +176,7 @@ def create_app() -> FastAPI:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
@@ -84,30 +187,35 @@ def create_app() -> FastAPI:
|
||||
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
|
||||
from app.routes.auth.sso import public_router as sso_public_router, internal_router as sso_internal_router
|
||||
from app.routes.api.module import router as module_router
|
||||
from app.routes.auth.subscription_plan import router as subscription_plan_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"])
|
||||
app.include_router(subscription_plan_router, prefix="/api/subscription-plan", tags=["Subscription Plans"])
|
||||
from app.routes.internal.module import router as internal_module_router
|
||||
|
||||
app.include_router(sso_public_router, prefix="/api/sso", tags=["SSO"])
|
||||
app.include_router(sso_internal_router, prefix="/internal/sso", tags=["Internal SSO"])
|
||||
app.include_router(module_router, prefix="/api/modules", tags=["Modules"])
|
||||
app.include_router(internal_module_router, prefix="/internal/modules", tags=["Internal Modules"])
|
||||
|
||||
# === 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
|
||||
from app.routes.theme.color_palette import router as palette_router
|
||||
app.include_router(palette_router, prefix="/api/theme", tags=["Theme Management"])
|
||||
|
||||
logger.info(
|
||||
f"{settings.PROJECT_NAME} v{settings.VERSION} started ({settings.APP_ENV})"
|
||||
)
|
||||
# === Admin Routes ===
|
||||
from app.routes.admin.modules import router as admin_modules_router
|
||||
from app.routes.admin.module_environments import router as admin_module_env_router
|
||||
from app.routes.admin.tenant_modules import router as admin_tenant_modules_router
|
||||
|
||||
app.include_router(audit_logs.router, prefix="/api/admin/audit-logs", tags=["Admin - Audit Logs"])
|
||||
app.include_router(admin_modules_router, prefix="/api/admin/modules", tags=["Admin - Modules"])
|
||||
app.include_router(admin_module_env_router, prefix="/api/admin/modules", tags=["Admin - Module Environments"])
|
||||
app.include_router(admin_tenant_modules_router, prefix="/api/admin/tenants", tags=["Admin - Tenant Modules"])
|
||||
|
||||
# === Basic Routes ===
|
||||
@app.get("/", tags=["Root"])
|
||||
@@ -138,7 +246,8 @@ def create_app() -> FastAPI:
|
||||
conn.execute(text("SELECT 1"))
|
||||
db_status = "healthy"
|
||||
except Exception as e:
|
||||
db_status = f"unhealthy: {str(e)}"
|
||||
logger.error(f"Health check DB error: {e}")
|
||||
db_status = "unhealthy"
|
||||
|
||||
return {
|
||||
"status": "healthy" if db_status == "healthy" else "degraded",
|
||||
@@ -147,7 +256,4 @@ def create_app() -> FastAPI:
|
||||
"version": settings.VERSION,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
return app
|
||||
+48
-20
@@ -1,18 +1,17 @@
|
||||
"""
|
||||
Security utilities for authentication and authorization.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
import bcrypt
|
||||
import jwt
|
||||
import uuid
|
||||
import logging
|
||||
from fastapi import HTTPException, status
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.core.redis import sync_redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SecurityUtils:
|
||||
"""Security utility class for authentication and authorization."""
|
||||
@@ -33,24 +32,24 @@ class SecurityUtils:
|
||||
"""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"})
|
||||
|
||||
to_encode.update({"exp": expire, "type": "access", "jti": str(uuid.uuid4())})
|
||||
|
||||
# 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"})
|
||||
to_encode.update({"exp": expire, "type": "refresh", "jti": str(uuid.uuid4())})
|
||||
|
||||
# Include tenant_id if provided
|
||||
if tenant_id:
|
||||
@@ -77,7 +76,20 @@ class SecurityUtils:
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type"
|
||||
)
|
||||
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti and sync_redis_client.client:
|
||||
try:
|
||||
if sync_redis_client.client.get(f"blacklist:{jti}"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has been revoked"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis blacklist check failed: {e}")
|
||||
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
@@ -129,24 +141,42 @@ class SecurityUtils:
|
||||
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 generate_module_token(data: Dict[str, Any], module_id: str, ttl_seconds: int = 900) -> str:
|
||||
"""Generate short-lived module-scoped JWT (15 min default)."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"type": "module_access",
|
||||
"aud": str(module_id),
|
||||
"iat": datetime.now(timezone.utc).timestamp()
|
||||
})
|
||||
|
||||
if not settings.SAAS_PRIVATE_KEY:
|
||||
raise ValueError("SAAS_PRIVATE_KEY is not configured. Cannot sign module identity tokens.")
|
||||
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.SAAS_PRIVATE_KEY,
|
||||
algorithm="RS256",
|
||||
headers={"kid": settings.SAAS_KEY_ID}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate_email(email: str) -> bool:
|
||||
"""Validate email format."""
|
||||
@@ -161,6 +191,4 @@ class SecurityUtils:
|
||||
|
||||
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()
|
||||
+29
-53
@@ -2,32 +2,25 @@ 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=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)
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Project
|
||||
PROJECT_NAME: str = "SaaS Architecture"
|
||||
VERSION: str = "1.0.0"
|
||||
PROJECT_NAME: str
|
||||
VERSION: str
|
||||
|
||||
# FastAPI
|
||||
PORT: int
|
||||
@@ -38,9 +31,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# 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
|
||||
@@ -52,14 +43,24 @@ class Settings(BaseSettings):
|
||||
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
|
||||
REDIS_HOST: str
|
||||
REDIS_PORT: int
|
||||
REDIS_PASSWORD: Optional[str]
|
||||
REDIS_DB: int = 0
|
||||
|
||||
REDIS_ENABLED: bool = True
|
||||
REDIS_TIMEOUT: int = 10
|
||||
REDIS_MAX_CONNECTIONS: int = 10
|
||||
|
||||
@property
|
||||
def REDIS_URL(self) -> str:
|
||||
if self.REDIS_PASSWORD:
|
||||
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
|
||||
# Email
|
||||
SMTP_HOST: str
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_PORT: int
|
||||
SMTP_SECURE: bool = True
|
||||
SMTP_USER: str
|
||||
SMTP_PASSWORD: str
|
||||
@@ -67,60 +68,35 @@ class Settings(BaseSettings):
|
||||
|
||||
# JWT settings
|
||||
ACCESS_TOKEN_SECRET: str
|
||||
ACCESS_TOKEN_EXPIRES: int = 86400
|
||||
ACCESS_TOKEN_EXPIRES: int = 900
|
||||
REFRESH_TOKEN_SECRET: str
|
||||
REFRESH_TOKEN_EXPIRES: int = 864000
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
|
||||
|
||||
# Cookie settings
|
||||
COOKIE_SECURE: bool = False
|
||||
COOKIE_DOMAIN: Optional[str] = None
|
||||
|
||||
# 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"
|
||||
# Module Integration Security (RS256)
|
||||
SAAS_PRIVATE_KEY: Optional[str] = None
|
||||
SAAS_KEY_ID: str = "saas-key-v1"
|
||||
|
||||
# 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
|
||||
|
||||
REDIS_CHAT_TTL: int = 86400
|
||||
|
||||
# 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
|
||||
|
||||
@@ -27,6 +27,10 @@ class AuthController:
|
||||
def refresh_token(db: Session, token_data: RefreshTokenRequest):
|
||||
return AuthService.refresh_access_token(db, token_data.refresh_token)
|
||||
|
||||
@staticmethod
|
||||
def refresh_token_raw(db: Session, refresh_token: str):
|
||||
return AuthService.refresh_access_token(db, refresh_token)
|
||||
|
||||
@staticmethod
|
||||
def update_user(
|
||||
db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User
|
||||
@@ -40,8 +44,8 @@ class AuthController:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def logout(current_user: User):
|
||||
return AuthService.logout(current_user)
|
||||
def logout(current_user: User, token: str):
|
||||
return AuthService.logout(current_user, token)
|
||||
|
||||
@staticmethod
|
||||
def me(db: Session, current_user: User):
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.services.auth.module_service import ModuleService
|
||||
from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate, ModuleResponse
|
||||
from app.models.auth.module_model import Module
|
||||
|
||||
class ModuleController:
|
||||
@staticmethod
|
||||
def list_modules(db: Session) -> List[ModuleResponse]:
|
||||
modules = ModuleService.list_modules(db)
|
||||
return [ModuleResponse.model_validate(m) for m in modules]
|
||||
|
||||
@staticmethod
|
||||
def create_module(db: Session, module_data: ModuleCreate) -> ModuleResponse:
|
||||
module = ModuleService.create_module(db, module_data)
|
||||
return ModuleResponse.model_validate(module)
|
||||
|
||||
@staticmethod
|
||||
def get_available_modules(db: Session, current_user) -> List[ModuleResponse]:
|
||||
from app.schemas.auth.module_schema import ModuleAvailableResponse
|
||||
results = ModuleService.get_available_modules(db, current_user.tenant_id)
|
||||
|
||||
response = []
|
||||
for item in results:
|
||||
mod = item["module"]
|
||||
response.append(ModuleAvailableResponse(
|
||||
module_id=mod.module_id,
|
||||
module_name=mod.module_name,
|
||||
description=mod.description,
|
||||
icon_url=mod.icon_url,
|
||||
display_order=mod.display_order or 0,
|
||||
is_active=item["is_active"]
|
||||
))
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def get_module(db: Session, module_id: str) -> ModuleResponse:
|
||||
module = ModuleService.get_module(db, module_id)
|
||||
return ModuleResponse.model_validate(module)
|
||||
|
||||
@staticmethod
|
||||
def update_module(db: Session, module_id: str, module_data: ModuleUpdate) -> ModuleResponse:
|
||||
module = ModuleService.update_module(db, module_id, module_data)
|
||||
return ModuleResponse.model_validate(module)
|
||||
|
||||
@staticmethod
|
||||
def delete_module(db: Session, module_id: str):
|
||||
ModuleService.delete_module(db, module_id)
|
||||
return {"message": "Module deleted successfully"}
|
||||
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.services.auth.module_environment_service import ModuleEnvironmentService
|
||||
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentUpdate, EnvironmentResponse
|
||||
|
||||
class ModuleEnvironmentController:
|
||||
@staticmethod
|
||||
def list_environments(db: Session, module_id: str) -> List[EnvironmentResponse]:
|
||||
environments = ModuleEnvironmentService.list_environments(db, module_id)
|
||||
return [EnvironmentResponse.model_validate(env) for env in environments]
|
||||
|
||||
@staticmethod
|
||||
def create_environment(db: Session, module_id: str, env_data: EnvironmentCreate) -> EnvironmentResponse:
|
||||
environment = ModuleEnvironmentService.create_environment(db, module_id, env_data)
|
||||
return EnvironmentResponse.model_validate(environment)
|
||||
|
||||
@staticmethod
|
||||
def update_environment(db: Session, module_id: str, env_id: str, env_data: EnvironmentUpdate) -> EnvironmentResponse:
|
||||
environment = ModuleEnvironmentService.update_environment(db, module_id, env_id, env_data)
|
||||
return EnvironmentResponse.model_validate(environment)
|
||||
|
||||
@staticmethod
|
||||
def set_default_environment(db: Session, module_id: str, env_id: str):
|
||||
ModuleEnvironmentService.set_default_environment(db, module_id, env_id)
|
||||
return {"message": "Environment set as default"}
|
||||
|
||||
@staticmethod
|
||||
def delete_environment(db: Session, module_id: str, env_id: str):
|
||||
ModuleEnvironmentService.delete_environment(db, module_id, env_id)
|
||||
return {"message": "Environment deleted successfully"}
|
||||
@@ -60,6 +60,18 @@ class RoleController:
|
||||
}
|
||||
for ra in role.role_accesses
|
||||
]
|
||||
|
||||
# Add Module Permissions
|
||||
accesses.extend([
|
||||
{
|
||||
"id": str(rma.module_access.id),
|
||||
"access_code": rma.module_access.access_code,
|
||||
"category": rma.module_access.category,
|
||||
"name": rma.module_access.name,
|
||||
"parent_id": str(rma.module_access.parent_id) if rma.module_access.parent_id else None,
|
||||
}
|
||||
for rma in role.role_module_accesses
|
||||
])
|
||||
|
||||
return RoleWithAccessesResponse(
|
||||
id=role.id,
|
||||
@@ -77,6 +89,10 @@ class RoleController:
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
filter_role_names: Optional[List[str]] = None,
|
||||
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
) -> RolePaginatedResponse:
|
||||
tenant_id = None if current_user_tenant_id is None else current_user_tenant_id
|
||||
return RoleService.get_roles_paginated(
|
||||
@@ -85,4 +101,8 @@ class RoleController:
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
filter_role_names=filter_role_names,
|
||||
filter_tenant_ids=filter_tenant_ids,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import Request, HTTPException
|
||||
from typing import Optional, Dict, Any
|
||||
import uuid
|
||||
import logging
|
||||
from app.services.auth.sso_service import SSOService
|
||||
from app.services.auth.trust_service import TrustService
|
||||
from app.schemas.auth.sso_schema import SSOInitiateRequest, SSOExchangeRequest
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.models.auth.user_model import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SSOController:
|
||||
@staticmethod
|
||||
def initiate_sso(db: Session, request: SSOInitiateRequest, current_user: User):
|
||||
return SSOService.generate_signed_payload(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
module_id=request.module_id,
|
||||
tenant_id=current_user.tenant_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def exchange_grant(
|
||||
db: Session,
|
||||
payload: SSOExchangeRequest,
|
||||
x_module_signature: Optional[str] = None,
|
||||
x_module_key: Optional[str] = None
|
||||
):
|
||||
module = db.query(Module).filter(Module.module_id == payload.module_id).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module.id,
|
||||
ModuleEnvironment.slug == payload.environment_slug
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
headers = {}
|
||||
if x_module_signature:
|
||||
headers["X-Module-Signature"] = x_module_signature
|
||||
if x_module_key:
|
||||
headers["X-Module-Key"] = x_module_key
|
||||
|
||||
actual_body = payload.model_dump_json()
|
||||
try:
|
||||
TrustService.validate_module_trust(
|
||||
environment=env,
|
||||
request_headers=headers,
|
||||
request_body=actual_body
|
||||
)
|
||||
except HTTPException:
|
||||
logger.warning(
|
||||
"HMAC verify with body failed for %s, trying empty fallback (DEPRECATED)",
|
||||
payload.module_id,
|
||||
)
|
||||
TrustService.validate_module_trust(
|
||||
environment=env,
|
||||
request_headers=headers,
|
||||
request_body=""
|
||||
)
|
||||
|
||||
return SSOService.exchange_grant(
|
||||
db=db,
|
||||
grant_code=payload.grant_code,
|
||||
module_id=payload.module_id,
|
||||
environment_slug=payload.environment_slug
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.schemas.auth.subscription_plan_schema import SubscriptionPlanCreate, SubscriptionPlanUpdate
|
||||
from app.services.auth.subscription_plan_service import SubscriptionPlanService
|
||||
|
||||
class SubscriptionPlanController:
|
||||
|
||||
@staticmethod
|
||||
def create_plan(db: Session, plan_data: SubscriptionPlanCreate):
|
||||
return SubscriptionPlanService.create_plan(db, plan_data)
|
||||
|
||||
@staticmethod
|
||||
def update_plan(db: Session, plan_id: uuid.UUID, plan_data: SubscriptionPlanUpdate):
|
||||
return SubscriptionPlanService.update_plan(db, plan_id, plan_data)
|
||||
|
||||
@staticmethod
|
||||
def get_plan(db: Session, plan_id: uuid.UUID):
|
||||
return SubscriptionPlanService.get_plan(db, plan_id)
|
||||
|
||||
@staticmethod
|
||||
def get_all_plans(db: Session, is_public: Optional[bool] = None, status: Optional[str] = None):
|
||||
return SubscriptionPlanService.get_all_plans(db, is_public, status)
|
||||
|
||||
@staticmethod
|
||||
def get_paginated_plans(
|
||||
db: Session,
|
||||
page: int,
|
||||
page_size: int,
|
||||
search: Optional[str],
|
||||
filter_names: Optional[List[str]] = None,
|
||||
statuses: Optional[List[str]] = None,
|
||||
visibility: Optional[List[bool]] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
):
|
||||
return SubscriptionPlanService.get_paginated_plans(
|
||||
db,
|
||||
page,
|
||||
page_size,
|
||||
search,
|
||||
filter_names=filter_names,
|
||||
statuses=statuses,
|
||||
visibility=visibility,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_plan(db: Session, plan_id: uuid.UUID):
|
||||
return SubscriptionPlanService.delete_plan(db, plan_id)
|
||||
@@ -2,7 +2,7 @@ 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
|
||||
from typing import List, Optional
|
||||
|
||||
class TenantController:
|
||||
|
||||
@@ -33,6 +33,12 @@ class TenantController:
|
||||
page_size: int,
|
||||
search: Optional[str],
|
||||
is_active: Optional[bool],
|
||||
filter_tenant_names: Optional[List[str]] = None,
|
||||
filter_tenant_domains: Optional[List[str]] = None,
|
||||
filter_plan_ids: Optional[List[uuid.UUID]] = None,
|
||||
statuses: Optional[List[bool]] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
):
|
||||
return TenantService.get_tenants_paginated(
|
||||
db=db,
|
||||
@@ -40,4 +46,10 @@ class TenantController:
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
is_active=is_active,
|
||||
filter_tenant_names=filter_tenant_names,
|
||||
filter_tenant_domains=filter_tenant_domains,
|
||||
filter_plan_ids=filter_plan_ids,
|
||||
statuses=statuses,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.models.auth.module_model import Module
|
||||
import uuid
|
||||
from app.services.auth.tenant_module_service import TenantModuleService
|
||||
from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate, TenantModuleResponse
|
||||
|
||||
class TenantModuleController:
|
||||
@staticmethod
|
||||
def list_tenant_modules(db: Session, tenant_id: str) -> List[TenantModuleResponse]:
|
||||
results = TenantModuleService.list_tenant_modules(db, tenant_id)
|
||||
|
||||
response_list = []
|
||||
for tm, mod in results:
|
||||
response_list.append(TenantModuleResponse(
|
||||
id=str(tm.id),
|
||||
tenant_id=str(tm.tenant_id),
|
||||
module_id=str(tm.module_id),
|
||||
module_name=mod.module_name,
|
||||
module_icon_url=mod.icon_url,
|
||||
assigned_environment_slug=tm.assigned_environment_slug or "prod",
|
||||
is_active=tm.is_active,
|
||||
module_config=tm.module_config,
|
||||
created_at=tm.created_at
|
||||
))
|
||||
return response_list
|
||||
|
||||
@staticmethod
|
||||
def assign_module(db: Session, tenant_id: str, assignment_data: TenantModuleCreate) -> TenantModuleResponse:
|
||||
tm = TenantModuleService.assign_module(db, tenant_id, assignment_data)
|
||||
|
||||
module = db.query(Module).filter(Module.id == tm.module_id).first()
|
||||
|
||||
return TenantModuleResponse(
|
||||
id=str(tm.id),
|
||||
tenant_id=str(tm.tenant_id),
|
||||
module_id=str(tm.module_id),
|
||||
module_name=module.module_name if module else "Unknown",
|
||||
module_icon_url=module.icon_url if module else None,
|
||||
assigned_environment_slug=tm.assigned_environment_slug,
|
||||
is_active=tm.is_active,
|
||||
module_config=tm.module_config,
|
||||
created_at=tm.created_at
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def update_assignment(db: Session, tenant_id: str, tenant_module_id: str, update_data: TenantModuleUpdate) -> TenantModuleResponse:
|
||||
tm = TenantModuleService.update_assignment(db, tenant_id, tenant_module_id, update_data)
|
||||
|
||||
module = db.query(Module).filter(Module.id == tm.module_id).first()
|
||||
|
||||
return TenantModuleResponse(
|
||||
id=str(tm.id),
|
||||
tenant_id=str(tm.tenant_id),
|
||||
module_id=str(tm.module_id),
|
||||
module_name=module.module_name if module else "Unknown",
|
||||
module_icon_url=module.icon_url if module else None,
|
||||
assigned_environment_slug=tm.assigned_environment_slug,
|
||||
is_active=tm.is_active,
|
||||
module_config=tm.module_config,
|
||||
created_at=tm.created_at
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def remove_assignment(db: Session, tenant_id: str, tenant_module_id: str):
|
||||
TenantModuleService.remove_assignment(db, tenant_id, tenant_module_id)
|
||||
return {"message": "Module removed from tenant successfully"}
|
||||
@@ -1,5 +1,5 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi import HTTPException, status, BackgroundTasks
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
from app.models.auth.user_model import User
|
||||
@@ -21,9 +21,9 @@ class UserController:
|
||||
return current_user.tenant_id
|
||||
|
||||
@staticmethod
|
||||
def create_user(db: Session, user_data: UserCreate, current_user: User) -> User:
|
||||
def create_user(db: Session, user_data: UserCreate, current_user: User, background_tasks: BackgroundTasks) -> User:
|
||||
tenant_id = UserController._resolve_tenant_id(current_user, user_data.tenant_id)
|
||||
return UserService.create_user(db, user_data, tenant_id)
|
||||
return UserService.create_user(db, user_data, tenant_id, background_tasks)
|
||||
|
||||
@staticmethod
|
||||
def get_all_users(db: Session, current_user: User) -> List[User]:
|
||||
@@ -36,12 +36,12 @@ class UserController:
|
||||
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:
|
||||
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, current_user: User, background_tasks: BackgroundTasks) -> 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)
|
||||
return UserService.update_user(db, user_id, user_data, tenant_id, background_tasks)
|
||||
|
||||
@staticmethod
|
||||
def delete_user(db: Session, user_id: uuid.UUID, current_user: User):
|
||||
@@ -55,7 +55,13 @@ class UserController:
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
filter_names: Optional[List[str]] = None,
|
||||
filter_emails: Optional[List[str]] = None,
|
||||
statuses: Optional[List[str]] = None,
|
||||
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
|
||||
filter_role_ids: Optional[List[uuid.UUID]] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
):
|
||||
tenant_id = current_user.tenant_id
|
||||
return UserService.get_users_paginated(
|
||||
@@ -64,5 +70,11 @@ class UserController:
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
status=status,
|
||||
filter_names=filter_names,
|
||||
filter_emails=filter_emails,
|
||||
statuses=statuses,
|
||||
filter_tenant_ids=filter_tenant_ids,
|
||||
filter_role_ids=filter_role_ids,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
@@ -1,28 +1,33 @@
|
||||
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
|
||||
from typing import List
|
||||
from app.models.auth.user_model import User
|
||||
from app.models.theme.color_palette_model import ColorPalette
|
||||
from app.schemas.theme.color_palette_schema import (
|
||||
ColorPaletteCreate,
|
||||
ColorPaletteUpdate,
|
||||
)
|
||||
from app.services.theme.color_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)
|
||||
return PaletteService.get_all_palettes(db)
|
||||
|
||||
@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)
|
||||
def create_palette(
|
||||
db: Session, data: ColorPaletteCreate, current_user: User
|
||||
) -> ColorPalette:
|
||||
return PaletteService.create_palette(db, data)
|
||||
|
||||
@staticmethod
|
||||
def update_palette(db: Session, palette_id: UUID, data: ColorPaletteUpdate) -> ColorPalette:
|
||||
def update_palette(
|
||||
db: Session, palette_id: UUID, data: ColorPaletteUpdate
|
||||
) -> ColorPalette:
|
||||
return PaletteService.update_palette(db, palette_id, data)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from redis.asyncio import Redis, from_url as async_from_url
|
||||
from app.config.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RedisClient:
|
||||
def __init__(self):
|
||||
self._redis: Optional[Redis] = None
|
||||
|
||||
async def connect(self):
|
||||
"""
|
||||
Initializes the Redis connection pool (Async).
|
||||
"""
|
||||
if settings.REDIS_ENABLED:
|
||||
try:
|
||||
self._redis = async_from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
max_connections=settings.REDIS_MAX_CONNECTIONS,
|
||||
socket_timeout=settings.REDIS_TIMEOUT
|
||||
)
|
||||
ping = await self._redis.ping()
|
||||
if ping:
|
||||
logger.info("Connected to Redis (Async)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Redis: {e}")
|
||||
self._redis = None
|
||||
else:
|
||||
logger.info("Redis is disabled in settings")
|
||||
|
||||
async def close(self):
|
||||
"""
|
||||
Closes the Redis connection.
|
||||
"""
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
logger.info("Redis connection closed")
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Get a value by key.
|
||||
"""
|
||||
if not self._redis:
|
||||
return None
|
||||
try:
|
||||
return await self._redis.get(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis GET error for key {key}: {e}")
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: str, expire: int = None) -> bool:
|
||||
"""
|
||||
Set a value by key with optional expiration time in seconds.
|
||||
"""
|
||||
if not self._redis:
|
||||
return False
|
||||
try:
|
||||
return await self._redis.set(key, value, ex=expire)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis SET error for key {key}: {e}")
|
||||
return False
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Delete a value by key.
|
||||
"""
|
||||
if not self._redis:
|
||||
return False
|
||||
try:
|
||||
return await self._redis.delete(key) > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Redis DELETE error for key {key}: {e}")
|
||||
return False
|
||||
|
||||
@property
|
||||
def client(self) -> Optional[Redis]:
|
||||
"""
|
||||
Expose the raw Redis client if needed for advanced operations.
|
||||
"""
|
||||
return self._redis
|
||||
|
||||
class SyncRedisClient:
|
||||
def __init__(self):
|
||||
self._redis = None
|
||||
|
||||
def connect(self):
|
||||
"""
|
||||
Initializes the Redis connection pool (Sync).
|
||||
"""
|
||||
if settings.REDIS_ENABLED:
|
||||
try:
|
||||
import redis
|
||||
self._redis = redis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
max_connections=settings.REDIS_MAX_CONNECTIONS,
|
||||
socket_timeout=settings.REDIS_TIMEOUT
|
||||
)
|
||||
if self._redis.ping():
|
||||
logger.info("Connected to Redis (Sync)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Redis (Sync): {e}")
|
||||
self._redis = None
|
||||
|
||||
def close(self):
|
||||
if self._redis:
|
||||
self._redis.close()
|
||||
logger.info("Redis connection closed (Sync)")
|
||||
|
||||
def rpush(self, key: str, *values) -> int:
|
||||
if not self._redis:
|
||||
try:
|
||||
self.connect()
|
||||
except Exception:
|
||||
pass
|
||||
if not self._redis:
|
||||
return 0
|
||||
try:
|
||||
return self._redis.rpush(key, *values)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis RPUSH error: {e}")
|
||||
return 0
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
return self._redis
|
||||
|
||||
redis_client = RedisClient()
|
||||
sync_redis_client = SyncRedisClient()
|
||||
@@ -0,0 +1 @@
|
||||
from .helpers import get_client_ip
|
||||
@@ -0,0 +1,7 @@
|
||||
from fastapi import Request
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
@@ -2,9 +2,15 @@ from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from datetime import datetime, timezone
|
||||
from app.config.database import get_db
|
||||
from app.config.security import security
|
||||
from app.models.auth.user_model import User
|
||||
from app.models.auth.access_model import Access
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
from app.services.auth.subscription_entitlement_service import (
|
||||
SubscriptionEntitlementService,
|
||||
)
|
||||
|
||||
security_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -41,6 +47,8 @@ def get_current_user(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
setattr(user, "_saas_db_session", db)
|
||||
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
@@ -48,6 +56,27 @@ def get_current_user(
|
||||
detail="User is inactive"
|
||||
)
|
||||
|
||||
if user.tenant_id is not None:
|
||||
tenant = db.query(Tenant).filter(Tenant.id == user.tenant_id).first()
|
||||
if tenant is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found"
|
||||
)
|
||||
|
||||
today = datetime.now(timezone.utc).date()
|
||||
if tenant.end_date and tenant.end_date <= today and tenant.status != "EXPIRED":
|
||||
tenant.status = "EXPIRED"
|
||||
tenant.is_active = False
|
||||
db.commit()
|
||||
db.refresh(tenant)
|
||||
|
||||
if not tenant.is_active or tenant.status in {"INACTIVE", "EXPIRED"}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Tenant is inactive"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
def require_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
@@ -59,24 +88,21 @@ def require_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
return current_user
|
||||
|
||||
def has_access(user: User, access_code: str) -> bool:
|
||||
if not user.role:
|
||||
db = getattr(user, "_saas_db_session", None)
|
||||
if db is None:
|
||||
return False
|
||||
|
||||
user_access_codes = {ra.access.access_code for ra in user.role.role_accesses}
|
||||
|
||||
|
||||
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
|
||||
db, user
|
||||
)
|
||||
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}
|
||||
|
||||
user_access_codes = SubscriptionEntitlementService.get_effective_access_codes(
|
||||
db, user
|
||||
)
|
||||
if access_code in user_access_codes:
|
||||
return True
|
||||
|
||||
from app.models.auth.access_models import Access
|
||||
|
||||
return True
|
||||
requested_access = db.query(Access).filter(
|
||||
Access.access_code == access_code
|
||||
).first()
|
||||
@@ -93,10 +119,11 @@ def can_access(user: User, access_code: str, db: Session) -> bool:
|
||||
return False
|
||||
|
||||
def get_user_accesses(user: User) -> List[str]:
|
||||
if not user.role:
|
||||
db = getattr(user, "_saas_db_session", None)
|
||||
if db is None:
|
||||
return []
|
||||
|
||||
return [ra.access.access_code for ra in user.role.role_accesses]
|
||||
|
||||
return sorted(SubscriptionEntitlementService.get_effective_access_codes(db, user))
|
||||
|
||||
def require_access(access_code: str):
|
||||
def check_permission(current_user: User = Depends(get_current_user)) -> bool:
|
||||
@@ -121,4 +148,4 @@ def require_access_hierarchical(access_code: str):
|
||||
)
|
||||
return True
|
||||
|
||||
return check_permission
|
||||
return check_permission
|
||||
|
||||
@@ -2,4 +2,9 @@ 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
|
||||
from app.models.auth.user_model import User
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
from app.models.auth.role_module_access_model import RoleModuleAccess
|
||||
from app.models.auth.subscription_plan_model import SubscriptionPlan
|
||||
from app.models.auth.plan_access_model import PlanAccess
|
||||
from app.models.auth.plan_module_access_model import PlanModuleAccess
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, DateTime, func, ForeignKey
|
||||
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Index, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
@@ -12,6 +12,10 @@ class Access(Base):
|
||||
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)
|
||||
|
||||
|
||||
sync_checksum = Column(String, nullable=True)
|
||||
last_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, DateTime, func, ForeignKey, Index, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class ModuleAccess(Base):
|
||||
__tablename__ = "module_accesses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
|
||||
access_code = Column(String, nullable=False, index=True)
|
||||
|
||||
category = Column(String, nullable=False, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
parent_id = Column(UUID(as_uuid=True), ForeignKey('module_accesses.id'), nullable=True, index=True)
|
||||
|
||||
sync_checksum = Column(String, nullable=True)
|
||||
last_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
module = relationship("Module", back_populates="module_accesses")
|
||||
role_module_accesses = relationship("RoleModuleAccess", back_populates="module_access")
|
||||
parent = relationship("ModuleAccess", remote_side=[id], backref="children")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('module_id', 'access_code', name='uq_module_access_code'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ModuleAccess module={self.module_id} code={self.access_code}>"
|
||||
@@ -0,0 +1,37 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey, UniqueConstraint, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class ModuleEnvironment(Base):
|
||||
__tablename__ = "module_environments"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
|
||||
slug = Column(String, nullable=False, index=True)
|
||||
|
||||
frontend_base_url = Column(String, nullable=False)
|
||||
sso_entry_path = Column(String, default="/sso/start")
|
||||
|
||||
backend_base_url = Column(String, nullable=False)
|
||||
sso_exchange_endpoint = Column(String, default="/internal/sso/exchange")
|
||||
permission_sync_endpoint = Column(String, default="/internal/permissions/sync")
|
||||
provisioning_endpoint = Column(String, default="/internal/tenants/provision")
|
||||
|
||||
trust_type = Column(String, nullable=False)
|
||||
trust_credentials = Column(JSON, nullable=False)
|
||||
|
||||
is_default = Column(Boolean, default=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
module = relationship("Module", back_populates="environments")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('module_id', 'slug', name='uq_module_env_slug'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ModuleEnvironment {self.slug} for {self.module_id}>"
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Integer, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class Module(Base):
|
||||
__tablename__ = "modules"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
module_id = Column(String, unique=True, nullable=False, index=True)
|
||||
module_name = Column(String, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
status = Column(String, default="active")
|
||||
icon_url = Column(String, nullable=True)
|
||||
display_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
environments = relationship("ModuleEnvironment", back_populates="module", cascade="all, delete-orphan")
|
||||
tenant_modules = relationship("TenantModule", back_populates="module", cascade="all, delete-orphan")
|
||||
module_accesses = relationship("ModuleAccess", back_populates="module", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Module {self.module_id}>"
|
||||
@@ -0,0 +1,24 @@
|
||||
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 PlanAccess(Base):
|
||||
__tablename__ = "plan_accesses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
access_id = Column(UUID(as_uuid=True), ForeignKey("accesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
plan = relationship("SubscriptionPlan", back_populates="plan_accesses")
|
||||
access = relationship("Access")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('plan_id', 'access_id', name='uq_plan_access'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PlanAccess plan={self.plan_id} access={self.access_id}>"
|
||||
@@ -0,0 +1,24 @@
|
||||
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 PlanModuleAccess(Base):
|
||||
__tablename__ = "plan_module_accesses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
module_access_id = Column(UUID(as_uuid=True), ForeignKey("module_accesses.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
plan = relationship("SubscriptionPlan", back_populates="plan_module_accesses")
|
||||
module_access = relationship("ModuleAccess")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('plan_id', 'module_access_id', name='uq_plan_module_access'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PlanModuleAccess plan={self.plan_id} module_access={self.module_access_id}>"
|
||||
@@ -4,7 +4,6 @@ from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
@@ -20,12 +19,14 @@ class Role(Base):
|
||||
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"
|
||||
)
|
||||
role_module_accesses = relationship(
|
||||
"RoleModuleAccess", back_populates="role", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role {self.role_name}>"
|
||||
return f"<Role {self.role_name}>"
|
||||
@@ -0,0 +1,24 @@
|
||||
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 RoleModuleAccess(Base):
|
||||
__tablename__ = "role_module_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)
|
||||
module_access_id = Column(UUID(as_uuid=True), ForeignKey("module_accesses.id"), nullable=False, index=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
role = relationship("Role", back_populates="role_module_accesses")
|
||||
module_access = relationship("ModuleAccess", back_populates="role_module_accesses")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('role_id', 'module_access_id', name='uq_role_module_access'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RoleModuleAccess role={self.role_id} access={self.module_access_id}>"
|
||||
@@ -0,0 +1,31 @@
|
||||
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 SSOGrant(Base):
|
||||
__tablename__ = "sso_grants"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
grant_code = Column(String, unique=True, nullable=False, index=True)
|
||||
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
|
||||
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True)
|
||||
|
||||
environment_slug = Column(String, nullable=False)
|
||||
# redirect_url removed - stateless grants
|
||||
|
||||
is_used = Column(Boolean, default=False)
|
||||
used_at = Column(DateTime(timezone=True), nullable=True)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False) # 60 seconds
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User")
|
||||
module = relationship("Module")
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SSOGrant {self.grant_code} for user {self.user_id}>"
|
||||
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, Integer, func, Numeric
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class SubscriptionPlan(Base):
|
||||
__tablename__ = "subscription_plans"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
name = Column(String, unique=True, nullable=False, index=True)
|
||||
description = Column(String, nullable=True)
|
||||
price = Column(Numeric(10, 2), nullable=True)
|
||||
duration_days = Column(Integer, nullable=True)
|
||||
max_users_allowed = Column(Integer, nullable=True)
|
||||
is_public = Column(Boolean, default=True)
|
||||
status = Column(String, default="active")
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenants = relationship("Tenant", back_populates="plan")
|
||||
plan_accesses = relationship("PlanAccess", back_populates="plan", cascade="all, delete-orphan")
|
||||
plan_module_accesses = relationship("PlanModuleAccess", back_populates="plan", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SubscriptionPlan {self.name}>"
|
||||
@@ -1,10 +1,9 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey
|
||||
from sqlalchemy import Column, String, Boolean, Date, 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"
|
||||
|
||||
@@ -13,6 +12,10 @@ class Tenant(Base):
|
||||
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)
|
||||
plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id"), nullable=True, index=True)
|
||||
start_date = Column(Date, nullable=True)
|
||||
end_date = Column(Date, nullable=True)
|
||||
status = Column(String, nullable=False, default="ACTIVE", index=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(
|
||||
@@ -20,8 +23,14 @@ class Tenant(Base):
|
||||
)
|
||||
|
||||
# Relationships
|
||||
users = relationship("User", back_populates="tenant")
|
||||
roles = relationship("Role", back_populates="tenant")
|
||||
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
|
||||
roles = relationship("Role", back_populates="tenant", cascade="all, delete-orphan")
|
||||
tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan")
|
||||
plan = relationship("SubscriptionPlan", back_populates="tenants")
|
||||
|
||||
@property
|
||||
def tenant_id(self):
|
||||
return self.id
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tenant {self.tenant_name}>"
|
||||
return f"<Tenant {self.tenant_name}>"
|
||||
@@ -0,0 +1,33 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey, UniqueConstraint, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.config.database import Base
|
||||
|
||||
class TenantModule(Base):
|
||||
__tablename__ = "tenant_modules"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False, index=True)
|
||||
module_id = Column(UUID(as_uuid=True), ForeignKey("modules.id"), nullable=False, index=True)
|
||||
|
||||
assigned_environment_slug = Column(String, nullable=True)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
activated_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
deactivated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
plan_tier = Column(String, nullable=True)
|
||||
module_config = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
tenant = relationship("Tenant", back_populates="tenant_modules")
|
||||
module = relationship("Module", back_populates="tenant_modules")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('tenant_id', 'module_id', name='uq_tenant_module'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TenantModule tenant={self.tenant_id} module={self.module_id}>"
|
||||
@@ -0,0 +1,38 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, DateTime, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.sql import func
|
||||
from app.config.database import Base
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
|
||||
module_name = Column(String(100), nullable=False, index=True)
|
||||
|
||||
action_type = Column(String(20), nullable=False, index=True)
|
||||
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
entity_id = Column(String(255), nullable=True)
|
||||
|
||||
entity_name = Column(String(255), nullable=True)
|
||||
|
||||
performed_by_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
performed_by_email = Column(String(255), nullable=True, index=True)
|
||||
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
|
||||
old_values = Column(JSONB, nullable=True)
|
||||
|
||||
new_values = Column(JSONB, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<AuditLog {self.action_type} on {self.module_name}"
|
||||
f" by {self.performed_by_email}"
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, DateTime, func, Text, Integer, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.config.database import Base
|
||||
import enum
|
||||
|
||||
class EventStatus(str, enum.Enum):
|
||||
PENDING = "PENDING"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
|
||||
class EventLog(Base):
|
||||
__tablename__ = "event_logs"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
event_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Idempotency Key
|
||||
event_type = Column(String, nullable=False)
|
||||
payload = Column(JSONB, nullable=False)
|
||||
|
||||
target_module_id = Column(UUID(as_uuid=True), nullable=False)
|
||||
target_environment_slug = Column(String, nullable=False)
|
||||
target_url = Column(String, nullable=False)
|
||||
|
||||
status = Column(String, default=EventStatus.PENDING, index=True)
|
||||
retry_count = Column(Integer, default=0)
|
||||
next_retry_at = Column(DateTime(timezone=True), default=func.now(), index=True)
|
||||
error_log = Column(Text, nullable=True)
|
||||
follow_up_event = Column(JSONB, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EventLog {self.event_type} -> {self.target_module_id} ({self.status})>"
|
||||
@@ -0,0 +1,85 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import asc, desc, or_, cast, String
|
||||
|
||||
from app.config.database import get_db
|
||||
from app.middleware.auth_middleware import get_current_user, User
|
||||
from app.models.system.audit_log import AuditLog
|
||||
from app.schemas.auth.audit_schema import AuditLogListResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=AuditLogListResponse)
|
||||
def get_audit_logs(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
module_name: Optional[str] = Query(None, description="Filter by module name"),
|
||||
action_type: Optional[str] = Query(None, description="CREATE | UPDATE | DELETE"),
|
||||
performed_by_email: Optional[str] = Query(None, description="Filter by actor email"),
|
||||
search: Optional[str] = Query(None, description="Search audit logs"),
|
||||
module_names: Optional[List[str]] = Query(None, description="Filter by module names"),
|
||||
action_types: Optional[List[str]] = Query(None, description="Filter by action types"),
|
||||
performed_by_emails: Optional[List[str]] = Query(None, description="Filter by actor emails"),
|
||||
sort_by: Optional[str] = Query("created_at", description="Sort by created_at, module_name, action_type, or performed_by_email"),
|
||||
sort_order: Optional[str] = Query("desc", description="Sort order: asc or desc"),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""
|
||||
Fetch audit logs with optional filters.
|
||||
Only accessible to authenticated users (super-admin in practice).
|
||||
"""
|
||||
query = db.query(AuditLog)
|
||||
|
||||
if module_name:
|
||||
query = query.filter(AuditLog.module_name == module_name)
|
||||
if module_names:
|
||||
query = query.filter(AuditLog.module_name.in_(module_names))
|
||||
if action_type:
|
||||
query = query.filter(AuditLog.action_type == action_type.upper())
|
||||
if action_types:
|
||||
normalized_action_types = [item.upper() for item in action_types]
|
||||
query = query.filter(AuditLog.action_type.in_(normalized_action_types))
|
||||
if performed_by_email:
|
||||
query = query.filter(
|
||||
AuditLog.performed_by_email.ilike(f"%{performed_by_email}%")
|
||||
)
|
||||
if performed_by_emails:
|
||||
query = query.filter(AuditLog.performed_by_email.in_(performed_by_emails))
|
||||
if search:
|
||||
search_term = search.strip()
|
||||
query = query.filter(
|
||||
or_(
|
||||
AuditLog.module_name.ilike(f"%{search_term}%"),
|
||||
AuditLog.action_type.ilike(f"%{search_term}%"),
|
||||
AuditLog.entity_name.ilike(f"%{search_term}%"),
|
||||
AuditLog.description.ilike(f"%{search_term}%"),
|
||||
AuditLog.performed_by_email.ilike(f"%{search_term}%"),
|
||||
AuditLog.ip_address.ilike(f"%{search_term}%"),
|
||||
cast(AuditLog.entity_id, String).ilike(f"%{search_term}%"),
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
sort_column_map = {
|
||||
"created_at": AuditLog.created_at,
|
||||
"module_name": AuditLog.module_name,
|
||||
"action_type": AuditLog.action_type,
|
||||
"performed_by_email": AuditLog.performed_by_email,
|
||||
}
|
||||
sort_column = sort_column_map.get(sort_by or "created_at", AuditLog.created_at)
|
||||
order_fn = asc if (sort_order or "").lower() == "asc" else desc
|
||||
items = (
|
||||
query.order_by(order_fn(sort_column))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return AuditLogListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
import uuid as uuid_module
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config.database import get_db
|
||||
from app.middleware.auth_middleware import get_current_user, require_access, User
|
||||
from app.controllers.auth.module_environment_controller import ModuleEnvironmentController
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentResponse, EnvironmentUpdate
|
||||
from app.services.auth.module_service import ModuleService
|
||||
from app.services.system.audit_log_service import AuditLogService
|
||||
from app.helper.helpers import get_client_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _get_env_snapshot(db: Session, module_id: str, env_id: str) -> dict:
|
||||
"""Helper to capture state before changes for the Audit Log."""
|
||||
try:
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.id == uuid_module.UUID(env_id),
|
||||
ModuleEnvironment.module_id == uuid_module.UUID(module_id)
|
||||
).first()
|
||||
if not env: return {}
|
||||
return {
|
||||
"slug": env.slug,
|
||||
"backend_base_url": env.backend_base_url,
|
||||
"is_default": env.is_default,
|
||||
"is_active": env.is_active
|
||||
}
|
||||
except: return {}
|
||||
|
||||
@router.get("/{module_id}/environments", response_model=List[EnvironmentResponse])
|
||||
def list_environments(
|
||||
module_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("modules.view")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return ModuleEnvironmentController.list_environments(db, module_id)
|
||||
|
||||
@router.post("/{module_id}/environments", response_model=EnvironmentResponse, status_code=201)
|
||||
def create_environment(
|
||||
request: Request,
|
||||
module_id: str,
|
||||
env_data: EnvironmentCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("modules.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
module = ModuleService.get_module(db, module_id)
|
||||
result = ModuleEnvironmentController.create_environment(db, module_id, env_data)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Module Environments",
|
||||
action_type="CREATE",
|
||||
entity_id=str(result.id),
|
||||
entity_name=f"{module.module_name} / {result.slug}",
|
||||
description=f"Environment '{result.slug}' created for '{module.module_name}'",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
new_values=env_data.model_dump(exclude={"trust_credentials"})
|
||||
)
|
||||
return result
|
||||
|
||||
@router.put("/{module_id}/environments/{env_id}", response_model=EnvironmentResponse)
|
||||
def update_environment(
|
||||
request: Request,
|
||||
module_id: str,
|
||||
env_id: str,
|
||||
env_data: EnvironmentUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("modules.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
module = ModuleService.get_module(db, module_id)
|
||||
old_values = _get_env_snapshot(db, module_id, env_id)
|
||||
|
||||
result = ModuleEnvironmentController.update_environment(db, module_id, env_id, env_data)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Module Environments",
|
||||
action_type="UPDATE",
|
||||
entity_id=env_id,
|
||||
entity_name=f"{module.module_name} / {result.slug}",
|
||||
description=f"Environment '{result.slug}' updated",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
old_values=old_values,
|
||||
new_values=env_data.model_dump(exclude_unset=True, exclude={"trust_credentials"})
|
||||
)
|
||||
return result
|
||||
|
||||
@router.patch("/{module_id}/environments/{env_id}/default")
|
||||
def set_default_environment(
|
||||
request: Request,
|
||||
module_id: str,
|
||||
env_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("modules.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
module = ModuleService.get_module(db, module_id)
|
||||
result = ModuleEnvironmentController.set_default_environment(db, module_id, env_id)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Module Environments",
|
||||
action_type="UPDATE",
|
||||
entity_id=env_id,
|
||||
entity_name=f"{module.module_name}",
|
||||
description=f"Set default environment for {module.module_name}",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
new_values={"is_default": True}
|
||||
)
|
||||
return result
|
||||
|
||||
@router.delete("/{module_id}/environments/{env_id}")
|
||||
def delete_environment(
|
||||
request: Request,
|
||||
module_id: str,
|
||||
env_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("modules.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
module = ModuleService.get_module(db, module_id)
|
||||
snapshot = _get_env_snapshot(db, module_id, env_id)
|
||||
|
||||
result = ModuleEnvironmentController.delete_environment(db, module_id, env_id)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Module Environments",
|
||||
action_type="DELETE",
|
||||
entity_id=env_id,
|
||||
entity_name=f"{module.module_name} / {snapshot.get('slug')}",
|
||||
description=f"Environment deleted from {module.module_name}",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
old_values=snapshot
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,144 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.access_model import Access
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
import uuid
|
||||
from app.config.database import get_db
|
||||
from app.services.auth.module_permission_service import ModulePermissionService
|
||||
from app.middleware.auth_middleware import get_current_user, require_access, User
|
||||
from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate, ModuleResponse
|
||||
from app.controllers.auth.module_controller import ModuleController
|
||||
from app.services.system.audit_log_service import AuditLogService
|
||||
from app.helper.helpers import get_client_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=List[ModuleResponse])
|
||||
def list_modules(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("modules.view")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return ModuleController.list_modules(db)
|
||||
|
||||
@router.post("/", response_model=ModuleResponse, status_code=201)
|
||||
def create_module(
|
||||
request: Request,
|
||||
module_data: ModuleCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("modules.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
result = ModuleController.create_module(db, module_data)
|
||||
|
||||
# Professional Audit Logging
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Modules",
|
||||
action_type="CREATE",
|
||||
entity_id=str(result.id),
|
||||
entity_name=result.module_name,
|
||||
description=f"Module '{result.module_name}' created",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
new_values=module_data.model_dump()
|
||||
)
|
||||
return result
|
||||
|
||||
@router.get("/{module_id}", response_model=ModuleResponse)
|
||||
def get_module(
|
||||
module_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("modules.view")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return ModuleController.get_module(db, module_id)
|
||||
|
||||
@router.put("/{module_id}", response_model=ModuleResponse)
|
||||
def update_module(
|
||||
request: Request,
|
||||
module_id: str,
|
||||
module_data: ModuleUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("modules.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Snapshot before update
|
||||
existing = ModuleController.get_module(db, module_id)
|
||||
old_values = {"name": existing.module_name, "status": existing.status}
|
||||
|
||||
result = ModuleController.update_module(db, module_id, module_data)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Modules",
|
||||
action_type="UPDATE",
|
||||
entity_id=module_id,
|
||||
entity_name=result.module_name,
|
||||
description=f"Module '{result.module_name}' updated",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
old_values=old_values,
|
||||
new_values=module_data.model_dump(exclude_unset=True)
|
||||
)
|
||||
return result
|
||||
|
||||
@router.delete("/{module_id}")
|
||||
def delete_module(
|
||||
request: Request,
|
||||
module_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("modules.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Capture name for the log before it's deleted
|
||||
existing = ModuleController.get_module(db, module_id)
|
||||
module_name = existing.module_name
|
||||
|
||||
result = ModuleController.delete_module(db, module_id)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Modules",
|
||||
action_type="DELETE",
|
||||
entity_id=module_id,
|
||||
entity_name=module_name,
|
||||
description=f"Module '{module_name}' deleted",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request)
|
||||
)
|
||||
return result
|
||||
|
||||
# ── Permission Sync Routes ──────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{module_id}/permissions")
|
||||
def get_module_permissions(
|
||||
module_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("modules.view")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Now using the Service method you just shared!
|
||||
return ModulePermissionService.get_module_permissions(db, module_id)
|
||||
|
||||
@router.post("/{module_id}/permissions/sync")
|
||||
def sync_module_permissions(
|
||||
module_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("modules.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
return ModulePermissionService.sync_permissions(db, module_id)
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Permission sync failed: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
from fastapi import APIRouter, Depends, status, HTTPException, Request, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.config.database import get_db
|
||||
from app.middleware.auth_middleware import get_current_user, require_access, User
|
||||
from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate, TenantModuleResponse
|
||||
from app.controllers.auth.tenant_module_controller import TenantModuleController
|
||||
from app.services.system.audit_log_service import AuditLogService
|
||||
from app.helper.helpers import get_client_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/{tenant_id}/modules", response_model=List[TenantModuleResponse])
|
||||
def list_tenant_modules(
|
||||
tenant_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("tenants.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return TenantModuleController.list_tenant_modules(db, tenant_id)
|
||||
|
||||
@router.post("/{tenant_id}/modules", response_model=TenantModuleResponse)
|
||||
def assign_module_to_tenant(
|
||||
request: Request,
|
||||
tenant_id: str,
|
||||
assignment_data: TenantModuleCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("tenants.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
result = TenantModuleController.assign_module(db, tenant_id, assignment_data)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Tenant Modules",
|
||||
action_type="CREATE",
|
||||
entity_id=str(result.id),
|
||||
entity_name=f"Tenant {tenant_id} / Module {assignment_data.module_id}",
|
||||
description=f"Module assigned to tenant {tenant_id}",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
new_values=assignment_data.model_dump(mode='json')
|
||||
)
|
||||
return result
|
||||
|
||||
@router.put("/{tenant_id}/modules/{tenant_module_id}", response_model=TenantModuleResponse)
|
||||
def update_tenant_module(
|
||||
request: Request,
|
||||
tenant_id: str,
|
||||
tenant_module_id: str,
|
||||
update_data: TenantModuleUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("tenants.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
result = TenantModuleController.update_assignment(db, tenant_id, tenant_module_id, update_data)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Tenant Modules",
|
||||
action_type="UPDATE",
|
||||
entity_id=tenant_module_id,
|
||||
entity_name=f"Tenant {tenant_id} / Module assignment",
|
||||
description=f"Tenant module assignment updated",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
new_values=update_data.model_dump(exclude_unset=True)
|
||||
)
|
||||
return result
|
||||
|
||||
@router.delete("/{tenant_id}/modules/{tenant_module_id}")
|
||||
def remove_module_from_tenant(
|
||||
request: Request,
|
||||
tenant_id: str,
|
||||
tenant_module_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
_: bool = Depends(require_access("tenants.manage")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
result = TenantModuleController.remove_assignment(db, tenant_id, tenant_module_id)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Tenant Modules",
|
||||
action_type="DELETE",
|
||||
entity_id=tenant_module_id,
|
||||
entity_name=f"Tenant {tenant_id} / Module assignment",
|
||||
description=f"Tenant module assignment deleted",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.config.database import get_db
|
||||
from app.middleware.auth_middleware import get_current_user, User
|
||||
from app.schemas.auth.module_schema import ModuleAvailableResponse
|
||||
from app.controllers.auth.module_controller import ModuleController
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/available", response_model=List[ModuleAvailableResponse])
|
||||
def get_available_modules(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List modules available to the current user (based on tenant subscription).
|
||||
For platform admin (tenant_id=None), lists all active modules.
|
||||
"""
|
||||
return ModuleController.get_available_modules(db, current_user)
|
||||
@@ -1,3 +1,4 @@
|
||||
from .auth import router
|
||||
from .tenant import router
|
||||
from .role import router
|
||||
from .auth import router as auth_router
|
||||
from .tenant import router as tenant_router
|
||||
from .role import router as role_router
|
||||
from .user import router as user_router
|
||||
+47
-20
@@ -1,6 +1,8 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi import APIRouter, Depends, Request, Response, status, HTTPException
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config.database import get_db
|
||||
from app.config.settings import settings
|
||||
from app.controllers.auth.auth_controller import AuthController
|
||||
from app.schemas.auth.auth_schema import (
|
||||
UserSignup,
|
||||
@@ -14,13 +16,20 @@ from app.schemas.auth.auth_schema import (
|
||||
VerifyOTPRequest,
|
||||
ResetPasswordWithOTP,
|
||||
)
|
||||
from app.middleware.auth_middleware import get_current_user
|
||||
from app.schemas.auth.language_schema import UpdateLanguageRequest
|
||||
from app.middleware.auth_middleware import get_current_user, security_scheme
|
||||
from app.middleware.tenant_middleware import get_tenant_from_header
|
||||
from app.models.auth.user_model import User
|
||||
import uuid
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _cookie_kwargs():
|
||||
kwargs = dict(httponly=True, samesite="lax", secure=settings.COOKIE_SECURE, path="/")
|
||||
if settings.COOKIE_DOMAIN:
|
||||
kwargs["domain"] = settings.COOKIE_DOMAIN
|
||||
return kwargs
|
||||
|
||||
@router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def signup(
|
||||
user_data: UserSignup,
|
||||
@@ -30,12 +39,30 @@ def signup(
|
||||
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)
|
||||
def signin(signin_data: UserSignin, response: Response, db: Session = Depends(get_db)):
|
||||
result = AuthController.signin(db, signin_data)
|
||||
cookie_kw = _cookie_kwargs()
|
||||
access_max_age = settings.ACCESS_TOKEN_EXPIRES if signin_data.remember_me else None
|
||||
refresh_max_age = settings.REFRESH_TOKEN_EXPIRES if signin_data.remember_me else None
|
||||
response.set_cookie(key="access_token", value=result["access_token"], max_age=access_max_age, **cookie_kw)
|
||||
response.set_cookie(key="refresh_token", value=result["refresh_token"], max_age=refresh_max_age, **cookie_kw)
|
||||
return result
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
def refresh_token(token_data: RefreshTokenRequest, db: Session = Depends(get_db)):
|
||||
return AuthController.refresh_token(db, token_data)
|
||||
def refresh_token(
|
||||
token_data: RefreshTokenRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
refresh_tok = token_data.refresh_token or request.cookies.get("refresh_token")
|
||||
if not refresh_tok:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing refresh token")
|
||||
result = AuthController.refresh_token_raw(db, refresh_tok)
|
||||
cookie_kw = _cookie_kwargs()
|
||||
response.set_cookie(key="access_token", value=result["access_token"], **cookie_kw)
|
||||
response.set_cookie(key="refresh_token", value=result["refresh_token"], **cookie_kw)
|
||||
return result
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(
|
||||
@@ -56,21 +83,11 @@ def update_user(
|
||||
@router.patch("/update/{user_id}/language", response_model=UserResponse)
|
||||
def update_language(
|
||||
user_id: uuid.UUID,
|
||||
language_data: dict,
|
||||
language_data: UpdateLanguageRequest,
|
||||
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)
|
||||
user_update = UserUpdate(preferred_language=language_data.preferred_language.value)
|
||||
return AuthController.update_user(db, user_id, user_update, current_user)
|
||||
|
||||
@router.post("/reset-password")
|
||||
@@ -82,8 +99,18 @@ def reset_password(
|
||||
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)
|
||||
def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
token = credentials.credentials if credentials else request.cookies.get("access_token")
|
||||
result = AuthController.logout(current_user, token)
|
||||
cookie_kw = _cookie_kwargs()
|
||||
response.delete_cookie(key="access_token", **cookie_kw)
|
||||
response.delete_cookie(key="refresh_token", **cookie_kw)
|
||||
return result
|
||||
|
||||
@router.post("/forgot-password")
|
||||
def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)):
|
||||
|
||||
+73
-4
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, status, Query
|
||||
from fastapi import APIRouter, Depends, status, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
@@ -13,20 +13,40 @@ from app.schemas.auth.role_schema import (
|
||||
)
|
||||
from app.middleware.auth_middleware import get_current_user, require_access
|
||||
from app.models.auth.user_model import User
|
||||
from app.services.system.audit_log_service import AuditLogService
|
||||
from app.helper.helpers import get_client_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/create", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_role(
|
||||
request: Request,
|
||||
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(
|
||||
result = RoleController.create_role(
|
||||
db, role_data, current_user.tenant_id if current_user.tenant_id else None
|
||||
)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Roles",
|
||||
action_type="CREATE",
|
||||
entity_id=str(result.id),
|
||||
entity_name=result.role_name,
|
||||
description=f"Role '{result.role_name}' created",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
new_values={
|
||||
"role_name": result.role_name,
|
||||
"tenant_id": str(result.tenant_id) if result.tenant_id else None,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@router.get("/get", response_model=List[RoleResponse])
|
||||
def get_all_roles(
|
||||
db: Session = Depends(get_db),
|
||||
@@ -48,32 +68,77 @@ def get_role(
|
||||
|
||||
@router.put("/update/{role_id}", response_model=RoleResponse)
|
||||
def update_role(
|
||||
request: Request,
|
||||
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")),
|
||||
):
|
||||
|
||||
existing = RoleController.get_role_by_id(db, role_id)
|
||||
old_values = {"role_name": existing.role_name}
|
||||
|
||||
is_superadmin = current_user.tenant_id is None
|
||||
return RoleController.update_role(
|
||||
result = RoleController.update_role(
|
||||
db, role_id, role_data, is_superadmin=is_superadmin
|
||||
)
|
||||
|
||||
new_values = role_data.model_dump(mode='json', exclude_unset=True)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Roles",
|
||||
action_type="UPDATE",
|
||||
entity_id=str(role_id),
|
||||
entity_name=result.role_name,
|
||||
description=f"Role '{result.role_name}' updated",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
old_values=old_values,
|
||||
new_values=new_values
|
||||
)
|
||||
return result
|
||||
|
||||
@router.delete("/delete/{role_id}")
|
||||
def delete_role(
|
||||
request: Request,
|
||||
role_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(require_access("admin.role.delete")),
|
||||
):
|
||||
|
||||
existing = RoleController.get_role_by_id(db, role_id)
|
||||
role_name = existing.role_name
|
||||
|
||||
is_superadmin = current_user.tenant_id is None
|
||||
return RoleController.delete_role(db, role_id, is_superadmin=is_superadmin)
|
||||
result = RoleController.delete_role(db, role_id, is_superadmin=is_superadmin)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Roles",
|
||||
action_type="DELETE",
|
||||
entity_id=str(role_id),
|
||||
entity_name=role_name,
|
||||
description=f"Role '{result.role_name}' deleted",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
old_values={"role_name": role_name}
|
||||
)
|
||||
return result
|
||||
|
||||
@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"),
|
||||
filter_role_names: Optional[List[str]] = Query(None, description="Filter by role names"),
|
||||
filter_tenant_ids: Optional[List[uuid.UUID]] = Query(None, description="Filter by tenant IDs"),
|
||||
sort_by: Optional[str] = Query(None, description="Sort by role name or tenant"),
|
||||
sort_order: Optional[str] = Query(None, description="Sort order: asc or desc"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_=Depends(require_access("admin.role.read")),
|
||||
@@ -84,4 +149,8 @@ def list_roles(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
filter_role_names=filter_role_names,
|
||||
filter_tenant_ids=filter_tenant_ids,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
from fastapi import APIRouter, Depends, Header, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.config.database import get_db
|
||||
from app.middleware.auth_middleware import get_current_user, User
|
||||
from app.schemas.auth.sso_schema import SSOInitiateRequest, SSOExchangeRequest
|
||||
from app.controllers.auth.sso_controller import SSOController
|
||||
|
||||
public_router = APIRouter()
|
||||
internal_router = APIRouter()
|
||||
|
||||
@public_router.post("/initiate")
|
||||
def initiate_sso(
|
||||
request: SSOInitiateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
User-facing endpoint to start SSO flow.
|
||||
Returns a signed payload and target URL for the client to POST.
|
||||
"""
|
||||
return SSOController.initiate_sso(db, request, current_user)
|
||||
|
||||
@internal_router.post("/exchange")
|
||||
def exchange_grant(
|
||||
request: Request,
|
||||
payload: SSOExchangeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
x_module_signature: Optional[str] = Header(None, alias="X-Module-Signature"),
|
||||
x_module_key: Optional[str] = Header(None, alias="X-Module-Key")
|
||||
):
|
||||
"""
|
||||
Internal server-to-server endpoint for modules to exchange grant code for token.
|
||||
Must be signed or authenticated via trust credentials.
|
||||
"""
|
||||
return SSOController.exchange_grant(
|
||||
db=db,
|
||||
payload=payload,
|
||||
x_module_signature=x_module_signature,
|
||||
x_module_key=x_module_key
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, status, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config.database import get_db
|
||||
from app.controllers.auth.subscription_plan_controller import SubscriptionPlanController
|
||||
from app.schemas.auth.subscription_plan_schema import (
|
||||
SubscriptionPlanCreate,
|
||||
SubscriptionPlanUpdate,
|
||||
SubscriptionPlanResponse,
|
||||
SubscriptionPlanDetailResponse,
|
||||
SubscriptionPlanPaginatedResponse
|
||||
)
|
||||
from app.middleware.auth_middleware import get_current_user, require_access, User
|
||||
from app.services.system.audit_log_service import AuditLogService
|
||||
from app.helper.helpers import get_client_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/create", response_model=SubscriptionPlanResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_plan(
|
||||
request: Request,
|
||||
plan_data: SubscriptionPlanCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.plan.create"))
|
||||
):
|
||||
result = SubscriptionPlanController.create_plan(db, plan_data)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="SubscriptionPlans",
|
||||
action_type="CREATE",
|
||||
entity_id=str(result.id),
|
||||
entity_name=result.name,
|
||||
description=f"Plan '{result.name}' created",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
new_values=plan_data.model_dump(mode='json')
|
||||
)
|
||||
return result
|
||||
|
||||
@router.put("/update/{plan_id}", response_model=SubscriptionPlanResponse)
|
||||
def update_plan(
|
||||
request: Request,
|
||||
plan_id: uuid.UUID,
|
||||
plan_data: SubscriptionPlanUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.plan.update"))
|
||||
):
|
||||
result = SubscriptionPlanController.update_plan(db, plan_id, plan_data)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="SubscriptionPlans",
|
||||
action_type="UPDATE",
|
||||
entity_id=str(plan_id),
|
||||
entity_name=result.name,
|
||||
description=f"Plan '{result.name}' updated",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
new_values=plan_data.model_dump(mode='json')
|
||||
)
|
||||
return result
|
||||
|
||||
@router.get("/get/{plan_id}", response_model=SubscriptionPlanDetailResponse)
|
||||
def get_plan(
|
||||
plan_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.plan.read"))
|
||||
):
|
||||
plan = SubscriptionPlanController.get_plan(db, plan_id)
|
||||
return SubscriptionPlanDetailResponse(
|
||||
id=plan.id,
|
||||
name=plan.name,
|
||||
description=plan.description,
|
||||
price=float(plan.price) if plan.price is not None else None,
|
||||
duration_days=plan.duration_days,
|
||||
max_users_allowed=plan.max_users_allowed,
|
||||
is_public=plan.is_public,
|
||||
status=plan.status,
|
||||
created_at=plan.created_at,
|
||||
updated_at=plan.updated_at,
|
||||
access_ids=[pa.access_id for pa in plan.plan_accesses],
|
||||
module_access_ids=[pma.module_access_id for pma in plan.plan_module_accesses]
|
||||
)
|
||||
|
||||
@router.get("/list", response_model=SubscriptionPlanPaginatedResponse)
|
||||
def list_plans(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
search: Optional[str] = Query(None),
|
||||
filter_names: Optional[List[str]] = Query(None),
|
||||
statuses: Optional[List[str]] = Query(None),
|
||||
visibility: Optional[List[bool]] = Query(None, description="Filter by public/private visibility"),
|
||||
sort_by: Optional[str] = Query(None, description="Sort by name, status, visibility, or price"),
|
||||
sort_order: Optional[str] = Query(None, description="Sort order: asc or desc"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.plan.read"))
|
||||
):
|
||||
return SubscriptionPlanController.get_paginated_plans(
|
||||
db,
|
||||
page,
|
||||
page_size,
|
||||
search,
|
||||
filter_names=filter_names,
|
||||
statuses=statuses,
|
||||
visibility=visibility,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
@router.get("/all", response_model=List[SubscriptionPlanResponse])
|
||||
def all_plans(
|
||||
is_public: Optional[bool] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.plan.read"))
|
||||
):
|
||||
return SubscriptionPlanController.get_all_plans(db, is_public, status)
|
||||
|
||||
@router.delete("/delete/{plan_id}")
|
||||
def delete_plan(
|
||||
request: Request,
|
||||
plan_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.plan.delete"))
|
||||
):
|
||||
result = SubscriptionPlanController.delete_plan(db, plan_id)
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="SubscriptionPlans",
|
||||
action_type="DELETE",
|
||||
entity_id=str(plan_id),
|
||||
entity_name=str(plan_id),
|
||||
description=f"Plan '{plan_id}' deleted",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request)
|
||||
)
|
||||
return result
|
||||
+96
-21
@@ -1,24 +1,41 @@
|
||||
from fastapi import APIRouter, Depends, status, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, status, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.auth_middleware import get_current_user, require_access, User
|
||||
from app.middleware.tenant_middleware import get_tenant_id_from_user
|
||||
from app.models.auth.user_model import User
|
||||
from app.services.system.audit_log_service import AuditLogService
|
||||
from app.helper.helpers import get_client_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/create", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_tenant(
|
||||
request: Request,
|
||||
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)
|
||||
result = TenantController.create_tenant(db, tenant_data)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Tenants",
|
||||
action_type="CREATE",
|
||||
entity_id=str(result.id),
|
||||
entity_name=result.tenant_name,
|
||||
description=f"Tenant '{result.tenant_name}' onboarded",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
new_values=tenant_data.model_dump(mode='json')
|
||||
)
|
||||
return result
|
||||
|
||||
@router.get("/get", response_model=List[TenantResponse])
|
||||
def get_all_tenants(
|
||||
@@ -26,17 +43,9 @@ def get_all_tenants(
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.tenant.read"))
|
||||
):
|
||||
# READ actions are typically not logged to avoid DB bloat
|
||||
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),
|
||||
@@ -44,30 +53,84 @@ def get_my_tenant(
|
||||
):
|
||||
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"
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
return TenantController.get_tenant_by_id(db, tenant_id)
|
||||
|
||||
@router.put("/update/{tenant_id}", response_model=TenantResponse)
|
||||
def update_tenant(
|
||||
request: Request,
|
||||
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)
|
||||
# 1. Get snapshot BEFORE update for Audit Log
|
||||
existing = TenantController.get_tenant_by_id(db, tenant_id)
|
||||
old_snapshot = {
|
||||
"tenant_name": existing.tenant_name,
|
||||
"tenant_domain": existing.tenant_domain,
|
||||
"tenant_logo_url": existing.tenant_logo_url,
|
||||
"is_active": existing.is_active,
|
||||
"plan_id": str(existing.plan_id) if existing.plan_id else None,
|
||||
"start_date": existing.start_date.isoformat() if existing.start_date else None,
|
||||
"end_date": existing.end_date.isoformat() if existing.end_date else None,
|
||||
"status": existing.status,
|
||||
}
|
||||
|
||||
# 2. Perform update
|
||||
result = TenantController.update_tenant(db, tenant_id, tenant_data)
|
||||
|
||||
# 3. Prepare new values and indentify deltas
|
||||
new_snapshot = tenant_data.model_dump(mode='json', exclude_unset=True)
|
||||
|
||||
old_values = {k: old_snapshot[k] for k in new_snapshot if k in old_snapshot and old_snapshot[k] != new_snapshot[k]}
|
||||
new_values = {k: new_snapshot[k] for k in old_values}
|
||||
|
||||
# 4. Log the change
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Tenants",
|
||||
action_type="UPDATE",
|
||||
entity_id=str(tenant_id),
|
||||
entity_name=result.tenant_name,
|
||||
description=f"Tenant '{result.tenant_name}' updated",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
old_values=old_values,
|
||||
new_values=new_values
|
||||
)
|
||||
return result
|
||||
|
||||
@router.delete("/delete/{tenant_id}")
|
||||
def delete_tenant(
|
||||
request: Request,
|
||||
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)
|
||||
# 1. Get snapshot before deletion
|
||||
existing = TenantController.get_tenant_by_id(db, tenant_id)
|
||||
|
||||
# 2. Perform deletion
|
||||
result = TenantController.delete_tenant(db, tenant_id)
|
||||
|
||||
# 3. Log the deletion
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Tenants",
|
||||
action_type="DELETE",
|
||||
entity_id=str(tenant_id),
|
||||
entity_name=existing.tenant_name,
|
||||
description=f"Tenant '{existing.tenant_name}' removed",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
old_values={"tenant_name": existing.tenant_name, "domain": existing.tenant_domain}
|
||||
)
|
||||
return result
|
||||
|
||||
@router.get("/list", response_model=TenantPaginatedResponse)
|
||||
def list_tenants(
|
||||
@@ -75,6 +138,12 @@ def list_tenants(
|
||||
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"),
|
||||
filter_tenant_names: Optional[List[str]] = Query(None, description="Filter by tenant names"),
|
||||
filter_tenant_domains: Optional[List[str]] = Query(None, description="Filter by tenant domains"),
|
||||
filter_plan_ids: Optional[List[uuid.UUID]] = Query(None, description="Filter by plan IDs"),
|
||||
statuses: Optional[List[bool]] = Query(None, description="Filter by tenant statuses"),
|
||||
sort_by: Optional[str] = Query(None, description="Sort by name, domain, status, or plan"),
|
||||
sort_order: Optional[str] = Query(None, description="Sort order: asc or desc"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.tenant.read")),
|
||||
@@ -85,4 +154,10 @@ def list_tenants(
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
is_active=is_active,
|
||||
filter_tenant_names=filter_tenant_names,
|
||||
filter_tenant_domains=filter_tenant_domains,
|
||||
filter_plan_ids=filter_plan_ids,
|
||||
statuses=statuses,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
+81
-8
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, status, Query
|
||||
from fastapi import APIRouter, Depends, status, Query, BackgroundTasks, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
@@ -7,17 +7,38 @@ 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
|
||||
from app.services.system.audit_log_service import AuditLogService
|
||||
from app.helper.helpers import get_client_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/create", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
request: Request,
|
||||
user_data: UserCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
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)
|
||||
result = UserController.create_user(db, user_data, current_user, background_tasks)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Users",
|
||||
action_type="CREATE",
|
||||
entity_id=str(result.id),
|
||||
entity_name=result.email,
|
||||
description=f"User '{result.email}' created",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
new_values={
|
||||
"email": result.email,
|
||||
"tenant_id": str(result.tenant_id) if result.tenant_id else None,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@router.get("/get", response_model=List[UserResponse])
|
||||
def get_all_users(
|
||||
@@ -38,29 +59,75 @@ def get_user(
|
||||
|
||||
@router.put("/update/{user_id}", response_model=UserResponse)
|
||||
def update_user(
|
||||
request: Request,
|
||||
user_id: uuid.UUID,
|
||||
user_data: UserUpdate,
|
||||
background_tasks: BackgroundTasks,
|
||||
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)
|
||||
|
||||
existing = UserController.get_user_by_id(db, user_id, current_user)
|
||||
old_values = {"email": existing.email}
|
||||
|
||||
result = UserController.update_user(db, user_id, user_data, current_user, background_tasks)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Users",
|
||||
action_type="UPDATE",
|
||||
entity_id=str(user_id),
|
||||
entity_name=result.email,
|
||||
description=f"User '{result.email}' updated",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
old_values=old_values,
|
||||
new_values=user_data.model_dump(mode='json', exclude_unset=True)
|
||||
)
|
||||
return result
|
||||
|
||||
@router.delete("/delete/{user_id}")
|
||||
def delete_user(
|
||||
request: Request,
|
||||
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)
|
||||
|
||||
existing = UserController.get_user_by_id(db, user_id, current_user)
|
||||
user_email = existing.email
|
||||
|
||||
result = UserController.delete_user(db, user_id, current_user)
|
||||
|
||||
AuditLogService.log(
|
||||
db=db,
|
||||
module_name="Users",
|
||||
action_type="DELETE",
|
||||
entity_id=str(user_id),
|
||||
entity_name=user_email,
|
||||
description=f"User '{user_email}' deleted",
|
||||
performed_by_id=str(current_user.id),
|
||||
performed_by_email=current_user.email,
|
||||
ip_address=get_client_ip(request),
|
||||
old_values={"email": user_email}
|
||||
)
|
||||
return result
|
||||
|
||||
@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)"),
|
||||
filter_names: Optional[List[str]] = Query(None, description="Filter by full names"),
|
||||
filter_emails: Optional[List[str]] = Query(None, description="Filter by emails"),
|
||||
statuses: Optional[List[str]] = Query(None, description="Filter by user statuses"),
|
||||
filter_tenant_ids: Optional[List[uuid.UUID]] = Query(None, description="Filter by tenant IDs"),
|
||||
filter_role_ids: Optional[List[uuid.UUID]] = Query(None, description="Filter by role IDs"),
|
||||
sort_by: Optional[str] = Query(None, description="Sort by name, email, status, tenant, or role"),
|
||||
sort_order: Optional[str] = Query(None, description="Sort order: asc or desc"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("admin.user.read")),
|
||||
@@ -71,5 +138,11 @@ def list_users(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
status=status,
|
||||
)
|
||||
filter_names=filter_names,
|
||||
filter_emails=filter_emails,
|
||||
statuses=statuses,
|
||||
filter_tenant_ids=filter_tenant_ids,
|
||||
filter_role_ids=filter_role_ids,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config.database import get_db
|
||||
from app.services.auth.module_permission_service import ModulePermissionService
|
||||
from app.middleware.auth_middleware import get_current_user, User, require_access
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/{module_id}/permissions/sync")
|
||||
def sync_module_permissions(
|
||||
module_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Trigger synchronization of permissions for a specific module.
|
||||
Should be restricted to platform admins or authorized roles.
|
||||
"""
|
||||
# TODO: Add specific permission check for "module.manage" or similar
|
||||
# For now, allowing any authenticated user (or superadmin check) based on existing patterns
|
||||
if current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Platform admin access required")
|
||||
|
||||
# Run sync
|
||||
# We can run in background if it takes time, but synchronous gives immediate feedback
|
||||
try:
|
||||
result = ModulePermissionService.sync_permissions(db, module_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1 @@
|
||||
from .color_palette import router
|
||||
@@ -3,8 +3,12 @@ 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.controllers.theme.color_palette_controller import PaletteController
|
||||
from app.schemas.theme.color_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
|
||||
|
||||
@@ -12,8 +16,7 @@ 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)
|
||||
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
):
|
||||
return PaletteController.get_all_palettes(db, current_user)
|
||||
|
||||
@@ -21,20 +24,22 @@ def get_all_palettes(
|
||||
def get_palette(
|
||||
palette_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
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)
|
||||
@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"))
|
||||
_=Depends(require_access("superadmin.palette.create")),
|
||||
):
|
||||
return PaletteController.create_palette(db, data, current_user)
|
||||
|
||||
@router.put("/update/users/me/preferences", deprecated=True)
|
||||
@router.put("/update/users/me/preferences", deprecated=True)
|
||||
def update_user_preference():
|
||||
pass
|
||||
|
||||
@@ -44,7 +49,7 @@ def update_palette(
|
||||
data: ColorPaletteUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.palette.update"))
|
||||
_=Depends(require_access("superadmin.palette.update")),
|
||||
):
|
||||
return PaletteController.update_palette(db, palette_id, data)
|
||||
|
||||
@@ -53,6 +58,6 @@ def delete_palette(
|
||||
palette_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
_ = Depends(require_access("superadmin.palette.delete"))
|
||||
_=Depends(require_access("superadmin.palette.delete")),
|
||||
):
|
||||
return PaletteController.delete_palette(db, palette_id)
|
||||
@@ -11,7 +11,9 @@ class AccessBase(BaseModel):
|
||||
class AccessResponse(AccessBase):
|
||||
id: uuid.UUID
|
||||
parent_id: Optional[uuid.UUID] = None
|
||||
module_id: Optional[uuid.UUID] = None
|
||||
module_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,26 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
class AuditLogResponse(BaseModel):
|
||||
id: UUID
|
||||
module_name: str
|
||||
action_type: str
|
||||
entity_id: Optional[str] = None
|
||||
entity_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
performed_by_email: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
old_values: Optional[Dict[str, Any]] = None
|
||||
new_values: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class AuditLogListResponse(BaseModel):
|
||||
items: List[AuditLogResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -16,6 +16,7 @@ class UserSignup(UserBase):
|
||||
class UserSignin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
remember_me: bool = False
|
||||
|
||||
class AccessInRole(BaseModel):
|
||||
id: str
|
||||
@@ -24,8 +25,8 @@ class AccessInRole(BaseModel):
|
||||
name: str
|
||||
|
||||
class RoleInUser(BaseModel):
|
||||
id: uuid.UUID
|
||||
role_name: str
|
||||
id: Optional[uuid.UUID] = None
|
||||
role_name: Optional[str] = None
|
||||
accesses: List[str] = []
|
||||
|
||||
class UserResponse(UserBase):
|
||||
@@ -33,6 +34,7 @@ class UserResponse(UserBase):
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
tenant_name: Optional[str] = None
|
||||
tenant_logo_url: Optional[str] = None
|
||||
subscription_details: Optional[dict] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
role: Optional[RoleInUser] = None
|
||||
@@ -55,7 +57,7 @@ class TokenResponse(BaseModel):
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refresh_token: str
|
||||
refresh_token: Optional[str] = None
|
||||
|
||||
class ResetPassword(BaseModel):
|
||||
old_password: str
|
||||
@@ -67,10 +69,10 @@ class ForgotPasswordRequest(BaseModel):
|
||||
|
||||
class VerifyOTPRequest(BaseModel):
|
||||
email: EmailStr
|
||||
otp: str
|
||||
otp: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$')
|
||||
|
||||
|
||||
class ResetPasswordWithOTP(BaseModel):
|
||||
email: EmailStr
|
||||
otp: str
|
||||
otp: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$')
|
||||
new_password: str = Field(..., min_length=8)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
from enum import Enum
|
||||
|
||||
class LanguageEnum(str, Enum):
|
||||
"""Supported languages for the application."""
|
||||
EN = "en"
|
||||
AR = "ar"
|
||||
|
||||
class UpdateLanguageRequest(BaseModel):
|
||||
preferred_language: str
|
||||
preferred_language: LanguageEnum
|
||||
@@ -0,0 +1,50 @@
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
class EnvironmentCreate(BaseModel):
|
||||
slug: str
|
||||
frontend_base_url: str
|
||||
backend_base_url: str
|
||||
sso_entry_path: str = "/sso/callback"
|
||||
permission_sync_endpoint: str = "/internal/permissions"
|
||||
sso_exchange_endpoint: Optional[str] = "/internal/sso/exchange"
|
||||
provisioning_endpoint: str = "/internal/tenants/provision"
|
||||
trust_type: str = "hmac"
|
||||
trust_credentials: Dict[str, Any]
|
||||
is_default: bool = False
|
||||
is_active: bool = True
|
||||
|
||||
class EnvironmentUpdate(BaseModel):
|
||||
slug: Optional[str] = None
|
||||
frontend_base_url: Optional[str] = None
|
||||
backend_base_url: Optional[str] = None
|
||||
sso_entry_path: Optional[str] = None
|
||||
permission_sync_endpoint: Optional[str] = None
|
||||
sso_exchange_endpoint: Optional[str] = None
|
||||
provisioning_endpoint: Optional[str] = None
|
||||
trust_type: Optional[str] = None
|
||||
trust_credentials: Optional[Dict[str, Any]] = None
|
||||
is_default: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class EnvironmentResponse(BaseModel):
|
||||
id: UUID
|
||||
module_id: UUID
|
||||
slug: str
|
||||
frontend_base_url: str
|
||||
backend_base_url: str
|
||||
sso_entry_path: Optional[str] = "/sso/callback"
|
||||
permission_sync_endpoint: Optional[str] = "/internal/permissions"
|
||||
sso_exchange_endpoint: Optional[str]
|
||||
provisioning_endpoint: Optional[str] = "/internal/tenants/provision"
|
||||
trust_type: str
|
||||
is_default: bool
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,44 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
class ModuleCreate(BaseModel):
|
||||
module_id: str
|
||||
module_name: str
|
||||
description: Optional[str] = None
|
||||
icon_url: Optional[str] = None
|
||||
status: str = "active"
|
||||
display_order: int = 0
|
||||
|
||||
class ModuleUpdate(BaseModel):
|
||||
module_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
icon_url: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
display_order: Optional[int] = None
|
||||
|
||||
class ModuleResponse(BaseModel):
|
||||
id: UUID
|
||||
module_id: str
|
||||
module_name: str
|
||||
description: Optional[str]
|
||||
icon_url: Optional[str]
|
||||
status: str
|
||||
display_order: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ModuleAvailableResponse(BaseModel):
|
||||
module_id: str
|
||||
module_name: str
|
||||
description: Optional[str]
|
||||
icon_url: Optional[str]
|
||||
display_order: int
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class SSOInitiateRequest(BaseModel):
|
||||
module_id: str
|
||||
|
||||
class SSOExchangeRequest(BaseModel):
|
||||
grant_code: str
|
||||
module_id: str
|
||||
environment_slug: str
|
||||
@@ -0,0 +1,47 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
class SubscriptionPlanBase(BaseModel):
|
||||
name: str = Field(..., min_length=2, max_length=100)
|
||||
description: Optional[str] = None
|
||||
price: Optional[float] = None
|
||||
duration_days: Optional[int] = Field(None, ge=1)
|
||||
max_users_allowed: Optional[int] = Field(None, ge=0)
|
||||
is_public: bool = True
|
||||
status: str = "active"
|
||||
|
||||
class SubscriptionPlanCreate(SubscriptionPlanBase):
|
||||
access_ids: Optional[List[uuid.UUID]] = []
|
||||
module_access_ids: Optional[List[uuid.UUID]] = []
|
||||
|
||||
class SubscriptionPlanUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||
description: Optional[str] = None
|
||||
price: Optional[float] = None
|
||||
duration_days: Optional[int] = Field(None, ge=1)
|
||||
max_users_allowed: Optional[int] = Field(None, ge=0)
|
||||
is_public: Optional[bool] = None
|
||||
status: Optional[str] = None
|
||||
access_ids: Optional[List[uuid.UUID]] = None
|
||||
module_access_ids: Optional[List[uuid.UUID]] = None
|
||||
|
||||
class SubscriptionPlanResponse(SubscriptionPlanBase):
|
||||
id: uuid.UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class SubscriptionPlanDetailResponse(SubscriptionPlanResponse):
|
||||
access_ids: List[uuid.UUID] = []
|
||||
module_access_ids: List[uuid.UUID] = []
|
||||
|
||||
class SubscriptionPlanPaginatedResponse(BaseModel):
|
||||
items: List[SubscriptionPlanResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
class TenantModuleCreate(BaseModel):
|
||||
module_id: str
|
||||
assigned_environment_slug: Optional[str] = "prod"
|
||||
is_active: bool = True
|
||||
module_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
class TenantModuleUpdate(BaseModel):
|
||||
assigned_environment_slug: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
module_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
class TenantModuleResponse(BaseModel):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
module_id: UUID
|
||||
module_name: str
|
||||
module_icon_url: Optional[str]
|
||||
assigned_environment_slug: str
|
||||
is_active: bool
|
||||
module_config: Optional[Dict[str, Any]]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -1,6 +1,6 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
import uuid
|
||||
|
||||
class TenantBase(BaseModel):
|
||||
@@ -8,18 +8,37 @@ class TenantBase(BaseModel):
|
||||
tenant_domain: str = Field(..., min_length=3, max_length=255)
|
||||
tenant_logo_url: Optional[str] = None
|
||||
|
||||
class ModuleEnvironmentAssignment(BaseModel):
|
||||
module_id: uuid.UUID
|
||||
environment_slug: str
|
||||
|
||||
class TenantCreate(TenantBase):
|
||||
pass
|
||||
plan_id: uuid.UUID
|
||||
start_date: Optional[date] = None
|
||||
end_date: Optional[date] = None
|
||||
status: Optional[str] = "ACTIVE"
|
||||
|
||||
module_environments: Optional[List[ModuleEnvironmentAssignment]] = []
|
||||
default_environment_slug: str = "prod"
|
||||
|
||||
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
|
||||
plan_id: Optional[uuid.UUID] = None
|
||||
start_date: Optional[date] = None
|
||||
end_date: Optional[date] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
class TenantResponse(TenantBase):
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
is_active: bool
|
||||
plan_id: Optional[uuid.UUID] = None
|
||||
start_date: Optional[date] = None
|
||||
end_date: Optional[date] = None
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -1,19 +1,90 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from app.models.auth.access_model import Access
|
||||
from typing import List
|
||||
from typing import List, Any
|
||||
from app.core.redis import sync_redis_client
|
||||
import json
|
||||
import logging
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
from app.models.auth.module_model import Module
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AccessService:
|
||||
|
||||
@staticmethod
|
||||
def get_all_accesses(db: Session, category: str = None) -> List[Access]:
|
||||
def get_all_accesses(db: Session, category: str = None) -> List[any]:
|
||||
|
||||
cache_key = f"saas:access:v2:all:{category if category else 'full'}"
|
||||
cached_data = sync_redis_client.client.get(cache_key) if sync_redis_client.client else None
|
||||
|
||||
if cached_data:
|
||||
try:
|
||||
data_list = json.loads(cached_data)
|
||||
class SimpleAccess:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
deserialized_list = []
|
||||
for item in data_list:
|
||||
if "created_at" in item and item["created_at"]:
|
||||
try:
|
||||
item["created_at"] = datetime.fromisoformat(item["created_at"])
|
||||
except ValueError:
|
||||
item["created_at"] = None
|
||||
deserialized_list.append(SimpleAccess(**item))
|
||||
|
||||
return deserialized_list
|
||||
except Exception as e:
|
||||
logger.warning(f"Access cache read error: {e}")
|
||||
|
||||
query = db.query(Access)
|
||||
|
||||
if category:
|
||||
query = query.filter(Access.category == category)
|
||||
|
||||
return query.all()
|
||||
saas_accesses = query.all()
|
||||
for access in saas_accesses:
|
||||
access.module_name = "SaaS (Internal)"
|
||||
|
||||
module_query = db.query(ModuleAccess).options(joinedload(ModuleAccess.module))
|
||||
|
||||
if category:
|
||||
module_query = module_query.filter(ModuleAccess.category == category)
|
||||
|
||||
module_accesses = module_query.all()
|
||||
|
||||
for ma in module_accesses:
|
||||
if ma.module:
|
||||
ma.module_name = ma.module.module_name
|
||||
|
||||
result = saas_accesses + module_accesses
|
||||
|
||||
try:
|
||||
if sync_redis_client.client:
|
||||
serialized = []
|
||||
for item in result:
|
||||
serialized.append({
|
||||
"id": str(item.id),
|
||||
"access_code": item.access_code,
|
||||
"name": item.name,
|
||||
"category": item.category,
|
||||
"parent_id": str(item.parent_id) if item.parent_id else None,
|
||||
"module_id": str(item.module_id) if getattr(item, "module_id", None) else None,
|
||||
"module_name": getattr(item, "module_name", None),
|
||||
"created_at": item.created_at.isoformat() if item.created_at else None,
|
||||
})
|
||||
|
||||
sync_redis_client.client.set(cache_key, json.dumps(serialized), ex=3600)
|
||||
except Exception as e:
|
||||
logger.warning(f"Access cache write error: {e}")
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_access_categories(db: Session) -> List[str]:
|
||||
categories = db.query(Access.category).distinct().all()
|
||||
return [cat[0] for cat in categories]
|
||||
module_categories = db.query(ModuleAccess.category).distinct().all()
|
||||
|
||||
all_cats = set([cat[0] for cat in categories] + [cat[0] for cat in module_categories])
|
||||
return list(all_cats)
|
||||
@@ -3,10 +3,19 @@ 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
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import secrets
|
||||
import uuid
|
||||
import logging
|
||||
import jwt
|
||||
from app.config.settings import settings
|
||||
from app.services.auth.email_service import EmailService
|
||||
from app.core.redis import sync_redis_client
|
||||
from app.services.auth.subscription_entitlement_service import (
|
||||
SubscriptionEntitlementService,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AuthService:
|
||||
|
||||
@@ -47,12 +56,15 @@ class AuthService:
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="User inactive")
|
||||
|
||||
effective_accesses = sorted(
|
||||
SubscriptionEntitlementService.get_effective_access_codes(db, user)
|
||||
)
|
||||
role_data = None
|
||||
if user.role:
|
||||
if user.role or effective_accesses:
|
||||
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],
|
||||
"id": str(user.role.id) if user.role else None,
|
||||
"role_name": user.role.role_name if user.role else "subscription",
|
||||
"accesses": effective_accesses,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -73,6 +85,9 @@ class AuthService:
|
||||
"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,
|
||||
"subscription_details": SubscriptionEntitlementService.get_subscription_summary(
|
||||
db, user.tenant_id
|
||||
),
|
||||
"created_at": user.created_at,
|
||||
"updated_at": user.updated_at,
|
||||
"role": role_data,
|
||||
@@ -102,11 +117,38 @@ class AuthService:
|
||||
{"sub": str(user.id)}, tenant_id=user.tenant_id
|
||||
)
|
||||
|
||||
effective_accesses = sorted(
|
||||
SubscriptionEntitlementService.get_effective_access_codes(db, user)
|
||||
)
|
||||
role_data = None
|
||||
if user.role or effective_accesses:
|
||||
role_data = {
|
||||
"id": str(user.role.id) if user.role else None,
|
||||
"role_name": user.role.role_name if user.role else "subscription",
|
||||
"accesses": effective_accesses,
|
||||
}
|
||||
|
||||
return {
|
||||
"access_token": new_access_token,
|
||||
"refresh_token": new_refresh_token,
|
||||
"token_type": "bearer",
|
||||
"user": user,
|
||||
"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,
|
||||
"subscription_details": SubscriptionEntitlementService.get_subscription_summary(
|
||||
db, user.tenant_id
|
||||
),
|
||||
"created_at": user.created_at,
|
||||
"updated_at": user.updated_at,
|
||||
"role": role_data,
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -159,20 +201,30 @@ class AuthService:
|
||||
return {"message": "Password updated successfully"}
|
||||
|
||||
@staticmethod
|
||||
def logout(current_user: User):
|
||||
def logout(current_user: User, token: str):
|
||||
try:
|
||||
payload = jwt.decode(token, settings.ACCESS_TOKEN_SECRET, algorithms=["HS256"])
|
||||
jti = payload.get("jti")
|
||||
exp = payload.get("exp")
|
||||
if jti and exp and sync_redis_client.client:
|
||||
remaining_ttl = int(exp - datetime.now(timezone.utc).timestamp())
|
||||
if remaining_ttl > 0:
|
||||
sync_redis_client.client.setex(f"blacklist:{jti}", remaining_ttl, "1")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to blacklist token on logout: {e}")
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
@staticmethod
|
||||
def me(db: Session, current_user: User):
|
||||
effective_accesses = sorted(
|
||||
SubscriptionEntitlementService.get_effective_access_codes(db, current_user)
|
||||
)
|
||||
role_data = None
|
||||
|
||||
if current_user.role:
|
||||
if current_user.role or effective_accesses:
|
||||
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
|
||||
],
|
||||
"id": str(current_user.role.id) if current_user.role else None,
|
||||
"role_name": current_user.role.role_name if current_user.role else "subscription",
|
||||
"accesses": effective_accesses,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -189,6 +241,9 @@ class AuthService:
|
||||
"tenant_logo_url": (
|
||||
current_user.tenant.tenant_logo_url if current_user.tenant else None
|
||||
),
|
||||
"subscription_details": SubscriptionEntitlementService.get_subscription_summary(
|
||||
db, current_user.tenant_id
|
||||
),
|
||||
"created_at": current_user.created_at,
|
||||
"updated_at": current_user.updated_at,
|
||||
"role": role_data,
|
||||
@@ -196,92 +251,174 @@ class AuthService:
|
||||
|
||||
@staticmethod
|
||||
def forgot_password(db: Session, email: str):
|
||||
print(f"DEBUG: Processing forgot_password for email: {email}")
|
||||
"""Generate and send OTP for password reset via Redis storage."""
|
||||
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})
|
||||
otp_code = "".join([secrets.choice("0123456789") for _ in range(6)])
|
||||
|
||||
redis_key = f"otp:{email}"
|
||||
try:
|
||||
if sync_redis_client.client:
|
||||
sync_redis_client.client.setex(redis_key, 600, otp_code) # 600s = 10 minutes
|
||||
logger.info(f"OTP generated for email: {email}")
|
||||
else:
|
||||
logger.error("Redis client unavailable for OTP storage")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Password reset service temporarily unavailable"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis error storing OTP: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to generate OTP"
|
||||
)
|
||||
|
||||
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")
|
||||
EmailService.send_otp(email, otp_code)
|
||||
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}")
|
||||
logger.error(f"Failed to send OTP email to {email}: {e}")
|
||||
|
||||
return {"message": "If the email is registered, an OTP has been sent."}
|
||||
|
||||
@staticmethod
|
||||
def _check_otp_attempts(email: str):
|
||||
"""Check if OTP attempts are exceeded. Raises 429 if locked out."""
|
||||
attempts_key = f"otp_attempts:{email}"
|
||||
try:
|
||||
attempts = sync_redis_client.client.get(attempts_key)
|
||||
if attempts and int(attempts) >= 5:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many attempts. Try again in 15 minutes."
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis error checking OTP attempts: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _increment_otp_attempts(email: str):
|
||||
"""Increment failed OTP attempt counter with 15-minute TTL."""
|
||||
attempts_key = f"otp_attempts:{email}"
|
||||
try:
|
||||
pipe = sync_redis_client.client.pipeline()
|
||||
pipe.incr(attempts_key)
|
||||
pipe.expire(attempts_key, 900)
|
||||
pipe.execute()
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis error incrementing OTP attempts: {e}")
|
||||
|
||||
@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()
|
||||
)
|
||||
"""Verify OTP from Redis storage using constant-time comparison."""
|
||||
redis_key = f"otp:{email}"
|
||||
|
||||
if not otp_entry:
|
||||
try:
|
||||
if not sync_redis_client.client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Verification service temporarily unavailable"
|
||||
)
|
||||
|
||||
AuthService._check_otp_attempts(email)
|
||||
|
||||
stored_otp = sync_redis_client.client.get(redis_key)
|
||||
|
||||
if not stored_otp:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired OTP"
|
||||
)
|
||||
|
||||
if not secrets.compare_digest(otp, stored_otp):
|
||||
AuthService._increment_otp_attempts(email)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired OTP"
|
||||
)
|
||||
|
||||
sync_redis_client.client.delete(redis_key)
|
||||
sync_redis_client.client.delete(f"otp_attempts:{email}")
|
||||
sync_redis_client.client.setex(f"otp_verified:{email}", 300, "1")
|
||||
|
||||
logger.info(f"OTP verified successfully for email: {email}")
|
||||
return {"message": "OTP verified successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Redis error verifying OTP: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OTP"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to verify 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()
|
||||
)
|
||||
"""Reset password after verifying OTP and delete OTP from Redis."""
|
||||
redis_key = f"otp:{email}"
|
||||
verified_key = f"otp_verified:{email}"
|
||||
|
||||
if not otp_entry:
|
||||
try:
|
||||
if not sync_redis_client.client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Password reset service temporarily unavailable"
|
||||
)
|
||||
|
||||
AuthService._check_otp_attempts(email)
|
||||
|
||||
is_pre_verified = sync_redis_client.client.get(verified_key)
|
||||
|
||||
if not is_pre_verified:
|
||||
stored_otp = sync_redis_client.client.get(redis_key)
|
||||
|
||||
if not stored_otp:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired OTP"
|
||||
)
|
||||
|
||||
if not secrets.compare_digest(otp, stored_otp):
|
||||
AuthService._increment_otp_attempts(email)
|
||||
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=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Password too weak"
|
||||
)
|
||||
|
||||
user.password = security.hash_password(new_password)
|
||||
user.password_updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
sync_redis_client.client.delete(redis_key)
|
||||
sync_redis_client.client.delete(verified_key)
|
||||
sync_redis_client.client.delete(f"otp_attempts:{email}")
|
||||
logger.info(f"Password reset successfully for email: {email}")
|
||||
|
||||
return {"message": "Password updated successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error resetting password: {e}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired OTP"
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to reset password"
|
||||
)
|
||||
|
||||
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"}
|
||||
@@ -1,41 +1,35 @@
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import logging
|
||||
from app.config.settings import settings
|
||||
|
||||
load_dotenv()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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['From'] = settings.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)
|
||||
if settings.SMTP_SECURE:
|
||||
server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT)
|
||||
else:
|
||||
server = smtplib.SMTP(EmailService.SMTP_HOST, EmailService.SMTP_PORT)
|
||||
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT)
|
||||
server.starttls()
|
||||
|
||||
server.login(EmailService.SMTP_USER, EmailService.SMTP_PASSWORD)
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
text = msg.as_string()
|
||||
server.sendmail(EmailService.EMAIL_FROM, to_email, text)
|
||||
server.sendmail(settings.EMAIL_FROM, to_email, text)
|
||||
server.quit()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to send email: {e}")
|
||||
logger.error(f"Failed to send email: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,280 @@
|
||||
import uuid
|
||||
import httpx
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
import hmac
|
||||
import hashlib
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
from app.models.system.event_log_model import EventLog, EventStatus
|
||||
from app.services.auth.trust_service import TrustService
|
||||
from app.core.redis import sync_redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class EventService:
|
||||
@staticmethod
|
||||
def emit_event(
|
||||
db: Session,
|
||||
event_type: str,
|
||||
payload: Dict[str, Any],
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
follow_up_event: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Emits an event by writing it to the Outbox (event_logs).
|
||||
Scopes delivery to relevant modules based on tenant_id.
|
||||
"""
|
||||
event_id = str(uuid.uuid4())
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
if "event_id" not in payload:
|
||||
payload["event_id"] = event_id
|
||||
|
||||
final_payload = {
|
||||
"event_id": event_id,
|
||||
"event_type": event_type,
|
||||
"timestamp": timestamp,
|
||||
"data": payload
|
||||
}
|
||||
|
||||
targets: List[ModuleEnvironment] = []
|
||||
|
||||
payload_data = payload.get("data", payload) if isinstance(payload, dict) else {}
|
||||
|
||||
targets_list = payload.get("targets")
|
||||
|
||||
if targets_list and isinstance(targets_list, list):
|
||||
target_configs = targets_list
|
||||
for target in target_configs:
|
||||
module_id = target.get("module_id")
|
||||
env_slug = target.get("environment_slug")
|
||||
|
||||
if module_id and env_slug:
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module_id,
|
||||
ModuleEnvironment.slug == env_slug
|
||||
).first()
|
||||
if env:
|
||||
targets.append(env)
|
||||
|
||||
elif tenant_id:
|
||||
tenant_modules = db.query(TenantModule).filter(
|
||||
TenantModule.tenant_id == tenant_id,
|
||||
TenantModule.is_active == True
|
||||
).all()
|
||||
|
||||
for tm in tenant_modules:
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == tm.module_id,
|
||||
ModuleEnvironment.slug == (tm.assigned_environment_slug or "prod")
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == tm.module_id,
|
||||
ModuleEnvironment.is_default == True
|
||||
).first()
|
||||
|
||||
if env:
|
||||
targets.append(env)
|
||||
|
||||
if not targets:
|
||||
logger.warning(f"Event {event_type} emitted with no resolved targets. Payload scoping: {'Explicit' if 'targets' in payload.get('data', {}) else 'Implicit'}")
|
||||
return
|
||||
|
||||
for env in targets:
|
||||
base = env.backend_base_url.rstrip('/')
|
||||
|
||||
if event_type in {"TENANT_PROVISION_REQUESTED", "TENANT_UPDATED", "TENANT_STATUS_CHANGED", "TENANT_DEPROVISION_REQUESTED"} and env.provisioning_endpoint:
|
||||
endpoint = env.provisioning_endpoint.lstrip('/')
|
||||
logger.info(f"Trace: base='{base}', endpoint='{endpoint}'")
|
||||
target_url = f"{base}/{endpoint}"
|
||||
logger.info(f"Trace: Calculated target_url='{target_url}'")
|
||||
else:
|
||||
logger.info(f"Using default event stream for env '{env.slug}'. ProvEndpoint: '{env.provisioning_endpoint}'")
|
||||
target_url = f"{base}/api/internal/events"
|
||||
|
||||
log = EventLog(
|
||||
event_id=uuid.UUID(event_id),
|
||||
event_type=event_type,
|
||||
payload=final_payload,
|
||||
target_module_id=env.module_id,
|
||||
target_environment_slug=env.slug,
|
||||
target_url=target_url,
|
||||
status=EventStatus.PENDING,
|
||||
next_retry_at=datetime.now(timezone.utc),
|
||||
follow_up_event=follow_up_event
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
db.flush()
|
||||
|
||||
try:
|
||||
sync_redis_client.rpush("saas:events:queue", event_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to push event to Redis queue: {e}")
|
||||
|
||||
@staticmethod
|
||||
def process_queue_item(db: Session, event_id: str):
|
||||
"""
|
||||
Process all pending EventLogs associated with the given logical event_id.
|
||||
"""
|
||||
logs = db.query(EventLog).filter(
|
||||
EventLog.event_id == uuid.UUID(event_id),
|
||||
EventLog.status == EventStatus.PENDING
|
||||
).all()
|
||||
|
||||
if not logs:
|
||||
return 0
|
||||
|
||||
processed_count = 0
|
||||
for log in logs:
|
||||
try:
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == log.target_module_id,
|
||||
ModuleEnvironment.slug == log.target_environment_slug
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
log.status = EventStatus.FAILED
|
||||
log.error_log = "Target environment config missing"
|
||||
continue
|
||||
|
||||
payload_json = json.dumps(log.payload)
|
||||
secret = env.trust_credentials.get("hmac_secret") if env.trust_credentials else None
|
||||
|
||||
if secret:
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload_json.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
else:
|
||||
signature = ""
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-SaaS-Signature": signature,
|
||||
"X-SaaS-Event-Source": "saas-core"
|
||||
}
|
||||
|
||||
logger.info(f"Sending event {log.event_type} to {log.target_url}")
|
||||
response = httpx.post(log.target_url, content=payload_json, headers=headers, timeout=5)
|
||||
|
||||
if response.status_code in range(200, 300):
|
||||
log.status = EventStatus.COMPLETED
|
||||
log.error_log = None
|
||||
processed_count += 1
|
||||
|
||||
if log.follow_up_event:
|
||||
follow_up = log.follow_up_event
|
||||
logger.info(f"Triggering follow-up event {follow_up.get('event_type')} after {log.event_type} completed")
|
||||
EventService.emit_event(
|
||||
db,
|
||||
event_type=follow_up["event_type"],
|
||||
payload=follow_up["payload"],
|
||||
tenant_id=uuid.UUID(follow_up["tenant_id"]) if follow_up.get("tenant_id") else None
|
||||
)
|
||||
else:
|
||||
log.retry_count += 1
|
||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=backoff)
|
||||
log.error_log = f"HTTP {response.status_code}: {response.text}"
|
||||
if log.retry_count > 10:
|
||||
log.status = EventStatus.FAILED
|
||||
|
||||
except Exception as e:
|
||||
log.retry_count += 1
|
||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=backoff)
|
||||
log.error_log = str(e)
|
||||
|
||||
db.commit()
|
||||
|
||||
return processed_count
|
||||
|
||||
@staticmethod
|
||||
def process_outbox(db: Session, batch_size: int = 50):
|
||||
"""
|
||||
Worker method to process pending events.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
logs = db.query(EventLog).filter(
|
||||
EventLog.status == EventStatus.PENDING,
|
||||
EventLog.next_retry_at <= now
|
||||
).limit(batch_size).all()
|
||||
|
||||
if logs:
|
||||
logger.info(f"Found {len(logs)} events to process (next_retry_at <= {now})")
|
||||
|
||||
for log in logs:
|
||||
try:
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == log.target_module_id,
|
||||
ModuleEnvironment.slug == log.target_environment_slug
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
log.status = EventStatus.FAILED
|
||||
log.error_log = "Target environment config missing"
|
||||
continue
|
||||
|
||||
payload_json = json.dumps(log.payload)
|
||||
secret = env.trust_credentials.get("hmac_secret")
|
||||
|
||||
if secret:
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload_json.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
else:
|
||||
signature = ""
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-SaaS-Signature": signature,
|
||||
"X-SaaS-Event-Source": "saas-core"
|
||||
}
|
||||
|
||||
logger.info(f"Sending event {log.event_type} to {log.target_url}. Payload: {payload_json}")
|
||||
response = httpx.post(log.target_url, content=payload_json, headers=headers, timeout=5)
|
||||
|
||||
if response.status_code in range(200, 300):
|
||||
log.status = EventStatus.COMPLETED
|
||||
log.error_log = None
|
||||
|
||||
if log.follow_up_event:
|
||||
follow_up = log.follow_up_event
|
||||
logger.info(f"Triggering follow-up event {follow_up.get('event_type')} after {log.event_type} completed")
|
||||
EventService.emit_event(
|
||||
db,
|
||||
event_type=follow_up["event_type"],
|
||||
payload=follow_up["payload"],
|
||||
tenant_id=uuid.UUID(follow_up["tenant_id"]) if follow_up.get("tenant_id") else None
|
||||
)
|
||||
else:
|
||||
log.retry_count += 1
|
||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||
log.next_retry_at = now + timedelta(seconds=backoff)
|
||||
log.error_log = f"HTTP {response.status_code}: {response.text}"
|
||||
|
||||
if log.retry_count > 10:
|
||||
log.status = EventStatus.FAILED
|
||||
|
||||
except Exception as e:
|
||||
log.retry_count += 1
|
||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||
log.next_retry_at = now + timedelta(seconds=backoff)
|
||||
log.error_log = str(e)
|
||||
|
||||
db.commit()
|
||||
|
||||
return len(logs)
|
||||
@@ -0,0 +1,111 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import HTTPException
|
||||
from typing import List
|
||||
import uuid
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.schemas.auth.module_environment_schema import EnvironmentCreate, EnvironmentUpdate
|
||||
|
||||
class ModuleEnvironmentService:
|
||||
@staticmethod
|
||||
def list_environments(db: Session, module_id: str) -> List[ModuleEnvironment]:
|
||||
module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
return db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module.id
|
||||
).order_by(ModuleEnvironment.is_default.desc(), ModuleEnvironment.slug).all()
|
||||
|
||||
@staticmethod
|
||||
def create_environment(db: Session, module_id: str, env_data: EnvironmentCreate) -> ModuleEnvironment:
|
||||
module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
if env_data.is_default:
|
||||
db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module.id,
|
||||
ModuleEnvironment.is_default == True
|
||||
).update({"is_default": False})
|
||||
|
||||
try:
|
||||
environment = ModuleEnvironment(
|
||||
module_id=module.id,
|
||||
slug=env_data.slug,
|
||||
frontend_base_url=env_data.frontend_base_url,
|
||||
backend_base_url=env_data.backend_base_url,
|
||||
sso_entry_path=env_data.sso_entry_path,
|
||||
permission_sync_endpoint=env_data.permission_sync_endpoint,
|
||||
sso_exchange_endpoint=env_data.sso_exchange_endpoint,
|
||||
trust_type=env_data.trust_type,
|
||||
trust_credentials=env_data.trust_credentials,
|
||||
is_default=env_data.is_default,
|
||||
is_active=env_data.is_active
|
||||
)
|
||||
db.add(environment)
|
||||
db.commit()
|
||||
db.refresh(environment)
|
||||
return environment
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Environment slug already exists for this module")
|
||||
|
||||
@staticmethod
|
||||
def update_environment(db: Session, module_id: str, env_id: str, env_data: EnvironmentUpdate) -> ModuleEnvironment:
|
||||
environment = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.id == uuid.UUID(env_id),
|
||||
ModuleEnvironment.module_id == uuid.UUID(module_id)
|
||||
).first()
|
||||
|
||||
if not environment:
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
if env_data.is_default and env_data.is_default != environment.is_default:
|
||||
db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == environment.module_id,
|
||||
ModuleEnvironment.id != environment.id,
|
||||
ModuleEnvironment.is_default == True
|
||||
).update({"is_default": False})
|
||||
|
||||
update_data = env_data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(environment, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(environment)
|
||||
return environment
|
||||
|
||||
@staticmethod
|
||||
def set_default_environment(db: Session, module_id: str, env_id: str):
|
||||
environment = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.id == uuid.UUID(env_id),
|
||||
ModuleEnvironment.module_id == uuid.UUID(module_id)
|
||||
).first()
|
||||
|
||||
if not environment:
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == environment.module_id
|
||||
).update({"is_default": False})
|
||||
|
||||
environment.is_default = True
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def delete_environment(db: Session, module_id: str, env_id: str):
|
||||
environment = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.id == uuid.UUID(env_id),
|
||||
ModuleEnvironment.module_id == uuid.UUID(module_id)
|
||||
).first()
|
||||
|
||||
if not environment:
|
||||
raise HTTPException(status_code=404, detail="Environment not found")
|
||||
|
||||
if environment.is_default:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete default environment. Set another environment as default first.")
|
||||
|
||||
db.delete(environment)
|
||||
db.commit()
|
||||
@@ -0,0 +1,162 @@
|
||||
import requests
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException
|
||||
from typing import List, Dict, Any
|
||||
import hmac
|
||||
import hashlib
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.models.auth.access_model import Access
|
||||
from app.services.auth.trust_service import TrustService
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
import uuid
|
||||
from app.core.redis import sync_redis_client
|
||||
|
||||
class ModulePermissionService:
|
||||
|
||||
@staticmethod
|
||||
def sync_permissions(db: Session, module_id: str):
|
||||
"""
|
||||
Connects to the module's default environment and fetches defined permissions.
|
||||
Updates the local ModuleAccess table to mirror these permissions.
|
||||
|
||||
:param module_id: The UUID string of the module
|
||||
"""
|
||||
try:
|
||||
module_uuid = uuid.UUID(module_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid module UUID")
|
||||
|
||||
module = db.query(Module).filter(Module.id == module_uuid).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module.id,
|
||||
ModuleEnvironment.is_default == True
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module.id,
|
||||
ModuleEnvironment.is_active == True
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
raise HTTPException(status_code=400, detail="No active environment to sync from")
|
||||
|
||||
try:
|
||||
|
||||
payload_body = "{}"
|
||||
|
||||
secret = env.trust_credentials.get("hmac_secret")
|
||||
if not secret:
|
||||
pass
|
||||
|
||||
if secret:
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload_body.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
else:
|
||||
signature = ""
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-SaaS-Signature": signature
|
||||
}
|
||||
|
||||
url = f"{env.backend_base_url}{env.permission_sync_endpoint}"
|
||||
response = requests.post(url, headers=headers, data=payload_body, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Failed to fetch permissions from module: {str(e)}")
|
||||
|
||||
permissions: List[Dict[str, Any]] = data.get("permissions", [])
|
||||
|
||||
synced_count = 0
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
|
||||
permission_map = {}
|
||||
|
||||
for perm in permissions:
|
||||
code = perm.get("permission_code")
|
||||
if not code:
|
||||
continue
|
||||
|
||||
access = db.query(ModuleAccess).filter(
|
||||
ModuleAccess.module_id == module.id,
|
||||
ModuleAccess.access_code == code
|
||||
).first()
|
||||
|
||||
if not access:
|
||||
access = ModuleAccess(
|
||||
access_code=code,
|
||||
module_id=module.id,
|
||||
name=perm.get("name", code),
|
||||
category=perm.get("category", "General"),
|
||||
)
|
||||
db.add(access)
|
||||
else:
|
||||
access.name = perm.get("name", access.name)
|
||||
access.category = perm.get("category", access.category)
|
||||
|
||||
access.last_synced_at = timestamp
|
||||
access.sync_checksum = perm.get("hash")
|
||||
|
||||
permission_map[code] = access
|
||||
synced_count += 1
|
||||
|
||||
db.flush()
|
||||
|
||||
for perm in permissions:
|
||||
code = perm.get("permission_code")
|
||||
parent_code = perm.get("parent_code")
|
||||
|
||||
if not code or not parent_code:
|
||||
continue
|
||||
|
||||
access = permission_map.get(code)
|
||||
parent_access = permission_map.get(parent_code)
|
||||
|
||||
if not parent_access:
|
||||
parent_access = db.query(ModuleAccess).filter(
|
||||
ModuleAccess.module_id == module.id,
|
||||
ModuleAccess.access_code == parent_code
|
||||
).first()
|
||||
|
||||
if access and parent_access:
|
||||
access.parent_id = parent_access.id
|
||||
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
if sync_redis_client.client:
|
||||
sync_redis_client.client.delete("saas:access:v2:all:full")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Permissions synced successfully",
|
||||
"synced_count": synced_count
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_module_permissions(db: Session, module_id: str):
|
||||
"""List permissions for a module from local DB."""
|
||||
try:
|
||||
module_uuid = uuid.UUID(module_id)
|
||||
module = db.query(Module).filter(Module.id == module_uuid).first()
|
||||
except ValueError:
|
||||
module = db.query(Module).filter(Module.module_id == module_id).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
return db.query(ModuleAccess).filter(
|
||||
ModuleAccess.module_id == module.id
|
||||
).all()
|
||||
@@ -0,0 +1,77 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import HTTPException
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
from app.models.auth.module_model import Module
|
||||
from app.schemas.auth.module_schema import ModuleCreate, ModuleUpdate
|
||||
|
||||
class ModuleService:
|
||||
@staticmethod
|
||||
def list_modules(db: Session) -> List[Module]:
|
||||
return db.query(Module).order_by(Module.display_order, Module.module_name).all()
|
||||
|
||||
@staticmethod
|
||||
def create_module(db: Session, module_data: ModuleCreate) -> Module:
|
||||
try:
|
||||
module = Module(
|
||||
module_id=module_data.module_id,
|
||||
module_name=module_data.module_name,
|
||||
description=module_data.description,
|
||||
icon_url=module_data.icon_url,
|
||||
status=module_data.status,
|
||||
display_order=module_data.display_order
|
||||
)
|
||||
db.add(module)
|
||||
db.commit()
|
||||
db.refresh(module)
|
||||
return module
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Module ID already exists")
|
||||
|
||||
@staticmethod
|
||||
def get_module(db: Session, module_id: str) -> Module:
|
||||
module = db.query(Module).filter(Module.id == uuid.UUID(module_id)).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
return module
|
||||
|
||||
@staticmethod
|
||||
def get_available_modules(db: Session, tenant_id: Optional[uuid.UUID]) -> List[dict]:
|
||||
if tenant_id:
|
||||
results = db.query(Module, TenantModule.is_active).join(
|
||||
TenantModule,
|
||||
(TenantModule.module_id == Module.id) & (TenantModule.tenant_id == tenant_id)
|
||||
).filter(
|
||||
Module.status == "active",
|
||||
TenantModule.is_active == True
|
||||
).order_by(Module.display_order).all()
|
||||
|
||||
return [{"module": mod, "is_active": is_active} for mod, is_active in results]
|
||||
else:
|
||||
modules = db.query(Module).filter(Module.status == "active").order_by(Module.display_order).all()
|
||||
return [{"module": mod, "is_active": True} for mod in modules]
|
||||
|
||||
@staticmethod
|
||||
def get_module_by_module_id(db: Session, module_id_str: str) -> Optional[Module]:
|
||||
return db.query(Module).filter(Module.module_id == module_id_str).first()
|
||||
|
||||
@staticmethod
|
||||
def update_module(db: Session, module_id: str, module_data: ModuleUpdate) -> Module:
|
||||
module = ModuleService.get_module(db, module_id)
|
||||
|
||||
update_data = module_data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(module, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(module)
|
||||
return module
|
||||
|
||||
@staticmethod
|
||||
def delete_module(db: Session, module_id: str):
|
||||
module = ModuleService.get_module(db, module_id)
|
||||
db.delete(module)
|
||||
db.commit()
|
||||
@@ -1,17 +1,25 @@
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, cast, String
|
||||
from sqlalchemy import or_, cast, String, asc, desc
|
||||
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.role_module_access_model import RoleModuleAccess
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
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
|
||||
from app.services.auth.event_service import EventService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RoleService:
|
||||
|
||||
@staticmethod
|
||||
def create_role(db: Session, role_data: RoleCreate) -> Role:
|
||||
def create_role(db: Session, role_data: RoleCreate, emit_events: bool = True) -> Role:
|
||||
existing = (
|
||||
db.query(Role)
|
||||
.filter(
|
||||
@@ -39,23 +47,87 @@ class RoleService:
|
||||
|
||||
if role_data.access_ids:
|
||||
RoleService.assign_accesses(db, role.id, role_data.access_ids)
|
||||
|
||||
if emit_events:
|
||||
assigned_modules = (
|
||||
db.query(RoleModuleAccess, ModuleAccess)
|
||||
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
|
||||
.filter(RoleModuleAccess.role_id == role.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
if assigned_modules:
|
||||
module_map = {}
|
||||
|
||||
for rma, ma in assigned_modules:
|
||||
mid = str(ma.module_id)
|
||||
if mid not in module_map:
|
||||
module_map[mid] = []
|
||||
module_map[mid].append(ma.access_code)
|
||||
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
|
||||
env_map = {}
|
||||
if role.tenant_id:
|
||||
tm_assignments = db.query(TenantModule).filter(
|
||||
TenantModule.tenant_id == role.tenant_id,
|
||||
TenantModule.module_id.in_([uuid.UUID(m) for m in module_map.keys()])
|
||||
).all()
|
||||
for tm in tm_assignments:
|
||||
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
|
||||
|
||||
targets = []
|
||||
for mid, codes in module_map.items():
|
||||
env_slug = env_map.get(mid, "prod")
|
||||
targets.append({
|
||||
"module_id": mid,
|
||||
"environment_slug": env_slug,
|
||||
"permissions": codes
|
||||
})
|
||||
|
||||
if targets:
|
||||
provisioning_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
"role_id": str(role.id),
|
||||
"role_name": role.role_name,
|
||||
"tenant_id": str(role.tenant_id) if role.tenant_id else None,
|
||||
"provisioning_id": provisioning_id,
|
||||
"targets": targets
|
||||
}
|
||||
|
||||
logger.info(f"ROLE_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
||||
|
||||
EventService.emit_event(
|
||||
db,
|
||||
event_type="ROLE_PROVISION_REQUESTED",
|
||||
payload=payload,
|
||||
tenant_id=role.tenant_id
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
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()
|
||||
db.query(RoleModuleAccess).filter(RoleModuleAccess.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",
|
||||
)
|
||||
if not access_ids:
|
||||
return
|
||||
|
||||
role_access = RoleAccess(role_id=role_id, access_id=access_id)
|
||||
db.add(role_access)
|
||||
saas_accesses = db.query(Access).filter(Access.id.in_(access_ids)).all()
|
||||
saas_ids = {a.id for a in saas_accesses}
|
||||
|
||||
for access in saas_accesses:
|
||||
db.add(RoleAccess(role_id=role_id, access_id=access.id))
|
||||
|
||||
remaining_ids = set(access_ids) - saas_ids
|
||||
|
||||
if remaining_ids:
|
||||
module_accesses = db.query(ModuleAccess).filter(ModuleAccess.id.in_(remaining_ids)).all()
|
||||
for access in module_accesses:
|
||||
db.add(RoleModuleAccess(role_id=role_id, module_access_id=access.id))
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -84,7 +156,25 @@ class RoleService:
|
||||
) -> Role:
|
||||
role = RoleService.get_role_by_id(db, role_id)
|
||||
|
||||
def get_module_permissions_snapshot(r_id):
|
||||
snapshot_data = (
|
||||
db.query(RoleModuleAccess, ModuleAccess)
|
||||
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
|
||||
.filter(RoleModuleAccess.role_id == r_id)
|
||||
.all()
|
||||
)
|
||||
snapshot_map = {}
|
||||
for rma, ma in snapshot_data:
|
||||
mid = str(ma.module_id)
|
||||
if mid not in snapshot_map:
|
||||
snapshot_map[mid] = set()
|
||||
snapshot_map[mid].add(ma.access_code)
|
||||
return snapshot_map
|
||||
|
||||
before_snapshot = get_module_permissions_snapshot(role_id)
|
||||
|
||||
update_dict = role_data.model_dump(exclude_unset=True)
|
||||
role_name_changed = "role_name" in update_dict
|
||||
|
||||
if role.is_default and not is_superadmin:
|
||||
raise HTTPException(
|
||||
@@ -102,6 +192,61 @@ class RoleService:
|
||||
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
|
||||
after_snapshot = get_module_permissions_snapshot(role_id)
|
||||
|
||||
all_modules = set(before_snapshot.keys()) | set(after_snapshot.keys())
|
||||
|
||||
env_map = {}
|
||||
if role.tenant_id:
|
||||
tm_assignments = db.query(TenantModule).filter(
|
||||
TenantModule.tenant_id == role.tenant_id,
|
||||
TenantModule.module_id.in_([uuid.UUID(m) for m in all_modules])
|
||||
).all()
|
||||
for tm in tm_assignments:
|
||||
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
|
||||
|
||||
diff_targets = []
|
||||
|
||||
for mid in all_modules:
|
||||
before_set = before_snapshot.get(mid, set())
|
||||
after_set = after_snapshot.get(mid, set())
|
||||
|
||||
added = list(after_set - before_set)
|
||||
removed = list(before_set - after_set)
|
||||
|
||||
is_active_module = mid in after_snapshot and len(after_snapshot[mid]) > 0
|
||||
|
||||
if added or removed or (role_name_changed and is_active_module):
|
||||
env_slug = env_map.get(mid, "prod")
|
||||
diff_targets.append({
|
||||
"module_id": mid,
|
||||
"environment_slug": env_slug,
|
||||
"added_permissions": added,
|
||||
"removed_permissions": removed
|
||||
})
|
||||
|
||||
if diff_targets:
|
||||
provisioning_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
"role_id": str(role.id),
|
||||
"role_name": role.role_name,
|
||||
"tenant_id": str(role.tenant_id) if role.tenant_id else None,
|
||||
"provisioning_id": provisioning_id,
|
||||
"targets": diff_targets
|
||||
}
|
||||
|
||||
logger.info(f"ROLE_UPDATED Payload: {json.dumps(payload, default=str)}")
|
||||
|
||||
EventService.emit_event(
|
||||
db,
|
||||
event_type="ROLE_UPDATED",
|
||||
payload=payload,
|
||||
tenant_id=role.tenant_id
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
@@ -113,6 +258,49 @@ class RoleService:
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Default roles can only be deleted by superadmins.",
|
||||
)
|
||||
|
||||
active_modules = (
|
||||
db.query(ModuleAccess.module_id)
|
||||
.join(RoleModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
|
||||
.filter(RoleModuleAccess.role_id == role.id)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
|
||||
if active_modules:
|
||||
module_ids = [m[0] for m in active_modules]
|
||||
env_map = {}
|
||||
if role.tenant_id:
|
||||
tm_assignments = db.query(TenantModule).filter(
|
||||
TenantModule.tenant_id == role.tenant_id,
|
||||
TenantModule.module_id.in_(module_ids)
|
||||
).all()
|
||||
for tm in tm_assignments:
|
||||
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
|
||||
|
||||
targets = []
|
||||
for m in active_modules:
|
||||
mid = str(m[0])
|
||||
env_slug = env_map.get(mid, "prod")
|
||||
targets.append({"module_id": mid, "environment_slug": env_slug})
|
||||
|
||||
if targets:
|
||||
provisioning_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
"role_id": str(role.id),
|
||||
"tenant_id": str(role.tenant_id) if role.tenant_id else None,
|
||||
"provisioning_id": provisioning_id,
|
||||
"targets": targets
|
||||
}
|
||||
|
||||
logger.info(f"ROLE_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
||||
|
||||
EventService.emit_event(
|
||||
db,
|
||||
event_type="ROLE_DEPROVISION_REQUESTED",
|
||||
payload=payload,
|
||||
tenant_id=role.tenant_id
|
||||
)
|
||||
|
||||
db.delete(role)
|
||||
db.commit()
|
||||
@@ -125,6 +313,10 @@ class RoleService:
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
filter_role_names: Optional[List[str]] = None,
|
||||
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
) -> RolePaginatedResponse:
|
||||
|
||||
query = db.query(Role)
|
||||
@@ -132,6 +324,12 @@ class RoleService:
|
||||
if tenant_id is not None:
|
||||
query = query.filter(Role.tenant_id == tenant_id)
|
||||
|
||||
if filter_role_names:
|
||||
query = query.filter(Role.role_name.in_(filter_role_names))
|
||||
|
||||
if filter_tenant_ids:
|
||||
query = query.filter(Role.tenant_id.in_(filter_tenant_ids))
|
||||
|
||||
if search and search.strip():
|
||||
search_term = search.strip()
|
||||
query = query.filter(
|
||||
@@ -141,6 +339,15 @@ class RoleService:
|
||||
)
|
||||
)
|
||||
|
||||
sort_column_map = {
|
||||
"name": Role.role_name,
|
||||
"tenant": Role.tenant_id,
|
||||
}
|
||||
sort_column = sort_column_map.get(sort_by or "")
|
||||
if sort_column is not None:
|
||||
order_fn = desc if (sort_order or "").lower() == "desc" else asc
|
||||
query = query.order_by(order_fn(sort_column))
|
||||
|
||||
total = query.count()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status
|
||||
from app.models.auth.sso_grant_model import SSOGrant
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
from app.models.auth.user_model import User
|
||||
from app.config.security import security
|
||||
from app.services.auth.trust_service import TrustService
|
||||
from app.core.redis import sync_redis_client
|
||||
import json
|
||||
import time
|
||||
from app.services.auth.subscription_entitlement_service import (
|
||||
SubscriptionEntitlementService,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SSOService:
|
||||
@staticmethod
|
||||
def generate_grant(
|
||||
db: Session,
|
||||
user_id: uuid.UUID,
|
||||
module_id: str,
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Generates a one-time SSO grant code for the specified module.
|
||||
Resolves the correct environment URL based on tenant/user config.
|
||||
"""
|
||||
module = db.query(Module).filter(Module.module_id == module_id).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
if module.status != "active":
|
||||
raise HTTPException(status_code=403, detail="Module is disabled")
|
||||
|
||||
environment_slug = "prod"
|
||||
|
||||
if tenant_id:
|
||||
tm = db.query(TenantModule).filter(
|
||||
TenantModule.tenant_id == tenant_id,
|
||||
TenantModule.module_id == module.id
|
||||
).first()
|
||||
|
||||
if not tm or not tm.is_active:
|
||||
raise HTTPException(status_code=403, detail="Tenant does not have access to this module")
|
||||
|
||||
if tm.assigned_environment_slug:
|
||||
environment_slug = tm.assigned_environment_slug
|
||||
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module.id,
|
||||
ModuleEnvironment.slug == environment_slug
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
env = db.query(ModuleEnvironment).filter(
|
||||
ModuleEnvironment.module_id == module.id,
|
||||
ModuleEnvironment.is_default == True
|
||||
).first()
|
||||
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail="No active environment found for module. Please configure an environment in the Admin Console.")
|
||||
|
||||
grant_code = str(uuid.uuid4().hex)
|
||||
|
||||
redirect_url = f"{env.frontend_base_url}{env.sso_entry_path}?grant={grant_code}"
|
||||
|
||||
grant_data = json.dumps({
|
||||
"user_id": str(user_id),
|
||||
"module_id": str(module.id),
|
||||
"tenant_id": str(tenant_id) if tenant_id else None,
|
||||
"environment_slug": env.slug,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
|
||||
if not sync_redis_client.client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="SSO service temporarily unavailable"
|
||||
)
|
||||
|
||||
sync_redis_client.client.setex(f"sso_grant:{grant_code}", 60, grant_data)
|
||||
|
||||
return {
|
||||
"grant_code": grant_code,
|
||||
"redirect_url": redirect_url
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_signed_payload(
|
||||
db: Session,
|
||||
user_id: uuid.UUID,
|
||||
module_id: str,
|
||||
tenant_id: Optional[uuid.UUID] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generates a signed payload for the client to POST directly to the module backend.
|
||||
"""
|
||||
module = db.query(Module).filter(Module.module_id == module_id).first()
|
||||
if not module or module.status != "active":
|
||||
raise HTTPException(status_code=404, detail="Module not found or disabled")
|
||||
|
||||
environment_slug = "prod"
|
||||
if tenant_id:
|
||||
tm = db.query(TenantModule).filter(TenantModule.tenant_id == tenant_id, TenantModule.module_id == module.id).first()
|
||||
if tm and tm.is_active and tm.assigned_environment_slug:
|
||||
environment_slug = tm.assigned_environment_slug
|
||||
|
||||
env = db.query(ModuleEnvironment).filter(ModuleEnvironment.module_id == module.id, ModuleEnvironment.slug == environment_slug).first()
|
||||
if not env:
|
||||
env = db.query(ModuleEnvironment).filter(ModuleEnvironment.module_id == module.id, ModuleEnvironment.is_default == True).first()
|
||||
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail="No active environment found for module. Please configure an environment in the Admin Console.")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
permissions = sorted(
|
||||
SubscriptionEntitlementService.get_plan_module_access_codes(
|
||||
db, tenant_id, module.id
|
||||
)
|
||||
)
|
||||
if not permissions and user.role and user.role.role_module_accesses:
|
||||
permissions = sorted(
|
||||
{
|
||||
rma.module_access.access_code
|
||||
for rma in user.role.role_module_accesses
|
||||
if rma.module_access and rma.module_access.module_id == module.id
|
||||
}
|
||||
)
|
||||
|
||||
timestamp = int(time.time() * 1000)
|
||||
subscription_details = SubscriptionEntitlementService.get_subscription_summary(
|
||||
db, tenant_id
|
||||
)
|
||||
|
||||
payload_data = {
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"tenant_id": str(tenant_id) if tenant_id else None,
|
||||
"permissions": permissions,
|
||||
"tenant_name": user.tenant.tenant_name if user.tenant else None,
|
||||
"subscription": subscription_details,
|
||||
"timestamp": timestamp,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"role": user.role.role_name if user.role else None
|
||||
}
|
||||
|
||||
tenant_id_str = str(tenant_id) if tenant_id else ""
|
||||
canonical_string = f"user_id={user.id}&email={user.email}&tenant_id={tenant_id_str}×tamp={timestamp}"
|
||||
|
||||
try:
|
||||
signature = TrustService.sign_payload(env, canonical_string)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=500, detail="Module trust configuration error (missing HMAC secret)")
|
||||
|
||||
base_url = env.backend_base_url.rstrip('/')
|
||||
path = env.sso_entry_path if env.sso_entry_path else "/sso/login"
|
||||
|
||||
if not path.startswith('/'):
|
||||
path = '/' + path
|
||||
|
||||
target_url = f"{base_url}{path}"
|
||||
|
||||
return {
|
||||
"target_url": target_url,
|
||||
"payload": payload_data,
|
||||
"headers": {
|
||||
"X-App-Id": "saas",
|
||||
"X-App-Id": module.module_id,
|
||||
"X-Signature": signature
|
||||
},
|
||||
"redirect_url": env.frontend_base_url
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def exchange_grant(
|
||||
db: Session,
|
||||
grant_code: str,
|
||||
module_id: str,
|
||||
environment_slug: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validates grant and returns a short-lived module-scoped token.
|
||||
This is called by the Module Backend.
|
||||
Grants are stored in Redis — atomically deleted on exchange (one-time use).
|
||||
"""
|
||||
if not sync_redis_client.client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="SSO service temporarily unavailable"
|
||||
)
|
||||
|
||||
redis_key = f"sso_grant:{grant_code}"
|
||||
|
||||
pipe = sync_redis_client.client.pipeline()
|
||||
pipe.get(redis_key)
|
||||
pipe.delete(redis_key)
|
||||
grant_json, _ = pipe.execute()
|
||||
|
||||
if not grant_json:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired grant code")
|
||||
|
||||
grant_data = json.loads(grant_json)
|
||||
|
||||
module = db.query(Module).filter(Module.module_id == module_id).first()
|
||||
if not module or str(module.id) != grant_data["module_id"]:
|
||||
raise HTTPException(status_code=401, detail="Grant invalid for this module")
|
||||
|
||||
if grant_data["environment_slug"] != environment_slug:
|
||||
raise HTTPException(status_code=401, detail="Grant invalid for this environment")
|
||||
|
||||
user = db.query(User).filter(User.id == grant_data["user_id"]).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
grant_tenant_id = grant_data.get("tenant_id")
|
||||
if grant_tenant_id:
|
||||
if str(user.tenant_id) != grant_tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Tenant mismatch for SSO grant"
|
||||
)
|
||||
|
||||
permissions = sorted(
|
||||
SubscriptionEntitlementService.get_plan_module_access_codes(
|
||||
db,
|
||||
uuid.UUID(grant_tenant_id) if grant_tenant_id else None,
|
||||
module.id,
|
||||
)
|
||||
)
|
||||
if not permissions and user.role and user.role.role_module_accesses:
|
||||
permissions = sorted(
|
||||
{
|
||||
rma.module_access.access_code
|
||||
for rma in user.role.role_module_accesses
|
||||
if rma.module_access and rma.module_access.module_id == module.id
|
||||
}
|
||||
)
|
||||
|
||||
token_payload = {
|
||||
"sub": str(user.id),
|
||||
"email": user.email,
|
||||
"tenant_id": grant_tenant_id,
|
||||
"module_id": module_id,
|
||||
"environment": environment_slug,
|
||||
"permissions": permissions,
|
||||
"roles": [user.role.role_name] if user.role else []
|
||||
}
|
||||
|
||||
token = security.generate_module_token(token_payload, module_id)
|
||||
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": 900,
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.auth.access_model import Access
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
from app.models.auth.plan_access_model import PlanAccess
|
||||
from app.models.auth.plan_module_access_model import PlanModuleAccess
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
from app.models.auth.user_model import User
|
||||
|
||||
|
||||
class SubscriptionEntitlementService:
|
||||
@staticmethod
|
||||
def get_tenant(db: Session, tenant_id: Optional[uuid.UUID]) -> Optional[Tenant]:
|
||||
if not tenant_id:
|
||||
return None
|
||||
return db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_plan_access_codes(
|
||||
db: Session,
|
||||
tenant_id: Optional[uuid.UUID],
|
||||
) -> Set[str]:
|
||||
tenant = SubscriptionEntitlementService.get_tenant(db, tenant_id)
|
||||
if not tenant or not tenant.plan_id:
|
||||
return set()
|
||||
|
||||
rows = (
|
||||
db.query(Access.access_code)
|
||||
.join(PlanAccess, PlanAccess.access_id == Access.id)
|
||||
.filter(PlanAccess.plan_id == tenant.plan_id)
|
||||
.all()
|
||||
)
|
||||
return {row[0] for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def get_plan_module_access_codes(
|
||||
db: Session,
|
||||
tenant_id: Optional[uuid.UUID],
|
||||
module_id: Optional[uuid.UUID] = None,
|
||||
) -> Set[str]:
|
||||
tenant = SubscriptionEntitlementService.get_tenant(db, tenant_id)
|
||||
if not tenant or not tenant.plan_id:
|
||||
return set()
|
||||
|
||||
query = (
|
||||
db.query(ModuleAccess.access_code)
|
||||
.join(
|
||||
PlanModuleAccess,
|
||||
PlanModuleAccess.module_access_id == ModuleAccess.id,
|
||||
)
|
||||
.filter(PlanModuleAccess.plan_id == tenant.plan_id)
|
||||
)
|
||||
if module_id:
|
||||
query = query.filter(ModuleAccess.module_id == module_id)
|
||||
|
||||
rows = query.all()
|
||||
return {row[0] for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def get_effective_access_codes(db: Session, user: User) -> Set[str]:
|
||||
role_codes = set()
|
||||
if user.role:
|
||||
role_codes = {
|
||||
ra.access.access_code
|
||||
for ra in user.role.role_accesses
|
||||
if ra.access is not None
|
||||
}
|
||||
|
||||
plan_codes = SubscriptionEntitlementService.get_plan_access_codes(
|
||||
db, user.tenant_id
|
||||
)
|
||||
return role_codes | plan_codes
|
||||
|
||||
@staticmethod
|
||||
def get_subscription_summary(
|
||||
db: Session, tenant_id: Optional[uuid.UUID]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
tenant = SubscriptionEntitlementService.get_tenant(db, tenant_id)
|
||||
if not tenant:
|
||||
return None
|
||||
|
||||
plan_name = tenant.plan.name if tenant.plan else None
|
||||
return {
|
||||
"plan_id": str(tenant.plan_id) if tenant.plan_id else None,
|
||||
"plan_name": plan_name,
|
||||
"max_users_allowed": tenant.plan.max_users_allowed if tenant.plan else None,
|
||||
"start_date": tenant.start_date.isoformat() if tenant.start_date else None,
|
||||
"end_date": tenant.end_date.isoformat() if tenant.end_date else None,
|
||||
"status": tenant.status,
|
||||
"is_active": tenant.is_active,
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import or_, cast, String, asc, desc
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models.auth.subscription_plan_model import SubscriptionPlan
|
||||
from app.models.auth.plan_access_model import PlanAccess
|
||||
from app.models.auth.plan_module_access_model import PlanModuleAccess
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
from app.models.auth.role_model import Role
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from app.schemas.auth.role_schema import RoleUpdate
|
||||
from app.services.auth.role_service import RoleService
|
||||
from app.services.auth.event_service import EventService
|
||||
from app.schemas.auth.subscription_plan_schema import (
|
||||
SubscriptionPlanCreate,
|
||||
SubscriptionPlanUpdate,
|
||||
SubscriptionPlanPaginatedResponse,
|
||||
SubscriptionPlanResponse
|
||||
)
|
||||
|
||||
class SubscriptionPlanService:
|
||||
@staticmethod
|
||||
def _resolve_environment_slug(
|
||||
db: Session,
|
||||
module_id: uuid.UUID,
|
||||
assigned_environment_slug: Optional[str],
|
||||
) -> Optional[str]:
|
||||
if assigned_environment_slug:
|
||||
return assigned_environment_slug
|
||||
|
||||
default_env = (
|
||||
db.query(ModuleEnvironment)
|
||||
.filter(
|
||||
ModuleEnvironment.module_id == module_id,
|
||||
ModuleEnvironment.is_default == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if default_env:
|
||||
return default_env.slug
|
||||
|
||||
any_env = (
|
||||
db.query(ModuleEnvironment)
|
||||
.filter(ModuleEnvironment.module_id == module_id)
|
||||
.first()
|
||||
)
|
||||
return any_env.slug if any_env else None
|
||||
|
||||
@staticmethod
|
||||
def _sync_tenant_default_roles_for_plan(
|
||||
db: Session,
|
||||
plan_id: uuid.UUID,
|
||||
) -> None:
|
||||
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
return
|
||||
|
||||
plan_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan_id).all()
|
||||
plan_module_accesses = (
|
||||
db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan_id).all()
|
||||
)
|
||||
effective_access_ids = [row.access_id for row in plan_accesses] + [
|
||||
row.module_access_id for row in plan_module_accesses
|
||||
]
|
||||
|
||||
tenants = db.query(Tenant).filter(Tenant.plan_id == plan_id).all()
|
||||
for tenant in tenants:
|
||||
default_role = (
|
||||
db.query(Role)
|
||||
.filter(Role.tenant_id == tenant.id, Role.is_default == True)
|
||||
.first()
|
||||
)
|
||||
if default_role:
|
||||
RoleService.update_role(
|
||||
db,
|
||||
default_role.id,
|
||||
RoleUpdate(access_ids=effective_access_ids),
|
||||
is_superadmin=True,
|
||||
)
|
||||
|
||||
active_modules = (
|
||||
db.query(TenantModule)
|
||||
.filter(
|
||||
TenantModule.tenant_id == tenant.id,
|
||||
TenantModule.is_active == True,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not active_modules:
|
||||
continue
|
||||
|
||||
targets = []
|
||||
for tm in active_modules:
|
||||
env_slug = SubscriptionPlanService._resolve_environment_slug(
|
||||
db,
|
||||
tm.module_id,
|
||||
tm.assigned_environment_slug,
|
||||
)
|
||||
if env_slug:
|
||||
targets.append(
|
||||
{
|
||||
"module_id": str(tm.module_id),
|
||||
"environment_slug": env_slug,
|
||||
}
|
||||
)
|
||||
|
||||
if not targets:
|
||||
continue
|
||||
|
||||
EventService.emit_event(
|
||||
db,
|
||||
event_type="TENANT_UPDATED",
|
||||
payload={
|
||||
"tenant_id": str(tenant.id),
|
||||
"tenant_name": tenant.tenant_name,
|
||||
"tenant_domain": tenant.tenant_domain,
|
||||
"tenant_logo_url": tenant.tenant_logo_url,
|
||||
"max_users_allowed": plan.max_users_allowed,
|
||||
"targets": targets,
|
||||
},
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def create_plan(db: Session, plan_data: SubscriptionPlanCreate) -> SubscriptionPlan:
|
||||
existing = db.query(SubscriptionPlan).filter(SubscriptionPlan.name == plan_data.name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Plan name already exists")
|
||||
|
||||
plan = SubscriptionPlan(
|
||||
name=plan_data.name,
|
||||
description=plan_data.description,
|
||||
price=plan_data.price,
|
||||
duration_days=plan_data.duration_days,
|
||||
max_users_allowed=plan_data.max_users_allowed,
|
||||
is_public=plan_data.is_public,
|
||||
status=plan_data.status
|
||||
)
|
||||
db.add(plan)
|
||||
db.flush()
|
||||
|
||||
if plan_data.access_ids:
|
||||
for acc_id in plan_data.access_ids:
|
||||
db.add(PlanAccess(plan_id=plan.id, access_id=acc_id))
|
||||
|
||||
if plan_data.module_access_ids:
|
||||
for macc_id in plan_data.module_access_ids:
|
||||
db.add(PlanModuleAccess(plan_id=plan.id, module_access_id=macc_id))
|
||||
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan
|
||||
|
||||
@staticmethod
|
||||
def update_plan(db: Session, plan_id: uuid.UUID, plan_data: SubscriptionPlanUpdate) -> SubscriptionPlan:
|
||||
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found")
|
||||
|
||||
update_dict = plan_data.model_dump(exclude_unset=True)
|
||||
|
||||
if "name" in update_dict and update_dict["name"] != plan.name:
|
||||
existing = db.query(SubscriptionPlan).filter(SubscriptionPlan.name == update_dict["name"]).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Plan name already exists")
|
||||
|
||||
if "access_ids" in update_dict:
|
||||
access_ids = update_dict.pop("access_ids")
|
||||
db.query(PlanAccess).filter(PlanAccess.plan_id == plan.id).delete()
|
||||
if access_ids:
|
||||
for acc_id in access_ids:
|
||||
db.add(PlanAccess(plan_id=plan.id, access_id=acc_id))
|
||||
|
||||
if "module_access_ids" in update_dict:
|
||||
module_access_ids = update_dict.pop("module_access_ids")
|
||||
db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).delete()
|
||||
if module_access_ids:
|
||||
for macc_id in module_access_ids:
|
||||
db.add(PlanModuleAccess(plan_id=plan.id, module_access_id=macc_id))
|
||||
|
||||
for key, value in update_dict.items():
|
||||
setattr(plan, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
SubscriptionPlanService._sync_tenant_default_roles_for_plan(db, plan.id)
|
||||
db.commit()
|
||||
return plan
|
||||
|
||||
@staticmethod
|
||||
def get_plan(db: Session, plan_id: uuid.UUID) -> SubscriptionPlan:
|
||||
plan = (
|
||||
db.query(SubscriptionPlan)
|
||||
.options(
|
||||
joinedload(SubscriptionPlan.plan_accesses),
|
||||
joinedload(SubscriptionPlan.plan_module_accesses)
|
||||
)
|
||||
.filter(SubscriptionPlan.id == plan_id)
|
||||
.first()
|
||||
)
|
||||
if not plan:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found")
|
||||
return plan
|
||||
|
||||
@staticmethod
|
||||
def get_all_plans(db: Session, is_public: Optional[bool] = None, status: Optional[str] = None):
|
||||
query = db.query(SubscriptionPlan)
|
||||
if is_public is not None:
|
||||
query = query.filter(SubscriptionPlan.is_public == is_public)
|
||||
if status is not None:
|
||||
query = query.filter(SubscriptionPlan.status == status)
|
||||
return query.all()
|
||||
|
||||
@staticmethod
|
||||
def get_paginated_plans(
|
||||
db: Session,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
filter_names: Optional[List[str]] = None,
|
||||
statuses: Optional[List[str]] = None,
|
||||
visibility: Optional[List[bool]] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
) -> SubscriptionPlanPaginatedResponse:
|
||||
query = db.query(SubscriptionPlan)
|
||||
|
||||
if filter_names:
|
||||
query = query.filter(SubscriptionPlan.name.in_(filter_names))
|
||||
|
||||
if statuses:
|
||||
query = query.filter(SubscriptionPlan.status.in_(statuses))
|
||||
|
||||
if visibility is not None and len(visibility) > 0:
|
||||
query = query.filter(SubscriptionPlan.is_public.in_(visibility))
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
SubscriptionPlan.name.ilike(f"%{search}%"),
|
||||
cast(SubscriptionPlan.id, String).ilike(f"%{search}%")
|
||||
)
|
||||
)
|
||||
|
||||
sort_column_map = {
|
||||
"name": SubscriptionPlan.name,
|
||||
"price": SubscriptionPlan.price,
|
||||
"status": SubscriptionPlan.status,
|
||||
"visibility": SubscriptionPlan.is_public,
|
||||
}
|
||||
sort_column = sort_column_map.get(sort_by or "")
|
||||
if sort_column is not None:
|
||||
order_fn = desc if (sort_order or "").lower() == "desc" else asc
|
||||
query = query.order_by(order_fn(sort_column))
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * page_size
|
||||
plans = query.offset(offset).limit(page_size).all()
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
||||
|
||||
return SubscriptionPlanPaginatedResponse(
|
||||
items=[SubscriptionPlanResponse.from_orm(p) for p in plans],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def delete_plan(db: Session, plan_id: uuid.UUID):
|
||||
plan = SubscriptionPlanService.get_plan(db, plan_id)
|
||||
db.delete(plan)
|
||||
db.commit()
|
||||
return {"message": "Plan deleted successfully"}
|
||||
@@ -0,0 +1,87 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import HTTPException
|
||||
from typing import List, Tuple
|
||||
import uuid
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
from app.models.auth.module_model import Module
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
from app.schemas.auth.tenant_module_schema import TenantModuleCreate, TenantModuleUpdate
|
||||
|
||||
class TenantModuleService:
|
||||
@staticmethod
|
||||
def list_tenant_modules(db: Session, tenant_id: str) -> List[Tuple[TenantModule, Module]]:
|
||||
tenant = db.query(Tenant).filter(Tenant.id == uuid.UUID(tenant_id)).first()
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
return db.query(TenantModule, Module).join(
|
||||
Module, TenantModule.module_id == Module.id
|
||||
).filter(
|
||||
TenantModule.tenant_id == tenant.id
|
||||
).all()
|
||||
|
||||
@staticmethod
|
||||
def assign_module(db: Session, tenant_id: str, assignment_data: TenantModuleCreate) -> TenantModule:
|
||||
tenant = db.query(Tenant).filter(Tenant.id == uuid.UUID(tenant_id)).first()
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
module = db.query(Module).filter(Module.id == uuid.UUID(assignment_data.module_id)).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
existing = db.query(TenantModule).filter(
|
||||
TenantModule.tenant_id == tenant.id,
|
||||
TenantModule.module_id == module.id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Module already assigned to this tenant")
|
||||
|
||||
try:
|
||||
tenant_module = TenantModule(
|
||||
tenant_id=tenant.id,
|
||||
module_id=module.id,
|
||||
assigned_environment_slug=assignment_data.assigned_environment_slug,
|
||||
is_active=assignment_data.is_active,
|
||||
module_config=assignment_data.module_config
|
||||
)
|
||||
db.add(tenant_module)
|
||||
db.commit()
|
||||
db.refresh(tenant_module)
|
||||
return tenant_module
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Module assignment conflict")
|
||||
|
||||
@staticmethod
|
||||
def update_assignment(db: Session, tenant_id: str, tenant_module_id: str, update_data: TenantModuleUpdate) -> TenantModule:
|
||||
tenant_module = db.query(TenantModule).filter(
|
||||
TenantModule.id == uuid.UUID(tenant_module_id),
|
||||
TenantModule.tenant_id == uuid.UUID(tenant_id)
|
||||
).first()
|
||||
|
||||
if not tenant_module:
|
||||
raise HTTPException(status_code=404, detail="Tenant module assignment not found")
|
||||
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
for key, value in update_dict.items():
|
||||
setattr(tenant_module, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(tenant_module)
|
||||
return tenant_module
|
||||
|
||||
@staticmethod
|
||||
def remove_assignment(db: Session, tenant_id: str, tenant_module_id: str):
|
||||
tenant_module = db.query(TenantModule).filter(
|
||||
TenantModule.id == uuid.UUID(tenant_module_id),
|
||||
TenantModule.tenant_id == uuid.UUID(tenant_id)
|
||||
).first()
|
||||
|
||||
if not tenant_module:
|
||||
raise HTTPException(status_code=404, detail="Tenant module assignment not found")
|
||||
|
||||
db.delete(tenant_module)
|
||||
db.commit()
|
||||
@@ -1,13 +1,83 @@
|
||||
from datetime import date, datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, cast, String
|
||||
from sqlalchemy import or_, cast, String, asc, desc
|
||||
from fastapi import HTTPException, status
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
from app.models.auth.subscription_plan_model import SubscriptionPlan
|
||||
from app.models.auth.plan_access_model import PlanAccess
|
||||
from app.models.auth.plan_module_access_model import PlanModuleAccess
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
from app.models.auth.role_model import Role
|
||||
from app.models.auth.role_module_access_model import RoleModuleAccess
|
||||
from app.schemas.auth.tenant_schema import TenantCreate, TenantUpdate, TenantPaginatedResponse, TenantResponse
|
||||
from app.schemas.auth.role_schema import RoleCreate, RoleUpdate
|
||||
from app.services.auth.role_service import RoleService
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
from app.services.auth.event_service import EventService
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TenantService:
|
||||
|
||||
STATUS_ACTIVE = "ACTIVE"
|
||||
STATUS_INACTIVE = "INACTIVE"
|
||||
STATUS_EXPIRED = "EXPIRED"
|
||||
|
||||
@staticmethod
|
||||
def _today() -> date:
|
||||
return datetime.now(timezone.utc).date()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_status(status_value: Optional[str], is_active: bool) -> str:
|
||||
normalized = (status_value or "").strip().upper()
|
||||
if normalized:
|
||||
return normalized
|
||||
return TenantService.STATUS_ACTIVE if is_active else TenantService.STATUS_INACTIVE
|
||||
|
||||
@staticmethod
|
||||
def _resolve_lifecycle(
|
||||
*,
|
||||
start_date: Optional[date],
|
||||
end_date: Optional[date],
|
||||
status_value: Optional[str],
|
||||
is_active: bool,
|
||||
) -> tuple[str, bool]:
|
||||
if start_date and end_date and end_date < start_date:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="end_date must be greater than or equal to start_date",
|
||||
)
|
||||
|
||||
normalized_status = TenantService._normalize_status(status_value, is_active)
|
||||
if end_date and end_date <= TenantService._today():
|
||||
return TenantService.STATUS_EXPIRED, False
|
||||
if normalized_status in {
|
||||
TenantService.STATUS_INACTIVE,
|
||||
TenantService.STATUS_EXPIRED,
|
||||
}:
|
||||
return normalized_status, False
|
||||
return normalized_status, True
|
||||
|
||||
@staticmethod
|
||||
def _sync_tenant_lifecycle(tenant: Tenant) -> bool:
|
||||
resolved_status, resolved_is_active = TenantService._resolve_lifecycle(
|
||||
start_date=tenant.start_date,
|
||||
end_date=tenant.end_date,
|
||||
status_value=tenant.status,
|
||||
is_active=tenant.is_active,
|
||||
)
|
||||
|
||||
changed = (
|
||||
tenant.status != resolved_status
|
||||
or tenant.is_active != resolved_is_active
|
||||
)
|
||||
if changed:
|
||||
tenant.status = resolved_status
|
||||
tenant.is_active = resolved_is_active
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def create_tenant(db: Session, tenant_data: TenantCreate) -> Tenant:
|
||||
existing = db.query(Tenant).filter(Tenant.tenant_name == tenant_data.tenant_name).first()
|
||||
@@ -23,21 +93,158 @@ class TenantService:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant domain already exists"
|
||||
)
|
||||
|
||||
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == tenant_data.plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid plan_id provided"
|
||||
)
|
||||
|
||||
tenant = Tenant(
|
||||
tenant_name=tenant_data.tenant_name,
|
||||
tenant_domain=tenant_data.tenant_domain,
|
||||
tenant_logo_url=tenant_data.tenant_logo_url
|
||||
)
|
||||
provisioning_id = str(uuid.uuid4())
|
||||
|
||||
db.add(tenant)
|
||||
db.commit()
|
||||
db.refresh(tenant)
|
||||
return tenant
|
||||
try:
|
||||
# 1. Create Tenant
|
||||
tenant = Tenant(
|
||||
tenant_name=tenant_data.tenant_name,
|
||||
tenant_domain=tenant_data.tenant_domain,
|
||||
tenant_logo_url=tenant_data.tenant_logo_url,
|
||||
plan_id=tenant_data.plan_id,
|
||||
start_date=tenant_data.start_date,
|
||||
end_date=tenant_data.end_date,
|
||||
)
|
||||
tenant.status, tenant.is_active = TenantService._resolve_lifecycle(
|
||||
start_date=tenant.start_date,
|
||||
end_date=tenant.end_date,
|
||||
status_value=tenant_data.status,
|
||||
is_active=tenant.is_active,
|
||||
)
|
||||
db.add(tenant)
|
||||
db.flush()
|
||||
|
||||
plan_module_accesses = db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).all()
|
||||
mod_access_ids = [pma.module_access_id for pma in plan_module_accesses]
|
||||
|
||||
distinct_module_ids = []
|
||||
if mod_access_ids:
|
||||
modules_query = db.query(ModuleAccess.module_id).filter(ModuleAccess.id.in_(mod_access_ids)).distinct().all()
|
||||
distinct_module_ids = [m[0] for m in modules_query]
|
||||
|
||||
module_env_map = {}
|
||||
if tenant_data.module_environments:
|
||||
for me in tenant_data.module_environments:
|
||||
module_env_map[me.module_id] = me.environment_slug
|
||||
|
||||
event_targets = []
|
||||
for mod_id in distinct_module_ids:
|
||||
env_slug = module_env_map.get(mod_id, tenant_data.default_environment_slug)
|
||||
tm = TenantModule(
|
||||
tenant_id=tenant.id,
|
||||
module_id=mod_id,
|
||||
assigned_environment_slug=env_slug,
|
||||
is_active=True
|
||||
)
|
||||
db.add(tm)
|
||||
|
||||
event_targets.append({
|
||||
"module_id": str(mod_id),
|
||||
"environment_slug": env_slug
|
||||
})
|
||||
|
||||
db.flush()
|
||||
|
||||
plan_saas_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan.id).all()
|
||||
all_access_ids = [a.access_id for a in plan_saas_accesses] + mod_access_ids
|
||||
|
||||
role_create_data = RoleCreate(
|
||||
role_name="Primary Admin",
|
||||
tenant_id=tenant.id,
|
||||
is_default=True,
|
||||
access_ids=all_access_ids
|
||||
)
|
||||
role = RoleService.create_role(db, role_create_data, emit_events=False)
|
||||
|
||||
role_module_perms = (
|
||||
db.query(RoleModuleAccess, ModuleAccess)
|
||||
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
|
||||
.filter(RoleModuleAccess.role_id == role.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
role_follow_up = None
|
||||
if role_module_perms:
|
||||
module_map = {}
|
||||
for rma, ma in role_module_perms:
|
||||
mid = str(ma.module_id)
|
||||
if mid not in module_map:
|
||||
module_map[mid] = []
|
||||
module_map[mid].append(ma.access_code)
|
||||
|
||||
role_targets = []
|
||||
for target in event_targets:
|
||||
mid = target["module_id"]
|
||||
if mid in module_map:
|
||||
role_targets.append({
|
||||
"module_id": mid,
|
||||
"environment_slug": target["environment_slug"],
|
||||
"permissions": module_map[mid]
|
||||
})
|
||||
|
||||
if role_targets:
|
||||
role_follow_up = {
|
||||
"event_type": "ROLE_PROVISION_REQUESTED",
|
||||
"tenant_id": str(tenant.id),
|
||||
"payload": {
|
||||
"role_id": str(role.id),
|
||||
"role_name": role.role_name,
|
||||
"tenant_id": str(tenant.id),
|
||||
"provisioning_id": str(uuid.uuid4()),
|
||||
"targets": role_targets
|
||||
}
|
||||
}
|
||||
|
||||
if event_targets:
|
||||
logger.info(f"Creating tenant {tenant.tenant_name}. Processing {len(event_targets)} event targets.")
|
||||
|
||||
payload = {
|
||||
"tenant_id": str(tenant.id),
|
||||
"tenant_name": tenant.tenant_name,
|
||||
"tenant_domain": tenant.tenant_domain,
|
||||
"tenant_logo_url": tenant.tenant_logo_url,
|
||||
"max_users_allowed": plan.max_users_allowed,
|
||||
"provisioning_id": provisioning_id,
|
||||
"targets": event_targets
|
||||
}
|
||||
|
||||
EventService.emit_event(
|
||||
db,
|
||||
event_type="TENANT_PROVISION_REQUESTED",
|
||||
payload=payload,
|
||||
tenant_id=tenant.id,
|
||||
follow_up_event=role_follow_up
|
||||
)
|
||||
logger.info(f"Event TENANT_PROVISION_REQUESTED emitted to outbox{' (with ROLE_PROVISION_REQUESTED follow-up)' if role_follow_up else ''}.")
|
||||
|
||||
db.commit()
|
||||
db.refresh(tenant)
|
||||
|
||||
return tenant
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def get_all_tenants(db: Session):
|
||||
return db.query(Tenant).all()
|
||||
tenants = db.query(Tenant).all()
|
||||
changed = False
|
||||
for tenant in tenants:
|
||||
changed = TenantService._sync_tenant_lifecycle(tenant) or changed
|
||||
if changed:
|
||||
db.commit()
|
||||
for tenant in tenants:
|
||||
db.refresh(tenant)
|
||||
return tenants
|
||||
|
||||
@staticmethod
|
||||
def get_tenant_by_id(db: Session, tenant_id: uuid.UUID) -> Tenant:
|
||||
@@ -47,6 +254,9 @@ class TenantService:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tenant not found"
|
||||
)
|
||||
if TenantService._sync_tenant_lifecycle(tenant):
|
||||
db.commit()
|
||||
db.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
@staticmethod
|
||||
@@ -55,19 +265,195 @@ class TenantService:
|
||||
|
||||
update_dict = tenant_data.model_dump(exclude_unset=True)
|
||||
|
||||
should_emit_update = False
|
||||
should_emit_status = False
|
||||
|
||||
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")
|
||||
should_emit_update = True
|
||||
|
||||
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")
|
||||
|
||||
should_emit_update = True
|
||||
|
||||
if "tenant_logo_url" in update_dict and update_dict["tenant_logo_url"] != tenant.tenant_logo_url:
|
||||
should_emit_update = True
|
||||
|
||||
if "is_active" in update_dict and update_dict["is_active"] != tenant.is_active:
|
||||
should_emit_status = True
|
||||
|
||||
if "plan_id" in update_dict and update_dict["plan_id"] != tenant.plan_id:
|
||||
new_plan_id = update_dict["plan_id"]
|
||||
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == new_plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=400, detail="Invalid plan_id")
|
||||
|
||||
plan_module_accesses = db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).all()
|
||||
mod_access_ids = [pma.module_access_id for pma in plan_module_accesses]
|
||||
|
||||
new_modules_set = set()
|
||||
if mod_access_ids:
|
||||
modules_query = db.query(ModuleAccess.module_id).filter(ModuleAccess.id.in_(mod_access_ids)).distinct().all()
|
||||
new_modules_set = {m[0] for m in modules_query}
|
||||
|
||||
current_modules = db.query(TenantModule).filter(TenantModule.tenant_id == tenant.id).all()
|
||||
current_map = {tm.module_id: tm for tm in current_modules}
|
||||
|
||||
event_targets = []
|
||||
provisioning_id = str(uuid.uuid4())
|
||||
|
||||
default_env = "prod"
|
||||
|
||||
for mod_id in new_modules_set:
|
||||
if mod_id in current_map:
|
||||
tm = current_map[mod_id]
|
||||
if not tm.is_active:
|
||||
tm.is_active = True
|
||||
event_targets.append({
|
||||
"module_id": str(mod_id),
|
||||
"environment_slug": tm.assigned_environment_slug or default_env
|
||||
})
|
||||
else:
|
||||
tm = TenantModule(
|
||||
tenant_id=tenant.id,
|
||||
module_id=mod_id,
|
||||
assigned_environment_slug=default_env,
|
||||
is_active=True
|
||||
)
|
||||
db.add(tm)
|
||||
event_targets.append({
|
||||
"module_id": str(mod_id),
|
||||
"environment_slug": default_env
|
||||
})
|
||||
|
||||
for mod_id, tm in current_map.items():
|
||||
if mod_id not in new_modules_set and tm.is_active:
|
||||
tm.is_active = False
|
||||
|
||||
db.flush()
|
||||
|
||||
plan_saas_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan.id).all()
|
||||
all_access_ids = [a.access_id for a in plan_saas_accesses] + mod_access_ids
|
||||
|
||||
default_role = db.query(Role).filter(Role.tenant_id == tenant.id, Role.is_default == True).first()
|
||||
if default_role:
|
||||
role_update_data = RoleUpdate(access_ids=all_access_ids)
|
||||
RoleService.update_role(db, default_role.id, role_update_data, is_superadmin=True)
|
||||
|
||||
role_follow_up = None
|
||||
if event_targets and default_role:
|
||||
role_module_perms = (
|
||||
db.query(RoleModuleAccess, ModuleAccess)
|
||||
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
|
||||
.filter(RoleModuleAccess.role_id == default_role.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
module_map = {}
|
||||
for rma, ma in role_module_perms:
|
||||
mid = str(ma.module_id)
|
||||
if mid not in module_map:
|
||||
module_map[mid] = []
|
||||
module_map[mid].append(ma.access_code)
|
||||
|
||||
role_targets = []
|
||||
for target in event_targets:
|
||||
mid = target["module_id"]
|
||||
if mid in module_map:
|
||||
role_targets.append({
|
||||
"module_id": mid,
|
||||
"environment_slug": target["environment_slug"],
|
||||
"permissions": module_map[mid]
|
||||
})
|
||||
|
||||
if role_targets:
|
||||
role_follow_up = {
|
||||
"event_type": "ROLE_PROVISION_REQUESTED",
|
||||
"tenant_id": str(tenant.id),
|
||||
"payload": {
|
||||
"role_id": str(default_role.id),
|
||||
"role_name": default_role.role_name,
|
||||
"tenant_id": str(tenant.id),
|
||||
"provisioning_id": str(uuid.uuid4()),
|
||||
"targets": role_targets
|
||||
}
|
||||
}
|
||||
|
||||
if event_targets:
|
||||
payload = {
|
||||
"tenant_id": str(tenant.id),
|
||||
"tenant_name": tenant.tenant_name,
|
||||
"max_users_allowed": plan.max_users_allowed,
|
||||
"provisioning_id": provisioning_id,
|
||||
"targets": event_targets
|
||||
}
|
||||
EventService.emit_event(
|
||||
db,
|
||||
event_type="TENANT_PROVISION_REQUESTED",
|
||||
payload=payload,
|
||||
tenant_id=tenant.id,
|
||||
follow_up_event=role_follow_up
|
||||
)
|
||||
|
||||
for key, value in update_dict.items():
|
||||
setattr(tenant, key, value)
|
||||
|
||||
|
||||
if any(key in update_dict for key in ("start_date", "end_date", "status", "is_active")):
|
||||
tenant.status, tenant.is_active = TenantService._resolve_lifecycle(
|
||||
start_date=tenant.start_date,
|
||||
end_date=tenant.end_date,
|
||||
status_value=update_dict.get("status"),
|
||||
is_active=tenant.is_active,
|
||||
)
|
||||
should_emit_status = True
|
||||
|
||||
if should_emit_update or should_emit_status:
|
||||
active_modules = db.query(TenantModule).filter(
|
||||
TenantModule.tenant_id == tenant.id,
|
||||
TenantModule.is_active == True
|
||||
).all()
|
||||
|
||||
broadcast_targets = [
|
||||
{"module_id": str(tm.module_id), "environment_slug": tm.assigned_environment_slug or "prod"}
|
||||
for tm in active_modules
|
||||
]
|
||||
|
||||
if broadcast_targets:
|
||||
|
||||
if should_emit_update:
|
||||
payload = {
|
||||
"tenant_id": str(tenant.id),
|
||||
"tenant_name": tenant.tenant_name,
|
||||
"tenant_domain": tenant.tenant_domain,
|
||||
"tenant_logo_url": tenant.tenant_logo_url,
|
||||
"max_users_allowed": tenant.plan.max_users_allowed if tenant.plan else None,
|
||||
"targets": broadcast_targets
|
||||
}
|
||||
EventService.emit_event(
|
||||
db,
|
||||
event_type="TENANT_UPDATED",
|
||||
payload=payload,
|
||||
tenant_id=tenant.id
|
||||
)
|
||||
|
||||
if should_emit_status:
|
||||
payload = {
|
||||
"tenant_id": str(tenant.id),
|
||||
"is_active": tenant.is_active,
|
||||
"status": "ACTIVE" if tenant.is_active else "INACTIVE",
|
||||
"targets": broadcast_targets
|
||||
}
|
||||
EventService.emit_event(
|
||||
db,
|
||||
event_type="TENANT_STATUS_CHANGED",
|
||||
payload=payload,
|
||||
tenant_id=tenant.id
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(tenant)
|
||||
return tenant
|
||||
@@ -75,6 +461,33 @@ class TenantService:
|
||||
@staticmethod
|
||||
def delete_tenant(db: Session, tenant_id: uuid.UUID):
|
||||
tenant = TenantService.get_tenant_by_id(db, tenant_id)
|
||||
|
||||
active_modules = db.query(TenantModule).filter(
|
||||
TenantModule.tenant_id == tenant.id,
|
||||
TenantModule.is_active == True
|
||||
).all()
|
||||
|
||||
if active_modules:
|
||||
broadcast_targets = [
|
||||
{"module_id": str(tm.module_id), "environment_slug": tm.assigned_environment_slug or "prod"}
|
||||
for tm in active_modules
|
||||
]
|
||||
|
||||
if broadcast_targets:
|
||||
|
||||
payload = {
|
||||
"tenant_id": str(tenant.id),
|
||||
"tenant_name": tenant.tenant_name,
|
||||
"targets": broadcast_targets
|
||||
}
|
||||
|
||||
EventService.emit_event(
|
||||
db,
|
||||
event_type="TENANT_DEPROVISION_REQUESTED",
|
||||
payload=payload,
|
||||
tenant_id=tenant.id
|
||||
)
|
||||
|
||||
db.delete(tenant)
|
||||
db.commit()
|
||||
return {"message": "Tenant deleted successfully"}
|
||||
@@ -86,10 +499,25 @@ class TenantService:
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
filter_tenant_names: Optional[List[str]] = None,
|
||||
filter_tenant_domains: Optional[List[str]] = None,
|
||||
filter_plan_ids: Optional[List[uuid.UUID]] = None,
|
||||
statuses: Optional[List[bool]] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
) -> TenantPaginatedResponse:
|
||||
|
||||
query = db.query(Tenant)
|
||||
|
||||
if filter_tenant_names:
|
||||
query = query.filter(Tenant.tenant_name.in_(filter_tenant_names))
|
||||
|
||||
if filter_tenant_domains:
|
||||
query = query.filter(Tenant.tenant_domain.in_(filter_tenant_domains))
|
||||
|
||||
if filter_plan_ids:
|
||||
query = query.filter(Tenant.plan_id.in_(filter_plan_ids))
|
||||
|
||||
if search and search.strip():
|
||||
search_term = search.strip()
|
||||
query = query.filter(
|
||||
@@ -103,11 +531,33 @@ class TenantService:
|
||||
if is_active is not None:
|
||||
query = query.filter(Tenant.is_active == is_active)
|
||||
|
||||
if statuses is not None and len(statuses) > 0:
|
||||
query = query.filter(Tenant.is_active.in_(statuses))
|
||||
|
||||
sort_column_map = {
|
||||
"name": Tenant.tenant_name,
|
||||
"domain": Tenant.tenant_domain,
|
||||
"status": Tenant.is_active,
|
||||
"plan": Tenant.plan_id,
|
||||
}
|
||||
sort_column = sort_column_map.get(sort_by or "")
|
||||
if sort_column is not None:
|
||||
order_fn = desc if (sort_order or "").lower() == "desc" else asc
|
||||
query = query.order_by(order_fn(sort_column))
|
||||
|
||||
total = query.count()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
tenants = query.offset(offset).limit(page_size).all()
|
||||
|
||||
changed = False
|
||||
for tenant in tenants:
|
||||
changed = TenantService._sync_tenant_lifecycle(tenant) or changed
|
||||
if changed:
|
||||
db.commit()
|
||||
for tenant in tenants:
|
||||
db.refresh(tenant)
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
||||
|
||||
return TenantPaginatedResponse(
|
||||
@@ -116,4 +566,4 @@ class TenantService:
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import hmac
|
||||
import hashlib
|
||||
from typing import Dict, Any, Optional
|
||||
from app.models.auth.module_environment_model import ModuleEnvironment
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
class TrustService:
|
||||
@staticmethod
|
||||
def verify_request_signature(environment: ModuleEnvironment, signature: str, payload: str = "") -> bool:
|
||||
"""
|
||||
Verify the HMAC signature of an incoming request from a module.
|
||||
Currently supports HMAC-SHA256.
|
||||
"""
|
||||
if environment.trust_type != "hmac":
|
||||
if environment.trust_type == "static_key":
|
||||
secret = environment.trust_credentials.get("secret_key")
|
||||
return hmac.compare_digest(signature, secret)
|
||||
return False
|
||||
|
||||
secret = environment.trust_credentials.get("hmac_secret")
|
||||
if not secret:
|
||||
return False
|
||||
|
||||
expected_signature = hmac.new(
|
||||
secret.encode(),
|
||||
payload.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected_signature, signature)
|
||||
|
||||
@staticmethod
|
||||
def sign_payload(environment: ModuleEnvironment, payload: str) -> str:
|
||||
"""
|
||||
Signs a raw payload string using the environment's HMAC secret.
|
||||
Used for direct signed POST flows.
|
||||
"""
|
||||
secret = environment.trust_credentials.get("hmac_secret")
|
||||
if not secret:
|
||||
raise ValueError(f"Module environment {environment.slug} missing 'hmac_secret'")
|
||||
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return signature
|
||||
|
||||
@staticmethod
|
||||
def sign_outbound_payload(environment: ModuleEnvironment, method: str, path: str, payload_json: str, timestamp: str) -> str:
|
||||
"""
|
||||
Generates HMAC-SHA256 signature for outbound requests to modules.
|
||||
Signature = HMAC-SHA256(secret, method + path + timestamp + SHA256(payload))
|
||||
"""
|
||||
if environment.trust_type != "hmac":
|
||||
pass
|
||||
|
||||
secret = environment.trust_credentials.get("hmac_secret")
|
||||
if not secret:
|
||||
raise ValueError(f"Module environment {environment.slug} missing 'hmac_secret' for outbound signing")
|
||||
|
||||
payload_hash = hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
|
||||
|
||||
string_to_sign = f"{method.upper()}{path}{timestamp}{payload_hash}"
|
||||
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
string_to_sign.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return signature
|
||||
|
||||
@staticmethod
|
||||
def validate_module_trust(environment: ModuleEnvironment, request_headers: Dict[str, str], request_body: str = ""):
|
||||
"""
|
||||
Validates that the request comes from a trusted module environment.
|
||||
Raises HTTPException if authentication fails.
|
||||
"""
|
||||
if not environment.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Module environment is inactive"
|
||||
)
|
||||
|
||||
if environment.trust_type == "hmac":
|
||||
signature = request_headers.get("X-Module-Signature")
|
||||
if not signature:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing signature"
|
||||
)
|
||||
|
||||
if not TrustService.verify_request_signature(environment, signature, request_body):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid signature"
|
||||
)
|
||||
|
||||
elif environment.trust_type == "static_key":
|
||||
api_key = request_headers.get("X-Module-Key")
|
||||
secret = environment.trust_credentials.get("secret_key")
|
||||
if not api_key or not hmac.compare_digest(api_key, secret):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API Key"
|
||||
)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail=f"Trust type {environment.trust_type} not supported yet"
|
||||
)
|
||||
@@ -1,17 +1,27 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, cast, String
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import or_, cast, String, asc, desc, func
|
||||
from fastapi import HTTPException, status, BackgroundTasks
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any
|
||||
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
|
||||
from app.services.auth.event_service import EventService
|
||||
import logging
|
||||
import json
|
||||
from app.models.auth.role_module_access_model import RoleModuleAccess
|
||||
from app.models.auth.module_access_model import ModuleAccess
|
||||
from app.models.auth.tenant_module_model import TenantModule
|
||||
from app.models.auth.role_model import Role
|
||||
from app.models.auth.tenant_model import Tenant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class UserService:
|
||||
|
||||
@staticmethod
|
||||
def create_user(db: Session, user_data: UserCreate, tenant_id: uuid.UUID = None) -> User:
|
||||
def create_user(db: Session, user_data: UserCreate, tenant_id: uuid.UUID = None, background_tasks: BackgroundTasks = None) -> User:
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -36,8 +46,35 @@ class UserService:
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.flush()
|
||||
db.refresh(user)
|
||||
|
||||
targets = []
|
||||
if user.role_id:
|
||||
targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
|
||||
|
||||
payload = {
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"phone_number": user.phone_number,
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"role_id": str(user.role_id) if user.role_id else None,
|
||||
"status": user.status,
|
||||
"targets": targets
|
||||
}
|
||||
|
||||
logger.info(f"USER_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
||||
|
||||
EventService.emit_event(
|
||||
db=db,
|
||||
event_type="USER_PROVISION_REQUESTED",
|
||||
payload=payload,
|
||||
tenant_id=user.tenant_id
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
@@ -61,13 +98,31 @@ class UserService:
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, tenant_id: uuid.UUID = None) -> User:
|
||||
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, tenant_id: uuid.UUID = None, background_tasks: BackgroundTasks = None) -> User:
|
||||
user = UserService.get_user_by_id(db, user_id, tenant_id)
|
||||
|
||||
old_role_id = user.role_id
|
||||
old_targets = []
|
||||
if old_role_id:
|
||||
old_targets = UserService._resolve_targets_for_role(db, old_role_id, user.tenant_id)
|
||||
|
||||
update_dict = user_data.model_dump(exclude_unset=True)
|
||||
if tenant_id:
|
||||
update_dict.pop("tenant_id", None)
|
||||
|
||||
if "role_id" in update_dict and update_dict["role_id"] is not None:
|
||||
role = db.query(Role).filter(Role.id == update_dict["role_id"]).first()
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Role not found"
|
||||
)
|
||||
if tenant_id and role.tenant_id is not None and role.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot assign a role from another tenant"
|
||||
)
|
||||
|
||||
if "email" in update_dict and update_dict["email"] != user.email:
|
||||
if db.query(User).filter(User.email == update_dict["email"]).first():
|
||||
raise HTTPException(
|
||||
@@ -88,13 +143,88 @@ class UserService:
|
||||
for key, value in update_dict.items():
|
||||
setattr(user, key, value)
|
||||
|
||||
db.commit()
|
||||
db.flush()
|
||||
db.refresh(user)
|
||||
|
||||
new_targets = []
|
||||
if user.role_id:
|
||||
new_targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
|
||||
|
||||
role_changed = (old_role_id != user.role_id)
|
||||
|
||||
base_payload = {
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"phone_number": user.phone_number,
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"role_id": str(user.role_id) if user.role_id else None,
|
||||
"status": user.status
|
||||
}
|
||||
|
||||
if role_changed:
|
||||
old_mids = {t["module_id"] for t in old_targets}
|
||||
new_mids = {t["module_id"] for t in new_targets}
|
||||
removed_mids = old_mids - new_mids
|
||||
|
||||
deprovision_targets = [t for t in old_targets if t["module_id"] in removed_mids]
|
||||
|
||||
if deprovision_targets:
|
||||
payload = {**base_payload, "targets": deprovision_targets}
|
||||
logger.info(f"USER_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
||||
EventService.emit_event(
|
||||
db=db,
|
||||
event_type="USER_DEPROVISION_REQUESTED",
|
||||
payload=payload,
|
||||
tenant_id=user.tenant_id
|
||||
)
|
||||
|
||||
if new_targets:
|
||||
payload = {**base_payload, "targets": new_targets}
|
||||
logger.info(f"USER_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
||||
EventService.emit_event(
|
||||
db=db,
|
||||
event_type="USER_PROVISION_REQUESTED",
|
||||
payload=payload,
|
||||
tenant_id=user.tenant_id
|
||||
)
|
||||
else:
|
||||
if new_targets:
|
||||
payload = {**base_payload, "targets": new_targets}
|
||||
logger.info(f"USER_UPDATED Payload: {json.dumps(payload, default=str)}")
|
||||
EventService.emit_event(
|
||||
db=db,
|
||||
event_type="USER_UPDATED",
|
||||
payload=payload,
|
||||
tenant_id=user.tenant_id
|
||||
)
|
||||
|
||||
db.commit()
|
||||
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)
|
||||
|
||||
targets = []
|
||||
if user.role_id:
|
||||
targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
|
||||
|
||||
if targets:
|
||||
payload = {
|
||||
"user_id": str(user.id),
|
||||
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
||||
"targets": targets
|
||||
}
|
||||
logger.info(f"USER_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
||||
EventService.emit_event(
|
||||
db=db,
|
||||
event_type="USER_DEPROVISION_REQUESTED",
|
||||
payload=payload,
|
||||
tenant_id=user.tenant_id
|
||||
)
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"message": "User deleted successfully"}
|
||||
@@ -106,16 +236,23 @@ class UserService:
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
filter_names: Optional[List[str]] = None,
|
||||
filter_emails: Optional[List[str]] = None,
|
||||
statuses: Optional[List[str]] = None,
|
||||
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
|
||||
filter_role_ids: Optional[List[uuid.UUID]] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
) -> UserPaginatedResponse:
|
||||
|
||||
query = db.query(User)
|
||||
query = db.query(User).outerjoin(Tenant, User.tenant_id == Tenant.id).outerjoin(Role, User.role_id == Role.id)
|
||||
|
||||
# Scope to tenant if not superadmin
|
||||
if tenant_id:
|
||||
query = query.filter(User.tenant_id == tenant_id)
|
||||
|
||||
if search and search.strip():
|
||||
search_term = search.strip()
|
||||
search_term = search_term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
query = query.filter(
|
||||
or_(
|
||||
User.email.ilike(f"%{search_term}%"),
|
||||
@@ -126,8 +263,73 @@ class UserService:
|
||||
)
|
||||
)
|
||||
|
||||
if status is not None:
|
||||
query = query.filter(User.status == status)
|
||||
if filter_names:
|
||||
normalized_names = [name.strip() for name in filter_names if isinstance(name, str) and name.strip()]
|
||||
if normalized_names:
|
||||
query = query.filter(
|
||||
func.trim(
|
||||
func.concat(
|
||||
User.first_name,
|
||||
" ",
|
||||
func.coalesce(User.last_name, ""),
|
||||
)
|
||||
).in_(normalized_names)
|
||||
)
|
||||
|
||||
if filter_emails:
|
||||
normalized_emails = [email.strip() for email in filter_emails if isinstance(email, str) and email.strip()]
|
||||
if normalized_emails:
|
||||
query = query.filter(User.email.in_(normalized_emails))
|
||||
|
||||
if statuses:
|
||||
normalized_statuses = [status for status in statuses if status]
|
||||
if normalized_statuses:
|
||||
query = query.filter(User.status.in_(normalized_statuses))
|
||||
|
||||
if filter_tenant_ids:
|
||||
tenant_ids: List[uuid.UUID] = []
|
||||
for tenant_value in filter_tenant_ids:
|
||||
try:
|
||||
tenant_ids.append(
|
||||
tenant_value if isinstance(tenant_value, uuid.UUID) else uuid.UUID(str(tenant_value))
|
||||
)
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
continue
|
||||
if tenant_ids:
|
||||
query = query.filter(User.tenant_id.in_(tenant_ids))
|
||||
|
||||
if filter_role_ids:
|
||||
role_ids: List[uuid.UUID] = []
|
||||
for role_value in filter_role_ids:
|
||||
try:
|
||||
role_ids.append(
|
||||
role_value if isinstance(role_value, uuid.UUID) else uuid.UUID(str(role_value))
|
||||
)
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
continue
|
||||
if role_ids:
|
||||
query = query.filter(User.role_id.in_(role_ids))
|
||||
|
||||
normalized_sort_by = (sort_by or "").strip().lower()
|
||||
normalized_sort_order = (sort_order or "asc").strip().lower()
|
||||
sort_fn = desc if normalized_sort_order == "desc" else asc
|
||||
|
||||
if normalized_sort_by == "name":
|
||||
query = query.order_by(
|
||||
sort_fn(func.lower(User.first_name)),
|
||||
sort_fn(func.lower(func.coalesce(User.last_name, ""))),
|
||||
asc(User.email),
|
||||
)
|
||||
elif normalized_sort_by == "email":
|
||||
query = query.order_by(sort_fn(func.lower(User.email)))
|
||||
elif normalized_sort_by == "status":
|
||||
query = query.order_by(sort_fn(func.lower(User.status)), asc(User.email))
|
||||
elif normalized_sort_by == "tenant":
|
||||
query = query.order_by(sort_fn(func.lower(func.coalesce(Tenant.tenant_name, ""))), asc(User.email))
|
||||
elif normalized_sort_by == "role":
|
||||
query = query.order_by(sort_fn(func.lower(func.coalesce(Role.role_name, ""))), asc(User.email))
|
||||
else:
|
||||
query = query.order_by(desc(User.created_at))
|
||||
|
||||
total = query.count()
|
||||
|
||||
@@ -142,4 +344,40 @@ class UserService:
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_targets_for_role(db: Session, role_id: uuid.UUID, tenant_id: uuid.UUID = None) -> List[Dict[str, Any]]:
|
||||
active_modules = (
|
||||
db.query(ModuleAccess.module_id)
|
||||
.join(RoleModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
|
||||
.filter(RoleModuleAccess.role_id == role_id)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
|
||||
if not active_modules:
|
||||
return []
|
||||
|
||||
module_ids = [m[0] for m in active_modules]
|
||||
|
||||
env_map = {}
|
||||
if tenant_id:
|
||||
tm_assignments = db.query(TenantModule).filter(
|
||||
TenantModule.tenant_id == tenant_id,
|
||||
TenantModule.module_id.in_(module_ids),
|
||||
TenantModule.is_active == True
|
||||
).all()
|
||||
for tm in tm_assignments:
|
||||
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
|
||||
|
||||
targets = []
|
||||
for mid_uuid in module_ids:
|
||||
mid = str(mid_uuid)
|
||||
env_slug = env_map.get(mid, "prod")
|
||||
targets.append({
|
||||
"module_id": mid,
|
||||
"environment_slug": env_slug
|
||||
})
|
||||
|
||||
return targets
|
||||
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.system.audit_log import AuditLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuditLogService:
|
||||
@staticmethod
|
||||
def log(
|
||||
db: Session,
|
||||
module_name: str,
|
||||
action_type: str,
|
||||
description: str,
|
||||
entity_id: Optional[str] = None,
|
||||
entity_name: Optional[str] = None,
|
||||
performed_by_id: Optional[str] = None,
|
||||
performed_by_email: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
old_values: Optional[Dict[str, Any]] = None,
|
||||
new_values: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Write an audit log entry.
|
||||
|
||||
Failures are silently swallowed and logged — they must NEVER break
|
||||
the main operation that triggered them.
|
||||
"""
|
||||
try:
|
||||
entry = AuditLog(
|
||||
module_name=module_name,
|
||||
action_type=action_type,
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
description=description,
|
||||
performed_by_id=(
|
||||
uuid.UUID(performed_by_id) if performed_by_id else None
|
||||
),
|
||||
performed_by_email=performed_by_email,
|
||||
ip_address=ip_address,
|
||||
old_values=old_values,
|
||||
new_values=new_values,
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
logger.error("AuditLogService.log failed: %s", exc, exc_info=True)
|
||||
@@ -1,19 +1,17 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
from fastapi import HTTPException, status
|
||||
from app.models.theme.color_palette_models import ColorPalette
|
||||
from app.schemas.theme.palette_schema import ColorPaletteCreate, ColorPaletteUpdate
|
||||
from app.models.theme.color_palette_model import ColorPalette
|
||||
from app.schemas.theme.color_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()
|
||||
def get_all_palettes(db: Session) -> List[ColorPalette]:
|
||||
return db.query(ColorPalette).all()
|
||||
|
||||
@staticmethod
|
||||
def get_palette_by_id(db: Session, palette_id: UUID) -> ColorPalette:
|
||||
@@ -23,19 +21,15 @@ class PaletteService:
|
||||
return palette
|
||||
|
||||
@staticmethod
|
||||
def create_palette(db: Session, data: ColorPaletteCreate, tenant_id: Optional[UUID] = None) -> ColorPalette:
|
||||
def create_palette(db: Session, data: ColorPaletteCreate) -> 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.query(ColorPalette).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()
|
||||
@@ -43,9 +37,11 @@ class PaletteService:
|
||||
return db_palette
|
||||
|
||||
@staticmethod
|
||||
def update_palette(db: Session, palette_id: UUID, data: ColorPaletteUpdate) -> ColorPalette:
|
||||
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:
|
||||
@@ -54,14 +50,12 @@ class PaletteService:
|
||||
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})
|
||||
|
||||
db.query(ColorPalette).filter(
|
||||
ColorPalette.id != palette_id
|
||||
).update({"is_default": False})
|
||||
|
||||
palette.is_default = data.is_default
|
||||
|
||||
|
||||
db.commit()
|
||||
db.refresh(palette)
|
||||
return palette
|
||||
@@ -69,7 +63,7 @@ class PaletteService:
|
||||
@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
|
||||
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import typer
|
||||
from typing import Optional
|
||||
|
||||
app = typer.Typer(help="Management script for SaaS Backend")
|
||||
seed_app = typer.Typer(help="Seeding commands")
|
||||
app.add_typer(seed_app, name="seed")
|
||||
|
||||
def run_command(args: list[str], env_name: str):
|
||||
"""
|
||||
Helper to run commands with a specific APP_ENV.
|
||||
Replaces "python" with sys.executable to ensure the same interpreter is used.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
env["APP_ENV"] = env_name
|
||||
|
||||
if args[0] == "python":
|
||||
args[0] = sys.executable
|
||||
|
||||
try:
|
||||
subprocess.run(args, env=env, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
sys.exit(e.returncode)
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
|
||||
@app.command()
|
||||
def run(
|
||||
env: str = typer.Option("local", "--env", "-e", help="Environment to run in (local, development, production, testing)")
|
||||
):
|
||||
"""Start the FastAPI server."""
|
||||
run_command(["python", "run.py"], env)
|
||||
|
||||
@app.command()
|
||||
def migrate(
|
||||
env: str = typer.Option("local", "--env", "-e", help="Environment to run in (local, development, production, testing)")
|
||||
):
|
||||
"""Run Alembic migrations (upgrade head)."""
|
||||
run_command(["python", "-m", "alembic", "upgrade", "head"], env)
|
||||
|
||||
@seed_app.command("palettes")
|
||||
def seed_palettes(
|
||||
env: str = typer.Option("local", "--env", "-e", help="Environment to run in (local, development, production, testing)")
|
||||
):
|
||||
"""Seed palettes database."""
|
||||
run_command(["python", "scripts/seed_palettes.py"], env)
|
||||
|
||||
@seed_app.command("superadmin")
|
||||
def seed_superadmin(
|
||||
env: str = typer.Option("local", "--env", "-e", help="Environment to run in (local, development, production, testing)")
|
||||
):
|
||||
"""Seed superadmin user."""
|
||||
run_command(["python", "scripts/seed_superadmin.py"], env)
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
Generated
-105
@@ -1,105 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -7,4 +7,8 @@ alembic==1.17.2
|
||||
pydantic-settings==2.12.0
|
||||
bcrypt>=4.0.1
|
||||
pyjwt>=2.8.0
|
||||
email-validator>=2.1.0
|
||||
email-validator>=2.1.0
|
||||
redis==7.1.0
|
||||
requests==2.32.5
|
||||
typer==0.21.1
|
||||
httpx==0.28.1
|
||||
@@ -9,5 +9,5 @@ if __name__ == "__main__":
|
||||
"run:app",
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
reload=settings.APP_ENV == "development"
|
||||
)
|
||||
reload=settings.APP_ENV == "development",
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user