From f3deee5479a73f1d0c18be0d44e5ded952172ef4 Mon Sep 17 00:00:00 2001 From: amee Date: Sat, 18 Apr 2026 16:20:17 +0530 Subject: [PATCH] feat: tenat subscription, duration, start and expiry date --- alembic/env.py | 1 + ...f2e9c1a7b44_add_tenant_lifecycle_fields.py | 48 ++++++++++++++ ...add_duration_days_to_subscription_plans.py | 29 +++++++++ app/__init__.py | 1 + app/middleware/auth_middleware.py | 23 +++++++ app/models/auth/subscription_plan_model.py | 3 +- app/models/auth/tenant_model.py | 9 ++- app/routes/auth/subscription_plan.py | 1 + app/routes/auth/tenant.py | 7 ++- app/schemas/auth/subscription_plan_schema.py | 2 + app/schemas/auth/tenant_schema.py | 13 +++- .../auth/subscription_plan_service.py | 1 + app/services/auth/tenant_service.py | 62 ++++++++++++++++++- 13 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 alembic/versions/5f2e9c1a7b44_add_tenant_lifecycle_fields.py create mode 100644 alembic/versions/6a1b2c3d4e55_add_duration_days_to_subscription_plans.py diff --git a/alembic/env.py b/alembic/env.py index 7ad9cbe..604f516 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -42,6 +42,7 @@ import app.models.auth.tenant_model import app.models.auth.access_model import app.models.auth.role_access_model import app.models.theme.color_palette_model +import app.models.auth.subscription_plan_model # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/alembic/versions/5f2e9c1a7b44_add_tenant_lifecycle_fields.py b/alembic/versions/5f2e9c1a7b44_add_tenant_lifecycle_fields.py new file mode 100644 index 0000000..9b96001 --- /dev/null +++ b/alembic/versions/5f2e9c1a7b44_add_tenant_lifecycle_fields.py @@ -0,0 +1,48 @@ +"""add tenant lifecycle fields + +Revision ID: 5f2e9c1a7b44 +Revises: 720027c97104 +Create Date: 2026-04-18 14:10:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "5f2e9c1a7b44" +down_revision: Union[str, Sequence[str], None] = "720027c97104" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS start_date DATE") + op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS end_date DATE") + op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS status VARCHAR") + op.execute("ALTER TABLE tenants ALTER COLUMN status SET DEFAULT 'ACTIVE'") + + op.execute( + """ + UPDATE tenants + SET status = CASE + WHEN is_active = true THEN 'ACTIVE' + ELSE 'INACTIVE' + END + WHERE status IS NULL + """ + ) + + op.execute("ALTER TABLE tenants ALTER COLUMN status SET NOT NULL") + op.execute("ALTER TABLE tenants ALTER COLUMN status DROP DEFAULT") + op.execute( + "CREATE INDEX IF NOT EXISTS ix_tenants_status ON tenants (status)" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_tenants_status") + op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS status") + op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS end_date") + op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS start_date") diff --git a/alembic/versions/6a1b2c3d4e55_add_duration_days_to_subscription_plans.py b/alembic/versions/6a1b2c3d4e55_add_duration_days_to_subscription_plans.py new file mode 100644 index 0000000..b0f544a --- /dev/null +++ b/alembic/versions/6a1b2c3d4e55_add_duration_days_to_subscription_plans.py @@ -0,0 +1,29 @@ +"""add duration days to subscription plans + +Revision ID: 6a1b2c3d4e55 +Revises: 5f2e9c1a7b44 +Create Date: 2026-04-18 15:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "6a1b2c3d4e55" +down_revision: Union[str, Sequence[str], None] = "5f2e9c1a7b44" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS duration_days INTEGER" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE subscription_plans DROP COLUMN IF EXISTS duration_days" + ) diff --git a/app/__init__.py b/app/__init__.py index 43776ee..ad40293 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -16,6 +16,7 @@ import app.models.auth.module_environment_model import app.models.auth.tenant_module_model import app.models.auth.sso_grant_model import app.models.auth.access_model +import app.models.auth.subscription_plan_model import app.models.system.event_log_model import app.models.system.audit_log import asyncio diff --git a/app/middleware/auth_middleware.py b/app/middleware/auth_middleware.py index ab0e89e..02deae3 100644 --- a/app/middleware/auth_middleware.py +++ b/app/middleware/auth_middleware.py @@ -2,10 +2,12 @@ from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.orm import Session from typing import List +from datetime import datetime, timezone from app.config.database import get_db from app.config.security import security from app.models.auth.user_model import User from app.models.auth.access_model import Access +from app.models.auth.tenant_model import Tenant security_scheme = HTTPBearer(auto_error=False) @@ -49,6 +51,27 @@ def get_current_user( detail="User is inactive" ) + if user.tenant_id is not None: + tenant = db.query(Tenant).filter(Tenant.id == user.tenant_id).first() + if tenant is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Tenant not found" + ) + + today = datetime.now(timezone.utc).date() + if tenant.end_date and tenant.end_date < today and tenant.status != "EXPIRED": + tenant.status = "EXPIRED" + tenant.is_active = False + db.commit() + db.refresh(tenant) + + if not tenant.is_active or tenant.status in {"INACTIVE", "EXPIRED"}: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Tenant is inactive" + ) + return user def require_active_user(current_user: User = Depends(get_current_user)) -> User: diff --git a/app/models/auth/subscription_plan_model.py b/app/models/auth/subscription_plan_model.py index 19b0fdd..abffacf 100644 --- a/app/models/auth/subscription_plan_model.py +++ b/app/models/auth/subscription_plan_model.py @@ -1,5 +1,5 @@ import uuid -from sqlalchemy import Column, String, Boolean, DateTime, func, Numeric +from sqlalchemy import Column, String, Boolean, DateTime, Integer, func, Numeric from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from app.config.database import Base @@ -11,6 +11,7 @@ class SubscriptionPlan(Base): name = Column(String, unique=True, nullable=False, index=True) description = Column(String, nullable=True) price = Column(Numeric(10, 2), nullable=True) + duration_days = Column(Integer, nullable=True) is_public = Column(Boolean, default=True) status = Column(String, default="active") diff --git a/app/models/auth/tenant_model.py b/app/models/auth/tenant_model.py index 62959f1..03f6082 100644 --- a/app/models/auth/tenant_model.py +++ b/app/models/auth/tenant_model.py @@ -1,5 +1,5 @@ import uuid -from sqlalchemy import Column, String, Boolean, DateTime, func, ForeignKey +from sqlalchemy import Column, String, Boolean, Date, DateTime, func, ForeignKey from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from app.config.database import Base @@ -13,6 +13,9 @@ class Tenant(Base): tenant_logo_url = Column(String, nullable=True) is_active = Column(Boolean, default=True, nullable=False) plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id"), nullable=True, index=True) + start_date = Column(Date, nullable=True) + end_date = Column(Date, nullable=True) + status = Column(String, nullable=False, default="ACTIVE", index=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column( @@ -25,5 +28,9 @@ class Tenant(Base): tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan") plan = relationship("SubscriptionPlan", back_populates="tenants") + @property + def tenant_id(self): + return self.id + def __repr__(self): return f"" \ No newline at end of file diff --git a/app/routes/auth/subscription_plan.py b/app/routes/auth/subscription_plan.py index a54dd69..74ac3ef 100644 --- a/app/routes/auth/subscription_plan.py +++ b/app/routes/auth/subscription_plan.py @@ -80,6 +80,7 @@ def get_plan( name=plan.name, description=plan.description, price=float(plan.price) if plan.price is not None else None, + duration_days=plan.duration_days, is_public=plan.is_public, status=plan.status, created_at=plan.created_at, diff --git a/app/routes/auth/tenant.py b/app/routes/auth/tenant.py index 3ddee71..b983725 100644 --- a/app/routes/auth/tenant.py +++ b/app/routes/auth/tenant.py @@ -70,7 +70,12 @@ def update_tenant( old_snapshot = { "tenant_name": existing.tenant_name, "tenant_domain": existing.tenant_domain, - "is_active": existing.is_active + "tenant_logo_url": existing.tenant_logo_url, + "is_active": existing.is_active, + "plan_id": str(existing.plan_id) if existing.plan_id else None, + "start_date": existing.start_date.isoformat() if existing.start_date else None, + "end_date": existing.end_date.isoformat() if existing.end_date else None, + "status": existing.status, } # 2. Perform update diff --git a/app/schemas/auth/subscription_plan_schema.py b/app/schemas/auth/subscription_plan_schema.py index 764225c..a3c21b8 100644 --- a/app/schemas/auth/subscription_plan_schema.py +++ b/app/schemas/auth/subscription_plan_schema.py @@ -7,6 +7,7 @@ class SubscriptionPlanBase(BaseModel): name: str = Field(..., min_length=2, max_length=100) description: Optional[str] = None price: Optional[float] = None + duration_days: Optional[int] = Field(None, ge=1) is_public: bool = True status: str = "active" @@ -18,6 +19,7 @@ class SubscriptionPlanUpdate(BaseModel): name: Optional[str] = Field(None, min_length=2, max_length=100) description: Optional[str] = None price: Optional[float] = None + duration_days: Optional[int] = Field(None, ge=1) is_public: Optional[bool] = None status: Optional[str] = None access_ids: Optional[List[uuid.UUID]] = None diff --git a/app/schemas/auth/tenant_schema.py b/app/schemas/auth/tenant_schema.py index d28c746..89c4154 100644 --- a/app/schemas/auth/tenant_schema.py +++ b/app/schemas/auth/tenant_schema.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field from typing import Optional, List -from datetime import datetime +from datetime import date, datetime import uuid class TenantBase(BaseModel): @@ -14,6 +14,10 @@ class ModuleEnvironmentAssignment(BaseModel): class TenantCreate(TenantBase): plan_id: uuid.UUID + start_date: Optional[date] = None + end_date: Optional[date] = None + status: Optional[str] = "ACTIVE" + module_environments: Optional[List[ModuleEnvironmentAssignment]] = [] default_environment_slug: str = "prod" @@ -23,11 +27,18 @@ class TenantUpdate(BaseModel): tenant_logo_url: Optional[str] = None is_active: Optional[bool] = None plan_id: Optional[uuid.UUID] = None + start_date: Optional[date] = None + end_date: Optional[date] = None + status: Optional[str] = None class TenantResponse(TenantBase): id: uuid.UUID + tenant_id: uuid.UUID is_active: bool plan_id: Optional[uuid.UUID] = None + start_date: Optional[date] = None + end_date: Optional[date] = None + status: str created_at: datetime updated_at: datetime diff --git a/app/services/auth/subscription_plan_service.py b/app/services/auth/subscription_plan_service.py index dd8ae61..1bc0b0b 100644 --- a/app/services/auth/subscription_plan_service.py +++ b/app/services/auth/subscription_plan_service.py @@ -26,6 +26,7 @@ class SubscriptionPlanService: name=plan_data.name, description=plan_data.description, price=plan_data.price, + duration_days=plan_data.duration_days, is_public=plan_data.is_public, status=plan_data.status ) diff --git a/app/services/auth/tenant_service.py b/app/services/auth/tenant_service.py index 028cb65..37533f6 100644 --- a/app/services/auth/tenant_service.py +++ b/app/services/auth/tenant_service.py @@ -1,3 +1,4 @@ +from datetime import date, datetime, timezone from sqlalchemy.orm import Session from sqlalchemy import or_, cast, String, asc, desc from fastapi import HTTPException, status @@ -20,7 +21,45 @@ import logging logger = logging.getLogger(__name__) class TenantService: - + STATUS_ACTIVE = "ACTIVE" + STATUS_INACTIVE = "INACTIVE" + STATUS_EXPIRED = "EXPIRED" + + @staticmethod + def _today() -> date: + return datetime.now(timezone.utc).date() + + @staticmethod + def _normalize_status(status_value: Optional[str], is_active: bool) -> str: + normalized = (status_value or "").strip().upper() + if normalized: + return normalized + return TenantService.STATUS_ACTIVE if is_active else TenantService.STATUS_INACTIVE + + @staticmethod + def _resolve_lifecycle( + *, + start_date: Optional[date], + end_date: Optional[date], + status_value: Optional[str], + is_active: bool, + ) -> tuple[str, bool]: + if start_date and end_date and end_date < start_date: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="end_date must be greater than or equal to start_date", + ) + + normalized_status = TenantService._normalize_status(status_value, is_active) + if end_date and end_date < TenantService._today(): + return TenantService.STATUS_EXPIRED, False + if normalized_status in { + TenantService.STATUS_INACTIVE, + TenantService.STATUS_EXPIRED, + }: + return normalized_status, False + return normalized_status, True + @staticmethod def create_tenant(db: Session, tenant_data: TenantCreate) -> Tenant: existing = db.query(Tenant).filter(Tenant.tenant_name == tenant_data.tenant_name).first() @@ -52,7 +91,15 @@ class TenantService: tenant_name=tenant_data.tenant_name, tenant_domain=tenant_data.tenant_domain, tenant_logo_url=tenant_data.tenant_logo_url, - plan_id=tenant_data.plan_id + plan_id=tenant_data.plan_id, + start_date=tenant_data.start_date, + end_date=tenant_data.end_date, + ) + tenant.status, tenant.is_active = TenantService._resolve_lifecycle( + start_date=tenant.start_date, + end_date=tenant.end_date, + status_value=tenant_data.status, + is_active=tenant.is_active, ) db.add(tenant) db.flush() @@ -323,7 +370,16 @@ class TenantService: for key, value in update_dict.items(): setattr(tenant, key, value) - + + if any(key in update_dict for key in ("start_date", "end_date", "status", "is_active")): + tenant.status, tenant.is_active = TenantService._resolve_lifecycle( + start_date=tenant.start_date, + end_date=tenant.end_date, + status_value=update_dict.get("status"), + is_active=tenant.is_active, + ) + should_emit_status = True + if should_emit_update or should_emit_status: active_modules = db.query(TenantModule).filter( TenantModule.tenant_id == tenant.id,