refactor: migrate templates and static files to self-contained modules

Templates Migration:
- Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.)
- Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.)
- Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms)
- Migrate public templates to modules (billing, marketplace, cms)
- Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/)
- Migrate letzshop partials to marketplace module

Static Files Migration:
- Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file)
- Migrate vendor JS to modules: tenancy (4 files), core (2 files)
- Migrate shared JS: vendor-selector.js to core, media-picker.js to cms
- Migrate storefront JS: storefront-layout.js to core
- Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/)
- Update all template references to use module_static paths

Naming Consistency:
- Rename static/platform/ to static/public/
- Rename app/templates/platform/ to app/templates/public/
- Update all extends and static references

Documentation:
- Update module-system.md with shared templates documentation
- Update frontend-structure.md with new module JS organization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 14:34:16 +01:00
parent 843703258f
commit 4e28d91a78
542 changed files with 11603 additions and 9037 deletions

View File

@@ -2,6 +2,10 @@
"""
Messaging module API routes.
Admin routes:
- /messages/* - Conversation and message management
- /notifications/* - Admin notifications and platform alerts
Vendor routes:
- /messages/* - Conversation and message management
- /notifications/* - Vendor notifications
@@ -12,10 +16,11 @@ Storefront routes:
- Customer-facing messaging
"""
from app.modules.messaging.routes.api.admin import admin_router
from app.modules.messaging.routes.api.storefront import router as storefront_router
from app.modules.messaging.routes.api.vendor import vendor_router
# Tag for OpenAPI documentation
STOREFRONT_TAG = "Messages (Storefront)"
__all__ = ["storefront_router", "vendor_router", "STOREFRONT_TAG"]
__all__ = ["admin_router", "storefront_router", "vendor_router", "STOREFRONT_TAG"]

View File

@@ -0,0 +1,26 @@
# app/modules/messaging/routes/api/admin.py
"""
Messaging module admin API routes.
Aggregates all admin messaging routes:
- /messages/* - Conversation and message management
- /notifications/* - Admin notifications and platform alerts
- /email-templates/* - Email template management
"""
from fastapi import APIRouter, Depends
from app.api.deps import require_module_access
from .admin_messages import admin_messages_router
from .admin_notifications import admin_notifications_router
from .admin_email_templates import admin_email_templates_router
admin_router = APIRouter(
dependencies=[Depends(require_module_access("messaging"))],
)
# Aggregate all messaging admin routes
admin_router.include_router(admin_messages_router, tags=["admin-messages"])
admin_router.include_router(admin_notifications_router, tags=["admin-notifications"])
admin_router.include_router(admin_email_templates_router, tags=["admin-email-templates"])

View File

