feat: add email settings with database overrides for admin and vendor

Platform Email Settings (Admin):
- Add GET/PUT/DELETE /admin/settings/email/* endpoints
- Settings stored in admin_settings table override .env values
- Support all providers: SMTP, SendGrid, Mailgun, Amazon SES
- Edit mode UI with provider-specific configuration forms
- Reset to .env defaults functionality
- Test email to verify configuration

Vendor Email Settings:
- Add VendorEmailSettings model with one-to-one vendor relationship
- Migration: v0a1b2c3d4e5_add_vendor_email_settings.py
- Service: vendor_email_settings_service.py with tier validation
- API endpoints: /vendor/email-settings/* (CRUD, status, verify)
- Email tab in vendor settings page with full configuration
- Warning banner until email is configured (like billing warnings)
- Premium providers (SendGrid, Mailgun, SES) tier-gated to Business+

Email Service Updates:
- get_platform_email_config(db) checks DB first, then .env
- Configurable provider classes accept config dict
- EmailService uses database-aware providers
- Vendor emails use vendor's own SMTP (Wizamart doesn't pay)
- "Powered by Wizamart" footer for Essential/Professional tiers
- White-label (no footer) for Business/Enterprise tiers

Other:
- Add scripts/install.py for first-time platform setup
- Add make install target
- Update init-prod to include email template seeding

🤖 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:23:47 +01:00
parent ad28a8a9a3
commit 36603178c3
51 changed files with 4959 additions and 1141 deletions

View File

@@ -2,6 +2,9 @@
"""
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
@@ -15,11 +18,10 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_vendor_api, get_db
from app.exceptions.content_page import VendorNotAssociatedException
from app.services.content_page_service import content_page_service
from models.database.user import User
router = APIRouter()
router = APIRouter(prefix="/content-pages")
logger = logging.getLogger(__name__)
@@ -111,11 +113,8 @@ def list_vendor_pages(
Returns vendor-specific overrides + platform defaults (vendor overrides take precedence).
"""
if not current_user.vendor_id:
raise VendorNotAssociatedException()
pages = content_page_service.list_pages_for_vendor(
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
db, vendor_id=current_user.token_vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@@ -132,11 +131,8 @@ def list_vendor_overrides(
Shows what the vendor has customized.
"""
if not current_user.vendor_id:
raise VendorNotAssociatedException()
pages = content_page_service.list_all_vendor_pages(
db, vendor_id=current_user.vendor_id, include_unpublished=include_unpublished
db, vendor_id=current_user.token_vendor_id, include_unpublished=include_unpublished
)
return [page.to_dict() for page in pages]
@@ -154,13 +150,10 @@ def get_page(
Returns vendor override if exists, otherwise platform default.
"""
if not current_user.vendor_id:
raise VendorNotAssociatedException()
page = content_page_service.get_page_for_vendor_or_raise(
db,
slug=slug,
vendor_id=current_user.vendor_id,
vendor_id=current_user.token_vendor_id,
include_unpublished=include_unpublished,
)
@@ -178,15 +171,12 @@ def create_vendor_page(
This will be shown instead of the platform default for this vendor.
"""
if not current_user.vendor_id:
raise VendorNotAssociatedException()
page = content_page_service.create_page(
db,
slug=page_data.slug,
title=page_data.title,
content=page_data.content,
vendor_id=current_user.vendor_id,
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,
@@ -214,14 +204,11 @@ def update_vendor_page(
Can only update pages owned by this vendor.
"""
if not current_user.vendor_id:
raise VendorNotAssociatedException()
# Update with ownership check in service layer
page = content_page_service.update_vendor_page(
db,
page_id=page_id,
vendor_id=current_user.vendor_id,
vendor_id=current_user.token_vendor_id,
title=page_data.title,
content=page_data.content,
content_format=page_data.content_format,
@@ -251,9 +238,6 @@ def delete_vendor_page(
Can only delete pages owned by this vendor.
After deletion, platform default will be shown (if exists).
"""
if not current_user.vendor_id:
raise VendorNotAssociatedException()
# Delete with ownership check in service layer
content_page_service.delete_vendor_page(db, page_id, current_user.vendor_id)
content_page_service.delete_vendor_page(db, page_id, current_user.token_vendor_id)
db.commit()