2026-06-04 00:06:27 +00:00
|
|
|
"""User service: read, update, soft-delete, list."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-06-10 21:24:24 +00:00
|
|
|
from datetime import UTC, datetime
|
2026-06-04 00:06:27 +00:00
|
|
|
|
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.core.security import hash_password
|
|
|
|
|
from app.models.user import User, UserRole
|
|
|
|
|
from app.schemas.user import UserCreateRequest, UserUpdate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserNotFound(Exception):
|
|
|
|
|
"""Raised when a user lookup fails."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EmailAlreadyTaken(Exception):
|
|
|
|
|
"""Raised when attempting to create/update a user with an existing email."""
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 21:24:24 +00:00
|
|
|
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
|
2026-06-04 00:06:27 +00:00
|
|
|
"""Fetch a user by ID (active only, soft-deleted excluded)."""
|
2026-06-10 21:24:24 +00:00
|
|
|
result = await db.execute(select(User).where(User.id == user_id, User.deleted_at.is_(None)))
|
2026-06-04 00:06:27 +00:00
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 21:24:24 +00:00
|
|
|
async def get_user_by_email(db: AsyncSession, email: str, org_id: int | None = None) -> User | None:
|
2026-06-04 00:06:27 +00:00
|
|
|
"""Fetch a user by email, optionally scoped to an org."""
|
|
|
|
|
stmt = select(User).where(
|
|
|
|
|
User.email == email.lower(),
|
|
|
|
|
User.deleted_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
if org_id is not None:
|
|
|
|
|
stmt = stmt.where(User.org_id == org_id)
|
|
|
|
|
result = await db.execute(stmt)
|
|
|
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def update_user_profile(
|
|
|
|
|
db: AsyncSession, user: User, payload: UserUpdate, *, is_admin: bool = False
|
|
|
|
|
) -> User:
|
|
|
|
|
"""Apply partial updates to a user.
|
|
|
|
|
|
|
|
|
|
Non-admin callers cannot change the role. All other fields are optional.
|
|
|
|
|
"""
|
|
|
|
|
data = payload.model_dump(exclude_unset=True)
|
|
|
|
|
|
|
|
|
|
if "role" in data and not is_admin:
|
|
|
|
|
# Silently drop role change for non-admin callers
|
|
|
|
|
data.pop("role")
|
|
|
|
|
|
|
|
|
|
for field, value in data.items():
|
|
|
|
|
if value is not None:
|
|
|
|
|
setattr(user, field, value)
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
await db.refresh(user)
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def soft_delete_user(db: AsyncSession, user: User) -> User:
|
|
|
|
|
"""Soft-delete a user by setting deleted_at to now."""
|
|
|
|
|
user.deleted_at = datetime.now(UTC)
|
|
|
|
|
await db.commit()
|
|
|
|
|
await db.refresh(user)
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 21:24:24 +00:00
|
|
|
async def create_user_as_admin(db: AsyncSession, payload: UserCreateRequest, org_id: int) -> User:
|
2026-06-04 00:06:27 +00:00
|
|
|
"""Create a new user in the given org (admin-only flow)."""
|
|
|
|
|
existing = await get_user_by_email(db, payload.email, org_id=org_id)
|
|
|
|
|
if existing is not None:
|
2026-06-10 21:24:24 +00:00
|
|
|
raise EmailAlreadyTaken(f"A user with email {payload.email!r} already exists in this org.")
|
2026-06-04 00:06:27 +00:00
|
|
|
|
|
|
|
|
user = User(
|
|
|
|
|
org_id=org_id,
|
|
|
|
|
email=payload.email.lower(),
|
|
|
|
|
password_hash=hash_password(payload.password),
|
|
|
|
|
name=payload.name,
|
|
|
|
|
role=payload.role if payload.role else UserRole.sales_rep,
|
|
|
|
|
)
|
|
|
|
|
db.add(user)
|
|
|
|
|
await db.commit()
|
|
|
|
|
await db.refresh(user)
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
2026-06-10 21:24:24 +00:00
|
|
|
async def list_users(db: AsyncSession, org_id: int, skip: int = 0, limit: int = 50) -> list[User]:
|
2026-06-04 00:06:27 +00:00
|
|
|
"""List active users in an org, paginated."""
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(User)
|
|
|
|
|
.where(User.org_id == org_id, User.deleted_at.is_(None))
|
|
|
|
|
.order_by(User.id)
|
|
|
|
|
.offset(skip)
|
|
|
|
|
.limit(limit)
|
|
|
|
|
)
|
|
|
|
|
return list(result.scalars().all())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def count_users_in_org(db: AsyncSession, org_id: int) -> int:
|
|
|
|
|
"""Count active users in an org."""
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(User)
|
|
|
|
|
.where(User.org_id == org_id, User.deleted_at.is_(None))
|
|
|
|
|
)
|
|
|
|
|
return int(result.scalar_one())
|