REFACTOR - Database logic

This commit is contained in:
Urtzi Alfaro
2025-08-08 09:08:41 +02:00
parent 0154365bfc
commit 488bb3ef93
113 changed files with 22842 additions and 6503 deletions

View File

@@ -0,0 +1,16 @@
"""
Tenant Service Repositories
Repository implementations for tenant service
"""
from .base import TenantBaseRepository
from .tenant_repository import TenantRepository
from .tenant_member_repository import TenantMemberRepository
from .subscription_repository import SubscriptionRepository
__all__ = [
"TenantBaseRepository",
"TenantRepository",
"TenantMemberRepository",
"SubscriptionRepository"
]

View File

@@ -0,0 +1,234 @@
"""
Base Repository for Tenant Service
Service-specific repository base class with tenant management utilities
"""
from typing import Optional, List, Dict, Any, Type
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from datetime import datetime, timedelta
import structlog
import json
from shared.database.repository import BaseRepository
from shared.database.exceptions import DatabaseError
logger = structlog.get_logger()
class TenantBaseRepository(BaseRepository):
"""Base repository for tenant service with common tenant operations"""
def __init__(self, model: Type, session: AsyncSession, cache_ttl: Optional[int] = 600):
# Tenant data is relatively stable, medium cache time (10 minutes)
super().__init__(model, session, cache_ttl)
async def get_by_tenant_id(self, tenant_id: str, skip: int = 0, limit: int = 100) -> List:
"""Get records by tenant ID"""
if hasattr(self.model, 'tenant_id'):
return await self.get_multi(
skip=skip,
limit=limit,
filters={"tenant_id": tenant_id},
order_by="created_at",
order_desc=True
)
return await self.get_multi(skip=skip, limit=limit)
async def get_by_user_id(self, user_id: str, skip: int = 0, limit: int = 100) -> List:
"""Get records by user ID (for cross-service references)"""
if hasattr(self.model, 'user_id'):
return await self.get_multi(
skip=skip,
limit=limit,
filters={"user_id": user_id},
order_by="created_at",
order_desc=True
)
elif hasattr(self.model, 'owner_id'):
return await self.get_multi(
skip=skip,
limit=limit,
filters={"owner_id": user_id},
order_by="created_at",
order_desc=True
)
return []
async def get_active_records(self, skip: int = 0, limit: int = 100) -> List:
"""Get active records (if model has is_active field)"""
if hasattr(self.model, 'is_active'):
return await self.get_multi(
skip=skip,
limit=limit,
filters={"is_active": True},
order_by="created_at",
order_desc=True
)
return await self.get_multi(skip=skip, limit=limit)
async def deactivate_record(self, record_id: Any) -> Optional:
"""Deactivate a record instead of deleting it"""
if hasattr(self.model, 'is_active'):
return await self.update(record_id, {"is_active": False})
return await self.delete(record_id)
async def activate_record(self, record_id: Any) -> Optional:
"""Activate a record"""
if hasattr(self.model, 'is_active'):
return await self.update(record_id, {"is_active": True})
return await self.get_by_id(record_id)
async def cleanup_old_records(self, days_old: int = 365) -> int:
"""Clean up old tenant records (very conservative - 1 year)"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
table_name = self.model.__tablename__
# Only delete inactive records that are very old
conditions = [
"created_at < :cutoff_date"
]
if hasattr(self.model, 'is_active'):
conditions.append("is_active = false")
query_text = f"""
DELETE FROM {table_name}
WHERE {' AND '.join(conditions)}
"""
result = await self.session.execute(text(query_text), {"cutoff_date": cutoff_date})
deleted_count = result.rowcount
logger.info(f"Cleaned up old {self.model.__name__} records",
deleted_count=deleted_count,
days_old=days_old)
return deleted_count
except Exception as e:
logger.error("Failed to cleanup old records",
model=self.model.__name__,
error=str(e))
raise DatabaseError(f"Cleanup failed: {str(e)}")
async def get_statistics_by_tenant(self, tenant_id: str) -> Dict[str, Any]:
"""Get statistics for a tenant"""
try:
table_name = self.model.__tablename__
# Get basic counts
total_records = await self.count(filters={"tenant_id": tenant_id})
# Get active records if applicable
active_records = total_records
if hasattr(self.model, 'is_active'):
active_records = await self.count(filters={
"tenant_id": tenant_id,
"is_active": True
})
# Get recent activity (records in last 7 days)
seven_days_ago = datetime.utcnow() - timedelta(days=7)
recent_query = text(f"""
SELECT COUNT(*) as count
FROM {table_name}
WHERE tenant_id = :tenant_id
AND created_at >= :seven_days_ago
""")
result = await self.session.execute(recent_query, {
"tenant_id": tenant_id,
"seven_days_ago": seven_days_ago
})
recent_records = result.scalar() or 0
return {
"total_records": total_records,
"active_records": active_records,
"inactive_records": total_records - active_records,
"recent_records_7d": recent_records
}
except Exception as e:
logger.error("Failed to get tenant statistics",
model=self.model.__name__,
tenant_id=tenant_id,
error=str(e))
return {
"total_records": 0,
"active_records": 0,
"inactive_records": 0,
"recent_records_7d": 0
}
def _validate_tenant_data(self, data: Dict[str, Any], required_fields: List[str]) -> Dict[str, Any]:
"""Validate tenant-related data"""
errors = []
for field in required_fields:
if field not in data or not data[field]:
errors.append(f"Missing required field: {field}")
# Validate tenant_id format if present
if "tenant_id" in data and data["tenant_id"]:
tenant_id = data["tenant_id"]
if not isinstance(tenant_id, str) or len(tenant_id) < 1:
errors.append("Invalid tenant_id format")
# Validate user_id format if present
if "user_id" in data and data["user_id"]:
user_id = data["user_id"]
if not isinstance(user_id, str) or len(user_id) < 1:
errors.append("Invalid user_id format")
# Validate owner_id format if present
if "owner_id" in data and data["owner_id"]:
owner_id = data["owner_id"]
if not isinstance(owner_id, str) or len(owner_id) < 1:
errors.append("Invalid owner_id format")
# Validate email format if present
if "email" in data and data["email"]:
email = data["email"]
if "@" not in email or "." not in email.split("@")[-1]:
errors.append("Invalid email format")
# Validate phone format if present (basic validation)
if "phone" in data and data["phone"]:
phone = data["phone"]
if not isinstance(phone, str) or len(phone) < 9:
errors.append("Invalid phone format")
# Validate coordinates if present
if "latitude" in data and data["latitude"] is not None:
try:
lat = float(data["latitude"])
if lat < -90 or lat > 90:
errors.append("Invalid latitude - must be between -90 and 90")
except (ValueError, TypeError):
errors.append("Invalid latitude format")
if "longitude" in data and data["longitude"] is not None:
try:
lng = float(data["longitude"])
if lng < -180 or lng > 180:
errors.append("Invalid longitude - must be between -180 and 180")
except (ValueError, TypeError):
errors.append("Invalid longitude format")
# Validate JSON fields
json_fields = ["permissions"]
for field in json_fields:
if field in data and data[field]:
if isinstance(data[field], str):
try:
json.loads(data[field])
except json.JSONDecodeError:
errors.append(f"Invalid JSON format in {field}")
return {
"is_valid": len(errors) == 0,
"errors": errors
}

View File

@@ -0,0 +1,420 @@
"""
Subscription Repository
Repository for subscription 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 Subscription
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
logger = structlog.get_logger()
class SubscriptionRepository(TenantBaseRepository):
"""Repository for subscription operations"""
def __init__(self, model_class, session: AsyncSession, cache_ttl: Optional[int] = 600):
# Subscriptions are relatively stable, medium cache time (10 minutes)
super().__init__(model_class, session, cache_ttl)
async def create_subscription(self, subscription_data: Dict[str, Any]) -> Subscription:
"""Create a new subscription with validation"""
try:
# Validate subscription data
validation_result = self._validate_tenant_data(
subscription_data,
["tenant_id", "plan"]
)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid subscription data: {validation_result['errors']}")
# Check for existing active subscription
existing_subscription = await self.get_active_subscription(
subscription_data["tenant_id"]
)
if existing_subscription:
raise DuplicateRecordError(f"Tenant already has an active subscription")
# Set default values based on plan
plan_config = self._get_plan_configuration(subscription_data["plan"])
# Set defaults from plan config
for key, value in plan_config.items():
if key not in subscription_data:
subscription_data[key] = value
# Set default subscription values
if "status" not in subscription_data:
subscription_data["status"] = "active"
if "billing_cycle" not in subscription_data:
subscription_data["billing_cycle"] = "monthly"
if "next_billing_date" not in subscription_data:
# Set next billing date based on cycle
if subscription_data["billing_cycle"] == "yearly":
subscription_data["next_billing_date"] = datetime.utcnow() + timedelta(days=365)
else:
subscription_data["next_billing_date"] = datetime.utcnow() + timedelta(days=30)
# Create subscription
subscription = await self.create(subscription_data)
logger.info("Subscription created successfully",
subscription_id=subscription.id,
tenant_id=subscription.tenant_id,
plan=subscription.plan,
monthly_price=subscription.monthly_price)
return subscription
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Failed to create subscription",
tenant_id=subscription_data.get("tenant_id"),
plan=subscription_data.get("plan"),
error=str(e))
raise DatabaseError(f"Failed to create subscription: {str(e)}")
async def get_active_subscription(self, tenant_id: str) -> Optional[Subscription]:
"""Get active subscription for tenant"""
try:
subscriptions = await self.get_multi(
filters={
"tenant_id": tenant_id,
"status": "active"
},
limit=1,
order_by="created_at",
order_desc=True
)
return subscriptions[0] if subscriptions else None
except Exception as e:
logger.error("Failed to get active subscription",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to get subscription: {str(e)}")
async def get_tenant_subscriptions(
self,
tenant_id: str,
include_inactive: bool = False
) -> List[Subscription]:
"""Get all subscriptions for a tenant"""
try:
filters = {"tenant_id": tenant_id}
if not include_inactive:
filters["status"] = "active"
return await self.get_multi(
filters=filters,
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get tenant subscriptions",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to get subscriptions: {str(e)}")
async def update_subscription_plan(
self,
subscription_id: str,
new_plan: str
) -> Optional[Subscription]:
"""Update subscription plan and pricing"""
try:
valid_plans = ["basic", "professional", "enterprise"]
if new_plan not in valid_plans:
raise ValidationError(f"Invalid plan. Must be one of: {valid_plans}")
# Get new plan configuration
plan_config = self._get_plan_configuration(new_plan)
# Update subscription with new plan details
update_data = {
"plan": new_plan,
"monthly_price": plan_config["monthly_price"],
"max_users": plan_config["max_users"],
"max_locations": plan_config["max_locations"],
"max_products": plan_config["max_products"],
"updated_at": datetime.utcnow()
}
updated_subscription = await self.update(subscription_id, update_data)
logger.info("Subscription plan updated",
subscription_id=subscription_id,
new_plan=new_plan,
new_price=plan_config["monthly_price"])
return updated_subscription
except ValidationError:
raise
except Exception as e:
logger.error("Failed to update subscription plan",
subscription_id=subscription_id,
new_plan=new_plan,
error=str(e))
raise DatabaseError(f"Failed to update plan: {str(e)}")
async def cancel_subscription(
self,
subscription_id: str,
reason: str = None
) -> Optional[Subscription]:
"""Cancel a subscription"""
try:
update_data = {
"status": "cancelled",
"updated_at": datetime.utcnow()
}
updated_subscription = await self.update(subscription_id, update_data)
logger.info("Subscription cancelled",
subscription_id=subscription_id,
reason=reason)
return updated_subscription
except Exception as e:
logger.error("Failed to cancel subscription",
subscription_id=subscription_id,
error=str(e))
raise DatabaseError(f"Failed to cancel subscription: {str(e)}")
async def suspend_subscription(
self,
subscription_id: str,
reason: str = None
) -> Optional[Subscription]:
"""Suspend a subscription"""
try:
update_data = {
"status": "suspended",
"updated_at": datetime.utcnow()
}
updated_subscription = await self.update(subscription_id, update_data)
logger.info("Subscription suspended",
subscription_id=subscription_id,
reason=reason)
return updated_subscription
except Exception as e:
logger.error("Failed to suspend subscription",
subscription_id=subscription_id,
error=str(e))
raise DatabaseError(f"Failed to suspend subscription: {str(e)}")
async def reactivate_subscription(
self,
subscription_id: str
) -> Optional[Subscription]:
"""Reactivate a cancelled or suspended subscription"""
try:
# Reset billing date when reactivating
next_billing_date = datetime.utcnow() + timedelta(days=30)
update_data = {
"status": "active",
"next_billing_date": next_billing_date,
"updated_at": datetime.utcnow()
}
updated_subscription = await self.update(subscription_id, update_data)
logger.info("Subscription reactivated",
subscription_id=subscription_id,
next_billing_date=next_billing_date)
return updated_subscription
except Exception as e:
logger.error("Failed to reactivate subscription",
subscription_id=subscription_id,
error=str(e))
raise DatabaseError(f"Failed to reactivate subscription: {str(e)}")
async def get_subscriptions_due_for_billing(
self,
days_ahead: int = 3
) -> List[Subscription]:
"""Get subscriptions that need billing in the next N days"""
try:
cutoff_date = datetime.utcnow() + timedelta(days=days_ahead)
query_text = """
SELECT * FROM subscriptions
WHERE status = 'active'
AND next_billing_date <= :cutoff_date
ORDER BY next_billing_date ASC
"""
result = await self.session.execute(text(query_text), {
"cutoff_date": cutoff_date
})
subscriptions = []
for row in result.fetchall():
record_dict = dict(row._mapping)
subscription = self.model(**record_dict)
subscriptions.append(subscription)
return subscriptions
except Exception as e:
logger.error("Failed to get subscriptions due for billing",
days_ahead=days_ahead,
error=str(e))
return []
async def update_billing_date(
self,
subscription_id: str,
next_billing_date: datetime
) -> Optional[Subscription]:
"""Update next billing date for subscription"""
try:
updated_subscription = await self.update(subscription_id, {
"next_billing_date": next_billing_date,
"updated_at": datetime.utcnow()
})
logger.info("Subscription billing date updated",
subscription_id=subscription_id,
next_billing_date=next_billing_date)
return updated_subscription
except Exception as e:
logger.error("Failed to update billing date",
subscription_id=subscription_id,
error=str(e))
raise DatabaseError(f"Failed to update billing date: {str(e)}")
async def get_subscription_statistics(self) -> Dict[str, Any]:
"""Get subscription statistics"""
try:
# Get counts by plan
plan_query = text("""
SELECT plan, COUNT(*) as count
FROM subscriptions
WHERE status = 'active'
GROUP BY plan
ORDER BY count DESC
""")
result = await self.session.execute(plan_query)
subscriptions_by_plan = {row.plan: row.count for row in result.fetchall()}
# Get counts by status
status_query = text("""
SELECT status, COUNT(*) as count
FROM subscriptions
GROUP BY status
ORDER BY count DESC
""")
result = await self.session.execute(status_query)
subscriptions_by_status = {row.status: row.count for row in result.fetchall()}
# Get revenue statistics
revenue_query = text("""
SELECT
SUM(monthly_price) as total_monthly_revenue,
AVG(monthly_price) as avg_monthly_price,
COUNT(*) as total_active_subscriptions
FROM subscriptions
WHERE status = 'active'
""")
revenue_result = await self.session.execute(revenue_query)
revenue_row = revenue_result.fetchone()
# Get upcoming billing count
thirty_days_ahead = datetime.utcnow() + timedelta(days=30)
upcoming_billing = len(await self.get_subscriptions_due_for_billing(30))
return {
"subscriptions_by_plan": subscriptions_by_plan,
"subscriptions_by_status": subscriptions_by_status,
"total_monthly_revenue": float(revenue_row.total_monthly_revenue or 0),
"avg_monthly_price": float(revenue_row.avg_monthly_price or 0),
"total_active_subscriptions": int(revenue_row.total_active_subscriptions or 0),
"upcoming_billing_30d": upcoming_billing
}
except Exception as e:
logger.error("Failed to get subscription statistics", error=str(e))
return {
"subscriptions_by_plan": {},
"subscriptions_by_status": {},
"total_monthly_revenue": 0.0,
"avg_monthly_price": 0.0,
"total_active_subscriptions": 0,
"upcoming_billing_30d": 0
}
async def cleanup_old_subscriptions(self, days_old: int = 730) -> int:
"""Clean up very old cancelled subscriptions (2 years)"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
query_text = """
DELETE FROM subscriptions
WHERE status IN ('cancelled', 'suspended')
AND updated_at < :cutoff_date
"""
result = await self.session.execute(text(query_text), {"cutoff_date": cutoff_date})
deleted_count = result.rowcount
logger.info("Cleaned up old subscriptions",
deleted_count=deleted_count,
days_old=days_old)
return deleted_count
except Exception as e:
logger.error("Failed to cleanup old subscriptions",
error=str(e))
raise DatabaseError(f"Cleanup failed: {str(e)}")
def _get_plan_configuration(self, plan: str) -> Dict[str, Any]:
"""Get configuration for a subscription plan"""
plan_configs = {
"basic": {
"monthly_price": 29.99,
"max_users": 2,
"max_locations": 1,
"max_products": 50
},
"professional": {
"monthly_price": 79.99,
"max_users": 10,
"max_locations": 3,
"max_products": 200
},
"enterprise": {
"monthly_price": 199.99,
"max_users": 50,
"max_locations": 10,
"max_products": 1000
}
}
return plan_configs.get(plan, plan_configs["basic"])

View File

@@ -0,0 +1,447 @@
"""
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)}")

