From d68145effaf23e011c11d1d507becb78d7a59b72 Mon Sep 17 00:00:00 2001 From: azeeee05 Date: Wed, 29 Apr 2026 11:45:43 +0300 Subject: [PATCH] changes done on subscription module with saas --- ...max_users_allowed_to_subscription_plans.py | 29 +++++ app/middleware/auth_middleware.py | 30 +++-- app/models/auth/subscription_plan_model.py | 1 + app/routes/auth/subscription_plan.py | 3 +- app/schemas/auth/auth_schema.py | 7 +- app/schemas/auth/subscription_plan_schema.py | 4 +- app/services/auth/auth_service.py | 65 ++++++++-- app/services/auth/event_service.py | 4 +- app/services/auth/sso_service.py | 57 ++++++--- .../auth/subscription_entitlement_service.py | 96 +++++++++++++++ .../auth/subscription_plan_service.py | 114 +++++++++++++++++- app/services/auth/tenant_service.py | 3 + 12 files changed, 361 insertions(+), 52 deletions(-) create mode 100644 alembic/versions/7b2c4d5e6f77_add_max_users_allowed_to_subscription_plans.py create mode 100644 app/services/auth/subscription_entitlement_service.py diff --git a/alembic/versions/7b2c4d5e6f77_add_max_users_allowed_to_subscription_plans.py b/alembic/versions/7b2c4d5e6f77_add_max_users_allowed_to_subscription_plans.py new file mode 100644 index 0000000..7c24d5d --- /dev/null +++ b/alembic/versions/7b2c4d5e6f77_add_max_users_allowed_to_subscription_plans.py @@ -0,0 +1,29 @@ +"""add max users allowed to subscription plans + +Revision ID: 7b2c4d5e6f77 +Revises: 6a1b2c3d4e55 +Create Date: 2026-04-28 10:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "7b2c4d5e6f77" +down_revision: Union[str, Sequence[str], None] = "6a1b2c3d4e55" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS max_users_allowed INTEGER" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE subscription_plans DROP COLUMN IF EXISTS max_users_allowed" + ) diff --git a/app/middleware/auth_middleware.py b/app/middleware/auth_middleware.py index c95e70c..782ad68 100644 --- a/app/middleware/auth_middleware.py +++ b/app/middleware/auth_middleware.py @@ -8,6 +8,9 @@ from app.config.security import security from app.models.auth.user_model import User from app.models.auth.access_model import Access from app.models.auth.tenant_model import Tenant +from app.services.auth.subscription_entitlement_service import ( + SubscriptionEntitlementService, +) security_scheme = HTTPBearer(auto_error=False) @@ -44,6 +47,8 @@ def get_current_user( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" ) + + setattr(user, "_saas_db_session", db) if user.status != "active": raise HTTPException( @@ -83,19 +88,19 @@ def require_active_user(current_user: User = Depends(get_current_user)) -> User: return current_user def has_access(user: User, access_code: str) -> bool: - if not user.role: + db = getattr(user, "_saas_db_session", None) + if db is None: return False - - user_access_codes = {ra.access.access_code for ra in user.role.role_accesses} - + + user_access_codes = SubscriptionEntitlementService.get_effective_access_codes( + db, user + ) return access_code in user_access_codes def can_access(user: User, access_code: str, db: Session) -> bool: - if not user.role: - return False - - user_access_codes = {ra.access.access_code for ra in user.role.role_accesses} - + user_access_codes = SubscriptionEntitlementService.get_effective_access_codes( + db, user + ) if access_code in user_access_codes: return True requested_access = db.query(Access).filter( @@ -114,10 +119,11 @@ def can_access(user: User, access_code: str, db: Session) -> bool: return False def get_user_accesses(user: User) -> List[str]: - if not user.role: + db = getattr(user, "_saas_db_session", None) + if db is None: return [] - - return [ra.access.access_code for ra in user.role.role_accesses] + + return sorted(SubscriptionEntitlementService.get_effective_access_codes(db, user)) def require_access(access_code: str): def check_permission(current_user: User = Depends(get_current_user)) -> bool: diff --git a/app/models/auth/subscription_plan_model.py b/app/models/auth/subscription_plan_model.py index abffacf..5144ace 100644 --- a/app/models/auth/subscription_plan_model.py +++ b/app/models/auth/subscription_plan_model.py @@ -12,6 +12,7 @@ class SubscriptionPlan(Base): description = Column(String, nullable=True) price = Column(Numeric(10, 2), nullable=True) duration_days = Column(Integer, nullable=True) + max_users_allowed = Column(Integer, nullable=True) is_public = Column(Boolean, default=True) status = Column(String, default="active") diff --git a/app/routes/auth/subscription_plan.py b/app/routes/auth/subscription_plan.py index 74ac3ef..bee6fbd 100644 --- a/app/routes/auth/subscription_plan.py +++ b/app/routes/auth/subscription_plan.py @@ -81,6 +81,7 @@ def get_plan( description=plan.description, price=float(plan.price) if plan.price is not None else None, duration_days=plan.duration_days, + max_users_allowed=plan.max_users_allowed, is_public=plan.is_public, status=plan.status, created_at=plan.created_at, @@ -145,4 +146,4 @@ def delete_plan( performed_by_email=current_user.email, ip_address=get_client_ip(request) ) - return result \ No newline at end of file + return result diff --git a/app/schemas/auth/auth_schema.py b/app/schemas/auth/auth_schema.py index 6a9d12e..661be44 100644 --- a/app/schemas/auth/auth_schema.py +++ b/app/schemas/auth/auth_schema.py @@ -25,8 +25,8 @@ class AccessInRole(BaseModel): name: str class RoleInUser(BaseModel): - id: uuid.UUID - role_name: str + id: Optional[uuid.UUID] = None + role_name: Optional[str] = None accesses: List[str] = [] class UserResponse(UserBase): @@ -34,6 +34,7 @@ class UserResponse(UserBase): tenant_id: Optional[uuid.UUID] = None tenant_name: Optional[str] = None tenant_logo_url: Optional[str] = None + subscription_details: Optional[dict] = None created_at: datetime updated_at: datetime role: Optional[RoleInUser] = None @@ -74,4 +75,4 @@ class VerifyOTPRequest(BaseModel): class ResetPasswordWithOTP(BaseModel): email: EmailStr otp: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$') - new_password: str = Field(..., min_length=8) \ No newline at end of file + new_password: str = Field(..., min_length=8) diff --git a/app/schemas/auth/subscription_plan_schema.py b/app/schemas/auth/subscription_plan_schema.py index a3c21b8..5b7df6f 100644 --- a/app/schemas/auth/subscription_plan_schema.py +++ b/app/schemas/auth/subscription_plan_schema.py @@ -8,6 +8,7 @@ class SubscriptionPlanBase(BaseModel): description: Optional[str] = None price: Optional[float] = None duration_days: Optional[int] = Field(None, ge=1) + max_users_allowed: Optional[int] = Field(None, ge=0) is_public: bool = True status: str = "active" @@ -20,6 +21,7 @@ class SubscriptionPlanUpdate(BaseModel): description: Optional[str] = None price: Optional[float] = None duration_days: Optional[int] = Field(None, ge=1) + max_users_allowed: Optional[int] = Field(None, ge=0) is_public: Optional[bool] = None status: Optional[str] = None access_ids: Optional[List[uuid.UUID]] = None @@ -42,4 +44,4 @@ class SubscriptionPlanPaginatedResponse(BaseModel): total: int page: int page_size: int - total_pages: int \ No newline at end of file + total_pages: int diff --git a/app/services/auth/auth_service.py b/app/services/auth/auth_service.py index 70cd9c8..9507a82 100644 --- a/app/services/auth/auth_service.py +++ b/app/services/auth/auth_service.py @@ -11,6 +11,9 @@ import jwt from app.config.settings import settings from app.services.auth.email_service import EmailService from app.core.redis import sync_redis_client +from app.services.auth.subscription_entitlement_service import ( + SubscriptionEntitlementService, +) logger = logging.getLogger(__name__) @@ -53,12 +56,15 @@ class AuthService: if user.status != "active": raise HTTPException(status_code=403, detail="User inactive") + effective_accesses = sorted( + SubscriptionEntitlementService.get_effective_access_codes(db, user) + ) role_data = None - if user.role: + if user.role or effective_accesses: role_data = { - "id": str(user.role.id), - "role_name": user.role.role_name, - "accesses": [ra.access.access_code for ra in user.role.role_accesses], + "id": str(user.role.id) if user.role else None, + "role_name": user.role.role_name if user.role else "subscription", + "accesses": effective_accesses, } return { @@ -79,6 +85,9 @@ class AuthService: "tenant_id": user.tenant_id, "tenant_name": user.tenant.tenant_name if user.tenant else None, "tenant_logo_url": user.tenant.tenant_logo_url if user.tenant else None, + "subscription_details": SubscriptionEntitlementService.get_subscription_summary( + db, user.tenant_id + ), "created_at": user.created_at, "updated_at": user.updated_at, "role": role_data, @@ -108,11 +117,38 @@ class AuthService: {"sub": str(user.id)}, tenant_id=user.tenant_id ) + effective_accesses = sorted( + SubscriptionEntitlementService.get_effective_access_codes(db, user) + ) + role_data = None + if user.role or effective_accesses: + role_data = { + "id": str(user.role.id) if user.role else None, + "role_name": user.role.role_name if user.role else "subscription", + "accesses": effective_accesses, + } + return { "access_token": new_access_token, "refresh_token": new_refresh_token, "token_type": "bearer", - "user": user, + "user": { + "id": str(user.id), + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "phone_number": user.phone_number, + "status": user.status, + "tenant_id": user.tenant_id, + "tenant_name": user.tenant.tenant_name if user.tenant else None, + "tenant_logo_url": user.tenant.tenant_logo_url if user.tenant else None, + "subscription_details": SubscriptionEntitlementService.get_subscription_summary( + db, user.tenant_id + ), + "created_at": user.created_at, + "updated_at": user.updated_at, + "role": role_data, + }, } @staticmethod @@ -180,15 +216,15 @@ class AuthService: @staticmethod def me(db: Session, current_user: User): + effective_accesses = sorted( + SubscriptionEntitlementService.get_effective_access_codes(db, current_user) + ) role_data = None - - if current_user.role: + if current_user.role or effective_accesses: role_data = { - "id": str(current_user.role.id), - "role_name": current_user.role.role_name, - "accesses": [ - ra.access.access_code for ra in current_user.role.role_accesses - ], + "id": str(current_user.role.id) if current_user.role else None, + "role_name": current_user.role.role_name if current_user.role else "subscription", + "accesses": effective_accesses, } return { @@ -205,6 +241,9 @@ class AuthService: "tenant_logo_url": ( current_user.tenant.tenant_logo_url if current_user.tenant else None ), + "subscription_details": SubscriptionEntitlementService.get_subscription_summary( + db, current_user.tenant_id + ), "created_at": current_user.created_at, "updated_at": current_user.updated_at, "role": role_data, @@ -382,4 +421,4 @@ class AuthService: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reset password" - ) \ No newline at end of file + ) diff --git a/app/services/auth/event_service.py b/app/services/auth/event_service.py index e82c06c..17808f4 100644 --- a/app/services/auth/event_service.py +++ b/app/services/auth/event_service.py @@ -91,7 +91,7 @@ class EventService: for env in targets: base = env.backend_base_url.rstrip('/') - if event_type == "TENANT_PROVISION_REQUESTED" and env.provisioning_endpoint: + if event_type in {"TENANT_PROVISION_REQUESTED", "TENANT_UPDATED", "TENANT_STATUS_CHANGED", "TENANT_DEPROVISION_REQUESTED"} and env.provisioning_endpoint: endpoint = env.provisioning_endpoint.lstrip('/') logger.info(f"Trace: base='{base}', endpoint='{endpoint}'") target_url = f"{base}/{endpoint}" @@ -277,4 +277,4 @@ class EventService: db.commit() - return len(logs) \ No newline at end of file + return len(logs) diff --git a/app/services/auth/sso_service.py b/app/services/auth/sso_service.py index 26b9f9c..3ce8b74 100644 --- a/app/services/auth/sso_service.py +++ b/app/services/auth/sso_service.py @@ -14,6 +14,9 @@ from app.services.auth.trust_service import TrustService from app.core.redis import sync_redis_client import json import time +from app.services.auth.subscription_entitlement_service import ( + SubscriptionEntitlementService, +) logger = logging.getLogger(__name__) @@ -120,25 +123,32 @@ class SSOService: if not user: raise HTTPException(status_code=404, detail="User not found") - permissions = [] - if user.role: - for ra in user.role.role_accesses: - if ra.access: - pass + permissions = sorted( + SubscriptionEntitlementService.get_plan_module_access_codes( + db, tenant_id, module.id + ) + ) + if not permissions and user.role and user.role.role_module_accesses: + permissions = sorted( + { + rma.module_access.access_code + for rma in user.role.role_module_accesses + if rma.module_access and rma.module_access.module_id == module.id + } + ) - if user.role.role_module_accesses: - for rma in user.role.role_module_accesses: - if rma.module_access and rma.module_access.module_id == module.id: - permissions.append(rma.module_access.access_code) - - timestamp = int(time.time() * 1000) - + subscription_details = SubscriptionEntitlementService.get_subscription_summary( + db, tenant_id + ) + payload_data = { "user_id": str(user.id), "email": user.email, "tenant_id": str(tenant_id) if tenant_id else None, "permissions": permissions, + "tenant_name": user.tenant.tenant_name if user.tenant else None, + "subscription": subscription_details, "timestamp": timestamp, "first_name": user.first_name, "last_name": user.last_name, @@ -221,12 +231,21 @@ class SSOService: detail="Tenant mismatch for SSO grant" ) - permissions = [] - if user.role: - if user.role.role_module_accesses: - for rma in user.role.role_module_accesses: - if rma.module_access and rma.module_access.module_id == module.id: - permissions.append(rma.module_access.access_code) + permissions = sorted( + SubscriptionEntitlementService.get_plan_module_access_codes( + db, + uuid.UUID(grant_tenant_id) if grant_tenant_id else None, + module.id, + ) + ) + if not permissions and user.role and user.role.role_module_accesses: + permissions = sorted( + { + rma.module_access.access_code + for rma in user.role.role_module_accesses + if rma.module_access and rma.module_access.module_id == module.id + } + ) token_payload = { "sub": str(user.id), @@ -250,4 +269,4 @@ class SSOService: "first_name": user.first_name, "last_name": user.last_name } - } \ No newline at end of file + } diff --git a/app/services/auth/subscription_entitlement_service.py b/app/services/auth/subscription_entitlement_service.py new file mode 100644 index 0000000..a00b25d --- /dev/null +++ b/app/services/auth/subscription_entitlement_service.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import uuid +from typing import Any, Dict, Optional, Set + +from sqlalchemy.orm import Session + +from app.models.auth.access_model import Access +from app.models.auth.module_access_model import ModuleAccess +from app.models.auth.plan_access_model import PlanAccess +from app.models.auth.plan_module_access_model import PlanModuleAccess +from app.models.auth.tenant_model import Tenant +from app.models.auth.user_model import User + + +class SubscriptionEntitlementService: + @staticmethod + def get_tenant(db: Session, tenant_id: Optional[uuid.UUID]) -> Optional[Tenant]: + if not tenant_id: + return None + return db.query(Tenant).filter(Tenant.id == tenant_id).first() + + @staticmethod + def get_plan_access_codes( + db: Session, + tenant_id: Optional[uuid.UUID], + ) -> Set[str]: + tenant = SubscriptionEntitlementService.get_tenant(db, tenant_id) + if not tenant or not tenant.plan_id: + return set() + + rows = ( + db.query(Access.access_code) + .join(PlanAccess, PlanAccess.access_id == Access.id) + .filter(PlanAccess.plan_id == tenant.plan_id) + .all() + ) + return {row[0] for row in rows} + + @staticmethod + def get_plan_module_access_codes( + db: Session, + tenant_id: Optional[uuid.UUID], + module_id: Optional[uuid.UUID] = None, + ) -> Set[str]: + tenant = SubscriptionEntitlementService.get_tenant(db, tenant_id) + if not tenant or not tenant.plan_id: + return set() + + query = ( + db.query(ModuleAccess.access_code) + .join( + PlanModuleAccess, + PlanModuleAccess.module_access_id == ModuleAccess.id, + ) + .filter(PlanModuleAccess.plan_id == tenant.plan_id) + ) + if module_id: + query = query.filter(ModuleAccess.module_id == module_id) + + rows = query.all() + return {row[0] for row in rows} + + @staticmethod + def get_effective_access_codes(db: Session, user: User) -> Set[str]: + role_codes = set() + if user.role: + role_codes = { + ra.access.access_code + for ra in user.role.role_accesses + if ra.access is not None + } + + plan_codes = SubscriptionEntitlementService.get_plan_access_codes( + db, user.tenant_id + ) + return role_codes | plan_codes + + @staticmethod + def get_subscription_summary( + db: Session, tenant_id: Optional[uuid.UUID] + ) -> Optional[Dict[str, Any]]: + tenant = SubscriptionEntitlementService.get_tenant(db, tenant_id) + if not tenant: + return None + + plan_name = tenant.plan.name if tenant.plan else None + return { + "plan_id": str(tenant.plan_id) if tenant.plan_id else None, + "plan_name": plan_name, + "max_users_allowed": tenant.plan.max_users_allowed if tenant.plan else None, + "start_date": tenant.start_date.isoformat() if tenant.start_date else None, + "end_date": tenant.end_date.isoformat() if tenant.end_date else None, + "status": tenant.status, + "is_active": tenant.is_active, + } diff --git a/app/services/auth/subscription_plan_service.py b/app/services/auth/subscription_plan_service.py index 1bc0b0b..4453c98 100644 --- a/app/services/auth/subscription_plan_service.py +++ b/app/services/auth/subscription_plan_service.py @@ -7,6 +7,13 @@ from fastapi import HTTPException, status from app.models.auth.subscription_plan_model import SubscriptionPlan from app.models.auth.plan_access_model import PlanAccess from app.models.auth.plan_module_access_model import PlanModuleAccess +from app.models.auth.tenant_model import Tenant +from app.models.auth.role_model import Role +from app.models.auth.tenant_module_model import TenantModule +from app.models.auth.module_environment_model import ModuleEnvironment +from app.schemas.auth.role_schema import RoleUpdate +from app.services.auth.role_service import RoleService +from app.services.auth.event_service import EventService from app.schemas.auth.subscription_plan_schema import ( SubscriptionPlanCreate, SubscriptionPlanUpdate, @@ -15,6 +22,108 @@ from app.schemas.auth.subscription_plan_schema import ( ) class SubscriptionPlanService: + @staticmethod + def _resolve_environment_slug( + db: Session, + module_id: uuid.UUID, + assigned_environment_slug: Optional[str], + ) -> Optional[str]: + if assigned_environment_slug: + return assigned_environment_slug + + default_env = ( + db.query(ModuleEnvironment) + .filter( + ModuleEnvironment.module_id == module_id, + ModuleEnvironment.is_default == True, + ) + .first() + ) + if default_env: + return default_env.slug + + any_env = ( + db.query(ModuleEnvironment) + .filter(ModuleEnvironment.module_id == module_id) + .first() + ) + return any_env.slug if any_env else None + + @staticmethod + def _sync_tenant_default_roles_for_plan( + db: Session, + plan_id: uuid.UUID, + ) -> None: + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() + if not plan: + return + + plan_accesses = db.query(PlanAccess).filter(PlanAccess.plan_id == plan_id).all() + plan_module_accesses = ( + db.query(PlanModuleAccess).filter(PlanModuleAccess.plan_id == plan_id).all() + ) + effective_access_ids = [row.access_id for row in plan_accesses] + [ + row.module_access_id for row in plan_module_accesses + ] + + tenants = db.query(Tenant).filter(Tenant.plan_id == plan_id).all() + for tenant in tenants: + default_role = ( + db.query(Role) + .filter(Role.tenant_id == tenant.id, Role.is_default == True) + .first() + ) + if default_role: + RoleService.update_role( + db, + default_role.id, + RoleUpdate(access_ids=effective_access_ids), + is_superadmin=True, + ) + + active_modules = ( + db.query(TenantModule) + .filter( + TenantModule.tenant_id == tenant.id, + TenantModule.is_active == True, + ) + .all() + ) + if not active_modules: + continue + + targets = [] + for tm in active_modules: + env_slug = SubscriptionPlanService._resolve_environment_slug( + db, + tm.module_id, + tm.assigned_environment_slug, + ) + if env_slug: + targets.append( + { + "module_id": str(tm.module_id), + "environment_slug": env_slug, + } + ) + + if not targets: + continue + + EventService.emit_event( + db, + event_type="TENANT_UPDATED", + payload={ + "tenant_id": str(tenant.id), + "tenant_name": tenant.tenant_name, + "tenant_domain": tenant.tenant_domain, + "tenant_logo_url": tenant.tenant_logo_url, + "max_users_allowed": plan.max_users_allowed, + "targets": targets, + }, + tenant_id=tenant.id, + ) + @staticmethod def create_plan(db: Session, plan_data: SubscriptionPlanCreate) -> SubscriptionPlan: @@ -27,6 +136,7 @@ class SubscriptionPlanService: description=plan_data.description, price=plan_data.price, duration_days=plan_data.duration_days, + max_users_allowed=plan_data.max_users_allowed, is_public=plan_data.is_public, status=plan_data.status ) @@ -77,6 +187,8 @@ class SubscriptionPlanService: db.commit() db.refresh(plan) + SubscriptionPlanService._sync_tenant_default_roles_for_plan(db, plan.id) + db.commit() return plan @staticmethod @@ -163,4 +275,4 @@ class SubscriptionPlanService: plan = SubscriptionPlanService.get_plan(db, plan_id) db.delete(plan) db.commit() - return {"message": "Plan deleted successfully"} \ No newline at end of file + return {"message": "Plan deleted successfully"} diff --git a/app/services/auth/tenant_service.py b/app/services/auth/tenant_service.py index 9d4f07f..a632689 100644 --- a/app/services/auth/tenant_service.py +++ b/app/services/auth/tenant_service.py @@ -211,6 +211,7 @@ class TenantService: "tenant_name": tenant.tenant_name, "tenant_domain": tenant.tenant_domain, "tenant_logo_url": tenant.tenant_logo_url, + "max_users_allowed": plan.max_users_allowed, "provisioning_id": provisioning_id, "targets": event_targets } @@ -386,6 +387,7 @@ class TenantService: payload = { "tenant_id": str(tenant.id), "tenant_name": tenant.tenant_name, + "max_users_allowed": plan.max_users_allowed, "provisioning_id": provisioning_id, "targets": event_targets } @@ -428,6 +430,7 @@ class TenantService: "tenant_name": tenant.tenant_name, "tenant_domain": tenant.tenant_domain, "tenant_logo_url": tenant.tenant_logo_url, + "max_users_allowed": tenant.plan.max_users_allowed if tenant.plan else None, "targets": broadcast_targets } EventService.emit_event(