refactor: migrate modules from re-exports to canonical implementations
Move actual code implementations into module directories: - orders: 5 services, 4 models, order/invoice schemas - inventory: 3 services, 2 models, 30+ schemas - customers: 3 services, 2 models, customer schemas - messaging: 3 services, 2 models, message/notification schemas - monitoring: background_tasks_service - marketplace: 5+ services including letzshop submodule - dev_tools: code_quality_service, test_runner_service - billing: billing_service - contracts: definition.py Legacy files in app/services/, models/database/, models/schema/ now re-export from canonical module locations for backwards compatibility. Architecture validator passes with 0 errors. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,24 +2,29 @@
|
||||
"""
|
||||
Messaging module services.
|
||||
|
||||
Re-exports messaging-related services from their source locations.
|
||||
This module contains the canonical implementations of messaging-related services.
|
||||
"""
|
||||
|
||||
from app.services.messaging_service import (
|
||||
from app.modules.messaging.services.messaging_service import (
|
||||
messaging_service,
|
||||
MessagingService,
|
||||
)
|
||||
from app.services.message_attachment_service import (
|
||||
from app.modules.messaging.services.message_attachment_service import (
|
||||
message_attachment_service,
|
||||
MessageAttachmentService,
|
||||
)
|
||||
from app.services.admin_notification_service import (
|
||||
from app.modules.messaging.services.admin_notification_service import (
|
||||
admin_notification_service,
|
||||
AdminNotificationService,
|
||||
platform_alert_service,
|
||||
PlatformAlertService,
|
||||
# Constants
|
||||
NotificationType,
|
||||
Priority,
|
||||
AlertType,
|
||||
Severity,
|
||||
)
|
||||
|
||||
# Note: notification_service is a placeholder - not yet implemented
|
||||
|
||||
__all__ = [
|
||||
"messaging_service",
|
||||
"MessagingService",
|
||||
@@ -27,4 +32,11 @@ __all__ = [
|
||||
"MessageAttachmentService",
|
||||
"admin_notification_service",
|
||||
"AdminNotificationService",
|
||||
"platform_alert_service",
|
||||
"PlatformAlertService",
|
||||
# Constants
|
||||
"NotificationType",
|
||||
"Priority",
|
||||
"AlertType",
|
||||
"Severity",
|
||||
]
|
||||
|
||||
702
app/modules/messaging/services/admin_notification_service.py
Normal file
702
app/modules/messaging/services/admin_notification_service.py
Normal file
@@ -0,0 +1,702 @@
|
||||
# app/modules/messaging/services/admin_notification_service.py
|
||||
"""
|
||||
Admin notification service.
|
||||
|
||||
Provides functionality for:
|
||||
- Creating and managing admin notifications
|
||||
- Managing platform alerts
|
||||
- Notification statistics and queries
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, case, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.messaging.models.admin_notification import AdminNotification
|
||||
from models.database.admin import PlatformAlert
|
||||
from models.schema.admin import AdminNotificationCreate, PlatformAlertCreate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NOTIFICATION TYPES
|
||||
# ============================================================================
|
||||
|
||||
class NotificationType:
|
||||
"""Notification type constants."""
|
||||
|
||||
SYSTEM_ALERT = "system_alert"
|
||||
IMPORT_FAILURE = "import_failure"
|
||||
EXPORT_FAILURE = "export_failure"
|
||||
ORDER_SYNC_FAILURE = "order_sync_failure"
|
||||
VENDOR_ISSUE = "vendor_issue"
|
||||
CUSTOMER_MESSAGE = "customer_message"
|
||||
VENDOR_MESSAGE = "vendor_message"
|
||||
SECURITY_ALERT = "security_alert"
|
||||
PERFORMANCE_ALERT = "performance_alert"
|
||||
ORDER_EXCEPTION = "order_exception"
|
||||
CRITICAL_ERROR = "critical_error"
|
||||
|
||||
|
||||
class Priority:
|
||||
"""Priority level constants."""
|
||||
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
class AlertType:
|
||||
"""Platform alert type constants."""
|
||||
|
||||
SECURITY = "security"
|
||||
PERFORMANCE = "performance"
|
||||
CAPACITY = "capacity"
|
||||
INTEGRATION = "integration"
|
||||
DATABASE = "database"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
class Severity:
|
||||
"""Alert severity constants."""
|
||||
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ADMIN NOTIFICATION SERVICE
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AdminNotificationService:
|
||||
"""Service for managing admin notifications."""
|
||||
|
||||
def create_notification(
|
||||
self,
|
||||
db: Session,
|
||||
notification_type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
priority: str = Priority.NORMAL,
|
||||
action_required: bool = False,
|
||||
action_url: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> AdminNotification:
|
||||
"""
|
||||
Create a new admin notification.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
notification_type: Type of notification
|
||||
title: Notification title
|
||||
message: Notification message
|
||||
priority: Priority level (low, normal, high, critical)
|
||||
action_required: Whether action is required
|
||||
action_url: URL to relevant admin page
|
||||
metadata: Additional contextual data
|
||||
|
||||
Returns:
|
||||
Created AdminNotification
|
||||
"""
|
||||
notification = AdminNotification(
|
||||
type=notification_type,
|
||||
title=title,
|
||||
message=message,
|
||||
priority=priority,
|
||||
action_required=action_required,
|
||||
action_url=action_url,
|
||||
notification_metadata=metadata,
|
||||
)
|
||||
db.add(notification)
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Created notification: {notification_type} - {title} (priority: {priority})"
|
||||
)
|
||||
|
||||
return notification
|
||||
|
||||
def create_from_schema(
|
||||
self,
|
||||
db: Session,
|
||||
data: AdminNotificationCreate,
|
||||
) -> AdminNotification:
|
||||
"""Create notification from Pydantic schema."""
|
||||
return self.create_notification(
|
||||
db=db,
|
||||
notification_type=data.type,
|
||||
title=data.title,
|
||||
message=data.message,
|
||||
priority=data.priority,
|
||||
action_required=data.action_required,
|
||||
action_url=data.action_url,
|
||||
metadata=data.metadata,
|
||||
)
|
||||
|
||||
def get_notifications(
|
||||
self,
|
||||
db: Session,
|
||||
priority: str | None = None,
|
||||
is_read: bool | None = None,
|
||||
notification_type: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[AdminNotification], int, int]:
|
||||
"""
|
||||
Get paginated admin notifications.
|
||||
|
||||
Returns:
|
||||
Tuple of (notifications, total_count, unread_count)
|
||||
"""
|
||||
query = db.query(AdminNotification)
|
||||
|
||||
# Apply filters
|
||||
if priority:
|
||||
query = query.filter(AdminNotification.priority == priority)
|
||||
|
||||
if is_read is not None:
|
||||
query = query.filter(AdminNotification.is_read == is_read)
|
||||
|
||||
if notification_type:
|
||||
query = query.filter(AdminNotification.type == notification_type)
|
||||
|
||||
# Get counts
|
||||
total = query.count()
|
||||
unread_count = (
|
||||
db.query(AdminNotification)
|
||||
.filter(AdminNotification.is_read == False) # noqa: E712
|
||||
.count()
|
||||
)
|
||||
|
||||
# Get paginated results ordered by priority and date
|
||||
priority_order = case(
|
||||
(AdminNotification.priority == "critical", 1),
|
||||
(AdminNotification.priority == "high", 2),
|
||||
(AdminNotification.priority == "normal", 3),
|
||||
(AdminNotification.priority == "low", 4),
|
||||
else_=5,
|
||||
)
|
||||
|
||||
notifications = (
|
||||
query.order_by(
|
||||
AdminNotification.is_read, # Unread first
|
||||
priority_order,
|
||||
AdminNotification.created_at.desc(),
|
||||
)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return notifications, total, unread_count
|
||||
|
||||
def get_unread_count(self, db: Session) -> int:
|
||||
"""Get count of unread notifications."""
|
||||
return (
|
||||
db.query(AdminNotification)
|
||||
.filter(AdminNotification.is_read == False) # noqa: E712
|
||||
.count()
|
||||
)
|
||||
|
||||
def get_recent_notifications(
|
||||
self,
|
||||
db: Session,
|
||||
limit: int = 5,
|
||||
) -> list[AdminNotification]:
|
||||
"""Get recent unread notifications for header dropdown."""
|
||||
priority_order = case(
|
||||
(AdminNotification.priority == "critical", 1),
|
||||
(AdminNotification.priority == "high", 2),
|
||||
(AdminNotification.priority == "normal", 3),
|
||||
(AdminNotification.priority == "low", 4),
|
||||
else_=5,
|
||||
)
|
||||
|
||||
return (
|
||||
db.query(AdminNotification)
|
||||
.filter(AdminNotification.is_read == False) # noqa: E712
|
||||
.order_by(priority_order, AdminNotification.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def mark_as_read(
|
||||
self,
|
||||
db: Session,
|
||||
notification_id: int,
|
||||
user_id: int,
|
||||
) -> AdminNotification | None:
|
||||
"""Mark a notification as read."""
|
||||
notification = (
|
||||
db.query(AdminNotification)
|
||||
.filter(AdminNotification.id == notification_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if notification and not notification.is_read:
|
||||
notification.is_read = True
|
||||
notification.read_at = datetime.utcnow()
|
||||
notification.read_by_user_id = user_id
|
||||
db.flush()
|
||||
|
||||
return notification
|
||||
|
||||
def mark_all_as_read(
|
||||
self,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
) -> int:
|
||||
"""Mark all unread notifications as read. Returns count of updated."""
|
||||
now = datetime.utcnow()
|
||||
count = (
|
||||
db.query(AdminNotification)
|
||||
.filter(AdminNotification.is_read == False) # noqa: E712
|
||||
.update(
|
||||
{
|
||||
AdminNotification.is_read: True,
|
||||
AdminNotification.read_at: now,
|
||||
AdminNotification.read_by_user_id: user_id,
|
||||
}
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
return count
|
||||
|
||||
def delete_old_notifications(
|
||||
self,
|
||||
db: Session,
|
||||
days: int = 30,
|
||||
) -> int:
|
||||
"""Delete notifications older than specified days."""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
count = (
|
||||
db.query(AdminNotification)
|
||||
.filter(
|
||||
and_(
|
||||
AdminNotification.is_read == True, # noqa: E712
|
||||
AdminNotification.created_at < cutoff,
|
||||
)
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
db.flush()
|
||||
return count
|
||||
|
||||
def delete_notification(
|
||||
self,
|
||||
db: Session,
|
||||
notification_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a notification by ID.
|
||||
|
||||
Returns:
|
||||
True if notification was deleted, False if not found.
|
||||
"""
|
||||
notification = (
|
||||
db.query(AdminNotification)
|
||||
.filter(AdminNotification.id == notification_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if notification:
|
||||
db.delete(notification)
|
||||
db.flush()
|
||||
logger.info(f"Deleted notification {notification_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# CONVENIENCE METHODS FOR CREATING SPECIFIC NOTIFICATION TYPES
|
||||
# =========================================================================
|
||||
|
||||
def notify_import_failure(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_name: str,
|
||||
job_id: int,
|
||||
error_message: str,
|
||||
vendor_id: int | None = None,
|
||||
) -> AdminNotification:
|
||||
"""Create notification for import job failure."""
|
||||
return self.create_notification(
|
||||
db=db,
|
||||
notification_type=NotificationType.IMPORT_FAILURE,
|
||||
title=f"Import Failed: {vendor_name}",
|
||||
message=error_message,
|
||||
priority=Priority.HIGH,
|
||||
action_required=True,
|
||||
action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=jobs"
|
||||
if vendor_id
|
||||
else "/admin/marketplace",
|
||||
metadata={"vendor_name": vendor_name, "job_id": job_id, "vendor_id": vendor_id},
|
||||
)
|
||||
|
||||
def notify_order_sync_failure(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_name: str,
|
||||
error_message: str,
|
||||
vendor_id: int | None = None,
|
||||
) -> AdminNotification:
|
||||
"""Create notification for order sync failure."""
|
||||
return self.create_notification(
|
||||
db=db,
|
||||
notification_type=NotificationType.ORDER_SYNC_FAILURE,
|
||||
title=f"Order Sync Failed: {vendor_name}",
|
||||
message=error_message,
|
||||
priority=Priority.HIGH,
|
||||
action_required=True,
|
||||
action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=jobs"
|
||||
if vendor_id
|
||||
else "/admin/marketplace/letzshop",
|
||||
metadata={"vendor_name": vendor_name, "vendor_id": vendor_id},
|
||||
)
|
||||
|
||||
def notify_order_exception(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_name: str,
|
||||
order_number: str,
|
||||
exception_count: int,
|
||||
vendor_id: int | None = None,
|
||||
) -> AdminNotification:
|
||||
"""Create notification for order item exceptions."""
|
||||
return self.create_notification(
|
||||
db=db,
|
||||
notification_type=NotificationType.ORDER_EXCEPTION,
|
||||
title=f"Order Exception: {order_number}",
|
||||
message=f"{exception_count} item(s) need attention for order {order_number} ({vendor_name})",
|
||||
priority=Priority.NORMAL,
|
||||
action_required=True,
|
||||
action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=exceptions"
|
||||
if vendor_id
|
||||
else "/admin/marketplace/letzshop",
|
||||
metadata={
|
||||
"vendor_name": vendor_name,
|
||||
"order_number": order_number,
|
||||
"exception_count": exception_count,
|
||||
"vendor_id": vendor_id,
|
||||
},
|
||||
)
|
||||
|
||||
def notify_critical_error(
|
||||
self,
|
||||
db: Session,
|
||||
error_type: str,
|
||||
error_message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> AdminNotification:
|
||||
"""Create notification for critical application errors."""
|
||||
return self.create_notification(
|
||||
db=db,
|
||||
notification_type=NotificationType.CRITICAL_ERROR,
|
||||
title=f"Critical Error: {error_type}",
|
||||
message=error_message,
|
||||
priority=Priority.CRITICAL,
|
||||
action_required=True,
|
||||
action_url="/admin/logs",
|
||||
metadata=details,
|
||||
)
|
||||
|
||||
def notify_vendor_issue(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_name: str,
|
||||
issue_type: str,
|
||||
message: str,
|
||||
vendor_id: int | None = None,
|
||||
) -> AdminNotification:
|
||||
"""Create notification for vendor-related issues."""
|
||||
return self.create_notification(
|
||||
db=db,
|
||||
notification_type=NotificationType.VENDOR_ISSUE,
|
||||
title=f"Vendor Issue: {vendor_name}",
|
||||
message=message,
|
||||
priority=Priority.HIGH,
|
||||
action_required=True,
|
||||
action_url=f"/admin/vendors/{vendor_id}" if vendor_id else "/admin/vendors",
|
||||
metadata={
|
||||
"vendor_name": vendor_name,
|
||||
"issue_type": issue_type,
|
||||
"vendor_id": vendor_id,
|
||||
},
|
||||
)
|
||||
|
||||
def notify_security_alert(
|
||||
self,
|
||||
db: Session,
|
||||
title: str,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> AdminNotification:
|
||||
"""Create notification for security-related alerts."""
|
||||
return self.create_notification(
|
||||
db=db,
|
||||
notification_type=NotificationType.SECURITY_ALERT,
|
||||
title=title,
|
||||
message=message,
|
||||
priority=Priority.CRITICAL,
|
||||
action_required=True,
|
||||
action_url="/admin/audit",
|
||||
metadata=details,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PLATFORM ALERT SERVICE
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class PlatformAlertService:
|
||||
"""Service for managing platform-wide alerts."""
|
||||
|
||||
def create_alert(
|
||||
self,
|
||||
db: Session,
|
||||
alert_type: str,
|
||||
severity: str,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
affected_vendors: list[int] | None = None,
|
||||
affected_systems: list[str] | None = None,
|
||||
auto_generated: bool = True,
|
||||
) -> PlatformAlert:
|
||||
"""Create a new platform alert."""
|
||||
now = datetime.utcnow()
|
||||
|
||||
alert = PlatformAlert(
|
||||
alert_type=alert_type,
|
||||
severity=severity,
|
||||
title=title,
|
||||
description=description,
|
||||
affected_vendors=affected_vendors,
|
||||
affected_systems=affected_systems,
|
||||
auto_generated=auto_generated,
|
||||
first_occurred_at=now,
|
||||
last_occurred_at=now,
|
||||
)
|
||||
db.add(alert)
|
||||
db.flush()
|
||||
|
||||
logger.info(f"Created platform alert: {alert_type} - {title} ({severity})")
|
||||
|
||||
return alert
|
||||
|
||||
def create_from_schema(
|
||||
self,
|
||||
db: Session,
|
||||
data: PlatformAlertCreate,
|
||||
) -> PlatformAlert:
|
||||
"""Create alert from Pydantic schema."""
|
||||
return self.create_alert(
|
||||
db=db,
|
||||
alert_type=data.alert_type,
|
||||
severity=data.severity,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
affected_vendors=data.affected_vendors,
|
||||
affected_systems=data.affected_systems,
|
||||
auto_generated=data.auto_generated,
|
||||
)
|
||||
|
||||
def get_alerts(
|
||||
self,
|
||||
db: Session,
|
||||
severity: str | None = None,
|
||||
alert_type: str | None = None,
|
||||
is_resolved: bool | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[PlatformAlert], int, int, int]:
|
||||
"""
|
||||
Get paginated platform alerts.
|
||||
|
||||
Returns:
|
||||
Tuple of (alerts, total_count, active_count, critical_count)
|
||||
"""
|
||||
query = db.query(PlatformAlert)
|
||||
|
||||
# Apply filters
|
||||
if severity:
|
||||
query = query.filter(PlatformAlert.severity == severity)
|
||||
|
||||
if alert_type:
|
||||
query = query.filter(PlatformAlert.alert_type == alert_type)
|
||||
|
||||
if is_resolved is not None:
|
||||
query = query.filter(PlatformAlert.is_resolved == is_resolved)
|
||||
|
||||
# Get counts
|
||||
total = query.count()
|
||||
active_count = (
|
||||
db.query(PlatformAlert)
|
||||
.filter(PlatformAlert.is_resolved == False) # noqa: E712
|
||||
.count()
|
||||
)
|
||||
critical_count = (
|
||||
db.query(PlatformAlert)
|
||||
.filter(
|
||||
and_(
|
||||
PlatformAlert.is_resolved == False, # noqa: E712
|
||||
PlatformAlert.severity == Severity.CRITICAL,
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
# Get paginated results
|
||||
severity_order = case(
|
||||
(PlatformAlert.severity == "critical", 1),
|
||||
(PlatformAlert.severity == "error", 2),
|
||||
(PlatformAlert.severity == "warning", 3),
|
||||
(PlatformAlert.severity == "info", 4),
|
||||
else_=5,
|
||||
)
|
||||
|
||||
alerts = (
|
||||
query.order_by(
|
||||
PlatformAlert.is_resolved, # Unresolved first
|
||||
severity_order,
|
||||
PlatformAlert.last_occurred_at.desc(),
|
||||
)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return alerts, total, active_count, critical_count
|
||||
|
||||
def resolve_alert(
|
||||
self,
|
||||
db: Session,
|
||||
alert_id: int,
|
||||
user_id: int,
|
||||
resolution_notes: str | None = None,
|
||||
) -> PlatformAlert | None:
|
||||
"""Resolve a platform alert."""
|
||||
alert = db.query(PlatformAlert).filter(PlatformAlert.id == alert_id).first()
|
||||
|
||||
if alert and not alert.is_resolved:
|
||||
alert.is_resolved = True
|
||||
alert.resolved_at = datetime.utcnow()
|
||||
alert.resolved_by_user_id = user_id
|
||||
alert.resolution_notes = resolution_notes
|
||||
db.flush()
|
||||
|
||||
logger.info(f"Resolved platform alert {alert_id}")
|
||||
|
||||
return alert
|
||||
|
||||
def get_statistics(self, db: Session) -> dict[str, int]:
|
||||
"""Get alert statistics."""
|
||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
total = db.query(PlatformAlert).count()
|
||||
active = (
|
||||
db.query(PlatformAlert)
|
||||
.filter(PlatformAlert.is_resolved == False) # noqa: E712
|
||||
.count()
|
||||
)
|
||||
critical = (
|
||||
db.query(PlatformAlert)
|
||||
.filter(
|
||||
and_(
|
||||
PlatformAlert.is_resolved == False, # noqa: E712
|
||||
PlatformAlert.severity == Severity.CRITICAL,
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
resolved_today = (
|
||||
db.query(PlatformAlert)
|
||||
.filter(
|
||||
and_(
|
||||
PlatformAlert.is_resolved == True, # noqa: E712
|
||||
PlatformAlert.resolved_at >= today_start,
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
return {
|
||||
"total_alerts": total,
|
||||
"active_alerts": active,
|
||||
"critical_alerts": critical,
|
||||
"resolved_today": resolved_today,
|
||||
}
|
||||
|
||||
def increment_occurrence(
|
||||
self,
|
||||
db: Session,
|
||||
alert_id: int,
|
||||
) -> PlatformAlert | None:
|
||||
"""Increment occurrence count for repeated alert."""
|
||||
alert = db.query(PlatformAlert).filter(PlatformAlert.id == alert_id).first()
|
||||
|
||||
if alert:
|
||||
alert.occurrence_count += 1
|
||||
alert.last_occurred_at = datetime.utcnow()
|
||||
db.flush()
|
||||
|
||||
return alert
|
||||
|
||||
def find_similar_active_alert(
|
||||
self,
|
||||
db: Session,
|
||||
alert_type: str,
|
||||
title: str,
|
||||
) -> PlatformAlert | None:
|
||||
"""Find an active alert with same type and title."""
|
||||
return (
|
||||
db.query(PlatformAlert)
|
||||
.filter(
|
||||
and_(
|
||||
PlatformAlert.alert_type == alert_type,
|
||||
PlatformAlert.title == title,
|
||||
PlatformAlert.is_resolved == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def create_or_increment_alert(
|
||||
self,
|
||||
db: Session,
|
||||
alert_type: str,
|
||||
severity: str,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
affected_vendors: list[int] | None = None,
|
||||
affected_systems: list[str] | None = None,
|
||||
) -> PlatformAlert:
|
||||
"""Create alert or increment occurrence if similar exists."""
|
||||
existing = self.find_similar_active_alert(db, alert_type, title)
|
||||
|
||||
if existing:
|
||||
self.increment_occurrence(db, existing.id)
|
||||
return existing
|
||||
|
||||
return self.create_alert(
|
||||
db=db,
|
||||
alert_type=alert_type,
|
||||
severity=severity,
|
||||
title=title,
|
||||
description=description,
|
||||
affected_vendors=affected_vendors,
|
||||
affected_systems=affected_systems,
|
||||
)
|
||||
|
||||
|
||||
# Singleton instances
|
||||
admin_notification_service = AdminNotificationService()
|
||||
platform_alert_service = PlatformAlertService()
|
||||
225
app/modules/messaging/services/message_attachment_service.py
Normal file
225
app/modules/messaging/services/message_attachment_service.py
Normal file
@@ -0,0 +1,225 @@
|
||||
# app/modules/messaging/services/message_attachment_service.py
|
||||
"""
|
||||
Attachment handling service for messaging system.
|
||||
|
||||
Handles file upload, validation, storage, and retrieval.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Allowed MIME types for attachments
|
||||
ALLOWED_MIME_TYPES = {
|
||||
# Images
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
# Documents
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
# Archives
|
||||
"application/zip",
|
||||
# Text
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
}
|
||||
|
||||
IMAGE_MIME_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
|
||||
# Default max file size in MB
|
||||
DEFAULT_MAX_FILE_SIZE_MB = 10
|
||||
|
||||
|
||||
class MessageAttachmentService:
|
||||
"""Service for handling message attachments."""
|
||||
|
||||
def __init__(self, storage_base: str = "uploads/messages"):
|
||||
self.storage_base = storage_base
|
||||
|
||||
def get_max_file_size_bytes(self, db: Session) -> int:
|
||||
"""Get maximum file size from platform settings."""
|
||||
max_mb = admin_settings_service.get_setting_value(
|
||||
db,
|
||||
"message_attachment_max_size_mb",
|
||||
default=DEFAULT_MAX_FILE_SIZE_MB,
|
||||
)
|
||||
try:
|
||||
max_mb = int(max_mb)
|
||||
except (TypeError, ValueError):
|
||||
max_mb = DEFAULT_MAX_FILE_SIZE_MB
|
||||
return max_mb * 1024 * 1024 # Convert to bytes
|
||||
|
||||
def validate_file_type(self, mime_type: str) -> bool:
|
||||
"""Check if file type is allowed."""
|
||||
return mime_type in ALLOWED_MIME_TYPES
|
||||
|
||||
def is_image(self, mime_type: str) -> bool:
|
||||
"""Check if file is an image."""
|
||||
return mime_type in IMAGE_MIME_TYPES
|
||||
|
||||
async def validate_and_store(
|
||||
self,
|
||||
db: Session,
|
||||
file: UploadFile,
|
||||
conversation_id: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Validate and store an uploaded file.
|
||||
|
||||
Returns dict with file metadata for MessageAttachment creation.
|
||||
|
||||
Raises:
|
||||
ValueError: If file type or size is invalid
|
||||
"""
|
||||
# Validate MIME type
|
||||
content_type = file.content_type or "application/octet-stream"
|
||||
if not self.validate_file_type(content_type):
|
||||
raise ValueError(
|
||||
f"File type '{content_type}' not allowed. "
|
||||
"Allowed types: images (JPEG, PNG, GIF, WebP), "
|
||||
"PDF, Office documents, ZIP, text files."
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# Validate file size
|
||||
max_size = self.get_max_file_size_bytes(db)
|
||||
if file_size > max_size:
|
||||
raise ValueError(
|
||||
f"File size {file_size / 1024 / 1024:.1f}MB exceeds "
|
||||
f"maximum allowed size of {max_size / 1024 / 1024:.1f}MB"
|
||||
)
|
||||
|
||||
# Generate unique filename
|
||||
original_filename = file.filename or "attachment"
|
||||
ext = Path(original_filename).suffix.lower()
|
||||
unique_filename = f"{uuid.uuid4().hex}{ext}"
|
||||
|
||||
# Create storage path: uploads/messages/YYYY/MM/conversation_id/filename
|
||||
now = datetime.utcnow()
|
||||
relative_path = os.path.join(
|
||||
self.storage_base,
|
||||
str(now.year),
|
||||
f"{now.month:02d}",
|
||||
str(conversation_id),
|
||||
)
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(relative_path, exist_ok=True)
|
||||
|
||||
# Full file path
|
||||
file_path = os.path.join(relative_path, unique_filename)
|
||||
|
||||
# Write file
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Prepare metadata
|
||||
is_image = self.is_image(content_type)
|
||||
metadata = {
|
||||
"filename": unique_filename,
|
||||
"original_filename": original_filename,
|
||||
"file_path": file_path,
|
||||
"file_size": file_size,
|
||||
"mime_type": content_type,
|
||||
"is_image": is_image,
|
||||
}
|
||||
|
||||
# Generate thumbnail for images
|
||||
if is_image:
|
||||
thumbnail_data = self._create_thumbnail(content, file_path)
|
||||
metadata.update(thumbnail_data)
|
||||
|
||||
logger.info(
|
||||
f"Stored attachment {unique_filename} for conversation {conversation_id} "
|
||||
f"({file_size} bytes, type: {content_type})"
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
def _create_thumbnail(self, content: bytes, original_path: str) -> dict:
|
||||
"""Create thumbnail for image attachments."""
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
img = Image.open(io.BytesIO(content))
|
||||
width, height = img.size
|
||||
|
||||
# Create thumbnail
|
||||
img.thumbnail((200, 200))
|
||||
|
||||
thumb_path = original_path.replace(".", "_thumb.")
|
||||
img.save(thumb_path)
|
||||
|
||||
return {
|
||||
"image_width": width,
|
||||
"image_height": height,
|
||||
"thumbnail_path": thumb_path,
|
||||
}
|
||||
except ImportError:
|
||||
logger.warning("PIL not installed, skipping thumbnail generation")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create thumbnail: {e}")
|
||||
return {}
|
||||
|
||||
def delete_attachment(
|
||||
self, file_path: str, thumbnail_path: str | None = None
|
||||
) -> bool:
|
||||
"""Delete attachment files from storage."""
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
logger.info(f"Deleted attachment file: {file_path}")
|
||||
|
||||
if thumbnail_path and os.path.exists(thumbnail_path):
|
||||
os.remove(thumbnail_path)
|
||||
logger.info(f"Deleted thumbnail: {thumbnail_path}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete attachment {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def get_download_url(self, file_path: str) -> str:
|
||||
"""
|
||||
Get download URL for an attachment.
|
||||
|
||||
For local storage, returns a relative path that can be served
|
||||
by the static file handler or a dedicated download endpoint.
|
||||
"""
|
||||
# Convert local path to URL path
|
||||
# Assumes files are served from /static/uploads or similar
|
||||
return f"/static/{file_path}"
|
||||
|
||||
def get_file_content(self, file_path: str) -> bytes | None:
|
||||
"""Read file content from storage."""
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
with open(file_path, "rb") as f:
|
||||
return f.read()
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read file {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance
|
||||
message_attachment_service = MessageAttachmentService()
|
||||
684
app/modules/messaging/services/messaging_service.py
Normal file
684
app/modules/messaging/services/messaging_service.py
Normal file
@@ -0,0 +1,684 @@
|
||||
# app/modules/messaging/services/messaging_service.py
|
||||
"""
|
||||
Messaging service for conversation and message management.
|
||||
|
||||
Provides functionality for:
|
||||
- Creating conversations between different participant types
|
||||
- Sending messages with attachments
|
||||
- Managing read status and unread counts
|
||||
- Conversation listing with filters
|
||||
- Multi-tenant data isolation
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.modules.messaging.models.message import (
|
||||
Conversation,
|
||||
ConversationParticipant,
|
||||
ConversationType,
|
||||
Message,
|
||||
MessageAttachment,
|
||||
ParticipantType,
|
||||
)
|
||||
from models.database.customer import Customer
|
||||
from models.database.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessagingService:
|
||||
"""Service for managing conversations and messages."""
|
||||
|
||||
# =========================================================================
|
||||
# CONVERSATION MANAGEMENT
|
||||
# =========================================================================
|
||||
|
||||
def create_conversation(
|
||||
self,
|
||||
db: Session,
|
||||
conversation_type: ConversationType,
|
||||
subject: str,
|
||||
initiator_type: ParticipantType,
|
||||
initiator_id: int,
|
||||
recipient_type: ParticipantType,
|
||||
recipient_id: int,
|
||||
vendor_id: int | None = None,
|
||||
initial_message: str | None = None,
|
||||
) -> Conversation:
|
||||
"""
|
||||
Create a new conversation between two participants.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
conversation_type: Type of conversation channel
|
||||
subject: Conversation subject line
|
||||
initiator_type: Type of initiating participant
|
||||
initiator_id: ID of initiating participant
|
||||
recipient_type: Type of receiving participant
|
||||
recipient_id: ID of receiving participant
|
||||
vendor_id: Required for vendor_customer/admin_customer types
|
||||
initial_message: Optional first message content
|
||||
|
||||
Returns:
|
||||
Created Conversation object
|
||||
"""
|
||||
# Validate vendor_id requirement
|
||||
if conversation_type in [
|
||||
ConversationType.VENDOR_CUSTOMER,
|
||||
ConversationType.ADMIN_CUSTOMER,
|
||||
]:
|
||||
if not vendor_id:
|
||||
raise ValueError(
|
||||
f"vendor_id required for {conversation_type.value} conversations"
|
||||
)
|
||||
|
||||
# Create conversation
|
||||
conversation = Conversation(
|
||||
conversation_type=conversation_type,
|
||||
subject=subject,
|
||||
vendor_id=vendor_id,
|
||||
)
|
||||
db.add(conversation)
|
||||
db.flush()
|
||||
|
||||
# Add participants
|
||||
initiator_vendor_id = (
|
||||
vendor_id if initiator_type == ParticipantType.VENDOR else None
|
||||
)
|
||||
recipient_vendor_id = (
|
||||
vendor_id if recipient_type == ParticipantType.VENDOR else None
|
||||
)
|
||||
|
||||
initiator = ConversationParticipant(
|
||||
conversation_id=conversation.id,
|
||||
participant_type=initiator_type,
|
||||
participant_id=initiator_id,
|
||||
vendor_id=initiator_vendor_id,
|
||||
unread_count=0, # Initiator has read their own message
|
||||
)
|
||||
recipient = ConversationParticipant(
|
||||
conversation_id=conversation.id,
|
||||
participant_type=recipient_type,
|
||||
participant_id=recipient_id,
|
||||
vendor_id=recipient_vendor_id,
|
||||
unread_count=1 if initial_message else 0,
|
||||
)
|
||||
|
||||
db.add(initiator)
|
||||
db.add(recipient)
|
||||
db.flush()
|
||||
|
||||
# Add initial message if provided
|
||||
if initial_message:
|
||||
self.send_message(
|
||||
db=db,
|
||||
conversation_id=conversation.id,
|
||||
sender_type=initiator_type,
|
||||
sender_id=initiator_id,
|
||||
content=initial_message,
|
||||
_skip_unread_update=True, # Already set above
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created {conversation_type.value} conversation {conversation.id}: "
|
||||
f"{initiator_type.value}:{initiator_id} -> {recipient_type.value}:{recipient_id}"
|
||||
)
|
||||
|
||||
return conversation
|
||||
|
||||
def get_conversation(
|
||||
self,
|
||||
db: Session,
|
||||
conversation_id: int,
|
||||
participant_type: ParticipantType,
|
||||
participant_id: int,
|
||||
) -> Conversation | None:
|
||||
"""
|
||||
Get conversation if participant has access.
|
||||
|
||||
Validates that the requester is a participant.
|
||||
"""
|
||||
conversation = (
|
||||
db.query(Conversation)
|
||||
.options(
|
||||
joinedload(Conversation.participants),
|
||||
joinedload(Conversation.messages).joinedload(Message.attachments),
|
||||
)
|
||||
.filter(Conversation.id == conversation_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
return None
|
||||
|
||||
# Verify participant access
|
||||
has_access = any(
|
||||
p.participant_type == participant_type
|
||||
and p.participant_id == participant_id
|
||||
for p in conversation.participants
|
||||
)
|
||||
|
||||
if not has_access:
|
||||
logger.warning(
|
||||
f"Access denied to conversation {conversation_id} for "
|
||||
f"{participant_type.value}:{participant_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
return conversation
|
||||
|
||||
def list_conversations(
|
||||
self,
|
||||
db: Session,
|
||||
participant_type: ParticipantType,
|
||||
participant_id: int,
|
||||
vendor_id: int | None = None,
|
||||
conversation_type: ConversationType | None = None,
|
||||
is_closed: bool | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
) -> tuple[list[Conversation], int, int]:
|
||||
"""
|
||||
List conversations for a participant with filters.
|
||||
|
||||
Returns:
|
||||
Tuple of (conversations, total_count, total_unread)
|
||||
"""
|
||||
# Base query: conversations where user is a participant
|
||||
query = (
|
||||
db.query(Conversation)
|
||||
.join(ConversationParticipant)
|
||||
.filter(
|
||||
and_(
|
||||
ConversationParticipant.participant_type == participant_type,
|
||||
ConversationParticipant.participant_id == participant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Multi-tenant filter for vendor users
|
||||
if participant_type == ParticipantType.VENDOR and vendor_id:
|
||||
query = query.filter(ConversationParticipant.vendor_id == vendor_id)
|
||||
|
||||
# Customer vendor isolation
|
||||
if participant_type == ParticipantType.CUSTOMER and vendor_id:
|
||||
query = query.filter(Conversation.vendor_id == vendor_id)
|
||||
|
||||
# Type filter
|
||||
if conversation_type:
|
||||
query = query.filter(Conversation.conversation_type == conversation_type)
|
||||
|
||||
# Status filter
|
||||
if is_closed is not None:
|
||||
query = query.filter(Conversation.is_closed == is_closed)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Get total unread across all conversations
|
||||
unread_query = db.query(
|
||||
func.sum(ConversationParticipant.unread_count)
|
||||
).filter(
|
||||
and_(
|
||||
ConversationParticipant.participant_type == participant_type,
|
||||
ConversationParticipant.participant_id == participant_id,
|
||||
)
|
||||
)
|
||||
|
||||
if participant_type == ParticipantType.VENDOR and vendor_id:
|
||||
unread_query = unread_query.filter(
|
||||
ConversationParticipant.vendor_id == vendor_id
|
||||
)
|
||||
|
||||
total_unread = unread_query.scalar() or 0
|
||||
|
||||
# Get paginated results, ordered by last activity
|
||||
conversations = (
|
||||
query.options(joinedload(Conversation.participants))
|
||||
.order_by(Conversation.last_message_at.desc().nullslast())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return conversations, total, total_unread
|
||||
|
||||
def close_conversation(
|
||||
self,
|
||||
db: Session,
|
||||
conversation_id: int,
|
||||
closer_type: ParticipantType,
|
||||
closer_id: int,
|
||||
) -> Conversation | None:
|
||||
"""Close a conversation thread."""
|
||||
conversation = self.get_conversation(
|
||||
db, conversation_id, closer_type, closer_id
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
return None
|
||||
|
||||
conversation.is_closed = True
|
||||
conversation.closed_at = datetime.now(UTC)
|
||||
conversation.closed_by_type = closer_type
|
||||
conversation.closed_by_id = closer_id
|
||||
|
||||
# Add system message
|
||||
self.send_message(
|
||||
db=db,
|
||||
conversation_id=conversation_id,
|
||||
sender_type=closer_type,
|
||||
sender_id=closer_id,
|
||||
content="Conversation closed",
|
||||
is_system_message=True,
|
||||
)
|
||||
|
||||
db.flush()
|
||||
return conversation
|
||||
|
||||
def reopen_conversation(
|
||||
self,
|
||||
db: Session,
|
||||
conversation_id: int,
|
||||
opener_type: ParticipantType,
|
||||
opener_id: int,
|
||||
) -> Conversation | None:
|
||||
"""Reopen a closed conversation."""
|
||||
conversation = self.get_conversation(
|
||||
db, conversation_id, opener_type, opener_id
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
return None
|
||||
|
||||
conversation.is_closed = False
|
||||
conversation.closed_at = None
|
||||
conversation.closed_by_type = None
|
||||
conversation.closed_by_id = None
|
||||
|
||||
# Add system message
|
||||
self.send_message(
|
||||
db=db,
|
||||
conversation_id=conversation_id,
|
||||
sender_type=opener_type,
|
||||
sender_id=opener_id,
|
||||
content="Conversation reopened",
|
||||
is_system_message=True,
|
||||
)
|
||||
|
||||
db.flush()
|
||||
return conversation
|
||||
|
||||
# =========================================================================
|
||||
# MESSAGE MANAGEMENT
|
||||
# =========================================================================
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
db: Session,
|
||||
conversation_id: int,
|
||||
sender_type: ParticipantType,
|
||||
sender_id: int,
|
||||
content: str,
|
||||
attachments: list[dict[str, Any]] | None = None,
|
||||
is_system_message: bool = False,
|
||||
_skip_unread_update: bool = False,
|
||||
) -> Message:
|
||||
"""
|
||||
Send a message in a conversation.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
conversation_id: Target conversation ID
|
||||
sender_type: Type of sender
|
||||
sender_id: ID of sender
|
||||
content: Message text content
|
||||
attachments: List of attachment dicts with file metadata
|
||||
is_system_message: Whether this is a system-generated message
|
||||
_skip_unread_update: Internal flag to skip unread increment
|
||||
|
||||
Returns:
|
||||
Created Message object
|
||||
"""
|
||||
# Create message
|
||||
message = Message(
|
||||
conversation_id=conversation_id,
|
||||
sender_type=sender_type,
|
||||
sender_id=sender_id,
|
||||
content=content,
|
||||
is_system_message=is_system_message,
|
||||
)
|
||||
db.add(message)
|
||||
db.flush()
|
||||
|
||||
# Add attachments if any
|
||||
if attachments:
|
||||
for att_data in attachments:
|
||||
attachment = MessageAttachment(
|
||||
message_id=message.id,
|
||||
filename=att_data["filename"],
|
||||
original_filename=att_data["original_filename"],
|
||||
file_path=att_data["file_path"],
|
||||
file_size=att_data["file_size"],
|
||||
mime_type=att_data["mime_type"],
|
||||
is_image=att_data.get("is_image", False),
|
||||
image_width=att_data.get("image_width"),
|
||||
image_height=att_data.get("image_height"),
|
||||
thumbnail_path=att_data.get("thumbnail_path"),
|
||||
)
|
||||
db.add(attachment)
|
||||
|
||||
# Update conversation metadata
|
||||
conversation = (
|
||||
db.query(Conversation).filter(Conversation.id == conversation_id).first()
|
||||
)
|
||||
|
||||
if conversation:
|
||||
conversation.last_message_at = datetime.now(UTC)
|
||||
conversation.message_count += 1
|
||||
|
||||
# Update unread counts for other participants
|
||||
if not _skip_unread_update:
|
||||
db.query(ConversationParticipant).filter(
|
||||
and_(
|
||||
ConversationParticipant.conversation_id == conversation_id,
|
||||
or_(
|
||||
ConversationParticipant.participant_type != sender_type,
|
||||
ConversationParticipant.participant_id != sender_id,
|
||||
),
|
||||
)
|
||||
).update(
|
||||
{
|
||||
ConversationParticipant.unread_count: ConversationParticipant.unread_count
|
||||
+ 1
|
||||
}
|
||||
)
|
||||
|
||||
db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Message {message.id} sent in conversation {conversation_id} "
|
||||
f"by {sender_type.value}:{sender_id}"
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
def delete_message(
|
||||
self,
|
||||
db: Session,
|
||||
message_id: int,
|
||||
deleter_type: ParticipantType,
|
||||
deleter_id: int,
|
||||
) -> Message | None:
|
||||
"""Soft delete a message (for moderation)."""
|
||||
message = db.query(Message).filter(Message.id == message_id).first()
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
# Verify deleter has access to conversation
|
||||
conversation = self.get_conversation(
|
||||
db, message.conversation_id, deleter_type, deleter_id
|
||||
)
|
||||
if not conversation:
|
||||
return None
|
||||
|
||||
message.is_deleted = True
|
||||
message.deleted_at = datetime.now(UTC)
|
||||
message.deleted_by_type = deleter_type
|
||||
message.deleted_by_id = deleter_id
|
||||
|
||||
db.flush()
|
||||
return message
|
||||
|
||||
def mark_conversation_read(
|
||||
self,
|
||||
db: Session,
|
||||
conversation_id: int,
|
||||
reader_type: ParticipantType,
|
||||
reader_id: int,
|
||||
) -> bool:
|
||||
"""Mark all messages in conversation as read for participant."""
|
||||
result = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(
|
||||
and_(
|
||||
ConversationParticipant.conversation_id == conversation_id,
|
||||
ConversationParticipant.participant_type == reader_type,
|
||||
ConversationParticipant.participant_id == reader_id,
|
||||
)
|
||||
)
|
||||
.update(
|
||||
{
|
||||
ConversationParticipant.unread_count: 0,
|
||||
ConversationParticipant.last_read_at: datetime.now(UTC),
|
||||
}
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
return result > 0
|
||||
|
||||
def get_unread_count(
|
||||
self,
|
||||
db: Session,
|
||||
participant_type: ParticipantType,
|
||||
participant_id: int,
|
||||
vendor_id: int | None = None,
|
||||
) -> int:
|
||||
"""Get total unread message count for a participant."""
|
||||
query = db.query(func.sum(ConversationParticipant.unread_count)).filter(
|
||||
and_(
|
||||
ConversationParticipant.participant_type == participant_type,
|
||||
ConversationParticipant.participant_id == participant_id,
|
||||
)
|
||||
)
|
||||
|
||||
if vendor_id:
|
||||
query = query.filter(ConversationParticipant.vendor_id == vendor_id)
|
||||
|
||||
return query.scalar() or 0
|
||||
|
||||
# =========================================================================
|
||||
# PARTICIPANT HELPERS
|
||||
# =========================================================================
|
||||
|
||||
def get_participant_info(
|
||||
self,
|
||||
db: Session,
|
||||
participant_type: ParticipantType,
|
||||
participant_id: int,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get display info for a participant (name, email, avatar)."""
|
||||
if participant_type in [ParticipantType.ADMIN, ParticipantType.VENDOR]:
|
||||
user = db.query(User).filter(User.id == participant_id).first()
|
||||
if user:
|
||||
return {
|
||||
"id": user.id,
|
||||
"type": participant_type.value,
|
||||
"name": f"{user.first_name or ''} {user.last_name or ''}".strip()
|
||||
or user.username,
|
||||
"email": user.email,
|
||||
"avatar_url": None, # Could add avatar support later
|
||||
}
|
||||
elif participant_type == ParticipantType.CUSTOMER:
|
||||
customer = db.query(Customer).filter(Customer.id == participant_id).first()
|
||||
if customer:
|
||||
return {
|
||||
"id": customer.id,
|
||||
"type": participant_type.value,
|
||||
"name": f"{customer.first_name or ''} {customer.last_name or ''}".strip()
|
||||
or customer.email,
|
||||
"email": customer.email,
|
||||
"avatar_url": None,
|
||||
}
|
||||
return None
|
||||
|
||||
def get_other_participant(
|
||||
self,
|
||||
conversation: Conversation,
|
||||
my_type: ParticipantType,
|
||||
my_id: int,
|
||||
) -> ConversationParticipant | None:
|
||||
"""Get the other participant in a conversation."""
|
||||
for p in conversation.participants:
|
||||
if p.participant_type != my_type or p.participant_id != my_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# NOTIFICATION PREFERENCES
|
||||
# =========================================================================
|
||||
|
||||
def update_notification_preferences(
|
||||
self,
|
||||
db: Session,
|
||||
conversation_id: int,
|
||||
participant_type: ParticipantType,
|
||||
participant_id: int,
|
||||
email_notifications: bool | None = None,
|
||||
muted: bool | None = None,
|
||||
) -> bool:
|
||||
"""Update notification preferences for a participant in a conversation."""
|
||||
updates = {}
|
||||
if email_notifications is not None:
|
||||
updates[ConversationParticipant.email_notifications] = email_notifications
|
||||
if muted is not None:
|
||||
updates[ConversationParticipant.muted] = muted
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
result = (
|
||||
db.query(ConversationParticipant)
|
||||
.filter(
|
||||
and_(
|
||||
ConversationParticipant.conversation_id == conversation_id,
|
||||
ConversationParticipant.participant_type == participant_type,
|
||||
ConversationParticipant.participant_id == participant_id,
|
||||
)
|
||||
)
|
||||
.update(updates)
|
||||
)
|
||||
db.flush()
|
||||
return result > 0
|
||||
|
||||
# =========================================================================
|
||||
# RECIPIENT QUERIES
|
||||
# =========================================================================
|
||||
|
||||
def get_vendor_recipients(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int | None = None,
|
||||
search: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""
|
||||
Get list of vendor users as potential recipients.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Optional vendor ID filter
|
||||
search: Search term for name/email
|
||||
skip: Pagination offset
|
||||
limit: Max results
|
||||
|
||||
Returns:
|
||||
Tuple of (recipients list, total count)
|
||||
"""
|
||||
from models.database.vendor import VendorUser
|
||||
|
||||
query = (
|
||||
db.query(User, VendorUser)
|
||||
.join(VendorUser, User.id == VendorUser.user_id)
|
||||
.filter(User.is_active == True) # noqa: E712
|
||||
)
|
||||
|
||||
if vendor_id:
|
||||
query = query.filter(VendorUser.vendor_id == vendor_id)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
(User.username.ilike(search_pattern))
|
||||
| (User.email.ilike(search_pattern))
|
||||
| (User.first_name.ilike(search_pattern))
|
||||
| (User.last_name.ilike(search_pattern))
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
results = query.offset(skip).limit(limit).all()
|
||||
|
||||
recipients = []
|
||||
for user, vendor_user in results:
|
||||
name = f"{user.first_name or ''} {user.last_name or ''}".strip() or user.username
|
||||
recipients.append({
|
||||
"id": user.id,
|
||||
"type": ParticipantType.VENDOR,
|
||||
"name": name,
|
||||
"email": user.email,
|
||||
"vendor_id": vendor_user.vendor_id,
|
||||
"vendor_name": vendor_user.vendor.name if vendor_user.vendor else None,
|
||||
})
|
||||
|
||||
return recipients, total
|
||||
|
||||
def get_customer_recipients(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_id: int | None = None,
|
||||
search: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""
|
||||
Get list of customers as potential recipients.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
vendor_id: Optional vendor ID filter (required for vendor users)
|
||||
search: Search term for name/email
|
||||
skip: Pagination offset
|
||||
limit: Max results
|
||||
|
||||
Returns:
|
||||
Tuple of (recipients list, total count)
|
||||
"""
|
||||
query = db.query(Customer).filter(Customer.is_active == True) # noqa: E712
|
||||
|
||||
if vendor_id:
|
||||
query = query.filter(Customer.vendor_id == vendor_id)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
(Customer.email.ilike(search_pattern))
|
||||
| (Customer.first_name.ilike(search_pattern))
|
||||
| (Customer.last_name.ilike(search_pattern))
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
results = query.offset(skip).limit(limit).all()
|
||||
|
||||
recipients = []
|
||||
for customer in results:
|
||||
name = f"{customer.first_name or ''} {customer.last_name or ''}".strip()
|
||||
recipients.append({
|
||||
"id": customer.id,
|
||||
"type": ParticipantType.CUSTOMER,
|
||||
"name": name or customer.email,
|
||||
"email": customer.email,
|
||||
"vendor_id": customer.vendor_id,
|
||||
})
|
||||
|
||||
return recipients, total
|
||||
|
||||
|
||||
# Singleton instance
|
||||
messaging_service = MessagingService()
|
||||
Reference in New Issue
Block a user