Initial commit
This commit is contained in:
129
workers/app/celery.py
Normal file
129
workers/app/celery.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Celery Application - ServiceManagerWeb Workers
|
||||
|
||||
Configuración principal de Celery para tareas asíncronas
|
||||
"""
|
||||
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
import os
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import setup_logging
|
||||
|
||||
# Setup logging
|
||||
setup_logging()
|
||||
|
||||
# Get settings
|
||||
settings = get_settings()
|
||||
|
||||
# Create Celery application
|
||||
celery_app = Celery(
|
||||
"servicemanager-workers",
|
||||
broker=settings.CELERY_BROKER_URL,
|
||||
backend=settings.CELERY_RESULT_BACKEND,
|
||||
include=[
|
||||
"app.tasks.email_tasks",
|
||||
"app.tasks.sla_tasks",
|
||||
"app.tasks.maintenance_tasks",
|
||||
"app.tasks.notification_tasks"
|
||||
]
|
||||
)
|
||||
|
||||
# Configure Celery
|
||||
celery_app.conf.update(
|
||||
# Task settings
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
|
||||
# Result backend settings
|
||||
result_expires=3600, # 1 hour
|
||||
result_persistent=True,
|
||||
|
||||
# Worker settings
|
||||
worker_prefetch_multiplier=1,
|
||||
worker_max_tasks_per_child=1000,
|
||||
worker_disable_rate_limits=False,
|
||||
|
||||
# Task routing
|
||||
task_routes={
|
||||
"app.tasks.email_tasks.*": {"queue": "email"},
|
||||
"app.tasks.sla_tasks.*": {"queue": "sla"},
|
||||
"app.tasks.maintenance_tasks.*": {"queue": "maintenance"},
|
||||
"app.tasks.notification_tasks.*": {"queue": "notifications"},
|
||||
},
|
||||
|
||||
# Queue configuration
|
||||
task_default_queue="default",
|
||||
task_default_exchange="default",
|
||||
task_default_routing_key="default",
|
||||
|
||||
# Beat schedule for periodic tasks
|
||||
beat_schedule={
|
||||
# Check SLA violations every 5 minutes
|
||||
"check-sla-violations": {
|
||||
"task": "app.tasks.sla_tasks.check_sla_violations",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
},
|
||||
|
||||
# Send daily digest at 8:00 AM
|
||||
"send-daily-digest": {
|
||||
"task": "app.tasks.notification_tasks.send_daily_digest",
|
||||
"schedule": crontab(hour=8, minute=0),
|
||||
},
|
||||
|
||||
# Clean old logs weekly on Sunday at 2:00 AM
|
||||
"cleanup-old-logs": {
|
||||
"task": "app.tasks.maintenance_tasks.cleanup_old_logs",
|
||||
"schedule": crontab(hour=2, minute=0, day_of_week=0),
|
||||
},
|
||||
|
||||
# Generate weekly reports on Monday at 9:00 AM
|
||||
"generate-weekly-reports": {
|
||||
"task": "app.tasks.maintenance_tasks.generate_weekly_reports",
|
||||
"schedule": crontab(hour=9, minute=0, day_of_week=1),
|
||||
},
|
||||
|
||||
# Health check every minute
|
||||
"worker-health-check": {
|
||||
"task": "app.tasks.maintenance_tasks.health_check",
|
||||
"schedule": crontab(minute="*/1"),
|
||||
},
|
||||
},
|
||||
|
||||
# Error handling
|
||||
task_reject_on_worker_lost=True,
|
||||
task_acks_late=True,
|
||||
|
||||
# Monitoring
|
||||
worker_send_task_events=True,
|
||||
task_send_sent_event=True,
|
||||
|
||||
# Security
|
||||
worker_hijack_root_logger=False,
|
||||
worker_log_format="[%(asctime)s: %(levelname)s/%(processName)s] %(message)s",
|
||||
worker_task_log_format="[%(asctime)s: %(levelname)s/%(processName)s][%(task_name)s(%(task_id)s)] %(message)s",
|
||||
)
|
||||
|
||||
# Optional: Configure SSL if needed
|
||||
if settings.ENVIRONMENT == "production":
|
||||
# Enable SSL for production
|
||||
celery_app.conf.update(
|
||||
broker_use_ssl=True,
|
||||
redis_backend_use_ssl=True,
|
||||
)
|
||||
|
||||
|
||||
# Import all tasks to register them
|
||||
from app.tasks import (
|
||||
email_tasks,
|
||||
sla_tasks,
|
||||
maintenance_tasks,
|
||||
notification_tasks
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
celery_app.start()
|
||||
117
workers/app/core/config.py
Normal file
117
workers/app/core/config.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Core Configuration for Workers - ServiceManagerWeb
|
||||
|
||||
Configuración compartida entre workers usando Pydantic Settings
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
|
||||
|
||||
class WorkerSettings(BaseSettings):
|
||||
"""Configuración para workers Celery."""
|
||||
|
||||
# ===================================
|
||||
# GENERAL
|
||||
# ===================================
|
||||
ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT")
|
||||
DEBUG: bool = Field(default=False, env="DEBUG")
|
||||
|
||||
# ===================================
|
||||
# DATABASE
|
||||
# ===================================
|
||||
DATABASE_URL: str = Field(..., env="DATABASE_URL")
|
||||
|
||||
# ===================================
|
||||
# CELERY & REDIS
|
||||
# ===================================
|
||||
CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL")
|
||||
CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND")
|
||||
REDIS_URL: str = Field(..., env="REDIS_URL")
|
||||
|
||||
# ===================================
|
||||
# EMAIL SETTINGS
|
||||
# ===================================
|
||||
SMTP_HOST: str = Field(default="localhost", env="SMTP_HOST")
|
||||
SMTP_PORT: int = Field(default=587, env="SMTP_PORT")
|
||||
SMTP_USER: Optional[str] = Field(default=None, env="SMTP_USER")
|
||||
SMTP_PASSWORD: Optional[str] = Field(default=None, env="SMTP_PASSWORD")
|
||||
SMTP_USE_TLS: bool = Field(default=True, env="SMTP_USE_TLS")
|
||||
SMTP_USE_SSL: bool = Field(default=False, env="SMTP_USE_SSL")
|
||||
|
||||
DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local", env="DEFAULT_FROM_EMAIL")
|
||||
DEFAULT_FROM_NAME: str = Field(default="ServiceManager", env="DEFAULT_FROM_NAME")
|
||||
|
||||
# Email retry settings
|
||||
EMAIL_MAX_RETRIES: int = Field(default=3, env="EMAIL_MAX_RETRIES")
|
||||
EMAIL_RETRY_DELAY: int = Field(default=60, env="EMAIL_RETRY_DELAY") # seconds
|
||||
|
||||
# ===================================
|
||||
# FILE PROCESSING
|
||||
# ===================================
|
||||
UPLOAD_PATH: str = Field(default="/app/uploads", env="UPLOAD_PATH")
|
||||
MAX_UPLOAD_SIZE_MB: int = Field(default=10, env="MAX_UPLOAD_SIZE_MB")
|
||||
|
||||
# ===================================
|
||||
# SLA SETTINGS
|
||||
# ===================================
|
||||
SLA_CHECK_ENABLED: bool = Field(default=True, env="SLA_CHECK_ENABLED")
|
||||
SLA_WARNING_THRESHOLD: float = Field(default=0.8, env="SLA_WARNING_THRESHOLD") # 80% of SLA time
|
||||
|
||||
# ===================================
|
||||
# LOGGING
|
||||
# ===================================
|
||||
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
LOG_FORMAT: str = Field(default="json", env="LOG_FORMAT")
|
||||
LOG_FILE: Optional[str] = Field(default=None, env="LOG_FILE")
|
||||
|
||||
# ===================================
|
||||
# MONITORING
|
||||
# ===================================
|
||||
SENTRY_DSN: Optional[str] = Field(default=None, env="SENTRY_DSN")
|
||||
PROMETHEUS_PORT: int = Field(default=8888, env="PROMETHEUS_PORT")
|
||||
|
||||
# ===================================
|
||||
# TASK SETTINGS
|
||||
# ===================================
|
||||
TASK_TIME_LIMIT: int = Field(default=300, env="TASK_TIME_LIMIT") # 5 minutes
|
||||
TASK_SOFT_TIME_LIMIT: int = Field(default=240, env="TASK_SOFT_TIME_LIMIT") # 4 minutes
|
||||
|
||||
# ===================================
|
||||
# MAINTENANCE SETTINGS
|
||||
# ===================================
|
||||
LOG_RETENTION_DAYS: int = Field(default=30, env="LOG_RETENTION_DAYS")
|
||||
ATTACHMENT_RETENTION_DAYS: int = Field(default=90, env="ATTACHMENT_RETENTION_DAYS")
|
||||
|
||||
# ===================================
|
||||
# NOTIFICATION SETTINGS
|
||||
# ===================================
|
||||
NOTIFICATION_BATCH_SIZE: int = Field(default=100, env="NOTIFICATION_BATCH_SIZE")
|
||||
DIGEST_ENABLED: bool = Field(default=True, env="DIGEST_ENABLED")
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8",
|
||||
"case_sensitive": True
|
||||
}
|
||||
|
||||
def is_production(self) -> bool:
|
||||
"""Check if environment is production."""
|
||||
return self.ENVIRONMENT.lower() == "production"
|
||||
|
||||
def is_development(self) -> bool:
|
||||
"""Check if environment is development."""
|
||||
return self.ENVIRONMENT.lower() == "development"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> WorkerSettings:
|
||||
"""
|
||||
Get cached settings instance.
|
||||
|
||||
Using lru_cache to create a singleton pattern for settings.
|
||||
"""
|
||||
return WorkerSettings()
|
||||
122
workers/app/core/logging.py
Normal file
122
workers/app/core/logging.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Logging Configuration for Workers - ServiceManagerWeb
|
||||
|
||||
Configuración de logging estructurado para workers Celery
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.config
|
||||
import sys
|
||||
from typing import Any, Dict
|
||||
import structlog
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""Configure structured logging for workers."""
|
||||
|
||||
processors = [
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
# Add worker-specific context
|
||||
structlog.processors.CallsiteParameterAdder(
|
||||
parameters=[
|
||||
structlog.processors.CallsiteParameter.FUNC_NAME,
|
||||
structlog.processors.CallsiteParameter.PATHNAME,
|
||||
structlog.processors.CallsiteParameter.LINENO,
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
if settings.LOG_FORMAT == "json":
|
||||
processors.append(structlog.processors.JSONRenderer())
|
||||
else:
|
||||
processors.append(structlog.dev.ConsoleRenderer(colors=True))
|
||||
|
||||
structlog.configure(
|
||||
processors=processors,
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
context_class=dict,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
# Configure standard library logging for Celery
|
||||
logging_config = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"json": {
|
||||
"()": structlog.stdlib.ProcessorFormatter,
|
||||
"processor": structlog.processors.JSONRenderer(),
|
||||
},
|
||||
"console": {
|
||||
"()": structlog.stdlib.ProcessorFormatter,
|
||||
"processor": structlog.dev.ConsoleRenderer(colors=True),
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": settings.LOG_LEVEL,
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": sys.stdout,
|
||||
"formatter": "json" if settings.LOG_FORMAT == "json" else "console",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"": { # root logger
|
||||
"handlers": ["console"],
|
||||
"level": settings.LOG_LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"celery": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.worker": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.task": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"app": {
|
||||
"handlers": ["console"],
|
||||
"level": settings.LOG_LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Add file handler if specified
|
||||
if settings.LOG_FILE:
|
||||
logging_config["handlers"]["file"] = {
|
||||
"level": settings.LOG_LEVEL,
|
||||
"class": "logging.handlers.RotatingFileHandler",
|
||||
"filename": settings.LOG_FILE,
|
||||
"maxBytes": 10 * 1024 * 1024, # 10MB
|
||||
"backupCount": 5,
|
||||
"formatter": "json",
|
||||
}
|
||||
|
||||
for logger_config in logging_config["loggers"].values():
|
||||
logger_config["handlers"].append("file")
|
||||
|
||||
logging.config.dictConfig(logging_config)
|
||||
|
||||
|
||||
def get_logger(name: str = None) -> structlog.BoundLogger:
|
||||
"""Get a configured structlog logger."""
|
||||
return structlog.get_logger(name)
|
||||
404
workers/app/tasks/email_tasks.py
Normal file
404
workers/app/tasks/email_tasks.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
Email Tasks - ServiceManagerWeb Workers
|
||||
|
||||
Tareas asíncronas para envío de emails y notificaciones
|
||||
"""
|
||||
|
||||
from celery import current_task
|
||||
from celery.exceptions import Retry
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
import smtplib
|
||||
import ssl
|
||||
from typing import Dict, List, Optional, Any
|
||||
from jinja2 import Template, Environment, BaseLoader
|
||||
import structlog
|
||||
|
||||
from app.celery import celery_app
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MemoryLoader(BaseLoader):
|
||||
"""Jinja2 loader for templates from memory/database."""
|
||||
|
||||
def __init__(self, templates: Dict[str, str]):
|
||||
self.templates = templates
|
||||
|
||||
def get_source(self, environment, template):
|
||||
if template in self.templates:
|
||||
source = self.templates[template]
|
||||
return source, None, lambda: True
|
||||
raise TemplateNotFoundError(template)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
autoretry_for=(Exception,),
|
||||
retry_kwargs={'max_retries': 3, 'countdown': 60},
|
||||
time_limit=120,
|
||||
soft_time_limit=90
|
||||
)
|
||||
def send_email_task(
|
||||
self,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_content: str,
|
||||
text_content: Optional[str] = None,
|
||||
from_email: Optional[str] = None,
|
||||
from_name: Optional[str] = None,
|
||||
attachments: Optional[List[Dict[str, Any]]] = None,
|
||||
tenant_id: Optional[str] = None,
|
||||
correlation_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send email using SMTP.
|
||||
|
||||
Args:
|
||||
to_email: Recipient email address
|
||||
subject: Email subject
|
||||
html_content: HTML content
|
||||
text_content: Plain text content (optional)
|
||||
from_email: Sender email (optional, uses default)
|
||||
from_name: Sender name (optional)
|
||||
attachments: List of attachment dicts
|
||||
tenant_id: Tenant ID for logging
|
||||
correlation_id: Correlation ID for tracking
|
||||
|
||||
Returns:
|
||||
Dict with send result
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name,
|
||||
tenant_id=tenant_id,
|
||||
correlation_id=correlation_id
|
||||
)
|
||||
|
||||
task_logger.info(
|
||||
"Starting email send task",
|
||||
to_email=to_email,
|
||||
subject=subject
|
||||
)
|
||||
|
||||
try:
|
||||
# Prepare email
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = f"{from_name or settings.DEFAULT_FROM_NAME} <{from_email or settings.DEFAULT_FROM_EMAIL}>"
|
||||
msg['To'] = to_email
|
||||
|
||||
# Add text content
|
||||
if text_content:
|
||||
text_part = MIMEText(text_content, 'plain', 'utf-8')
|
||||
msg.attach(text_part)
|
||||
|
||||
# Add HTML content
|
||||
html_part = MIMEText(html_content, 'html', 'utf-8')
|
||||
msg.attach(html_part)
|
||||
|
||||
# Add attachments
|
||||
if attachments:
|
||||
for attachment in attachments:
|
||||
part = MIMEBase('application', 'octet-stream')
|
||||
part.set_payload(attachment['content'])
|
||||
encoders.encode_base64(part)
|
||||
part.add_header(
|
||||
'Content-Disposition',
|
||||
f'attachment; filename= {attachment["filename"]}'
|
||||
)
|
||||
msg.attach(part)
|
||||
|
||||
# Send email
|
||||
context = ssl.create_default_context()
|
||||
|
||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
||||
if settings.SMTP_USE_TLS:
|
||||
server.starttls(context=context)
|
||||
|
||||
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
|
||||
server.send_message(msg)
|
||||
|
||||
task_logger.info(
|
||||
"Email sent successfully",
|
||||
to_email=to_email,
|
||||
subject=subject
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"to_email": to_email,
|
||||
"subject": subject,
|
||||
"sent_at": current_task.request.eta or "now"
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"Failed to send email",
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Check if we should retry
|
||||
if self.request.retries < self.max_retries:
|
||||
task_logger.info(
|
||||
"Retrying email send",
|
||||
retry_count=self.request.retries + 1,
|
||||
max_retries=self.max_retries
|
||||
)
|
||||
raise self.retry(countdown=60 * (2 ** self.request.retries))
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"to_email": to_email,
|
||||
"subject": subject,
|
||||
"error": str(exc)
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=300,
|
||||
soft_time_limit=240
|
||||
)
|
||||
def send_templated_email_task(
|
||||
self,
|
||||
to_email: str,
|
||||
template_name: str,
|
||||
context: Dict[str, Any],
|
||||
tenant_id: Optional[str] = None,
|
||||
correlation_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send email using a template.
|
||||
|
||||
Args:
|
||||
to_email: Recipient email
|
||||
template_name: Template identifier
|
||||
context: Template context variables
|
||||
tenant_id: Tenant ID
|
||||
correlation_id: Correlation ID
|
||||
|
||||
Returns:
|
||||
Dict with send result
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name,
|
||||
tenant_id=tenant_id,
|
||||
correlation_id=correlation_id
|
||||
)
|
||||
|
||||
task_logger.info(
|
||||
"Starting templated email task",
|
||||
to_email=to_email,
|
||||
template_name=template_name
|
||||
)
|
||||
|
||||
try:
|
||||
# TODO: Fetch template from database
|
||||
# For now, use mock templates
|
||||
templates = {
|
||||
"ticket_created": {
|
||||
"subject": "Nuevo ticket #{{ ticket_number }}: {{ subject }}",
|
||||
"html": """
|
||||
<h2>Nuevo ticket creado</h2>
|
||||
<p>Hola {{ user_name }},</p>
|
||||
<p>Se ha creado un nuevo ticket:</p>
|
||||
<ul>
|
||||
<li><strong>Número:</strong> #{{ ticket_number }}</li>
|
||||
<li><strong>Asunto:</strong> {{ subject }}</li>
|
||||
<li><strong>Prioridad:</strong> {{ priority }}</li>
|
||||
</ul>
|
||||
<p><a href="{{ ticket_url }}">Ver ticket</a></p>
|
||||
<p>Saludos,<br>Equipo de Soporte</p>
|
||||
""",
|
||||
"text": """
|
||||
Nuevo ticket creado
|
||||
|
||||
Hola {{ user_name }},
|
||||
|
||||
Se ha creado un nuevo ticket:
|
||||
|
||||
Número: #{{ ticket_number }}
|
||||
Asunto: {{ subject }}
|
||||
Prioridad: {{ priority }}
|
||||
|
||||
Ver ticket: {{ ticket_url }}
|
||||
|
||||
Saludos,
|
||||
Equipo de Soporte
|
||||
"""
|
||||
},
|
||||
"ticket_assigned": {
|
||||
"subject": "Ticket #{{ ticket_number }} asignado a ti",
|
||||
"html": """
|
||||
<h2>Ticket asignado</h2>
|
||||
<p>Hola {{ agent_name }},</p>
|
||||
<p>Se te ha asignado el ticket:</p>
|
||||
<ul>
|
||||
<li><strong>Número:</strong> #{{ ticket_number }}</li>
|
||||
<li><strong>Asunto:</strong> {{ subject }}</li>
|
||||
<li><strong>Cliente:</strong> {{ customer_name }}</li>
|
||||
<li><strong>Prioridad:</strong> {{ priority }}</li>
|
||||
</ul>
|
||||
<p><a href="{{ ticket_url }}">Ver ticket</a></p>
|
||||
""",
|
||||
"text": """
|
||||
Ticket asignado
|
||||
|
||||
Hola {{ agent_name }},
|
||||
|
||||
Se te ha asignado el ticket:
|
||||
|
||||
Número: #{{ ticket_number }}
|
||||
Asunto: {{ subject }}
|
||||
Cliente: {{ customer_name }}
|
||||
Prioridad: {{ priority }}
|
||||
|
||||
Ver ticket: {{ ticket_url }}
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
if template_name not in templates:
|
||||
raise ValueError(f"Template '{template_name}' not found")
|
||||
|
||||
template_data = templates[template_name]
|
||||
|
||||
# Render templates
|
||||
env = Environment(loader=MemoryLoader({
|
||||
f"{template_name}_subject": template_data["subject"],
|
||||
f"{template_name}_html": template_data["html"],
|
||||
f"{template_name}_text": template_data["text"]
|
||||
}))
|
||||
|
||||
subject_template = env.get_template(f"{template_name}_subject")
|
||||
html_template = env.get_template(f"{template_name}_html")
|
||||
text_template = env.get_template(f"{template_name}_text")
|
||||
|
||||
subject = subject_template.render(**context)
|
||||
html_content = html_template.render(**context)
|
||||
text_content = text_template.render(**context)
|
||||
|
||||
# Send email using the basic send task
|
||||
return send_email_task.apply_async(
|
||||
kwargs={
|
||||
"to_email": to_email,
|
||||
"subject": subject,
|
||||
"html_content": html_content,
|
||||
"text_content": text_content,
|
||||
"tenant_id": tenant_id,
|
||||
"correlation_id": correlation_id
|
||||
}
|
||||
).get()
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"Failed to send templated email",
|
||||
to_email=to_email,
|
||||
template_name=template_name,
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"to_email": to_email,
|
||||
"template_name": template_name,
|
||||
"error": str(exc)
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=600,
|
||||
soft_time_limit=540
|
||||
)
|
||||
def send_bulk_email_task(
|
||||
self,
|
||||
email_list: List[Dict[str, Any]],
|
||||
tenant_id: Optional[str] = None,
|
||||
correlation_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send bulk emails.
|
||||
|
||||
Args:
|
||||
email_list: List of email dicts with to_email, subject, content
|
||||
tenant_id: Tenant ID
|
||||
correlation_id: Correlation ID
|
||||
|
||||
Returns:
|
||||
Dict with bulk send results
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name,
|
||||
tenant_id=tenant_id,
|
||||
correlation_id=correlation_id
|
||||
)
|
||||
|
||||
total_emails = len(email_list)
|
||||
task_logger.info(f"Starting bulk email task", total_emails=total_emails)
|
||||
|
||||
results = []
|
||||
|
||||
for i, email_data in enumerate(email_list):
|
||||
try:
|
||||
result = send_email_task.apply_async(
|
||||
kwargs={
|
||||
**email_data,
|
||||
"tenant_id": tenant_id,
|
||||
"correlation_id": correlation_id
|
||||
}
|
||||
).get()
|
||||
|
||||
results.append(result)
|
||||
|
||||
# Update task progress
|
||||
current_task.update_state(
|
||||
state='PROGRESS',
|
||||
meta={'current': i + 1, 'total': total_emails}
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"Failed to send bulk email item",
|
||||
index=i,
|
||||
email_data=email_data,
|
||||
error=str(exc)
|
||||
)
|
||||
|
||||
results.append({
|
||||
"success": False,
|
||||
"to_email": email_data.get("to_email"),
|
||||
"error": str(exc)
|
||||
})
|
||||
|
||||
# Calculate stats
|
||||
successful = sum(1 for r in results if r.get("success"))
|
||||
failed = total_emails - successful
|
||||
|
||||
task_logger.info(
|
||||
"Bulk email task completed",
|
||||
total=total_emails,
|
||||
successful=successful,
|
||||
failed=failed
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total_emails,
|
||||
"successful": successful,
|
||||
"failed": failed,
|
||||
"results": results
|
||||
}
|
||||
371
workers/app/tasks/maintenance_tasks.py
Normal file
371
workers/app/tasks/maintenance_tasks.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
Maintenance Tasks - ServiceManagerWeb Workers
|
||||
|
||||
Tareas de mantenimiento del sistema
|
||||
"""
|
||||
|
||||
from celery import current_task
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, List
|
||||
import os
|
||||
import structlog
|
||||
|
||||
from app.celery import celery_app
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def health_check(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Worker health check task.
|
||||
|
||||
Returns basic health information about the worker.
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name
|
||||
)
|
||||
|
||||
try:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
# Basic health checks
|
||||
health_data = {
|
||||
"status": "healthy",
|
||||
"timestamp": current_time.isoformat(),
|
||||
"worker_id": self.request.hostname,
|
||||
"task_id": self.request.id,
|
||||
"environment": settings.ENVIRONMENT,
|
||||
"checks": {
|
||||
"redis": "unknown", # TODO: Check Redis connectivity
|
||||
"database": "unknown", # TODO: Check database connectivity
|
||||
"disk_space": "unknown", # TODO: Check disk space
|
||||
"memory": "unknown" # TODO: Check memory usage
|
||||
}
|
||||
}
|
||||
|
||||
task_logger.info("Worker health check completed", status="healthy")
|
||||
|
||||
return health_data
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"Worker health check failed",
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"error": str(exc)
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=1800, # 30 minutes
|
||||
soft_time_limit=1500 # 25 minutes
|
||||
)
|
||||
def cleanup_old_logs(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Clean up old log files and database records.
|
||||
|
||||
Removes:
|
||||
- Log files older than LOG_RETENTION_DAYS
|
||||
- Old notification logs
|
||||
- Old audit logs (if configured)
|
||||
- Temp files
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name
|
||||
)
|
||||
|
||||
task_logger.info("Starting cleanup of old logs")
|
||||
|
||||
try:
|
||||
current_time = datetime.utcnow()
|
||||
cutoff_date = current_time - timedelta(days=settings.LOG_RETENTION_DAYS)
|
||||
|
||||
cleanup_results = {
|
||||
"started_at": current_time.isoformat(),
|
||||
"cutoff_date": cutoff_date.isoformat(),
|
||||
"files_removed": 0,
|
||||
"bytes_freed": 0,
|
||||
"database_records_removed": 0,
|
||||
"errors": []
|
||||
}
|
||||
|
||||
# TODO: Implement actual file cleanup
|
||||
# For now, simulate cleanup
|
||||
|
||||
# Clean up log files
|
||||
log_dir = "/app/logs"
|
||||
if os.path.exists(log_dir):
|
||||
for filename in os.listdir(log_dir):
|
||||
filepath = os.path.join(log_dir, filename)
|
||||
if os.path.isfile(filepath):
|
||||
file_mtime = datetime.fromtimestamp(os.path.getmtime(filepath))
|
||||
if file_mtime < cutoff_date and filename.endswith('.log'):
|
||||
try:
|
||||
file_size = os.path.getsize(filepath)
|
||||
os.remove(filepath)
|
||||
cleanup_results["files_removed"] += 1
|
||||
cleanup_results["bytes_freed"] += file_size
|
||||
task_logger.info(f"Removed old log file", filename=filename)
|
||||
except Exception as e:
|
||||
cleanup_results["errors"].append(f"Failed to remove {filename}: {str(e)}")
|
||||
|
||||
# TODO: Clean up database records
|
||||
# - Old notification_logs
|
||||
# - Old audit_logs (with retention policy)
|
||||
# - Expired refresh_tokens
|
||||
# - Old file attachments (if configured)
|
||||
|
||||
task_logger.info(
|
||||
"Cleanup completed",
|
||||
files_removed=cleanup_results["files_removed"],
|
||||
bytes_freed=cleanup_results["bytes_freed"],
|
||||
errors=len(cleanup_results["errors"])
|
||||
)
|
||||
|
||||
return cleanup_results
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"Cleanup task failed",
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=3600, # 1 hour
|
||||
soft_time_limit=3300 # 55 minutes
|
||||
)
|
||||
def generate_weekly_reports(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate weekly reports for all tenants.
|
||||
|
||||
Creates:
|
||||
- SLA performance reports
|
||||
- Ticket volume reports
|
||||
- Agent performance reports
|
||||
- Customer satisfaction reports
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name
|
||||
)
|
||||
|
||||
task_logger.info("Starting weekly reports generation")
|
||||
|
||||
try:
|
||||
current_time = datetime.utcnow()
|
||||
week_start = current_time - timedelta(days=7)
|
||||
|
||||
report_results = {
|
||||
"generated_at": current_time.isoformat(),
|
||||
"period_start": week_start.isoformat(),
|
||||
"period_end": current_time.isoformat(),
|
||||
"reports_generated": [],
|
||||
"errors": []
|
||||
}
|
||||
|
||||
# TODO: Get list of active tenants from database
|
||||
mock_tenants = [
|
||||
{"id": "tenant-1", "name": "Aduanasoft Demo", "slug": "aduanasoft-demo"}
|
||||
]
|
||||
|
||||
for tenant in mock_tenants:
|
||||
try:
|
||||
task_logger.info(
|
||||
"Generating report for tenant",
|
||||
tenant_id=tenant["id"],
|
||||
tenant_name=tenant["name"]
|
||||
)
|
||||
|
||||
# TODO: Generate actual reports
|
||||
# For now, simulate report generation
|
||||
|
||||
report_data = {
|
||||
"tenant_id": tenant["id"],
|
||||
"tenant_name": tenant["name"],
|
||||
"period_start": week_start.isoformat(),
|
||||
"period_end": current_time.isoformat(),
|
||||
"metrics": {
|
||||
"tickets_created": 25,
|
||||
"tickets_resolved": 23,
|
||||
"avg_response_time_hours": 2.1,
|
||||
"avg_resolution_time_hours": 18.5,
|
||||
"sla_response_met_percentage": 92.0,
|
||||
"sla_resolution_met_percentage": 87.0,
|
||||
"customer_satisfaction_avg": 4.2
|
||||
}
|
||||
}
|
||||
|
||||
report_results["reports_generated"].append(report_data)
|
||||
|
||||
# TODO: Store report in database
|
||||
# TODO: Send report email to admins
|
||||
|
||||
# Update task progress
|
||||
current_task.update_state(
|
||||
state='PROGRESS',
|
||||
meta={
|
||||
'current': len(report_results["reports_generated"]),
|
||||
'total': len(mock_tenants)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to generate report for tenant {tenant['id']}: {str(e)}"
|
||||
report_results["errors"].append(error_msg)
|
||||
task_logger.error(
|
||||
"Report generation failed for tenant",
|
||||
tenant_id=tenant["id"],
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
task_logger.info(
|
||||
"Weekly reports generation completed",
|
||||
reports_generated=len(report_results["reports_generated"]),
|
||||
errors=len(report_results["errors"])
|
||||
)
|
||||
|
||||
return report_results
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"Weekly reports generation failed",
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=900, # 15 minutes
|
||||
soft_time_limit=780 # 13 minutes
|
||||
)
|
||||
def cleanup_temp_files(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Clean up temporary files and orphaned uploads.
|
||||
|
||||
Removes:
|
||||
- Temp upload files older than 24 hours
|
||||
- Orphaned attachment files (no DB reference)
|
||||
- Processing artifacts
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name
|
||||
)
|
||||
|
||||
task_logger.info("Starting temp files cleanup")
|
||||
|
||||
try:
|
||||
current_time = datetime.utcnow()
|
||||
cutoff_date = current_time - timedelta(hours=24)
|
||||
|
||||
cleanup_results = {
|
||||
"started_at": current_time.isoformat(),
|
||||
"temp_files_removed": 0,
|
||||
"orphaned_files_removed": 0,
|
||||
"bytes_freed": 0,
|
||||
"errors": []
|
||||
}
|
||||
|
||||
# Clean up temp directory
|
||||
temp_dirs = ["/tmp", "/app/temp", f"{settings.UPLOAD_PATH}/temp"]
|
||||
|
||||
for temp_dir in temp_dirs:
|
||||
if os.path.exists(temp_dir):
|
||||
for filename in os.listdir(temp_dir):
|
||||
filepath = os.path.join(temp_dir, filename)
|
||||
if os.path.isfile(filepath):
|
||||
try:
|
||||
file_mtime = datetime.fromtimestamp(os.path.getmtime(filepath))
|
||||
if file_mtime < cutoff_date:
|
||||
file_size = os.path.getsize(filepath)
|
||||
os.remove(filepath)
|
||||
cleanup_results["temp_files_removed"] += 1
|
||||
cleanup_results["bytes_freed"] += file_size
|
||||
except Exception as e:
|
||||
cleanup_results["errors"].append(f"Failed to remove temp file {filepath}: {str(e)}")
|
||||
|
||||
# TODO: Check for orphaned files in uploads directory
|
||||
# - Query database for all attachment file_paths
|
||||
# - Compare with actual files in upload directory
|
||||
# - Remove orphaned files
|
||||
|
||||
task_logger.info(
|
||||
"Temp files cleanup completed",
|
||||
temp_files_removed=cleanup_results["temp_files_removed"],
|
||||
orphaned_files_removed=cleanup_results["orphaned_files_removed"],
|
||||
bytes_freed=cleanup_results["bytes_freed"]
|
||||
)
|
||||
|
||||
return cleanup_results
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"Temp files cleanup failed",
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=300,
|
||||
soft_time_limit=240
|
||||
)
|
||||
def database_maintenance(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Perform database maintenance tasks.
|
||||
|
||||
- VACUUM and ANALYZE tables
|
||||
- Update statistics
|
||||
- Check for slow queries
|
||||
- Optimize indices if needed
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name
|
||||
)
|
||||
|
||||
task_logger.info("Starting database maintenance")
|
||||
|
||||
try:
|
||||
# TODO: Implement database maintenance
|
||||
# For now, return placeholder results
|
||||
|
||||
maintenance_results = {
|
||||
"started_at": datetime.utcnow().isoformat(),
|
||||
"tables_analyzed": 0,
|
||||
"indices_optimized": 0,
|
||||
"slow_queries_found": 0,
|
||||
"space_reclaimed_mb": 0
|
||||
}
|
||||
|
||||
task_logger.info("Database maintenance completed (placeholder)")
|
||||
|
||||
return maintenance_results
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"Database maintenance failed",
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
469
workers/app/tasks/notification_tasks.py
Normal file
469
workers/app/tasks/notification_tasks.py
Normal file
@@ -0,0 +1,469 @@
|
||||
"""
|
||||
Notification Tasks - ServiceManagerWeb Workers
|
||||
|
||||
Tareas para notificaciones y comunicaciones
|
||||
"""
|
||||
|
||||
from celery import current_task
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
import structlog
|
||||
|
||||
from app.celery import celery_app
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
from app.tasks.email_tasks import send_templated_email_task, send_bulk_email_task
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=900, # 15 minutes
|
||||
soft_time_limit=780 # 13 minutes
|
||||
)
|
||||
def send_daily_digest(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Send daily digest emails to agents and managers.
|
||||
|
||||
Includes:
|
||||
- New tickets assigned
|
||||
- SLA warnings
|
||||
- Performance summary
|
||||
- Pending tasks
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name
|
||||
)
|
||||
|
||||
task_logger.info("Starting daily digest generation")
|
||||
|
||||
if not settings.DIGEST_ENABLED:
|
||||
task_logger.info("Daily digest disabled, skipping")
|
||||
return {"status": "disabled"}
|
||||
|
||||
try:
|
||||
current_time = datetime.utcnow()
|
||||
yesterday = current_time - timedelta(days=1)
|
||||
|
||||
digest_results = {
|
||||
"generated_at": current_time.isoformat(),
|
||||
"period_start": yesterday.isoformat(),
|
||||
"period_end": current_time.isoformat(),
|
||||
"digests_sent": 0,
|
||||
"errors": []
|
||||
}
|
||||
|
||||
# TODO: Get active agents and managers from database
|
||||
mock_recipients = [
|
||||
{
|
||||
"user_id": "user-1",
|
||||
"email": "agent1@example.com",
|
||||
"name": "Agent One",
|
||||
"role": "AGENT",
|
||||
"tenant_id": "tenant-1"
|
||||
},
|
||||
{
|
||||
"user_id": "user-2",
|
||||
"email": "manager@example.com",
|
||||
"name": "Support Manager",
|
||||
"role": "SUPPORT_MANAGER",
|
||||
"tenant_id": "tenant-1"
|
||||
}
|
||||
]
|
||||
|
||||
for recipient in mock_recipients:
|
||||
try:
|
||||
task_logger.info(
|
||||
"Generating digest for user",
|
||||
user_id=recipient["user_id"],
|
||||
email=recipient["email"],
|
||||
role=recipient["role"]
|
||||
)
|
||||
|
||||
# TODO: Generate actual digest data from database
|
||||
digest_data = generate_digest_data(
|
||||
recipient["user_id"],
|
||||
recipient["role"],
|
||||
recipient["tenant_id"],
|
||||
yesterday,
|
||||
current_time
|
||||
)
|
||||
|
||||
# Send digest email
|
||||
send_templated_email_task.apply_async(kwargs={
|
||||
"to_email": recipient["email"],
|
||||
"template_name": "daily_digest",
|
||||
"context": {
|
||||
"user_name": recipient["name"],
|
||||
"role": recipient["role"],
|
||||
"date": current_time.strftime("%Y-%m-%d"),
|
||||
**digest_data
|
||||
},
|
||||
"tenant_id": recipient["tenant_id"],
|
||||
"correlation_id": self.request.id
|
||||
})
|
||||
|
||||
digest_results["digests_sent"] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to send digest to {recipient['email']}: {str(e)}"
|
||||
digest_results["errors"].append(error_msg)
|
||||
task_logger.error(
|
||||
"Digest generation failed for user",
|
||||
user_id=recipient["user_id"],
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
task_logger.info(
|
||||
"Daily digest generation completed",
|
||||
digests_sent=digest_results["digests_sent"],
|
||||
errors=len(digest_results["errors"])
|
||||
)
|
||||
|
||||
return digest_results
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"Daily digest generation failed",
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def generate_digest_data(
|
||||
user_id: str,
|
||||
role: str,
|
||||
tenant_id: str,
|
||||
period_start: datetime,
|
||||
period_end: datetime
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate digest data for a specific user.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
role: User role
|
||||
tenant_id: Tenant ID
|
||||
period_start: Start of digest period
|
||||
period_end: End of digest period
|
||||
|
||||
Returns:
|
||||
Dict with digest data
|
||||
"""
|
||||
# TODO: Implement actual database queries
|
||||
# For now, return mock data
|
||||
|
||||
base_data = {
|
||||
"summary": {
|
||||
"new_tickets": 5,
|
||||
"resolved_tickets": 7,
|
||||
"pending_tickets": 12,
|
||||
"overdue_tickets": 2
|
||||
},
|
||||
"sla_status": {
|
||||
"response_sla_met": 8,
|
||||
"response_sla_missed": 1,
|
||||
"resolution_sla_met": 6,
|
||||
"resolution_sla_missed": 2
|
||||
}
|
||||
}
|
||||
|
||||
if role == "AGENT":
|
||||
base_data.update({
|
||||
"assigned_tickets": [
|
||||
{
|
||||
"ticket_number": "TKT-2024-000001",
|
||||
"subject": "Problema de conexión",
|
||||
"priority": "HIGH",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"sla_due": "2024-01-15T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"urgent_tickets": 1,
|
||||
"performance": {
|
||||
"avg_response_time_hours": 1.5,
|
||||
"avg_resolution_time_hours": 18.2,
|
||||
"customer_satisfaction": 4.3
|
||||
}
|
||||
})
|
||||
|
||||
elif role in ["SUPPORT_MANAGER", "ADMIN"]:
|
||||
base_data.update({
|
||||
"team_summary": {
|
||||
"total_agents": 5,
|
||||
"active_agents": 4,
|
||||
"avg_load_per_agent": 6.2
|
||||
},
|
||||
"escalations": [
|
||||
{
|
||||
"ticket_number": "TKT-2024-000002",
|
||||
"reason": "SLA violation",
|
||||
"assigned_to": "agent1@example.com"
|
||||
}
|
||||
],
|
||||
"trends": {
|
||||
"ticket_volume_change": "+12%",
|
||||
"resolution_time_change": "-5%"
|
||||
}
|
||||
})
|
||||
|
||||
return base_data
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=600,
|
||||
soft_time_limit=540
|
||||
)
|
||||
def send_ticket_notifications(
|
||||
self,
|
||||
ticket_id: str,
|
||||
event_type: str,
|
||||
tenant_id: str,
|
||||
context: Dict[str, Any],
|
||||
correlation_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send ticket-related notifications.
|
||||
|
||||
Args:
|
||||
ticket_id: Ticket ID
|
||||
event_type: Type of event (created, assigned, updated, resolved, etc.)
|
||||
tenant_id: Tenant ID
|
||||
context: Context data for notifications
|
||||
correlation_id: Correlation ID
|
||||
|
||||
Returns:
|
||||
Dict with notification results
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name,
|
||||
ticket_id=ticket_id,
|
||||
event_type=event_type,
|
||||
tenant_id=tenant_id,
|
||||
correlation_id=correlation_id
|
||||
)
|
||||
|
||||
task_logger.info("Starting ticket notifications")
|
||||
|
||||
try:
|
||||
notification_results = {
|
||||
"ticket_id": ticket_id,
|
||||
"event_type": event_type,
|
||||
"notifications_sent": 0,
|
||||
"notifications": []
|
||||
}
|
||||
|
||||
# Determine who should receive notifications based on event type
|
||||
recipients = get_notification_recipients(ticket_id, event_type, tenant_id)
|
||||
|
||||
for recipient in recipients:
|
||||
try:
|
||||
template_name = f"ticket_{event_type}"
|
||||
|
||||
# Send notification
|
||||
result = send_templated_email_task.apply_async(kwargs={
|
||||
"to_email": recipient["email"],
|
||||
"template_name": template_name,
|
||||
"context": {
|
||||
**context,
|
||||
"recipient_name": recipient["name"],
|
||||
"recipient_role": recipient["role"]
|
||||
},
|
||||
"tenant_id": tenant_id,
|
||||
"correlation_id": correlation_id or self.request.id
|
||||
}).get()
|
||||
|
||||
notification_results["notifications"].append({
|
||||
"recipient": recipient["email"],
|
||||
"template": template_name,
|
||||
"success": result.get("success", False),
|
||||
"error": result.get("error")
|
||||
})
|
||||
|
||||
if result.get("success"):
|
||||
notification_results["notifications_sent"] += 1
|
||||
|
||||
except Exception as e:
|
||||
task_logger.error(
|
||||
"Failed to send notification",
|
||||
recipient_email=recipient["email"],
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
notification_results["notifications"].append({
|
||||
"recipient": recipient["email"],
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
task_logger.info(
|
||||
"Ticket notifications completed",
|
||||
notifications_sent=notification_results["notifications_sent"],
|
||||
total_recipients=len(recipients)
|
||||
)
|
||||
|
||||
return notification_results
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"Ticket notifications failed",
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def get_notification_recipients(
|
||||
ticket_id: str,
|
||||
event_type: str,
|
||||
tenant_id: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of users who should receive notifications for a ticket event.
|
||||
|
||||
Args:
|
||||
ticket_id: Ticket ID
|
||||
event_type: Event type
|
||||
tenant_id: Tenant ID
|
||||
|
||||
Returns:
|
||||
List of recipient dicts
|
||||
"""
|
||||
# TODO: Implement actual database queries
|
||||
# For now, return mock recipients based on event type
|
||||
|
||||
recipients = []
|
||||
|
||||
if event_type == "created":
|
||||
# Notify assigned agent (if any) and customer
|
||||
recipients = [
|
||||
{"email": "customer@example.com", "name": "Customer", "role": "CLIENT_USER"},
|
||||
{"email": "agent@example.com", "name": "Agent", "role": "AGENT"}
|
||||
]
|
||||
|
||||
elif event_type == "assigned":
|
||||
# Notify assigned agent and customer
|
||||
recipients = [
|
||||
{"email": "agent@example.com", "name": "Assigned Agent", "role": "AGENT"},
|
||||
{"email": "customer@example.com", "name": "Customer", "role": "CLIENT_USER"}
|
||||
]
|
||||
|
||||
elif event_type == "updated":
|
||||
# Notify all participants
|
||||
recipients = [
|
||||
{"email": "customer@example.com", "name": "Customer", "role": "CLIENT_USER"},
|
||||
{"email": "agent@example.com", "name": "Agent", "role": "AGENT"}
|
||||
]
|
||||
|
||||
elif event_type == "resolved":
|
||||
# Notify customer for feedback
|
||||
recipients = [
|
||||
{"email": "customer@example.com", "name": "Customer", "role": "CLIENT_USER"}
|
||||
]
|
||||
|
||||
elif event_type == "escalated":
|
||||
# Notify manager
|
||||
recipients = [
|
||||
{"email": "manager@example.com", "name": "Manager", "role": "SUPPORT_MANAGER"}
|
||||
]
|
||||
|
||||
return recipients
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=300,
|
||||
soft_time_limit=240
|
||||
)
|
||||
def send_system_alert(
|
||||
self,
|
||||
alert_type: str,
|
||||
message: str,
|
||||
severity: str = "INFO",
|
||||
tenant_id: Optional[str] = None,
|
||||
context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Send system alert to administrators.
|
||||
|
||||
Args:
|
||||
alert_type: Type of alert (system_error, sla_violation, etc.)
|
||||
message: Alert message
|
||||
severity: Alert severity (INFO, WARNING, ERROR, CRITICAL)
|
||||
tenant_id: Optional tenant ID
|
||||
context: Additional context data
|
||||
|
||||
Returns:
|
||||
Dict with alert results
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name,
|
||||
alert_type=alert_type,
|
||||
severity=severity,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
task_logger.info("Sending system alert", message=message)
|
||||
|
||||
try:
|
||||
# TODO: Get administrators from configuration/database
|
||||
admin_emails = ["admin@example.com", "alerts@example.com"]
|
||||
|
||||
alert_context = {
|
||||
"alert_type": alert_type,
|
||||
"message": message,
|
||||
"severity": severity,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"environment": settings.ENVIRONMENT,
|
||||
"tenant_id": tenant_id,
|
||||
**(context or {})
|
||||
}
|
||||
|
||||
notifications_sent = 0
|
||||
|
||||
for admin_email in admin_emails:
|
||||
try:
|
||||
send_templated_email_task.apply_async(kwargs={
|
||||
"to_email": admin_email,
|
||||
"template_name": "system_alert",
|
||||
"context": alert_context,
|
||||
"tenant_id": tenant_id,
|
||||
"correlation_id": self.request.id
|
||||
})
|
||||
notifications_sent += 1
|
||||
|
||||
except Exception as e:
|
||||
task_logger.error(
|
||||
"Failed to send alert to admin",
|
||||
admin_email=admin_email,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
task_logger.info(
|
||||
"System alert sent",
|
||||
notifications_sent=notifications_sent,
|
||||
total_admins=len(admin_emails)
|
||||
)
|
||||
|
||||
return {
|
||||
"alert_type": alert_type,
|
||||
"message": message,
|
||||
"severity": severity,
|
||||
"notifications_sent": notifications_sent,
|
||||
"sent_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"System alert failed",
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
327
workers/app/tasks/sla_tasks.py
Normal file
327
workers/app/tasks/sla_tasks.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
SLA Tasks - ServiceManagerWeb Workers
|
||||
|
||||
Tareas para monitoreo y gestión de SLAs
|
||||
"""
|
||||
|
||||
from celery import current_task
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
import structlog
|
||||
|
||||
from app.celery import celery_app
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
from app.tasks.email_tasks import send_templated_email_task
|
||||
|
||||
settings = get_settings()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=300,
|
||||
soft_time_limit=240
|
||||
)
|
||||
def check_sla_violations(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Check for SLA violations and send alerts.
|
||||
|
||||
This task runs every 5 minutes to check for:
|
||||
- Response SLA violations
|
||||
- Resolution SLA violations
|
||||
- SLA warnings (approaching deadline)
|
||||
|
||||
Returns:
|
||||
Dict with check results
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name
|
||||
)
|
||||
|
||||
task_logger.info("Starting SLA violations check")
|
||||
|
||||
if not settings.SLA_CHECK_ENABLED:
|
||||
task_logger.info("SLA check disabled, skipping")
|
||||
return {"status": "disabled"}
|
||||
|
||||
try:
|
||||
current_time = datetime.utcnow()
|
||||
results = {
|
||||
"checked_at": current_time.isoformat(),
|
||||
"response_violations": [],
|
||||
"resolution_violations": [],
|
||||
"warnings": [],
|
||||
"notifications_sent": 0
|
||||
}
|
||||
|
||||
# TODO: Implement actual database queries
|
||||
# For now, simulate some checks
|
||||
|
||||
# Mock violations for development
|
||||
mock_violations = [
|
||||
{
|
||||
"ticket_id": "mock-ticket-1",
|
||||
"ticket_number": "TKT-2024-000001",
|
||||
"subject": "Problema urgente de conexión",
|
||||
"priority": "HIGH",
|
||||
"sla_type": "response",
|
||||
"due_at": (current_time - timedelta(minutes=30)).isoformat(),
|
||||
"assigned_to_email": "agent@example.com",
|
||||
"created_by_email": "cliente@example.com",
|
||||
"tenant_id": "mock-tenant-1"
|
||||
}
|
||||
]
|
||||
|
||||
# Process violations
|
||||
for violation in mock_violations:
|
||||
task_logger.info(
|
||||
"Processing SLA violation",
|
||||
ticket_id=violation["ticket_id"],
|
||||
sla_type=violation["sla_type"]
|
||||
)
|
||||
|
||||
if violation["sla_type"] == "response":
|
||||
results["response_violations"].append(violation)
|
||||
|
||||
# Send notification to assigned agent
|
||||
if violation["assigned_to_email"]:
|
||||
send_templated_email_task.apply_async(kwargs={
|
||||
"to_email": violation["assigned_to_email"],
|
||||
"template_name": "sla_response_violation",
|
||||
"context": {
|
||||
"ticket_number": violation["ticket_number"],
|
||||
"subject": violation["subject"],
|
||||
"priority": violation["priority"],
|
||||
"due_at": violation["due_at"],
|
||||
"ticket_url": f"https://admin.servicemanager.local/tickets/{violation['ticket_id']}"
|
||||
},
|
||||
"tenant_id": violation["tenant_id"],
|
||||
"correlation_id": self.request.id
|
||||
})
|
||||
results["notifications_sent"] += 1
|
||||
|
||||
elif violation["sla_type"] == "resolution":
|
||||
results["resolution_violations"].append(violation)
|
||||
|
||||
# Send escalation notification
|
||||
send_templated_email_task.apply_async(kwargs={
|
||||
"to_email": "manager@example.com", # TODO: Get from tenant config
|
||||
"template_name": "sla_resolution_violation",
|
||||
"context": {
|
||||
"ticket_number": violation["ticket_number"],
|
||||
"subject": violation["subject"],
|
||||
"priority": violation["priority"],
|
||||
"assigned_to": violation["assigned_to_email"],
|
||||
"ticket_url": f"https://admin.servicemanager.local/tickets/{violation['ticket_id']}"
|
||||
},
|
||||
"tenant_id": violation["tenant_id"],
|
||||
"correlation_id": self.request.id
|
||||
})
|
||||
results["notifications_sent"] += 1
|
||||
|
||||
# TODO: Check for SLA warnings (approaching deadline)
|
||||
|
||||
task_logger.info(
|
||||
"SLA violations check completed",
|
||||
response_violations=len(results["response_violations"]),
|
||||
resolution_violations=len(results["resolution_violations"]),
|
||||
warnings=len(results["warnings"]),
|
||||
notifications_sent=results["notifications_sent"]
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"SLA violations check failed",
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=600,
|
||||
soft_time_limit=540
|
||||
)
|
||||
def calculate_sla_metrics(self, tenant_id: str, date_from: str, date_to: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate SLA metrics for a tenant and date range.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
date_from: Start date (ISO format)
|
||||
date_to: End date (ISO format)
|
||||
|
||||
Returns:
|
||||
Dict with SLA metrics
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
task_logger.info(
|
||||
"Starting SLA metrics calculation",
|
||||
date_from=date_from,
|
||||
date_to=date_to
|
||||
)
|
||||
|
||||
try:
|
||||
# TODO: Implement actual database queries
|
||||
# For now, return mock metrics
|
||||
|
||||
metrics = {
|
||||
"tenant_id": tenant_id,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
"calculated_at": datetime.utcnow().isoformat(),
|
||||
"response_sla": {
|
||||
"target_hours": 2,
|
||||
"met_count": 45,
|
||||
"total_count": 50,
|
||||
"percentage": 90.0,
|
||||
"avg_response_time_hours": 1.8
|
||||
},
|
||||
"resolution_sla": {
|
||||
"target_hours": 24,
|
||||
"met_count": 42,
|
||||
"total_count": 48,
|
||||
"percentage": 87.5,
|
||||
"avg_resolution_time_hours": 22.5
|
||||
},
|
||||
"by_priority": {
|
||||
"LOW": {
|
||||
"response_sla_percentage": 95.0,
|
||||
"resolution_sla_percentage": 90.0
|
||||
},
|
||||
"MEDIUM": {
|
||||
"response_sla_percentage": 88.0,
|
||||
"resolution_sla_percentage": 85.0
|
||||
},
|
||||
"HIGH": {
|
||||
"response_sla_percentage": 92.0,
|
||||
"resolution_sla_percentage": 88.0
|
||||
},
|
||||
"URGENT": {
|
||||
"response_sla_percentage": 85.0,
|
||||
"resolution_sla_percentage": 80.0
|
||||
}
|
||||
},
|
||||
"trends": {
|
||||
"response_sla_trend": "+2.5%",
|
||||
"resolution_sla_trend": "-1.2%"
|
||||
}
|
||||
}
|
||||
|
||||
task_logger.info(
|
||||
"SLA metrics calculation completed",
|
||||
response_sla_percentage=metrics["response_sla"]["percentage"],
|
||||
resolution_sla_percentage=metrics["resolution_sla"]["percentage"]
|
||||
)
|
||||
|
||||
return metrics
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"SLA metrics calculation failed",
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
time_limit=300,
|
||||
soft_time_limit=240
|
||||
)
|
||||
def send_sla_warnings(self, tenant_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Send SLA warning notifications for tickets approaching deadline.
|
||||
|
||||
Args:
|
||||
tenant_id: Optional tenant ID to filter by
|
||||
|
||||
Returns:
|
||||
Dict with warning results
|
||||
"""
|
||||
task_logger = logger.bind(
|
||||
task_id=self.request.id,
|
||||
task_name=self.name,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
task_logger.info("Starting SLA warnings check")
|
||||
|
||||
try:
|
||||
current_time = datetime.utcnow()
|
||||
warning_threshold = settings.SLA_WARNING_THRESHOLD # 80% of SLA time
|
||||
|
||||
# TODO: Query database for tickets approaching SLA deadlines
|
||||
|
||||
# Mock warnings
|
||||
warnings = [
|
||||
{
|
||||
"ticket_id": "mock-ticket-2",
|
||||
"ticket_number": "TKT-2024-000002",
|
||||
"subject": "Consulta técnica",
|
||||
"priority": "MEDIUM",
|
||||
"sla_type": "response",
|
||||
"due_at": (current_time + timedelta(minutes=30)).isoformat(),
|
||||
"time_remaining_percent": 15.0,
|
||||
"assigned_to_email": "agent@example.com",
|
||||
"tenant_id": "mock-tenant-1"
|
||||
}
|
||||
]
|
||||
|
||||
notifications_sent = 0
|
||||
|
||||
for warning in warnings:
|
||||
if warning["time_remaining_percent"] <= (1 - warning_threshold) * 100:
|
||||
task_logger.info(
|
||||
"Sending SLA warning",
|
||||
ticket_id=warning["ticket_id"],
|
||||
time_remaining_percent=warning["time_remaining_percent"]
|
||||
)
|
||||
|
||||
send_templated_email_task.apply_async(kwargs={
|
||||
"to_email": warning["assigned_to_email"],
|
||||
"template_name": "sla_warning",
|
||||
"context": {
|
||||
"ticket_number": warning["ticket_number"],
|
||||
"subject": warning["subject"],
|
||||
"priority": warning["priority"],
|
||||
"sla_type": warning["sla_type"],
|
||||
"due_at": warning["due_at"],
|
||||
"time_remaining_percent": warning["time_remaining_percent"],
|
||||
"ticket_url": f"https://admin.servicemanager.local/tickets/{warning['ticket_id']}"
|
||||
},
|
||||
"tenant_id": warning["tenant_id"],
|
||||
"correlation_id": self.request.id
|
||||
})
|
||||
notifications_sent += 1
|
||||
|
||||
task_logger.info(
|
||||
"SLA warnings check completed",
|
||||
warnings_found=len(warnings),
|
||||
notifications_sent=notifications_sent
|
||||
)
|
||||
|
||||
return {
|
||||
"warnings_found": len(warnings),
|
||||
"notifications_sent": notifications_sent,
|
||||
"warnings": warnings
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
task_logger.error(
|
||||
"SLA warnings check failed",
|
||||
error=str(exc),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
Reference in New Issue
Block a user