2026-01-17 14:18:00 +05:30
|
|
|
from sqlalchemy.orm import Session
|
2026-04-17 10:34:51 +05:30
|
|
|
from sqlalchemy import or_, cast, String, asc, desc, func
|
2026-01-20 17:38:01 +05:30
|
|
|
from fastapi import HTTPException, status, BackgroundTasks
|
2026-01-17 14:18:00 +05:30
|
|
|
from datetime import datetime
|
|
|
|
|
import uuid
|
2026-02-02 17:33:35 +05:30
|
|
|
from typing import Optional, List, Dict, Any
|
2026-01-17 14:18:00 +05:30
|
|
|
from app.models.auth.user_model import User
|
|
|
|
|
from app.schemas.auth.user_schema import UserCreate, UserUpdate, UserResponse, UserPaginatedResponse
|
|
|
|
|
from app.config.security import security
|
2026-01-20 17:38:01 +05:30
|
|
|
from app.services.auth.event_service import EventService
|
2026-02-02 17:33:35 +05:30
|
|
|
import logging
|
|
|
|
|
import json
|
|
|
|
|
from app.models.auth.role_module_access_model import RoleModuleAccess
|
|
|
|
|
from app.models.auth.module_access_model import ModuleAccess
|
|
|
|
|
from app.models.auth.tenant_module_model import TenantModule
|
2026-02-17 11:57:24 +05:30
|
|
|
from app.models.auth.role_model import Role
|
2026-04-17 10:34:51 +05:30
|
|
|
from app.models.auth.tenant_model import Tenant
|
2026-02-02 17:33:35 +05:30
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
2026-01-17 14:18:00 +05:30
|
|
|
|
|
|
|
|
class UserService:
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-01-20 17:38:01 +05:30
|
|
|
def create_user(db: Session, user_data: UserCreate, tenant_id: uuid.UUID = None, background_tasks: BackgroundTasks = None) -> User:
|
2026-01-17 14:18:00 +05:30
|
|
|
if db.query(User).filter(User.email == user_data.email).first():
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail="Email already registered"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not security.validate_password_strength(user_data.password):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail="Password too weak"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
user = User(
|
|
|
|
|
email=user_data.email,
|
|
|
|
|
password=security.hash_password(user_data.password),
|
|
|
|
|
first_name=user_data.first_name,
|
|
|
|
|
last_name=user_data.last_name,
|
|
|
|
|
phone_number=user_data.phone_number,
|
|
|
|
|
status=user_data.status or "active",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
role_id=user_data.role_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
db.add(user)
|
2026-02-02 17:33:35 +05:30
|
|
|
db.flush()
|
2026-01-17 14:18:00 +05:30
|
|
|
db.refresh(user)
|
2026-01-20 17:38:01 +05:30
|
|
|
|
2026-02-02 17:33:35 +05:30
|
|
|
targets = []
|
|
|
|
|
if user.role_id:
|
|
|
|
|
targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
"user_id": str(user.id),
|
|
|
|
|
"email": user.email,
|
|
|
|
|
"first_name": user.first_name,
|
|
|
|
|
"last_name": user.last_name,
|
|
|
|
|
"phone_number": user.phone_number,
|
|
|
|
|
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
|
|
|
|
"role_id": str(user.role_id) if user.role_id else None,
|
|
|
|
|
"status": user.status,
|
|
|
|
|
"targets": targets
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info(f"USER_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
|
|
|
|
|
2026-01-20 17:38:01 +05:30
|
|
|
EventService.emit_event(
|
|
|
|
|
db=db,
|
2026-02-02 17:33:35 +05:30
|
|
|
event_type="USER_PROVISION_REQUESTED",
|
|
|
|
|
payload=payload,
|
2026-01-20 17:38:01 +05:30
|
|
|
tenant_id=user.tenant_id
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-02 17:33:35 +05:30
|
|
|
db.commit()
|
2026-01-17 14:18:00 +05:30
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_all_users(db: Session, tenant_id: uuid.UUID = None):
|
|
|
|
|
query = db.query(User)
|
|
|
|
|
if tenant_id:
|
|
|
|
|
query = query.filter(User.tenant_id == tenant_id)
|
|
|
|
|
return query.all()
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_user_by_id(db: Session, user_id: uuid.UUID, tenant_id: uuid.UUID = None) -> User:
|
|
|
|
|
query = db.query(User).filter(User.id == user_id)
|
|
|
|
|
if tenant_id:
|
|
|
|
|
query = query.filter(User.tenant_id == tenant_id)
|
|
|
|
|
user = query.first()
|
|
|
|
|
if not user:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="User not found"
|
|
|
|
|
)
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-01-20 17:38:01 +05:30
|
|
|
def update_user(db: Session, user_id: uuid.UUID, user_data: UserUpdate, tenant_id: uuid.UUID = None, background_tasks: BackgroundTasks = None) -> User:
|
2026-01-17 14:18:00 +05:30
|
|
|
user = UserService.get_user_by_id(db, user_id, tenant_id)
|
2026-02-02 17:33:35 +05:30
|
|
|
|
|
|
|
|
old_role_id = user.role_id
|
|
|
|
|
old_targets = []
|
|
|
|
|
if old_role_id:
|
|
|
|
|
old_targets = UserService._resolve_targets_for_role(db, old_role_id, user.tenant_id)
|
2026-01-17 14:18:00 +05:30
|
|
|
|
|
|
|
|
update_dict = user_data.model_dump(exclude_unset=True)
|
|
|
|
|
if tenant_id:
|
|
|
|
|
update_dict.pop("tenant_id", None)
|
|
|
|
|
|
2026-02-17 11:57:24 +05:30
|
|
|
if "role_id" in update_dict and update_dict["role_id"] is not None:
|
|
|
|
|
role = db.query(Role).filter(Role.id == update_dict["role_id"]).first()
|
|
|
|
|
if not role:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail="Role not found"
|
|
|
|
|
)
|
|
|
|
|
if tenant_id and role.tenant_id is not None and role.tenant_id != tenant_id:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail="Cannot assign a role from another tenant"
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-17 14:18:00 +05:30
|
|
|
if "email" in update_dict and update_dict["email"] != user.email:
|
|
|
|
|
if db.query(User).filter(User.email == update_dict["email"]).first():
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail="Email already used"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
password = update_dict.pop("password", None)
|
|
|
|
|
if password:
|
|
|
|
|
if not security.validate_password_strength(password):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail="Password too weak"
|
|
|
|
|
)
|
|
|
|
|
user.password = security.hash_password(password)
|
|
|
|
|
user.password_updated_at = datetime.utcnow()
|
|
|
|
|
|
|
|
|
|
for key, value in update_dict.items():
|
|
|
|
|
setattr(user, key, value)
|
|
|
|
|
|
2026-01-20 17:38:01 +05:30
|
|
|
db.flush()
|
2026-01-17 14:18:00 +05:30
|
|
|
db.refresh(user)
|
2026-02-02 17:33:35 +05:30
|
|
|
|
|
|
|
|
new_targets = []
|
|
|
|
|
if user.role_id:
|
|
|
|
|
new_targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
|
2026-01-20 17:38:01 +05:30
|
|
|
|
2026-02-02 17:33:35 +05:30
|
|
|
role_changed = (old_role_id != user.role_id)
|
|
|
|
|
|
|
|
|
|
base_payload = {
|
|
|
|
|
"user_id": str(user.id),
|
|
|
|
|
"email": user.email,
|
|
|
|
|
"first_name": user.first_name,
|
|
|
|
|
"last_name": user.last_name,
|
|
|
|
|
"phone_number": user.phone_number,
|
|
|
|
|
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
|
|
|
|
"role_id": str(user.role_id) if user.role_id else None,
|
|
|
|
|
"status": user.status
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if role_changed:
|
|
|
|
|
old_mids = {t["module_id"] for t in old_targets}
|
|
|
|
|
new_mids = {t["module_id"] for t in new_targets}
|
|
|
|
|
removed_mids = old_mids - new_mids
|
2026-01-20 17:38:01 +05:30
|
|
|
|
2026-02-02 17:33:35 +05:30
|
|
|
deprovision_targets = [t for t in old_targets if t["module_id"] in removed_mids]
|
|
|
|
|
|
|
|
|
|
if deprovision_targets:
|
|
|
|
|
payload = {**base_payload, "targets": deprovision_targets}
|
|
|
|
|
logger.info(f"USER_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
|
|
|
|
EventService.emit_event(
|
|
|
|
|
db=db,
|
|
|
|
|
event_type="USER_DEPROVISION_REQUESTED",
|
|
|
|
|
payload=payload,
|
|
|
|
|
tenant_id=user.tenant_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if new_targets:
|
|
|
|
|
payload = {**base_payload, "targets": new_targets}
|
|
|
|
|
logger.info(f"USER_PROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
|
|
|
|
EventService.emit_event(
|
|
|
|
|
db=db,
|
|
|
|
|
event_type="USER_PROVISION_REQUESTED",
|
|
|
|
|
payload=payload,
|
|
|
|
|
tenant_id=user.tenant_id
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
if new_targets:
|
|
|
|
|
payload = {**base_payload, "targets": new_targets}
|
|
|
|
|
logger.info(f"USER_UPDATED Payload: {json.dumps(payload, default=str)}")
|
|
|
|
|
EventService.emit_event(
|
|
|
|
|
db=db,
|
|
|
|
|
event_type="USER_UPDATED",
|
|
|
|
|
payload=payload,
|
|
|
|
|
tenant_id=user.tenant_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
db.commit()
|
2026-01-17 14:18:00 +05:30
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def delete_user(db: Session, user_id: uuid.UUID, tenant_id: uuid.UUID = None):
|
|
|
|
|
user = UserService.get_user_by_id(db, user_id, tenant_id)
|
2026-02-02 17:33:35 +05:30
|
|
|
|
|
|
|
|
targets = []
|
|
|
|
|
if user.role_id:
|
|
|
|
|
targets = UserService._resolve_targets_for_role(db, user.role_id, user.tenant_id)
|
|
|
|
|
|
|
|
|
|
if targets:
|
|
|
|
|
payload = {
|
|
|
|
|
"user_id": str(user.id),
|
|
|
|
|
"tenant_id": str(user.tenant_id) if user.tenant_id else None,
|
|
|
|
|
"targets": targets
|
|
|
|
|
}
|
|
|
|
|
logger.info(f"USER_DEPROVISION_REQUESTED Payload: {json.dumps(payload, default=str)}")
|
|
|
|
|
EventService.emit_event(
|
|
|
|
|
db=db,
|
|
|
|
|
event_type="USER_DEPROVISION_REQUESTED",
|
|
|
|
|
payload=payload,
|
|
|
|
|
tenant_id=user.tenant_id
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-17 14:18:00 +05:30
|
|
|
db.delete(user)
|
|
|
|
|
db.commit()
|
|
|
|
|
return {"message": "User deleted successfully"}
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_users_paginated(
|
|
|
|
|
db: Session,
|
|
|
|
|
tenant_id: Optional[uuid.UUID] = None,
|
|
|
|
|
page: int = 1,
|
|
|
|
|
page_size: int = 10,
|
|
|
|
|
search: Optional[str] = None,
|
2026-04-17 10:34:51 +05:30
|
|
|
filter_names: Optional[List[str]] = None,
|
|
|
|
|
filter_emails: Optional[List[str]] = None,
|
|
|
|
|
statuses: Optional[List[str]] = None,
|
|
|
|
|
filter_tenant_ids: Optional[List[uuid.UUID]] = None,
|
|
|
|
|
filter_role_ids: Optional[List[uuid.UUID]] = None,
|
|
|
|
|
sort_by: Optional[str] = None,
|
|
|
|
|
sort_order: Optional[str] = None,
|
2026-01-17 14:18:00 +05:30
|
|
|
) -> UserPaginatedResponse:
|
2026-04-17 10:34:51 +05:30
|
|
|
query = db.query(User).outerjoin(Tenant, User.tenant_id == Tenant.id).outerjoin(Role, User.role_id == Role.id)
|
2026-01-17 14:18:00 +05:30
|
|
|
|
2026-04-17 10:34:51 +05:30
|
|
|
# Scope to tenant if not superadmin
|
2026-01-17 14:18:00 +05:30
|
|
|
if tenant_id:
|
|
|
|
|
query = query.filter(User.tenant_id == tenant_id)
|
|
|
|
|
|
|
|
|
|
if search and search.strip():
|
|
|
|
|
search_term = search.strip()
|
2026-02-17 11:57:24 +05:30
|
|
|
search_term = search_term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
2026-01-17 14:18:00 +05:30
|
|
|
query = query.filter(
|
|
|
|
|
or_(
|
|
|
|
|
User.email.ilike(f"%{search_term}%"),
|
|
|
|
|
User.first_name.ilike(f"%{search_term}%"),
|
|
|
|
|
User.last_name.ilike(f"%{search_term}%"),
|
|
|
|
|
User.phone_number.ilike(f"%{search_term}%"),
|
|
|
|
|
cast(User.id, String).ilike(f"%{search_term}%"),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-17 10:34:51 +05:30
|
|
|
if filter_names:
|
|
|
|
|
normalized_names = [name.strip() for name in filter_names if isinstance(name, str) and name.strip()]
|
|
|
|
|
if normalized_names:
|
|
|
|
|
query = query.filter(
|
|
|
|
|
func.trim(
|
|
|
|
|
func.concat(
|
|
|
|
|
User.first_name,
|
|
|
|
|
" ",
|
|
|
|
|
func.coalesce(User.last_name, ""),
|
|
|
|
|
)
|
|
|
|
|
).in_(normalized_names)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if filter_emails:
|
|
|
|
|
normalized_emails = [email.strip() for email in filter_emails if isinstance(email, str) and email.strip()]
|
|
|
|
|
if normalized_emails:
|
|
|
|
|
query = query.filter(User.email.in_(normalized_emails))
|
|
|
|
|
|
|
|
|
|
if statuses:
|
|
|
|
|
normalized_statuses = [status for status in statuses if status]
|
|
|
|
|
if normalized_statuses:
|
|
|
|
|
query = query.filter(User.status.in_(normalized_statuses))
|
|
|
|
|
|
|
|
|
|
if filter_tenant_ids:
|
|
|
|
|
tenant_ids: List[uuid.UUID] = []
|
|
|
|
|
for tenant_value in filter_tenant_ids:
|
|
|
|
|
try:
|
|
|
|
|
tenant_ids.append(
|
|
|
|
|
tenant_value if isinstance(tenant_value, uuid.UUID) else uuid.UUID(str(tenant_value))
|
|
|
|
|
)
|
|
|
|
|
except (ValueError, AttributeError, TypeError):
|
|
|
|
|
continue
|
|
|
|
|
if tenant_ids:
|
|
|
|
|
query = query.filter(User.tenant_id.in_(tenant_ids))
|
|
|
|
|
|
|
|
|
|
if filter_role_ids:
|
|
|
|
|
role_ids: List[uuid.UUID] = []
|
|
|
|
|
for role_value in filter_role_ids:
|
|
|
|
|
try:
|
|
|
|
|
role_ids.append(
|
|
|
|
|
role_value if isinstance(role_value, uuid.UUID) else uuid.UUID(str(role_value))
|
|
|
|
|
)
|
|
|
|
|
except (ValueError, AttributeError, TypeError):
|
|
|
|
|
continue
|
|
|
|
|
if role_ids:
|
|
|
|
|
query = query.filter(User.role_id.in_(role_ids))
|
|
|
|
|
|
|
|
|
|
normalized_sort_by = (sort_by or "").strip().lower()
|
|
|
|
|
normalized_sort_order = (sort_order or "asc").strip().lower()
|
|
|
|
|
sort_fn = desc if normalized_sort_order == "desc" else asc
|
|
|
|
|
|
|
|
|
|
if normalized_sort_by == "name":
|
|
|
|
|
query = query.order_by(
|
|
|
|
|
sort_fn(func.lower(User.first_name)),
|
|
|
|
|
sort_fn(func.lower(func.coalesce(User.last_name, ""))),
|
|
|
|
|
asc(User.email),
|
|
|
|
|
)
|
|
|
|
|
elif normalized_sort_by == "email":
|
|
|
|
|
query = query.order_by(sort_fn(func.lower(User.email)))
|
|
|
|
|
elif normalized_sort_by == "status":
|
|
|
|
|
query = query.order_by(sort_fn(func.lower(User.status)), asc(User.email))
|
|
|
|
|
elif normalized_sort_by == "tenant":
|
|
|
|
|
query = query.order_by(sort_fn(func.lower(func.coalesce(Tenant.tenant_name, ""))), asc(User.email))
|
|
|
|
|
elif normalized_sort_by == "role":
|
|
|
|
|
query = query.order_by(sort_fn(func.lower(func.coalesce(Role.role_name, ""))), asc(User.email))
|
|
|
|
|
else:
|
|
|
|
|
query = query.order_by(desc(User.created_at))
|
2026-01-17 14:18:00 +05:30
|
|
|
|
|
|
|
|
total = query.count()
|
|
|
|
|
|
|
|
|
|
offset = (page - 1) * page_size
|
|
|
|
|
users = query.offset(offset).limit(page_size).all()
|
|
|
|
|
|
|
|
|
|
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
|
|
|
|
|
|
|
|
|
return UserPaginatedResponse(
|
|
|
|
|
items=[UserResponse.model_validate(user) for user in users],
|
|
|
|
|
total=total,
|
|
|
|
|
page=page,
|
|
|
|
|
page_size=page_size,
|
|
|
|
|
total_pages=total_pages,
|
2026-02-02 17:33:35 +05:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _resolve_targets_for_role(db: Session, role_id: uuid.UUID, tenant_id: uuid.UUID = None) -> List[Dict[str, Any]]:
|
|
|
|
|
active_modules = (
|
|
|
|
|
db.query(ModuleAccess.module_id)
|
|
|
|
|
.join(RoleModuleAccess, RoleModuleAccess.module_access_id == ModuleAccess.id)
|
|
|
|
|
.filter(RoleModuleAccess.role_id == role_id)
|
|
|
|
|
.distinct()
|
|
|
|
|
.all()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not active_modules:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
module_ids = [m[0] for m in active_modules]
|
|
|
|
|
|
|
|
|
|
env_map = {}
|
|
|
|
|
if tenant_id:
|
|
|
|
|
tm_assignments = db.query(TenantModule).filter(
|
|
|
|
|
TenantModule.tenant_id == tenant_id,
|
|
|
|
|
TenantModule.module_id.in_(module_ids),
|
2026-04-17 10:34:51 +05:30
|
|
|
TenantModule.is_active == True
|
2026-02-02 17:33:35 +05:30
|
|
|
).all()
|
|
|
|
|
for tm in tm_assignments:
|
|
|
|
|
env_map[str(tm.module_id)] = tm.assigned_environment_slug or "prod"
|
|
|
|
|
|
|
|
|
|
targets = []
|
|
|
|
|
for mid_uuid in module_ids:
|
|
|
|
|
mid = str(mid_uuid)
|
|
|
|
|
env_slug = env_map.get(mid, "prod")
|
|
|
|
|
targets.append({
|
|
|
|
|
"module_id": mid,
|
|
|
|
|
"environment_slug": env_slug
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return targets
|