mirror of
https://github.com/aljazceru/enclava.git
synced 2025-12-17 07:24:34 +01:00
moving plugins to separate directory
This commit is contained in:
@@ -1,90 +0,0 @@
|
|||||||
# A generic, single database configuration.
|
|
||||||
|
|
||||||
[alembic]
|
|
||||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
|
||||||
# Uncomment the line below if you want the files to be prepended with date and time
|
|
||||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
|
||||||
|
|
||||||
# path to migration scripts
|
|
||||||
script_location = migrations
|
|
||||||
|
|
||||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
|
||||||
# Uncomment the line below if you want the files to be prepended with date and time
|
|
||||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
|
||||||
|
|
||||||
# sys.path path, will be prepended to sys.path if present.
|
|
||||||
# defaults to the current working directory.
|
|
||||||
prepend_sys_path = .
|
|
||||||
|
|
||||||
# timezone to use when rendering the date within the migration file
|
|
||||||
# as well as the filename.
|
|
||||||
# If specified, requires the python-dateutil library that can be installed by running "pip install alembic[tz]"
|
|
||||||
# timezone =
|
|
||||||
|
|
||||||
# max length of characters to apply to the
|
|
||||||
# "slug" field
|
|
||||||
# truncate_slug_length = 40
|
|
||||||
|
|
||||||
# set to 'true' to run the environment during
|
|
||||||
# the 'revision' command, regardless of autogenerate
|
|
||||||
# revision_environment = false
|
|
||||||
|
|
||||||
# set to 'true' to allow .pyc and .pyo files without
|
|
||||||
# a source .py file to be detected as revisions in the
|
|
||||||
# versions/ directory
|
|
||||||
# sourceless = false
|
|
||||||
|
|
||||||
# version path separator; As mentioned above, this is the character used to split
|
|
||||||
# version_locations. The default within new alembic.ini files is "os", which uses
|
|
||||||
# os.pathsep. If this key is omitted entirely, it falls back to the legacy
|
|
||||||
# behavior of splitting on spaces and/or commas.
|
|
||||||
# Valid values for version_path_separator are:
|
|
||||||
#
|
|
||||||
# version_path_separator = :
|
|
||||||
# version_path_separator = ;
|
|
||||||
# version_path_separator = space
|
|
||||||
version_path_separator = os
|
|
||||||
|
|
||||||
# set to 'true' to search source files recursively
|
|
||||||
# in each "version_locations" directory
|
|
||||||
# new in Alembic version 1.10
|
|
||||||
# recursive_version_locations = false
|
|
||||||
|
|
||||||
# the output encoding used when revision files
|
|
||||||
# are written from script.py.mako
|
|
||||||
# output_encoding = utf-8
|
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARN
|
|
||||||
handlers = console
|
|
||||||
qualname =
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARN
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers =
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
datefmt = %H:%M:%S
|
|
||||||
@@ -1,719 +0,0 @@
|
|||||||
"""
|
|
||||||
Zammad Plugin Implementation
|
|
||||||
Provides integration between Enclava platform and Zammad helpdesk system
|
|
||||||
"""
|
|
||||||
from typing import Dict, Any, List, Optional
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
|
|
||||||
from pydantic import BaseModel
|
|
||||||
import aiohttp
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from app.services.base_plugin import BasePlugin, PluginContext
|
|
||||||
from app.services.plugin_database import PluginDatabaseSession, plugin_db_manager
|
|
||||||
from app.services.plugin_security import plugin_security_policy_manager
|
|
||||||
from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
class ZammadTicket(BaseModel):
|
|
||||||
"""Zammad ticket model"""
|
|
||||||
id: str
|
|
||||||
title: str
|
|
||||||
body: str
|
|
||||||
status: str
|
|
||||||
priority: str
|
|
||||||
customer_id: str
|
|
||||||
group_id: str
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
ai_summary: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ZammadConfiguration(BaseModel):
|
|
||||||
"""Zammad configuration model"""
|
|
||||||
name: str
|
|
||||||
zammad_url: str
|
|
||||||
api_token: str
|
|
||||||
chatbot_id: str
|
|
||||||
ai_summarization: Dict[str, Any]
|
|
||||||
sync_settings: Dict[str, Any]
|
|
||||||
webhook_settings: Dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
# Plugin database models
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
class ZammadConfiguration(Base):
|
|
||||||
__tablename__ = "zammad_configurations"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
user_id = Column(String, nullable=False, index=True)
|
|
||||||
name = Column(String(100), nullable=False)
|
|
||||||
zammad_url = Column(String(500), nullable=False)
|
|
||||||
api_token_encrypted = Column(Text, nullable=False)
|
|
||||||
chatbot_id = Column(String(100), nullable=False)
|
|
||||||
is_active = Column(Boolean, default=True)
|
|
||||||
ai_summarization_enabled = Column(Boolean, default=True)
|
|
||||||
auto_summarize = Column(Boolean, default=True)
|
|
||||||
sync_enabled = Column(Boolean, default=True)
|
|
||||||
sync_interval_hours = Column(Integer, default=2)
|
|
||||||
created_at = Column(DateTime, default=datetime.now(timezone.utc))
|
|
||||||
updated_at = Column(DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
|
|
||||||
|
|
||||||
class ZammadTicket(Base):
|
|
||||||
__tablename__ = "zammad_tickets"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
zammad_ticket_id = Column(String(50), nullable=False, index=True)
|
|
||||||
configuration_id = Column(UUID(as_uuid=True), ForeignKey("zammad_configurations.id"))
|
|
||||||
title = Column(String(500), nullable=False)
|
|
||||||
body = Column(Text)
|
|
||||||
status = Column(String(50))
|
|
||||||
priority = Column(String(50))
|
|
||||||
customer_id = Column(String(50))
|
|
||||||
group_id = Column(String(50))
|
|
||||||
ai_summary = Column(Text)
|
|
||||||
last_synced = Column(DateTime, default=datetime.now(timezone.utc))
|
|
||||||
created_at = Column(DateTime, default=datetime.now(timezone.utc))
|
|
||||||
updated_at = Column(DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
|
|
||||||
|
|
||||||
configuration = relationship("ZammadConfiguration", back_populates="tickets")
|
|
||||||
|
|
||||||
ZammadConfiguration.tickets = relationship("ZammadTicket", back_populates="configuration")
|
|
||||||
|
|
||||||
class ZammadPlugin(BasePlugin):
|
|
||||||
"""Zammad helpdesk integration plugin with full framework integration"""
|
|
||||||
|
|
||||||
def __init__(self, manifest, plugin_token: str):
|
|
||||||
super().__init__(manifest, plugin_token)
|
|
||||||
self.zammad_client = None
|
|
||||||
self.db_models = [ZammadConfiguration, ZammadTicket]
|
|
||||||
|
|
||||||
async def initialize(self) -> bool:
|
|
||||||
"""Initialize Zammad plugin with database setup"""
|
|
||||||
try:
|
|
||||||
self.logger.info("Initializing Zammad plugin")
|
|
||||||
|
|
||||||
# Create database tables
|
|
||||||
await self._create_database_tables()
|
|
||||||
|
|
||||||
# Test platform API connectivity
|
|
||||||
health = await self.api_client.get("/health")
|
|
||||||
self.logger.info(f"Platform API health: {health.get('status')}")
|
|
||||||
|
|
||||||
# Validate security policy
|
|
||||||
policy = plugin_security_policy_manager.get_security_policy(self.plugin_id, None)
|
|
||||||
self.logger.info(f"Security policy loaded: {policy.get('max_api_calls_per_minute')} calls/min")
|
|
||||||
|
|
||||||
self.logger.info("Zammad plugin initialized successfully")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to initialize Zammad plugin: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _create_database_tables(self):
|
|
||||||
"""Create plugin database tables"""
|
|
||||||
try:
|
|
||||||
engine = await plugin_db_manager.get_plugin_engine(self.plugin_id)
|
|
||||||
if engine:
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
self.logger.info("Database tables created successfully")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to create database tables: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def cleanup(self) -> bool:
|
|
||||||
"""Cleanup plugin resources"""
|
|
||||||
try:
|
|
||||||
self.logger.info("Cleaning up Zammad plugin")
|
|
||||||
# Close any open connections
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error during cleanup: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_api_router(self) -> APIRouter:
|
|
||||||
"""Return FastAPI router for Zammad endpoints"""
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.get("/health")
|
|
||||||
async def health_check():
|
|
||||||
"""Plugin health check endpoint"""
|
|
||||||
return await self.health_check()
|
|
||||||
|
|
||||||
@router.get("/tickets")
|
|
||||||
async def get_tickets(context: PluginContext = Depends(self.get_auth_context)):
|
|
||||||
"""Get tickets from Zammad"""
|
|
||||||
try:
|
|
||||||
self._track_request()
|
|
||||||
|
|
||||||
config = await self.get_active_config(context.user_id)
|
|
||||||
if not config:
|
|
||||||
raise HTTPException(status_code=404, detail="No Zammad configuration found")
|
|
||||||
|
|
||||||
tickets = await self.fetch_tickets_from_zammad(config)
|
|
||||||
return {"tickets": tickets, "count": len(tickets)}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self._track_request(success=False)
|
|
||||||
self.logger.error(f"Error fetching tickets: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@router.get("/tickets/{ticket_id}")
|
|
||||||
async def get_ticket(ticket_id: str, context: PluginContext = Depends(self.get_auth_context)):
|
|
||||||
"""Get specific ticket from Zammad"""
|
|
||||||
try:
|
|
||||||
self._track_request()
|
|
||||||
|
|
||||||
config = await self.get_active_config(context.user_id)
|
|
||||||
if not config:
|
|
||||||
raise HTTPException(status_code=404, detail="No Zammad configuration found")
|
|
||||||
|
|
||||||
ticket = await self.fetch_ticket_from_zammad(config, ticket_id)
|
|
||||||
return {"ticket": ticket}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self._track_request(success=False)
|
|
||||||
self.logger.error(f"Error fetching ticket {ticket_id}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@router.post("/tickets/{ticket_id}/summarize")
|
|
||||||
async def summarize_ticket(
|
|
||||||
ticket_id: str,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
context: PluginContext = Depends(self.get_auth_context)
|
|
||||||
):
|
|
||||||
"""Generate AI summary for ticket"""
|
|
||||||
try:
|
|
||||||
self._track_request()
|
|
||||||
|
|
||||||
config = await self.get_active_config(context.user_id)
|
|
||||||
if not config:
|
|
||||||
raise HTTPException(status_code=404, detail="No Zammad configuration found")
|
|
||||||
|
|
||||||
# Start summarization in background
|
|
||||||
background_tasks.add_task(
|
|
||||||
self.summarize_ticket_async,
|
|
||||||
config,
|
|
||||||
ticket_id,
|
|
||||||
context.user_id
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "started",
|
|
||||||
"ticket_id": ticket_id,
|
|
||||||
"message": "AI summarization started in background"
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self._track_request(success=False)
|
|
||||||
self.logger.error(f"Error starting summarization for ticket {ticket_id}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@router.post("/webhooks/ticket-created")
|
|
||||||
async def handle_ticket_webhook(webhook_data: Dict[str, Any]):
|
|
||||||
"""Handle Zammad webhook for new tickets"""
|
|
||||||
try:
|
|
||||||
ticket_id = webhook_data.get("ticket", {}).get("id")
|
|
||||||
if not ticket_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid webhook data")
|
|
||||||
|
|
||||||
self.logger.info(f"Received webhook for ticket: {ticket_id}")
|
|
||||||
|
|
||||||
# Process webhook asynchronously
|
|
||||||
asyncio.create_task(self.process_ticket_webhook(webhook_data))
|
|
||||||
|
|
||||||
return {"status": "processed", "ticket_id": ticket_id}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error processing webhook: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@router.get("/configurations")
|
|
||||||
async def get_configurations(context: PluginContext = Depends(self.get_auth_context)):
|
|
||||||
"""Get user's Zammad configurations"""
|
|
||||||
try:
|
|
||||||
configs = await self.get_user_configurations(context.user_id)
|
|
||||||
return {"configurations": configs}
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error fetching configurations: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@router.post("/configurations")
|
|
||||||
async def create_configuration(
|
|
||||||
config_data: Dict[str, Any],
|
|
||||||
context: PluginContext = Depends(self.get_auth_context)
|
|
||||||
):
|
|
||||||
"""Create new Zammad configuration"""
|
|
||||||
try:
|
|
||||||
# Validate configuration against schema
|
|
||||||
schema = await self.get_configuration_schema()
|
|
||||||
is_valid, errors = await self.config.validate_config(config_data, schema)
|
|
||||||
|
|
||||||
if not is_valid:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid configuration: {errors}")
|
|
||||||
|
|
||||||
# Test connection before saving
|
|
||||||
connection_test = await self.test_zammad_connection(config_data)
|
|
||||||
if not connection_test["success"]:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Connection test failed: {connection_test['error']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save configuration to plugin database
|
|
||||||
success = await self._save_configuration_to_db(config_data, context.user_id)
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to save configuration")
|
|
||||||
|
|
||||||
return {"status": "created", "config": {"name": config_data.get("name"), "zammad_url": config_data.get("zammad_url")}}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error creating configuration: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@router.get("/statistics")
|
|
||||||
async def get_statistics(context: PluginContext = Depends(self.get_auth_context)):
|
|
||||||
"""Get plugin usage statistics"""
|
|
||||||
try:
|
|
||||||
stats = await self._get_plugin_statistics(context.user_id)
|
|
||||||
return stats
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error getting statistics: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
@router.get("/tickets/sync")
|
|
||||||
async def sync_tickets_manual(context: PluginContext = Depends(self.get_auth_context)):
|
|
||||||
"""Manually trigger ticket sync"""
|
|
||||||
try:
|
|
||||||
result = await self._sync_user_tickets(context.user_id)
|
|
||||||
return {"status": "completed", "synced_count": result}
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error syncing tickets: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|
||||||
# Plugin-specific methods
|
|
||||||
|
|
||||||
async def get_active_config(self, user_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Get active Zammad configuration for user from database"""
|
|
||||||
try:
|
|
||||||
async with PluginDatabaseSession(self.plugin_id, plugin_db_manager) as db:
|
|
||||||
config = await db.query(ZammadConfiguration).filter(
|
|
||||||
ZammadConfiguration.user_id == user_id,
|
|
||||||
ZammadConfiguration.is_active == True
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if config:
|
|
||||||
# Decrypt API token
|
|
||||||
from app.services.plugin_security import plugin_token_manager
|
|
||||||
api_token = plugin_token_manager.decrypt_plugin_secret(config.api_token_encrypted)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(config.id),
|
|
||||||
"name": config.name,
|
|
||||||
"zammad_url": config.zammad_url,
|
|
||||||
"api_token": api_token,
|
|
||||||
"chatbot_id": config.chatbot_id,
|
|
||||||
"ai_summarization": {
|
|
||||||
"enabled": config.ai_summarization_enabled,
|
|
||||||
"auto_summarize": config.auto_summarize
|
|
||||||
},
|
|
||||||
"sync_settings": {
|
|
||||||
"enabled": config.sync_enabled,
|
|
||||||
"interval_hours": config.sync_interval_hours
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to get active config: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_user_configurations(self, user_id: str) -> List[Dict[str, Any]]:
|
|
||||||
"""Get all configurations for user from database"""
|
|
||||||
try:
|
|
||||||
async with PluginDatabaseSession(self.plugin_id, plugin_db_manager) as db:
|
|
||||||
configs = await db.query(ZammadConfiguration).filter(
|
|
||||||
ZammadConfiguration.user_id == user_id
|
|
||||||
).all()
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for config in configs:
|
|
||||||
result.append({
|
|
||||||
"id": str(config.id),
|
|
||||||
"name": config.name,
|
|
||||||
"zammad_url": config.zammad_url,
|
|
||||||
"chatbot_id": config.chatbot_id,
|
|
||||||
"is_active": config.is_active,
|
|
||||||
"created_at": config.created_at.isoformat(),
|
|
||||||
"updated_at": config.updated_at.isoformat()
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to get user configurations: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def fetch_tickets_from_zammad(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
||||||
"""Fetch tickets from Zammad API"""
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Token {config['api_token']}",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
async with session.get(
|
|
||||||
f"{config['zammad_url']}/api/v1/tickets",
|
|
||||||
headers=headers,
|
|
||||||
timeout=30
|
|
||||||
) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=response.status,
|
|
||||||
detail=f"Zammad API error: {await response.text()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return await response.json()
|
|
||||||
|
|
||||||
async def fetch_ticket_from_zammad(self, config: Dict[str, Any], ticket_id: str) -> Dict[str, Any]:
|
|
||||||
"""Fetch specific ticket from Zammad"""
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Token {config['api_token']}",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
async with session.get(
|
|
||||||
f"{config['zammad_url']}/api/v1/tickets/{ticket_id}",
|
|
||||||
headers=headers,
|
|
||||||
timeout=30
|
|
||||||
) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=response.status,
|
|
||||||
detail=f"Zammad API error: {await response.text()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return await response.json()
|
|
||||||
|
|
||||||
async def summarize_ticket_async(self, config: Dict[str, Any], ticket_id: str, user_id: str):
|
|
||||||
"""Asynchronously summarize a ticket using platform AI"""
|
|
||||||
try:
|
|
||||||
# Get ticket details
|
|
||||||
ticket = await self.fetch_ticket_from_zammad(config, ticket_id)
|
|
||||||
|
|
||||||
# Use platform chatbot API for summarization
|
|
||||||
chatbot_response = await self.api_client.call_chatbot_api(
|
|
||||||
chatbot_id=config["chatbot_id"],
|
|
||||||
message=f"Summarize this support ticket:\n\nTitle: {ticket.get('title', '')}\n\nContent: {ticket.get('body', '')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
summary = chatbot_response.get("response", "")
|
|
||||||
|
|
||||||
# TODO: Store summary in database
|
|
||||||
self.logger.info(f"Generated summary for ticket {ticket_id}: {summary[:100]}...")
|
|
||||||
|
|
||||||
# Update ticket in Zammad with summary
|
|
||||||
await self.update_ticket_summary(config, ticket_id, summary)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error summarizing ticket {ticket_id}: {e}")
|
|
||||||
|
|
||||||
async def update_ticket_summary(self, config: Dict[str, Any], ticket_id: str, summary: str):
|
|
||||||
"""Update ticket with AI summary"""
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Token {config['api_token']}",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
update_data = {
|
|
||||||
"note": f"AI Summary: {summary}"
|
|
||||||
}
|
|
||||||
|
|
||||||
async with session.put(
|
|
||||||
f"{config['zammad_url']}/api/v1/tickets/{ticket_id}",
|
|
||||||
headers=headers,
|
|
||||||
json=update_data,
|
|
||||||
timeout=30
|
|
||||||
) as response:
|
|
||||||
if response.status not in [200, 201]:
|
|
||||||
self.logger.error(f"Failed to update ticket {ticket_id} with summary")
|
|
||||||
|
|
||||||
async def test_zammad_connection(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Test connection to Zammad instance"""
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Token {config['api_token']}",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
async with session.get(
|
|
||||||
f"{config['zammad_url']}/api/v1/users/me",
|
|
||||||
headers=headers,
|
|
||||||
timeout=10
|
|
||||||
) as response:
|
|
||||||
if response.status == 200:
|
|
||||||
user_data = await response.json()
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"user": user_data.get("login", "unknown"),
|
|
||||||
"zammad_version": response.headers.get("X-Zammad-Version", "unknown")
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": f"HTTP {response.status}: {await response.text()}"
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
async def process_ticket_webhook(self, webhook_data: Dict[str, Any]):
|
|
||||||
"""Process ticket webhook asynchronously"""
|
|
||||||
try:
|
|
||||||
ticket_data = webhook_data.get("ticket", {})
|
|
||||||
ticket_id = ticket_data.get("id")
|
|
||||||
|
|
||||||
self.logger.info(f"Processing webhook for ticket {ticket_id}")
|
|
||||||
|
|
||||||
# TODO: Get configuration and auto-summarize if enabled
|
|
||||||
# This would require looking up the configuration associated with the webhook
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error processing webhook: {e}")
|
|
||||||
|
|
||||||
# Cron job functions
|
|
||||||
|
|
||||||
async def sync_tickets_from_zammad(self) -> bool:
|
|
||||||
"""Sync tickets from Zammad (cron job)"""
|
|
||||||
try:
|
|
||||||
self.logger.info("Starting ticket sync from Zammad")
|
|
||||||
|
|
||||||
# TODO: Get all active configurations and sync tickets
|
|
||||||
# This would iterate through all user configurations
|
|
||||||
|
|
||||||
self.logger.info("Ticket sync completed successfully")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Ticket sync failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def cleanup_old_summaries(self) -> bool:
|
|
||||||
"""Clean up old AI summaries (cron job)"""
|
|
||||||
try:
|
|
||||||
self.logger.info("Starting cleanup of old summaries")
|
|
||||||
|
|
||||||
# TODO: Clean up summaries older than retention period
|
|
||||||
|
|
||||||
self.logger.info("Summary cleanup completed")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Summary cleanup failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def check_zammad_connection(self) -> bool:
|
|
||||||
"""Check Zammad connectivity (cron job)"""
|
|
||||||
try:
|
|
||||||
# TODO: Test all configured Zammad instances
|
|
||||||
self.logger.info("Zammad connectivity check completed")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Connectivity check failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def generate_weekly_reports(self) -> bool:
|
|
||||||
"""Generate weekly reports (cron job)"""
|
|
||||||
try:
|
|
||||||
self.logger.info("Generating weekly reports")
|
|
||||||
|
|
||||||
# TODO: Generate and send weekly ticket reports
|
|
||||||
|
|
||||||
self.logger.info("Weekly reports generated successfully")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Report generation failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Enhanced database integration methods
|
|
||||||
|
|
||||||
async def _save_configuration_to_db(self, config_data: Dict[str, Any], user_id: str) -> bool:
|
|
||||||
"""Save Zammad configuration to plugin database"""
|
|
||||||
try:
|
|
||||||
from app.services.plugin_security import plugin_token_manager
|
|
||||||
|
|
||||||
# Encrypt API token
|
|
||||||
encrypted_token = plugin_token_manager.encrypt_plugin_secret(config_data["api_token"])
|
|
||||||
|
|
||||||
async with PluginDatabaseSession(self.plugin_id, plugin_db_manager) as db:
|
|
||||||
# Deactivate existing configurations if this is set as active
|
|
||||||
if config_data.get("is_active", True):
|
|
||||||
await db.query(ZammadConfiguration).filter(
|
|
||||||
ZammadConfiguration.user_id == user_id,
|
|
||||||
ZammadConfiguration.is_active == True
|
|
||||||
).update({"is_active": False})
|
|
||||||
|
|
||||||
# Create new configuration
|
|
||||||
config = ZammadConfiguration(
|
|
||||||
user_id=user_id,
|
|
||||||
name=config_data["name"],
|
|
||||||
zammad_url=config_data["zammad_url"],
|
|
||||||
api_token_encrypted=encrypted_token,
|
|
||||||
chatbot_id=config_data["chatbot_id"],
|
|
||||||
is_active=config_data.get("is_active", True),
|
|
||||||
ai_summarization_enabled=config_data.get("ai_summarization", {}).get("enabled", True),
|
|
||||||
auto_summarize=config_data.get("ai_summarization", {}).get("auto_summarize", True),
|
|
||||||
sync_enabled=config_data.get("sync_settings", {}).get("enabled", True),
|
|
||||||
sync_interval_hours=config_data.get("sync_settings", {}).get("interval_hours", 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(config)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
self.logger.info(f"Saved Zammad configuration for user {user_id}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to save configuration: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _get_plugin_statistics(self, user_id: str) -> Dict[str, Any]:
|
|
||||||
"""Get plugin usage statistics"""
|
|
||||||
try:
|
|
||||||
async with PluginDatabaseSession(self.plugin_id, plugin_db_manager) as db:
|
|
||||||
# Get configuration count
|
|
||||||
config_count = await db.query(ZammadConfiguration).filter(
|
|
||||||
ZammadConfiguration.user_id == user_id
|
|
||||||
).count()
|
|
||||||
|
|
||||||
# Get ticket count
|
|
||||||
ticket_count = await db.query(ZammadTicket).join(ZammadConfiguration).filter(
|
|
||||||
ZammadConfiguration.user_id == user_id
|
|
||||||
).count()
|
|
||||||
|
|
||||||
# Get tickets with AI summaries
|
|
||||||
summarized_count = await db.query(ZammadTicket).join(ZammadConfiguration).filter(
|
|
||||||
ZammadConfiguration.user_id == user_id,
|
|
||||||
ZammadTicket.ai_summary.isnot(None)
|
|
||||||
).count()
|
|
||||||
|
|
||||||
# Get recent activity (last 7 days)
|
|
||||||
from datetime import timedelta
|
|
||||||
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
|
|
||||||
recent_tickets = await db.query(ZammadTicket).join(ZammadConfiguration).filter(
|
|
||||||
ZammadConfiguration.user_id == user_id,
|
|
||||||
ZammadTicket.last_synced >= week_ago
|
|
||||||
).count()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"configurations": config_count,
|
|
||||||
"total_tickets": ticket_count,
|
|
||||||
"tickets_with_summaries": summarized_count,
|
|
||||||
"recent_tickets": recent_tickets,
|
|
||||||
"summary_rate": round((summarized_count / max(ticket_count, 1)) * 100, 1),
|
|
||||||
"last_sync": datetime.now(timezone.utc).isoformat()
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to get statistics: {e}")
|
|
||||||
return {
|
|
||||||
"error": str(e),
|
|
||||||
"configurations": 0,
|
|
||||||
"total_tickets": 0,
|
|
||||||
"tickets_with_summaries": 0,
|
|
||||||
"recent_tickets": 0,
|
|
||||||
"summary_rate": 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _sync_user_tickets(self, user_id: str) -> int:
|
|
||||||
"""Sync tickets for a specific user"""
|
|
||||||
try:
|
|
||||||
config = await self.get_active_config(user_id)
|
|
||||||
if not config:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Fetch tickets from Zammad
|
|
||||||
tickets = await self.fetch_tickets_from_zammad(config)
|
|
||||||
synced_count = 0
|
|
||||||
|
|
||||||
async with PluginDatabaseSession(self.plugin_id, plugin_db_manager) as db:
|
|
||||||
config_record = await db.query(ZammadConfiguration).filter(
|
|
||||||
ZammadConfiguration.id == config["id"]
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not config_record:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
for ticket_data in tickets:
|
|
||||||
# Check if ticket already exists
|
|
||||||
existing_ticket = await db.query(ZammadTicket).filter(
|
|
||||||
ZammadTicket.zammad_ticket_id == str(ticket_data["id"]),
|
|
||||||
ZammadTicket.configuration_id == config_record.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_ticket:
|
|
||||||
# Update existing ticket
|
|
||||||
existing_ticket.title = ticket_data.get("title", "")
|
|
||||||
existing_ticket.body = ticket_data.get("body", "")
|
|
||||||
existing_ticket.status = ticket_data.get("state", "")
|
|
||||||
existing_ticket.priority = ticket_data.get("priority", "")
|
|
||||||
existing_ticket.last_synced = datetime.now(timezone.utc)
|
|
||||||
existing_ticket.updated_at = datetime.now(timezone.utc)
|
|
||||||
else:
|
|
||||||
# Create new ticket
|
|
||||||
new_ticket = ZammadTicket(
|
|
||||||
zammad_ticket_id=str(ticket_data["id"]),
|
|
||||||
configuration_id=config_record.id,
|
|
||||||
title=ticket_data.get("title", ""),
|
|
||||||
body=ticket_data.get("body", ""),
|
|
||||||
status=ticket_data.get("state", ""),
|
|
||||||
priority=ticket_data.get("priority", ""),
|
|
||||||
customer_id=str(ticket_data.get("customer_id", "")),
|
|
||||||
group_id=str(ticket_data.get("group_id", "")),
|
|
||||||
last_synced=datetime.now(timezone.utc)
|
|
||||||
)
|
|
||||||
db.add(new_ticket)
|
|
||||||
synced_count += 1
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
self.logger.info(f"Synced {synced_count} new tickets for user {user_id}")
|
|
||||||
return synced_count
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to sync tickets for user {user_id}: {e}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
async def _store_ticket_summary(self, ticket_id: str, summary: str, config_id: str):
|
|
||||||
"""Store AI-generated summary in database"""
|
|
||||||
try:
|
|
||||||
async with PluginDatabaseSession(self.plugin_id, plugin_db_manager) as db:
|
|
||||||
ticket = await db.query(ZammadTicket).filter(
|
|
||||||
ZammadTicket.zammad_ticket_id == ticket_id,
|
|
||||||
ZammadTicket.configuration_id == config_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if ticket:
|
|
||||||
ticket.ai_summary = summary
|
|
||||||
ticket.updated_at = datetime.now(timezone.utc)
|
|
||||||
await db.commit()
|
|
||||||
self.logger.info(f"Stored AI summary for ticket {ticket_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to store summary for ticket {ticket_id}: {e}")
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
apiVersion: "v1"
|
|
||||||
kind: "Plugin"
|
|
||||||
metadata:
|
|
||||||
name: "zammad"
|
|
||||||
version: "1.0.0"
|
|
||||||
description: "Zammad helpdesk integration with AI summarization and ticket management"
|
|
||||||
author: "Enclava Team"
|
|
||||||
license: "MIT"
|
|
||||||
homepage: "https://github.com/enclava/plugins/zammad"
|
|
||||||
repository: "https://github.com/enclava/plugins/zammad"
|
|
||||||
tags:
|
|
||||||
- "helpdesk"
|
|
||||||
- "ticket-management"
|
|
||||||
- "ai-summarization"
|
|
||||||
- "integration"
|
|
||||||
|
|
||||||
spec:
|
|
||||||
runtime:
|
|
||||||
python_version: "3.11"
|
|
||||||
dependencies:
|
|
||||||
- "aiohttp>=3.8.0"
|
|
||||||
- "pydantic>=2.0.0"
|
|
||||||
- "httpx>=0.24.0"
|
|
||||||
- "python-dateutil>=2.8.0"
|
|
||||||
environment_variables:
|
|
||||||
ZAMMAD_TIMEOUT: "30"
|
|
||||||
ZAMMAD_MAX_RETRIES: "3"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
platform_apis:
|
|
||||||
- "chatbot:invoke"
|
|
||||||
- "rag:query"
|
|
||||||
- "llm:completion"
|
|
||||||
- "llm:embeddings"
|
|
||||||
plugin_scopes:
|
|
||||||
- "tickets:read"
|
|
||||||
- "tickets:write"
|
|
||||||
- "tickets:summarize"
|
|
||||||
- "webhooks:receive"
|
|
||||||
- "config:manage"
|
|
||||||
- "sync:execute"
|
|
||||||
external_domains:
|
|
||||||
- "*.zammad.com"
|
|
||||||
- "*.zammad.org"
|
|
||||||
- "api.zammad.org"
|
|
||||||
|
|
||||||
database:
|
|
||||||
schema: "plugin_zammad"
|
|
||||||
migrations_path: "./migrations"
|
|
||||||
auto_migrate: true
|
|
||||||
|
|
||||||
api_endpoints:
|
|
||||||
- path: "/tickets"
|
|
||||||
methods: ["GET", "POST"]
|
|
||||||
description: "List and create Zammad tickets"
|
|
||||||
auth_required: true
|
|
||||||
|
|
||||||
- path: "/tickets/{ticket_id}"
|
|
||||||
methods: ["GET", "PUT", "DELETE"]
|
|
||||||
description: "Get, update, or delete specific ticket"
|
|
||||||
auth_required: true
|
|
||||||
|
|
||||||
- path: "/tickets/{ticket_id}/summarize"
|
|
||||||
methods: ["POST"]
|
|
||||||
description: "Generate AI summary for ticket"
|
|
||||||
auth_required: true
|
|
||||||
|
|
||||||
- path: "/tickets/{ticket_id}/articles"
|
|
||||||
methods: ["GET", "POST"]
|
|
||||||
description: "Get ticket articles or add new article"
|
|
||||||
auth_required: true
|
|
||||||
|
|
||||||
- path: "/webhooks/ticket-created"
|
|
||||||
methods: ["POST"]
|
|
||||||
description: "Handle Zammad webhook for new tickets"
|
|
||||||
auth_required: false
|
|
||||||
|
|
||||||
- path: "/webhooks/ticket-updated"
|
|
||||||
methods: ["POST"]
|
|
||||||
description: "Handle Zammad webhook for updated tickets"
|
|
||||||
auth_required: false
|
|
||||||
|
|
||||||
- path: "/configurations"
|
|
||||||
methods: ["GET", "POST", "PUT", "DELETE"]
|
|
||||||
description: "Manage Zammad configurations"
|
|
||||||
auth_required: true
|
|
||||||
|
|
||||||
- path: "/configurations/{config_id}/test"
|
|
||||||
methods: ["POST"]
|
|
||||||
description: "Test Zammad configuration connection"
|
|
||||||
auth_required: true
|
|
||||||
|
|
||||||
- path: "/statistics"
|
|
||||||
methods: ["GET"]
|
|
||||||
description: "Get plugin usage statistics"
|
|
||||||
auth_required: true
|
|
||||||
|
|
||||||
- path: "/health"
|
|
||||||
methods: ["GET"]
|
|
||||||
description: "Plugin health check"
|
|
||||||
auth_required: false
|
|
||||||
|
|
||||||
cron_jobs:
|
|
||||||
- name: "sync_tickets"
|
|
||||||
schedule: "0 */2 * * *"
|
|
||||||
function: "sync_tickets_from_zammad"
|
|
||||||
description: "Sync tickets from Zammad every 2 hours"
|
|
||||||
enabled: true
|
|
||||||
timeout_seconds: 600
|
|
||||||
max_retries: 3
|
|
||||||
|
|
||||||
- name: "cleanup_summaries"
|
|
||||||
schedule: "0 3 * * 0"
|
|
||||||
function: "cleanup_old_summaries"
|
|
||||||
description: "Clean up old AI summaries weekly"
|
|
||||||
enabled: true
|
|
||||||
timeout_seconds: 300
|
|
||||||
max_retries: 1
|
|
||||||
|
|
||||||
- name: "health_check"
|
|
||||||
schedule: "*/15 * * * *"
|
|
||||||
function: "check_zammad_connection"
|
|
||||||
description: "Check Zammad API connectivity every 15 minutes"
|
|
||||||
enabled: true
|
|
||||||
timeout_seconds: 60
|
|
||||||
max_retries: 2
|
|
||||||
|
|
||||||
- name: "generate_reports"
|
|
||||||
schedule: "0 9 * * 1"
|
|
||||||
function: "generate_weekly_reports"
|
|
||||||
description: "Generate weekly ticket reports"
|
|
||||||
enabled: false
|
|
||||||
timeout_seconds: 900
|
|
||||||
max_retries: 2
|
|
||||||
|
|
||||||
ui_config:
|
|
||||||
configuration_schema: "./config_schema.json"
|
|
||||||
ui_components: "./ui/components"
|
|
||||||
pages:
|
|
||||||
- name: "dashboard"
|
|
||||||
path: "/plugins/zammad"
|
|
||||||
component: "ZammadDashboard"
|
|
||||||
|
|
||||||
- name: "settings"
|
|
||||||
path: "/plugins/zammad/settings"
|
|
||||||
component: "ZammadSettings"
|
|
||||||
|
|
||||||
- name: "tickets"
|
|
||||||
path: "/plugins/zammad/tickets"
|
|
||||||
component: "ZammadTicketList"
|
|
||||||
|
|
||||||
- name: "analytics"
|
|
||||||
path: "/plugins/zammad/analytics"
|
|
||||||
component: "ZammadAnalytics"
|
|
||||||
|
|
||||||
external_services:
|
|
||||||
allowed_domains:
|
|
||||||
- "*.zammad.com"
|
|
||||||
- "*.zammad.org"
|
|
||||||
- "api.zammad.org"
|
|
||||||
- "help.zammad.com"
|
|
||||||
|
|
||||||
webhooks:
|
|
||||||
- endpoint: "/webhooks/ticket-created"
|
|
||||||
security: "signature_validation"
|
|
||||||
|
|
||||||
- endpoint: "/webhooks/ticket-updated"
|
|
||||||
security: "signature_validation"
|
|
||||||
|
|
||||||
rate_limits:
|
|
||||||
"*.zammad.com": 100
|
|
||||||
"*.zammad.org": 100
|
|
||||||
"api.zammad.org": 200
|
|
||||||
|
|
||||||
config_schema:
|
|
||||||
type: "object"
|
|
||||||
required:
|
|
||||||
- "zammad_url"
|
|
||||||
- "api_token"
|
|
||||||
- "chatbot_id"
|
|
||||||
properties:
|
|
||||||
zammad_url:
|
|
||||||
type: "string"
|
|
||||||
format: "uri"
|
|
||||||
title: "Zammad URL"
|
|
||||||
description: "The base URL of your Zammad instance"
|
|
||||||
examples:
|
|
||||||
- "https://company.zammad.com"
|
|
||||||
- "https://support.example.com"
|
|
||||||
|
|
||||||
api_token:
|
|
||||||
type: "string"
|
|
||||||
title: "API Token"
|
|
||||||
description: "Zammad API token with ticket read/write permissions"
|
|
||||||
minLength: 20
|
|
||||||
format: "password"
|
|
||||||
|
|
||||||
chatbot_id:
|
|
||||||
type: "string"
|
|
||||||
title: "Chatbot ID"
|
|
||||||
description: "Platform chatbot ID for AI summarization"
|
|
||||||
examples:
|
|
||||||
- "zammad-summarizer"
|
|
||||||
- "ticket-assistant"
|
|
||||||
|
|
||||||
ai_summarization:
|
|
||||||
type: "object"
|
|
||||||
title: "AI Summarization Settings"
|
|
||||||
properties:
|
|
||||||
enabled:
|
|
||||||
type: "boolean"
|
|
||||||
title: "Enable AI Summarization"
|
|
||||||
description: "Automatically summarize tickets using AI"
|
|
||||||
default: true
|
|
||||||
|
|
||||||
model:
|
|
||||||
type: "string"
|
|
||||||
title: "AI Model"
|
|
||||||
description: "LLM model to use for summarization"
|
|
||||||
default: "gpt-3.5-turbo"
|
|
||||||
|
|
||||||
max_tokens:
|
|
||||||
type: "integer"
|
|
||||||
title: "Max Summary Tokens"
|
|
||||||
description: "Maximum tokens for AI summary"
|
|
||||||
minimum: 50
|
|
||||||
maximum: 500
|
|
||||||
default: 150
|
|
||||||
|
|
||||||
draft_settings:
|
|
||||||
type: "object"
|
|
||||||
title: "AI Draft Settings"
|
|
||||||
properties:
|
|
||||||
enabled:
|
|
||||||
type: "boolean"
|
|
||||||
title: "Enable AI Drafts"
|
|
||||||
description: "Generate AI draft responses for tickets"
|
|
||||||
default: false
|
|
||||||
|
|
||||||
model:
|
|
||||||
type: "string"
|
|
||||||
title: "Draft Model"
|
|
||||||
description: "LLM model to use for draft generation"
|
|
||||||
default: "gpt-3.5-turbo"
|
|
||||||
|
|
||||||
max_tokens:
|
|
||||||
type: "integer"
|
|
||||||
title: "Max Draft Tokens"
|
|
||||||
description: "Maximum tokens for AI draft responses"
|
|
||||||
minimum: 100
|
|
||||||
maximum: 1000
|
|
||||||
default: 300
|
|
||||||
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"""Alembic environment for Zammad plugin"""
|
|
||||||
from logging.config import fileConfig
|
|
||||||
from sqlalchemy import engine_from_config
|
|
||||||
from sqlalchemy import pool
|
|
||||||
from alembic import context
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
|
||||||
# access to the values within the .ini file in use.
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
|
||||||
# This line sets up loggers basically.
|
|
||||||
if config.config_file_name is not None:
|
|
||||||
fileConfig(config.config_file_name)
|
|
||||||
|
|
||||||
# add your model's MetaData object here
|
|
||||||
# for 'autogenerate' support
|
|
||||||
from main import Base
|
|
||||||
target_metadata = Base.metadata
|
|
||||||
|
|
||||||
# other values from the config, defined by the needs of env.py,
|
|
||||||
# can be acquired:
|
|
||||||
# my_important_option = config.get_main_option("my_important_option")
|
|
||||||
# ... etc.
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
|
||||||
"""Run migrations in 'offline' mode.
|
|
||||||
|
|
||||||
This configures the context with just a URL
|
|
||||||
and not an Engine, though an Engine is acceptable
|
|
||||||
here as well. By skipping the Engine creation
|
|
||||||
we don't even need a DBAPI to be available.
|
|
||||||
|
|
||||||
Calls to context.execute() here emit the given string to the
|
|
||||||
script output.
|
|
||||||
|
|
||||||
"""
|
|
||||||
# Get database URL from environment variable
|
|
||||||
url = os.getenv("DATABASE_URL")
|
|
||||||
context.configure(
|
|
||||||
url=url,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
literal_binds=True,
|
|
||||||
dialect_opts={"paramstyle": "named"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
|
||||||
"""Run migrations in 'online' mode.
|
|
||||||
|
|
||||||
In this scenario we need to create an Engine
|
|
||||||
and associate a connection with the context.
|
|
||||||
|
|
||||||
"""
|
|
||||||
# Get database URL from environment variable
|
|
||||||
database_url = os.getenv("DATABASE_URL")
|
|
||||||
|
|
||||||
configuration = config.get_section(config.config_ini_section)
|
|
||||||
configuration["sqlalchemy.url"] = database_url
|
|
||||||
|
|
||||||
connectable = engine_from_config(
|
|
||||||
configuration,
|
|
||||||
prefix="sqlalchemy.",
|
|
||||||
poolclass=pool.NullPool,
|
|
||||||
)
|
|
||||||
|
|
||||||
with connectable.connect() as connection:
|
|
||||||
context.configure(
|
|
||||||
connection=connection, target_metadata=target_metadata
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""${message}
|
|
||||||
|
|
||||||
Revision ID: ${up_revision}
|
|
||||||
Revises: ${down_revision | comma,n}
|
|
||||||
Create Date: ${create_date}
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
${imports if imports else ""}
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = ${repr(up_revision)}
|
|
||||||
down_revision = ${repr(down_revision)}
|
|
||||||
branch_labels = ${repr(branch_labels)}
|
|
||||||
depends_on = ${repr(depends_on)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
${upgrades if upgrades else "pass"}
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
${downgrades if downgrades else "pass"}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"""Initial Zammad plugin schema
|
|
||||||
|
|
||||||
Revision ID: 001
|
|
||||||
Revises:
|
|
||||||
Create Date: 2024-12-22 12:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '001'
|
|
||||||
down_revision = None
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Create initial Zammad plugin schema"""
|
|
||||||
|
|
||||||
# Create zammad_configurations table
|
|
||||||
op.create_table(
|
|
||||||
'zammad_configurations',
|
|
||||||
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
|
||||||
sa.Column('user_id', sa.String(255), nullable=False),
|
|
||||||
sa.Column('name', sa.String(100), nullable=False),
|
|
||||||
sa.Column('zammad_url', sa.String(500), nullable=False),
|
|
||||||
sa.Column('api_token_encrypted', sa.Text(), nullable=False),
|
|
||||||
sa.Column('chatbot_id', sa.String(100), nullable=False),
|
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')),
|
|
||||||
sa.Column('ai_summarization_enabled', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')),
|
|
||||||
sa.Column('auto_summarize', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')),
|
|
||||||
sa.Column('sync_enabled', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')),
|
|
||||||
sa.Column('sync_interval_hours', sa.Integer(), nullable=False, server_default=sa.text('2')),
|
|
||||||
sa.Column('created_at', sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
|
||||||
sa.Column('updated_at', sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create zammad_tickets table
|
|
||||||
op.create_table(
|
|
||||||
'zammad_tickets',
|
|
||||||
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
|
||||||
sa.Column('zammad_ticket_id', sa.String(50), nullable=False),
|
|
||||||
sa.Column('configuration_id', UUID(as_uuid=True), nullable=True),
|
|
||||||
sa.Column('title', sa.String(500), nullable=False),
|
|
||||||
sa.Column('body', sa.Text(), nullable=True),
|
|
||||||
sa.Column('status', sa.String(50), nullable=True),
|
|
||||||
sa.Column('priority', sa.String(50), nullable=True),
|
|
||||||
sa.Column('customer_id', sa.String(50), nullable=True),
|
|
||||||
sa.Column('group_id', sa.String(50), nullable=True),
|
|
||||||
sa.Column('ai_summary', sa.Text(), nullable=True),
|
|
||||||
sa.Column('last_synced', sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
|
||||||
sa.Column('created_at', sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
|
||||||
sa.Column('updated_at', sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
|
||||||
sa.ForeignKeyConstraint(['configuration_id'], ['zammad_configurations.id'], ondelete='CASCADE'),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for performance
|
|
||||||
op.create_index('idx_zammad_configurations_user_id', 'zammad_configurations', ['user_id'])
|
|
||||||
op.create_index('idx_zammad_configurations_user_active', 'zammad_configurations', ['user_id', 'is_active'])
|
|
||||||
|
|
||||||
op.create_index('idx_zammad_tickets_zammad_id', 'zammad_tickets', ['zammad_ticket_id'])
|
|
||||||
op.create_index('idx_zammad_tickets_config_id', 'zammad_tickets', ['configuration_id'])
|
|
||||||
op.create_index('idx_zammad_tickets_status', 'zammad_tickets', ['status'])
|
|
||||||
op.create_index('idx_zammad_tickets_last_synced', 'zammad_tickets', ['last_synced'])
|
|
||||||
|
|
||||||
# Create updated_at trigger function if it doesn't exist
|
|
||||||
op.execute("""
|
|
||||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE 'plpgsql';
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Create triggers to automatically update updated_at columns
|
|
||||||
op.execute("""
|
|
||||||
CREATE TRIGGER update_zammad_configurations_updated_at
|
|
||||||
BEFORE UPDATE ON zammad_configurations
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
|
||||||
""")
|
|
||||||
|
|
||||||
op.execute("""
|
|
||||||
CREATE TRIGGER update_zammad_tickets_updated_at
|
|
||||||
BEFORE UPDATE ON zammad_tickets
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Drop Zammad plugin schema"""
|
|
||||||
|
|
||||||
# Drop triggers first
|
|
||||||
op.execute("DROP TRIGGER IF EXISTS update_zammad_tickets_updated_at ON zammad_tickets;")
|
|
||||||
op.execute("DROP TRIGGER IF EXISTS update_zammad_configurations_updated_at ON zammad_configurations;")
|
|
||||||
|
|
||||||
# Drop indexes
|
|
||||||
op.drop_index('idx_zammad_tickets_last_synced')
|
|
||||||
op.drop_index('idx_zammad_tickets_status')
|
|
||||||
op.drop_index('idx_zammad_tickets_config_id')
|
|
||||||
op.drop_index('idx_zammad_tickets_zammad_id')
|
|
||||||
op.drop_index('idx_zammad_configurations_user_active')
|
|
||||||
op.drop_index('idx_zammad_configurations_user_id')
|
|
||||||
|
|
||||||
# Drop tables (tickets first due to foreign key)
|
|
||||||
op.drop_table('zammad_tickets')
|
|
||||||
op.drop_table('zammad_configurations')
|
|
||||||
|
|
||||||
# Note: We don't drop the update_updated_at_column function as it might be used by other tables
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
aiohttp>=3.8.0
|
|
||||||
pydantic>=2.0.0
|
|
||||||
httpx>=0.24.0
|
|
||||||
python-dateutil>=2.8.0
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
/**
|
|
||||||
* Zammad Plugin Dashboard Component
|
|
||||||
* Main dashboard for Zammad plugin showing tickets, statistics, and quick actions
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Grid,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Typography,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
Alert,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
IconButton,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
LinearProgress,
|
|
||||||
Tooltip
|
|
||||||
} from '@mui/material';
|
|
||||||
import {
|
|
||||||
Refresh as RefreshIcon,
|
|
||||||
Sync as SyncIcon,
|
|
||||||
Analytics as AnalyticsIcon,
|
|
||||||
Assignment as TicketIcon,
|
|
||||||
AutoAwesome as AIIcon,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
OpenInNew as OpenIcon
|
|
||||||
} from '@mui/icons-material';
|
|
||||||
|
|
||||||
interface ZammadTicket {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
priority: string;
|
|
||||||
customer_id: string;
|
|
||||||
created_at: string;
|
|
||||||
ai_summary?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ZammadStats {
|
|
||||||
configurations: number;
|
|
||||||
total_tickets: number;
|
|
||||||
tickets_with_summaries: number;
|
|
||||||
recent_tickets: number;
|
|
||||||
summary_rate: number;
|
|
||||||
last_sync: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ZammadDashboard: React.FC = () => {
|
|
||||||
const [tickets, setTickets] = useState<ZammadTicket[]>([]);
|
|
||||||
const [stats, setStats] = useState<ZammadStats | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [selectedTicket, setSelectedTicket] = useState<ZammadTicket | null>(null);
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [syncing, setSyncing] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDashboardData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadDashboardData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Load statistics
|
|
||||||
const statsResponse = await fetch('/api/v1/plugins/zammad/statistics');
|
|
||||||
if (statsResponse.ok) {
|
|
||||||
const statsData = await statsResponse.json();
|
|
||||||
setStats(statsData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load recent tickets
|
|
||||||
const ticketsResponse = await fetch('/api/v1/plugins/zammad/tickets?limit=10');
|
|
||||||
if (ticketsResponse.ok) {
|
|
||||||
const ticketsData = await ticketsResponse.json();
|
|
||||||
setTickets(ticketsData.tickets || []);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to load dashboard data');
|
|
||||||
console.error('Dashboard load error:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSyncTickets = async () => {
|
|
||||||
setSyncing(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/plugins/zammad/tickets/sync', {
|
|
||||||
method: 'GET'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const result = await response.json();
|
|
||||||
// Reload dashboard data after sync
|
|
||||||
await loadDashboardData();
|
|
||||||
// Show success message with sync count
|
|
||||||
console.log(`Synced ${result.synced_count} tickets`);
|
|
||||||
} else {
|
|
||||||
throw new Error('Sync failed');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to sync tickets');
|
|
||||||
} finally {
|
|
||||||
setSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTicketClick = (ticket: ZammadTicket) => {
|
|
||||||
setSelectedTicket(ticket);
|
|
||||||
setDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSummarizeTicket = async (ticketId: string) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/plugins/zammad/tickets/${ticketId}/summarize`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Show success message
|
|
||||||
console.log('Summarization started');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Summarization failed:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status.toLowerCase()) {
|
|
||||||
case 'open': return 'error';
|
|
||||||
case 'pending': return 'warning';
|
|
||||||
case 'closed': return 'success';
|
|
||||||
default: return 'default';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityColor = (priority: string) => {
|
|
||||||
switch (priority) {
|
|
||||||
case '3 high': return 'error';
|
|
||||||
case '2 normal': return 'warning';
|
|
||||||
case '1 low': return 'success';
|
|
||||||
default: return 'default';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{/* Header */}
|
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
|
||||||
<Typography variant="h4" component="h1">
|
|
||||||
Zammad Dashboard
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box display="flex" gap={2}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<SyncIcon />}
|
|
||||||
onClick={handleSyncTickets}
|
|
||||||
disabled={syncing}
|
|
||||||
>
|
|
||||||
{syncing ? 'Syncing...' : 'Sync Tickets'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
onClick={loadDashboardData}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert severity="error" sx={{ mb: 3 }}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && <LinearProgress sx={{ mb: 3 }} />}
|
|
||||||
|
|
||||||
{/* Statistics Cards */}
|
|
||||||
{stats && (
|
|
||||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<Box display="flex" alignItems="center" gap={2}>
|
|
||||||
<TicketIcon color="primary" />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6">{stats.total_tickets}</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Total Tickets
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<Box display="flex" alignItems="center" gap={2}>
|
|
||||||
<AIIcon color="secondary" />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6">{stats.tickets_with_summaries}</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
AI Summaries
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<Box display="flex" alignItems="center" gap={2}>
|
|
||||||
<AnalyticsIcon color="success" />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6">{stats.summary_rate}%</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Summary Rate
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<Box display="flex" alignItems="center" gap={2}>
|
|
||||||
<RefreshIcon color="info" />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6">{stats.recent_tickets}</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Recent (7 days)
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent Tickets Table */}
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
|
||||||
<Typography variant="h6">Recent Tickets</Typography>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
endIcon={<OpenIcon />}
|
|
||||||
onClick={() => window.location.hash = '#/plugins/zammad/tickets'}
|
|
||||||
>
|
|
||||||
View All
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{tickets.length === 0 ? (
|
|
||||||
<Typography variant="body2" color="text.secondary" textAlign="center" py={4}>
|
|
||||||
No tickets found. Try syncing with Zammad.
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Title</TableCell>
|
|
||||||
<TableCell>Status</TableCell>
|
|
||||||
<TableCell>Priority</TableCell>
|
|
||||||
<TableCell>AI Summary</TableCell>
|
|
||||||
<TableCell>Actions</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{tickets.map((ticket) => (
|
|
||||||
<TableRow key={ticket.id} hover onClick={() => handleTicketClick(ticket)}>
|
|
||||||
<TableCell>
|
|
||||||
<Typography variant="body2" noWrap sx={{ maxWidth: 200 }}>
|
|
||||||
{ticket.title}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip
|
|
||||||
label={ticket.status}
|
|
||||||
color={getStatusColor(ticket.status) as any}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip
|
|
||||||
label={ticket.priority}
|
|
||||||
color={getPriorityColor(ticket.priority) as any}
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{ticket.ai_summary ? (
|
|
||||||
<Chip label="Available" color="success" size="small" />
|
|
||||||
) : (
|
|
||||||
<Chip label="None" color="default" size="small" />
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Tooltip title="Generate AI Summary">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleSummarizeTicket(ticket.id);
|
|
||||||
}}
|
|
||||||
disabled={!!ticket.ai_summary}
|
|
||||||
>
|
|
||||||
<AIIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Ticket Detail Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onClose={() => setDialogOpen(false)}
|
|
||||||
maxWidth="md"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<DialogTitle>
|
|
||||||
Ticket Details
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
{selectedTicket && (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
{selectedTicket.title}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box display="flex" gap={2} mb={2}>
|
|
||||||
<Chip label={selectedTicket.status} color={getStatusColor(selectedTicket.status) as any} />
|
|
||||||
<Chip label={selectedTicket.priority} color={getPriorityColor(selectedTicket.priority) as any} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" paragraph>
|
|
||||||
Customer: {selectedTicket.customer_id}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" paragraph>
|
|
||||||
Created: {new Date(selectedTicket.created_at).toLocaleString()}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{selectedTicket.ai_summary && (
|
|
||||||
<Box mt={2}>
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
AI Summary
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{
|
|
||||||
backgroundColor: 'grey.100',
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 1
|
|
||||||
}}>
|
|
||||||
{selectedTicket.ai_summary}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setDialogOpen(false)}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
{selectedTicket && !selectedTicket.ai_summary && (
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<AIIcon />}
|
|
||||||
onClick={() => {
|
|
||||||
handleSummarizeTicket(selectedTicket.id);
|
|
||||||
setDialogOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Generate Summary
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,512 +0,0 @@
|
|||||||
/**
|
|
||||||
* Zammad Plugin Settings Component
|
|
||||||
* Configuration interface for Zammad plugin
|
|
||||||
*/
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Typography,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
Switch,
|
|
||||||
FormControlLabel,
|
|
||||||
FormGroup,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Alert,
|
|
||||||
Divider,
|
|
||||||
Accordion,
|
|
||||||
AccordionSummary,
|
|
||||||
AccordionDetails,
|
|
||||||
Chip,
|
|
||||||
LinearProgress
|
|
||||||
} from '@mui/material';
|
|
||||||
import {
|
|
||||||
ExpandMore as ExpandMoreIcon,
|
|
||||||
Save as SaveIcon,
|
|
||||||
TestTube as TestIcon,
|
|
||||||
Security as SecurityIcon,
|
|
||||||
Sync as SyncIcon,
|
|
||||||
Smart as AIIcon
|
|
||||||
} from '@mui/icons-material';
|
|
||||||
|
|
||||||
interface ZammadConfig {
|
|
||||||
name: string;
|
|
||||||
zammad_url: string;
|
|
||||||
api_token: string;
|
|
||||||
chatbot_id: string;
|
|
||||||
ai_summarization: {
|
|
||||||
enabled: boolean;
|
|
||||||
model: string;
|
|
||||||
max_tokens: number;
|
|
||||||
auto_summarize: boolean;
|
|
||||||
};
|
|
||||||
sync_settings: {
|
|
||||||
enabled: boolean;
|
|
||||||
interval_hours: number;
|
|
||||||
sync_articles: boolean;
|
|
||||||
max_tickets_per_sync: number;
|
|
||||||
};
|
|
||||||
webhook_settings: {
|
|
||||||
secret: string;
|
|
||||||
enabled_events: string[];
|
|
||||||
};
|
|
||||||
notification_settings: {
|
|
||||||
email_notifications: boolean;
|
|
||||||
slack_webhook_url: string;
|
|
||||||
notification_events: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultConfig: ZammadConfig = {
|
|
||||||
name: '',
|
|
||||||
zammad_url: '',
|
|
||||||
api_token: '',
|
|
||||||
chatbot_id: '',
|
|
||||||
ai_summarization: {
|
|
||||||
enabled: true,
|
|
||||||
model: 'gpt-3.5-turbo',
|
|
||||||
max_tokens: 150,
|
|
||||||
auto_summarize: true
|
|
||||||
},
|
|
||||||
sync_settings: {
|
|
||||||
enabled: true,
|
|
||||||
interval_hours: 2,
|
|
||||||
sync_articles: true,
|
|
||||||
max_tickets_per_sync: 100
|
|
||||||
},
|
|
||||||
webhook_settings: {
|
|
||||||
secret: '',
|
|
||||||
enabled_events: ['ticket.create', 'ticket.update']
|
|
||||||
},
|
|
||||||
notification_settings: {
|
|
||||||
email_notifications: false,
|
|
||||||
slack_webhook_url: '',
|
|
||||||
notification_events: ['sync_error', 'api_error']
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ZammadSettings: React.FC = () => {
|
|
||||||
const [config, setConfig] = useState<ZammadConfig>(defaultConfig);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [testing, setTesting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
|
||||||
const [testResult, setTestResult] = useState<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadConfiguration();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadConfiguration = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/plugins/zammad/configurations');
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.configurations.length > 0) {
|
|
||||||
// Load the first (active) configuration
|
|
||||||
const loadedConfig = data.configurations[0];
|
|
||||||
setConfig({
|
|
||||||
...defaultConfig,
|
|
||||||
...loadedConfig
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to load configuration');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfigChange = (path: string, value: any) => {
|
|
||||||
setConfig(prev => {
|
|
||||||
const newConfig = { ...prev };
|
|
||||||
const keys = path.split('.');
|
|
||||||
let current: any = newConfig;
|
|
||||||
|
|
||||||
for (let i = 0; i < keys.length - 1; i++) {
|
|
||||||
current = current[keys[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
current[keys[keys.length - 1]] = value;
|
|
||||||
return newConfig;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
|
||||||
setTesting(true);
|
|
||||||
setTestResult(null);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/plugins/zammad/configurations/test', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
zammad_url: config.zammad_url,
|
|
||||||
api_token: config.api_token
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
setTestResult(result);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
setError(`Connection test failed: ${result.error}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Connection test failed');
|
|
||||||
} finally {
|
|
||||||
setTesting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveConfiguration = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/plugins/zammad/configurations', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(config)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setSuccess('Configuration saved successfully');
|
|
||||||
} else {
|
|
||||||
const errorData = await response.json();
|
|
||||||
setError(errorData.detail || 'Failed to save configuration');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to save configuration');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleArrayToggle = (path: string, value: string) => {
|
|
||||||
const currentArray = path.split('.').reduce((obj, key) => obj[key], config) as string[];
|
|
||||||
const newArray = currentArray.includes(value)
|
|
||||||
? currentArray.filter(item => item !== value)
|
|
||||||
: [...currentArray, value];
|
|
||||||
handleConfigChange(path, newArray);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h4" gutterBottom>Zammad Settings</Typography>
|
|
||||||
<LinearProgress />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
|
||||||
<Typography variant="h4" component="h1">
|
|
||||||
Zammad Settings
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box display="flex" gap={2}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<TestIcon />}
|
|
||||||
onClick={handleTestConnection}
|
|
||||||
disabled={testing || !config.zammad_url || !config.api_token}
|
|
||||||
>
|
|
||||||
{testing ? 'Testing...' : 'Test Connection'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<SaveIcon />}
|
|
||||||
onClick={handleSaveConfiguration}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saving ? 'Saving...' : 'Save Configuration'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert severity="error" sx={{ mb: 3 }}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<Alert severity="success" sx={{ mb: 3 }}>
|
|
||||||
{success}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{testResult && (
|
|
||||||
<Alert
|
|
||||||
severity={testResult.success ? 'success' : 'error'}
|
|
||||||
sx={{ mb: 3 }}
|
|
||||||
>
|
|
||||||
{testResult.success
|
|
||||||
? `Connection successful! User: ${testResult.user}, Version: ${testResult.zammad_version}`
|
|
||||||
: `Connection failed: ${testResult.error}`
|
|
||||||
}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Basic Configuration */}
|
|
||||||
<Card sx={{ mb: 3 }}>
|
|
||||||
<CardContent>
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Basic Configuration
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box display="flex" flexDirection="column" gap={3}>
|
|
||||||
<TextField
|
|
||||||
label="Configuration Name"
|
|
||||||
value={config.name}
|
|
||||||
onChange={(e) => handleConfigChange('name', e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Zammad URL"
|
|
||||||
value={config.zammad_url}
|
|
||||||
onChange={(e) => handleConfigChange('zammad_url', e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
placeholder="https://company.zammad.com"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="API Token"
|
|
||||||
type="password"
|
|
||||||
value={config.api_token}
|
|
||||||
onChange={(e) => handleConfigChange('api_token', e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
helperText="Zammad API token with ticket read/write permissions"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Chatbot ID"
|
|
||||||
value={config.chatbot_id}
|
|
||||||
onChange={(e) => handleConfigChange('chatbot_id', e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
required
|
|
||||||
helperText="Platform chatbot ID for AI summarization"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* AI Summarization Settings */}
|
|
||||||
<Accordion sx={{ mb: 2 }}>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
||||||
<Box display="flex" alignItems="center" gap={2}>
|
|
||||||
<AIIcon />
|
|
||||||
<Typography variant="h6">AI Summarization</Typography>
|
|
||||||
<Chip
|
|
||||||
label={config.ai_summarization.enabled ? 'Enabled' : 'Disabled'}
|
|
||||||
color={config.ai_summarization.enabled ? 'success' : 'default'}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Box display="flex" flexDirection="column" gap={3}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={config.ai_summarization.enabled}
|
|
||||||
onChange={(e) => handleConfigChange('ai_summarization.enabled', e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Enable AI Summarization"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel>AI Model</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={config.ai_summarization.model}
|
|
||||||
onChange={(e) => handleConfigChange('ai_summarization.model', e.target.value)}
|
|
||||||
label="AI Model"
|
|
||||||
>
|
|
||||||
<MenuItem value="gpt-3.5-turbo">GPT-3.5 Turbo</MenuItem>
|
|
||||||
<MenuItem value="gpt-4">GPT-4</MenuItem>
|
|
||||||
<MenuItem value="claude-3-sonnet">Claude 3 Sonnet</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Max Summary Tokens"
|
|
||||||
type="number"
|
|
||||||
value={config.ai_summarization.max_tokens}
|
|
||||||
onChange={(e) => handleConfigChange('ai_summarization.max_tokens', parseInt(e.target.value))}
|
|
||||||
inputProps={{ min: 50, max: 500 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={config.ai_summarization.auto_summarize}
|
|
||||||
onChange={(e) => handleConfigChange('ai_summarization.auto_summarize', e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Auto-summarize New Tickets"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{/* Sync Settings */}
|
|
||||||
<Accordion sx={{ mb: 2 }}>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
||||||
<Box display="flex" alignItems="center" gap={2}>
|
|
||||||
<SyncIcon />
|
|
||||||
<Typography variant="h6">Sync Settings</Typography>
|
|
||||||
<Chip
|
|
||||||
label={config.sync_settings.enabled ? 'Enabled' : 'Disabled'}
|
|
||||||
color={config.sync_settings.enabled ? 'success' : 'default'}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Box display="flex" flexDirection="column" gap={3}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={config.sync_settings.enabled}
|
|
||||||
onChange={(e) => handleConfigChange('sync_settings.enabled', e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Enable Automatic Sync"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Sync Interval (Hours)"
|
|
||||||
type="number"
|
|
||||||
value={config.sync_settings.interval_hours}
|
|
||||||
onChange={(e) => handleConfigChange('sync_settings.interval_hours', parseInt(e.target.value))}
|
|
||||||
inputProps={{ min: 1, max: 24 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={config.sync_settings.sync_articles}
|
|
||||||
onChange={(e) => handleConfigChange('sync_settings.sync_articles', e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Sync Ticket Articles"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Max Tickets Per Sync"
|
|
||||||
type="number"
|
|
||||||
value={config.sync_settings.max_tickets_per_sync}
|
|
||||||
onChange={(e) => handleConfigChange('sync_settings.max_tickets_per_sync', parseInt(e.target.value))}
|
|
||||||
inputProps={{ min: 10, max: 1000 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{/* Webhook Settings */}
|
|
||||||
<Accordion sx={{ mb: 2 }}>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
||||||
<Box display="flex" alignItems="center" gap={2}>
|
|
||||||
<SecurityIcon />
|
|
||||||
<Typography variant="h6">Webhook Settings</Typography>
|
|
||||||
</Box>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Box display="flex" flexDirection="column" gap={3}>
|
|
||||||
<TextField
|
|
||||||
label="Webhook Secret"
|
|
||||||
type="password"
|
|
||||||
value={config.webhook_settings.secret}
|
|
||||||
onChange={(e) => handleConfigChange('webhook_settings.secret', e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
helperText="Secret for webhook signature validation"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography variant="subtitle2">Enabled Webhook Events</Typography>
|
|
||||||
<FormGroup>
|
|
||||||
{['ticket.create', 'ticket.update', 'ticket.close', 'article.create'].map((event) => (
|
|
||||||
<FormControlLabel
|
|
||||||
key={event}
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={config.webhook_settings.enabled_events.includes(event)}
|
|
||||||
onChange={() => handleArrayToggle('webhook_settings.enabled_events', event)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={event}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</FormGroup>
|
|
||||||
</Box>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{/* Notification Settings */}
|
|
||||||
<Accordion>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
||||||
<Typography variant="h6">Notification Settings</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Box display="flex" flexDirection="column" gap={3}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={config.notification_settings.email_notifications}
|
|
||||||
onChange={(e) => handleConfigChange('notification_settings.email_notifications', e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Email Notifications"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Slack Webhook URL"
|
|
||||||
value={config.notification_settings.slack_webhook_url}
|
|
||||||
onChange={(e) => handleConfigChange('notification_settings.slack_webhook_url', e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
placeholder="https://hooks.slack.com/services/..."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography variant="subtitle2">Notification Events</Typography>
|
|
||||||
<FormGroup>
|
|
||||||
{['sync_error', 'api_error', 'new_tickets', 'summarization_complete'].map((event) => (
|
|
||||||
<FormControlLabel
|
|
||||||
key={event}
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={config.notification_settings.notification_events.includes(event)}
|
|
||||||
onChange={() => handleArrayToggle('notification_settings.notification_events', event)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={event.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</FormGroup>
|
|
||||||
</Box>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user