test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
1 parent
8e3a4d95da
commit
164f5f9615
97 files changed
+37281
No files matched your search
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.core.socketio import sio
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.logger import get_logger
|
||||
from app.models.user import User
|
||||
from app.models.session_to_agent import SessionStatus
|
||||
from app.core.auth import CHAT_MANAGE_PERMISSIONS, get_current_user, has_any_permission
|
||||
from app.database import get_db
|
||||
from app.services.chat_notifications import notify_chat_assigned
|
||||
from app.services.human_routing import notify_customer, route_session_to_human
|
||||
from app.models.schemas.chat import ChatDetailResponse, TransferReasonType
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Shown to the customer when a human takes over an external-channel chat. The
|
||||
# widget surfaces the handover in its own UI, so only channels need a message.
|
||||
HANDOVER_NOTICE = "You're now connected with a member of our team."
|
||||
|
||||
|
||||
def _is_uuid(value: str) -> bool:
|
||||
"""Guard the repository lookup: a malformed id is a 404, not a 500."""
|
||||
try:
|
||||
UUID(str(value))
|
||||
return True
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
async def _notify_customer_of_handover(db: Session, session, user: User) -> None:
|
||||
"""Tell a channel customer a human joined, and record it in the thread."""
|
||||
await notify_customer(db, session, HANDOVER_NOTICE, user_id=str(user.id))
|
||||
|
||||
|
||||
async def _load_manageable_session(db: Session, session_id: str, current_user: User):
|
||||
"""The session, if this user may act on it — or the right HTTPException.
|
||||
|
||||
Shared by every action that changes who handles a chat, so they cannot
|
||||
drift apart: the permission grant, the organization scope and the
|
||||
visibility check are one sequence, checked in one place.
|
||||
"""
|
||||
# manage_all_chats, not "manage_chats" — the latter is not a real
|
||||
# permission and never matched. has_any_permission also honours the
|
||||
# super_admin bypass the previous hand-rolled set check ignored.
|
||||
if not has_any_permission(current_user, CHAT_MANAGE_PERMISSIONS):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
|
||||
session = SessionToAgentRepository(db).get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Chat session not found")
|
||||
|
||||
# A session id is guessable and was never scoped here: without this an
|
||||
# agent could act on a conversation belonging to another organization.
|
||||
if session.organization_id != current_user.organization_id:
|
||||
raise HTTPException(status_code=404, detail="Chat session not found")
|
||||
|
||||
# Acting is limited to what the caller may actually see, so widening the
|
||||
# inbox to the unclaimed queue can't be used to reach past it.
|
||||
user_permissions = {p.name for p in current_user.role.permissions}
|
||||
is_super_admin = "super_admin" in user_permissions
|
||||
# manage_all_chats implies seeing them all — a role that may manage every
|
||||
# chat should not need a separate view grant to act on one.
|
||||
can_view_all = is_super_admin or bool(
|
||||
{"view_all_chats", "manage_all_chats"} & user_permissions
|
||||
)
|
||||
|
||||
if not can_view_all:
|
||||
can_view_assigned = is_super_admin or "view_assigned_chats" in user_permissions
|
||||
has_access = await ChatRepository(db).check_session_access(
|
||||
session_id=session_id,
|
||||
user_id=current_user.id if can_view_assigned else None,
|
||||
user_groups=[str(group.id) for group in current_user.groups] if can_view_assigned else [],
|
||||
include_unassigned=is_super_admin or "view_unassigned_chats" in user_permissions
|
||||
)
|
||||
if not has_access:
|
||||
raise HTTPException(status_code=404, detail="Chat session not found")
|
||||
|
||||
return session
|
||||
|
||||
|
||||
@router.post("/{session_id}/takeover", response_model=ChatDetailResponse)
|
||||
async def takeover_chat(
|
||||
session_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Take over a chat session"""
|
||||
try:
|
||||
session = await _load_manageable_session(db, session_id, current_user)
|
||||
session_repo = SessionToAgentRepository(db)
|
||||
chat_repo = ChatRepository(db)
|
||||
|
||||
# Update session
|
||||
success = session_repo.takeover_session(
|
||||
session_id=session_id,
|
||||
user_id=str(current_user.id)
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Failed to take over chat"
|
||||
)
|
||||
|
||||
# Let the customer know a human joined (external channels only), before
|
||||
# the detail is read back so the notice is part of the returned thread.
|
||||
await _notify_customer_of_handover(db, session, current_user)
|
||||
|
||||
# Get updated chat details
|
||||
chat = await chat_repo.get_chat_detail(
|
||||
session_id=session_id,
|
||||
org_id=current_user.organization_id
|
||||
)
|
||||
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to get chat details after takeover"
|
||||
)
|
||||
|
||||
return ChatDetailResponse(**chat)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error taking over chat: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to take over chat"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{session_id}/route-to-human", response_model=ChatDetailResponse)
|
||||
async def route_chat_to_human(
|
||||
session_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Stop the AI answering this chat and queue it for the team.
|
||||
|
||||
Taking a chat over was the only way to silence the AI, which forces whoever
|
||||
spots the problem to handle it personally. This hands it to the queue
|
||||
instead, leaving it claimable by anyone.
|
||||
"""
|
||||
try:
|
||||
session = await _load_manageable_session(db, session_id, current_user)
|
||||
|
||||
if session.status == SessionStatus.CLOSED:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="This chat is closed")
|
||||
if session.user_id is not None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="A human is already handling this chat")
|
||||
|
||||
routed = await route_session_to_human(
|
||||
db,
|
||||
session,
|
||||
reason=TransferReasonType.DIRECT_REQUEST.value,
|
||||
description=f"Routed to the team by {current_user.full_name}",
|
||||
)
|
||||
if not routed:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="This chat is already waiting for a human")
|
||||
|
||||
chat = await ChatRepository(db).get_chat_detail(
|
||||
session_id=session_id,
|
||||
org_id=current_user.organization_id
|
||||
)
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to get chat details after routing"
|
||||
)
|
||||
|
||||
return ChatDetailResponse(**chat)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error routing chat to human: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to route chat to a human"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{session_id}/reassign", response_model=ChatDetailResponse)
|
||||
async def reassign_chat(
|
||||
session_id: str,
|
||||
to_user_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Reassign a chat session to a different user"""
|
||||
try:
|
||||
session = await _load_manageable_session(db, session_id, current_user)
|
||||
session_repo = SessionToAgentRepository(db)
|
||||
|
||||
# The new owner must be a real, active member of the same org —
|
||||
# otherwise a chat (and its assignment notification) could be pushed
|
||||
# onto someone outside it. A 404 here doesn't confirm whether an id
|
||||
# exists elsewhere.
|
||||
target_user = UserRepository(db).get_user(to_user_id) if _is_uuid(to_user_id) else None
|
||||
if (target_user is None
|
||||
or not target_user.is_active
|
||||
or target_user.organization_id != current_user.organization_id):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Only allow reassignment for open sessions handled by a user (not AI)
|
||||
if str(session.status.name).lower() != 'open':
|
||||
raise HTTPException(status_code=400, detail="Only open sessions can be reassigned")
|
||||
if session.user_id is None:
|
||||
raise HTTPException(status_code=400, detail="Chat must be handled by a user to reassign")
|
||||
|
||||
# Capture the previous assignee BEFORE reassigning. reassign_session
|
||||
# re-fetches this same row, so within one DB session the identity map
|
||||
# hands back the very object `session` points at — it mutates
|
||||
# session.user_id to the new owner in place. Reading it afterward would
|
||||
# target the new assignee's room, not the previous one's.
|
||||
previous_user_id = session.user_id
|
||||
|
||||
# Update session owner
|
||||
success = session_repo.reassign_session(session_id=session_id, to_user_id=to_user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to reassign chat")
|
||||
|
||||
# Fetch updated chat detail
|
||||
chat_repo = ChatRepository(db)
|
||||
chat = await chat_repo.get_chat_detail(
|
||||
session_id=session_id,
|
||||
org_id=current_user.organization_id
|
||||
)
|
||||
if not chat:
|
||||
raise HTTPException(status_code=500, detail="Failed to get chat details after reassignment")
|
||||
|
||||
# Notify widget room (customer)
|
||||
await sio.emit('room_event', {
|
||||
'type': 'reassigned',
|
||||
'session_id': session_id,
|
||||
'message': 'Your conversation has been reassigned to another agent.'
|
||||
}, room=session_id, namespace='/widget')
|
||||
|
||||
# Notify agent rooms (convention: user_{id})
|
||||
await sio.emit('room_event', {
|
||||
'type': 'reassigned',
|
||||
'session_id': session_id,
|
||||
'assigned_to': to_user_id
|
||||
}, room=f"user_{to_user_id}")
|
||||
|
||||
# Also notify the PREVIOUS assignee that the chat left their queue —
|
||||
# unless it was reassigned to themselves (a no-op).
|
||||
if previous_user_id and str(previous_user_id) != str(to_user_id):
|
||||
await sio.emit('room_event', {
|
||||
'type': 'reassigned_from_you',
|
||||
'session_id': session_id
|
||||
}, room=f"user_{previous_user_id}")
|
||||
|
||||
# Push to the new assignee — a socket event only reaches them if the
|
||||
# dashboard is already open.
|
||||
await notify_chat_assigned(db, session, to_user_id, assigned_by=current_user.id)
|
||||
|
||||
return ChatDetailResponse(**chat)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error reassigning chat: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Failed to reassign chat")
|
||||
Reference in new issue
Block a user