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.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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user