from datetime import datetime, timezone from sqlalchemy import select, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from staff.orm import Staff from auth.utils import hash_password from auth.models import default_permissions_for_role from shared.audit import log_action, diff from shared.exceptions import NotFoundError, AuthorizationError import uuid VALID_ROLES = ("sysadmin", "admin", "editor", "user") def _now() -> datetime: return datetime.now(timezone.utc) def _to_dict(staff: Staff) -> dict: return { "id": staff.id, "email": staff.email, "name": staff.name, "role": staff.role, "is_active": staff.is_active, "permissions": staff.permissions, } def _to_response(staff: Staff) -> dict: return _to_dict(staff) async def list_staff(db: AsyncSession, search: str = None, role_filter: str = None) -> dict: stmt = select(Staff) if role_filter: stmt = stmt.where(Staff.role == role_filter) if search: s = f"%{search.lower()}%" stmt = stmt.where( or_( func.lower(Staff.name).like(s), func.lower(Staff.email).like(s), ) ) stmt = stmt.order_by(Staff.name) result = await db.execute(stmt) rows = result.scalars().all() return {"staff": [_to_response(r) for r in rows], "total": len(rows)} async def get_staff(db: AsyncSession, staff_id: str) -> dict: result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) staff = result.scalar_one_or_none() if staff is None: raise NotFoundError("Staff member not found") return _to_response(staff) async def get_staff_me(db: AsyncSession, user_sub: str) -> dict: return await get_staff(db, user_sub) async def create_staff( db: AsyncSession, data: dict, current_user_role: str, actor_id: str, actor_name: str, ) -> dict: role = data.get("role", "user") if role not in VALID_ROLES: raise AuthorizationError(f"Invalid role: {role}") if current_user_role == "admin" and role == "sysadmin": raise AuthorizationError("Admin cannot create sysadmin accounts") existing = await db.execute( select(Staff).where(Staff.email == data["email"]).limit(1) ) if existing.scalar_one_or_none() is not None: raise AuthorizationError("A staff member with this email already exists") permissions = data.get("permissions") if permissions is None and role in ("editor", "user"): permissions = default_permissions_for_role(role) uid = str(uuid.uuid4()) now = _now() staff = Staff( id=uid, firestore_id=None, email=data["email"], name=data["name"], role=role, hashed_password=hash_password(data["password"]), is_active=True, permissions=permissions, ui_prefs={}, created_at=now, updated_at=now, ) db.add(staff) await db.flush() await log_action( db, actor_id=actor_id, actor_name=actor_name, action="CREATE", entity_type="staff", entity_id=uid, entity_label=data["email"], meta={"role": role}, ) await db.commit() await db.refresh(staff) return _to_response(staff) async def update_staff( db: AsyncSession, staff_id: str, data: dict, current_user_role: str, current_user_id: str, actor_id: str, actor_name: str, ) -> dict: result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) staff = result.scalar_one_or_none() if staff is None: raise NotFoundError("Staff member not found") if current_user_role == "admin" and staff.role == "sysadmin": raise AuthorizationError("Admin cannot modify sysadmin accounts") if current_user_role == "admin" and data.get("role") == "sysadmin": raise AuthorizationError("Admin cannot promote to sysadmin") old = _to_dict(staff) if data.get("email") is not None: dup = await db.execute( select(Staff).where(Staff.email == data["email"], Staff.id != staff_id).limit(1) ) if dup.scalar_one_or_none() is not None: raise AuthorizationError("A staff member with this email already exists") staff.email = data["email"] if data.get("name") is not None: staff.name = data["name"] if data.get("role") is not None: if data["role"] not in VALID_ROLES: raise AuthorizationError(f"Invalid role: {data['role']}") staff.role = data["role"] if data.get("is_active") is not None: staff.is_active = data["is_active"] if "permissions" in data: staff.permissions = data["permissions"] staff.updated_at = _now() await db.flush() changes = diff(old, _to_dict(staff)) action = "PERMISSION_CHANGE" if "permissions" in data and len(changes) == 1 else "UPDATE" await log_action( db, actor_id=actor_id, actor_name=actor_name, action=action, entity_type="staff", entity_id=staff_id, entity_label=staff.email, changes=changes or None, ) await db.commit() await db.refresh(staff) return _to_response(staff) async def update_staff_password( db: AsyncSession, staff_id: str, new_password: str, current_user_role: str, actor_id: str, actor_name: str, ) -> dict: result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) staff = result.scalar_one_or_none() if staff is None: raise NotFoundError("Staff member not found") if current_user_role == "admin" and staff.role == "sysadmin": raise AuthorizationError("Admin cannot modify sysadmin accounts") staff.hashed_password = hash_password(new_password) staff.updated_at = _now() await db.flush() await log_action( db, actor_id=actor_id, actor_name=actor_name, action="UPDATE", entity_type="staff", entity_id=staff_id, entity_label=staff.email, meta={"detail": "password changed"}, ) await db.commit() return {"message": "Password updated successfully"} async def get_preferences(db: AsyncSession, staff_id: str) -> dict: result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) staff = result.scalar_one_or_none() if staff is None: raise NotFoundError("Staff member not found") return staff.ui_prefs or {} async def update_preferences(db: AsyncSession, staff_id: str, page_key: str, prefs: dict) -> dict: result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) staff = result.scalar_one_or_none() if staff is None: raise NotFoundError("Staff member not found") current = dict(staff.ui_prefs or {}) current[page_key] = {**current.get(page_key, {}), **prefs} staff.ui_prefs = current staff.updated_at = _now() await db.commit() return current async def delete_staff( db: AsyncSession, staff_id: str, current_user_role: str, current_user_id: str, actor_id: str, actor_name: str, ) -> dict: if staff_id == current_user_id: raise AuthorizationError("Cannot delete your own account") result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1)) staff = result.scalar_one_or_none() if staff is None: raise NotFoundError("Staff member not found") if current_user_role == "admin" and staff.role == "sysadmin": raise AuthorizationError("Admin cannot delete sysadmin accounts") label = staff.email await log_action( db, actor_id=actor_id, actor_name=actor_name, action="DELETE", entity_type="staff", entity_id=staff_id, entity_label=label, ) await db.delete(staff) await db.commit() return {"message": "Staff member deleted"}