724 lines
29 KiB
Python
724 lines
29 KiB
Python
"""
|
|
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 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,
|
|
}
|