feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
1 parent
2aee6286d6
commit
5e0615f36d
126 files changed
+17436
No files matched your search
@@ -0,0 +1,579 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from app.models.session_to_agent import ACTIVE_STATUSES, SessionToAgent, SessionStatus, EndChatReasonType
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta
|
||||
from app.core.logger import get_logger
|
||||
from sqlalchemy import or_, func
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.services.conversation_token import session_key_for_scope
|
||||
from app.utils.uuids import as_uuid
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class SessionToAgentRepository:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_session(self, session_id: UUID | str, agent_id: UUID | str = None, customer_id: UUID | str = None, user_id: UUID | str = None, organization_id: UUID | str = None, channel: str = 'web', conversation_key: Optional[str] = None) -> SessionToAgent:
|
||||
"""Create a new session assignment"""
|
||||
try:
|
||||
# Convert string IDs to UUID objects
|
||||
if isinstance(session_id, str):
|
||||
session_id = UUID(session_id)
|
||||
if isinstance(agent_id, str):
|
||||
agent_id = UUID(agent_id)
|
||||
if isinstance(customer_id, str):
|
||||
customer_id = UUID(customer_id)
|
||||
if isinstance(user_id, str):
|
||||
user_id = UUID(user_id)
|
||||
if isinstance(organization_id, str):
|
||||
organization_id = UUID(organization_id)
|
||||
|
||||
workflow_id = None
|
||||
|
||||
# Check if agent has an active workflow (only if agent_id is provided)
|
||||
if agent_id is not None:
|
||||
from app.models.agent import Agent
|
||||
agent = self.db.query(Agent).filter(Agent.id == agent_id).first()
|
||||
|
||||
if agent:
|
||||
logger.info(f"Agent {agent_id} has use_workflow: {agent.use_workflow} and active_workflow_id: {agent.active_workflow_id}")
|
||||
if agent.use_workflow and agent.active_workflow_id:
|
||||
workflow_id = agent.active_workflow_id
|
||||
logger.info(f"Agent {agent_id} has active workflow {workflow_id}, adding to session")
|
||||
|
||||
session = SessionToAgent(
|
||||
session_id=session_id,
|
||||
agent_id=agent_id,
|
||||
customer_id=customer_id,
|
||||
user_id=user_id,
|
||||
organization_id=organization_id,
|
||||
status=SessionStatus.OPEN,
|
||||
workflow_id=workflow_id,
|
||||
channel=channel,
|
||||
conversation_key=conversation_key
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
return session
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating session: {str(e)}")
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
def get_session(self, session_id: UUID | str) -> Optional[SessionToAgent]:
|
||||
"""Get session by ID"""
|
||||
try:
|
||||
if isinstance(session_id, str):
|
||||
session_id = UUID(session_id)
|
||||
return self.db.query(SessionToAgent).filter(
|
||||
SessionToAgent.session_id == session_id
|
||||
).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting session: {str(e)}")
|
||||
return None
|
||||
|
||||
def assign_user(self, session_id: UUID | str, user_id: UUID | str) -> bool:
|
||||
"""Assign a user to a session"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return False
|
||||
|
||||
# Convert string user_id to UUID if needed
|
||||
if isinstance(user_id, str):
|
||||
user_id = UUID(user_id)
|
||||
|
||||
session.user_id = user_id
|
||||
session.status = SessionStatus.TRANSFERRED
|
||||
self.db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error assigning user to session: {str(e)}")
|
||||
self.db.rollback()
|
||||
return False
|
||||
|
||||
def close_session(self, session_id: UUID | str, reason=None, description=None) -> bool:
|
||||
"""Close a session, optionally recording why (end_chat reason/description)."""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return False
|
||||
|
||||
session.status = SessionStatus.CLOSED
|
||||
session.closed_at = datetime.utcnow()
|
||||
if reason is not None:
|
||||
session.end_chat_reason = reason
|
||||
if description is not None:
|
||||
session.end_chat_description = description
|
||||
self.db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing session: {str(e)}")
|
||||
self.db.rollback()
|
||||
return False
|
||||
|
||||
def get_agent_sessions(self, agent_id: UUID | str, status: SessionStatus = None) -> List[SessionToAgent]:
|
||||
"""Get all sessions for an agent"""
|
||||
try:
|
||||
query = self.db.query(SessionToAgent).filter(
|
||||
SessionToAgent.agent_id == agent_id
|
||||
)
|
||||
if status:
|
||||
query = query.filter(SessionToAgent.status == status)
|
||||
return query.all()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting agent sessions: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_user_sessions(self, user_id: UUID | str, status: SessionStatus = None) -> List[SessionToAgent]:
|
||||
"""Get all sessions assigned to a user"""
|
||||
try:
|
||||
query = self.db.query(SessionToAgent).filter(
|
||||
SessionToAgent.user_id == user_id
|
||||
)
|
||||
if status:
|
||||
query = query.filter(SessionToAgent.status == status)
|
||||
return query.all()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user sessions: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_open_sessions(self) -> List[SessionToAgent]:
|
||||
"""Get all open sessions"""
|
||||
try:
|
||||
return self.db.query(SessionToAgent).filter(
|
||||
SessionToAgent.status == SessionStatus.OPEN
|
||||
).all()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting open sessions: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_customer_sessions(self, customer_id: UUID | str, status: SessionStatus, scope: str) -> List[SessionToAgent]:
|
||||
"""A customer's sessions in `status` that a conversation-token scope reaches"""
|
||||
try:
|
||||
query = self.db.query(
|
||||
SessionToAgent,
|
||||
User.full_name.label('user_full_name'),
|
||||
User.profile_pic.label('user_profile_pic')
|
||||
).outerjoin(
|
||||
User, SessionToAgent.user_id == User.id
|
||||
).filter(
|
||||
SessionToAgent.customer_id == as_uuid(customer_id)
|
||||
)
|
||||
query = query.filter(SessionToAgent.status == status, _in_scope(scope))
|
||||
|
||||
return query.order_by(SessionToAgent.assigned_at.desc()).all()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting customer sessions: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_active_widget_session(self, customer_id: UUID | str, agent_id: UUID | str, scope: str) -> Optional[SessionToAgent]:
|
||||
"""The open session a widget conversation token may resume.
|
||||
|
||||
Never "the customer's open session": a widget token can name a customer
|
||||
the visitor never proved to be, so it only reaches sessions created
|
||||
under its own scope.
|
||||
"""
|
||||
try:
|
||||
return self.db.query(SessionToAgent).filter(
|
||||
SessionToAgent.customer_id == as_uuid(customer_id),
|
||||
SessionToAgent.agent_id == as_uuid(agent_id),
|
||||
SessionToAgent.status.in_(ACTIVE_STATUSES),
|
||||
_in_scope(scope),
|
||||
).order_by(SessionToAgent.assigned_at.desc()).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting active widget session: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_widget_session(self, session_id: UUID | str, customer_id: UUID | str, scope: Optional[str],
|
||||
active_only: bool = True) -> Optional[SessionToAgent]:
|
||||
"""A widget socket's own conversation, while it still belongs to that socket.
|
||||
|
||||
Socket handlers act on this rather than on "the customer's open session":
|
||||
a widget token may name a customer the visitor only claimed to be, and
|
||||
must never reach a conversation outside its own scope.
|
||||
"""
|
||||
own = self.get_session(session_id) if session_id else None
|
||||
if own is None or str(own.customer_id) != str(customer_id):
|
||||
return None
|
||||
if not session_in_scope(own, scope):
|
||||
return None
|
||||
if active_only and own.status not in ACTIVE_STATUSES:
|
||||
return None
|
||||
return own
|
||||
|
||||
def customer_has_sessions_outside_scope(self, customer_id: UUID | str, scope: str) -> bool:
|
||||
"""True when the customer has a session this token scope did not create.
|
||||
|
||||
Fails closed: on error the customer is treated as shared.
|
||||
"""
|
||||
try:
|
||||
return self.db.query(SessionToAgent.session_id).filter(
|
||||
SessionToAgent.customer_id == as_uuid(customer_id),
|
||||
_outside_scope(scope),
|
||||
).first() is not None
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking customer session scopes: {str(e)}")
|
||||
return True
|
||||
|
||||
def get_agent_customer_sessions(self, agent_id: UUID | str, customer_id: UUID | str, status: SessionStatus = None) -> List[SessionToAgent]:
|
||||
"""Get all sessions between an agent and customer"""
|
||||
try:
|
||||
query = self.db.query(SessionToAgent).filter(
|
||||
SessionToAgent.agent_id == agent_id,
|
||||
SessionToAgent.customer_id == customer_id
|
||||
)
|
||||
if status:
|
||||
query = query.filter(SessionToAgent.status == status)
|
||||
|
||||
return query.order_by(SessionToAgent.assigned_at.desc()).all()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting agent-customer sessions: {str(e)}")
|
||||
return []
|
||||
|
||||
def reopen_closed_session(self, session_id: UUID | str) -> bool:
|
||||
"""Reopen a closed session"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return False
|
||||
|
||||
if session.status == SessionStatus.CLOSED:
|
||||
session.status = SessionStatus.OPEN
|
||||
self.db.commit()
|
||||
return True
|
||||
return False # Session was not closed
|
||||
except Exception as e:
|
||||
logger.error(f"Error reopening session: {str(e)}")
|
||||
self.db.rollback()
|
||||
return False
|
||||
|
||||
def get_latest_customer_session(self, customer_id: UUID | str, scope: str, agent_id: UUID | str = None) -> Optional[SessionToAgent]:
|
||||
"""The latest session a conversation-token scope reaches, whatever its status"""
|
||||
try:
|
||||
query = self.db.query(SessionToAgent).filter(
|
||||
SessionToAgent.customer_id == as_uuid(customer_id)
|
||||
)
|
||||
if agent_id:
|
||||
query = query.filter(SessionToAgent.agent_id == agent_id)
|
||||
query = query.filter(_in_scope(scope))
|
||||
|
||||
return query.order_by(SessionToAgent.assigned_at.desc()).first()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting latest customer session: {str(e)}")
|
||||
return None
|
||||
|
||||
def update_session(self, session_id: UUID | str, data: dict) -> bool:
|
||||
"""Update a session"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
logger.error(f"Session {session_id} not found for update")
|
||||
return False
|
||||
|
||||
logger.info(f"Updating session {session_id} with data: {data}")
|
||||
|
||||
# Direct assignment instead of setattr for better SQLAlchemy JSON handling
|
||||
if 'workflow_state' in data:
|
||||
session.workflow_state = data['workflow_state']
|
||||
logger.info(f"Set workflow_state = {data['workflow_state']}")
|
||||
|
||||
if 'current_node_id' in data:
|
||||
session.current_node_id = data['current_node_id']
|
||||
logger.info(f"Set current_node_id = {data['current_node_id']}")
|
||||
|
||||
# Handle other fields with setattr
|
||||
for key, value in data.items():
|
||||
if key not in ['workflow_state', 'current_node_id']:
|
||||
setattr(session, key, value)
|
||||
logger.info(f"Set {key} = {value}")
|
||||
|
||||
# Mark the session as dirty to ensure SQLAlchemy tracks the changes
|
||||
self.db.flush()
|
||||
self.db.commit()
|
||||
|
||||
# Verify the update by refreshing from database
|
||||
self.db.refresh(session)
|
||||
logger.info(f"Session after update - workflow_state: {session.workflow_state}")
|
||||
logger.info(f"Session after update - current_node_id: {session.current_node_id}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating session: {str(e)}")
|
||||
self.db.rollback()
|
||||
return False
|
||||
|
||||
def takeover_session(self, session_id: str, user_id: str) -> bool:
|
||||
"""Take over a chat session"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return False
|
||||
|
||||
# Check if session is already taken
|
||||
if session.user_id is not None:
|
||||
return False
|
||||
|
||||
# Update session
|
||||
session.user_id = UUID(user_id)
|
||||
session.group_id = None # Remove group assignment
|
||||
session.status = SessionStatus.OPEN # Keep status as open
|
||||
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error taking over session: {str(e)}")
|
||||
self.db.rollback()
|
||||
return False
|
||||
|
||||
def reassign_session(self, session_id: str, to_user_id: str) -> bool:
|
||||
"""Reassign a chat session to another user regardless of current assignee"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
return False
|
||||
|
||||
# Update assignment
|
||||
session.user_id = UUID(to_user_id)
|
||||
session.group_id = None
|
||||
session.status = SessionStatus.OPEN
|
||||
session.updated_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error reassigning session: {str(e)}")
|
||||
self.db.rollback()
|
||||
return False
|
||||
|
||||
def update_workflow_state(self, session_id: UUID | str, current_node_id: Optional[UUID], workflow_state: dict) -> bool:
|
||||
"""Update workflow state and current node for a session"""
|
||||
try:
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
logger.error(f"Session {session_id} not found for workflow state update")
|
||||
return False
|
||||
|
||||
logger.info(f"Updating workflow state for session {session_id}")
|
||||
logger.info(f"Setting current_node_id to: {current_node_id}")
|
||||
logger.info(f"Setting workflow_state to: {workflow_state}")
|
||||
|
||||
# Update fields
|
||||
session.current_node_id = current_node_id
|
||||
session.workflow_state = workflow_state.copy() if workflow_state else {}
|
||||
session.updated_at = datetime.utcnow()
|
||||
|
||||
# Explicitly mark JSON field as modified for SQLAlchemy
|
||||
flag_modified(session, 'workflow_state')
|
||||
|
||||
# Commit changes
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
|
||||
# Verify the update
|
||||
logger.info(f"Verified - current_node_id: {session.current_node_id}")
|
||||
logger.info(f"Verified - workflow_state: {session.workflow_state}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating workflow state: {str(e)}")
|
||||
self.db.rollback()
|
||||
return False
|
||||
|
||||
def update_session_status(self, session_id: UUID | str, status: str) -> Optional[SessionToAgent]:
|
||||
"""Update the status of a session"""
|
||||
try:
|
||||
session = self.db.query(SessionToAgent).filter(SessionToAgent.session_id == session_id).first()
|
||||
if not session:
|
||||
logger.error(f"Session {session_id} not found")
|
||||
return None
|
||||
|
||||
# Convert string status to enum if needed
|
||||
if isinstance(status, str):
|
||||
try:
|
||||
status = SessionStatus[status]
|
||||
except KeyError:
|
||||
logger.error(f"Invalid session status: {status}")
|
||||
return None
|
||||
|
||||
session.status = status
|
||||
session.updated_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
logger.info(f"Updated session {session_id} status to {status}")
|
||||
return session
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating session status: {str(e)}")
|
||||
self.db.rollback()
|
||||
return None
|
||||
|
||||
def add_workflow_history_entry(self, session_id: UUID | str, node_id: UUID | str, entry_type: str, data: dict) -> bool:
|
||||
"""Add an entry to the workflow history with proper multi-language Unicode support"""
|
||||
try:
|
||||
import json
|
||||
import copy
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
logger.error(f"Session {session_id} not found for workflow history update")
|
||||
return False
|
||||
|
||||
# Initialize workflow_history if None
|
||||
if session.workflow_history is None:
|
||||
session.workflow_history = []
|
||||
|
||||
# Create a deep copy of the data to avoid modifying the original
|
||||
data_copy = copy.deepcopy(data)
|
||||
|
||||
# Create history entry
|
||||
history_entry = {
|
||||
"node_id": str(node_id),
|
||||
"type": entry_type,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"data": data_copy
|
||||
}
|
||||
|
||||
# Convert to JSON with ensure_ascii=False to preserve Unicode characters
|
||||
# Then parse it back to ensure it's properly formatted for SQLAlchemy
|
||||
json_str = json.dumps(history_entry, ensure_ascii=False, separators=(',', ':'))
|
||||
unicode_safe_entry = json.loads(json_str)
|
||||
|
||||
# Add to history
|
||||
session.workflow_history.append(unicode_safe_entry)
|
||||
|
||||
# Mark as modified for SQLAlchemy
|
||||
flag_modified(session, 'workflow_history')
|
||||
|
||||
# Commit changes
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
|
||||
logger.info(f"Added workflow history entry for session {session_id}: {entry_type}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding workflow history entry: {str(e)}")
|
||||
self.db.rollback()
|
||||
return False
|
||||
|
||||
def get_workflow_history(self, session_id: UUID | str) -> list:
|
||||
"""Get the workflow history for a session"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
if not session:
|
||||
logger.error(f"Session {session_id} not found")
|
||||
return []
|
||||
|
||||
return session.workflow_history or []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting workflow history: {str(e)}")
|
||||
return []
|
||||
|
||||
def auto_close_inactive_agent_chats(self) -> int:
|
||||
"""
|
||||
Auto-close chats that are handled by agents (not users) and have been inactive for more than 1 day.
|
||||
Returns the number of chats that were closed.
|
||||
"""
|
||||
try:
|
||||
# Calculate the cutoff time (24 hours ago)
|
||||
cutoff_time = datetime.utcnow() - timedelta(days=1)
|
||||
|
||||
# Find open sessions handled by agents (user_id is None) where the last message is older than 1 day
|
||||
sessions_to_close = (
|
||||
self.db.query(SessionToAgent)
|
||||
.join(
|
||||
ChatHistory,
|
||||
SessionToAgent.session_id == ChatHistory.session_id
|
||||
)
|
||||
.filter(
|
||||
SessionToAgent.status == SessionStatus.OPEN,
|
||||
SessionToAgent.user_id.is_(None), # Handled by agent, not user
|
||||
SessionToAgent.agent_id.is_not(None) # Must have an agent assigned
|
||||
)
|
||||
.group_by(SessionToAgent.session_id)
|
||||
.having(
|
||||
func.max(ChatHistory.created_at) < cutoff_time
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
closed_count = 0
|
||||
|
||||
for session in sessions_to_close:
|
||||
try:
|
||||
# Update session status to closed
|
||||
session.status = SessionStatus.CLOSED
|
||||
session.end_chat_reason = EndChatReasonType.ISSUE_RESOLVED
|
||||
session.end_chat_description = "Inactive for more than one day"
|
||||
session.updated_at = datetime.utcnow()
|
||||
|
||||
closed_count += 1
|
||||
logger.info(f"Auto-closed inactive chat session {session.session_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing session {session.session_id}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Commit all changes
|
||||
if closed_count > 0:
|
||||
self.db.commit()
|
||||
logger.info(f"Auto-closed {closed_count} inactive agent chats")
|
||||
|
||||
return closed_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in auto_close_inactive_agent_chats: {str(e)}")
|
||||
self.db.rollback()
|
||||
return 0
|
||||
|
||||
|
||||
def _in_scope(scope: str):
|
||||
"""SQL condition: the session was created under this token scope."""
|
||||
key = session_key_for_scope(scope)
|
||||
if key is None:
|
||||
return SessionToAgent.conversation_key.is_(None)
|
||||
return SessionToAgent.conversation_key == key
|
||||
|
||||
|
||||
def _outside_scope(scope: str):
|
||||
"""SQL condition: the session was NOT created under this token scope.
|
||||
|
||||
Spelled out rather than ~_in_scope: NOT (key = 'x') is NULL, not true, for a
|
||||
keyless session, which would hide every vouched-for conversation.
|
||||
"""
|
||||
key = session_key_for_scope(scope)
|
||||
if key is None:
|
||||
return SessionToAgent.conversation_key.isnot(None)
|
||||
return or_(SessionToAgent.conversation_key.is_(None), SessionToAgent.conversation_key != key)
|
||||
|
||||
|
||||
def session_in_scope(session: SessionToAgent, scope: Optional[str]) -> bool:
|
||||
"""Python twin of _in_scope for a session already loaded. No scope, no access."""
|
||||
if not scope:
|
||||
return False
|
||||
return session.conversation_key == session_key_for_scope(scope)
|
||||
Reference in new issue
Block a user