View File

@@ -0,0 +1,410 @@
"""
Tenant Repository
Repository for tenant 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 uuid
from .base import TenantBaseRepository
from app.models.tenants import Tenant
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
logger = structlog.get_logger()
class TenantRepository(TenantBaseRepository):
"""Repository for tenant operations"""
def __init__(self, model_class, session: AsyncSession, cache_ttl: Optional[int] = 600):
# Tenants are relatively stable, longer cache time (10 minutes)
super().__init__(model_class, session, cache_ttl)
async def create_tenant(self, tenant_data: Dict[str, Any]) -> Tenant:
"""Create a new tenant with validation"""
try:
# Validate tenant data
validation_result = self._validate_tenant_data(
tenant_data,
["name", "address", "postal_code", "owner_id"]
)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid tenant data: {validation_result['errors']}")
# Generate subdomain if not provided
if "subdomain" not in tenant_data or not tenant_data["subdomain"]:
subdomain = await self._generate_unique_subdomain(tenant_data["name"])
tenant_data["subdomain"] = subdomain
else:
# Check if provided subdomain is unique
existing_tenant = await self.get_by_subdomain(tenant_data["subdomain"])
if existing_tenant:
raise DuplicateRecordError(f"Subdomain {tenant_data['subdomain']} already exists")
# Set default values
if "business_type" not in tenant_data:
tenant_data["business_type"] = "bakery"
if "city" not in tenant_data:
tenant_data["city"] = "Madrid"
if "is_active" not in tenant_data:
tenant_data["is_active"] = True
if "subscription_tier" not in tenant_data:
tenant_data["subscription_tier"] = "basic"
if "model_trained" not in tenant_data:
tenant_data["model_trained"] = False
# Create tenant
tenant = await self.create(tenant_data)
logger.info("Tenant created successfully",
tenant_id=tenant.id,
name=tenant.name,
subdomain=tenant.subdomain,
owner_id=tenant.owner_id)
return tenant
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Failed to create tenant",
name=tenant_data.get("name"),
error=str(e))
raise DatabaseError(f"Failed to create tenant: {str(e)}")
async def get_by_subdomain(self, subdomain: str) -> Optional[Tenant]:
"""Get tenant by subdomain"""
try:
return await self.get_by_field("subdomain", subdomain)
except Exception as e:
logger.error("Failed to get tenant by subdomain",
subdomain=subdomain,
error=str(e))
raise DatabaseError(f"Failed to get tenant: {str(e)}")
async def get_tenants_by_owner(self, owner_id: str) -> List[Tenant]:
"""Get all tenants owned by a user"""
try:
return await self.get_multi(
filters={"owner_id": owner_id, "is_active": True},
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get tenants by owner",
owner_id=owner_id,
error=str(e))
raise DatabaseError(f"Failed to get tenants: {str(e)}")
async def get_active_tenants(self, skip: int = 0, limit: int = 100) -> List[Tenant]:
"""Get all active tenants"""
return await self.get_active_records(skip=skip, limit=limit)
async def search_tenants(
self,
search_term: str,
business_type: str = None,
city: str = None,
skip: int = 0,
limit: int = 50
) -> List[Tenant]:
"""Search tenants by name, address, or other criteria"""
try:
# Build search conditions
conditions = ["is_active = true"]
params = {"skip": skip, "limit": limit}
# Add text search
conditions.append("(LOWER(name) LIKE LOWER(:search_term) OR LOWER(address) LIKE LOWER(:search_term))")
params["search_term"] = f"%{search_term}%"
# Add business type filter
if business_type:
conditions.append("business_type = :business_type")
params["business_type"] = business_type
# Add city filter
if city:
conditions.append("LOWER(city) = LOWER(:city)")
params["city"] = city
query_text = f"""
SELECT * FROM tenants
WHERE {' AND '.join(conditions)}
ORDER BY name ASC
LIMIT :limit OFFSET :skip
"""
result = await self.session.execute(text(query_text), params)
tenants = []
for row in result.fetchall():
record_dict = dict(row._mapping)
tenant = self.model(**record_dict)
tenants.append(tenant)
return tenants
except Exception as e:
logger.error("Failed to search tenants",
search_term=search_term,
error=str(e))
return []
async def update_tenant_model_status(
self,
tenant_id: str,
model_trained: bool,
last_training_date: datetime = None
) -> Optional[Tenant]:
"""Update tenant model training status"""
try:
update_data = {
"model_trained": model_trained,
"updated_at": datetime.utcnow()
}
if last_training_date:
update_data["last_training_date"] = last_training_date
elif model_trained:
update_data["last_training_date"] = datetime.utcnow()
updated_tenant = await self.update(tenant_id, update_data)
logger.info("Tenant model status updated",
tenant_id=tenant_id,
model_trained=model_trained,
last_training_date=last_training_date)
return updated_tenant
except Exception as e:
logger.error("Failed to update tenant model status",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to update model status: {str(e)}")
async def update_subscription_tier(
self,
tenant_id: str,
subscription_tier: str
) -> Optional[Tenant]:
"""Update tenant subscription tier"""
try:
valid_tiers = ["basic", "professional", "enterprise"]
if subscription_tier not in valid_tiers:
raise ValidationError(f"Invalid subscription tier. Must be one of: {valid_tiers}")
updated_tenant = await self.update(tenant_id, {
"subscription_tier": subscription_tier,
"updated_at": datetime.utcnow()
})
logger.info("Tenant subscription tier updated",
tenant_id=tenant_id,
subscription_tier=subscription_tier)
return updated_tenant
except ValidationError:
raise
except Exception as e:
logger.error("Failed to update subscription tier",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to update subscription: {str(e)}")
async def get_tenants_by_location(
self,
latitude: float,
longitude: float,
radius_km: float = 10.0,
limit: int = 50
) -> List[Tenant]:
"""Get tenants within a geographic radius"""
try:
# Using Haversine formula for distance calculation
query_text = """
SELECT *,
(6371 * acos(
cos(radians(:latitude)) *
cos(radians(latitude)) *
cos(radians(longitude) - radians(:longitude)) +
sin(radians(:latitude)) *
sin(radians(latitude))
)) AS distance_km
FROM tenants
WHERE is_active = true
AND latitude IS NOT NULL
AND longitude IS NOT NULL
HAVING distance_km <= :radius_km
ORDER BY distance_km ASC
LIMIT :limit
"""
result = await self.session.execute(text(query_text), {
"latitude": latitude,
"longitude": longitude,
"radius_km": radius_km,
"limit": limit
})
tenants = []
for row in result.fetchall():
# Create tenant object (excluding the calculated distance_km field)
record_dict = dict(row._mapping)
record_dict.pop("distance_km", None) # Remove calculated field
tenant = self.model(**record_dict)
tenants.append(tenant)
return tenants
except Exception as e:
logger.error("Failed to get tenants by location",
latitude=latitude,
longitude=longitude,
radius_km=radius_km,
error=str(e))
return []
async def get_tenant_statistics(self) -> Dict[str, Any]:
"""Get global tenant statistics"""
try:
# Get basic counts
total_tenants = await self.count()
active_tenants = await self.count(filters={"is_active": True})
# Get tenants by business type
business_type_query = text("""
SELECT business_type, COUNT(*) as count
FROM tenants
WHERE is_active = true
GROUP BY business_type
ORDER BY count DESC
""")
result = await self.session.execute(business_type_query)
business_type_stats = {row.business_type: row.count for row in result.fetchall()}
# Get tenants by subscription tier
tier_query = text("""
SELECT subscription_tier, COUNT(*) as count
FROM tenants
WHERE is_active = true
GROUP BY subscription_tier
ORDER BY count DESC
""")
tier_result = await self.session.execute(tier_query)
tier_stats = {row.subscription_tier: row.count for row in tier_result.fetchall()}
# Get model training statistics
model_query = text("""
SELECT
COUNT(CASE WHEN model_trained = true THEN 1 END) as trained_count,
COUNT(CASE WHEN model_trained = false THEN 1 END) as untrained_count,
AVG(EXTRACT(EPOCH FROM (NOW() - last_training_date))/86400) as avg_days_since_training
FROM tenants
WHERE is_active = true
""")
model_result = await self.session.execute(model_query)
model_row = model_result.fetchone()
# Get recent registrations (last 30 days)
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
recent_registrations = await self.count(filters={
"created_at": f">= '{thirty_days_ago.isoformat()}'"
})
return {
"total_tenants": total_tenants,
"active_tenants": active_tenants,
"inactive_tenants": total_tenants - active_tenants,
"tenants_by_business_type": business_type_stats,
"tenants_by_subscription": tier_stats,
"model_training": {
"trained_tenants": int(model_row.trained_count or 0),
"untrained_tenants": int(model_row.untrained_count or 0),
"avg_days_since_training": float(model_row.avg_days_since_training or 0)
} if model_row else {
"trained_tenants": 0,
"untrained_tenants": 0,
"avg_days_since_training": 0.0
},
"recent_registrations_30d": recent_registrations
}
except Exception as e:
logger.error("Failed to get tenant statistics", error=str(e))
return {
"total_tenants": 0,
"active_tenants": 0,
"inactive_tenants": 0,
"tenants_by_business_type": {},
"tenants_by_subscription": {},
"model_training": {
"trained_tenants": 0,
"untrained_tenants": 0,
"avg_days_since_training": 0.0
},
"recent_registrations_30d": 0
}
async def _generate_unique_subdomain(self, name: str) -> str:
"""Generate a unique subdomain from tenant name"""
try:
# Clean the name to create a subdomain
subdomain = name.lower().replace(' ', '-')
# Remove accents
subdomain = subdomain.replace('á', 'a').replace('é', 'e').replace('í', 'i').replace('ó', 'o').replace('ú', 'u')
subdomain = subdomain.replace('ñ', 'n')
# Keep only alphanumeric and hyphens
subdomain = ''.join(c for c in subdomain if c.isalnum() or c == '-')
# Remove multiple consecutive hyphens
while '--' in subdomain:
subdomain = subdomain.replace('--', '-')
# Remove leading/trailing hyphens
subdomain = subdomain.strip('-')
# Ensure minimum length
if len(subdomain) < 3:
subdomain = f"tenant-{subdomain}"
# Check if subdomain exists
existing_tenant = await self.get_by_subdomain(subdomain)
if not existing_tenant:
return subdomain
# If it exists, add a unique suffix
counter = 1
while True:
candidate = f"{subdomain}-{counter}"
existing_tenant = await self.get_by_subdomain(candidate)
if not existing_tenant:
return candidate
counter += 1
# Prevent infinite loop
if counter > 9999:
return f"{subdomain}-{uuid.uuid4().hex[:6]}"
except Exception as e:
logger.error("Failed to generate unique subdomain",
name=name,
error=str(e))
# Fallback to UUID-based subdomain
return f"tenant-{uuid.uuid4().hex[:8]}"
async def deactivate_tenant(self, tenant_id: str) -> Optional[Tenant]:
"""Deactivate a tenant"""
return await self.deactivate_record(tenant_id)
async def activate_tenant(self, tenant_id: str) -> Optional[Tenant]:
"""Activate a tenant"""
return await self.activate_record(tenant_id)