Files
bakery-ia/services/tenant/app/repositories/tenant_member_repository.py
2025-08-08 09:08:41 +02:00

447 lines
17 KiB
Python

"""
Tenant Member Repository
Repository for tenant membership operations
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, and_
from datetime import datetime, timedelta
import structlog
import json
from .base import TenantBaseRepository
from app.models.tenants import TenantMember
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
logger = structlog.get_logger()
class TenantMemberRepository(TenantBaseRepository):
"""Repository for tenant member operations"""
def __init__(self, model_class, session: AsyncSession, cache_ttl: Optional[int] = 300):
# Member data changes more frequently, shorter cache time (5 minutes)
super().__init__(model_class, session, cache_ttl)
async def create_membership(self, membership_data: Dict[str, Any]) -> TenantMember:
"""Create a new tenant membership with validation"""
try:
# Validate membership data
validation_result = self._validate_tenant_data(
membership_data,
["tenant_id", "user_id", "role"]
)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid membership data: {validation_result['errors']}")
# Check for existing membership
existing_membership = await self.get_membership(
membership_data["tenant_id"],
membership_data["user_id"]
)
if existing_membership and existing_membership.is_active:
raise DuplicateRecordError(f"User is already an active member of this tenant")
# Set default values
if "is_active" not in membership_data:
membership_data["is_active"] = True
if "joined_at" not in membership_data:
membership_data["joined_at"] = datetime.utcnow()
# Set permissions based on role
if "permissions" not in membership_data:
membership_data["permissions"] = self._get_default_permissions(
membership_data["role"]
)
# If reactivating existing membership
if existing_membership and not existing_membership.is_active:
# Update existing membership
update_data = {
key: value for key, value in membership_data.items()
if key not in ["tenant_id", "user_id"]
}
membership = await self.update(existing_membership.id, update_data)
else:
# Create new membership
membership = await self.create(membership_data)
logger.info("Tenant membership created",
membership_id=membership.id,
tenant_id=membership.tenant_id,
user_id=membership.user_id,
role=membership.role)
return membership
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Failed to create membership",
tenant_id=membership_data.get("tenant_id"),
user_id=membership_data.get("user_id"),
error=str(e))
raise DatabaseError(f"Failed to create membership: {str(e)}")
async def get_membership(self, tenant_id: str, user_id: str) -> Optional[TenantMember]:
"""Get specific membership by tenant and user"""
try:
memberships = await self.get_multi(
filters={
"tenant_id": tenant_id,
"user_id": user_id
},
limit=1,
order_by="created_at",
order_desc=True
)
return memberships[0] if memberships else None
except Exception as e:
logger.error("Failed to get membership",
tenant_id=tenant_id,
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to get membership: {str(e)}")
async def get_tenant_members(
self,
tenant_id: str,
active_only: bool = True,
role: str = None
) -> List[TenantMember]:
"""Get all members of a tenant"""
try:
filters = {"tenant_id": tenant_id}
if active_only:
filters["is_active"] = True
if role:
filters["role"] = role
return await self.get_multi(
filters=filters,
order_by="joined_at",
order_desc=False
)
except Exception as e:
logger.error("Failed to get tenant members",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to get members: {str(e)}")
async def get_user_memberships(
self,
user_id: str,
active_only: bool = True
) -> List[TenantMember]:
"""Get all tenants a user is a member of"""
try:
filters = {"user_id": user_id}
if active_only:
filters["is_active"] = True
return await self.get_multi(
filters=filters,
order_by="joined_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get user memberships",
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to get memberships: {str(e)}")
async def verify_user_access(
self,
user_id: str,
tenant_id: str
) -> Dict[str, Any]:
"""Verify if user has access to tenant and return access details"""
try:
membership = await self.get_membership(tenant_id, user_id)
if not membership or not membership.is_active:
return {
"has_access": False,
"role": "none",
"permissions": []
}
# Parse permissions
permissions = []
if membership.permissions:
try:
permissions = json.loads(membership.permissions)
except json.JSONDecodeError:
logger.warning("Invalid permissions JSON for membership",
membership_id=membership.id)
permissions = self._get_default_permissions(membership.role)
return {
"has_access": True,
"role": membership.role,
"permissions": permissions,
"membership_id": str(membership.id),
"joined_at": membership.joined_at.isoformat() if membership.joined_at else None
}
except Exception as e:
logger.error("Failed to verify user access",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
return {
"has_access": False,
"role": "none",
"permissions": []
}
async def update_member_role(
self,
tenant_id: str,
user_id: str,
new_role: str,
updated_by: str = None
) -> Optional[TenantMember]:
"""Update member role and permissions"""
try:
valid_roles = ["owner", "admin", "member", "viewer"]
if new_role not in valid_roles:
raise ValidationError(f"Invalid role. Must be one of: {valid_roles}")
membership = await self.get_membership(tenant_id, user_id)
if not membership:
raise ValidationError("Membership not found")
# Get new permissions based on role
new_permissions = self._get_default_permissions(new_role)
updated_membership = await self.update(membership.id, {
"role": new_role,
"permissions": json.dumps(new_permissions)
})
logger.info("Member role updated",
membership_id=membership.id,
tenant_id=tenant_id,
user_id=user_id,
old_role=membership.role,
new_role=new_role,
updated_by=updated_by)
return updated_membership
except ValidationError:
raise
except Exception as e:
logger.error("Failed to update member role",
tenant_id=tenant_id,
user_id=user_id,
new_role=new_role,
error=str(e))
raise DatabaseError(f"Failed to update role: {str(e)}")
async def deactivate_membership(
self,
tenant_id: str,
user_id: str,
deactivated_by: str = None
) -> Optional[TenantMember]:
"""Deactivate a membership (remove user from tenant)"""
try:
membership = await self.get_membership(tenant_id, user_id)
if not membership:
raise ValidationError("Membership not found")
# Don't allow deactivating the owner
if membership.role == "owner":
raise ValidationError("Cannot deactivate the owner membership")
updated_membership = await self.update(membership.id, {
"is_active": False
})
logger.info("Membership deactivated",
membership_id=membership.id,
tenant_id=tenant_id,
user_id=user_id,
deactivated_by=deactivated_by)
return updated_membership
except ValidationError:
raise
except Exception as e:
logger.error("Failed to deactivate membership",
tenant_id=tenant_id,
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to deactivate membership: {str(e)}")
async def reactivate_membership(
self,
tenant_id: str,
user_id: str,
reactivated_by: str = None
) -> Optional[TenantMember]:
"""Reactivate a deactivated membership"""
try:
membership = await self.get_membership(tenant_id, user_id)
if not membership:
raise ValidationError("Membership not found")
updated_membership = await self.update(membership.id, {
"is_active": True,
"joined_at": datetime.utcnow() # Update join date
})
logger.info("Membership reactivated",
membership_id=membership.id,
tenant_id=tenant_id,
user_id=user_id,
reactivated_by=reactivated_by)
return updated_membership
except ValidationError:
raise
except Exception as e:
logger.error("Failed to reactivate membership",
tenant_id=tenant_id,
user_id=user_id,
error=str(e))
raise DatabaseError(f"Failed to reactivate membership: {str(e)}")
async def get_membership_statistics(self, tenant_id: str) -> Dict[str, Any]:
"""Get membership statistics for a tenant"""
try:
# Get counts by role
role_query = text("""
SELECT role, COUNT(*) as count
FROM tenant_members
WHERE tenant_id = :tenant_id AND is_active = true
GROUP BY role
ORDER BY count DESC
""")
result = await self.session.execute(role_query, {"tenant_id": tenant_id})
members_by_role = {row.role: row.count for row in result.fetchall()}
# Get basic counts
total_members = await self.count(filters={"tenant_id": tenant_id})
active_members = await self.count(filters={
"tenant_id": tenant_id,
"is_active": True
})
# Get recent activity (members joined in last 30 days)
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
recent_joins = len(await self.get_multi(
filters={
"tenant_id": tenant_id,
"is_active": True
},
limit=1000 # High limit to get accurate count
))
# Filter for recent joins (manual filtering since we can't use date range in filters easily)
recent_members = 0
all_active_members = await self.get_tenant_members(tenant_id, active_only=True)
for member in all_active_members:
if member.joined_at and member.joined_at >= thirty_days_ago:
recent_members += 1
return {
"total_members": total_members,
"active_members": active_members,
"inactive_members": total_members - active_members,
"members_by_role": members_by_role,
"recent_joins_30d": recent_members
}
except Exception as e:
logger.error("Failed to get membership statistics",
tenant_id=tenant_id,
error=str(e))
return {
"total_members": 0,
"active_members": 0,
"inactive_members": 0,
"members_by_role": {},
"recent_joins_30d": 0
}
def _get_default_permissions(self, role: str) -> str:
"""Get default permissions JSON string for a role"""
permission_map = {
"owner": ["read", "write", "admin", "delete"],
"admin": ["read", "write", "admin"],
"member": ["read", "write"],
"viewer": ["read"]
}
permissions = permission_map.get(role, ["read"])
return json.dumps(permissions)
async def bulk_update_permissions(
self,
tenant_id: str,
role_permissions: Dict[str, List[str]]
) -> int:
"""Bulk update permissions for all members of specific roles"""
try:
updated_count = 0
for role, permissions in role_permissions.items():
members = await self.get_tenant_members(
tenant_id, active_only=True, role=role
)
for member in members:
await self.update(member.id, {
"permissions": json.dumps(permissions)
})
updated_count += 1
logger.info("Bulk updated member permissions",
tenant_id=tenant_id,
updated_count=updated_count,
roles=list(role_permissions.keys()))
return updated_count
except Exception as e:
logger.error("Failed to bulk update permissions",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Bulk permission update failed: {str(e)}")
async def cleanup_inactive_memberships(self, days_old: int = 180) -> int:
"""Clean up old inactive memberships"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
query_text = """
DELETE FROM tenant_members
WHERE is_active = false
AND created_at < :cutoff_date
"""
result = await self.session.execute(text(query_text), {"cutoff_date": cutoff_date})
deleted_count = result.rowcount
logger.info("Cleaned up inactive memberships",
deleted_count=deleted_count,
days_old=days_old)
return deleted_count
except Exception as e:
logger.error("Failed to cleanup inactive memberships",
error=str(e))
raise DatabaseError(f"Cleanup failed: {str(e)}")