Files
orion/app/modules/messaging/routes/api/admin_messages.py
Samir Boulahtit 4aa6f76e46
Some checks failed
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 10s
refactor(arch): move auth schemas to tenancy module and add cross-module service methods
Move all auth schemas (UserContext, UserLogin, LoginResponse, etc.) from
legacy models/schema/auth.py to app/modules/tenancy/schemas/auth.py per
MOD-019. Update 84 import sites across 14 modules. Legacy file now
re-exports for backwards compatibility.

Add missing tenancy service methods for cross-module consumers:
- merchant_service.get_merchant_by_owner_id()
- merchant_service.get_merchant_count_for_owner()
- admin_service.get_user_by_id() (public, was private-only)
- platform_service.get_active_store_count()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:57:04 +01:00

626 lines
20 KiB
Python

# app/modules/messaging/routes/api/admin_messages.py
"""
Admin messaging endpoints.
Provides endpoints for:
- Viewing conversations (admin_store and admin_customer channels)
- Sending and receiving messages
- Managing conversation status
- File attachments
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.messaging.exceptions import (
ConversationClosedException,
ConversationNotFoundException,
InvalidConversationTypeException,
InvalidRecipientTypeException,
MessageAttachmentException,
)
from app.modules.messaging.models import ConversationType, ParticipantType
from app.modules.messaging.schemas import (
AdminConversationListResponse,
AdminConversationSummary,
AttachmentResponse,
CloseConversationResponse,
ConversationCreate,
ConversationDetailResponse,
MarkReadResponse,
MessageResponse,
NotificationPreferencesUpdate,
ParticipantInfo,
ParticipantResponse,
RecipientListResponse,
RecipientOption,
ReopenConversationResponse,
UnreadCountResponse,
)
from app.modules.messaging.services.message_attachment_service import (
message_attachment_service,
)
from app.modules.messaging.services.messaging_service import messaging_service
from app.modules.tenancy.schemas.auth import UserContext
admin_messages_router = APIRouter(prefix="/messages")
logger = logging.getLogger(__name__)
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
def _enrich_message(
db: Session, message: Any, include_attachments: bool = True
) -> MessageResponse:
"""Enrich message with sender info and attachments."""
sender_info = messaging_service.get_participant_info(
db, message.sender_type, message.sender_id
)
attachments = []
if include_attachments and message.attachments:
for att in message.attachments:
attachments.append(
AttachmentResponse(
id=att.id,
filename=att.filename,
original_filename=att.original_filename,
file_size=att.file_size,
mime_type=att.mime_type,
is_image=att.is_image,
image_width=att.image_width,
image_height=att.image_height,
download_url=message_attachment_service.get_download_url(
att.file_path
),
thumbnail_url=(
message_attachment_service.get_download_url(att.thumbnail_path)
if att.thumbnail_path
else None
),
)
)
return MessageResponse(
id=message.id,
conversation_id=message.conversation_id,
sender_type=message.sender_type,
sender_id=message.sender_id,
content=message.content,
is_system_message=message.is_system_message,
is_deleted=message.is_deleted,
created_at=message.created_at,
sender_name=sender_info["name"] if sender_info else None,
sender_email=sender_info["email"] if sender_info else None,
attachments=attachments,
)
def _enrich_conversation_summary(
db: Session, conversation: Any, current_user_id: int
) -> AdminConversationSummary:
"""Enrich conversation with other participant info and unread count."""
# Get current user's participant record
my_participant = next(
(
p
for p in conversation.participants
if p.participant_type == ParticipantType.ADMIN
and p.participant_id == current_user_id
),
None,
)
unread_count = my_participant.unread_count if my_participant else 0
# Get other participant info
other = messaging_service.get_other_participant(
conversation, ParticipantType.ADMIN, current_user_id
)
other_info = None
if other:
info = messaging_service.get_participant_info(
db, other.participant_type, other.participant_id
)
if info:
other_info = ParticipantInfo(
id=info["id"],
type=info["type"],
name=info["name"],
email=info.get("email"),
)
# Get last message preview
last_message_preview = None
if conversation.messages:
last_msg = conversation.messages[-1] if conversation.messages else None
if last_msg:
preview = last_msg.content[:100]
if len(last_msg.content) > 100:
preview += "..."
last_message_preview = preview
# Get store info if applicable
store_name = None
store_code = None
if conversation.store:
store_name = conversation.store.name
store_code = conversation.store.store_code
return AdminConversationSummary(
id=conversation.id,
conversation_type=conversation.conversation_type,
subject=conversation.subject,
store_id=conversation.store_id,
is_closed=conversation.is_closed,
closed_at=conversation.closed_at,
last_message_at=conversation.last_message_at,
message_count=conversation.message_count,
created_at=conversation.created_at,
unread_count=unread_count,
other_participant=other_info,
last_message_preview=last_message_preview,
store_name=store_name,
store_code=store_code,
)
# ============================================================================
# CONVERSATION LIST
# ============================================================================
@admin_messages_router.get("", response_model=AdminConversationListResponse)
def list_conversations(
conversation_type: ConversationType | None = Query(None, description="Filter by type"),
is_closed: bool | None = Query(None, description="Filter by status"),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> AdminConversationListResponse:
"""List conversations for admin (admin_store and admin_customer channels)."""
conversations, total, total_unread = messaging_service.list_conversations(
db=db,
participant_type=ParticipantType.ADMIN,
participant_id=current_admin.id,
conversation_type=conversation_type,
is_closed=is_closed,
skip=skip,
limit=limit,
)
return AdminConversationListResponse(
conversations=[
_enrich_conversation_summary(db, c, current_admin.id) for c in conversations
],
total=total,
total_unread=total_unread,
skip=skip,
limit=limit,
)
@admin_messages_router.get("/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> UnreadCountResponse:
"""Get total unread message count for header badge."""
count = messaging_service.get_unread_count(
db=db,
participant_type=ParticipantType.ADMIN,
participant_id=current_admin.id,
)
return UnreadCountResponse(total_unread=count)
# ============================================================================
# RECIPIENTS
# ============================================================================
@admin_messages_router.get("/recipients", response_model=RecipientListResponse)
def get_recipients(
recipient_type: ParticipantType = Query(..., description="Type of recipients to list"),
search: str | None = Query(None, description="Search by name/email"),
store_id: int | None = Query(None, description="Filter by store"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> RecipientListResponse:
"""Get list of available recipients for compose modal."""
if recipient_type == ParticipantType.STORE:
recipient_data, total = messaging_service.get_store_recipients(
db=db,
store_id=store_id,
search=search,
skip=skip,
limit=limit,
)
recipients = [
RecipientOption(
id=r["id"],
type=r["type"],
name=r["name"],
email=r["email"],
store_id=r["store_id"],
store_name=r.get("store_name"),
)
for r in recipient_data
]
elif recipient_type == ParticipantType.CUSTOMER:
recipient_data, total = messaging_service.get_customer_recipients(
db=db,
store_id=store_id,
search=search,
skip=skip,
limit=limit,
)
recipients = [
RecipientOption(
id=r["id"],
type=r["type"],
name=r["name"],
email=r["email"],
store_id=r["store_id"],
)
for r in recipient_data
]
else:
recipients = []
total = 0
return RecipientListResponse(recipients=recipients, total=total)
# ============================================================================
# CREATE CONVERSATION
# ============================================================================
@admin_messages_router.post("", response_model=ConversationDetailResponse)
def create_conversation(
data: ConversationCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> ConversationDetailResponse:
"""Create a new conversation."""
# Validate conversation type for admin
if data.conversation_type not in [
ConversationType.ADMIN_STORE,
ConversationType.ADMIN_CUSTOMER,
]:
raise InvalidConversationTypeException(
message="Admin can only create admin_store or admin_customer conversations",
allowed_types=["admin_store", "admin_customer"],
)
# Validate recipient type matches conversation type
if (
data.conversation_type == ConversationType.ADMIN_STORE
and data.recipient_type != ParticipantType.STORE
):
raise InvalidRecipientTypeException(
conversation_type="admin_store",
expected_recipient_type="store",
)
if (
data.conversation_type == ConversationType.ADMIN_CUSTOMER
and data.recipient_type != ParticipantType.CUSTOMER
):
raise InvalidRecipientTypeException(
conversation_type="admin_customer",
expected_recipient_type="customer",
)
# Create conversation
conversation = messaging_service.create_conversation(
db=db,
conversation_type=data.conversation_type,
subject=data.subject,
initiator_type=ParticipantType.ADMIN,
initiator_id=current_admin.id,
recipient_type=data.recipient_type,
recipient_id=data.recipient_id,
store_id=data.store_id,
initial_message=data.initial_message,
)
db.commit()
db.refresh(conversation)
logger.info(
f"Admin {current_admin.username} created conversation {conversation.id} "
f"with {data.recipient_type.value}:{data.recipient_id}"
)
# Return full detail response
return _build_conversation_detail(db, conversation, current_admin.id)
# ============================================================================
# CONVERSATION DETAIL
# ============================================================================
def _build_conversation_detail(
db: Session, conversation: Any, current_user_id: int
) -> ConversationDetailResponse:
"""Build full conversation detail response."""
# Get my participant for unread count
my_participant = next(
(
p
for p in conversation.participants
if p.participant_type == ParticipantType.ADMIN
and p.participant_id == current_user_id
),
None,
)
unread_count = my_participant.unread_count if my_participant else 0
# Build participant responses
participants = []
for p in conversation.participants:
info = messaging_service.get_participant_info(
db, p.participant_type, p.participant_id
)
participants.append(
ParticipantResponse(
id=p.id,
participant_type=p.participant_type,
participant_id=p.participant_id,
unread_count=p.unread_count,
last_read_at=p.last_read_at,
email_notifications=p.email_notifications,
muted=p.muted,
participant_info=(
ParticipantInfo(
id=info["id"],
type=info["type"],
name=info["name"],
email=info.get("email"),
)
if info
else None
),
)
)
# Build message responses
messages = [_enrich_message(db, m) for m in conversation.messages]
# Get store name if applicable
store_name = None
if conversation.store:
store_name = conversation.store.name
return ConversationDetailResponse(
id=conversation.id,
conversation_type=conversation.conversation_type,
subject=conversation.subject,
store_id=conversation.store_id,
is_closed=conversation.is_closed,
closed_at=conversation.closed_at,
closed_by_type=conversation.closed_by_type,
closed_by_id=conversation.closed_by_id,
last_message_at=conversation.last_message_at,
message_count=conversation.message_count,
created_at=conversation.created_at,
updated_at=conversation.updated_at,
participants=participants,
messages=messages,
unread_count=unread_count,
store_name=store_name,
)
@admin_messages_router.get("/{conversation_id}", response_model=ConversationDetailResponse)
def get_conversation(
conversation_id: int,
mark_read: bool = Query(True, description="Automatically mark as read"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> ConversationDetailResponse:
"""Get conversation detail with messages."""
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.ADMIN,
participant_id=current_admin.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
# Mark as read if requested
if mark_read:
messaging_service.mark_conversation_read(
db=db,
conversation_id=conversation_id,
reader_type=ParticipantType.ADMIN,
reader_id=current_admin.id,
)
db.commit()
return _build_conversation_detail(db, conversation, current_admin.id)
# ============================================================================
# SEND MESSAGE
# ============================================================================
@admin_messages_router.post("/{conversation_id}/messages", response_model=MessageResponse)
async def send_message(
conversation_id: int,
content: str = Form(...),
files: list[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> MessageResponse:
"""Send a message in a conversation, optionally with attachments."""
# Verify access
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.ADMIN,
participant_id=current_admin.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
if conversation.is_closed:
raise ConversationClosedException(conversation_id)
# Process attachments
attachments = []
for file in files:
try:
att_data = await message_attachment_service.validate_and_store(
db=db, file=file, conversation_id=conversation_id
)
attachments.append(att_data)
except ValueError as e:
raise MessageAttachmentException(str(e))
# Send message
message = messaging_service.send_message(
db=db,
conversation_id=conversation_id,
sender_type=ParticipantType.ADMIN,
sender_id=current_admin.id,
content=content,
attachments=attachments if attachments else None,
)
db.commit()
db.refresh(message)
logger.info(
f"Admin {current_admin.username} sent message {message.id} "
f"in conversation {conversation_id}"
)
return _enrich_message(db, message)
# ============================================================================
# CONVERSATION ACTIONS
# ============================================================================
@admin_messages_router.post("/{conversation_id}/close", response_model=CloseConversationResponse)
def close_conversation(
conversation_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> CloseConversationResponse:
"""Close a conversation."""
conversation = messaging_service.close_conversation(
db=db,
conversation_id=conversation_id,
closer_type=ParticipantType.ADMIN,
closer_id=current_admin.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
db.commit()
logger.info(
f"Admin {current_admin.username} closed conversation {conversation_id}"
)
return CloseConversationResponse(
success=True,
message="Conversation closed",
conversation_id=conversation_id,
)
@admin_messages_router.post("/{conversation_id}/reopen", response_model=ReopenConversationResponse)
def reopen_conversation(
conversation_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> ReopenConversationResponse:
"""Reopen a closed conversation."""
conversation = messaging_service.reopen_conversation(
db=db,
conversation_id=conversation_id,
opener_type=ParticipantType.ADMIN,
opener_id=current_admin.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
db.commit()
logger.info(
f"Admin {current_admin.username} reopened conversation {conversation_id}"
)
return ReopenConversationResponse(
success=True,
message="Conversation reopened",
conversation_id=conversation_id,
)
@admin_messages_router.put("/{conversation_id}/read", response_model=MarkReadResponse)
def mark_read(
conversation_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> MarkReadResponse:
"""Mark conversation as read."""
success = messaging_service.mark_conversation_read(
db=db,
conversation_id=conversation_id,
reader_type=ParticipantType.ADMIN,
reader_id=current_admin.id,
)
db.commit()
return MarkReadResponse(
success=success,
conversation_id=conversation_id,
unread_count=0,
)
class PreferencesUpdateResponse(BaseModel):
"""Response for preferences update."""
success: bool
@admin_messages_router.put("/{conversation_id}/preferences", response_model=PreferencesUpdateResponse)
def update_preferences(
conversation_id: int,
preferences: NotificationPreferencesUpdate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> PreferencesUpdateResponse:
"""Update notification preferences for a conversation."""
success = messaging_service.update_notification_preferences(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.ADMIN,
participant_id=current_admin.id,
email_notifications=preferences.email_notifications,
muted=preferences.muted,
)
db.commit()
return PreferencesUpdateResponse(success=success)