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:
29
app/api/v1/vendor/__init__.py
vendored
29
app/api/v1/vendor/__init__.py
vendored
@@ -21,44 +21,23 @@ Self-contained modules (auto-discovered from app/modules/{module}/routes/api/ven
|
||||
- orders: Order management, fulfillment, exceptions, invoices
|
||||
- marketplace: Letzshop integration, product sync, onboarding
|
||||
- catalog: Vendor product catalog management
|
||||
- cms: Content pages management
|
||||
- cms: Content pages management, media library
|
||||
- customers: Customer management
|
||||
- payments: Payment configuration, Stripe connect, transactions
|
||||
- tenancy: Vendor info, auth, profile, team management
|
||||
- messaging: Messages, notifications, email settings, email templates
|
||||
- core: Dashboard, settings
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
# Import all sub-routers (legacy routes that haven't been migrated to modules)
|
||||
from . import (
|
||||
email_settings,
|
||||
email_templates,
|
||||
media,
|
||||
messages,
|
||||
notifications,
|
||||
)
|
||||
|
||||
# Create vendor router
|
||||
router = APIRouter()
|
||||
|
||||
# ============================================================================
|
||||
# JSON API ROUTES ONLY
|
||||
# ============================================================================
|
||||
# These routes return JSON and are mounted at /api/v1/vendor/*
|
||||
|
||||
# Email configuration
|
||||
router.include_router(email_templates.router, tags=["vendor-email-templates"])
|
||||
router.include_router(email_settings.router, tags=["vendor-email-settings"])
|
||||
|
||||
# Services (with prefixes: /media/*, etc.)
|
||||
router.include_router(media.router, tags=["vendor-media"])
|
||||
router.include_router(notifications.router, tags=["vendor-notifications"])
|
||||
router.include_router(messages.router, tags=["vendor-messages"])
|
||||
|
||||
# Services (with prefixes: /media/*, etc.)
|
||||
router.include_router(media.router, tags=["vendor-media"])
|
||||
router.include_router(notifications.router, tags=["vendor-notifications"])
|
||||
router.include_router(messages.router, tags=["vendor-messages"])
|
||||
# All vendor routes are now auto-discovered from self-contained modules.
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
276
app/api/v1/vendor/email_settings.py
vendored
276
app/api/v1/vendor/email_settings.py
vendored
@@ -1,276 +0,0 @@
|
||||
# app/api/v1/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
|
||||
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@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(),
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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(),
|
||||
)
|
||||
|
||||
|
||||
@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"],
|
||||
)
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
291
app/api/v1/vendor/email_templates.py
vendored
291
app/api/v1/vendor/email_templates.py
vendored
@@ -1,291 +0,0 @@
|
||||
# app/api/v1/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
|
||||
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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, {})
|
||||
304
app/api/v1/vendor/media.py
vendored
304
app/api/v1/vendor/media.py
vendored
@@ -1,304 +0,0 @@
|
||||
# app/api/v1/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,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/media")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
633
app/api/v1/vendor/messages.py
vendored
633
app/api/v1/vendor/messages.py
vendored
@@ -1,633 +0,0 @@
|
||||
# app/api/v1/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
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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)
|
||||
219
app/api/v1/vendor/notifications.py
vendored
219
app/api/v1/vendor/notifications.py
vendored
@@ -1,219 +0,0 @@
|
||||
# app/api/v1/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,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/notifications")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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"
|
||||
)
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@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")
|
||||
Reference in New Issue
Block a user