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