moving plugins to separate directory

This commit is contained in:
2025-09-15 09:20:30 +02:00
parent 8adb4775f8
commit af1e96f2ed
9 changed files with 0 additions and 2213 deletions

View File

@@ -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

View File

@@ -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}")

View File

@@ -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

View File

@@ -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()

View File

@@ -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"}

View File

@@ -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

View File

@@ -1,4 +0,0 @@
aiohttp>=3.8.0
pydantic>=2.0.0
httpx>=0.24.0
python-dateutil>=2.8.0

View File

@@ -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>
);
};

View File

@@ -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>
);
};