feat: Implemented subscription module base
This commit is contained in:
@@ -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 ###
|
||||||
@@ -188,12 +188,14 @@ def create_app() -> FastAPI:
|
|||||||
from app.routes.auth.user import router as user_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.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.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(auth_router, prefix="/api/auth", tags=["Authentication"])
|
||||||
app.include_router(tenant_router, prefix="/api/tenant", tags=["Tenant Management"])
|
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(role_router, prefix="/api/role", tags=["Role Management"])
|
||||||
app.include_router(access_router, prefix="/api/access", tags=["Access 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(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
|
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_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)
|
||||||
@@ -4,4 +4,7 @@ from app.models.auth.role_access_model import RoleAccess
|
|||||||
from app.models.auth.tenant_model import Tenant
|
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.module_access_model import ModuleAccess
|
||||||
from app.models.auth.role_module_access_model import RoleModuleAccess
|
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
|
||||||
@@ -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}>"
|
||||||
@@ -12,6 +12,7 @@ class Tenant(Base):
|
|||||||
tenant_domain = Column(String, unique=True, nullable=False, index=True)
|
tenant_domain = Column(String, unique=True, nullable=False, index=True)
|
||||||
tenant_logo_url = Column(String, nullable=True)
|
tenant_logo_url = Column(String, nullable=True)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
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())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(
|
updated_at = Column(
|
||||||
@@ -22,6 +23,7 @@ class Tenant(Base):
|
|||||||
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
|
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
|
||||||
roles = relationship("Role", 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")
|
tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan")
|
||||||
|
plan = relationship("SubscriptionPlan", back_populates="tenants")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Tenant {self.tenant_name}>"
|
return f"<Tenant {self.tenant_name}>"
|
||||||
@@ -25,6 +25,7 @@ class EventLog(Base):
|
|||||||
retry_count = Column(Integer, default=0)
|
retry_count = Column(Integer, default=0)
|
||||||
next_retry_at = Column(DateTime(timezone=True), default=func.now(), index=True)
|
next_retry_at = Column(DateTime(timezone=True), default=func.now(), index=True)
|
||||||
error_log = Column(Text, nullable=True)
|
error_log = Column(Text, nullable=True)
|
||||||
|
follow_up_event = Column(JSONB, nullable=True)
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,23 +8,26 @@ class TenantBase(BaseModel):
|
|||||||
tenant_domain: str = Field(..., min_length=3, max_length=255)
|
tenant_domain: str = Field(..., min_length=3, max_length=255)
|
||||||
tenant_logo_url: Optional[str] = None
|
tenant_logo_url: Optional[str] = None
|
||||||
|
|
||||||
class TenantModuleCreate(BaseModel):
|
class ModuleEnvironmentAssignment(BaseModel):
|
||||||
module_id: uuid.UUID
|
module_id: uuid.UUID
|
||||||
environment_slug: str
|
environment_slug: str
|
||||||
|
|
||||||
class TenantCreate(TenantBase):
|
class TenantCreate(TenantBase):
|
||||||
modules: List[TenantModuleCreate] = []
|
plan_id: uuid.UUID
|
||||||
|
module_environments: Optional[List[ModuleEnvironmentAssignment]] = []
|
||||||
|
default_environment_slug: str = "prod"
|
||||||
|
|
||||||
class TenantUpdate(BaseModel):
|
class TenantUpdate(BaseModel):
|
||||||
tenant_name: Optional[str] = Field(None, min_length=2, max_length=100)
|
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_domain: Optional[str] = Field(None, min_length=3, max_length=255)
|
||||||
tenant_logo_url: Optional[str] = None
|
tenant_logo_url: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
modules: Optional[List[TenantModuleCreate]] = None
|
plan_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
class TenantResponse(TenantBase):
|
class TenantResponse(TenantBase):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
plan_id: Optional[uuid.UUID] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class AccessService:
|
|||||||
"name": item.name,
|
"name": item.name,
|
||||||
"category": item.category,
|
"category": item.category,
|
||||||
"parent_id": str(item.parent_id) if item.parent_id else None,
|
"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),
|
"module_name": getattr(item, "module_name", None),
|
||||||
"created_at": item.created_at.isoformat() if item.created_at else None,
|
"created_at": item.created_at.isoformat() if item.created_at else None,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ class EventService:
|
|||||||
db: Session,
|
db: Session,
|
||||||
event_type: str,
|
event_type: str,
|
||||||
payload: Dict[str, Any],
|
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).
|
Emits an event by writing it to the Outbox (event_logs).
|
||||||
@@ -107,7 +108,8 @@ class EventService:
|
|||||||
target_environment_slug=env.slug,
|
target_environment_slug=env.slug,
|
||||||
target_url=target_url,
|
target_url=target_url,
|
||||||
status=EventStatus.PENDING,
|
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)
|
db.add(log)
|
||||||
|
|
||||||
@@ -169,6 +171,16 @@ class EventService:
|
|||||||
log.status = EventStatus.COMPLETED
|
log.status = EventStatus.COMPLETED
|
||||||
log.error_log = None
|
log.error_log = None
|
||||||
processed_count += 1
|
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:
|
else:
|
||||||
log.retry_count += 1
|
log.retry_count += 1
|
||||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||||
@@ -182,9 +194,9 @@ class EventService:
|
|||||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||||
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=backoff)
|
log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=backoff)
|
||||||
log.error_log = str(e)
|
log.error_log = str(e)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return processed_count
|
return processed_count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -238,12 +250,22 @@ class EventService:
|
|||||||
if response.status_code in range(200, 300):
|
if response.status_code in range(200, 300):
|
||||||
log.status = EventStatus.COMPLETED
|
log.status = EventStatus.COMPLETED
|
||||||
log.error_log = None
|
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:
|
else:
|
||||||
log.retry_count += 1
|
log.retry_count += 1
|
||||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||||
log.next_retry_at = now + timedelta(seconds=backoff)
|
log.next_retry_at = now + timedelta(seconds=backoff)
|
||||||
log.error_log = f"HTTP {response.status_code}: {response.text}"
|
log.error_log = f"HTTP {response.status_code}: {response.text}"
|
||||||
|
|
||||||
if log.retry_count > 10:
|
if log.retry_count > 10:
|
||||||
log.status = EventStatus.FAILED
|
log.status = EventStatus.FAILED
|
||||||
|
|
||||||
@@ -252,7 +274,7 @@ class EventService:
|
|||||||
backoff = min(60 * (2 ** log.retry_count), 86400)
|
backoff = min(60 * (2 ** log.retry_count), 86400)
|
||||||
log.next_retry_at = now + timedelta(seconds=backoff)
|
log.next_retry_at = now + timedelta(seconds=backoff)
|
||||||
log.error_log = str(e)
|
log.error_log = str(e)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return len(logs)
|
return len(logs)
|
||||||
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
|||||||
class RoleService:
|
class RoleService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_role(db: Session, role_data: RoleCreate) -> Role:
|
def create_role(db: Session, role_data: RoleCreate, emit_events: bool = True) -> Role:
|
||||||
existing = (
|
existing = (
|
||||||
db.query(Role)
|
db.query(Role)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -48,62 +48,63 @@ class RoleService:
|
|||||||
if role_data.access_ids:
|
if role_data.access_ids:
|
||||||
RoleService.assign_accesses(db, role.id, role_data.access_ids)
|
RoleService.assign_accesses(db, role.id, role_data.access_ids)
|
||||||
|
|
||||||
assigned_modules = (
|
if emit_events:
|
||||||
db.query(RoleModuleAccess, ModuleAccess)
|
assigned_modules = (
|
||||||
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
|
db.query(RoleModuleAccess, ModuleAccess)
|
||||||
.filter(RoleModuleAccess.role_id == role.id)
|
.join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
|
||||||
.all()
|
.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 = []
|
if assigned_modules:
|
||||||
for mid, codes in module_map.items():
|
module_map = {}
|
||||||
env_slug = env_map.get(mid, "prod")
|
|
||||||
targets.append({
|
for rma, ma in assigned_modules:
|
||||||
"module_id": mid,
|
mid = str(ma.module_id)
|
||||||
"environment_slug": env_slug,
|
if mid not in module_map:
|
||||||
"permissions": codes
|
module_map[mid] = []
|
||||||
})
|
module_map[mid].append(ma.access_code)
|
||||||
|
|
||||||
if targets:
|
from app.models.auth.tenant_module_model import TenantModule
|
||||||
provisioning_id = str(uuid.uuid4())
|
|
||||||
payload = {
|
env_map = {}
|
||||||
"role_id": str(role.id),
|
if role.tenant_id:
|
||||||
"role_name": role.role_name,
|
tm_assignments = db.query(TenantModule).filter(
|
||||||
"tenant_id": str(role.tenant_id) if role.tenant_id else None,
|
TenantModule.tenant_id == role.tenant_id,
|
||||||
"provisioning_id": provisioning_id,
|
TenantModule.module_id.in_([uuid.UUID(m) for m in module_map.keys()])
|
||||||
"targets": targets
|
).all()
|
||||||
}
|
for tm in tm_assignments:
|
||||||
|
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
|
||||||
logger.info(f"ROLE_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
|
||||||
|
targets = []
|
||||||
EventService.emit_event(
|
for mid, codes in module_map.items():
|
||||||
db,
|
env_slug = env_map.get(mid, "prod")
|
||||||
event_type="ROLE_PROVISION_REQUESTED",
|
targets.append({
|
||||||
payload=payload,
|
"module_id": mid,
|
||||||
tenant_id=role.tenant_id
|
"environment_slug": env_slug,
|
||||||
)
|
"permissions": codes
|
||||||
|
})
|
||||||
db.commit()
|
|
||||||
|
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
|
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"}
|
||||||
@@ -3,7 +3,15 @@ from sqlalchemy import or_, cast, String
|
|||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from app.models.auth.tenant_model import Tenant
|
from app.models.auth.tenant_model import Tenant
|
||||||
from app.models.auth.tenant_module_model import TenantModule
|
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.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
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.services.auth.event_service import EventService
|
from app.services.auth.event_service import EventService
|
||||||
@@ -28,6 +36,13 @@ class TenantService:
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Tenant domain already exists"
|
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())
|
provisioning_id = str(uuid.uuid4())
|
||||||
|
|
||||||
@@ -36,31 +51,96 @@ class TenantService:
|
|||||||
tenant = Tenant(
|
tenant = Tenant(
|
||||||
tenant_name=tenant_data.tenant_name,
|
tenant_name=tenant_data.tenant_name,
|
||||||
tenant_domain=tenant_data.tenant_domain,
|
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.add(tenant)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# 2. Create Tenant Modules & Build Event Targets
|
plan_module_accesses = db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).all()
|
||||||
event_targets = []
|
mod_access_ids = [pma.module_access_id for pma in plan_module_accesses]
|
||||||
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)
|
|
||||||
|
|
||||||
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:
|
if event_targets:
|
||||||
logger.info(f"Creating tenant {tenant.tenant_name}. Processing {len(event_targets)} event targets.")
|
logger.info(f"Creating tenant {tenant.tenant_name}. Processing {len(event_targets)} event targets.")
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"tenant_id": str(tenant.id),
|
"tenant_id": str(tenant.id),
|
||||||
"tenant_name": tenant.tenant_name,
|
"tenant_name": tenant.tenant_name,
|
||||||
@@ -69,14 +149,15 @@ class TenantService:
|
|||||||
"provisioning_id": provisioning_id,
|
"provisioning_id": provisioning_id,
|
||||||
"targets": event_targets
|
"targets": event_targets
|
||||||
}
|
}
|
||||||
|
|
||||||
EventService.emit_event(
|
EventService.emit_event(
|
||||||
db,
|
db,
|
||||||
event_type="TENANT_PROVISION_REQUESTED",
|
event_type="TENANT_PROVISION_REQUESTED",
|
||||||
payload=payload,
|
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.commit()
|
||||||
db.refresh(tenant)
|
db.refresh(tenant)
|
||||||
@@ -127,62 +208,118 @@ class TenantService:
|
|||||||
|
|
||||||
if "is_active" in update_dict and update_dict["is_active"] != tenant.is_active:
|
if "is_active" in update_dict and update_dict["is_active"] != tenant.is_active:
|
||||||
should_emit_status = True
|
should_emit_status = True
|
||||||
|
|
||||||
if "modules" in update_dict:
|
if "plan_id" in update_dict and update_dict["plan_id"] != tenant.plan_id:
|
||||||
modules_data = update_dict.pop("modules")
|
new_plan_id = update_dict["plan_id"]
|
||||||
if modules_data is not None:
|
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == new_plan_id).first()
|
||||||
current_modules = db.query(TenantModule).filter(TenantModule.tenant_id == tenant.id).all()
|
if not plan:
|
||||||
current_map = {tm.module_id: tm for tm in current_modules}
|
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]
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
event_targets = []
|
current_modules = db.query(TenantModule).filter(TenantModule.tenant_id == tenant.id).all()
|
||||||
provisioning_id = str(uuid.uuid4())
|
current_map = {tm.module_id: tm for tm in current_modules}
|
||||||
|
|
||||||
for module_id, data in new_map.items():
|
event_targets = []
|
||||||
new_env_slug = data.get("environment_slug")
|
provisioning_id = str(uuid.uuid4())
|
||||||
|
|
||||||
if module_id in current_map:
|
default_env = "prod"
|
||||||
tm = current_map[module_id]
|
|
||||||
if tm.assigned_environment_slug != new_env_slug or not tm.is_active:
|
for mod_id in new_modules_set:
|
||||||
tm.assigned_environment_slug = new_env_slug
|
if mod_id in current_map:
|
||||||
tm.is_active = True
|
tm = current_map[mod_id]
|
||||||
event_targets.append({
|
if not tm.is_active:
|
||||||
"module_id": str(module_id),
|
tm.is_active = True
|
||||||
"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.append({
|
event_targets.append({
|
||||||
"module_id": str(module_id),
|
"module_id": str(mod_id),
|
||||||
"environment_slug": new_env_slug
|
"environment_slug": tm.assigned_environment_slug or default_env
|
||||||
})
|
})
|
||||||
|
else:
|
||||||
for module_id, tm in current_map.items():
|
tm = TenantModule(
|
||||||
if module_id not in new_map:
|
tenant_id=tenant.id,
|
||||||
tm.is_active = False
|
module_id=mod_id,
|
||||||
|
assigned_environment_slug=default_env,
|
||||||
db.flush()
|
is_active=True
|
||||||
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
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,
|
||||||
|
"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():
|
for key, value in update_dict.items():
|
||||||
setattr(tenant, key, value)
|
setattr(tenant, key, value)
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ PREDEFINED_ACCESSES = [
|
|||||||
("superadmin.user.update", "Superadmin", "Allow access to update any user", None),
|
("superadmin.user.update", "Superadmin", "Allow access to update any user", None),
|
||||||
("superadmin.user.delete", "Superadmin", "Allow access to delete any user", None),
|
("superadmin.user.delete", "Superadmin", "Allow access to delete any user", None),
|
||||||
("superadmin.access.read", "Superadmin", "Allow access to view all accesses", None),
|
("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
|
# Theme/Palette
|
||||||
("superadmin.palette.read", "Superadmin", "Allow access to view color palettes", None),
|
("superadmin.palette.read", "Superadmin", "Allow access to view color palettes", None),
|
||||||
|
|||||||
Reference in New Issue
Block a user