feat: tenat subscription, duration, start and expiry date
This commit is contained in:
@@ -42,6 +42,7 @@ import app.models.auth.tenant_model
|
|||||||
import app.models.auth.access_model
|
import app.models.auth.access_model
|
||||||
import app.models.auth.role_access_model
|
import app.models.auth.role_access_model
|
||||||
import app.models.theme.color_palette_model
|
import app.models.theme.color_palette_model
|
||||||
|
import app.models.auth.subscription_plan_model
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# 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"
|
||||||
|
)
|
||||||
@@ -16,6 +16,7 @@ import app.models.auth.module_environment_model
|
|||||||
import app.models.auth.tenant_module_model
|
import app.models.auth.tenant_module_model
|
||||||
import app.models.auth.sso_grant_model
|
import app.models.auth.sso_grant_model
|
||||||
import app.models.auth.access_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.event_log_model
|
||||||
import app.models.system.audit_log
|
import app.models.system.audit_log
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ from fastapi import Depends, HTTPException, Request, status
|
|||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from datetime import datetime, timezone
|
||||||
from app.config.database import get_db
|
from app.config.database import get_db
|
||||||
from app.config.security import security
|
from app.config.security import security
|
||||||
from app.models.auth.user_model import User
|
from app.models.auth.user_model import User
|
||||||
from app.models.auth.access_model import Access
|
from app.models.auth.access_model import Access
|
||||||
|
from app.models.auth.tenant_model import Tenant
|
||||||
|
|
||||||
security_scheme = HTTPBearer(auto_error=False)
|
security_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
@@ -49,6 +51,27 @@ def get_current_user(
|
|||||||
detail="User is inactive"
|
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
|
return user
|
||||||
|
|
||||||
def require_active_user(current_user: User = Depends(get_current_user)) -> User:
|
def require_active_user(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.config.database import Base
|
from app.config.database import Base
|
||||||
@@ -11,6 +11,7 @@ class SubscriptionPlan(Base):
|
|||||||
name = Column(String, unique=True, nullable=False, index=True)
|
name = Column(String, unique=True, nullable=False, index=True)
|
||||||
description = Column(String, nullable=True)
|
description = Column(String, nullable=True)
|
||||||
price = Column(Numeric(10, 2), nullable=True)
|
price = Column(Numeric(10, 2), nullable=True)
|
||||||
|
duration_days = Column(Integer, nullable=True)
|
||||||
is_public = Column(Boolean, default=True)
|
is_public = Column(Boolean, default=True)
|
||||||
status = Column(String, default="active")
|
status = Column(String, default="active")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from app.config.database import Base
|
from app.config.database import Base
|
||||||
@@ -13,6 +13,9 @@ class Tenant(Base):
|
|||||||
tenant_logo_url = Column(String, nullable=True)
|
tenant_logo_url = Column(String, nullable=True)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id"), nullable=True, index=True)
|
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())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(
|
updated_at = Column(
|
||||||
@@ -25,5 +28,9 @@ class Tenant(Base):
|
|||||||
tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan")
|
tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan")
|
||||||
plan = relationship("SubscriptionPlan", back_populates="tenants")
|
plan = relationship("SubscriptionPlan", back_populates="tenants")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tenant_id(self):
|
||||||
|
return self.id
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Tenant {self.tenant_name}>"
|
return f"<Tenant {self.tenant_name}>"
|
||||||
@@ -80,6 +80,7 @@ def get_plan(
|
|||||||
name=plan.name,
|
name=plan.name,
|
||||||
description=plan.description,
|
description=plan.description,
|
||||||
price=float(plan.price) if plan.price is not None else None,
|
price=float(plan.price) if plan.price is not None else None,
|
||||||
|
duration_days=plan.duration_days,
|
||||||
is_public=plan.is_public,
|
is_public=plan.is_public,
|
||||||
status=plan.status,
|
status=plan.status,
|
||||||
created_at=plan.created_at,
|
created_at=plan.created_at,
|
||||||
|
|||||||
@@ -70,7 +70,12 @@ def update_tenant(
|
|||||||
old_snapshot = {
|
old_snapshot = {
|
||||||
"tenant_name": existing.tenant_name,
|
"tenant_name": existing.tenant_name,
|
||||||
"tenant_domain": existing.tenant_domain,
|
"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
|
# 2. Perform update
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class SubscriptionPlanBase(BaseModel):
|
|||||||
name: str = Field(..., min_length=2, max_length=100)
|
name: str = Field(..., min_length=2, max_length=100)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
price: Optional[float] = None
|
price: Optional[float] = None
|
||||||
|
duration_days: Optional[int] = Field(None, ge=1)
|
||||||
is_public: bool = True
|
is_public: bool = True
|
||||||
status: str = "active"
|
status: str = "active"
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ class SubscriptionPlanUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(None, min_length=2, max_length=100)
|
name: Optional[str] = Field(None, min_length=2, max_length=100)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
price: Optional[float] = None
|
price: Optional[float] = None
|
||||||
|
duration_days: Optional[int] = Field(None, ge=1)
|
||||||
is_public: Optional[bool] = None
|
is_public: Optional[bool] = None
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
access_ids: Optional[List[uuid.UUID]] = None
|
access_ids: Optional[List[uuid.UUID]] = None
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
class TenantBase(BaseModel):
|
class TenantBase(BaseModel):
|
||||||
@@ -14,6 +14,10 @@ class ModuleEnvironmentAssignment(BaseModel):
|
|||||||
|
|
||||||
class TenantCreate(TenantBase):
|
class TenantCreate(TenantBase):
|
||||||
plan_id: uuid.UUID
|
plan_id: uuid.UUID
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
status: Optional[str] = "ACTIVE"
|
||||||
|
|
||||||
module_environments: Optional[List[ModuleEnvironmentAssignment]] = []
|
module_environments: Optional[List[ModuleEnvironmentAssignment]] = []
|
||||||
default_environment_slug: str = "prod"
|
default_environment_slug: str = "prod"
|
||||||
|
|
||||||
@@ -23,11 +27,18 @@ class TenantUpdate(BaseModel):
|
|||||||
tenant_logo_url: Optional[str] = None
|
tenant_logo_url: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
plan_id: Optional[uuid.UUID] = None
|
plan_id: Optional[uuid.UUID] = None
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
class TenantResponse(TenantBase):
|
class TenantResponse(TenantBase):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
|
tenant_id: uuid.UUID
|
||||||
is_active: bool
|
is_active: bool
|
||||||
plan_id: Optional[uuid.UUID] = None
|
plan_id: Optional[uuid.UUID] = None
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
status: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class SubscriptionPlanService:
|
|||||||
name=plan_data.name,
|
name=plan_data.name,
|
||||||
description=plan_data.description,
|
description=plan_data.description,
|
||||||
price=plan_data.price,
|
price=plan_data.price,
|
||||||
|
duration_days=plan_data.duration_days,
|
||||||
is_public=plan_data.is_public,
|
is_public=plan_data.is_public,
|
||||||
status=plan_data.status
|
status=plan_data.status
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from datetime import date, datetime, timezone
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import or_, cast, String, asc, desc
|
from sqlalchemy import or_, cast, String, asc, desc
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
@@ -20,7 +21,45 @@ import logging
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class TenantService:
|
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
|
@staticmethod
|
||||||
def create_tenant(db: Session, tenant_data: TenantCreate) -> Tenant:
|
def create_tenant(db: Session, tenant_data: TenantCreate) -> Tenant:
|
||||||
existing = db.query(Tenant).filter(Tenant.tenant_name == tenant_data.tenant_name).first()
|
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_name=tenant_data.tenant_name,
|
||||||
tenant_domain=tenant_data.tenant_domain,
|
tenant_domain=tenant_data.tenant_domain,
|
||||||
tenant_logo_url=tenant_data.tenant_logo_url,
|
tenant_logo_url=tenant_data.tenant_logo_url,
|
||||||
plan_id=tenant_data.plan_id
|
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.add(tenant)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -323,7 +370,16 @@ class TenantService:
|
|||||||
|
|
||||||
for key, value in update_dict.items():
|
for key, value in update_dict.items():
|
||||||
setattr(tenant, key, value)
|
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:
|
if should_emit_update or should_emit_status:
|
||||||
active_modules = db.query(TenantModule).filter(
|
active_modules = db.query(TenantModule).filter(
|
||||||
TenantModule.tenant_id == tenant.id,
|
TenantModule.tenant_id == tenant.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user