@@ -0,0 +1,356 @@
# app/modules/messaging/routes/api/admin_email_templates.py
"""
Admin email template management endpoints.
Allows platform administrators to:
- View all email templates
- Edit template content for all languages
- Preview templates with sample data
- Send test emails
- View email logs
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel, EmailStr, Field
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.services.email_service import EmailService
from app.modules.messaging.services.email_template_service import EmailTemplateService
from models.schema.auth import UserContext
admin_email_templates_router = APIRouter(prefix="/email-templates")
logger = logging.getLogger(__name__)
# =============================================================================
# SCHEMAS
# =============================================================================
class TemplateUpdate(BaseModel):
"""Schema for updating a platform template."""
subject: str = Field(..., min_length=1, max_length=500)
body_html: str = Field(..., min_length=1)
body_text: str | None = None
class PreviewRequest(BaseModel):
"""Schema for previewing a template."""
template_code: str
language: str = "en"
variables: dict[str, Any] = {}
class TestEmailRequest(BaseModel):
"""Schema for sending a test email."""
template_code: str
language: str = "en"
to_email: EmailStr
variables: dict[str, Any] = {}
class TemplateListItem(BaseModel):
"""Schema for a template in the list."""
code: str
name: str
description: str | None = None
category: str
languages: list[str] # Matches service output field name
is_platform_only: bool = False
variables: list[str] = []
class Config:
from_attributes = True
class TemplateListResponse(BaseModel):
"""Response schema for listing templates."""
templates: list[TemplateListItem]
class CategoriesResponse(BaseModel):
"""Response schema for template categories."""
categories: list[str]
# =============================================================================
# ENDPOINTS
# =============================================================================
@admin_email_templates_router.get("", response_model=TemplateListResponse)
def list_templates(
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
List all platform email templates.
Returns templates grouped by code with available languages.
"""
service = EmailTemplateService(db)
return TemplateListResponse(templates=service.list_platform_templates())
@admin_email_templates_router.get("/categories", response_model=CategoriesResponse)
def get_categories(
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get list of email template categories."""
service = EmailTemplateService(db)
return CategoriesResponse(categories=service.get_template_categories())
@admin_email_templates_router.get("/{code}")
def get_template(
code: str,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get a specific template with all language versions.
Returns template metadata and content for all available languages.
"""
service = EmailTemplateService(db)
return service.get_platform_template(code)
@admin_email_templates_router.get("/{code}/{language}")
def get_template_language(
code: str,
language: str,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get a specific template for a specific language.
Returns template content with variables information.
"""
service = EmailTemplateService(db)
template = service.get_platform_template_language(code, language)
return {
"code": template.code,
"language": template.language,
"name": template.name,
"description": template.description,
"category": template.category,
"subject": template.subject,
"body_html": template.body_html,
"body_text": template.body_text,
"variables": template.variables,
"required_variables": template.required_variables,
"is_platform_only": template.is_platform_only,
}
@admin_email_templates_router.put("/{code}/{language}")
def update_template(
code: str,
language: str,
template_data: TemplateUpdate,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Update a platform email template.
Updates the template content for a specific language.
"""
service = EmailTemplateService(db)
service.update_platform_template(
code=code,
language=language,
subject=template_data.subject,
body_html=template_data.body_html,
body_text=template_data.body_text,
)
db.commit()
return {"message": "Template updated successfully"}
@admin_email_templates_router.post("/{code}/preview")
def preview_template(
code: str,
preview_data: PreviewRequest,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Preview a template with sample variables.
Renders the template with provided variables and returns the result.
"""
service = EmailTemplateService(db)
# Merge with sample variables if not provided
variables = {
**_get_sample_variables(code),
**preview_data.variables,
}
return service.preview_template(code, preview_data.language, variables)
@admin_email_templates_router.post("/{code}/test")
def send_test_email(
code: str,
test_data: TestEmailRequest,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Send a test email using the template.
Sends the template to the specified email address with sample data.
"""
# Merge with sample variables
variables = {
**_get_sample_variables(code),
**test_data.variables,
}
try:
email_svc = EmailService(db)
email_log = email_svc.send_template(
template_code=code,
to_email=test_data.to_email,
variables=variables,
language=test_data.language,
)
if email_log.status == "sent":
return {
"success": True,
"message": f"Test email sent to {test_data.to_email}",
}
else:
return {
"success": False,
"message": email_log.error_message or "Failed to send email",
}
except Exception as e:
logger.exception(f"Failed to send test email: {e}")
return {
"success": False,
"message": str(e),
}
@admin_email_templates_router.get("/{code}/logs")
def get_template_logs(
code: str,
limit: int = 50,
offset: int = 0,
current_user: UserContext = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""
Get email logs for a specific template.
Returns recent email send attempts for the template.
"""
service = EmailTemplateService(db)
logs, total = service.get_template_logs(code, limit, offset)
return {
"logs": logs,
"total": total,
"limit": limit,
"offset": offset,
}
# =============================================================================
# HELPERS
# =============================================================================
def _get_sample_variables(template_code: str) -> dict[str, Any]:
"""Get sample variables for testing templates."""
samples = {
"signup_welcome": {
"first_name": "John",
"company_name": "Acme Corp",
"email": "john@example.com",
"vendor_code": "acme",
"login_url": "https://example.com/login",
"trial_days": "14",
"tier_name": "Business",
"platform_name": "Wizamart",
},
"order_confirmation": {
"customer_name": "Jane Doe",
"order_number": "ORD-12345",
"order_total": "€99.99",
"order_items_count": "3",
"order_date": "2024-01-15",
"shipping_address": "123 Main St, Luxembourg City, L-1234",
"platform_name": "Wizamart",
},
"password_reset": {
"customer_name": "John Doe",
"reset_link": "https://example.com/reset?token=abc123",
"expiry_hours": "1",
"platform_name": "Wizamart",
},
"team_invite": {
"invitee_name": "Jane",
"inviter_name": "John",
"vendor_name": "Acme Corp",
"role": "Admin",
"accept_url": "https://example.com/accept",
"expires_in_days": "7",
"platform_name": "Wizamart",
},
"subscription_welcome": {
"vendor_name": "Acme Corp",
"tier_name": "Business",
"billing_cycle": "Monthly",
"amount": "€49.99",
"next_billing_date": "2024-02-15",
"dashboard_url": "https://example.com/dashboard",
"platform_name": "Wizamart",
},
"payment_failed": {
"vendor_name": "Acme Corp",
"tier_name": "Business",
"amount": "€49.99",
"retry_date": "2024-01-18",
"update_payment_url": "https://example.com/billing",
"support_email": "support@wizamart.com",
"platform_name": "Wizamart",
},
"subscription_cancelled": {
"vendor_name": "Acme Corp",
"tier_name": "Business",
"end_date": "2024-02-15",
"reactivate_url": "https://example.com/billing",
"platform_name": "Wizamart",
},
"trial_ending": {
"vendor_name": "Acme Corp",
"tier_name": "Business",
"days_remaining": "3",
"trial_end_date": "2024-01-18",
"upgrade_url": "https://example.com/upgrade",
"features_list": "Unlimited products, API access, Priority support",
"platform_name": "Wizamart",
},
}
return samples.get(template_code, {"platform_name": "Wizamart"})

View File

@@ -0,0 +1,624 @@
# app/modules/messaging/routes/api/admin_messages.py
"""
Admin messaging endpoints.
Provides endpoints for:
- Viewing conversations (admin_vendor 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.services.message_attachment_service import message_attachment_service
from app.modules.messaging.services.messaging_service import messaging_service
from app.modules.messaging.models import ConversationType, ParticipantType
from app.modules.messaging.schemas import (
AdminConversationListResponse,
AdminConversationSummary,
AttachmentResponse,
CloseConversationResponse,
ConversationCreate,
ConversationDetailResponse,
MarkReadResponse,
MessageCreate,
MessageResponse,
NotificationPreferencesUpdate,
ParticipantInfo,
ParticipantResponse,
RecipientListResponse,
RecipientOption,
ReopenConversationResponse,
UnreadCountResponse,
)
from models.schema.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 vendor info if applicable
vendor_name = None
vendor_code = None
if conversation.vendor:
vendor_name = conversation.vendor.name
vendor_code = conversation.vendor.vendor_code
return AdminConversationSummary(
id=conversation.id,
conversation_type=conversation.conversation_type,
subject=conversation.subject,
vendor_id=conversation.vendor_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,
vendor_name=vendor_name,
vendor_code=vendor_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_vendor 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"),
vendor_id: int | None = Query(None, description="Filter by vendor"),
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.VENDOR:
recipient_data, total = messaging_service.get_vendor_recipients(
db=db,
vendor_id=vendor_id,
search=search,
skip=skip,
limit=limit,
)
recipients = [
RecipientOption(
id=r["id"],
type=r["type"],
name=r["name"],
email=r["email"],
vendor_id=r["vendor_id"],
vendor_name=r.get("vendor_name"),
)
for r in recipient_data
]
elif recipient_type == ParticipantType.CUSTOMER:
recipient_data, total = messaging_service.get_customer_recipients(
db=db,
vendor_id=vendor_id,
search=search,
skip=skip,
limit=limit,
)
recipients = [
RecipientOption(
id=r["id"],
type=r["type"],
name=r["name"],
email=r["email"],
vendor_id=r["vendor_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_VENDOR,
ConversationType.ADMIN_CUSTOMER,
]:
raise InvalidConversationTypeException(
message="Admin can only create admin_vendor or admin_customer conversations",
allowed_types=["admin_vendor", "admin_customer"],
)
# Validate recipient type matches conversation type
if (
data.conversation_type == ConversationType.ADMIN_VENDOR
and data.recipient_type != ParticipantType.VENDOR
):
raise InvalidRecipientTypeException(
conversation_type="admin_vendor",
expected_recipient_type="vendor",
)
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,
vendor_id=data.vendor_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 vendor name if applicable
vendor_name = None
if conversation.vendor:
vendor_name = conversation.vendor.name
return ConversationDetailResponse(
id=conversation.id,
conversation_type=conversation.conversation_type,
subject=conversation.subject,
vendor_id=conversation.vendor_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,
vendor_name=vendor_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)

View File

@@ -0,0 +1,327 @@
# app/modules/messaging/routes/api/admin_notifications.py
"""
Admin notifications and platform alerts endpoints.
Provides endpoints for:
- Viewing admin notifications
- Managing platform alerts
- System health monitoring
"""
import logging
from fastapi import APIRouter, Depends, Query
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.services.admin_notification_service import (
admin_notification_service,
platform_alert_service,
)
from models.schema.auth import UserContext
from models.schema.admin import (
AdminNotificationCreate,
AdminNotificationListResponse,
AdminNotificationResponse,
PlatformAlertCreate,
PlatformAlertListResponse,
PlatformAlertResolve,
PlatformAlertResponse,
)
from app.modules.messaging.schemas import (
AlertStatisticsResponse,
MessageResponse,
UnreadCountResponse,
)
admin_notifications_router = APIRouter(prefix="/notifications")
logger = logging.getLogger(__name__)
# ============================================================================
# ADMIN NOTIFICATIONS
# ============================================================================
@admin_notifications_router.get("", response_model=AdminNotificationListResponse)
def get_notifications(
priority: str | None = Query(None, description="Filter by priority"),
notification_type: str | None = Query(None, description="Filter by type"),
is_read: bool | None = Query(None, description="Filter by read status"),
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),
) -> AdminNotificationListResponse:
"""Get admin notifications with filtering."""
notifications, total, unread_count = admin_notification_service.get_notifications(
db=db,
priority=priority,
is_read=is_read,
notification_type=notification_type,
skip=skip,
limit=limit,
)
return AdminNotificationListResponse(
notifications=[
AdminNotificationResponse(
id=n.id,
type=n.type,
priority=n.priority,
title=n.title,
message=n.message,
is_read=n.is_read,
read_at=n.read_at,
read_by_user_id=n.read_by_user_id,
action_required=n.action_required,
action_url=n.action_url,
metadata=n.notification_metadata,
created_at=n.created_at,
)
for n in notifications
],
total=total,
unread_count=unread_count,
skip=skip,
limit=limit,
)
@admin_notifications_router.post("", response_model=AdminNotificationResponse)
def create_notification(
notification_data: AdminNotificationCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> AdminNotificationResponse:
"""Create a new admin notification (manual)."""
notification = admin_notification_service.create_from_schema(
db=db, data=notification_data
)
db.commit()
logger.info(f"Admin {current_admin.username} created notification: {notification.title}")
return AdminNotificationResponse(
id=notification.id,
type=notification.type,
priority=notification.priority,
title=notification.title,
message=notification.message,
is_read=notification.is_read,
read_at=notification.read_at,
read_by_user_id=notification.read_by_user_id,
action_required=notification.action_required,
action_url=notification.action_url,
metadata=notification.notification_metadata,
created_at=notification.created_at,
)
@admin_notifications_router.get("/recent")
def get_recent_notifications(
limit: int = Query(5, ge=1, le=10),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> dict:
"""Get recent unread notifications for header dropdown."""
notifications = admin_notification_service.get_recent_notifications(
db=db, limit=limit
)
unread_count = admin_notification_service.get_unread_count(db)
return {
"notifications": [
{
"id": n.id,
"type": n.type,
"priority": n.priority,
"title": n.title,
"message": n.message[:100] + "..." if len(n.message) > 100 else n.message,
"action_url": n.action_url,
"created_at": n.created_at.isoformat(),
}
for n in notifications
],
"unread_count": unread_count,
}
@admin_notifications_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 count of unread notifications."""
count = admin_notification_service.get_unread_count(db)
return UnreadCountResponse(unread_count=count)
@admin_notifications_router.put("/{notification_id}/read", response_model=MessageResponse)
def mark_as_read(
notification_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> MessageResponse:
"""Mark notification as read."""
notification = admin_notification_service.mark_as_read(
db=db, notification_id=notification_id, user_id=current_admin.id
)
db.commit()
if notification:
return MessageResponse(message="Notification marked as read")
return MessageResponse(message="Notification not found")
@admin_notifications_router.put("/mark-all-read", response_model=MessageResponse)
def mark_all_as_read(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> MessageResponse:
"""Mark all notifications as read."""
count = admin_notification_service.mark_all_as_read(
db=db, user_id=current_admin.id
)
db.commit()
return MessageResponse(message=f"Marked {count} notifications as read")
@admin_notifications_router.delete("/{notification_id}", response_model=MessageResponse)
def delete_notification(
notification_id: int,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> MessageResponse:
"""Delete a notification."""
deleted = admin_notification_service.delete_notification(
db=db, notification_id=notification_id
)
db.commit()
if deleted:
return MessageResponse(message="Notification deleted")
return MessageResponse(message="Notification not found")
# ============================================================================
# PLATFORM ALERTS
# ============================================================================
@admin_notifications_router.get("/alerts", response_model=PlatformAlertListResponse)
def get_platform_alerts(
severity: str | None = Query(None, description="Filter by severity"),
alert_type: str | None = Query(None, description="Filter by alert type"),
is_resolved: bool | None = Query(None, description="Filter by resolution status"),
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),
) -> PlatformAlertListResponse:
"""Get platform alerts with filtering."""
alerts, total, active_count, critical_count = platform_alert_service.get_alerts(
db=db,
severity=severity,
alert_type=alert_type,
is_resolved=is_resolved,
skip=skip,
limit=limit,
)
return PlatformAlertListResponse(
alerts=[
PlatformAlertResponse(
id=a.id,
alert_type=a.alert_type,
severity=a.severity,
title=a.title,
description=a.description,
affected_vendors=a.affected_vendors,
affected_systems=a.affected_systems,
is_resolved=a.is_resolved,
resolved_at=a.resolved_at,
resolved_by_user_id=a.resolved_by_user_id,
resolution_notes=a.resolution_notes,
auto_generated=a.auto_generated,
occurrence_count=a.occurrence_count,
first_occurred_at=a.first_occurred_at,
last_occurred_at=a.last_occurred_at,
created_at=a.created_at,
)
for a in alerts
],
total=total,
active_count=active_count,
critical_count=critical_count,
skip=skip,
limit=limit,
)
@admin_notifications_router.post("/alerts", response_model=PlatformAlertResponse)
def create_platform_alert(
alert_data: PlatformAlertCreate,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> PlatformAlertResponse:
"""Create new platform alert (manual)."""
alert = platform_alert_service.create_from_schema(db=db, data=alert_data)
db.commit()
logger.info(f"Admin {current_admin.username} created alert: {alert.title}")
return PlatformAlertResponse(
id=alert.id,
alert_type=alert.alert_type,
severity=alert.severity,
title=alert.title,
description=alert.description,
affected_vendors=alert.affected_vendors,
affected_systems=alert.affected_systems,
is_resolved=alert.is_resolved,
resolved_at=alert.resolved_at,
resolved_by_user_id=alert.resolved_by_user_id,
resolution_notes=alert.resolution_notes,
auto_generated=alert.auto_generated,
occurrence_count=alert.occurrence_count,
first_occurred_at=alert.first_occurred_at,
last_occurred_at=alert.last_occurred_at,
created_at=alert.created_at,
)
@admin_notifications_router.put("/alerts/{alert_id}/resolve", response_model=MessageResponse)
def resolve_platform_alert(
alert_id: int,
resolve_data: PlatformAlertResolve,
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> MessageResponse:
"""Resolve platform alert."""
alert = platform_alert_service.resolve_alert(
db=db,
alert_id=alert_id,
user_id=current_admin.id,
resolution_notes=resolve_data.resolution_notes,
)
db.commit()
if alert:
logger.info(f"Admin {current_admin.username} resolved alert {alert_id}")
return MessageResponse(message="Alert resolved successfully")
return MessageResponse(message="Alert not found or already resolved")
@admin_notifications_router.get("/alerts/stats", response_model=AlertStatisticsResponse)
def get_alert_statistics(
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
) -> AlertStatisticsResponse:
"""Get alert statistics for dashboard."""
stats = platform_alert_service.get_statistics(db)
return AlertStatisticsResponse(**stats)

View File

@@ -27,12 +27,12 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.exceptions import (
from app.modules.messaging.exceptions import (
AttachmentNotFoundException,
ConversationClosedException,
ConversationNotFoundException,
VendorNotFoundException,
)
from app.modules.tenancy.exceptions import VendorNotFoundException
from app.modules.customers.schemas import CustomerContext
from app.modules.messaging.models.message import ConversationType, ParticipantType
from app.modules.messaging.schemas import (

View File

@@ -20,8 +20,8 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.vendor_email_settings_service import VendorEmailSettingsService
from app.services.subscription_service import subscription_service
from app.modules.cms.services.vendor_email_settings_service import VendorEmailSettingsService
from app.modules.billing.services.subscription_service import subscription_service
from models.schema.auth import UserContext
vendor_email_settings_router = APIRouter(prefix="/email-settings")

View File

@@ -17,9 +17,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.email_service import EmailService
from app.services.email_template_service import EmailTemplateService
from app.services.vendor_service import vendor_service
from app.modules.messaging.services.email_service import EmailService
from app.modules.messaging.services.email_template_service import EmailTemplateService
from app.modules.tenancy.services.vendor_service import vendor_service
from models.schema.auth import UserContext
vendor_email_templates_router = APIRouter(prefix="/email-templates")

View File

@@ -20,15 +20,15 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions import (
from app.modules.messaging.exceptions import (
ConversationClosedException,
ConversationNotFoundException,
InvalidConversationTypeException,
InvalidRecipientTypeException,
MessageAttachmentException,
)
from app.services.message_attachment_service import message_attachment_service
from app.services.messaging_service import messaging_service
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.messaging.models import ConversationType, ParticipantType
from app.modules.messaging.schemas import (
AttachmentResponse,

View File

@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.services.vendor_service import vendor_service
from app.modules.tenancy.services.vendor_service import vendor_service
from models.schema.auth import UserContext
from app.modules.messaging.schemas import (
MessageResponse,