feat: Implemented subscription module base

This commit is contained in:
Furqan-14
2026-04-09 11:55:58 +05:30
parent f200604ca5
commit bab1c3d718
19 changed files with 850 additions and 139 deletions
@@ -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,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 ###
+2
View File
@@ -188,12 +188,14 @@ def create_app() -> FastAPI:
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"])
@@ -0,0 +1,31 @@
import uuid
from typing import 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]):
return SubscriptionPlanService.get_paginated_plans(db, page, page_size, search)
@staticmethod
def delete_plan(db: Session, plan_id: uuid.UUID):
return SubscriptionPlanService.delete_plan(db, plan_id)
+3
View File
@@ -5,3 +5,6 @@ from app.models.auth.tenant_model import Tenant
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
+24
View File
@@ -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}>"
@@ -0,0 +1,28 @@
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, 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)
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}>"
+2
View File
@@ -12,6 +12,7 @@ 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)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(
@@ -22,6 +23,7 @@ class Tenant(Base):
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")
def __repr__(self):
return f"<Tenant {self.tenant_name}>"
+1
View File
@@ -25,6 +25,7 @@ class EventLog(Base):
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())
+132
View File
@@ -0,0 +1,132 @@
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,
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),
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)
@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
@@ -0,0 +1,43 @@
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
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
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
+6 -3
View File
@@ -8,23 +8,26 @@ class TenantBase(BaseModel):
tenant_domain: str = Field(..., min_length=3, max_length=255)
tenant_logo_url: Optional[str] = None
class TenantModuleCreate(BaseModel):
class ModuleEnvironmentAssignment(BaseModel):
module_id: uuid.UUID
environment_slug: str
class TenantCreate(TenantBase):
modules: List[TenantModuleCreate] = []
plan_id: uuid.UUID
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
modules: Optional[List[TenantModuleCreate]] = None
plan_id: Optional[uuid.UUID] = None
class TenantResponse(TenantBase):
id: uuid.UUID
is_active: bool
plan_id: Optional[uuid.UUID] = None
created_at: datetime
updated_at: datetime
+1
View File
@@ -71,6 +71,7 @@ class AccessService:
"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,
})
+24 -2
View File
@@ -23,7 +23,8 @@ class EventService:
db: Session,
event_type: str,
payload: Dict[str, Any],
tenant_id: Optional[uuid.UUID] = None
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).
@@ -107,7 +108,8 @@ class EventService:
target_environment_slug=env.slug,
target_url=target_url,
status=EventStatus.PENDING,
next_retry_at=datetime.now(timezone.utc)
next_retry_at=datetime.now(timezone.utc),
follow_up_event=follow_up_event
)
db.add(log)
@@ -169,6 +171,16 @@ class EventService:
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)
@@ -238,6 +250,16 @@ class EventService:
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)
+49 -48
View File
@@ -19,7 +19,7 @@ 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(
@@ -48,62 +48,63 @@ class RoleService:
if role_data.access_ids:
RoleService.assign_accesses(db, role.id, role_data.access_ids)
assigned_modules = (
db.query(RoleModuleAccess, ModuleAccess)
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
.filter(RoleModuleAccess.role_id == role.id)
.all()
)
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 = {}
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)
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
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"
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
})
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
}
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)}")
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
)
EventService.emit_event(
db,
event_type="ROLE_PROVISION_REQUESTED",
payload=payload,
tenant_id=role.tenant_id
)
db.commit()
db.commit()
return role
@@ -0,0 +1,133 @@
import uuid
from typing import Optional
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, cast, String
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.schemas.auth.subscription_plan_schema import (
SubscriptionPlanCreate,
SubscriptionPlanUpdate,
SubscriptionPlanPaginatedResponse,
SubscriptionPlanResponse
)
class SubscriptionPlanService:
@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,
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)
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) -> SubscriptionPlanPaginatedResponse:
query = db.query(SubscriptionPlan)
if search:
query = query.filter(
or_(
SubscriptionPlan.name.ilike(f"%{search}%"),
cast(SubscriptionPlan.id, String).ilike(f"%{search}%")
)
)
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"}
+201 -64
View File
@@ -3,7 +3,15 @@ from sqlalchemy import or_, cast, String
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 app.services.auth.event_service import EventService
@@ -29,6 +37,13 @@ class TenantService:
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"
)
provisioning_id = str(uuid.uuid4())
try:
@@ -36,27 +51,92 @@ class TenantService:
tenant = Tenant(
tenant_name=tenant_data.tenant_name,
tenant_domain=tenant_data.tenant_domain,
tenant_logo_url=tenant_data.tenant_logo_url
tenant_logo_url=tenant_data.tenant_logo_url,
plan_id=tenant_data.plan_id
)
db.add(tenant)
db.flush()
# 2. Create Tenant Modules & Build Event Targets
event_targets = []
if tenant_data.modules:
for mod_data in tenant_data.modules:
tm = TenantModule(
tenant_id=tenant.id,
module_id=mod_data.module_id,
assigned_environment_slug=mod_data.environment_slug,
is_active=True
)
db.add(tm)
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]
event_targets.append({
"module_id": str(mod_data.module_id),
"environment_slug": mod_data.environment_slug
})
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.")
@@ -74,9 +154,10 @@ class TenantService:
db,
event_type="TENANT_PROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id
tenant_id=tenant.id,
follow_up_event=role_follow_up
)
logger.info("Event TENANT_PROVISION_REQUESTED emitted to outbox.")
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)
@@ -128,61 +209,117 @@ class TenantService:
if "is_active" in update_dict and update_dict["is_active"] != tenant.is_active:
should_emit_status = True
if "modules" in update_dict:
modules_data = update_dict.pop("modules")
if modules_data is not None:
current_modules = db.query(TenantModule).filter(TenantModule.tenant_id == tenant.id).all()
current_map = {tm.module_id: tm for tm in current_modules}
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")
new_map = {m["module_id"]: m for m in modules_data}
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]
event_targets = []
provisioning_id = str(uuid.uuid4())
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}
for module_id, data in new_map.items():
new_env_slug = data.get("environment_slug")
current_modules = db.query(TenantModule).filter(TenantModule.tenant_id == tenant.id).all()
current_map = {tm.module_id: tm for tm in current_modules}
if module_id in current_map:
tm = current_map[module_id]
if tm.assigned_environment_slug != new_env_slug or not tm.is_active:
tm.assigned_environment_slug = new_env_slug
tm.is_active = True
event_targets.append({
"module_id": str(module_id),
"environment_slug": new_env_slug
})
else:
tm = TenantModule(
tenant_id=tenant.id,
module_id=module_id,
assigned_environment_slug=new_env_slug,
is_active=True
)
db.add(tm)
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(module_id),
"environment_slug": new_env_slug
"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]
})
for module_id, tm in current_map.items():
if module_id not in new_map:
tm.is_active = False
db.flush()
if event_targets:
payload = {
if role_targets:
role_follow_up = {
"event_type": "ROLE_PROVISION_REQUESTED",
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"provisioning_id": provisioning_id,
"targets": event_targets
"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
}
}
EventService.emit_event(
db,
event_type="TENANT_PROVISION_REQUESTED",
payload=payload,
tenant_id=tenant.id
)
if event_targets:
payload = {
"tenant_id": str(tenant.id),
"tenant_name": tenant.tenant_name,
"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)
+6
View File
@@ -41,6 +41,12 @@ PREDEFINED_ACCESSES = [
("superadmin.user.delete", "Superadmin", "Allow access to delete any user", None),
("superadmin.access.read", "Superadmin", "Allow access to view all accesses", None),
# Subscription Plans
("superadmin.plan.create", "Superadmin", "Allow access to create subscription plans", None),
("superadmin.plan.read", "Superadmin", "Allow access to view subscription plans", None),
("superadmin.plan.update", "Superadmin", "Allow access to update subscription plans", None),
("superadmin.plan.delete", "Superadmin", "Allow access to delete subscription plans", None),
# Theme/Palette
("superadmin.palette.read", "Superadmin", "Allow access to view color palettes", None),
("superadmin.palette.create", "Superadmin", "Allow access to create color palettes", None),