fix: resolve email settings architecture violations and add tests/docs

- Fix API-002 in admin/settings.py: use service layer for DB delete
- Fix API-001/API-003 in vendor/email_settings.py: add Pydantic response
  models, remove HTTPException raises
- Fix SVC-002/SVC-006 in vendor_email_settings_service.py: use domain
  exceptions, change db.commit() to db.flush()
- Add unit tests for VendorEmailSettingsService
- Add integration tests for vendor and admin email settings APIs
- Add user guide (docs/guides/email-settings.md)
- Add developer guide (docs/implementation/email-settings.md)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-05 22:38:10 +01:00
parent 36603178c3
commit 84a523cd7b
9 changed files with 1765 additions and 85 deletions

View File

@@ -610,7 +610,8 @@ def reset_email_settings(
for key in EMAIL_SETTING_KEYS:
setting = admin_settings_service.get_setting_by_key(db, key)
if setting:
db.delete(setting)
# Use service method for deletion (API-002 compliance)
admin_settings_service.delete_setting(db, key, current_admin.id)
deleted_count += 1
# Log action

View File

@@ -14,13 +14,12 @@ Vendor Context: Uses token_vendor_id from JWT token (authenticated vendor API pa
import logging
from fastapi import APIRouter, Depends, HTTPException
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.exceptions import NotFoundError, ValidationError, AuthorizationError
from app.services.vendor_email_settings_service import VendorEmailSettingsService
from app.services.subscription_service import subscription_service
from models.database.user import User
@@ -76,16 +75,62 @@ class VerifyEmailRequest(BaseModel):
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("")
@router.get("", response_model=EmailSettingsResponse)
def get_email_settings(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
) -> EmailSettingsResponse:
"""
Get current email settings for the vendor.
@@ -96,24 +141,24 @@ def get_email_settings(
settings = service.get_settings(vendor_id)
if not settings:
return {
"configured": False,
"settings": None,
"message": "Email settings not configured. Configure SMTP to send emails to customers.",
}
return EmailSettingsResponse(
configured=False,
settings=None,
message="Email settings not configured. Configure SMTP to send emails to customers.",
)
return {
"configured": settings.is_configured,
"verified": settings.is_verified,
"settings": settings.to_dict(),
}
return EmailSettingsResponse(
configured=settings.is_configured,
verified=settings.is_verified,
settings=settings.to_dict(),
)
@router.get("/status")
@router.get("/status", response_model=EmailStatusResponse)
def get_email_status(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
) -> EmailStatusResponse:
"""
Get email configuration status.
@@ -121,14 +166,15 @@ def get_email_status(
"""
vendor_id = current_user.token_vendor_id
service = VendorEmailSettingsService(db)
return service.get_status(vendor_id)
status = service.get_status(vendor_id)
return EmailStatusResponse(**status)
@router.get("/providers")
@router.get("/providers", response_model=ProvidersResponse)
def get_available_providers(
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
) -> ProvidersResponse:
"""
Get available email providers for current tier.
@@ -140,22 +186,24 @@ def get_available_providers(
# Get vendor's current tier
tier = subscription_service.get_current_tier(db, vendor_id)
return {
"providers": service.get_available_providers(tier),
"current_tier": tier.value if tier else None,
}
return ProvidersResponse(
providers=service.get_available_providers(tier),
current_tier=tier.value if tier else None,
)
@router.put("")
@router.put("", response_model=EmailUpdateResponse)
def update_email_settings(
data: EmailSettingsUpdate,
current_user: User = 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)
@@ -163,63 +211,66 @@ def update_email_settings(
# Get vendor's current tier for validation
tier = subscription_service.get_current_tier(db, vendor_id)
try:
settings = service.create_or_update(
vendor_id=vendor_id,
data=data.model_dump(exclude_unset=True),
current_tier=tier,
)
return {
"success": True,
"message": "Email settings updated successfully",
"settings": settings.to_dict(),
}
except AuthorizationError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
# 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")
@router.post("/verify", response_model=EmailVerifyResponse)
def verify_email_settings(
data: VerifyEmailRequest,
current_user: User = 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)
try:
result = service.verify_settings(vendor_id, data.test_email)
if result["success"]:
return result
else:
raise HTTPException(status_code=400, detail=result["message"])
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
# 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("")
@router.delete("", response_model=EmailDeleteResponse)
def delete_email_settings(
current_user: User = 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)
if service.delete(vendor_id):
return {"success": True, "message": "Email settings deleted"}
else:
raise HTTPException(status_code=404, detail="Email settings not found")
# Service raises ResourceNotFoundException if not found (API-003 compliance)
service.delete(vendor_id)
db.commit()
return EmailDeleteResponse(
success=True,
message="Email settings deleted",
)