From bab1c3d71828fad572ae6d6757d3a830bcd50d10 Mon Sep 17 00:00:00 2001 From: Furqan-14 Date: Thu, 9 Apr 2026 11:55:58 +0530 Subject: [PATCH] feat: Implemented subscription module base --- ...97104_add_follow_up_event_to_event_logs.py | 32 ++ .../9283c3f52a76_add_subscription_model.py | 86 ++++++ app/__init__.py | 2 + .../auth/subscription_plan_controller.py | 31 ++ app/models/auth/__init__.py | 5 +- app/models/auth/plan_access_model.py | 24 ++ app/models/auth/plan_module_access_model.py | 24 ++ app/models/auth/subscription_plan_model.py | 28 ++ app/models/auth/tenant_model.py | 2 + app/models/system/event_log_model.py | 1 + app/routes/auth/subscription_plan.py | 132 ++++++++ app/schemas/auth/subscription_plan_schema.py | 43 +++ app/schemas/auth/tenant_schema.py | 9 +- app/services/auth/access_service.py | 1 + app/services/auth/event_service.py | 36 ++- app/services/auth/role_service.py | 113 +++---- .../auth/subscription_plan_service.py | 133 +++++++++ app/services/auth/tenant_service.py | 281 +++++++++++++----- scripts/seed_superadmin.py | 6 + 19 files changed, 850 insertions(+), 139 deletions(-) create mode 100644 alembic/versions/720027c97104_add_follow_up_event_to_event_logs.py create mode 100644 alembic/versions/9283c3f52a76_add_subscription_model.py create mode 100644 app/controllers/auth/subscription_plan_controller.py create mode 100644 app/models/auth/plan_access_model.py create mode 100644 app/models/auth/plan_module_access_model.py create mode 100644 app/models/auth/subscription_plan_model.py create mode 100644 app/routes/auth/subscription_plan.py create mode 100644 app/schemas/auth/subscription_plan_schema.py create mode 100644 app/services/auth/subscription_plan_service.py diff --git a/alembic/versions/720027c97104_add_follow_up_event_to_event_logs.py b/alembic/versions/720027c97104_add_follow_up_event_to_event_logs.py new file mode 100644 index 0000000..0c6ae16 --- /dev/null +++ b/alembic/versions/720027c97104_add_follow_up_event_to_event_logs.py @@ -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 ### diff --git a/alembic/versions/9283c3f52a76_add_subscription_model.py b/alembic/versions/9283c3f52a76_add_subscription_model.py new file mode 100644 index 0000000..6bb6cc5 --- /dev/null +++ b/alembic/versions/9283c3f52a76_add_subscription_model.py @@ -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 ### diff --git a/app/__init__.py b/app/__init__.py index 7298afd..43776ee 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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"]) diff --git a/app/controllers/auth/subscription_plan_controller.py b/app/controllers/auth/subscription_plan_controller.py new file mode 100644 index 0000000..025bd64 --- /dev/null +++ b/app/controllers/auth/subscription_plan_controller.py @@ -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) \ No newline at end of file diff --git a/app/models/auth/__init__.py b/app/models/auth/__init__.py index ea39255..08fa7f6 100644 --- a/app/models/auth/__init__.py +++ b/app/models/auth/__init__.py @@ -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.user_model import User from app.models.auth.module_access_model import ModuleAccess -from app.models.auth.role_module_access_model import RoleModuleAccess \ No newline at end of file +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 \ No newline at end of file diff --git a/app/models/auth/plan_access_model.py b/app/models/auth/plan_access_model.py new file mode 100644 index 0000000..196f65f --- /dev/null +++ b/app/models/auth/plan_access_model.py @@ -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"" diff --git a/app/models/auth/plan_module_access_model.py b/app/models/auth/plan_module_access_model.py new file mode 100644 index 0000000..c850b50 --- /dev/null +++ b/app/models/auth/plan_module_access_model.py @@ -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"" diff --git a/app/models/auth/subscription_plan_model.py b/app/models/auth/subscription_plan_model.py new file mode 100644 index 0000000..19b0fdd --- /dev/null +++ b/app/models/auth/subscription_plan_model.py @@ -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"" diff --git a/app/models/auth/tenant_model.py b/app/models/auth/tenant_model.py index 0ee4719..62959f1 100644 --- a/app/models/auth/tenant_model.py +++ b/app/models/auth/tenant_model.py @@ -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"" \ No newline at end of file diff --git a/app/models/system/event_log_model.py b/app/models/system/event_log_model.py index 17fc6d3..08caea1 100644 --- a/app/models/system/event_log_model.py +++ b/app/models/system/event_log_model.py @@ -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()) diff --git a/app/routes/auth/subscription_plan.py b/app/routes/auth/subscription_plan.py new file mode 100644 index 0000000..3cc12f5 --- /dev/null +++ b/app/routes/auth/subscription_plan.py @@ -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 \ No newline at end of file diff --git a/app/schemas/auth/subscription_plan_schema.py b/app/schemas/auth/subscription_plan_schema.py new file mode 100644 index 0000000..764225c --- /dev/null +++ b/app/schemas/auth/subscription_plan_schema.py @@ -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 \ No newline at end of file diff --git a/app/schemas/auth/tenant_schema.py b/app/schemas/auth/tenant_schema.py index bc90407..d28c746 100644 --- a/app/schemas/auth/tenant_schema.py +++ b/app/schemas/auth/tenant_schema.py @@ -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 diff --git a/app/services/auth/access_service.py b/app/services/auth/access_service.py index 9a6a8aa..e470208 100644 --- a/app/services/auth/access_service.py +++ b/app/services/auth/access_service.py @@ -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, }) diff --git a/app/services/auth/event_service.py b/app/services/auth/event_service.py index 505a9f7..e82c06c 100644 --- a/app/services/auth/event_service.py +++ b/app/services/auth/event_service.py @@ -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) @@ -182,9 +194,9 @@ class EventService: backoff = min(60 * (2 ** log.retry_count), 86400) log.next_retry_at = datetime.now(timezone.utc) + timedelta(seconds=backoff) log.error_log = str(e) - + db.commit() - + return processed_count @staticmethod @@ -238,12 +250,22 @@ 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) log.next_retry_at = now + timedelta(seconds=backoff) log.error_log = f"HTTP {response.status_code}: {response.text}" - + if log.retry_count > 10: log.status = EventStatus.FAILED @@ -252,7 +274,7 @@ class EventService: backoff = min(60 * (2 ** log.retry_count), 86400) log.next_retry_at = now + timedelta(seconds=backoff) log.error_log = str(e) - + db.commit() - + return len(logs) \ No newline at end of file diff --git a/app/services/auth/role_service.py b/app/services/auth/role_service.py index 123c3b8..4c08b36 100644 --- a/app/services/auth/role_service.py +++ b/app/services/auth/role_service.py @@ -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 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" + if emit_events: + assigned_modules = ( + db.query(RoleModuleAccess, ModuleAccess) + .join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id) + .filter(RoleModuleAccess.role_id == role.id) + .all() + ) - targets = [] - for mid, codes in module_map.items(): - env_slug = env_map.get(mid, "prod") - targets.append({ - "module_id": mid, - "environment_slug": env_slug, - "permissions": codes - }) - - if targets: - provisioning_id = str(uuid.uuid4()) - payload = { - "role_id": str(role.id), - "role_name": role.role_name, - "tenant_id": str(role.tenant_id) if role.tenant_id else None, - "provisioning_id": provisioning_id, - "targets": targets - } - - logger.info(f"ROLE_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}") - - EventService.emit_event( - db, - event_type="ROLE_PROVISION_REQUESTED", - payload=payload, - tenant_id=role.tenant_id - ) - - db.commit() + if assigned_modules: + module_map = {} + + for rma, ma in assigned_modules: + mid = str(ma.module_id) + if mid not in module_map: + module_map[mid] = [] + module_map[mid].append(ma.access_code) + + from app.models.auth.tenant_module_model import TenantModule + + env_map = {} + if role.tenant_id: + tm_assignments = db.query(TenantModule).filter( + TenantModule.tenant_id == role.tenant_id, + TenantModule.module_id.in_([uuid.UUID(m) for m in module_map.keys()]) + ).all() + for tm in tm_assignments: + env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod" + + targets = [] + for mid, codes in module_map.items(): + env_slug = env_map.get(mid, "prod") + targets.append({ + "module_id": mid, + "environment_slug": env_slug, + "permissions": codes + }) + + if targets: + provisioning_id = str(uuid.uuid4()) + payload = { + "role_id": str(role.id), + "role_name": role.role_name, + "tenant_id": str(role.tenant_id) if role.tenant_id else None, + "provisioning_id": provisioning_id, + "targets": targets + } + + logger.info(f"ROLE_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}") + + EventService.emit_event( + db, + event_type="ROLE_PROVISION_REQUESTED", + payload=payload, + tenant_id=role.tenant_id + ) + + db.commit() return role diff --git a/app/services/auth/subscription_plan_service.py b/app/services/auth/subscription_plan_service.py new file mode 100644 index 0000000..3ca0565 --- /dev/null +++ b/app/services/auth/subscription_plan_service.py @@ -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"} \ No newline at end of file diff --git a/app/services/auth/tenant_service.py b/app/services/auth/tenant_service.py index bac24f3..936c413 100644 --- a/app/services/auth/tenant_service.py +++ b/app/services/auth/tenant_service.py @@ -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 @@ -28,6 +36,13 @@ class TenantService: status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant domain already exists" ) + + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == tenant_data.plan_id).first() + if not plan: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid plan_id provided" + ) provisioning_id = str(uuid.uuid4()) @@ -36,31 +51,96 @@ 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) - - event_targets.append({ - "module_id": str(mod_data.module_id), - "environment_slug": mod_data.environment_slug - }) + plan_module_accesses = db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan.id).all() + mod_access_ids = [pma.module_access_id for pma in plan_module_accesses] + distinct_module_ids = [] + if mod_access_ids: + modules_query = db.query(ModuleAccess.module_id).filter(ModuleAccess.id.in_(mod_access_ids)).distinct().all() + distinct_module_ids = [m[0] for m in modules_query] + + module_env_map = {} + if tenant_data.module_environments: + for me in tenant_data.module_environments: + module_env_map[me.module_id] = me.environment_slug + + event_targets = [] + for mod_id in distinct_module_ids: + env_slug = module_env_map.get(mod_id, tenant_data.default_environment_slug) + tm = TenantModule( + tenant_id=tenant.id, + module_id=mod_id, + assigned_environment_slug=env_slug, + is_active=True + ) + db.add(tm) + + event_targets.append({ + "module_id": str(mod_id), + "environment_slug": env_slug + }) + + db.flush() + + plan_saas_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan.id).all() + all_access_ids = [a.access_id for a in plan_saas_accesses] + mod_access_ids + + role_create_data = RoleCreate( + role_name="Primary Admin", + tenant_id=tenant.id, + is_default=True, + access_ids=all_access_ids + ) + role = RoleService.create_role(db, role_create_data, emit_events=False) + + role_module_perms = ( + db.query(RoleModuleAccess, ModuleAccess) + .join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id) + .filter(RoleModuleAccess.role_id == role.id) + .all() + ) + + role_follow_up = None + if role_module_perms: + module_map = {} + for rma, ma in role_module_perms: + mid = str(ma.module_id) + if mid not in module_map: + module_map[mid] = [] + module_map[mid].append(ma.access_code) + + role_targets = [] + for target in event_targets: + mid = target["module_id"] + if mid in module_map: + role_targets.append({ + "module_id": mid, + "environment_slug": target["environment_slug"], + "permissions": module_map[mid] + }) + + if role_targets: + role_follow_up = { + "event_type": "ROLE_PROVISION_REQUESTED", + "tenant_id": str(tenant.id), + "payload": { + "role_id": str(role.id), + "role_name": role.role_name, + "tenant_id": str(tenant.id), + "provisioning_id": str(uuid.uuid4()), + "targets": role_targets + } + } + if event_targets: logger.info(f"Creating tenant {tenant.tenant_name}. Processing {len(event_targets)} event targets.") - + payload = { "tenant_id": str(tenant.id), "tenant_name": tenant.tenant_name, @@ -69,14 +149,15 @@ class TenantService: "provisioning_id": provisioning_id, "targets": event_targets } - + EventService.emit_event( 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) @@ -127,62 +208,118 @@ 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] + + 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 = [] - provisioning_id = str(uuid.uuid4()) - - for module_id, data in new_map.items(): - new_env_slug = data.get("environment_slug") - - 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) + current_modules = db.query(TenantModule).filter(TenantModule.tenant_id == tenant.id).all() + current_map = {tm.module_id: tm for tm in current_modules} + + event_targets = [] + provisioning_id = str(uuid.uuid4()) + + default_env = "prod" + + for mod_id in new_modules_set: + if mod_id in current_map: + tm = current_map[mod_id] + if not tm.is_active: + tm.is_active = True event_targets.append({ - "module_id": str(module_id), - "environment_slug": new_env_slug + "module_id": str(mod_id), + "environment_slug": tm.assigned_environment_slug or default_env }) - - 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 = { - "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 + else: + tm = TenantModule( + tenant_id=tenant.id, + module_id=mod_id, + assigned_environment_slug=default_env, + is_active=True ) + db.add(tm) + event_targets.append({ + "module_id": str(mod_id), + "environment_slug": default_env + }) + + for mod_id, tm in current_map.items(): + if mod_id not in new_modules_set and tm.is_active: + tm.is_active = False + + db.flush() + + plan_saas_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan.id).all() + all_access_ids = [a.access_id for a in plan_saas_accesses] + mod_access_ids + + default_role = db.query(Role).filter(Role.tenant_id == tenant.id, Role.is_default == True).first() + if default_role: + role_update_data = RoleUpdate(access_ids=all_access_ids) + RoleService.update_role(db, default_role.id, role_update_data, is_superadmin=True) + + role_follow_up = None + if event_targets and default_role: + role_module_perms = ( + db.query(RoleModuleAccess, ModuleAccess) + .join(ModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id) + .filter(RoleModuleAccess.role_id == default_role.id) + .all() + ) + + module_map = {} + for rma, ma in role_module_perms: + mid = str(ma.module_id) + if mid not in module_map: + module_map[mid] = [] + module_map[mid].append(ma.access_code) + + role_targets = [] + for target in event_targets: + mid = target["module_id"] + if mid in module_map: + role_targets.append({ + "module_id": mid, + "environment_slug": target["environment_slug"], + "permissions": module_map[mid] + }) + + if role_targets: + role_follow_up = { + "event_type": "ROLE_PROVISION_REQUESTED", + "tenant_id": str(tenant.id), + "payload": { + "role_id": str(default_role.id), + "role_name": default_role.role_name, + "tenant_id": str(tenant.id), + "provisioning_id": str(uuid.uuid4()), + "targets": role_targets + } + } + + if event_targets: + payload = { + "tenant_id": str(tenant.id), + "tenant_name": tenant.tenant_name, + "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) diff --git a/scripts/seed_superadmin.py b/scripts/seed_superadmin.py index f5eb64e..4ecc520 100644 --- a/scripts/seed_superadmin.py +++ b/scripts/seed_superadmin.py @@ -40,6 +40,12 @@ PREDEFINED_ACCESSES = [ ("superadmin.user.update", "Superadmin", "Allow access to update any user", None), ("superadmin.user.delete", "Superadmin", "Allow access to delete any user", None), ("superadmin.access.read", "Superadmin", "Allow access to view all accesses", None), + + # 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),