refactor: migrate messaging and media routes to modules

Messaging module (communication):
- vendor_messages.py: Conversation and message management
- vendor_notifications.py: Vendor notifications
- vendor_email_settings.py: SMTP and provider configuration
- vendor_email_templates.py: Email template customization

CMS module (content management):
- vendor_media.py: Media library management
- vendor_content_pages.py: Content page overrides

All routes auto-discovered via is_self_contained=True.
Deleted 5 legacy files from app/api/v1/vendor/.
app/api/v1/vendor/ now empty except for __init__.py (auto-discovery only).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 15:21:25 +01:00
parent da3f28849e
commit 7639ca602b
12 changed files with 385 additions and 410 deletions

View File

@@ -1,278 +1,25 @@
# app/modules/cms/routes/api/vendor.py
"""
Vendor Content Pages API
CMS module vendor API routes.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
Vendors can:
- View their content pages (includes platform defaults)
- Create/edit/delete their own content page overrides
- Preview pages before publishing
Aggregates all vendor CMS routes:
- /content-pages/* - Content page management
- /media/* - Media library management
"""
import logging
from fastapi import APIRouter
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, get_db
from app.modules.cms.exceptions import ContentPageNotFoundException
from app.modules.cms.schemas import (
VendorContentPageCreate,
VendorContentPageUpdate,
ContentPageResponse,
CMSUsageResponse,
)
from app.modules.cms.services import content_page_service
from app.services.vendor_service import VendorService # noqa: MOD-004 - shared platform service
from models.database.user import User
from .vendor_content_pages import vendor_content_pages_router
from .vendor_media import vendor_media_router
# Route configuration for auto-discovery
ROUTE_CONFIG = {
"prefix": "/content-pages",
"tags": ["vendor-content-pages"],
"priority": 100, # Register last (CMS has catch-all slug routes)
}
vendor_service = VendorService()
vendor_router = APIRouter()
router = vendor_router # Alias for discovery compatibility
router = APIRouter()
vendor_router = router # Alias for discovery compatibility
logger = logging.getLogger(__name__)
# ============================================================================
# VENDOR CONTENT PAGES
# ============================================================================
@router.get("/", response_model=list[ContentPageResponse])
def list_vendor_pages(
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
List all content pages available for this vendor.
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
"""
pages = content_page_service.list_pages_for_vendor(
db, vendor_id=current_user.token_vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@router.get("/overrides", response_model=list[ContentPageResponse])
def list_vendor_overrides(
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
List only vendor-specific content pages (no platform defaults).
Shows what the vendor has customized.
"""
pages = content_page_service.list_all_vendor_pages(
db, vendor_id=current_user.token_vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@router.get("/usage", response_model=CMSUsageResponse)
def get_cms_usage(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get CMS usage statistics for the vendor.
Returns page counts and limits based on subscription tier.
"""
vendor = vendor_service.get_vendor_by_id_optional(db, current_user.token_vendor_id)
if not vendor:
return CMSUsageResponse(
total_pages=0,
custom_pages=0,
override_pages=0,
pages_limit=3,
custom_pages_limit=0,
can_create_page=False,
can_create_custom=False,
usage_percent=0,
custom_usage_percent=0,
)
# Get vendor's pages
vendor_pages = content_page_service.list_all_vendor_pages(
db, vendor_id=current_user.token_vendor_id, include_unpublished=True
)
total_pages = len(vendor_pages)
override_pages = sum(1 for p in vendor_pages if p.is_vendor_override)
custom_pages = total_pages - override_pages
# Get limits from subscription tier
pages_limit = None
custom_pages_limit = None
if vendor.subscription and vendor.subscription.tier:
pages_limit = vendor.subscription.tier.cms_pages_limit
custom_pages_limit = vendor.subscription.tier.cms_custom_pages_limit
# Calculate can_create flags
can_create_page = pages_limit is None or total_pages < pages_limit
can_create_custom = custom_pages_limit is None or custom_pages < custom_pages_limit
# Calculate usage percentages
usage_percent = 0 if pages_limit is None else min(100, (total_pages / pages_limit) * 100) if pages_limit > 0 else 100
custom_usage_percent = 0 if custom_pages_limit is None else min(100, (custom_pages / custom_pages_limit) * 100) if custom_pages_limit > 0 else 100
return CMSUsageResponse(
total_pages=total_pages,
custom_pages=custom_pages,
override_pages=override_pages,
pages_limit=pages_limit,
custom_pages_limit=custom_pages_limit,
can_create_page=can_create_page,
can_create_custom=can_create_custom,
usage_percent=usage_percent,
custom_usage_percent=custom_usage_percent,
)
@router.get("/platform-default/{slug}", response_model=ContentPageResponse)
def get_platform_default(
slug: str,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get the platform default content for a slug.
Useful for vendors to view the original before/after overriding.
"""
# Get vendor's platform
vendor = vendor_service.get_vendor_by_id_optional(db, current_user.token_vendor_id)
platform_id = 1 # Default to OMS
if vendor and vendor.platforms:
platform_id = vendor.platforms[0].id
# Get platform default (vendor_id=None)
page = content_page_service.get_vendor_default_page(
db, platform_id=platform_id, slug=slug, include_unpublished=True
)
if not page:
raise ContentPageNotFoundException(slug)
return page.to_dict()
@router.get("/{slug}", response_model=ContentPageResponse)
def get_page(
slug: str,
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get a specific content page by slug.
Returns vendor override if exists, otherwise platform default.
"""
page = content_page_service.get_page_for_vendor_or_raise(
db,
slug=slug,
vendor_id=current_user.token_vendor_id,
include_unpublished=include_unpublished,
)
return page.to_dict()
@router.post("/", response_model=ContentPageResponse, status_code=201)
def create_vendor_page(
page_data: VendorContentPageCreate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Create a vendor-specific content page override.
This will be shown instead of the platform default for this vendor.
"""
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
vendor_id=current_user.token_vendor_id,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
created_by=current_user.id,
)
db.commit()
return page.to_dict()
@router.put("/{page_id}", response_model=ContentPageResponse)
def update_vendor_page(
page_id: int,
page_data: VendorContentPageUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update a vendor-specific content page.
Can only update pages owned by this vendor.
"""
# Update with ownership check in service layer
page = content_page_service.update_vendor_page(
db,
page_id=page_id,
vendor_id=current_user.token_vendor_id,
title=page_data.title,
content=page_data.content,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
updated_by=current_user.id,
)
db.commit()
return page.to_dict()
@router.delete("/{page_id}", status_code=204)
def delete_vendor_page(
page_id: int,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Delete a vendor-specific content page.
Can only delete pages owned by this vendor.
After deletion, platform default will be shown (if exists).
"""
# Delete with ownership check in service layer
content_page_service.delete_vendor_page(db, page_id, current_user.token_vendor_id)
db.commit()
# Aggregate all CMS vendor routes
vendor_router.include_router(vendor_content_pages_router, tags=["vendor-content-pages"])
vendor_router.include_router(vendor_media_router, tags=["vendor-media"])

View File

@@ -0,0 +1,270 @@
# app/modules/cms/routes/api/vendor_content_pages.py
"""
Vendor Content Pages API
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
Vendors can:
- View their content pages (includes platform defaults)
- Create/edit/delete their own content page overrides
- Preview pages before publishing
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, get_db
from app.modules.cms.exceptions import ContentPageNotFoundException
from app.modules.cms.schemas import (
VendorContentPageCreate,
VendorContentPageUpdate,
ContentPageResponse,
CMSUsageResponse,
)
from app.modules.cms.services import content_page_service
from app.services.vendor_service import VendorService # noqa: MOD-004 - shared platform service
from models.database.user import User
vendor_service = VendorService()
vendor_content_pages_router = APIRouter(prefix="/content-pages")
logger = logging.getLogger(__name__)
# ============================================================================
# VENDOR CONTENT PAGES
# ============================================================================
@vendor_content_pages_router.get("/", response_model=list[ContentPageResponse])
def list_vendor_pages(
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
List all content pages available for this vendor.
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
"""
pages = content_page_service.list_pages_for_vendor(
db, vendor_id=current_user.token_vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@vendor_content_pages_router.get("/overrides", response_model=list[ContentPageResponse])
def list_vendor_overrides(
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
List only vendor-specific content pages (no platform defaults).
Shows what the vendor has customized.
"""
pages = content_page_service.list_all_vendor_pages(
db, vendor_id=current_user.token_vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@vendor_content_pages_router.get("/usage", response_model=CMSUsageResponse)
def get_cms_usage(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get CMS usage statistics for the vendor.
Returns page counts and limits based on subscription tier.
"""
vendor = vendor_service.get_vendor_by_id_optional(db, current_user.token_vendor_id)
if not vendor:
return CMSUsageResponse(
total_pages=0,
custom_pages=0,
override_pages=0,
pages_limit=3,
custom_pages_limit=0,
can_create_page=False,
can_create_custom=False,
usage_percent=0,
custom_usage_percent=0,
)
# Get vendor's pages
vendor_pages = content_page_service.list_all_vendor_pages(
db, vendor_id=current_user.token_vendor_id, include_unpublished=True
)
total_pages = len(vendor_pages)
override_pages = sum(1 for p in vendor_pages if p.is_vendor_override)
custom_pages = total_pages - override_pages
# Get limits from subscription tier
pages_limit = None
custom_pages_limit = None
if vendor.subscription and vendor.subscription.tier:
pages_limit = vendor.subscription.tier.cms_pages_limit
custom_pages_limit = vendor.subscription.tier.cms_custom_pages_limit
# Calculate can_create flags
can_create_page = pages_limit is None or total_pages < pages_limit
can_create_custom = custom_pages_limit is None or custom_pages < custom_pages_limit
# Calculate usage percentages
usage_percent = 0 if pages_limit is None else min(100, (total_pages / pages_limit) * 100) if pages_limit > 0 else 100
custom_usage_percent = 0 if custom_pages_limit is None else min(100, (custom_pages / custom_pages_limit) * 100) if custom_pages_limit > 0 else 100
return CMSUsageResponse(
total_pages=total_pages,
custom_pages=custom_pages,
override_pages=override_pages,
pages_limit=pages_limit,
custom_pages_limit=custom_pages_limit,
can_create_page=can_create_page,
can_create_custom=can_create_custom,
usage_percent=usage_percent,
custom_usage_percent=custom_usage_percent,
)
@vendor_content_pages_router.get("/platform-default/{slug}", response_model=ContentPageResponse)
def get_platform_default(
slug: str,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get the platform default content for a slug.
Useful for vendors to view the original before/after overriding.
"""
# Get vendor's platform
vendor = vendor_service.get_vendor_by_id_optional(db, current_user.token_vendor_id)
platform_id = 1 # Default to OMS
if vendor and vendor.platforms:
platform_id = vendor.platforms[0].id
# Get platform default (vendor_id=None)
page = content_page_service.get_vendor_default_page(
db, platform_id=platform_id, slug=slug, include_unpublished=True
)
if not page:
raise ContentPageNotFoundException(slug)
return page.to_dict()
@vendor_content_pages_router.get("/{slug}", response_model=ContentPageResponse)
def get_page(
slug: str,
include_unpublished: bool = Query(False, description="Include draft pages"),
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get a specific content page by slug.
Returns vendor override if exists, otherwise platform default.
"""
page = content_page_service.get_page_for_vendor_or_raise(
db,
slug=slug,
vendor_id=current_user.token_vendor_id,
include_unpublished=include_unpublished,
)
return page.to_dict()
@vendor_content_pages_router.post("/", response_model=ContentPageResponse, status_code=201)
def create_vendor_page(
page_data: VendorContentPageCreate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Create a vendor-specific content page override.
This will be shown instead of the platform default for this vendor.
"""
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
vendor_id=current_user.token_vendor_id,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
created_by=current_user.id,
)
db.commit()
return page.to_dict()
@vendor_content_pages_router.put("/{page_id}", response_model=ContentPageResponse)
def update_vendor_page(
page_id: int,
page_data: VendorContentPageUpdate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update a vendor-specific content page.
Can only update pages owned by this vendor.
"""
# Update with ownership check in service layer
page = content_page_service.update_vendor_page(
db,
page_id=page_id,
vendor_id=current_user.token_vendor_id,
title=page_data.title,
content=page_data.content,
content_format=page_data.content_format,
meta_description=page_data.meta_description,
meta_keywords=page_data.meta_keywords,
is_published=page_data.is_published,
show_in_footer=page_data.show_in_footer,
show_in_header=page_data.show_in_header,
show_in_legal=page_data.show_in_legal,
display_order=page_data.display_order,
updated_by=current_user.id,
)
db.commit()
return page.to_dict()
@vendor_content_pages_router.delete("/{page_id}", status_code=204)
def delete_vendor_page(
page_id: int,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Delete a vendor-specific content page.
Can only delete pages owned by this vendor.
After deletion, platform default will be shown (if exists).
"""
# Delete with ownership check in service layer
content_page_service.delete_vendor_page(db, page_id, current_user.token_vendor_id)
db.commit()

View File

@@ -0,0 +1,304 @@
# app/modules/cms/routes/api/vendor_media.py
"""
Vendor media and file management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
from fastapi import APIRouter, Depends, File, Query, UploadFile
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api
from app.core.database import get_db
from app.exceptions.media import MediaOptimizationException
from app.services.media_service import media_service
from models.schema.auth import UserContext
from models.schema.media import (
MediaDetailResponse,
MediaItemResponse,
MediaListResponse,
MediaMetadataUpdate,
MediaUploadResponse,
MediaUsageResponse,
MultipleUploadResponse,
OptimizationResultResponse,
UploadedFileInfo,
FailedFileInfo,
)
vendor_media_router = APIRouter(prefix="/media")
logger = logging.getLogger(__name__)
@vendor_media_router.get("", response_model=MediaListResponse)
def get_media_library(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
media_type: str | None = Query(None, description="image, video, document"),
folder: str | None = Query(None, description="Filter by folder"),
search: str | None = Query(None),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get vendor media library.
- Get all media files for vendor
- Filter by type (image, video, document)
- Filter by folder
- Search by filename
- Support pagination
"""
media_files, total = media_service.get_media_library(
db=db,
vendor_id=current_user.token_vendor_id,
skip=skip,
limit=limit,
media_type=media_type,
folder=folder,
search=search,
)
return MediaListResponse(
media=[MediaItemResponse.model_validate(m) for m in media_files],
total=total,
skip=skip,
limit=limit,
)
@vendor_media_router.post("/upload", response_model=MediaUploadResponse)
async def upload_media(
file: UploadFile = File(...),
folder: str | None = Query("general", description="products, general, etc."),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Upload media file.
- Accept file upload
- Validate file type and size
- Store file in vendor-specific directory
- Generate thumbnails for images
- Save metadata to database
- Return file URL
"""
# Read file content
file_content = await file.read()
# Upload using service (exceptions will propagate to handler)
media_file = await media_service.upload_file(
db=db,
vendor_id=current_user.token_vendor_id,
file_content=file_content,
filename=file.filename or "unnamed",
folder=folder or "general",
)
db.commit()
return MediaUploadResponse(
id=media_file.id,
file_url=media_file.file_url,
thumbnail_url=media_file.thumbnail_url,
filename=media_file.original_filename,
file_size=media_file.file_size,
media_type=media_file.media_type,
message="File uploaded successfully",
)
@vendor_media_router.post("/upload/multiple", response_model=MultipleUploadResponse)
async def upload_multiple_media(
files: list[UploadFile] = File(...),
folder: str | None = Query("general"),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Upload multiple media files at once.
- Accept multiple files
- Process each file
- Return list of uploaded file URLs
- Handle errors gracefully
"""
uploaded = []
failed = []
for file in files:
try:
file_content = await file.read()
media_file = await media_service.upload_file(
db=db,
vendor_id=current_user.token_vendor_id,
file_content=file_content,
filename=file.filename or "unnamed",
folder=folder or "general",
)
uploaded.append(UploadedFileInfo(
id=media_file.id,
filename=media_file.original_filename or media_file.filename,
file_url=media_file.file_url,
thumbnail_url=media_file.thumbnail_url,
))
except Exception as e:
logger.warning(f"Failed to upload {file.filename}: {e}")
failed.append(FailedFileInfo(
filename=file.filename or "unnamed",
error=str(e),
))
db.commit()
return MultipleUploadResponse(
uploaded_files=uploaded,
failed_files=failed,
total_uploaded=len(uploaded),
total_failed=len(failed),
message=f"Uploaded {len(uploaded)} files, {len(failed)} failed",
)
@vendor_media_router.get("/{media_id}", response_model=MediaDetailResponse)
def get_media_details(
media_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get media file details.
- Get file metadata
- Return file URL
- Return basic info
"""
# Service will raise MediaNotFoundException if not found
media = media_service.get_media(
db=db,
vendor_id=current_user.token_vendor_id,
media_id=media_id,
)
return MediaDetailResponse.model_validate(media)
@vendor_media_router.put("/{media_id}", response_model=MediaDetailResponse)
def update_media_metadata(
media_id: int,
metadata: MediaMetadataUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update media file metadata.
- Update filename
- Update alt text
- Update description
- Move to different folder
"""
# Service will raise MediaNotFoundException if not found
media = media_service.update_media_metadata(
db=db,
vendor_id=current_user.token_vendor_id,
media_id=media_id,
filename=metadata.filename,
alt_text=metadata.alt_text,
description=metadata.description,
folder=metadata.folder,
metadata=metadata.metadata,
)
db.commit()
return MediaDetailResponse.model_validate(media)
@vendor_media_router.delete("/{media_id}", response_model=MediaDetailResponse)
def delete_media(
media_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Delete media file.
- Verify file belongs to vendor
- Delete file from storage
- Delete database record
- Return success/error
"""
# Service will raise MediaNotFoundException if not found
media_service.delete_media(
db=db,
vendor_id=current_user.token_vendor_id,
media_id=media_id,
)
db.commit()
return MediaDetailResponse(message="Media file deleted successfully")
@vendor_media_router.get("/{media_id}/usage", response_model=MediaUsageResponse)
def get_media_usage(
media_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get where this media file is being used.
- Check products using this media
- Return list of usage
"""
# Service will raise MediaNotFoundException if not found
usage = media_service.get_media_usage(
db=db,
vendor_id=current_user.token_vendor_id,
media_id=media_id,
)
return MediaUsageResponse(**usage)
@vendor_media_router.post("/optimize/{media_id}", response_model=OptimizationResultResponse)
def optimize_media(
media_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Optimize media file (compress, resize, etc.).
Note: Image optimization requires PIL/Pillow to be installed.
"""
# Service will raise MediaNotFoundException if not found
media = media_service.get_media(
db=db,
vendor_id=current_user.token_vendor_id,
media_id=media_id,
)
if media.media_type != "image":
raise MediaOptimizationException("Only images can be optimized")
# For now, return current state - optimization is done on upload
return OptimizationResultResponse(
media_id=media_id,
original_size=media.file_size,
optimized_size=media.optimized_size or media.file_size,
savings_percent=0.0 if not media.optimized_size else
round((1 - media.optimized_size / media.file_size) * 100, 1),
optimized_url=media.file_url,
message="Image optimization applied on upload" if media.is_optimized
else "Image not yet optimized",
)

View File

@@ -2,38 +2,15 @@
"""
CMS module vendor routes.
This module wraps the existing vendor content pages and media routes
and adds module-based access control. Routes are re-exported from the
original location with the module access dependency.
Re-exports routes from the API routes for backwards compatibility
with the lazy router attachment pattern.
Includes:
- /content-pages/* - Content page management
- /media/* - Media library
"""
from fastapi import APIRouter, Depends
# Re-export vendor_router from API routes
from app.modules.cms.routes.api.vendor import vendor_router
from app.api.deps import require_module_access
# Import original routers (direct import to avoid circular dependency)
from app.api.v1.vendor.content_pages import router as content_original_router
from app.api.v1.vendor.media import router as media_original_router
# Create module-aware router for content pages
vendor_router = APIRouter(
prefix="/content-pages",
dependencies=[Depends(require_module_access("cms"))],
)
# Re-export all routes from the original content pages module
for route in content_original_router.routes:
vendor_router.routes.append(route)
# Create separate router for media library
vendor_media_router = APIRouter(
prefix="/media",
dependencies=[Depends(require_module_access("cms"))],
)
for route in media_original_router.routes:
vendor_media_router.routes.append(route)
__all__ = ["vendor_router"]

View File

@@ -1,9 +1,21 @@
# app/modules/messaging/routes/api/__init__.py
"""Messaging module API routes."""
"""
Messaging module API routes.
Vendor routes:
- /messages/* - Conversation and message management
- /notifications/* - Vendor notifications
- /email-settings/* - SMTP and provider configuration
- /email-templates/* - Email template customization
Storefront routes:
- Customer-facing messaging
"""
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", "STOREFRONT_TAG"]
__all__ = ["storefront_router", "vendor_router", "STOREFRONT_TAG"]

View File

@@ -0,0 +1,29 @@
# app/modules/messaging/routes/api/vendor.py
"""
Messaging module vendor API routes.
Aggregates all vendor messaging routes:
- /messages/* - Conversation and message management
- /notifications/* - Vendor notifications
- /email-settings/* - SMTP and provider configuration
- /email-templates/* - Email template customization
"""
from fastapi import APIRouter, Depends
from app.api.deps import require_module_access
from .vendor_messages import vendor_messages_router
from .vendor_notifications import vendor_notifications_router
from .vendor_email_settings import vendor_email_settings_router
from .vendor_email_templates import vendor_email_templates_router
vendor_router = APIRouter(
dependencies=[Depends(require_module_access("messaging"))],
)
# Aggregate all messaging vendor routes
vendor_router.include_router(vendor_messages_router, tags=["vendor-messages"])
vendor_router.include_router(vendor_notifications_router, tags=["vendor-notifications"])
vendor_router.include_router(vendor_email_settings_router, tags=["vendor-email-settings"])
vendor_router.include_router(vendor_email_templates_router, tags=["vendor-email-templates"])

View File

@@ -0,0 +1,276 @@
# app/modules/messaging/routes/api/vendor_email_settings.py
"""
Vendor email settings API endpoints.
Allows vendors to configure their email sending settings:
- SMTP configuration (all tiers)
- Advanced providers: SendGrid, Mailgun, SES (Business+ tier)
- Sender identity (from_email, from_name, reply_to)
- Signature/footer customization
- Configuration verification via test email
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
"""
import logging
from fastapi import APIRouter, Depends
from pydantic import BaseModel, EmailStr, Field
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 models.schema.auth import UserContext
vendor_email_settings_router = APIRouter(prefix="/email-settings")
logger = logging.getLogger(__name__)
# =============================================================================
# SCHEMAS
# =============================================================================
class EmailSettingsUpdate(BaseModel):
"""Schema for creating/updating email settings."""
# Sender Identity (Required)
from_email: EmailStr = Field(..., description="Sender email address")
from_name: str = Field(..., min_length=1, max_length=100, description="Sender name")
reply_to_email: EmailStr | None = Field(None, description="Reply-to email address")
# Signature (Optional)
signature_text: str | None = Field(None, description="Plain text signature")
signature_html: str | None = Field(None, description="HTML signature/footer")
# Provider
provider: str = Field("smtp", description="Email provider: smtp, sendgrid, mailgun, ses")
# SMTP Settings
smtp_host: str | None = Field(None, description="SMTP server hostname")
smtp_port: int | None = Field(587, ge=1, le=65535, description="SMTP server port")
smtp_username: str | None = Field(None, description="SMTP username")
smtp_password: str | None = Field(None, description="SMTP password")
smtp_use_tls: bool = Field(True, description="Use STARTTLS")
smtp_use_ssl: bool = Field(False, description="Use SSL/TLS (port 465)")
# SendGrid
sendgrid_api_key: str | None = Field(None, description="SendGrid API key")
# Mailgun
mailgun_api_key: str | None = Field(None, description="Mailgun API key")
mailgun_domain: str | None = Field(None, description="Mailgun sending domain")
# SES
ses_access_key_id: str | None = Field(None, description="AWS access key ID")
ses_secret_access_key: str | None = Field(None, description="AWS secret access key")
ses_region: str | None = Field("eu-west-1", description="AWS region")
class VerifyEmailRequest(BaseModel):
"""Schema for verifying email settings."""
test_email: EmailStr = Field(..., description="Email address to send test email to")
# Response models for API-001 compliance
class EmailSettingsResponse(BaseModel):
"""Response for email settings."""
configured: bool
verified: bool | None = None
settings: dict | None = None
message: str | None = None
class EmailStatusResponse(BaseModel):
"""Response for email status check."""
is_configured: bool
is_verified: bool
class ProvidersResponse(BaseModel):
"""Response for available providers."""
providers: list[dict]
current_tier: str | None
class EmailUpdateResponse(BaseModel):
"""Response for email settings update."""
success: bool
message: str
settings: dict
class EmailVerifyResponse(BaseModel):
"""Response for email verification."""
success: bool
message: str
class EmailDeleteResponse(BaseModel):
"""Response for email settings deletion."""
success: bool
message: str
# =============================================================================
# ENDPOINTS
# =============================================================================
@vendor_email_settings_router.get("", response_model=EmailSettingsResponse)
def get_email_settings(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
) -> EmailSettingsResponse:
"""
Get current email settings for the vendor.
Returns settings with sensitive fields masked.
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
settings = service.get_settings(vendor_id)
if not settings:
return EmailSettingsResponse(
configured=False,
settings=None,
message="Email settings not configured. Configure SMTP to send emails to customers.",
)
return EmailSettingsResponse(
configured=settings.is_configured,
verified=settings.is_verified,
settings=settings.to_dict(),
)
@vendor_email_settings_router.get("/status", response_model=EmailStatusResponse)
def get_email_status(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
) -> EmailStatusResponse:
"""
Get email configuration status.
Used by frontend to show warning banner if not configured.
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
status = service.get_status(vendor_id)
return EmailStatusResponse(**status)
@vendor_email_settings_router.get("/providers", response_model=ProvidersResponse)
def get_available_providers(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
) -> ProvidersResponse:
"""
Get available email providers for current tier.
Returns list of providers with availability status.
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
# Get vendor's current tier
tier = subscription_service.get_current_tier(db, vendor_id)
return ProvidersResponse(
providers=service.get_available_providers(tier),
current_tier=tier.value if tier else None,
)
@vendor_email_settings_router.put("", response_model=EmailUpdateResponse)
def update_email_settings(
data: EmailSettingsUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
) -> EmailUpdateResponse:
"""
Create or update email settings.
Premium providers (SendGrid, Mailgun, SES) require Business+ tier.
Raises AuthorizationException if tier is insufficient.
Raises ValidationException if data is invalid.
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
# Get vendor's current tier for validation
tier = subscription_service.get_current_tier(db, vendor_id)
# Service raises appropriate exceptions (API-003 compliance)
settings = service.create_or_update(
vendor_id=vendor_id,
data=data.model_dump(exclude_unset=True),
current_tier=tier,
)
db.commit()
return EmailUpdateResponse(
success=True,
message="Email settings updated successfully",
settings=settings.to_dict(),
)
@vendor_email_settings_router.post("/verify", response_model=EmailVerifyResponse)
def verify_email_settings(
data: VerifyEmailRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
) -> EmailVerifyResponse:
"""
Verify email settings by sending a test email.
Sends a test email to the provided address and updates verification status.
Raises ResourceNotFoundException if settings not configured.
Raises ValidationException if verification fails.
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
# Service raises appropriate exceptions (API-003 compliance)
result = service.verify_settings(vendor_id, data.test_email)
db.commit()
return EmailVerifyResponse(
success=result["success"],
message=result["message"],
)
@vendor_email_settings_router.delete("", response_model=EmailDeleteResponse)
def delete_email_settings(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
) -> EmailDeleteResponse:
"""
Delete email settings.
Warning: This will disable email sending for the vendor.
Raises ResourceNotFoundException if settings not found.
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
# Service raises ResourceNotFoundException if not found (API-003 compliance)
service.delete(vendor_id)
db.commit()
return EmailDeleteResponse(
success=True,
message="Email settings deleted",
)

View File

@@ -0,0 +1,291 @@
# app/modules/messaging/routes/api/vendor_email_templates.py
"""
Vendor email template override endpoints.
Allows vendors to customize platform email templates with their own content.
Platform-only templates (billing, subscription) cannot be overridden.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
"""
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_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 models.schema.auth import UserContext
vendor_email_templates_router = APIRouter(prefix="/email-templates")
logger = logging.getLogger(__name__)
# =============================================================================
# SCHEMAS
# =============================================================================
class VendorTemplateUpdate(BaseModel):
"""Schema for creating/updating a vendor template override."""
subject: str = Field(..., min_length=1, max_length=500)
body_html: str = Field(..., min_length=1)
body_text: str | None = None
name: str | None = Field(None, max_length=255)
class TemplatePreviewRequest(BaseModel):
"""Schema for previewing a template."""
language: str = "en"
variables: dict[str, Any] = {}
class TemplateTestRequest(BaseModel):
"""Schema for sending a test email."""
to_email: EmailStr
language: str = "en"
variables: dict[str, Any] = {}
# =============================================================================
# ENDPOINTS
# =============================================================================
@vendor_email_templates_router.get("")
def list_overridable_templates(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
List all email templates that the vendor can customize.
Returns platform templates with vendor override status.
Platform-only templates (billing, subscription) are excluded.
"""
vendor_id = current_user.token_vendor_id
service = EmailTemplateService(db)
return service.list_overridable_templates(vendor_id)
@vendor_email_templates_router.get("/{code}")
def get_template(
code: str,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get a specific template with all language versions.
Returns platform template details and vendor overrides for each language.
"""
vendor_id = current_user.token_vendor_id
service = EmailTemplateService(db)
return service.get_vendor_template(vendor_id, code)
@vendor_email_templates_router.get("/{code}/{language}")
def get_template_language(
code: str,
language: str,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get a specific template for a specific language.
Returns vendor override if exists, otherwise platform template.
"""
vendor_id = current_user.token_vendor_id
service = EmailTemplateService(db)
return service.get_vendor_template_language(vendor_id, code, language)
@vendor_email_templates_router.put("/{code}/{language}")
def update_template_override(
code: str,
language: str,
template_data: VendorTemplateUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Create or update a vendor template override.
Creates a vendor-specific version of the email template.
The platform template remains unchanged.
"""
vendor_id = current_user.token_vendor_id
service = EmailTemplateService(db)
result = service.create_or_update_vendor_override(
vendor_id=vendor_id,
code=code,
language=language,
subject=template_data.subject,
body_html=template_data.body_html,
body_text=template_data.body_text,
name=template_data.name,
)
db.commit()
return result
@vendor_email_templates_router.delete("/{code}/{language}")
def delete_template_override(
code: str,
language: str,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Delete a vendor template override.
Reverts to using the platform default template for this language.
"""
vendor_id = current_user.token_vendor_id
service = EmailTemplateService(db)
service.delete_vendor_override(vendor_id, code, language)
db.commit()
return {
"message": "Template override deleted - reverted to platform default",
"code": code,
"language": language,
}
@vendor_email_templates_router.post("/{code}/preview")
def preview_template(
code: str,
preview_data: TemplatePreviewRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Preview a template with sample variables.
Uses vendor override if exists, otherwise platform template.
"""
vendor_id = current_user.token_vendor_id
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
service = EmailTemplateService(db)
# Add branding variables
variables = {
**_get_sample_variables(code),
**preview_data.variables,
"platform_name": "Wizamart",
"vendor_name": vendor.name if vendor else "Your Store",
"support_email": vendor.contact_email if vendor else "support@wizamart.com",
}
return service.preview_vendor_template(
vendor_id=vendor_id,
code=code,
language=preview_data.language,
variables=variables,
)
@vendor_email_templates_router.post("/{code}/test")
def send_test_email(
code: str,
test_data: TemplateTestRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Send a test email using the template.
Uses vendor override if exists, otherwise platform template.
"""
vendor_id = current_user.token_vendor_id
vendor = vendor_service.get_vendor_by_id(db, vendor_id)
# Build test variables
variables = {
**_get_sample_variables(code),
**test_data.variables,
"platform_name": "Wizamart",
"vendor_name": vendor.name if vendor else "Your Store",
"support_email": vendor.contact_email if vendor else "support@wizamart.com",
}
try:
email_svc = EmailService(db)
email_log = email_svc.send_template(
template_code=code,
to_email=test_data.to_email,
variables=variables,
vendor_id=vendor_id,
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),
}
# =============================================================================
# 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",
},
"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",
},
"password_reset": {
"customer_name": "John Doe",
"reset_link": "https://example.com/reset?token=abc123",
"expiry_hours": "1",
},
"team_invite": {
"invitee_name": "Jane",
"inviter_name": "John",
"vendor_name": "Acme Corp",
"role": "Admin",
"accept_url": "https://example.com/accept",
"expires_in_days": "7",
},
}
return samples.get(template_code, {})

View File

@@ -0,0 +1,633 @@
# app/modules/messaging/routes/api/vendor_messages.py
"""
Vendor messaging endpoints.
Provides endpoints for:
- Viewing conversations (vendor_customer and admin_vendor channels)
- Sending and receiving messages
- Managing conversation status
- File attachments
Uses get_current_vendor_api dependency which guarantees token_vendor_id is present.
"""
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_vendor_api
from app.core.database import get_db
from app.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.models import ConversationType, ParticipantType
from app.modules.messaging.schemas import (
AttachmentResponse,
CloseConversationResponse,
ConversationCreate,
ConversationDetailResponse,
ConversationListResponse,
ConversationSummary,
MarkReadResponse,
MessageResponse,
NotificationPreferencesUpdate,
ParticipantInfo,
ParticipantResponse,
RecipientListResponse,
RecipientOption,
ReopenConversationResponse,
UnreadCountResponse,
)
from models.schema.auth import UserContext
vendor_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, vendor_id: int
) -> ConversationSummary:
"""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.VENDOR
and p.participant_id == current_user_id
and p.vendor_id == vendor_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.VENDOR, 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
return ConversationSummary(
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,
)
# ============================================================================
# CONVERSATION LIST
# ============================================================================
@vendor_messages_router.get("", response_model=ConversationListResponse)
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_user: UserContext = Depends(get_current_vendor_api),
) -> ConversationListResponse:
"""List conversations for vendor (vendor_customer and admin_vendor channels)."""
vendor_id = current_user.token_vendor_id
conversations, total, total_unread = messaging_service.list_conversations(
db=db,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
vendor_id=vendor_id,
conversation_type=conversation_type,
is_closed=is_closed,
skip=skip,
limit=limit,
)
return ConversationListResponse(
conversations=[
_enrich_conversation_summary(db, c, current_user.id, vendor_id)
for c in conversations
],
total=total,
total_unread=total_unread,
skip=skip,
limit=limit,
)
@vendor_messages_router.get("/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_vendor_api),
) -> UnreadCountResponse:
"""Get total unread message count for header badge."""
vendor_id = current_user.token_vendor_id
count = messaging_service.get_unread_count(
db=db,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
vendor_id=vendor_id,
)
return UnreadCountResponse(total_unread=count)
# ============================================================================
# RECIPIENTS
# ============================================================================
@vendor_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"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_vendor_api),
) -> RecipientListResponse:
"""Get list of available recipients for compose modal."""
vendor_id = current_user.token_vendor_id
if recipient_type == ParticipantType.CUSTOMER:
# List customers for this vendor (for vendor_customer conversations)
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:
# Vendors can't start conversations with admins - admins initiate those
recipients = []
total = 0
return RecipientListResponse(recipients=recipients, total=total)
# ============================================================================
# CREATE CONVERSATION
# ============================================================================
@vendor_messages_router.post("", response_model=ConversationDetailResponse)
def create_conversation(
data: ConversationCreate,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_vendor_api),
) -> ConversationDetailResponse:
"""Create a new conversation with a customer."""
vendor_id = current_user.token_vendor_id
# Vendors can only create vendor_customer conversations
if data.conversation_type != ConversationType.VENDOR_CUSTOMER:
raise InvalidConversationTypeException(
message="Vendors can only create vendor_customer conversations",
allowed_types=["vendor_customer"],
)
if data.recipient_type != ParticipantType.CUSTOMER:
raise InvalidRecipientTypeException(
conversation_type="vendor_customer",
expected_recipient_type="customer",
)
# Create conversation
conversation = messaging_service.create_conversation(
db=db,
conversation_type=ConversationType.VENDOR_CUSTOMER,
subject=data.subject,
initiator_type=ParticipantType.VENDOR,
initiator_id=current_user.id,
recipient_type=ParticipantType.CUSTOMER,
recipient_id=data.recipient_id,
vendor_id=vendor_id,
initial_message=data.initial_message,
)
db.commit()
db.refresh(conversation)
logger.info(
f"Vendor {current_user.username} created conversation {conversation.id} "
f"with customer:{data.recipient_id}"
)
# Return full detail response
return _build_conversation_detail(db, conversation, current_user.id, vendor_id)
# ============================================================================
# CONVERSATION DETAIL
# ============================================================================
def _build_conversation_detail(
db: Session, conversation: Any, current_user_id: int, vendor_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.VENDOR
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,
)
@vendor_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_user: UserContext = Depends(get_current_vendor_api),
) -> ConversationDetailResponse:
"""Get conversation detail with messages."""
vendor_id = current_user.token_vendor_id
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
# Verify vendor context
if conversation.vendor_id and conversation.vendor_id != vendor_id:
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.VENDOR,
reader_id=current_user.id,
)
db.commit()
return _build_conversation_detail(db, conversation, current_user.id, vendor_id)
# ============================================================================
# SEND MESSAGE
# ============================================================================
@vendor_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_user: UserContext = Depends(get_current_vendor_api),
) -> MessageResponse:
"""Send a message in a conversation, optionally with attachments."""
vendor_id = current_user.token_vendor_id
# Verify access
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
# Verify vendor context
if conversation.vendor_id and conversation.vendor_id != vendor_id:
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.VENDOR,
sender_id=current_user.id,
content=content,
attachments=attachments if attachments else None,
)
db.commit()
db.refresh(message)
logger.info(
f"Vendor {current_user.username} sent message {message.id} "
f"in conversation {conversation_id}"
)
return _enrich_message(db, message)
# ============================================================================
# CONVERSATION ACTIONS
# ============================================================================
@vendor_messages_router.post("/{conversation_id}/close", response_model=CloseConversationResponse)
def close_conversation(
conversation_id: int,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_vendor_api),
) -> CloseConversationResponse:
"""Close a conversation."""
vendor_id = current_user.token_vendor_id
# Verify access first
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
if conversation.vendor_id and conversation.vendor_id != vendor_id:
raise ConversationNotFoundException(str(conversation_id))
conversation = messaging_service.close_conversation(
db=db,
conversation_id=conversation_id,
closer_type=ParticipantType.VENDOR,
closer_id=current_user.id,
)
db.commit()
logger.info(
f"Vendor {current_user.username} closed conversation {conversation_id}"
)
return CloseConversationResponse(
success=True,
message="Conversation closed",
conversation_id=conversation_id,
)
@vendor_messages_router.post("/{conversation_id}/reopen", response_model=ReopenConversationResponse)
def reopen_conversation(
conversation_id: int,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_vendor_api),
) -> ReopenConversationResponse:
"""Reopen a closed conversation."""
vendor_id = current_user.token_vendor_id
# Verify access first
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
if conversation.vendor_id and conversation.vendor_id != vendor_id:
raise ConversationNotFoundException(str(conversation_id))
conversation = messaging_service.reopen_conversation(
db=db,
conversation_id=conversation_id,
opener_type=ParticipantType.VENDOR,
opener_id=current_user.id,
)
db.commit()
logger.info(
f"Vendor {current_user.username} reopened conversation {conversation_id}"
)
return ReopenConversationResponse(
success=True,
message="Conversation reopened",
conversation_id=conversation_id,
)
@vendor_messages_router.put("/{conversation_id}/read", response_model=MarkReadResponse)
def mark_read(
conversation_id: int,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_vendor_api),
) -> MarkReadResponse:
"""Mark conversation as read."""
success = messaging_service.mark_conversation_read(
db=db,
conversation_id=conversation_id,
reader_type=ParticipantType.VENDOR,
reader_id=current_user.id,
)
db.commit()
return MarkReadResponse(
success=success,
conversation_id=conversation_id,
unread_count=0,
)
class PreferencesUpdateResponse(BaseModel):
"""Response for preferences update."""
success: bool
@vendor_messages_router.put("/{conversation_id}/preferences", response_model=PreferencesUpdateResponse)
def update_preferences(
conversation_id: int,
preferences: NotificationPreferencesUpdate,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_vendor_api),
) -> PreferencesUpdateResponse:
"""Update notification preferences for a conversation."""
success = messaging_service.update_notification_preferences(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.VENDOR,
participant_id=current_user.id,
email_notifications=preferences.email_notifications,
muted=preferences.muted,
)
db.commit()
return PreferencesUpdateResponse(success=success)

View File

@@ -0,0 +1,219 @@
# app/modules/messaging/routes/api/vendor_notifications.py
"""
Vendor notification management endpoints.
Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pattern).
The get_current_vendor_api dependency guarantees token_vendor_id is present.
"""
import logging
from fastapi import APIRouter, Depends, Query
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 models.schema.auth import UserContext
from app.modules.messaging.schemas import (
MessageResponse,
NotificationListResponse,
NotificationSettingsResponse,
NotificationSettingsUpdate,
NotificationTemplateListResponse,
NotificationTemplateUpdate,
TestNotificationRequest,
UnreadCountResponse,
)
vendor_notifications_router = APIRouter(prefix="/notifications")
logger = logging.getLogger(__name__)
@vendor_notifications_router.get("", response_model=NotificationListResponse)
def get_notifications(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
unread_only: bool | None = Query(False),
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get vendor notifications.
TODO: Implement in Slice 5
- Get all notifications for vendor
- Filter by read/unread status
- Support pagination
- Return notification details
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return NotificationListResponse(
notifications=[],
total=0,
unread_count=0,
message="Notifications coming in Slice 5",
)
@vendor_notifications_router.get("/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get count of unread notifications.
TODO: Implement in Slice 5
- Count unread notifications for vendor
- Used for notification badge
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return UnreadCountResponse(unread_count=0, message="Unread count coming in Slice 5")
@vendor_notifications_router.put("/{notification_id}/read", response_model=MessageResponse)
def mark_as_read(
notification_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Mark notification as read.
TODO: Implement in Slice 5
- Mark single notification as read
- Update read timestamp
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MessageResponse(message="Mark as read coming in Slice 5")
@vendor_notifications_router.put("/mark-all-read", response_model=MessageResponse)
def mark_all_as_read(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Mark all notifications as read.
TODO: Implement in Slice 5
- Mark all vendor notifications as read
- Update timestamps
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MessageResponse(message="Mark all as read coming in Slice 5")
@vendor_notifications_router.delete("/{notification_id}", response_model=MessageResponse)
def delete_notification(
notification_id: int,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Delete notification.
TODO: Implement in Slice 5
- Delete single notification
- Verify notification belongs to vendor
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MessageResponse(message="Notification deletion coming in Slice 5")
@vendor_notifications_router.get("/settings", response_model=NotificationSettingsResponse)
def get_notification_settings(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get notification preferences.
TODO: Implement in Slice 5
- Get email notification settings
- Get in-app notification settings
- Get notification types enabled/disabled
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return NotificationSettingsResponse(
email_notifications=True,
in_app_notifications=True,
notification_types={},
message="Notification settings coming in Slice 5",
)
@vendor_notifications_router.put("/settings", response_model=MessageResponse)
def update_notification_settings(
settings: NotificationSettingsUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update notification preferences.
TODO: Implement in Slice 5
- Update email notification settings
- Update in-app notification settings
- Enable/disable specific notification types
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MessageResponse(message="Notification settings update coming in Slice 5")
@vendor_notifications_router.get("/templates", response_model=NotificationTemplateListResponse)
def get_notification_templates(
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Get notification email templates.
TODO: Implement in Slice 5
- Get all notification templates
- Include: order confirmation, shipping notification, etc.
- Return template details
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return NotificationTemplateListResponse(
templates=[], message="Notification templates coming in Slice 5"
)
@vendor_notifications_router.put("/templates/{template_id}", response_model=MessageResponse)
def update_notification_template(
template_id: int,
template_data: NotificationTemplateUpdate,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Update notification email template.
TODO: Implement in Slice 5
- Update template subject
- Update template body (HTML/text)
- Validate template variables
- Preview template
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MessageResponse(message="Template update coming in Slice 5")
@vendor_notifications_router.post("/test", response_model=MessageResponse)
def send_test_notification(
notification_data: TestNotificationRequest,
current_user: UserContext = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Send test notification.
TODO: Implement in Slice 5
- Send test email notification
- Use specified template
- Send to current user's email
"""
vendor = vendor_service.get_vendor_by_id(db, current_user.token_vendor_id) # noqa: F841
return MessageResponse(message="Test notification coming in Slice 5")

View File

@@ -1,39 +0,0 @@
# app/modules/messaging/routes/vendor.py
"""
Messaging module vendor routes.
This module wraps the existing vendor messages and notifications routes
and adds module-based access control. Routes are re-exported from the
original location with the module access dependency.
Includes:
- /messages/* - Message management
- /notifications/* - Notification management
"""
from fastapi import APIRouter, Depends
from app.api.deps import require_module_access
# Import original routers (direct import to avoid circular dependency)
from app.api.v1.vendor.messages import router as messages_original_router
from app.api.v1.vendor.notifications import router as notifications_original_router
# Create module-aware router for messages
vendor_router = APIRouter(
prefix="/messages",
dependencies=[Depends(require_module_access("messaging"))],
)
# Re-export all routes from the original messages module
for route in messages_original_router.routes:
vendor_router.routes.append(route)
# Create separate router for notifications
vendor_notifications_router = APIRouter(
prefix="/notifications",
dependencies=[Depends(require_module_access("messaging"))],
)
for route in notifications_original_router.routes:
vendor_notifications_router.routes.append(route)