feat(auth): 后台登录、角色权限与会话访问策略
管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
1 parent
9eb8613c05
commit
556b650c7a
123 files changed
+25293
No files matched your search
@@ -0,0 +1,724 @@
|
||||
"""
|
||||
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 typing import List
|
||||
from urllib.parse import quote, urlparse
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
import json
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from app.models.schemas.organization import (
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
OrganizationResponse,
|
||||
OrganizationCreateResponse
|
||||
)
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.services.auth_sessions import issue_tokens
|
||||
from app.core.logger import get_logger
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission, DEFAULT_AGENT_ROLE_PERMISSIONS
|
||||
from app.repositories.organization import OrganizationRepository
|
||||
from app.models.agent import Agent
|
||||
from app.models.session_to_agent import SessionToAgent
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
from app.core.cors import update_cors_middleware
|
||||
from app.core.application import app # Import the FastAPI app instance from the new location
|
||||
|
||||
# Disposable-address rejection lives in the enterprise module: the hosted signup
|
||||
# flow is what attracts throwaway signups, and the community edition has no
|
||||
# reason to carry an 8k-domain blocklist. Absent, every address is accepted.
|
||||
try:
|
||||
from app.enterprise.services.email_validation import ensure_not_disposable
|
||||
|
||||
HAS_EMAIL_VALIDATION = True
|
||||
except ImportError:
|
||||
HAS_EMAIL_VALIDATION = False
|
||||
|
||||
DOMAIN_TAKEN_MESSAGE = "That domain is already used by another organization."
|
||||
LOCALHOST_DOMAIN_MESSAGE = "Localhost can't be used as your domain. Use the domain of the site where you'll add the widget."
|
||||
LOCALHOST_HOSTS = frozenset({"localhost", "127.0.0.1"})
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(
|
||||
tags=["organizations"]
|
||||
)
|
||||
|
||||
|
||||
def is_localhost_domain(domain: str) -> bool:
|
||||
"""Whether a domain (bare host, host:port or URL) points at localhost"""
|
||||
domain = domain.strip()
|
||||
try:
|
||||
host = urlparse(domain if "://" in domain else f"//{domain}").hostname
|
||||
except ValueError:
|
||||
return False
|
||||
return (host or "") in LOCALHOST_HOSTS
|
||||
|
||||
|
||||
@router.post("", response_model=OrganizationCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_organization(
|
||||
org_data: OrganizationCreate,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a new organization with an admin user and default roles"""
|
||||
try:
|
||||
if HAS_EMAIL_VALIDATION:
|
||||
ensure_not_disposable(org_data.admin_email)
|
||||
|
||||
# Check if any organization exists
|
||||
existing_orgs = db.query(Organization).first()
|
||||
|
||||
# If organizations exist, return 403 Forbidden
|
||||
if existing_orgs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Organization already exists"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Create organization
|
||||
org_repo = OrganizationRepository(db)
|
||||
organization = org_repo.create_organization(
|
||||
name=org_data.name,
|
||||
domain=org_data.domain,
|
||||
timezone=org_data.timezone,
|
||||
business_hours=org_data.business_hours
|
||||
)
|
||||
|
||||
# Note: no default agent is created here. New orgs start with zero
|
||||
# agents so the guided onboarding flow (Create → Teach → Test → Launch)
|
||||
# is what creates the first agent.
|
||||
|
||||
# Get or create default permissions
|
||||
permissions = {}
|
||||
for name, description in Permission.default_permissions():
|
||||
# Try to get existing permission
|
||||
perm = db.query(Permission).filter(Permission.name == name).first()
|
||||
if not perm:
|
||||
perm = Permission(name=name, description=description)
|
||||
db.add(perm)
|
||||
db.flush()
|
||||
permissions[name] = perm
|
||||
|
||||
# Create default roles. Exactly one must be is_default: nothing enforces
|
||||
# that, and get_default_role() just takes .first() with no ordering, so
|
||||
# two defaults means a newly invited user lands on whichever row the
|
||||
# database happens to yield. It has to be Agent — the org creator is
|
||||
# given Admin explicitly below.
|
||||
admin_role = Role(
|
||||
name="Admin",
|
||||
description="Full access to all features",
|
||||
organization_id=organization.id,
|
||||
is_default=False
|
||||
)
|
||||
admin_role.permissions = list(permissions.values()) # All permissions
|
||||
db.add(admin_role)
|
||||
|
||||
agent_role = Role(
|
||||
name="Agent",
|
||||
description="Access to assigned chats and the unclaimed AI queue",
|
||||
organization_id=organization.id,
|
||||
is_default=True
|
||||
)
|
||||
agent_role.permissions = [
|
||||
permissions[name] for name in DEFAULT_AGENT_ROLE_PERMISSIONS
|
||||
]
|
||||
db.add(agent_role)
|
||||
db.flush()
|
||||
|
||||
# Create admin user with admin role
|
||||
admin = User(
|
||||
email=org_data.admin_email,
|
||||
full_name=org_data.admin_name,
|
||||
hashed_password=User.get_password_hash(org_data.admin_password),
|
||||
organization_id=organization.id,
|
||||
role_id=admin_role.id,
|
||||
is_active=True
|
||||
)
|
||||
db.add(admin)
|
||||
db.flush()
|
||||
|
||||
# Open the admin's first session and set cookies
|
||||
access_token, refresh_token = issue_tokens(db, admin.id, organization.id)
|
||||
|
||||
# Set cookies and return response
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="none", # Changed to "none" for cross-domain support (shopifiy)
|
||||
max_age=1800 # 30 minutes
|
||||
)
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="none", # Changed to "none" for cross-domain support (shopifiy)
|
||||
max_age=604800 # 7 days
|
||||
)
|
||||
|
||||
# Set session data with role information
|
||||
response.set_cookie(
|
||||
key="user_info",
|
||||
value=quote(json.dumps({
|
||||
"id": str(admin.id),
|
||||
"email": admin.email,
|
||||
"full_name": admin.full_name,
|
||||
"organization_id": str(organization.id),
|
||||
"role": admin_role.to_dict()
|
||||
}, default=str)),
|
||||
samesite="none", # Changed to "none" for cross-domain support (shopifiy)
|
||||
secure=True, # Required when samesite="none"
|
||||
max_age=604800 # 7 days
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Update CORS origins after creating organization
|
||||
update_cors_middleware(app)
|
||||
|
||||
return {
|
||||
"id": organization.id,
|
||||
"name": organization.name,
|
||||
"domain": organization.domain,
|
||||
"timezone": organization.timezone,
|
||||
"business_hours": organization.business_hours,
|
||||
"settings": organization.settings,
|
||||
"is_active": organization.is_active,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"user": {
|
||||
"id": admin.id,
|
||||
"email": admin.email,
|
||||
"full_name": admin.full_name,
|
||||
"organization_id": organization.id,
|
||||
"role": admin_role.to_dict()
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Organization creation failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to create organization: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/setup-status", response_model=dict)
|
||||
async def is_organization_setup(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check if at least one active organization is set up."""
|
||||
try:
|
||||
active_org_exists = db.query(Organization.id)\
|
||||
.filter(Organization.is_active == True)\
|
||||
.first() is not None
|
||||
|
||||
return {"is_setup": active_org_exists}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check organization setup status: {str(e)}", exc_info=True)
|
||||
# In case of error, conservatively assume setup might be needed or report error
|
||||
# Returning False might lock users out if DB is temporarily unavailable
|
||||
# A 500 error might be better to indicate a server issue
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to check organization setup status"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/check-domain/{domain}")
|
||||
async def check_domain_availability(
|
||||
domain: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check if an organization domain is available"""
|
||||
try:
|
||||
existing_org = db.query(Organization).filter(Organization.domain == domain).first()
|
||||
return {
|
||||
"available": not existing_org,
|
||||
"message": "Domain is available" if not existing_org else "Domain already exists"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check domain availability: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to check domain availability"
|
||||
)
|
||||
|
||||
|
||||
def get_own_organization_or_404(db: Session, org_id: UUID, current_user: User) -> Organization:
|
||||
"""Load an organization the caller actually belongs to.
|
||||
|
||||
These routes take the org id from the path, so without this a user of one
|
||||
tenant could read — and with manage_organization, rewrite — another
|
||||
tenant's record. 404 rather than 403: the existence of another
|
||||
organization isn't the caller's business either.
|
||||
"""
|
||||
if current_user.organization_id != org_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Organization not found"
|
||||
)
|
||||
|
||||
org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Organization not found"
|
||||
)
|
||||
return org
|
||||
|
||||
|
||||
@router.get("/{org_id}", response_model=OrganizationResponse)
|
||||
async def get_organization(
|
||||
org_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get organization by ID"""
|
||||
try:
|
||||
return get_own_organization_or_404(db, org_id, current_user)
|
||||
except HTTPException as he:
|
||||
raise he
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get organization {
|
||||
org_id}: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve organization. Please try again later."
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{org_id}", response_model=OrganizationResponse)
|
||||
async def update_organization(
|
||||
org_id: UUID,
|
||||
org_data: OrganizationUpdate,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update organization details including business hours"""
|
||||
try:
|
||||
org = get_own_organization_or_404(db, org_id, current_user)
|
||||
|
||||
# Update only provided fields
|
||||
update_data = org_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate business hours if provided
|
||||
if 'business_hours' in update_data:
|
||||
business_hours = update_data['business_hours']
|
||||
required_days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||
required_fields = ['start', 'end', 'enabled']
|
||||
|
||||
# Validate all days are present
|
||||
if not all(day in business_hours for day in required_days):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Business hours must include all days of the week"
|
||||
)
|
||||
|
||||
# Validate each day has required fields
|
||||
for day in required_days:
|
||||
if not all(field in business_hours[day] for field in required_fields):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Business hours for {day} must include start, end, and enabled status"
|
||||
)
|
||||
|
||||
# Validate time format (HH:MM)
|
||||
start = business_hours[day]['start']
|
||||
end = business_hours[day]['end']
|
||||
try:
|
||||
hours, minutes = start.split(':')
|
||||
if not (0 <= int(hours) <= 23 and 0 <= int(minutes) <= 59):
|
||||
raise ValueError
|
||||
hours, minutes = end.split(':')
|
||||
if not (0 <= int(hours) <= 23 and 0 <= int(minutes) <= 59):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid time format for {day}. Use HH:MM format (24-hour)"
|
||||
)
|
||||
|
||||
new_domain = update_data.get('domain')
|
||||
if new_domain and new_domain.lower() != org.domain.lower() and is_localhost_domain(new_domain):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=LOCALHOST_DOMAIN_MESSAGE
|
||||
)
|
||||
if new_domain and OrganizationRepository(db).is_domain_taken(new_domain, exclude_org_id=org.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=DOMAIN_TAKEN_MESSAGE
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(org, field, value)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# Another org claimed the domain between the check and the write
|
||||
if 'domain' not in update_data:
|
||||
raise
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=DOMAIN_TAKEN_MESSAGE
|
||||
)
|
||||
db.refresh(org)
|
||||
|
||||
# Update CORS origins after updating organization
|
||||
update_cors_middleware(app)
|
||||
|
||||
return org
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to update organization {org_id}: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update organization. Please try again later."
|
||||
)
|
||||
|
||||
|
||||
# @router.delete("/{org_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
# async def delete_organization(
|
||||
# org_id: UUID,
|
||||
# current_user: User = Depends(require_permissions("manage_organization")),
|
||||
# db: Session = Depends(get_db)
|
||||
# ):
|
||||
# """
|
||||
# Hard delete organization and all related data.
|
||||
|
||||
# This operation permanently removes:
|
||||
# - Users and their notifications, knowledge queue items, session assignments, ratings
|
||||
# - Customers and their ratings, chat histories
|
||||
# - Agents and their customizations, widgets, knowledge links, tool links, ratings, chat histories
|
||||
# - Workflows and workflow nodes
|
||||
# - Enterprise subscriptions and PayPal orders (if available)
|
||||
# - AI configurations
|
||||
# - Knowledge sources and knowledge-to-agent links
|
||||
# - MCP tools and their agent links
|
||||
# - User groups
|
||||
# - Roles and their permission associations
|
||||
# - Integration data (Shopify shops, Jira tokens)
|
||||
# - All remaining chat histories
|
||||
# - The organization itself
|
||||
|
||||
# WARNING: This operation cannot be undone.
|
||||
# """
|
||||
# try:
|
||||
# # Check if organization exists
|
||||
# org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||
# if not org:
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_404_NOT_FOUND,
|
||||
# detail="Organization not found"
|
||||
# )
|
||||
|
||||
# # Verify user belongs to this organization
|
||||
# if current_user.organization_id != org_id:
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_403_FORBIDDEN,
|
||||
# detail="You can only delete your own organization"
|
||||
# )
|
||||
|
||||
# logger.info(f"Starting hard delete for organization {org_id}")
|
||||
|
||||
# # Import models that might not be available in all environments
|
||||
# try:
|
||||
# from app.enterprise.models.subscription import Subscription
|
||||
# from app.enterprise.models.order import PayPalOrder
|
||||
# enterprise_available = True
|
||||
# except ImportError:
|
||||
# enterprise_available = False
|
||||
# logger.info("Enterprise models not available")
|
||||
|
||||
# # Import all required models
|
||||
# from app.models.user import User, UserGroup
|
||||
# from app.models.role import Role
|
||||
# from app.models.permission import Permission
|
||||
# from app.models.agent import Agent, AgentCustomization
|
||||
# from app.models.widget import Widget
|
||||
# from app.models.ai_config import AIConfig
|
||||
# from app.models.knowledge import Knowledge
|
||||
# from app.models.knowledge_to_agent import KnowledgeToAgent
|
||||
# from app.models.knowledge_queue import KnowledgeQueue
|
||||
# from app.models.chat_history import ChatHistory
|
||||
# from app.models.customer import Customer
|
||||
# from app.models.session_to_agent import SessionToAgent
|
||||
# from app.models.rating import Rating
|
||||
# from app.models.workflow import Workflow
|
||||
# from app.models.workflow_node import WorkflowNode
|
||||
# from app.models.mcp_tool import MCPTool, MCPToolToAgent
|
||||
# from app.models.notification import Notification
|
||||
# from app.models.shopify.shopify_shop import ShopifyShop
|
||||
# from app.models.jira import JiraToken
|
||||
|
||||
# # 1. Delete users and their related data first
|
||||
# logger.info("Deleting users and related data...")
|
||||
# users = db.query(User).filter(User.organization_id == org_id).all()
|
||||
# for user in users:
|
||||
# # Delete user notifications
|
||||
# db.query(Notification).filter(Notification.user_id == user.id).delete()
|
||||
# # Delete user knowledge queue items
|
||||
# db.query(KnowledgeQueue).filter(KnowledgeQueue.user_id == user.id).delete()
|
||||
# # Delete user session assignments
|
||||
# db.query(SessionToAgent).filter(SessionToAgent.user_id == user.id).delete()
|
||||
# # Delete user ratings
|
||||
# db.query(Rating).filter(Rating.user_id == user.id).delete()
|
||||
|
||||
# # Delete users themselves (this will cascade to user_groups via the association table)
|
||||
# db.query(User).filter(User.organization_id == org_id).delete()
|
||||
|
||||
# # 2. Delete customer-related data
|
||||
# logger.info("Deleting customer data...")
|
||||
# customers = db.query(Customer).filter(Customer.organization_id == org_id).all()
|
||||
# for customer in customers:
|
||||
# # Delete customer ratings
|
||||
# db.query(Rating).filter(Rating.customer_id == customer.id).delete()
|
||||
# # Delete customer chat histories
|
||||
# db.query(ChatHistory).filter(ChatHistory.customer_id == customer.id).delete()
|
||||
|
||||
# # Delete customers
|
||||
# db.query(Customer).filter(Customer.organization_id == org_id).delete()
|
||||
|
||||
# # 3. Delete agents and their related data
|
||||
# logger.info("Deleting agents and related data...")
|
||||
# agents = db.query(Agent).filter(Agent.organization_id == org_id).all()
|
||||
# for agent in agents:
|
||||
# # Delete agent customizations
|
||||
# db.query(AgentCustomization).filter(AgentCustomization.agent_id == agent.id).delete()
|
||||
# # Delete widgets for this agent
|
||||
# db.query(Widget).filter(Widget.agent_id == agent.id).delete()
|
||||
# # Delete knowledge-to-agent links
|
||||
# db.query(KnowledgeToAgent).filter(KnowledgeToAgent.agent_id == agent.id).delete()
|
||||
# # Delete MCP tool-to-agent links
|
||||
# db.query(MCPToolToAgent).filter(MCPToolToAgent.agent_id == agent.id).delete()
|
||||
# # Delete agent session assignments
|
||||
# db.query(SessionToAgent).filter(SessionToAgent.agent_id == agent.id).delete()
|
||||
# # Delete agent ratings
|
||||
# db.query(Rating).filter(Rating.agent_id == agent.id).delete()
|
||||
# # Delete agent chat histories
|
||||
# db.query(ChatHistory).filter(ChatHistory.agent_id == agent.id).delete()
|
||||
|
||||
# # Delete agents themselves
|
||||
# db.query(Agent).filter(Agent.organization_id == org_id).delete()
|
||||
|
||||
# # 4. Delete workflows and workflow nodes
|
||||
# logger.info("Deleting workflows...")
|
||||
# workflows = db.query(Workflow).filter(Workflow.organization_id == org_id).all()
|
||||
# for workflow in workflows:
|
||||
# # Delete workflow nodes
|
||||
# db.query(WorkflowNode).filter(WorkflowNode.workflow_id == workflow.id).delete()
|
||||
|
||||
# # Delete workflows
|
||||
# db.query(Workflow).filter(Workflow.organization_id == org_id).delete()
|
||||
|
||||
# # 5. Delete remaining widgets (not associated with agents)
|
||||
# logger.info("Deleting remaining widgets...")
|
||||
# db.query(Widget).filter(Widget.organization_id == org_id).delete()
|
||||
|
||||
# # 6. Delete enterprise subscriptions and orders if available
|
||||
# if enterprise_available:
|
||||
# logger.info("Deleting enterprise subscriptions and orders...")
|
||||
# # Delete PayPal orders first (they reference subscriptions)
|
||||
# db.query(PayPalOrder).filter(PayPalOrder.organization_id == org_id).delete()
|
||||
# # Delete subscriptions
|
||||
# db.query(Subscription).filter(Subscription.organization_id == org_id).delete()
|
||||
|
||||
# # 7. Delete AI configs
|
||||
# logger.info("Deleting AI configs...")
|
||||
# db.query(AIConfig).filter(AIConfig.organization_id == org_id).delete()
|
||||
|
||||
# # 8. Delete knowledge sources and related data
|
||||
# logger.info("Deleting knowledge sources...")
|
||||
# knowledge_sources = db.query(Knowledge).filter(Knowledge.organization_id == org_id).all()
|
||||
# for knowledge in knowledge_sources:
|
||||
# # Delete any remaining knowledge-to-agent links
|
||||
# db.query(KnowledgeToAgent).filter(KnowledgeToAgent.knowledge_id == knowledge.id).delete()
|
||||
|
||||
# # Delete knowledge sources
|
||||
# db.query(Knowledge).filter(Knowledge.organization_id == org_id).delete()
|
||||
|
||||
# # 9. Delete remaining knowledge queue items
|
||||
# logger.info("Deleting knowledge queue items...")
|
||||
# db.query(KnowledgeQueue).filter(KnowledgeQueue.organization_id == org_id).delete()
|
||||
|
||||
# # 10. Delete MCP tools
|
||||
# logger.info("Deleting MCP tools...")
|
||||
# mcp_tools = db.query(MCPTool).filter(MCPTool.organization_id == org_id).all()
|
||||
# for tool in mcp_tools:
|
||||
# # Delete any remaining MCP tool-to-agent links
|
||||
# db.query(MCPToolToAgent).filter(MCPToolToAgent.mcp_tool_id == tool.id).delete()
|
||||
|
||||
# # Delete MCP tools
|
||||
# db.query(MCPTool).filter(MCPTool.organization_id == org_id).delete()
|
||||
|
||||
# # 11. Delete user groups
|
||||
# logger.info("Deleting user groups...")
|
||||
# db.query(UserGroup).filter(UserGroup.organization_id == org_id).delete()
|
||||
|
||||
# # 12. Delete roles and their permission associations
|
||||
# logger.info("Deleting roles and their permissions...")
|
||||
|
||||
# # First, directly delete entries from the role_permissions association table
|
||||
# from app.models.permission import role_permissions
|
||||
|
||||
# # Get all role IDs for this organization
|
||||
# role_ids = [role_id for role_id, in db.query(Role.id).filter(Role.organization_id == org_id).all()]
|
||||
|
||||
# if role_ids:
|
||||
# # Delete from the association table using raw SQL
|
||||
# # This is necessary because SQLAlchemy ORM doesn't directly support bulk deletion from association tables
|
||||
# delete_stmt = role_permissions.delete().where(role_permissions.c.role_id.in_(role_ids))
|
||||
# db.execute(delete_stmt)
|
||||
# db.flush()
|
||||
|
||||
# # Now delete the roles
|
||||
# db.query(Role).filter(Role.organization_id == org_id).delete()
|
||||
|
||||
# # 13. Delete integration-specific data
|
||||
# logger.info("Deleting integration data...")
|
||||
# # Delete Shopify shops
|
||||
# db.query(ShopifyShop).filter(ShopifyShop.organization_id == org_id).delete()
|
||||
# # Delete Jira tokens
|
||||
# db.query(JiraToken).filter(JiraToken.organization_id == org_id).delete()
|
||||
|
||||
# # 14. Delete any remaining chat histories
|
||||
# logger.info("Deleting remaining chat histories...")
|
||||
# db.query(ChatHistory).filter(ChatHistory.organization_id == org_id).delete()
|
||||
|
||||
# # 15. Finally, delete the organization itself
|
||||
# logger.info("Deleting organization...")
|
||||
# db.delete(org)
|
||||
|
||||
# # Commit all changes
|
||||
# db.commit()
|
||||
# logger.info(f"Successfully hard deleted organization {org_id}")
|
||||
|
||||
# # Update CORS origins after deleting organization
|
||||
# update_cors_middleware(app)
|
||||
|
||||
# return None
|
||||
# except HTTPException as he:
|
||||
# db.rollback()
|
||||
# raise he
|
||||
# except Exception as e:
|
||||
# db.rollback()
|
||||
# logger.error(f"Failed to delete organization {org_id}: {str(e)}", exc_info=True)
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
# detail="Failed to delete organization. Please try again later."
|
||||
# )
|
||||
|
||||
|
||||
@router.get("/{org_id}/stats")
|
||||
async def get_organization_stats(
|
||||
org_id: UUID,
|
||||
current_user: User = Depends(require_permissions("view_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get organization statistics"""
|
||||
org = get_own_organization_or_404(db, org_id, current_user)
|
||||
|
||||
# --- Members ---
|
||||
total_users = db.query(User).filter(User.organization_id == org_id).count()
|
||||
active_users = db.query(User).filter(
|
||||
User.organization_id == org_id,
|
||||
User.is_active == True
|
||||
).count()
|
||||
# Count admins consistently with the team-overview endpoint (users.py):
|
||||
# a user is an admin if their role grants manage_organization/super_admin,
|
||||
# or the role is literally named "Admin". A plain Role.name == "Admin"
|
||||
# match misses custom/renamed roles that still hold admin permissions.
|
||||
org_users = db.query(User).filter(User.organization_id == org_id).all()
|
||||
admins = sum(
|
||||
1
|
||||
for u in org_users
|
||||
if u.role and (
|
||||
{"manage_organization", "super_admin"} & {p.name for p in (u.role.permissions or [])}
|
||||
or u.role.name == "Admin"
|
||||
)
|
||||
)
|
||||
members_agents = total_users - admins
|
||||
|
||||
# --- Active now (online) ---
|
||||
active_now = db.query(User).filter(
|
||||
User.organization_id == org_id,
|
||||
User.is_online == True
|
||||
).count()
|
||||
|
||||
# --- AI agents (live vs draft) ---
|
||||
agents_total = db.query(Agent).filter(Agent.organization_id == org_id).count()
|
||||
agents_live = db.query(Agent).filter(
|
||||
Agent.organization_id == org_id,
|
||||
Agent.is_active == True
|
||||
).count()
|
||||
agents_draft = agents_total - agents_live
|
||||
|
||||
# --- Conversations (last 30d vs previous 30d) ---
|
||||
now = datetime.now(timezone.utc)
|
||||
d30 = now - timedelta(days=30)
|
||||
d60 = now - timedelta(days=60)
|
||||
conversations_30d = db.query(SessionToAgent).filter(
|
||||
SessionToAgent.organization_id == org_id,
|
||||
SessionToAgent.assigned_at >= d30
|
||||
).count()
|
||||
conversations_prev_30d = db.query(SessionToAgent).filter(
|
||||
SessionToAgent.organization_id == org_id,
|
||||
SessionToAgent.assigned_at >= d60,
|
||||
SessionToAgent.assigned_at < d30
|
||||
).count()
|
||||
if conversations_prev_30d:
|
||||
conversations_change_pct = round(
|
||||
(conversations_30d - conversations_prev_30d) / conversations_prev_30d * 100
|
||||
)
|
||||
else:
|
||||
conversations_change_pct = 100 if conversations_30d else 0
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"settings": org.settings,
|
||||
# Design KPI strip
|
||||
"members_total": total_users,
|
||||
"members_admins": admins,
|
||||
"members_agents": members_agents,
|
||||
"active_now": active_now,
|
||||
"agents_total": agents_total,
|
||||
"agents_live": agents_live,
|
||||
"agents_draft": agents_draft,
|
||||
"conversations_30d": conversations_30d,
|
||||
"conversations_prev_30d": conversations_prev_30d,
|
||||
"conversations_change_pct": conversations_change_pct,
|
||||
}
|
||||
Reference in new issue
Block a user