- Added reusable validation functions (`validate_password_strength`, `validate_phone_number`, etc.) to centralize schema validation in `validators.py`. - Updated `schemas/users.py` to use shared validators, simplifying and unifying validation logic. - Introduced new error codes (`AUTH_007`, `SYS_005`) for enhanced error specificity. - Refactored exception handling in admin routes to use more appropriate error types (`AuthorizationError`, `DuplicateError`). - Improved organization query performance by replacing N+1 queries with optimized methods for member counts and data aggregation. - Strengthened security in JWT decoding to prevent algorithm confusion attacks, with strict validation of required claims and algorithm enforcement.
519 lines
18 KiB
Python
Executable File
519 lines
18 KiB
Python
Executable File
# app/crud/organization_async.py
|
|
"""Async CRUD operations for Organization model using SQLAlchemy 2.0 patterns."""
|
|
from typing import Optional, List, Dict, Any
|
|
from uuid import UUID
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy import func, or_, and_, select
|
|
|
|
from app.crud.base_async import CRUDBaseAsync
|
|
from app.models.organization import Organization
|
|
from app.models.user_organization import UserOrganization, OrganizationRole
|
|
from app.models.user import User
|
|
from app.schemas.organizations import (
|
|
OrganizationCreate,
|
|
OrganizationUpdate,
|
|
)
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CRUDOrganizationAsync(CRUDBaseAsync[Organization, OrganizationCreate, OrganizationUpdate]):
|
|
"""Async CRUD operations for Organization model."""
|
|
|
|
async def get_by_slug(self, db: AsyncSession, *, slug: str) -> Optional[Organization]:
|
|
"""Get organization by slug."""
|
|
try:
|
|
result = await db.execute(
|
|
select(Organization).where(Organization.slug == slug)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
except Exception as e:
|
|
logger.error(f"Error getting organization by slug {slug}: {str(e)}")
|
|
raise
|
|
|
|
async def create(self, db: AsyncSession, *, obj_in: OrganizationCreate) -> Organization:
|
|
"""Create a new organization with error handling."""
|
|
try:
|
|
db_obj = Organization(
|
|
name=obj_in.name,
|
|
slug=obj_in.slug,
|
|
description=obj_in.description,
|
|
is_active=obj_in.is_active,
|
|
settings=obj_in.settings or {}
|
|
)
|
|
db.add(db_obj)
|
|
await db.commit()
|
|
await db.refresh(db_obj)
|
|
return db_obj
|
|
except IntegrityError as e:
|
|
await db.rollback()
|
|
error_msg = str(e.orig) if hasattr(e, 'orig') else str(e)
|
|
if "slug" in error_msg.lower():
|
|
logger.warning(f"Duplicate slug attempted: {obj_in.slug}")
|
|
raise ValueError(f"Organization with slug '{obj_in.slug}' already exists")
|
|
logger.error(f"Integrity error creating organization: {error_msg}")
|
|
raise ValueError(f"Database integrity error: {error_msg}")
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Unexpected error creating organization: {str(e)}", exc_info=True)
|
|
raise
|
|
|
|
async def get_multi_with_filters(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
is_active: Optional[bool] = None,
|
|
search: Optional[str] = None,
|
|
sort_by: str = "created_at",
|
|
sort_order: str = "desc"
|
|
) -> tuple[List[Organization], int]:
|
|
"""
|
|
Get multiple organizations with filtering, searching, and sorting.
|
|
|
|
Returns:
|
|
Tuple of (organizations list, total count)
|
|
"""
|
|
try:
|
|
query = select(Organization)
|
|
|
|
# Apply filters
|
|
if is_active is not None:
|
|
query = query.where(Organization.is_active == is_active)
|
|
|
|
if search:
|
|
search_filter = or_(
|
|
Organization.name.ilike(f"%{search}%"),
|
|
Organization.slug.ilike(f"%{search}%"),
|
|
Organization.description.ilike(f"%{search}%")
|
|
)
|
|
query = query.where(search_filter)
|
|
|
|
# Get total count before pagination
|
|
count_query = select(func.count()).select_from(query.alias())
|
|
count_result = await db.execute(count_query)
|
|
total = count_result.scalar_one()
|
|
|
|
# Apply sorting
|
|
sort_column = getattr(Organization, sort_by, Organization.created_at)
|
|
if sort_order == "desc":
|
|
query = query.order_by(sort_column.desc())
|
|
else:
|
|
query = query.order_by(sort_column.asc())
|
|
|
|
# Apply pagination
|
|
query = query.offset(skip).limit(limit)
|
|
result = await db.execute(query)
|
|
organizations = list(result.scalars().all())
|
|
|
|
return organizations, total
|
|
except Exception as e:
|
|
logger.error(f"Error getting organizations with filters: {str(e)}")
|
|
raise
|
|
|
|
async def get_member_count(self, db: AsyncSession, *, organization_id: UUID) -> int:
|
|
"""Get the count of active members in an organization."""
|
|
try:
|
|
result = await db.execute(
|
|
select(func.count(UserOrganization.user_id)).where(
|
|
and_(
|
|
UserOrganization.organization_id == organization_id,
|
|
UserOrganization.is_active == True
|
|
)
|
|
)
|
|
)
|
|
return result.scalar_one() or 0
|
|
except Exception as e:
|
|
logger.error(f"Error getting member count for organization {organization_id}: {str(e)}")
|
|
raise
|
|
|
|
async def get_multi_with_member_counts(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
is_active: Optional[bool] = None,
|
|
search: Optional[str] = None
|
|
) -> tuple[List[Dict[str, Any]], int]:
|
|
"""
|
|
Get organizations with member counts in a SINGLE QUERY using JOIN and GROUP BY.
|
|
This eliminates the N+1 query problem.
|
|
|
|
Returns:
|
|
Tuple of (list of dicts with org and member_count, total count)
|
|
"""
|
|
try:
|
|
# Build base query with LEFT JOIN and GROUP BY
|
|
query = (
|
|
select(
|
|
Organization,
|
|
func.count(
|
|
func.distinct(
|
|
and_(
|
|
UserOrganization.is_active == True,
|
|
UserOrganization.user_id
|
|
).self_group()
|
|
)
|
|
).label('member_count')
|
|
)
|
|
.outerjoin(UserOrganization, Organization.id == UserOrganization.organization_id)
|
|
.group_by(Organization.id)
|
|
)
|
|
|
|
# Apply filters
|
|
if is_active is not None:
|
|
query = query.where(Organization.is_active == is_active)
|
|
|
|
if search:
|
|
search_filter = or_(
|
|
Organization.name.ilike(f"%{search}%"),
|
|
Organization.slug.ilike(f"%{search}%"),
|
|
Organization.description.ilike(f"%{search}%")
|
|
)
|
|
query = query.where(search_filter)
|
|
|
|
# Get total count
|
|
count_query = select(func.count(Organization.id))
|
|
if is_active is not None:
|
|
count_query = count_query.where(Organization.is_active == is_active)
|
|
if search:
|
|
count_query = count_query.where(search_filter)
|
|
|
|
count_result = await db.execute(count_query)
|
|
total = count_result.scalar_one()
|
|
|
|
# Apply pagination and ordering
|
|
query = query.order_by(Organization.created_at.desc()).offset(skip).limit(limit)
|
|
|
|
result = await db.execute(query)
|
|
rows = result.all()
|
|
|
|
# Convert to list of dicts
|
|
orgs_with_counts = [
|
|
{
|
|
'organization': org,
|
|
'member_count': member_count
|
|
}
|
|
for org, member_count in rows
|
|
]
|
|
|
|
return orgs_with_counts, total
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting organizations with member counts: {str(e)}", exc_info=True)
|
|
raise
|
|
|
|
async def add_user(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
organization_id: UUID,
|
|
user_id: UUID,
|
|
role: OrganizationRole = OrganizationRole.MEMBER,
|
|
custom_permissions: Optional[str] = None
|
|
) -> UserOrganization:
|
|
"""Add a user to an organization with a specific role."""
|
|
try:
|
|
# Check if relationship already exists
|
|
result = await db.execute(
|
|
select(UserOrganization).where(
|
|
and_(
|
|
UserOrganization.user_id == user_id,
|
|
UserOrganization.organization_id == organization_id
|
|
)
|
|
)
|
|
)
|
|
existing = result.scalar_one_or_none()
|
|
|
|
if existing:
|
|
# Reactivate if inactive, or raise error if already active
|
|
if not existing.is_active:
|
|
existing.is_active = True
|
|
existing.role = role
|
|
existing.custom_permissions = custom_permissions
|
|
await db.commit()
|
|
await db.refresh(existing)
|
|
return existing
|
|
else:
|
|
raise ValueError("User is already a member of this organization")
|
|
|
|
# Create new relationship
|
|
user_org = UserOrganization(
|
|
user_id=user_id,
|
|
organization_id=organization_id,
|
|
role=role,
|
|
is_active=True,
|
|
custom_permissions=custom_permissions
|
|
)
|
|
db.add(user_org)
|
|
await db.commit()
|
|
await db.refresh(user_org)
|
|
return user_org
|
|
except IntegrityError as e:
|
|
await db.rollback()
|
|
logger.error(f"Integrity error adding user to organization: {str(e)}")
|
|
raise ValueError("Failed to add user to organization")
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Error adding user to organization: {str(e)}", exc_info=True)
|
|
raise
|
|
|
|
async def remove_user(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
organization_id: UUID,
|
|
user_id: UUID
|
|
) -> bool:
|
|
"""Remove a user from an organization (soft delete)."""
|
|
try:
|
|
result = await db.execute(
|
|
select(UserOrganization).where(
|
|
and_(
|
|
UserOrganization.user_id == user_id,
|
|
UserOrganization.organization_id == organization_id
|
|
)
|
|
)
|
|
)
|
|
user_org = result.scalar_one_or_none()
|
|
|
|
if not user_org:
|
|
return False
|
|
|
|
user_org.is_active = False
|
|
await db.commit()
|
|
return True
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Error removing user from organization: {str(e)}", exc_info=True)
|
|
raise
|
|
|
|
async def update_user_role(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
organization_id: UUID,
|
|
user_id: UUID,
|
|
role: OrganizationRole,
|
|
custom_permissions: Optional[str] = None
|
|
) -> Optional[UserOrganization]:
|
|
"""Update a user's role in an organization."""
|
|
try:
|
|
result = await db.execute(
|
|
select(UserOrganization).where(
|
|
and_(
|
|
UserOrganization.user_id == user_id,
|
|
UserOrganization.organization_id == organization_id
|
|
)
|
|
)
|
|
)
|
|
user_org = result.scalar_one_or_none()
|
|
|
|
if not user_org:
|
|
return None
|
|
|
|
user_org.role = role
|
|
if custom_permissions is not None:
|
|
user_org.custom_permissions = custom_permissions
|
|
await db.commit()
|
|
await db.refresh(user_org)
|
|
return user_org
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Error updating user role: {str(e)}", exc_info=True)
|
|
raise
|
|
|
|
async def get_organization_members(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
organization_id: UUID,
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
is_active: bool = True
|
|
) -> tuple[List[Dict[str, Any]], int]:
|
|
"""
|
|
Get members of an organization with user details.
|
|
|
|
Returns:
|
|
Tuple of (members list with user details, total count)
|
|
"""
|
|
try:
|
|
# Build query with join
|
|
query = (
|
|
select(UserOrganization, User)
|
|
.join(User, UserOrganization.user_id == User.id)
|
|
.where(UserOrganization.organization_id == organization_id)
|
|
)
|
|
|
|
if is_active is not None:
|
|
query = query.where(UserOrganization.is_active == is_active)
|
|
|
|
# Get total count
|
|
count_query = select(func.count()).select_from(
|
|
select(UserOrganization)
|
|
.where(UserOrganization.organization_id == organization_id)
|
|
.where(UserOrganization.is_active == is_active if is_active is not None else True)
|
|
.alias()
|
|
)
|
|
count_result = await db.execute(count_query)
|
|
total = count_result.scalar_one()
|
|
|
|
# Apply ordering and pagination
|
|
query = query.order_by(UserOrganization.created_at.desc()).offset(skip).limit(limit)
|
|
result = await db.execute(query)
|
|
results = result.all()
|
|
|
|
members = []
|
|
for user_org, user in results:
|
|
members.append({
|
|
"user_id": user.id,
|
|
"email": user.email,
|
|
"first_name": user.first_name,
|
|
"last_name": user.last_name,
|
|
"role": user_org.role,
|
|
"is_active": user_org.is_active,
|
|
"joined_at": user_org.created_at
|
|
})
|
|
|
|
return members, total
|
|
except Exception as e:
|
|
logger.error(f"Error getting organization members: {str(e)}")
|
|
raise
|
|
|
|
async def get_user_organizations(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
user_id: UUID,
|
|
is_active: bool = True
|
|
) -> List[Organization]:
|
|
"""Get all organizations a user belongs to."""
|
|
try:
|
|
query = (
|
|
select(Organization)
|
|
.join(UserOrganization, Organization.id == UserOrganization.organization_id)
|
|
.where(UserOrganization.user_id == user_id)
|
|
)
|
|
|
|
if is_active is not None:
|
|
query = query.where(UserOrganization.is_active == is_active)
|
|
|
|
result = await db.execute(query)
|
|
return list(result.scalars().all())
|
|
except Exception as e:
|
|
logger.error(f"Error getting user organizations: {str(e)}")
|
|
raise
|
|
|
|
async def get_user_organizations_with_details(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
user_id: UUID,
|
|
is_active: bool = True
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get user's organizations with role and member count in SINGLE QUERY.
|
|
Eliminates N+1 problem by using subquery for member counts.
|
|
|
|
Returns:
|
|
List of dicts with organization, role, and member_count
|
|
"""
|
|
try:
|
|
# Subquery to get member counts for each organization
|
|
member_count_subq = (
|
|
select(
|
|
UserOrganization.organization_id,
|
|
func.count(UserOrganization.user_id).label('member_count')
|
|
)
|
|
.where(UserOrganization.is_active == True)
|
|
.group_by(UserOrganization.organization_id)
|
|
.subquery()
|
|
)
|
|
|
|
# Main query with JOIN to get org, role, and member count
|
|
query = (
|
|
select(
|
|
Organization,
|
|
UserOrganization.role,
|
|
func.coalesce(member_count_subq.c.member_count, 0).label('member_count')
|
|
)
|
|
.join(UserOrganization, Organization.id == UserOrganization.organization_id)
|
|
.outerjoin(member_count_subq, Organization.id == member_count_subq.c.organization_id)
|
|
.where(UserOrganization.user_id == user_id)
|
|
)
|
|
|
|
if is_active is not None:
|
|
query = query.where(UserOrganization.is_active == is_active)
|
|
|
|
result = await db.execute(query)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
'organization': org,
|
|
'role': role,
|
|
'member_count': member_count
|
|
}
|
|
for org, role, member_count in rows
|
|
]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting user organizations with details: {str(e)}", exc_info=True)
|
|
raise
|
|
|
|
async def get_user_role_in_org(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
user_id: UUID,
|
|
organization_id: UUID
|
|
) -> Optional[OrganizationRole]:
|
|
"""Get a user's role in a specific organization."""
|
|
try:
|
|
result = await db.execute(
|
|
select(UserOrganization).where(
|
|
and_(
|
|
UserOrganization.user_id == user_id,
|
|
UserOrganization.organization_id == organization_id,
|
|
UserOrganization.is_active == True
|
|
)
|
|
)
|
|
)
|
|
user_org = result.scalar_one_or_none()
|
|
|
|
return user_org.role if user_org else None
|
|
except Exception as e:
|
|
logger.error(f"Error getting user role in org: {str(e)}")
|
|
raise
|
|
|
|
async def is_user_org_owner(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
user_id: UUID,
|
|
organization_id: UUID
|
|
) -> bool:
|
|
"""Check if a user is an owner of an organization."""
|
|
role = await self.get_user_role_in_org(db, user_id=user_id, organization_id=organization_id)
|
|
return role == OrganizationRole.OWNER
|
|
|
|
async def is_user_org_admin(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
user_id: UUID,
|
|
organization_id: UUID
|
|
) -> bool:
|
|
"""Check if a user is an owner or admin of an organization."""
|
|
role = await self.get_user_role_in_org(db, user_id=user_id, organization_id=organization_id)
|
|
return role in [OrganizationRole.OWNER, OrganizationRole.ADMIN]
|
|
|
|
|
|
# Create a singleton instance for use across the application
|
|
organization_async = CRUDOrganizationAsync(Organization)
|