from datetime import time from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fastapi import HTTPException, status from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core.constants import ActorValue from app.modules.audit.constants import AuditRiskLevel, AuditSource from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService from app.modules.feishu_users.constants import ( FEISHU_USER_NOT_FOUND, FEISHU_USER_TARGET_TYPE, INVALID_FEISHU_TIMEZONE, INVALID_QUIET_HOURS, LAST_ACTIVE_ADMIN_ERROR, FeishuUserAuditAction, FeishuUserRole, FeishuUserStatus, ) from app.modules.feishu_users.models import FeishuUser _UPDATABLE_FIELDS = frozenset( { "role", "status", "timezone", "quiet_hours_start", "quiet_hours_end", } ) class FeishuUserManagementService: """Manage Feishu users while preserving an active administrator.""" def __init__(self, db: Session): self.db = db self.audit = AuditService(db) def list_users( self, *, role: str | None = None, status_filter: str | None = None, tenant_key: str | None = None, limit: int = 100, offset: int = 0, actor: str | None = None, ) -> tuple[list[FeishuUser], int]: filters = [] if role: filters.append(FeishuUser.role == FeishuUserRole(role)) if status_filter: filters.append(FeishuUser.status == FeishuUserStatus(status_filter)) if tenant_key: filters.append(FeishuUser.tenant_key == tenant_key) total = int( self.db.scalar( select(func.count()).select_from(FeishuUser).where(*filters) ) or 0 ) items = list( self.db.execute( select(FeishuUser) .where(*filters) .order_by(FeishuUser.created_at.desc(), FeishuUser.id.desc()) .offset(offset) .limit(limit) ).scalars() ) if actor: self._audit_read( actor=actor, action=FeishuUserAuditAction.LIST, response_payload={"count": len(items), "total": total}, ) return items, total def get_user(self, code: str, *, actor: str | None = None) -> FeishuUser: record = self._find_user(code) if actor: self._audit_read( actor=actor, action=FeishuUserAuditAction.READ, target_id=record.code, ) return record def update_user( self, code: str, *, changes: dict[str, Any], actor: str = ActorValue.API, ) -> FeishuUser: unexpected = set(changes) - _UPDATABLE_FIELDS if unexpected: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Unsupported Feishu user fields: {', '.join(sorted(unexpected))}", ) active_admin_ids = ( self._active_admin_ids() if {"role", "status"} & changes.keys() else [] ) record = self.db.execute( select(FeishuUser) .where(FeishuUser.code == code) .with_for_update() ).scalar_one_or_none() if record is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=FEISHU_USER_NOT_FOUND, ) if not changes: return record normalized = self._normalized_changes(record, changes) proposed_role = normalized.get("role", record.role) proposed_status = normalized.get("status", record.status) removes_active_admin = ( record.role == FeishuUserRole.ADMIN and record.status == FeishuUserStatus.ACTIVE and ( proposed_role != FeishuUserRole.ADMIN or proposed_status != FeishuUserStatus.ACTIVE ) ) if removes_active_admin and active_admin_ids == [record.id]: self.audit.record( AuditLogCreate( actor=actor, source=AuditSource.API, action=FeishuUserAuditAction.UPDATE_DENIED, target_type=FEISHU_USER_TARGET_TYPE, target_id=record.code, risk_level=AuditRiskLevel.HIGH, request_payload={ "role": proposed_role, "status": proposed_status, }, response_payload={ "result": "denied", "reason": "last_active_admin", }, status="denied", ) ) self.db.commit() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=LAST_ACTIVE_ADMIN_ERROR, ) before = _auditable_state(record) for field, value in normalized.items(): setattr(record, field, value) after = _auditable_state(record) self.audit.record( AuditLogCreate( actor=actor, source=AuditSource.API, action=FeishuUserAuditAction.UPDATE, target_type=FEISHU_USER_TARGET_TYPE, target_id=record.code, risk_level=AuditRiskLevel.HIGH, request_payload={"before": before, "after": after}, response_payload={"updated_fields": sorted(normalized)}, ) ) self.db.commit() self.db.refresh(record) return record def _find_user(self, code: str) -> FeishuUser: record = self.db.execute( select(FeishuUser).where(FeishuUser.code == code) ).scalar_one_or_none() if record is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=FEISHU_USER_NOT_FOUND, ) return record def _normalized_changes( self, record: FeishuUser, changes: dict[str, Any], ) -> dict[str, Any]: normalized = dict(changes) if "role" in normalized: if normalized["role"] is None: raise _unprocessable("role cannot be null") normalized["role"] = FeishuUserRole(normalized["role"]) if "status" in normalized: if normalized["status"] is None: raise _unprocessable("status cannot be null") normalized["status"] = FeishuUserStatus(normalized["status"]) if "timezone" in normalized: timezone = str(normalized["timezone"] or "").strip() if not timezone: raise _unprocessable(INVALID_FEISHU_TIMEZONE) try: ZoneInfo(timezone) except ZoneInfoNotFoundError as exc: raise _unprocessable(INVALID_FEISHU_TIMEZONE) from exc normalized["timezone"] = timezone for field in ("quiet_hours_start", "quiet_hours_end"): if field in normalized: normalized[field] = _optional_time(normalized[field]) quiet_start = normalized.get("quiet_hours_start", record.quiet_hours_start) quiet_end = normalized.get("quiet_hours_end", record.quiet_hours_end) if (quiet_start is None) != (quiet_end is None): raise _unprocessable(INVALID_QUIET_HOURS) return normalized def _active_admin_ids(self) -> list[int]: return list( self.db.execute( select(FeishuUser.id) .where( FeishuUser.role == FeishuUserRole.ADMIN, FeishuUser.status == FeishuUserStatus.ACTIVE, ) .order_by(FeishuUser.id.asc()) .with_for_update() ).scalars() ) def _audit_read( self, *, actor: str, action: str, target_id: str | None = None, response_payload: dict[str, Any] | None = None, ) -> None: self.audit.record( AuditLogCreate( actor=actor, source=AuditSource.API, action=action, target_type=FEISHU_USER_TARGET_TYPE, target_id=target_id, risk_level=AuditRiskLevel.LOW, response_payload=response_payload, ) ) self.db.commit() def _auditable_state(record: FeishuUser) -> dict[str, Any]: return { "role": record.role, "status": record.status, "timezone": record.timezone, "quiet_hours_start": ( record.quiet_hours_start.isoformat() if record.quiet_hours_start is not None else None ), "quiet_hours_end": ( record.quiet_hours_end.isoformat() if record.quiet_hours_end is not None else None ), } def _optional_time(value: Any) -> time | None: if value is None or isinstance(value, time): return value try: return time.fromisoformat(str(value)) except ValueError as exc: raise _unprocessable("Invalid quiet-hours time") from exc def _unprocessable(detail: str) -> HTTPException: return HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail, )