feat: tenat subscription, duration, start and expiry date

This commit is contained in:
amee
2026-04-18 16:20:17 +05:30
parent 53d6e92399
commit f3deee5479
13 changed files with 193 additions and 7 deletions
+1
View File
@@ -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.
@@ -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")
@@ -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"
)
+1
View File
@@ -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
+23
View File
@@ -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:
+2 -1
View File
@@ -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")
+8 -1
View File
@@ -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"<Tenant {self.tenant_name}>"
+1
View File
@@ -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,
+6 -1
View File
@@ -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
@@ -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
+12 -1
View File
@@ -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
@@ -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
)
+59 -3
View File
@@ -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,