New alert system and panel de control page
This commit is contained in:
@@ -13,7 +13,6 @@ import structlog
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from shared.alerts.base_service import BaseAlertService, AlertServiceMixin
|
||||
from shared.alerts.templates import format_item_message
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -127,17 +126,13 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
percentage = issue['capacity_percentage']
|
||||
|
||||
if status == 'severe_overload':
|
||||
template_data = self.format_spanish_message(
|
||||
'order_overload',
|
||||
percentage=int(percentage - 100)
|
||||
)
|
||||
|
||||
await self.publish_item(tenant_id, {
|
||||
'type': 'severe_capacity_overload',
|
||||
'severity': 'urgent',
|
||||
'title': template_data['title'],
|
||||
'message': template_data['message'],
|
||||
'actions': template_data['actions'],
|
||||
'title': 'Raw Alert - Will be enriched',
|
||||
'message': 'Raw Alert - Will be enriched',
|
||||
'actions': [],
|
||||
'metadata': {
|
||||
'planned_date': issue['planned_date'].isoformat(),
|
||||
'capacity_percentage': float(percentage),
|
||||
@@ -228,20 +223,16 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
else:
|
||||
severity = 'low'
|
||||
|
||||
template_data = self.format_spanish_message(
|
||||
'production_delay',
|
||||
batch_name=f"{delay['product_name']} #{delay['batch_number']}",
|
||||
delay_minutes=int(delay_minutes)
|
||||
)
|
||||
|
||||
await self.publish_item(delay['tenant_id'], {
|
||||
'type': 'production_delay',
|
||||
'severity': severity,
|
||||
'title': template_data['title'],
|
||||
'message': template_data['message'],
|
||||
'actions': template_data['actions'],
|
||||
'title': 'Raw Alert - Will be enriched',
|
||||
'message': 'Raw Alert - Will be enriched',
|
||||
'actions': [],
|
||||
'metadata': {
|
||||
'batch_id': str(delay['id']),
|
||||
'batch_name': f"{delay['product_name']} #{delay['batch_number']}",
|
||||
'product_name': delay['product_name'],
|
||||
'batch_number': delay['batch_number'],
|
||||
'delay_minutes': delay_minutes,
|
||||
@@ -367,17 +358,13 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
days_to_maintenance = equipment.get('days_to_maintenance', 30)
|
||||
|
||||
if status == 'down':
|
||||
template_data = self.format_spanish_message(
|
||||
'equipment_failure',
|
||||
equipment_name=equipment['name']
|
||||
)
|
||||
|
||||
await self.publish_item(equipment['tenant_id'], {
|
||||
'type': 'equipment_failure',
|
||||
'severity': 'urgent',
|
||||
'title': template_data['title'],
|
||||
'message': template_data['message'],
|
||||
'actions': template_data['actions'],
|
||||
'title': 'Raw Alert - Will be enriched',
|
||||
'message': 'Raw Alert - Will be enriched',
|
||||
'actions': [],
|
||||
'metadata': {
|
||||
'equipment_id': str(equipment['id']),
|
||||
'equipment_name': equipment['name'],
|
||||
@@ -389,18 +376,13 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
elif status == 'maintenance' or (days_to_maintenance is not None and days_to_maintenance <= 3):
|
||||
severity = 'high' if (days_to_maintenance is not None and days_to_maintenance <= 1) else 'medium'
|
||||
|
||||
template_data = self.format_spanish_message(
|
||||
'maintenance_required',
|
||||
equipment_name=equipment['name'],
|
||||
days_until_maintenance=max(0, int(days_to_maintenance)) if days_to_maintenance is not None else 3
|
||||
)
|
||||
|
||||
await self.publish_item(equipment['tenant_id'], {
|
||||
'type': 'maintenance_required',
|
||||
'severity': severity,
|
||||
'title': template_data['title'],
|
||||
'message': template_data['message'],
|
||||
'actions': template_data['actions'],
|
||||
'title': 'Raw Alert - Will be enriched',
|
||||
'message': 'Raw Alert - Will be enriched',
|
||||
'actions': [],
|
||||
'metadata': {
|
||||
'equipment_id': str(equipment['id']),
|
||||
'equipment_name': equipment['name'],
|
||||
@@ -412,18 +394,13 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
elif efficiency is not None and efficiency < 80:
|
||||
severity = 'medium' if efficiency < 70 else 'low'
|
||||
|
||||
template_data = self.format_spanish_message(
|
||||
'low_equipment_efficiency',
|
||||
equipment_name=equipment['name'],
|
||||
efficiency_percent=round(efficiency, 1)
|
||||
)
|
||||
|
||||
await self.publish_item(equipment['tenant_id'], {
|
||||
'type': 'low_equipment_efficiency',
|
||||
'severity': severity,
|
||||
'title': template_data['title'],
|
||||
'message': template_data['message'],
|
||||
'actions': template_data['actions'],
|
||||
'title': 'Raw Alert - Will be enriched',
|
||||
'message': 'Raw Alert - Will be enriched',
|
||||
'actions': [],
|
||||
'metadata': {
|
||||
'equipment_id': str(equipment['id']),
|
||||
'equipment_name': equipment['name'],
|
||||
@@ -476,19 +453,15 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
efficiency_loss = rec['efficiency_loss_percent']
|
||||
|
||||
if rec_type == 'reduce_production_time':
|
||||
template_data = self.format_spanish_message(
|
||||
'production_efficiency',
|
||||
suggested_time=f"{rec['start_hour']:02d}:00",
|
||||
savings_percent=efficiency_loss
|
||||
)
|
||||
|
||||
await self.publish_item(tenant_id, {
|
||||
'type': 'production_efficiency',
|
||||
'severity': 'medium',
|
||||
'title': template_data['title'],
|
||||
'message': template_data['message'],
|
||||
'actions': template_data['actions'],
|
||||
'title': 'Raw Alert - Will be enriched',
|
||||
'message': 'Raw Alert - Will be enriched',
|
||||
'actions': [],
|
||||
'metadata': {
|
||||
'suggested_time': f"{rec['start_hour']:02d}:00",
|
||||
'product_name': rec['product_name'],
|
||||
'avg_production_time': float(rec['avg_production_time']),
|
||||
'avg_planned_duration': float(rec['avg_planned_duration']),
|
||||
@@ -585,20 +558,17 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
peak_hour_record['avg_energy']) * 100
|
||||
|
||||
if potential_savings > 15: # More than 15% potential savings
|
||||
template_data = self.format_spanish_message(
|
||||
'energy_optimization',
|
||||
start_time=f"{min_off_peak['hour_of_day']:02d}:00",
|
||||
end_time=f"{min_off_peak['hour_of_day']+2:02d}:00",
|
||||
savings_euros=potential_savings * 0.15 # Rough estimate
|
||||
)
|
||||
|
||||
await self.publish_item(tenant_id, {
|
||||
'type': 'energy_optimization',
|
||||
'severity': 'low',
|
||||
'title': template_data['title'],
|
||||
'message': template_data['message'],
|
||||
'actions': template_data['actions'],
|
||||
'title': 'Raw Alert - Will be enriched',
|
||||
'message': 'Raw Alert - Will be enriched',
|
||||
'actions': [],
|
||||
'metadata': {
|
||||
'start_time': f"{min_off_peak['hour_of_day']:02d}:00",
|
||||
'end_time': f"{min_off_peak['hour_of_day']+2:02d}:00",
|
||||
'savings_euros': round(potential_savings * 0.15, 2),
|
||||
'equipment_name': equipment,
|
||||
'peak_hour': peak_hour_record['hour_of_day'],
|
||||
'optimal_hour': min_off_peak['hour_of_day'],
|
||||
@@ -629,20 +599,16 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
data = json.loads(payload)
|
||||
tenant_id = UUID(data['tenant_id'])
|
||||
|
||||
template_data = self.format_spanish_message(
|
||||
'production_delay',
|
||||
batch_name=f"{data['product_name']} #{data.get('batch_number', 'N/A')}",
|
||||
delay_minutes=data['delay_minutes']
|
||||
)
|
||||
|
||||
await self.publish_item(tenant_id, {
|
||||
'type': 'production_delay',
|
||||
'severity': 'high',
|
||||
'title': template_data['title'],
|
||||
'message': template_data['message'],
|
||||
'actions': template_data['actions'],
|
||||
'title': 'Raw Alert - Will be enriched',
|
||||
'message': 'Raw Alert - Will be enriched',
|
||||
'actions': [],
|
||||
'metadata': {
|
||||
'batch_id': data['batch_id'],
|
||||
'batch_name': f"{data['product_name']} #{data.get('batch_number', 'N/A')}",
|
||||
'delay_minutes': data['delay_minutes'],
|
||||
'trigger_source': 'database'
|
||||
}
|
||||
@@ -711,4 +677,84 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
logger.error("Error getting affected production batches",
|
||||
ingredient_id=ingredient_id,
|
||||
error=str(e))
|
||||
return []
|
||||
return []
|
||||
|
||||
async def emit_batch_start_alert(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
batch_id: str,
|
||||
batch_number: str,
|
||||
product_name: str,
|
||||
product_sku: str,
|
||||
quantity_planned: float,
|
||||
unit: str,
|
||||
priority: str = "normal",
|
||||
estimated_duration_minutes: Optional[int] = None,
|
||||
scheduled_start_time: Optional[datetime] = None,
|
||||
reasoning_data: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Emit action_needed alert when a production batch is ready to start.
|
||||
This appears in the Cola de Acciones (Action Queue) to prompt user to start the batch.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
batch_id: Production batch UUID
|
||||
batch_number: Human-readable batch number
|
||||
product_name: Product name
|
||||
product_sku: Product SKU
|
||||
quantity_planned: Planned quantity
|
||||
unit: Unit of measurement
|
||||
priority: Batch priority (urgent, high, normal, low)
|
||||
estimated_duration_minutes: Estimated production duration
|
||||
scheduled_start_time: When batch is scheduled to start
|
||||
reasoning_data: Structured reasoning from orchestrator (if auto-created)
|
||||
"""
|
||||
try:
|
||||
# Determine severity based on priority and timing
|
||||
if priority == 'urgent':
|
||||
severity = 'urgent'
|
||||
elif priority == 'high':
|
||||
severity = 'high'
|
||||
else:
|
||||
severity = 'medium'
|
||||
|
||||
# Build alert metadata
|
||||
metadata = {
|
||||
'batch_id': str(batch_id),
|
||||
'batch_number': batch_number,
|
||||
'product_name': product_name,
|
||||
'product_sku': product_sku,
|
||||
'quantity_planned': float(quantity_planned),
|
||||
'unit': unit,
|
||||
'priority': priority,
|
||||
'estimated_duration_minutes': estimated_duration_minutes,
|
||||
'scheduled_start_time': scheduled_start_time.isoformat() if scheduled_start_time else None,
|
||||
'reasoning_data': reasoning_data
|
||||
}
|
||||
|
||||
await self.publish_item(tenant_id, {
|
||||
'type': 'production_batch_start',
|
||||
'type_class': 'action_needed',
|
||||
'severity': severity,
|
||||
'title': 'Raw Alert - Will be enriched',
|
||||
'message': 'Raw Alert - Will be enriched',
|
||||
'actions': ['start_production_batch', 'reschedule_batch', 'view_batch_details'],
|
||||
'metadata': metadata
|
||||
}, item_type='alert')
|
||||
|
||||
logger.info(
|
||||
"Production batch start alert emitted",
|
||||
batch_id=str(batch_id),
|
||||
batch_number=batch_number,
|
||||
product_name=product_name,
|
||||
tenant_id=str(tenant_id)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to emit batch start alert",
|
||||
batch_id=str(batch_id),
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
Production Notification Service
|
||||
|
||||
Emits informational notifications for production state changes:
|
||||
- batch_state_changed: When batch transitions between states
|
||||
- batch_completed: When batch production completes
|
||||
- batch_started: When batch production begins
|
||||
|
||||
These are NOTIFICATIONS (not alerts) - informational state changes that don't require user action.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from shared.schemas.event_classification import RawEvent, EventClass, EventDomain
|
||||
from shared.alerts.base_service import BaseAlertService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProductionNotificationService(BaseAlertService):
|
||||
"""
|
||||
Service for emitting production notifications (informational state changes).
|
||||
"""
|
||||
|
||||
def __init__(self, rabbitmq_url: str = None):
|
||||
super().__init__(service_name="production", rabbitmq_url=rabbitmq_url)
|
||||
|
||||
async def emit_batch_state_changed_notification(
|
||||
self,
|
||||
db: Session,
|
||||
tenant_id: str,
|
||||
batch_id: str,
|
||||
product_sku: str,
|
||||
product_name: str,
|
||||
old_status: str,
|
||||
new_status: str,
|
||||
quantity: float,
|
||||
unit: str,
|
||||
assigned_to: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Emit notification when a production batch changes state.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: Tenant ID
|
||||
batch_id: Production batch ID
|
||||
product_sku: Product SKU
|
||||
product_name: Product name
|
||||
old_status: Previous status (PENDING, IN_PROGRESS, COMPLETED, etc.)
|
||||
new_status: New status
|
||||
quantity: Batch quantity
|
||||
unit: Unit of measurement
|
||||
assigned_to: Assigned worker/station (optional)
|
||||
"""
|
||||
try:
|
||||
# Build message based on state transition
|
||||
transition_messages = {
|
||||
("PENDING", "IN_PROGRESS"): f"Production started for {product_name}",
|
||||
("IN_PROGRESS", "COMPLETED"): f"Production completed for {product_name}",
|
||||
("IN_PROGRESS", "PAUSED"): f"Production paused for {product_name}",
|
||||
("PAUSED", "IN_PROGRESS"): f"Production resumed for {product_name}",
|
||||
("IN_PROGRESS", "FAILED"): f"Production failed for {product_name}",
|
||||
}
|
||||
|
||||
message = transition_messages.get(
|
||||
(old_status, new_status),
|
||||
f"{product_name} status changed from {old_status} to {new_status}"
|
||||
)
|
||||
|
||||
# Create notification event
|
||||
event = RawEvent(
|
||||
tenant_id=tenant_id,
|
||||
event_class=EventClass.NOTIFICATION,
|
||||
event_domain=EventDomain.PRODUCTION,
|
||||
event_type="batch_state_changed",
|
||||
title=f"Batch Status: {new_status}",
|
||||
message=f"{message} ({quantity} {unit})",
|
||||
service="production",
|
||||
event_metadata={
|
||||
"batch_id": batch_id,
|
||||
"product_sku": product_sku,
|
||||
"product_name": product_name,
|
||||
"old_status": old_status,
|
||||
"new_status": new_status,
|
||||
"quantity": quantity,
|
||||
"unit": unit,
|
||||
"assigned_to": assigned_to,
|
||||
"state_changed_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Publish to RabbitMQ for processing
|
||||
await self.publish_item(tenant_id, event.dict(), item_type="notification")
|
||||
|
||||
logger.info(
|
||||
f"Batch state change notification emitted: {batch_id} ({old_status} → {new_status})",
|
||||
extra={"tenant_id": tenant_id, "batch_id": batch_id}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to emit batch state change notification: {e}",
|
||||
extra={"tenant_id": tenant_id, "batch_id": batch_id},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def emit_batch_completed_notification(
|
||||
self,
|
||||
db: Session,
|
||||
tenant_id: str,
|
||||
batch_id: str,
|
||||
product_sku: str,
|
||||
product_name: str,
|
||||
quantity_produced: float,
|
||||
unit: str,
|
||||
production_duration_minutes: Optional[int] = None,
|
||||
quality_score: Optional[float] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Emit notification when a production batch is completed.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: Tenant ID
|
||||
batch_id: Production batch ID
|
||||
product_sku: Product SKU
|
||||
product_name: Product name
|
||||
quantity_produced: Quantity produced
|
||||
unit: Unit of measurement
|
||||
production_duration_minutes: Total production time (optional)
|
||||
quality_score: Quality score (0-100, optional)
|
||||
"""
|
||||
try:
|
||||
message = f"Produced {quantity_produced} {unit} of {product_name}"
|
||||
if production_duration_minutes:
|
||||
message += f" in {production_duration_minutes} minutes"
|
||||
if quality_score:
|
||||
message += f" (Quality: {quality_score:.1f}%)"
|
||||
|
||||
event = RawEvent(
|
||||
tenant_id=tenant_id,
|
||||
event_class=EventClass.NOTIFICATION,
|
||||
event_domain=EventDomain.PRODUCTION,
|
||||
event_type="batch_completed",
|
||||
title=f"Batch Completed: {product_name}",
|
||||
message=message,
|
||||
service="production",
|
||||
event_metadata={
|
||||
"batch_id": batch_id,
|
||||
"product_sku": product_sku,
|
||||
"product_name": product_name,
|
||||
"quantity_produced": quantity_produced,
|
||||
"unit": unit,
|
||||
"production_duration_minutes": production_duration_minutes,
|
||||
"quality_score": quality_score,
|
||||
"completed_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
await self.publish_item(tenant_id, event.dict(), item_type="notification")
|
||||
|
||||
logger.info(
|
||||
f"Batch completed notification emitted: {batch_id} ({quantity_produced} {unit})",
|
||||
extra={"tenant_id": tenant_id, "batch_id": batch_id}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to emit batch completed notification: {e}",
|
||||
extra={"tenant_id": tenant_id, "batch_id": batch_id},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def emit_batch_started_notification(
|
||||
self,
|
||||
db: Session,
|
||||
tenant_id: str,
|
||||
batch_id: str,
|
||||
product_sku: str,
|
||||
product_name: str,
|
||||
quantity_planned: float,
|
||||
unit: str,
|
||||
estimated_duration_minutes: Optional[int] = None,
|
||||
assigned_to: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Emit notification when a production batch is started.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: Tenant ID
|
||||
batch_id: Production batch ID
|
||||
product_sku: Product SKU
|
||||
product_name: Product name
|
||||
quantity_planned: Planned quantity
|
||||
unit: Unit of measurement
|
||||
estimated_duration_minutes: Estimated duration (optional)
|
||||
assigned_to: Assigned worker/station (optional)
|
||||
"""
|
||||
try:
|
||||
message = f"Started production of {quantity_planned} {unit} of {product_name}"
|
||||
if estimated_duration_minutes:
|
||||
message += f" (Est. {estimated_duration_minutes} min)"
|
||||
if assigned_to:
|
||||
message += f" - Assigned to {assigned_to}"
|
||||
|
||||
event = RawEvent(
|
||||
tenant_id=tenant_id,
|
||||
event_class=EventClass.NOTIFICATION,
|
||||
event_domain=EventDomain.PRODUCTION,
|
||||
event_type="batch_started",
|
||||
title=f"Batch Started: {product_name}",
|
||||
message=message,
|
||||
service="production",
|
||||
event_metadata={
|
||||
"batch_id": batch_id,
|
||||
"product_sku": product_sku,
|
||||
"product_name": product_name,
|
||||
"quantity_planned": quantity_planned,
|
||||
"unit": unit,
|
||||
"estimated_duration_minutes": estimated_duration_minutes,
|
||||
"assigned_to": assigned_to,
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
await self.publish_item(tenant_id, event.dict(), item_type="notification")
|
||||
|
||||
logger.info(
|
||||
f"Batch started notification emitted: {batch_id}",
|
||||
extra={"tenant_id": tenant_id, "batch_id": batch_id}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to emit batch started notification: {e}",
|
||||
extra={"tenant_id": tenant_id, "batch_id": batch_id},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def emit_equipment_status_notification(
|
||||
self,
|
||||
db: Session,
|
||||
tenant_id: str,
|
||||
equipment_id: str,
|
||||
equipment_name: str,
|
||||
old_status: str,
|
||||
new_status: str,
|
||||
reason: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Emit notification when equipment status changes.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: Tenant ID
|
||||
equipment_id: Equipment ID
|
||||
equipment_name: Equipment name
|
||||
old_status: Previous status
|
||||
new_status: New status
|
||||
reason: Reason for status change (optional)
|
||||
"""
|
||||
try:
|
||||
message = f"{equipment_name} status: {old_status} → {new_status}"
|
||||
if reason:
|
||||
message += f" - {reason}"
|
||||
|
||||
event = RawEvent(
|
||||
tenant_id=tenant_id,
|
||||
event_class=EventClass.NOTIFICATION,
|
||||
event_domain=EventDomain.PRODUCTION,
|
||||
event_type="equipment_status_changed",
|
||||
title=f"Equipment Status: {equipment_name}",
|
||||
message=message,
|
||||
service="production",
|
||||
event_metadata={
|
||||
"equipment_id": equipment_id,
|
||||
"equipment_name": equipment_name,
|
||||
"old_status": old_status,
|
||||
"new_status": new_status,
|
||||
"reason": reason,
|
||||
"status_changed_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
await self.publish_item(tenant_id, event.dict(), item_type="notification")
|
||||
|
||||
logger.info(
|
||||
f"Equipment status notification emitted: {equipment_name}",
|
||||
extra={"tenant_id": tenant_id, "equipment_id": equipment_id}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to emit equipment status notification: {e}",
|
||||
extra={"tenant_id": tenant_id, "equipment_id": equipment_id},
|
||||
exc_info=True,
|
||||
)
|
||||
Reference in New Issue
Block a user