Compare commits
47 Commits
4d6ee21408
...
cleanup/ph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e317e1de26 | ||
|
|
f04eb0a900 | ||
|
|
264c720e3e | ||
|
|
0921adbabb | ||
|
|
82d6a9e879 | ||
|
|
0526553c9b | ||
|
|
7bb9d813f2 | ||
|
|
59f7455521 | ||
|
|
34c8cc410a | ||
|
|
4f99fc1451 | ||
|
|
84ed711f6d | ||
|
|
7c79bdcc6c | ||
|
|
74370685f4 | ||
|
|
e2a1c15183 | ||
|
|
51512d6c91 | ||
|
|
4e9f2d9dbc | ||
|
|
d4ecddba22 | ||
|
|
3651ee9ed4 | ||
|
|
7da3334c03 | ||
|
|
3028db5197 | ||
|
|
7ad1f6bdff | ||
|
|
ad75fa031e | ||
|
|
ad1756c349 | ||
|
|
0386d4bf33 | ||
|
|
87d1662a18 | ||
|
|
909ed1cb17 | ||
|
|
4b6a03a898 | ||
|
|
6c8e5fdd57 | ||
|
|
52603f2deb | ||
|
|
9ca048fb9d | ||
|
|
cb8e747387 | ||
|
|
abc6c011ea | ||
|
|
de0e42cca8 | ||
|
|
ff44827b35 | ||
|
|
e93ea77c2b | ||
|
|
1f2e734ea2 | ||
|
|
6947819742 | ||
|
|
dc7a459ebb | ||
|
|
6e30d2d4e8 | ||
|
|
b2922ebec5 | ||
|
|
c4de8994dd | ||
|
|
f518e1751b | ||
|
|
a70f8cdd01 | ||
|
|
a1016ec1c2 | ||
|
|
52600c9dca | ||
|
|
f10916bfab | ||
|
|
f1ba0aa531 |
1019
CHANGELOG.md
1019
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,11 @@ class Igny8AdminConfig(AdminConfig):
|
||||
admin_site._actions = old_site._actions.copy()
|
||||
admin_site._global_actions = old_site._global_actions.copy()
|
||||
|
||||
# CRITICAL: Update each ModelAdmin's admin_site attribute to point to our custom site
|
||||
# Otherwise, each_context() will use the wrong admin site and miss our customizations
|
||||
for model, model_admin in admin_site._registry.items():
|
||||
model_admin.admin_site = admin_site
|
||||
|
||||
# Now replace the default site
|
||||
admin_module.site = admin_site
|
||||
admin_module.sites.site = admin_site
|
||||
|
||||
@@ -145,7 +145,16 @@ class Igny8ModelAdmin(UnfoldModelAdmin):
|
||||
for group in sidebar_navigation:
|
||||
group_is_active = False
|
||||
for item in group.get('items', []):
|
||||
item_link = item.get('link', '')
|
||||
# Unfold stores resolved link in 'link_callback', original lambda in 'link'
|
||||
item_link = item.get('link_callback') or item.get('link', '')
|
||||
# Convert to string (handles lazy proxy objects and ensures it's a string)
|
||||
try:
|
||||
item_link = str(item_link) if item_link else ''
|
||||
except:
|
||||
item_link = ''
|
||||
# Skip if it's a function representation (e.g., "<function ...>")
|
||||
if item_link.startswith('<'):
|
||||
continue
|
||||
# Check if current path matches this item's link
|
||||
if item_link and current_path.startswith(item_link):
|
||||
item['active'] = True
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
"""
|
||||
Custom AdminSite for IGNY8 to organize models into proper groups using Unfold
|
||||
NO EMOJIS - Unfold handles all icons via Material Design
|
||||
Custom AdminSite for IGNY8 using Unfold theme.
|
||||
|
||||
SIMPLIFIED VERSION - Navigation is now handled via UNFOLD settings in settings.py
|
||||
This file only handles:
|
||||
1. Custom URLs for dashboard, reports, and monitoring pages
|
||||
2. Index redirect to dashboard
|
||||
|
||||
All sidebar navigation is configured in settings.py under UNFOLD["SIDEBAR"]["navigation"]
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.apps import AdminConfig
|
||||
from django.apps import apps
|
||||
from django.urls import path, reverse_lazy
|
||||
from django.urls import path
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib.admin import sites
|
||||
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
||||
from unfold.sites import UnfoldAdminSite
|
||||
|
||||
|
||||
class Igny8AdminSite(UnfoldAdminSite):
|
||||
"""
|
||||
Custom AdminSite based on Unfold that organizes models into the planned groups
|
||||
Custom AdminSite based on Unfold.
|
||||
Navigation is handled via UNFOLD settings - this just adds custom URLs.
|
||||
"""
|
||||
site_header = 'IGNY8 Administration'
|
||||
site_title = 'IGNY8 Admin'
|
||||
index_title = 'IGNY8 Administration'
|
||||
|
||||
|
||||
def get_urls(self):
|
||||
"""Get admin URLs with dashboard, reports, and monitoring pages available"""
|
||||
from django.urls import path
|
||||
"""Add custom URLs for dashboard, reports, and monitoring pages"""
|
||||
from .dashboard import admin_dashboard
|
||||
from .reports import (
|
||||
revenue_report, usage_report, content_report, data_quality_report,
|
||||
@@ -31,12 +33,12 @@ class Igny8AdminSite(UnfoldAdminSite):
|
||||
from .monitoring import (
|
||||
system_health_dashboard, api_monitor_dashboard, debug_console
|
||||
)
|
||||
|
||||
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
# Dashboard
|
||||
path('dashboard/', self.admin_view(admin_dashboard), name='dashboard'),
|
||||
|
||||
|
||||
# Reports
|
||||
path('reports/revenue/', self.admin_view(revenue_report), name='report_revenue'),
|
||||
path('reports/usage/', self.admin_view(usage_report), name='report_usage'),
|
||||
@@ -44,310 +46,17 @@ class Igny8AdminSite(UnfoldAdminSite):
|
||||
path('reports/data-quality/', self.admin_view(data_quality_report), name='report_data_quality'),
|
||||
path('reports/token-usage/', self.admin_view(token_usage_report), name='report_token_usage'),
|
||||
path('reports/ai-cost-analysis/', self.admin_view(ai_cost_analysis), name='report_ai_cost_analysis'),
|
||||
|
||||
# Monitoring (NEW)
|
||||
|
||||
# Monitoring
|
||||
path('monitoring/system-health/', self.admin_view(system_health_dashboard), name='monitoring_system_health'),
|
||||
path('monitoring/api-monitor/', self.admin_view(api_monitor_dashboard), name='monitoring_api_monitor'),
|
||||
path('monitoring/debug-console/', self.admin_view(debug_console), name='monitoring_debug_console'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def index(self, request, extra_context=None):
|
||||
"""Redirect to custom dashboard"""
|
||||
from django.shortcuts import redirect
|
||||
return redirect('admin:dashboard')
|
||||
|
||||
def get_sidebar_list(self, request):
|
||||
"""
|
||||
Override Unfold's get_sidebar_list to return our custom app groups
|
||||
Convert Django app_list format to Unfold sidebar navigation format
|
||||
"""
|
||||
# Get our custom Django app list
|
||||
django_apps = self.get_app_list(request, app_label=None)
|
||||
|
||||
# Convert to Unfold navigation format: {title, items: [{title, link, icon}]}
|
||||
sidebar_groups = []
|
||||
|
||||
for app in django_apps:
|
||||
group = {
|
||||
'title': app['name'],
|
||||
'collapsible': True,
|
||||
'items': []
|
||||
}
|
||||
|
||||
# Convert each model to navigation item
|
||||
for model in app.get('models', []):
|
||||
if model.get('perms', {}).get('view', False) or model.get('perms', {}).get('change', False):
|
||||
item = {
|
||||
'title': model['name'],
|
||||
'link': model['admin_url'],
|
||||
'icon': None, # Unfold will use default
|
||||
'has_permission': True, # CRITICAL: Template checks this
|
||||
}
|
||||
group['items'].append(item)
|
||||
|
||||
# Only add groups that have items
|
||||
if group['items']:
|
||||
sidebar_groups.append(group)
|
||||
|
||||
return sidebar_groups
|
||||
|
||||
def each_context(self, request):
|
||||
"""
|
||||
Override context to ensure our custom app_list is always used
|
||||
This is called by all admin templates for sidebar rendering
|
||||
|
||||
CRITICAL FIX: Force custom sidebar on ALL pages including model detail/list views
|
||||
"""
|
||||
# CRITICAL: Must call parent to get sidebar_navigation set
|
||||
context = super().each_context(request)
|
||||
|
||||
# DEBUGGING: Print to console what parent returned
|
||||
print(f"\n=== DEBUG each_context for {request.path} ===")
|
||||
print(f"sidebar_navigation length from parent: {len(context.get('sidebar_navigation', []))}")
|
||||
if context.get('sidebar_navigation'):
|
||||
print(f"First sidebar group: {context['sidebar_navigation'][0].get('title', 'NO TITLE')}")
|
||||
|
||||
# Force our custom app list to be used everywhere - IGNORE app_label parameter
|
||||
custom_apps = self.get_app_list(request, app_label=None)
|
||||
context['available_apps'] = custom_apps
|
||||
context['app_list'] = custom_apps # Also set app_list for compatibility
|
||||
|
||||
# CRITICAL FIX: Ensure sidebar_navigation is using our custom sidebar
|
||||
# Parent's each_context already called get_sidebar_list(), which returns our custom sidebar
|
||||
# So sidebar_navigation should already be correct, but let's verify
|
||||
if not context.get('sidebar_navigation') or len(context.get('sidebar_navigation', [])) == 0:
|
||||
# If sidebar_navigation is empty, force it
|
||||
print("WARNING: sidebar_navigation was empty, forcing it!")
|
||||
context['sidebar_navigation'] = self.get_sidebar_list(request)
|
||||
|
||||
print(f"Final sidebar_navigation length: {len(context['sidebar_navigation'])}")
|
||||
print("=== END DEBUG ===\n")
|
||||
|
||||
return context
|
||||
|
||||
def get_app_list(self, request, app_label=None):
|
||||
"""
|
||||
Customize the app list to organize models into logical groups
|
||||
NO EMOJIS - Unfold handles all icons via Material Design
|
||||
|
||||
Args:
|
||||
request: The HTTP request
|
||||
app_label: IGNORED - Always return full custom sidebar for consistency
|
||||
"""
|
||||
# CRITICAL: Always build full app_dict (ignore app_label) for consistent sidebar
|
||||
app_dict = self._build_app_dict(request, None)
|
||||
|
||||
# Define our custom groups with their models (using object_name)
|
||||
# Organized by business function - Material icons configured in Unfold
|
||||
custom_groups = {
|
||||
'Accounts & Tenancy': {
|
||||
'models': [
|
||||
('igny8_core_auth', 'Account'),
|
||||
('igny8_core_auth', 'User'),
|
||||
('igny8_core_auth', 'Site'),
|
||||
('igny8_core_auth', 'Sector'),
|
||||
('igny8_core_auth', 'SiteUserAccess'),
|
||||
],
|
||||
},
|
||||
'Global Resources': {
|
||||
'models': [
|
||||
('igny8_core_auth', 'Industry'),
|
||||
('igny8_core_auth', 'IndustrySector'),
|
||||
('igny8_core_auth', 'SeedKeyword'),
|
||||
],
|
||||
},
|
||||
'Global Settings': {
|
||||
'models': [
|
||||
('system', 'GlobalIntegrationSettings'),
|
||||
('system', 'GlobalModuleSettings'),
|
||||
('system', 'GlobalAIPrompt'),
|
||||
('system', 'GlobalAuthorProfile'),
|
||||
('system', 'GlobalStrategy'),
|
||||
],
|
||||
},
|
||||
'Plans and Billing': {
|
||||
'models': [
|
||||
('igny8_core_auth', 'Plan'),
|
||||
('igny8_core_auth', 'Subscription'),
|
||||
('billing', 'BillingConfiguration'),
|
||||
('billing', 'Invoice'),
|
||||
('billing', 'Payment'),
|
||||
('billing', 'CreditPackage'),
|
||||
('billing', 'PaymentMethodConfig'),
|
||||
('billing', 'AccountPaymentMethod'),
|
||||
],
|
||||
},
|
||||
'Credits': {
|
||||
'models': [
|
||||
('billing', 'CreditTransaction'),
|
||||
('billing', 'CreditUsageLog'),
|
||||
('billing', 'CreditCostConfig'),
|
||||
('billing', 'PlanLimitUsage'),
|
||||
],
|
||||
},
|
||||
'Content Planning': {
|
||||
'models': [
|
||||
('planner', 'Keywords'),
|
||||
('planner', 'Clusters'),
|
||||
('planner', 'ContentIdeas'),
|
||||
],
|
||||
},
|
||||
'Content Generation': {
|
||||
'models': [
|
||||
('writer', 'Tasks'),
|
||||
('writer', 'Content'),
|
||||
('writer', 'Images'),
|
||||
('writer', 'ImagePrompts'),
|
||||
],
|
||||
},
|
||||
'Taxonomy & Organization': {
|
||||
'models': [
|
||||
('writer', 'ContentTaxonomy'),
|
||||
('writer', 'ContentTaxonomyRelation'),
|
||||
('writer', 'ContentClusterMap'),
|
||||
('writer', 'ContentAttribute'),
|
||||
],
|
||||
},
|
||||
'Publishing & Integration': {
|
||||
'models': [
|
||||
('integration', 'SiteIntegration'),
|
||||
('integration', 'SyncEvent'),
|
||||
('publishing', 'PublishingRecord'),
|
||||
('system', 'PublishingChannel'),
|
||||
('publishing', 'DeploymentRecord'),
|
||||
],
|
||||
},
|
||||
'AI & Automation': {
|
||||
'models': [
|
||||
('system', 'IntegrationSettings'),
|
||||
('system', 'AIPrompt'),
|
||||
('system', 'Strategy'),
|
||||
('system', 'AuthorProfile'),
|
||||
('system', 'APIKey'),
|
||||
('system', 'WebhookConfig'),
|
||||
('automation', 'AutomationConfig'),
|
||||
('automation', 'AutomationRun'),
|
||||
],
|
||||
},
|
||||
'System Settings': {
|
||||
'models': [
|
||||
('contenttypes', 'ContentType'),
|
||||
('system', 'ContentTemplate'),
|
||||
('system', 'TaxonomyConfig'),
|
||||
('system', 'SystemSetting'),
|
||||
('system', 'ContentTypeConfig'),
|
||||
('system', 'NotificationConfig'),
|
||||
],
|
||||
},
|
||||
'Django Admin': {
|
||||
'models': [
|
||||
('auth', 'Group'),
|
||||
('auth', 'Permission'),
|
||||
('igny8_core_auth', 'PasswordResetToken'),
|
||||
('sessions', 'Session'),
|
||||
],
|
||||
},
|
||||
'Tasks & Logging': {
|
||||
'models': [
|
||||
('ai', 'AITaskLog'),
|
||||
('system', 'AuditLog'),
|
||||
('admin', 'LogEntry'),
|
||||
('django_celery_results', 'TaskResult'),
|
||||
('django_celery_results', 'GroupResult'),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# ALWAYS build and return our custom organized app list
|
||||
# regardless of app_label parameter (for consistent sidebar on all pages)
|
||||
organized_apps = []
|
||||
|
||||
# Add Dashboard link as first item
|
||||
organized_apps.append({
|
||||
'name': '📊 Dashboard',
|
||||
'app_label': '_dashboard',
|
||||
'app_url': '/admin/dashboard/',
|
||||
'has_module_perms': True,
|
||||
'models': [],
|
||||
})
|
||||
|
||||
# Add Reports section with links to all reports
|
||||
organized_apps.append({
|
||||
'name': 'Reports & Analytics',
|
||||
'app_label': '_reports',
|
||||
'app_url': '#',
|
||||
'has_module_perms': True,
|
||||
'models': [
|
||||
{
|
||||
'name': 'Revenue Report',
|
||||
'object_name': 'RevenueReport',
|
||||
'admin_url': '/admin/reports/revenue/',
|
||||
'view_only': True,
|
||||
'perms': {'view': True},
|
||||
},
|
||||
{
|
||||
'name': 'Usage Report',
|
||||
'object_name': 'UsageReport',
|
||||
'admin_url': '/admin/reports/usage/',
|
||||
'view_only': True,
|
||||
'perms': {'view': True},
|
||||
},
|
||||
{
|
||||
'name': 'Content Report',
|
||||
'object_name': 'ContentReport',
|
||||
'admin_url': '/admin/reports/content/',
|
||||
'view_only': True,
|
||||
'perms': {'view': True},
|
||||
},
|
||||
{
|
||||
'name': 'Data Quality Report',
|
||||
'object_name': 'DataQualityReport',
|
||||
'admin_url': '/admin/reports/data-quality/',
|
||||
'view_only': True,
|
||||
'perms': {'view': True},
|
||||
},
|
||||
{
|
||||
'name': 'Token Usage Report',
|
||||
'object_name': 'TokenUsageReport',
|
||||
'admin_url': '/admin/reports/token-usage/',
|
||||
'view_only': True,
|
||||
'perms': {'view': True},
|
||||
},
|
||||
{
|
||||
'name': 'AI Cost Analysis',
|
||||
'object_name': 'AICostAnalysis',
|
||||
'admin_url': '/admin/reports/ai-cost-analysis/',
|
||||
'view_only': True,
|
||||
'perms': {'view': True},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
for group_name, group_config in custom_groups.items():
|
||||
group_models = []
|
||||
|
||||
for app_label, model_name in group_config['models']:
|
||||
# Find the model in app_dict
|
||||
for app in app_dict.values():
|
||||
if app['app_label'] == app_label:
|
||||
for model in app.get('models', []):
|
||||
if model['object_name'] == model_name:
|
||||
group_models.append(model)
|
||||
break
|
||||
|
||||
if group_models:
|
||||
# Get the first model's app_label to use as the real app_label
|
||||
first_model_app_label = group_config['models'][0][0]
|
||||
organized_apps.append({
|
||||
'name': group_name,
|
||||
'app_label': first_model_app_label, # Use real app_label, not fake one
|
||||
'app_url': f'/admin/{first_model_app_label}/', # Real URL, not '#'
|
||||
'has_module_perms': True,
|
||||
'models': group_models,
|
||||
})
|
||||
|
||||
return organized_apps
|
||||
def index(self, request, extra_context=None):
|
||||
"""Redirect admin index to custom dashboard"""
|
||||
return redirect('admin:dashboard')
|
||||
|
||||
|
||||
# Instantiate custom admin site
|
||||
|
||||
@@ -13,8 +13,6 @@ from django.conf import settings
|
||||
from .constants import (
|
||||
DEFAULT_AI_MODEL,
|
||||
JSON_MODE_MODELS,
|
||||
MODEL_RATES,
|
||||
IMAGE_MODEL_RATES,
|
||||
VALID_OPENAI_IMAGE_MODELS,
|
||||
VALID_SIZES_BY_MODEL,
|
||||
DEBUG_MODE,
|
||||
@@ -45,21 +43,18 @@ class AICore:
|
||||
self._load_account_settings()
|
||||
|
||||
def _load_account_settings(self):
|
||||
"""Load API keys from GlobalIntegrationSettings (platform-wide, used by ALL accounts)"""
|
||||
"""Load API keys from IntegrationProvider (centralized provider config)"""
|
||||
try:
|
||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
# Get global settings - single instance used by ALL accounts
|
||||
global_settings = GlobalIntegrationSettings.get_instance()
|
||||
|
||||
# Load API keys from global settings (platform-wide)
|
||||
self._openai_api_key = global_settings.openai_api_key
|
||||
self._runware_api_key = global_settings.runware_api_key
|
||||
self._bria_api_key = getattr(global_settings, 'bria_api_key', None)
|
||||
self._anthropic_api_key = getattr(global_settings, 'anthropic_api_key', None)
|
||||
# Load API keys from IntegrationProvider (centralized, platform-wide)
|
||||
self._openai_api_key = ModelRegistry.get_api_key('openai')
|
||||
self._runware_api_key = ModelRegistry.get_api_key('runware')
|
||||
self._bria_api_key = ModelRegistry.get_api_key('bria')
|
||||
self._anthropic_api_key = ModelRegistry.get_api_key('anthropic')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Could not load GlobalIntegrationSettings: {e}", exc_info=True)
|
||||
logger.error(f"Could not load API keys from IntegrationProvider: {e}", exc_info=True)
|
||||
self._openai_api_key = None
|
||||
self._runware_api_key = None
|
||||
self._bria_api_key = None
|
||||
@@ -169,24 +164,24 @@ class AICore:
|
||||
logger.info(f" - Model used in request: {active_model}")
|
||||
tracker.ai_call(f"Using model: {active_model}")
|
||||
|
||||
# Use ModelRegistry for validation with fallback to constants
|
||||
# Use ModelRegistry for validation (database-driven)
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
if not ModelRegistry.validate_model(active_model):
|
||||
# Fallback check against constants for backward compatibility
|
||||
if active_model not in MODEL_RATES:
|
||||
error_msg = f"Model '{active_model}' is not supported. Supported models: {list(MODEL_RATES.keys())}"
|
||||
logger.error(f"[AICore] {error_msg}")
|
||||
tracker.error('ConfigurationError', error_msg)
|
||||
return {
|
||||
'content': None,
|
||||
'error': error_msg,
|
||||
'input_tokens': 0,
|
||||
'output_tokens': 0,
|
||||
'total_tokens': 0,
|
||||
'model': active_model,
|
||||
'cost': 0.0,
|
||||
'api_id': None,
|
||||
}
|
||||
# Get list of supported models from database
|
||||
supported_models = [m.model_name for m in ModelRegistry.list_models(model_type='text')]
|
||||
error_msg = f"Model '{active_model}' is not supported. Supported models: {supported_models}"
|
||||
logger.error(f"[AICore] {error_msg}")
|
||||
tracker.error('ConfigurationError', error_msg)
|
||||
return {
|
||||
'content': None,
|
||||
'error': error_msg,
|
||||
'input_tokens': 0,
|
||||
'output_tokens': 0,
|
||||
'total_tokens': 0,
|
||||
'model': active_model,
|
||||
'cost': 0.0,
|
||||
'api_id': None,
|
||||
}
|
||||
|
||||
tracker.ai_call(f"Using model: {active_model}")
|
||||
|
||||
@@ -305,17 +300,13 @@ class AICore:
|
||||
tracker.parse(f"Received {total_tokens} tokens (input: {input_tokens}, output: {output_tokens})")
|
||||
tracker.parse(f"Content length: {len(content)} characters")
|
||||
|
||||
# Step 10: Calculate cost using ModelRegistry (with fallback to constants)
|
||||
# Step 10: Calculate cost using ModelRegistry (database-driven)
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
cost = float(ModelRegistry.calculate_cost(
|
||||
active_model,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens
|
||||
))
|
||||
# Fallback to constants if ModelRegistry returns 0
|
||||
if cost == 0:
|
||||
rates = MODEL_RATES.get(active_model, {'input': 2.00, 'output': 8.00})
|
||||
cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1_000_000
|
||||
tracker.parse(f"Cost calculated: ${cost:.6f}")
|
||||
|
||||
tracker.done("Request completed successfully")
|
||||
@@ -722,7 +713,8 @@ class AICore:
|
||||
n: int = 1,
|
||||
api_key: Optional[str] = None,
|
||||
negative_prompt: Optional[str] = None,
|
||||
function_name: str = 'generate_image'
|
||||
function_name: str = 'generate_image',
|
||||
style: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate image using AI with console logging.
|
||||
@@ -743,7 +735,7 @@ class AICore:
|
||||
print(f"[AI][{function_name}] Step 1: Preparing image generation request...")
|
||||
|
||||
if provider == 'openai':
|
||||
return self._generate_image_openai(prompt, model, size, n, api_key, negative_prompt, function_name)
|
||||
return self._generate_image_openai(prompt, model, size, n, api_key, negative_prompt, function_name, style)
|
||||
elif provider == 'runware':
|
||||
return self._generate_image_runware(prompt, model, size, n, api_key, negative_prompt, function_name)
|
||||
elif provider == 'bria':
|
||||
@@ -767,9 +759,15 @@ class AICore:
|
||||
n: int,
|
||||
api_key: Optional[str],
|
||||
negative_prompt: Optional[str],
|
||||
function_name: str
|
||||
function_name: str,
|
||||
style: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate image using OpenAI DALL-E"""
|
||||
"""Generate image using OpenAI DALL-E
|
||||
|
||||
Args:
|
||||
style: For DALL-E 3 only. 'vivid' (hyper-real/dramatic) or 'natural' (more realistic).
|
||||
Default is 'natural' for realistic photos.
|
||||
"""
|
||||
print(f"[AI][{function_name}] Provider: OpenAI")
|
||||
|
||||
# Determine character limit based on model
|
||||
@@ -854,6 +852,15 @@ class AICore:
|
||||
'size': size
|
||||
}
|
||||
|
||||
# For DALL-E 3, add style parameter
|
||||
# 'natural' = more realistic photos, 'vivid' = hyper-real/dramatic
|
||||
if model == 'dall-e-3':
|
||||
# Default to 'natural' for realistic images, but respect user preference
|
||||
dalle_style = style if style in ['vivid', 'natural'] else 'natural'
|
||||
data['style'] = dalle_style
|
||||
data['quality'] = 'hd' # Always use HD quality for best results
|
||||
print(f"[AI][{function_name}] DALL-E 3 style: {dalle_style}, quality: hd")
|
||||
|
||||
if negative_prompt:
|
||||
# Note: OpenAI DALL-E doesn't support negative_prompt in API, but we log it
|
||||
print(f"[AI][{function_name}] Note: Negative prompt provided but OpenAI DALL-E doesn't support it")
|
||||
@@ -886,11 +893,9 @@ class AICore:
|
||||
image_url = image_data.get('url')
|
||||
revised_prompt = image_data.get('revised_prompt')
|
||||
|
||||
# Use ModelRegistry for image cost (with fallback to constants)
|
||||
# Use ModelRegistry for image cost (database-driven)
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
cost = float(ModelRegistry.calculate_cost(model, num_images=n))
|
||||
if cost == 0:
|
||||
cost = IMAGE_MODEL_RATES.get(model, 0.040) * n
|
||||
print(f"[AI][{function_name}] Step 5: Image generated successfully")
|
||||
print(f"[AI][{function_name}] Step 6: Cost: ${cost:.4f}")
|
||||
print(f"[AI][{function_name}][Success] Image generation completed")
|
||||
@@ -982,24 +987,57 @@ class AICore:
|
||||
# Runware uses array payload with authentication task first, then imageInference
|
||||
# Reference: image-generation.php lines 79-97
|
||||
import uuid
|
||||
|
||||
# Build base inference task
|
||||
inference_task = {
|
||||
'taskType': 'imageInference',
|
||||
'taskUUID': str(uuid.uuid4()),
|
||||
'positivePrompt': prompt,
|
||||
'negativePrompt': negative_prompt or '',
|
||||
'model': runware_model,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'numberResults': 1,
|
||||
'outputFormat': 'webp'
|
||||
}
|
||||
|
||||
# Model-specific parameter configuration based on Runware documentation
|
||||
if runware_model.startswith('bria:'):
|
||||
# Bria 3.2 (bria:10@1) - Commercial-ready, steps 20-50 (API requires minimum 20)
|
||||
inference_task['steps'] = 20
|
||||
# Enhanced negative prompt for Bria to prevent disfigured images
|
||||
enhanced_negative = (negative_prompt or '') + ', disfigured, deformed, bad anatomy, wrong anatomy, extra limbs, missing limbs, floating limbs, mutated hands, extra fingers, missing fingers, fused fingers, poorly drawn hands, poorly drawn face, mutation, ugly, blurry, low quality, worst quality, jpeg artifacts, watermark, text, signature'
|
||||
inference_task['negativePrompt'] = enhanced_negative
|
||||
# Bria provider settings for enhanced quality
|
||||
inference_task['providerSettings'] = {
|
||||
'bria': {
|
||||
'promptEnhancement': True,
|
||||
'enhanceImage': True,
|
||||
'medium': 'photography',
|
||||
'contentModeration': True
|
||||
}
|
||||
}
|
||||
print(f"[AI][{function_name}] Using Bria 3.2 config: steps=20, enhanced negative prompt, providerSettings enabled")
|
||||
elif runware_model.startswith('google:'):
|
||||
# Nano Banana (google:4@2) - Premium quality
|
||||
# Google models use 'resolution' parameter INSTEAD of width/height
|
||||
# Remove width/height and use resolution only
|
||||
del inference_task['width']
|
||||
del inference_task['height']
|
||||
inference_task['resolution'] = '1k' # Use 1K tier for optimal speed/quality
|
||||
print(f"[AI][{function_name}] Using Nano Banana config: resolution=1k (no width/height)")
|
||||
else:
|
||||
# Hi Dream Full (runware:97@1) - General diffusion, steps 20, CFGScale 7
|
||||
inference_task['steps'] = 20
|
||||
inference_task['CFGScale'] = 7
|
||||
print(f"[AI][{function_name}] Using Hi Dream Full config: steps=20, CFGScale=7")
|
||||
|
||||
payload = [
|
||||
{
|
||||
'taskType': 'authentication',
|
||||
'apiKey': api_key
|
||||
},
|
||||
{
|
||||
'taskType': 'imageInference',
|
||||
'taskUUID': str(uuid.uuid4()),
|
||||
'positivePrompt': prompt,
|
||||
'negativePrompt': negative_prompt or '',
|
||||
'model': runware_model,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'steps': 30,
|
||||
'CFGScale': 7.5,
|
||||
'numberResults': 1,
|
||||
'outputFormat': 'webp'
|
||||
}
|
||||
inference_task
|
||||
]
|
||||
|
||||
request_start = time.time()
|
||||
@@ -1009,7 +1047,29 @@ class AICore:
|
||||
print(f"[AI][{function_name}] Step 4: Received response in {request_duration:.2f}s (status={response.status_code})")
|
||||
|
||||
if response.status_code != 200:
|
||||
error_msg = f"HTTP {response.status_code} error"
|
||||
# Log the full error response for debugging
|
||||
try:
|
||||
error_body = response.json()
|
||||
print(f"[AI][{function_name}][Error] Runware error response: {error_body}")
|
||||
logger.error(f"[AI][{function_name}] Runware HTTP {response.status_code} error body: {error_body}")
|
||||
|
||||
# Extract specific error message from Runware response
|
||||
error_detail = None
|
||||
if isinstance(error_body, list):
|
||||
for item in error_body:
|
||||
if isinstance(item, dict) and 'errors' in item:
|
||||
errors = item['errors']
|
||||
if isinstance(errors, list) and len(errors) > 0:
|
||||
err = errors[0]
|
||||
error_detail = err.get('message') or err.get('error') or str(err)
|
||||
break
|
||||
elif isinstance(error_body, dict):
|
||||
error_detail = error_body.get('message') or error_body.get('error') or str(error_body)
|
||||
|
||||
error_msg = f"HTTP {response.status_code}: {error_detail}" if error_detail else f"HTTP {response.status_code} error"
|
||||
except Exception as e:
|
||||
error_msg = f"HTTP {response.status_code} error (could not parse response: {e})"
|
||||
|
||||
print(f"[AI][{function_name}][Error] {error_msg}")
|
||||
return {
|
||||
'url': None,
|
||||
@@ -1290,24 +1350,13 @@ class AICore:
|
||||
}
|
||||
|
||||
def calculate_cost(self, model: str, input_tokens: int, output_tokens: int, model_type: str = 'text') -> float:
|
||||
"""Calculate cost for API call using ModelRegistry with fallback to constants"""
|
||||
"""Calculate cost for API call using ModelRegistry (database-driven)"""
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
if model_type == 'text':
|
||||
cost = float(ModelRegistry.calculate_cost(model, input_tokens=input_tokens, output_tokens=output_tokens))
|
||||
if cost == 0:
|
||||
# Fallback to constants
|
||||
rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00})
|
||||
input_cost = (input_tokens / 1_000_000) * rates['input']
|
||||
output_cost = (output_tokens / 1_000_000) * rates['output']
|
||||
return input_cost + output_cost
|
||||
return cost
|
||||
return float(ModelRegistry.calculate_cost(model, input_tokens=input_tokens, output_tokens=output_tokens))
|
||||
elif model_type == 'image':
|
||||
cost = float(ModelRegistry.calculate_cost(model, num_images=1))
|
||||
if cost == 0:
|
||||
rate = IMAGE_MODEL_RATES.get(model, 0.040)
|
||||
return rate * 1
|
||||
return cost
|
||||
return float(ModelRegistry.calculate_cost(model, num_images=1))
|
||||
return 0.0
|
||||
|
||||
# Legacy method names for backward compatibility
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
"""
|
||||
AI Constants - Model pricing, valid models, and configuration constants
|
||||
AI Constants - Configuration constants for AI operations
|
||||
|
||||
NOTE: Model pricing (MODEL_RATES, IMAGE_MODEL_RATES) has been moved to the database
|
||||
via AIModelConfig. Use ModelRegistry to get model pricing:
|
||||
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
cost = ModelRegistry.calculate_cost(model_id, input_tokens=N, output_tokens=N)
|
||||
|
||||
The constants below are DEPRECATED and kept only for reference/backward compatibility.
|
||||
Do NOT use MODEL_RATES or IMAGE_MODEL_RATES in new code.
|
||||
"""
|
||||
# Model pricing (per 1M tokens) - EXACT from reference plugin model-rates-config.php
|
||||
# DEPRECATED - Use AIModelConfig database table instead
|
||||
# Model pricing (per 1M tokens) - kept for reference only
|
||||
MODEL_RATES = {
|
||||
'gpt-4.1': {'input': 2.00, 'output': 8.00},
|
||||
'gpt-4o-mini': {'input': 0.15, 'output': 0.60},
|
||||
@@ -10,7 +20,8 @@ MODEL_RATES = {
|
||||
'gpt-5.2': {'input': 1.75, 'output': 14.00},
|
||||
}
|
||||
|
||||
# Image model pricing (per image) - EXACT from reference plugin
|
||||
# DEPRECATED - Use AIModelConfig database table instead
|
||||
# Image model pricing (per image) - kept for reference only
|
||||
IMAGE_MODEL_RATES = {
|
||||
'dall-e-3': 0.040,
|
||||
'dall-e-2': 0.020,
|
||||
|
||||
@@ -219,32 +219,12 @@ class GenerateImagePromptsFunction(BaseAIFunction):
|
||||
# Helper methods
|
||||
def _get_max_in_article_images(self, account) -> int:
|
||||
"""
|
||||
Get max_in_article_images from settings.
|
||||
Uses account's IntegrationSettings override, or GlobalIntegrationSettings.
|
||||
Get max_in_article_images from AISettings (with account override).
|
||||
"""
|
||||
from igny8_core.modules.system.models import IntegrationSettings
|
||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
||||
from igny8_core.modules.system.ai_settings import AISettings
|
||||
|
||||
# Try account-specific override first
|
||||
try:
|
||||
settings = IntegrationSettings.objects.get(
|
||||
account=account,
|
||||
integration_type='image_generation',
|
||||
is_active=True
|
||||
)
|
||||
max_images = settings.config.get('max_in_article_images')
|
||||
|
||||
if max_images is not None:
|
||||
max_images = int(max_images)
|
||||
logger.info(f"Using max_in_article_images={max_images} from account {account.id} IntegrationSettings override")
|
||||
return max_images
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
logger.debug(f"No IntegrationSettings override for account {account.id}, using GlobalIntegrationSettings")
|
||||
|
||||
# Use GlobalIntegrationSettings default
|
||||
global_settings = GlobalIntegrationSettings.get_instance()
|
||||
max_images = global_settings.max_in_article_images
|
||||
logger.info(f"Using max_in_article_images={max_images} from GlobalIntegrationSettings (account {account.id})")
|
||||
max_images = AISettings.get_effective_max_images(account)
|
||||
logger.info(f"Using max_in_article_images={max_images} for account {account.id}")
|
||||
return max_images
|
||||
|
||||
def _extract_content_elements(self, content: Content, max_images: int) -> Dict:
|
||||
|
||||
@@ -67,42 +67,33 @@ class GenerateImagesFunction(BaseAIFunction):
|
||||
if not tasks:
|
||||
raise ValueError("No tasks found")
|
||||
|
||||
# Get image generation settings
|
||||
# Try account-specific override, otherwise use GlobalIntegrationSettings
|
||||
from igny8_core.modules.system.models import IntegrationSettings
|
||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
||||
# Get image generation settings from AISettings (with account overrides)
|
||||
from igny8_core.modules.system.ai_settings import AISettings
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
image_settings = {}
|
||||
try:
|
||||
integration = IntegrationSettings.objects.get(
|
||||
account=account,
|
||||
integration_type='image_generation',
|
||||
is_active=True
|
||||
)
|
||||
image_settings = integration.config or {}
|
||||
logger.info(f"Using image settings from account {account.id} IntegrationSettings override")
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
logger.info(f"No IntegrationSettings override for account {account.id}, using GlobalIntegrationSettings")
|
||||
# Get effective settings (AISettings + AccountSettings overrides)
|
||||
image_style = AISettings.get_effective_image_style(account)
|
||||
max_images = AISettings.get_effective_max_images(account)
|
||||
|
||||
# Use GlobalIntegrationSettings for missing values
|
||||
global_settings = GlobalIntegrationSettings.get_instance()
|
||||
|
||||
# Extract settings with defaults from global settings
|
||||
provider = image_settings.get('provider') or image_settings.get('service') or global_settings.default_image_service
|
||||
if provider == 'runware':
|
||||
model = image_settings.get('model') or image_settings.get('runwareModel') or global_settings.runware_model
|
||||
# Get default image model and provider from database
|
||||
default_model = ModelRegistry.get_default_model('image')
|
||||
if default_model:
|
||||
model_config = ModelRegistry.get_model(default_model)
|
||||
provider = model_config.provider if model_config else 'openai'
|
||||
model = default_model
|
||||
else:
|
||||
model = image_settings.get('model') or global_settings.dalle_model
|
||||
provider = 'openai'
|
||||
model = 'dall-e-3'
|
||||
|
||||
logger.info(f"Using image settings: provider={provider}, model={model}, style={image_style}, max={max_images}")
|
||||
|
||||
return {
|
||||
'tasks': tasks,
|
||||
'account': account,
|
||||
'provider': provider,
|
||||
'model': model,
|
||||
'image_type': image_settings.get('image_type') or global_settings.image_style,
|
||||
'max_in_article_images': int(image_settings.get('max_in_article_images') or global_settings.max_in_article_images),
|
||||
'desktop_enabled': image_settings.get('desktop_enabled', True),
|
||||
'mobile_enabled': image_settings.get('mobile_enabled', True),
|
||||
'image_type': image_style,
|
||||
'max_in_article_images': max_images,
|
||||
}
|
||||
|
||||
def build_prompt(self, data: Dict, account=None) -> Dict:
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""
|
||||
Model Registry Service
|
||||
Central registry for AI model configurations with caching.
|
||||
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES from constants.py
|
||||
|
||||
This service provides:
|
||||
- Database-driven model configuration (from AIModelConfig)
|
||||
- Fallback to constants.py for backward compatibility
|
||||
- Integration provider API key retrieval (from IntegrationProvider)
|
||||
- Caching for performance
|
||||
- Cost calculation methods
|
||||
|
||||
@@ -20,6 +19,9 @@ Usage:
|
||||
|
||||
# Calculate cost
|
||||
cost = ModelRegistry.calculate_cost('gpt-4o-mini', input_tokens=1000, output_tokens=500)
|
||||
|
||||
# Get API key for a provider
|
||||
api_key = ModelRegistry.get_api_key('openai')
|
||||
"""
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
@@ -33,12 +35,14 @@ MODEL_CACHE_TTL = 300
|
||||
|
||||
# Cache key prefix
|
||||
CACHE_KEY_PREFIX = 'ai_model_'
|
||||
PROVIDER_CACHE_PREFIX = 'provider_'
|
||||
|
||||
|
||||
class ModelRegistry:
|
||||
"""
|
||||
Central registry for AI model configurations with caching.
|
||||
Uses AIModelConfig from database with fallback to constants.py
|
||||
Uses AIModelConfig from database for model configs.
|
||||
Uses IntegrationProvider for API keys.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@@ -46,6 +50,11 @@ class ModelRegistry:
|
||||
"""Generate cache key for model"""
|
||||
return f"{CACHE_KEY_PREFIX}{model_id}"
|
||||
|
||||
@classmethod
|
||||
def _get_provider_cache_key(cls, provider_id: str) -> str:
|
||||
"""Generate cache key for provider"""
|
||||
return f"{PROVIDER_CACHE_PREFIX}{provider_id}"
|
||||
|
||||
@classmethod
|
||||
def _get_from_db(cls, model_id: str) -> Optional[Any]:
|
||||
"""Get model config from database"""
|
||||
@@ -59,46 +68,6 @@ class ModelRegistry:
|
||||
logger.debug(f"Could not fetch model {model_id} from DB: {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _get_from_constants(cls, model_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get model config from constants.py as fallback.
|
||||
Returns a dict mimicking AIModelConfig attributes.
|
||||
"""
|
||||
from igny8_core.ai.constants import MODEL_RATES, IMAGE_MODEL_RATES
|
||||
|
||||
# Check text models first
|
||||
if model_id in MODEL_RATES:
|
||||
rates = MODEL_RATES[model_id]
|
||||
return {
|
||||
'model_name': model_id,
|
||||
'display_name': model_id,
|
||||
'model_type': 'text',
|
||||
'provider': 'openai',
|
||||
'input_cost_per_1m': Decimal(str(rates.get('input', 0))),
|
||||
'output_cost_per_1m': Decimal(str(rates.get('output', 0))),
|
||||
'cost_per_image': None,
|
||||
'is_active': True,
|
||||
'_from_constants': True
|
||||
}
|
||||
|
||||
# Check image models
|
||||
if model_id in IMAGE_MODEL_RATES:
|
||||
cost = IMAGE_MODEL_RATES[model_id]
|
||||
return {
|
||||
'model_name': model_id,
|
||||
'display_name': model_id,
|
||||
'model_type': 'image',
|
||||
'provider': 'openai' if 'dall-e' in model_id else 'runware',
|
||||
'input_cost_per_1m': None,
|
||||
'output_cost_per_1m': None,
|
||||
'cost_per_image': Decimal(str(cost)),
|
||||
'is_active': True,
|
||||
'_from_constants': True
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_model(cls, model_id: str) -> Optional[Any]:
|
||||
"""
|
||||
@@ -107,13 +76,12 @@ class ModelRegistry:
|
||||
Order of lookup:
|
||||
1. Cache
|
||||
2. Database (AIModelConfig)
|
||||
3. constants.py fallback
|
||||
|
||||
Args:
|
||||
model_id: The model identifier (e.g., 'gpt-4o-mini', 'dall-e-3')
|
||||
|
||||
Returns:
|
||||
AIModelConfig instance or dict with model config, None if not found
|
||||
AIModelConfig instance, None if not found
|
||||
"""
|
||||
cache_key = cls._get_cache_key(model_id)
|
||||
|
||||
@@ -129,13 +97,7 @@ class ModelRegistry:
|
||||
cache.set(cache_key, model_config, MODEL_CACHE_TTL)
|
||||
return model_config
|
||||
|
||||
# Fallback to constants
|
||||
fallback = cls._get_from_constants(model_id)
|
||||
if fallback:
|
||||
cache.set(cache_key, fallback, MODEL_CACHE_TTL)
|
||||
return fallback
|
||||
|
||||
logger.warning(f"Model {model_id} not found in DB or constants")
|
||||
logger.warning(f"Model {model_id} not found in database")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@@ -154,16 +116,6 @@ class ModelRegistry:
|
||||
if not model:
|
||||
return Decimal('0')
|
||||
|
||||
# Handle dict (from constants fallback)
|
||||
if isinstance(model, dict):
|
||||
if rate_type == 'input':
|
||||
return model.get('input_cost_per_1m') or Decimal('0')
|
||||
elif rate_type == 'output':
|
||||
return model.get('output_cost_per_1m') or Decimal('0')
|
||||
elif rate_type == 'image':
|
||||
return model.get('cost_per_image') or Decimal('0')
|
||||
return Decimal('0')
|
||||
|
||||
# Handle AIModelConfig instance
|
||||
if rate_type == 'input':
|
||||
return model.input_cost_per_1m or Decimal('0')
|
||||
@@ -195,8 +147,8 @@ class ModelRegistry:
|
||||
if not model:
|
||||
return Decimal('0')
|
||||
|
||||
# Determine model type
|
||||
model_type = model.get('model_type') if isinstance(model, dict) else model.model_type
|
||||
# Get model type from AIModelConfig
|
||||
model_type = model.model_type
|
||||
|
||||
if model_type == 'text':
|
||||
input_rate = cls.get_rate(model_id, 'input')
|
||||
@@ -218,7 +170,7 @@ class ModelRegistry:
|
||||
@classmethod
|
||||
def get_default_model(cls, model_type: str = 'text') -> Optional[str]:
|
||||
"""
|
||||
Get the default model for a given type.
|
||||
Get the default model for a given type from database.
|
||||
|
||||
Args:
|
||||
model_type: 'text' or 'image'
|
||||
@@ -236,32 +188,33 @@ class ModelRegistry:
|
||||
|
||||
if default:
|
||||
return default.model_name
|
||||
|
||||
# If no default is set, return first active model of this type
|
||||
first_active = AIModelConfig.objects.filter(
|
||||
model_type=model_type,
|
||||
is_active=True
|
||||
).order_by('model_name').first()
|
||||
|
||||
if first_active:
|
||||
return first_active.model_name
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get default {model_type} model from DB: {e}")
|
||||
|
||||
# Fallback to constants
|
||||
from igny8_core.ai.constants import DEFAULT_AI_MODEL
|
||||
if model_type == 'text':
|
||||
return DEFAULT_AI_MODEL
|
||||
elif model_type == 'image':
|
||||
return 'dall-e-3'
|
||||
logger.error(f"Could not get default {model_type} model from DB: {e}")
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def list_models(cls, model_type: Optional[str] = None, provider: Optional[str] = None) -> list:
|
||||
"""
|
||||
List all available models, optionally filtered by type or provider.
|
||||
List all available models from database, optionally filtered by type or provider.
|
||||
|
||||
Args:
|
||||
model_type: Filter by 'text', 'image', or 'embedding'
|
||||
provider: Filter by 'openai', 'anthropic', 'runware', etc.
|
||||
|
||||
Returns:
|
||||
List of model configs
|
||||
List of AIModelConfig instances
|
||||
"""
|
||||
models = []
|
||||
|
||||
try:
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
queryset = AIModelConfig.objects.filter(is_active=True)
|
||||
@@ -271,27 +224,10 @@ class ModelRegistry:
|
||||
if provider:
|
||||
queryset = queryset.filter(provider=provider)
|
||||
|
||||
models = list(queryset.order_by('sort_order', 'model_name'))
|
||||
return list(queryset.order_by('model_name'))
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not list models from DB: {e}")
|
||||
|
||||
# Add models from constants if not in DB
|
||||
if not models:
|
||||
from igny8_core.ai.constants import MODEL_RATES, IMAGE_MODEL_RATES
|
||||
|
||||
if model_type in (None, 'text'):
|
||||
for model_id in MODEL_RATES:
|
||||
fallback = cls._get_from_constants(model_id)
|
||||
if fallback:
|
||||
models.append(fallback)
|
||||
|
||||
if model_type in (None, 'image'):
|
||||
for model_id in IMAGE_MODEL_RATES:
|
||||
fallback = cls._get_from_constants(model_id)
|
||||
if fallback:
|
||||
models.append(fallback)
|
||||
|
||||
return models
|
||||
logger.error(f"Could not list models from DB: {e}")
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls, model_id: Optional[str] = None):
|
||||
@@ -311,10 +247,10 @@ class ModelRegistry:
|
||||
if hasattr(default_cache, 'delete_pattern'):
|
||||
default_cache.delete_pattern(f"{CACHE_KEY_PREFIX}*")
|
||||
else:
|
||||
# Fallback: clear known models
|
||||
from igny8_core.ai.constants import MODEL_RATES, IMAGE_MODEL_RATES
|
||||
for model_id in list(MODEL_RATES.keys()) + list(IMAGE_MODEL_RATES.keys()):
|
||||
cache.delete(cls._get_cache_key(model_id))
|
||||
# Fallback: clear all known models from DB
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
for model in AIModelConfig.objects.values_list('model_name', flat=True):
|
||||
cache.delete(cls._get_cache_key(model))
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clear all model caches: {e}")
|
||||
|
||||
@@ -332,8 +268,110 @@ class ModelRegistry:
|
||||
model = cls.get_model(model_id)
|
||||
if not model:
|
||||
return False
|
||||
|
||||
# Check if active
|
||||
if isinstance(model, dict):
|
||||
return model.get('is_active', True)
|
||||
return model.is_active
|
||||
|
||||
# ========== IntegrationProvider methods ==========
|
||||
|
||||
@classmethod
|
||||
def get_provider(cls, provider_id: str) -> Optional[Any]:
|
||||
"""
|
||||
Get IntegrationProvider by provider_id.
|
||||
|
||||
Args:
|
||||
provider_id: The provider identifier (e.g., 'openai', 'stripe', 'resend')
|
||||
|
||||
Returns:
|
||||
IntegrationProvider instance, None if not found
|
||||
"""
|
||||
cache_key = cls._get_provider_cache_key(provider_id)
|
||||
|
||||
# Try cache first
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
from igny8_core.modules.system.models import IntegrationProvider
|
||||
provider = IntegrationProvider.objects.filter(
|
||||
provider_id=provider_id,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if provider:
|
||||
cache.set(cache_key, provider, MODEL_CACHE_TTL)
|
||||
return provider
|
||||
except Exception as e:
|
||||
logger.error(f"Could not fetch provider {provider_id} from DB: {e}")
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_api_key(cls, provider_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get API key for a provider.
|
||||
|
||||
Args:
|
||||
provider_id: The provider identifier (e.g., 'openai', 'anthropic', 'runware')
|
||||
|
||||
Returns:
|
||||
API key string, None if not found or provider is inactive
|
||||
"""
|
||||
provider = cls.get_provider(provider_id)
|
||||
if provider and provider.api_key:
|
||||
return provider.api_key
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_api_secret(cls, provider_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get API secret for a provider (for OAuth, Stripe secret key, etc.).
|
||||
|
||||
Args:
|
||||
provider_id: The provider identifier
|
||||
|
||||
Returns:
|
||||
API secret string, None if not found
|
||||
"""
|
||||
provider = cls.get_provider(provider_id)
|
||||
if provider and provider.api_secret:
|
||||
return provider.api_secret
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_webhook_secret(cls, provider_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get webhook secret for a provider (for Stripe, PayPal webhooks).
|
||||
|
||||
Args:
|
||||
provider_id: The provider identifier
|
||||
|
||||
Returns:
|
||||
Webhook secret string, None if not found
|
||||
"""
|
||||
provider = cls.get_provider(provider_id)
|
||||
if provider and provider.webhook_secret:
|
||||
return provider.webhook_secret
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def clear_provider_cache(cls, provider_id: Optional[str] = None):
|
||||
"""
|
||||
Clear provider cache.
|
||||
|
||||
Args:
|
||||
provider_id: Clear specific provider cache, or all if None
|
||||
"""
|
||||
if provider_id:
|
||||
cache.delete(cls._get_provider_cache_key(provider_id))
|
||||
else:
|
||||
try:
|
||||
from django.core.cache import caches
|
||||
default_cache = caches['default']
|
||||
if hasattr(default_cache, 'delete_pattern'):
|
||||
default_cache.delete_pattern(f"{PROVIDER_CACHE_PREFIX}*")
|
||||
else:
|
||||
from igny8_core.modules.system.models import IntegrationProvider
|
||||
for pid in IntegrationProvider.objects.values_list('provider_id', flat=True):
|
||||
cache.delete(cls._get_provider_cache_key(pid))
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clear provider caches: {e}")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
AI Settings - Centralized model configurations and limits
|
||||
Uses global settings with optional per-account overrides.
|
||||
Uses AISettings (system defaults) with optional per-account overrides via AccountSettings.
|
||||
API keys are stored in IntegrationProvider.
|
||||
"""
|
||||
from typing import Dict, Any
|
||||
import logging
|
||||
@@ -22,10 +23,9 @@ def get_model_config(function_name: str, account) -> Dict[str, Any]:
|
||||
Get model configuration for AI function.
|
||||
|
||||
Architecture:
|
||||
- API keys: ALWAYS from GlobalIntegrationSettings (platform-wide)
|
||||
- Model/params: From IntegrationSettings if account has override, else from global
|
||||
- Free plan: Cannot override, uses global defaults
|
||||
- Starter/Growth/Scale: Can override model, temperature, max_tokens, etc.
|
||||
- API keys: From IntegrationProvider (centralized)
|
||||
- Model: From AIModelConfig (is_default=True)
|
||||
- Params: From AISettings with AccountSettings overrides
|
||||
|
||||
Args:
|
||||
function_name: Name of the AI function
|
||||
@@ -44,67 +44,57 @@ def get_model_config(function_name: str, account) -> Dict[str, Any]:
|
||||
actual_name = FUNCTION_ALIASES.get(function_name, function_name)
|
||||
|
||||
try:
|
||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
||||
from igny8_core.modules.system.models import IntegrationSettings
|
||||
from igny8_core.modules.system.ai_settings import AISettings
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
# Get global settings (for API keys and defaults)
|
||||
global_settings = GlobalIntegrationSettings.get_instance()
|
||||
# Get API key from IntegrationProvider
|
||||
api_key = ModelRegistry.get_api_key('openai')
|
||||
|
||||
if not global_settings.openai_api_key:
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"Platform OpenAI API key not configured. "
|
||||
"Please configure GlobalIntegrationSettings in Django admin."
|
||||
"Please configure IntegrationProvider in Django admin."
|
||||
)
|
||||
|
||||
# Start with global defaults
|
||||
model = global_settings.openai_model
|
||||
temperature = global_settings.openai_temperature
|
||||
max_tokens = global_settings.openai_max_tokens
|
||||
api_key = global_settings.openai_api_key # ALWAYS from global
|
||||
# Get default text model from AIModelConfig
|
||||
default_model = ModelRegistry.get_default_model('text')
|
||||
if not default_model:
|
||||
default_model = 'gpt-4o-mini' # Ultimate fallback
|
||||
|
||||
# Check if account has overrides (only for Starter/Growth/Scale plans)
|
||||
# Free plan users cannot create IntegrationSettings records
|
||||
model = default_model
|
||||
|
||||
# Get settings with account overrides
|
||||
temperature = AISettings.get_effective_temperature(account)
|
||||
max_tokens = AISettings.get_effective_max_tokens(account)
|
||||
|
||||
# Get max_tokens from AIModelConfig if available
|
||||
try:
|
||||
account_settings = IntegrationSettings.objects.get(
|
||||
account=account,
|
||||
integration_type='openai',
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
model_config = AIModelConfig.objects.filter(
|
||||
model_name=model,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
config = account_settings.config or {}
|
||||
|
||||
# Override model if specified (NULL = use global)
|
||||
if config.get('model'):
|
||||
model = config['model']
|
||||
|
||||
# Override temperature if specified
|
||||
if config.get('temperature') is not None:
|
||||
temperature = config['temperature']
|
||||
|
||||
# Override max_tokens if specified
|
||||
if config.get('max_tokens'):
|
||||
max_tokens = config['max_tokens']
|
||||
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
# No account override, use global defaults (already set above)
|
||||
pass
|
||||
).first()
|
||||
if model_config and model_config.max_output_tokens:
|
||||
max_tokens = model_config.max_output_tokens
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load max_tokens from AIModelConfig for {model}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Could not load OpenAI settings for account {account.id}: {e}")
|
||||
raise ValueError(
|
||||
f"Could not load OpenAI configuration for account {account.id}. "
|
||||
f"Please configure GlobalIntegrationSettings."
|
||||
f"Please configure IntegrationProvider and AISettings."
|
||||
)
|
||||
|
||||
# Validate model is in our supported list (optional validation)
|
||||
# Validate model is in our supported list using ModelRegistry (database-driven)
|
||||
try:
|
||||
from igny8_core.utils.ai_processor import MODEL_RATES
|
||||
if model not in MODEL_RATES:
|
||||
if not ModelRegistry.validate_model(model):
|
||||
supported_models = [m.model_name for m in ModelRegistry.list_models(model_type='text')]
|
||||
logger.warning(
|
||||
f"Model '{model}' for account {account.id} is not in supported list. "
|
||||
f"Supported models: {list(MODEL_RATES.keys())}"
|
||||
f"Supported models: {supported_models}"
|
||||
)
|
||||
except ImportError:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build response format based on model (JSON mode for supported models)
|
||||
|
||||
@@ -157,6 +157,7 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
||||
from igny8_core.modules.system.models import IntegrationSettings
|
||||
from igny8_core.ai.ai_core import AICore
|
||||
from igny8_core.ai.prompts import PromptRegistry
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"process_image_generation_queue STARTED")
|
||||
@@ -181,73 +182,86 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
||||
failed = 0
|
||||
results = []
|
||||
|
||||
# Get image generation settings
|
||||
# Try account-specific override, otherwise use GlobalIntegrationSettings
|
||||
# Get image generation settings from AISettings (with account overrides)
|
||||
logger.info("[process_image_generation_queue] Step 1: Loading image generation settings")
|
||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
||||
from igny8_core.modules.system.ai_settings import AISettings
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
config = {}
|
||||
try:
|
||||
image_settings = IntegrationSettings.objects.get(
|
||||
account=account,
|
||||
integration_type='image_generation',
|
||||
is_active=True
|
||||
)
|
||||
logger.info(f"[process_image_generation_queue] Using account {account.id} IntegrationSettings override")
|
||||
config = image_settings.config or {}
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
logger.info(f"[process_image_generation_queue] No IntegrationSettings override for account {account.id}, using GlobalIntegrationSettings")
|
||||
except Exception as e:
|
||||
logger.error(f"[process_image_generation_queue] ERROR loading image generation settings: {e}", exc_info=True)
|
||||
return {'success': False, 'error': f'Error loading image generation settings: {str(e)}'}
|
||||
# Get effective settings
|
||||
image_type = AISettings.get_effective_image_style(account)
|
||||
image_format = 'webp' # Default format
|
||||
|
||||
# Use GlobalIntegrationSettings for missing values
|
||||
global_settings = GlobalIntegrationSettings.get_instance()
|
||||
|
||||
logger.info(f"[process_image_generation_queue] Image generation settings loaded. Config keys: {list(config.keys())}")
|
||||
logger.info(f"[process_image_generation_queue] Full config: {config}")
|
||||
|
||||
# Get provider and model from config with global fallbacks
|
||||
provider = config.get('provider') or global_settings.default_image_service
|
||||
if provider == 'runware':
|
||||
model = config.get('model') or config.get('imageModel') or global_settings.runware_model
|
||||
# Get default image model from database
|
||||
default_model = ModelRegistry.get_default_model('image')
|
||||
if default_model:
|
||||
model_config = ModelRegistry.get_model(default_model)
|
||||
provider = model_config.provider if model_config else 'openai'
|
||||
model = default_model
|
||||
else:
|
||||
model = config.get('model') or config.get('imageModel') or global_settings.dalle_model
|
||||
provider = 'openai'
|
||||
model = 'dall-e-3'
|
||||
|
||||
logger.info(f"[process_image_generation_queue] Using PROVIDER: {provider}, MODEL: {model} from settings")
|
||||
image_type = config.get('image_type') or global_settings.image_style
|
||||
image_format = config.get('image_format', 'webp')
|
||||
desktop_enabled = config.get('desktop_enabled', True)
|
||||
mobile_enabled = config.get('mobile_enabled', True)
|
||||
# Get image sizes from config, with fallback defaults
|
||||
featured_image_size = config.get('featured_image_size') or ('1280x832' if provider == 'runware' else '1024x1024')
|
||||
desktop_image_size = config.get('desktop_image_size') or global_settings.desktop_image_size
|
||||
in_article_image_size = config.get('in_article_image_size') or '512x512' # Default to 512x512
|
||||
|
||||
# Style to prompt enhancement mapping
|
||||
# These style descriptors are added to the image prompt for better results
|
||||
STYLE_PROMPT_MAP = {
|
||||
# Runware styles
|
||||
'photorealistic': 'ultra realistic photography, natural lighting, real world look, photorealistic',
|
||||
'illustration': 'digital illustration, clean lines, artistic style, modern illustration',
|
||||
'3d_render': 'computer generated 3D render, modern polished 3D style, depth and dramatic lighting',
|
||||
'minimal_flat': 'minimal flat design, simple shapes, flat colors, modern graphic design aesthetic',
|
||||
'artistic': 'artistic painterly style, expressive brushstrokes, hand painted aesthetic',
|
||||
'cartoon': 'cartoon stylized illustration, playful exaggerated forms, animated character style',
|
||||
# DALL-E styles (mapped from OpenAI API style parameter)
|
||||
'natural': 'natural realistic style',
|
||||
'vivid': 'vivid dramatic hyper-realistic style',
|
||||
# Legacy fallbacks
|
||||
'realistic': 'ultra realistic photography, natural lighting, photorealistic',
|
||||
}
|
||||
|
||||
# Get the style description for prompt enhancement
|
||||
style_description = STYLE_PROMPT_MAP.get(image_type, STYLE_PROMPT_MAP.get('photorealistic'))
|
||||
logger.info(f"[process_image_generation_queue] Style: {image_type} -> prompt enhancement: {style_description[:50]}...")
|
||||
|
||||
# Model-specific landscape sizes (square is always 1024x1024)
|
||||
# For Runware models - based on Runware documentation for optimal results per model
|
||||
# For OpenAI DALL-E 3 - uses 1792x1024 for landscape
|
||||
MODEL_LANDSCAPE_SIZES = {
|
||||
'runware:97@1': '1280x768', # Hi Dream Full landscape
|
||||
'bria:10@1': '1344x768', # Bria 3.2 landscape (16:9)
|
||||
'google:4@2': '1376x768', # Nano Banana landscape (16:9)
|
||||
'dall-e-3': '1792x1024', # DALL-E 3 landscape
|
||||
'dall-e-2': '1024x1024', # DALL-E 2 only supports square
|
||||
}
|
||||
DEFAULT_SQUARE_SIZE = '1024x1024'
|
||||
|
||||
# Get model-specific landscape size for featured images
|
||||
model_landscape_size = MODEL_LANDSCAPE_SIZES.get(model, '1792x1024' if provider == 'openai' else '1280x768')
|
||||
|
||||
# Featured image always uses model-specific landscape size
|
||||
featured_image_size = model_landscape_size
|
||||
# In-article images: alternating square/landscape based on position (handled in image loop)
|
||||
in_article_square_size = DEFAULT_SQUARE_SIZE
|
||||
in_article_landscape_size = model_landscape_size
|
||||
|
||||
logger.info(f"[process_image_generation_queue] Settings loaded:")
|
||||
logger.info(f" - Provider: {provider}")
|
||||
logger.info(f" - Model: {model}")
|
||||
logger.info(f" - Image type: {image_type}")
|
||||
logger.info(f" - Image format: {image_format}")
|
||||
logger.info(f" - Desktop enabled: {desktop_enabled}")
|
||||
logger.info(f" - Mobile enabled: {mobile_enabled}")
|
||||
logger.info(f" - Featured image size: {featured_image_size}")
|
||||
logger.info(f" - In-article square: {in_article_square_size}, landscape: {in_article_landscape_size}")
|
||||
|
||||
# Get provider API key
|
||||
# API keys are ALWAYS from GlobalIntegrationSettings (accounts cannot override API keys)
|
||||
# Account IntegrationSettings only store provider preference, NOT API keys
|
||||
logger.info(f"[process_image_generation_queue] Step 2: Loading {provider.upper()} API key from GlobalIntegrationSettings")
|
||||
# Get provider API key from IntegrationProvider (centralized)
|
||||
logger.info(f"[process_image_generation_queue] Step 2: Loading {provider.upper()} API key from IntegrationProvider")
|
||||
|
||||
# Get API key from GlobalIntegrationSettings
|
||||
if provider == 'runware':
|
||||
api_key = global_settings.runware_api_key
|
||||
elif provider == 'openai':
|
||||
api_key = global_settings.dalle_api_key or global_settings.openai_api_key
|
||||
else:
|
||||
api_key = None
|
||||
# Get API key from IntegrationProvider (centralized)
|
||||
api_key = ModelRegistry.get_api_key(provider)
|
||||
|
||||
if not api_key:
|
||||
logger.error(f"[process_image_generation_queue] {provider.upper()} API key not configured in GlobalIntegrationSettings")
|
||||
return {'success': False, 'error': f'{provider.upper()} API key not configured in GlobalIntegrationSettings'}
|
||||
logger.error(f"[process_image_generation_queue] {provider.upper()} API key not configured in IntegrationProvider")
|
||||
return {'success': False, 'error': f'{provider.upper()} API key not configured'}
|
||||
|
||||
# Log API key presence (but not the actual key for security)
|
||||
api_key_preview = f"{api_key[:10]}...{api_key[-4:]}" if len(api_key) > 14 else "***"
|
||||
@@ -386,7 +400,7 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
||||
# Calculate actual template length with placeholders filled
|
||||
# Format template with dummy values to measure actual length
|
||||
template_with_dummies = image_prompt_template.format(
|
||||
image_type=image_type,
|
||||
image_type=style_description, # Use actual style description length
|
||||
post_title='X' * len(post_title), # Use same length as actual post_title
|
||||
image_prompt='' # Empty to measure template overhead
|
||||
)
|
||||
@@ -413,7 +427,7 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
||||
image_prompt = image_prompt[:max_image_prompt_length - 3] + "..."
|
||||
|
||||
formatted_prompt = image_prompt_template.format(
|
||||
image_type=image_type,
|
||||
image_type=style_description, # Use full style description instead of raw value
|
||||
post_title=post_title,
|
||||
image_prompt=image_prompt
|
||||
)
|
||||
@@ -478,15 +492,40 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
||||
}
|
||||
)
|
||||
|
||||
# Use appropriate size based on image type
|
||||
# Use appropriate size based on image type and position
|
||||
# Featured: Always landscape (model-specific)
|
||||
# In-article: Alternating square/landscape based on position
|
||||
# Position 0: Square (1024x1024)
|
||||
# Position 1: Landscape (model-specific)
|
||||
# Position 2: Square (1024x1024)
|
||||
# Position 3: Landscape (model-specific)
|
||||
if image.image_type == 'featured':
|
||||
image_size = featured_image_size # Read from config
|
||||
elif image.image_type == 'desktop':
|
||||
image_size = desktop_image_size
|
||||
elif image.image_type == 'mobile':
|
||||
image_size = '512x512' # Fixed mobile size
|
||||
else: # in_article or other
|
||||
image_size = in_article_image_size # Read from config, default 512x512
|
||||
image_size = featured_image_size # Model-specific landscape
|
||||
elif image.image_type == 'in_article':
|
||||
# Alternate based on position: even=square, odd=landscape
|
||||
position = image.position or 0
|
||||
if position % 2 == 0: # Position 0, 2: Square
|
||||
image_size = in_article_square_size
|
||||
else: # Position 1, 3: Landscape
|
||||
image_size = in_article_landscape_size
|
||||
logger.info(f"[process_image_generation_queue] In-article image position {position}: using {'square' if position % 2 == 0 else 'landscape'} size {image_size}")
|
||||
else: # desktop or other (legacy)
|
||||
image_size = in_article_square_size # Default to square
|
||||
|
||||
# For DALL-E, convert image_type to style parameter
|
||||
# image_type is from user settings (e.g., 'vivid', 'natural', 'realistic')
|
||||
# DALL-E accepts 'vivid' or 'natural' - map accordingly
|
||||
dalle_style = None
|
||||
if provider == 'openai':
|
||||
# Map image_type to DALL-E style
|
||||
# 'natural' = more realistic photos (default)
|
||||
# 'vivid' = hyper-real, dramatic images
|
||||
if image_type in ['vivid']:
|
||||
dalle_style = 'vivid'
|
||||
else:
|
||||
# Default to 'natural' for realistic photos
|
||||
dalle_style = 'natural'
|
||||
logger.info(f"[process_image_generation_queue] DALL-E style: {dalle_style} (from image_type: {image_type})")
|
||||
|
||||
result = ai_core.generate_image(
|
||||
prompt=formatted_prompt,
|
||||
@@ -495,7 +534,8 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
||||
size=image_size,
|
||||
api_key=api_key,
|
||||
negative_prompt=negative_prompt,
|
||||
function_name='generate_images_from_prompts'
|
||||
function_name='generate_images_from_prompts',
|
||||
style=dalle_style
|
||||
)
|
||||
|
||||
# Update progress: Image generation complete (50%)
|
||||
@@ -670,6 +710,33 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
||||
})
|
||||
failed += 1
|
||||
else:
|
||||
# Deduct credits for successful image generation
|
||||
credits_deducted = 0
|
||||
cost_usd = result.get('cost_usd', 0)
|
||||
if account:
|
||||
try:
|
||||
credits_deducted = CreditService.deduct_credits_for_image(
|
||||
account=account,
|
||||
model_name=model,
|
||||
num_images=1,
|
||||
description=f"Image generation: {content.title[:50] if content else 'Image'}" if content else f"Image {image_id}",
|
||||
metadata={
|
||||
'image_id': image_id,
|
||||
'content_id': content_id,
|
||||
'provider': provider,
|
||||
'model': model,
|
||||
'image_type': image.image_type if image else 'unknown',
|
||||
'size': image_size,
|
||||
},
|
||||
cost_usd=cost_usd,
|
||||
related_object_type='image',
|
||||
related_object_id=image_id
|
||||
)
|
||||
logger.info(f"[process_image_generation_queue] Credits deducted for image {image_id}: account balance now {credits_deducted}")
|
||||
except Exception as credit_error:
|
||||
logger.error(f"[process_image_generation_queue] Failed to deduct credits for image {image_id}: {credit_error}")
|
||||
# Don't fail the image generation if credit deduction fails
|
||||
|
||||
# Update progress: Complete (100%)
|
||||
self.update_state(
|
||||
state='PROGRESS',
|
||||
|
||||
@@ -145,7 +145,7 @@ def validate_model(model: str, model_type: str = 'text') -> Dict[str, Any]:
|
||||
Dict with 'valid' (bool) and optional 'error' (str)
|
||||
"""
|
||||
try:
|
||||
# Try database first
|
||||
# Use database-driven validation via AIModelConfig
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
|
||||
exists = AIModelConfig.objects.filter(
|
||||
@@ -169,29 +169,20 @@ def validate_model(model: str, model_type: str = 'text') -> Dict[str, Any]:
|
||||
else:
|
||||
return {
|
||||
'valid': False,
|
||||
'error': f'Model "{model}" is not found in database'
|
||||
'error': f'No {model_type} models configured in database'
|
||||
}
|
||||
|
||||
return {'valid': True}
|
||||
|
||||
except Exception:
|
||||
# Fallback to constants if database fails
|
||||
from .constants import MODEL_RATES, VALID_OPENAI_IMAGE_MODELS
|
||||
|
||||
if model_type == 'text':
|
||||
if model not in MODEL_RATES:
|
||||
return {
|
||||
'valid': False,
|
||||
'error': f'Model "{model}" is not in supported models list'
|
||||
}
|
||||
elif model_type == 'image':
|
||||
if model not in VALID_OPENAI_IMAGE_MODELS:
|
||||
return {
|
||||
'valid': False,
|
||||
'error': f'Model "{model}" is not valid for OpenAI image generation. Only {", ".join(VALID_OPENAI_IMAGE_MODELS)} are supported.'
|
||||
}
|
||||
|
||||
return {'valid': True}
|
||||
except Exception as e:
|
||||
# Log error but don't fallback to constants - DB is authoritative
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error validating model {model}: {e}")
|
||||
return {
|
||||
'valid': False,
|
||||
'error': f'Error validating model: {e}'
|
||||
}
|
||||
|
||||
|
||||
def validate_image_size(size: str, model: str) -> Dict[str, Any]:
|
||||
|
||||
@@ -132,6 +132,16 @@ class TeamManagementViewSet(viewsets.ViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check hard limit for users BEFORE creating
|
||||
from igny8_core.business.billing.services.limit_service import LimitService, HardLimitExceededError
|
||||
try:
|
||||
LimitService.check_hard_limit(account, 'users', additional_count=1)
|
||||
except HardLimitExceededError as e:
|
||||
return Response(
|
||||
{'error': str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Create user (simplified - in production, send invitation email)
|
||||
user = User.objects.create_user(
|
||||
email=email,
|
||||
|
||||
@@ -124,12 +124,22 @@ class IsEditorOrAbove(permissions.BasePermission):
|
||||
class IsAdminOrOwner(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that requires admin or owner role only
|
||||
OR user belongs to aws-admin account
|
||||
For settings, keys, billing operations
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Check if user belongs to aws-admin account (case-insensitive)
|
||||
if hasattr(request.user, 'account') and request.user.account:
|
||||
account_name = getattr(request.user.account, 'name', None)
|
||||
account_slug = getattr(request.user.account, 'slug', None)
|
||||
if account_name and account_name.lower() == 'aws admin':
|
||||
return True
|
||||
if account_slug == 'aws-admin':
|
||||
return True
|
||||
|
||||
# Check user role
|
||||
if hasattr(request.user, 'role'):
|
||||
role = request.user.role
|
||||
|
||||
@@ -9,6 +9,7 @@ from .account_views import (
|
||||
UsageAnalyticsViewSet,
|
||||
DashboardStatsViewSet
|
||||
)
|
||||
from igny8_core.modules.system.settings_views import ContentGenerationSettingsViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
|
||||
@@ -16,6 +17,10 @@ urlpatterns = [
|
||||
# Account settings (non-router endpoints for simplified access)
|
||||
path('settings/', AccountSettingsViewSet.as_view({'get': 'retrieve', 'patch': 'partial_update'}), name='account-settings'),
|
||||
|
||||
# AI Settings - Content Generation Settings per the plan
|
||||
# GET/POST /api/v1/account/settings/ai/
|
||||
path('settings/ai/', ContentGenerationSettingsViewSet.as_view({'get': 'list', 'post': 'create', 'put': 'create'}), name='ai-settings'),
|
||||
|
||||
# Team management
|
||||
path('team/', TeamManagementViewSet.as_view({'get': 'list', 'post': 'create'}), name='team-list'),
|
||||
path('team/<int:pk>/', TeamManagementViewSet.as_view({'delete': 'destroy'}), name='team-detail'),
|
||||
@@ -27,4 +32,4 @@ urlpatterns = [
|
||||
path('dashboard/stats/', DashboardStatsViewSet.as_view({'get': 'stats'}), name='dashboard-stats'),
|
||||
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
]
|
||||
@@ -117,7 +117,7 @@ class PlanResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = Plan
|
||||
fields = ('id', 'name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users',
|
||||
'max_keywords', 'max_content_words', 'included_credits', 'is_active', 'is_featured')
|
||||
'max_keywords', 'max_ahrefs_queries', 'included_credits', 'is_active', 'is_featured')
|
||||
export_order = fields
|
||||
import_id_fields = ('id',)
|
||||
skip_unchanged = True
|
||||
@@ -127,7 +127,7 @@ class PlanResource(resources.ModelResource):
|
||||
class PlanAdmin(ImportExportMixin, Igny8ModelAdmin):
|
||||
resource_class = PlanResource
|
||||
"""Plan admin - Global, no account filtering needed"""
|
||||
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_content_words', 'included_credits', 'is_active', 'is_featured']
|
||||
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_ahrefs_queries', 'included_credits', 'is_active', 'is_featured']
|
||||
list_filter = ['is_active', 'billing_cycle', 'is_internal', 'is_featured']
|
||||
search_fields = ['name', 'slug']
|
||||
readonly_fields = ['created_at']
|
||||
@@ -147,12 +147,12 @@ class PlanAdmin(ImportExportMixin, Igny8ModelAdmin):
|
||||
'description': 'Persistent limits for account-level resources'
|
||||
}),
|
||||
('Hard Limits (Persistent)', {
|
||||
'fields': ('max_keywords', 'max_clusters'),
|
||||
'fields': ('max_keywords',),
|
||||
'description': 'Total allowed - never reset'
|
||||
}),
|
||||
('Monthly Limits (Reset on Billing Cycle)', {
|
||||
'fields': ('max_content_ideas', 'max_content_words', 'max_images_basic', 'max_images_premium', 'max_image_prompts'),
|
||||
'description': 'Monthly allowances - reset at billing cycle'
|
||||
'fields': ('max_ahrefs_queries',),
|
||||
'description': 'Monthly Ahrefs keyword research queries (0 = disabled)'
|
||||
}),
|
||||
('Billing & Credits', {
|
||||
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
|
||||
@@ -214,6 +214,7 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
||||
'bulk_add_credits',
|
||||
'bulk_subtract_credits',
|
||||
'bulk_soft_delete',
|
||||
'bulk_hard_delete',
|
||||
]
|
||||
|
||||
def get_queryset(self, request):
|
||||
@@ -454,14 +455,39 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
||||
bulk_subtract_credits.short_description = 'Subtract credits from accounts'
|
||||
|
||||
def bulk_soft_delete(self, request, queryset):
|
||||
"""Soft delete selected accounts"""
|
||||
"""Soft delete selected accounts and all related data"""
|
||||
count = 0
|
||||
for account in queryset:
|
||||
if account.slug != 'aws-admin': # Protect admin account
|
||||
account.delete() # Soft delete via SoftDeletableModel
|
||||
account.delete() # Soft delete via SoftDeletableModel (now cascades)
|
||||
count += 1
|
||||
self.message_user(request, f'{count} account(s) soft deleted.', messages.SUCCESS)
|
||||
bulk_soft_delete.short_description = 'Soft delete selected accounts'
|
||||
self.message_user(request, f'{count} account(s) and all related data soft deleted.', messages.SUCCESS)
|
||||
bulk_soft_delete.short_description = 'Soft delete accounts (with cascade)'
|
||||
|
||||
def bulk_hard_delete(self, request, queryset):
|
||||
"""PERMANENTLY delete selected accounts and ALL related data - cannot be undone!"""
|
||||
import traceback
|
||||
count = 0
|
||||
errors = []
|
||||
for account in queryset:
|
||||
if account.slug == 'aws-admin': # Protect admin account
|
||||
errors.append(f'{account.name}: Protected system account')
|
||||
continue
|
||||
try:
|
||||
account.hard_delete_with_cascade() # Permanently delete everything
|
||||
count += 1
|
||||
except Exception as e:
|
||||
# Log full traceback for debugging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Hard delete failed for account {account.pk} ({account.name}): {traceback.format_exc()}')
|
||||
errors.append(f'{account.name}: {str(e)}')
|
||||
|
||||
if count > 0:
|
||||
self.message_user(request, f'{count} account(s) and ALL related data permanently deleted.', messages.SUCCESS)
|
||||
if errors:
|
||||
self.message_user(request, f'Errors: {"; ".join(errors)}', messages.ERROR)
|
||||
bulk_hard_delete.short_description = '⚠️ PERMANENTLY delete accounts (irreversible!)'
|
||||
|
||||
|
||||
class SubscriptionResource(resources.ModelResource):
|
||||
@@ -981,7 +1007,7 @@ class UserAdmin(ExportMixin, BaseUserAdmin, Igny8ModelAdmin):
|
||||
list_display = ['email', 'username', 'account', 'role', 'is_active', 'is_staff', 'created_at']
|
||||
list_filter = ['role', 'account', 'is_active', 'is_staff']
|
||||
search_fields = ['email', 'username']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
readonly_fields = ['created_at', 'updated_at', 'password_display']
|
||||
|
||||
fieldsets = BaseUserAdmin.fieldsets + (
|
||||
('IGNY8 Info', {'fields': ('account', 'role')}),
|
||||
@@ -999,8 +1025,45 @@ class UserAdmin(ExportMixin, BaseUserAdmin, Igny8ModelAdmin):
|
||||
'bulk_activate',
|
||||
'bulk_deactivate',
|
||||
'bulk_send_password_reset',
|
||||
'bulk_set_temporary_password',
|
||||
]
|
||||
|
||||
def password_display(self, obj):
|
||||
"""Show password hash with copy button (for debugging only)"""
|
||||
if obj.password:
|
||||
return f'Hash: {obj.password[:50]}...'
|
||||
return 'No password set'
|
||||
password_display.short_description = 'Password Hash'
|
||||
|
||||
def bulk_set_temporary_password(self, request, queryset):
|
||||
"""Set a temporary password for selected users and display it"""
|
||||
import secrets
|
||||
import string
|
||||
|
||||
# Generate a secure random password
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
temp_password = ''.join(secrets.choice(alphabet) for _ in range(12))
|
||||
|
||||
users_updated = []
|
||||
for user in queryset:
|
||||
user.set_password(temp_password)
|
||||
user.save(update_fields=['password'])
|
||||
users_updated.append(user.email)
|
||||
|
||||
if users_updated:
|
||||
# Display the password in the message (only visible to admin)
|
||||
self.message_user(
|
||||
request,
|
||||
f'Temporary password set for {len(users_updated)} user(s): "{temp_password}" (same password for all selected users)',
|
||||
messages.SUCCESS
|
||||
)
|
||||
self.message_user(
|
||||
request,
|
||||
f'Users updated: {", ".join(users_updated)}',
|
||||
messages.INFO
|
||||
)
|
||||
bulk_set_temporary_password.short_description = '🔑 Set temporary password (will display)'
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Filter users by account for non-superusers"""
|
||||
qs = super().get_queryset(request)
|
||||
|
||||
@@ -25,18 +25,7 @@ class Command(BaseCommand):
|
||||
'max_users': 999999,
|
||||
'max_sites': 999999,
|
||||
'max_keywords': 999999,
|
||||
'max_clusters': 999999,
|
||||
'max_content_ideas': 999999,
|
||||
'monthly_word_count_limit': 999999999,
|
||||
'daily_content_tasks': 999999,
|
||||
'daily_ai_requests': 999999,
|
||||
'daily_ai_request_limit': 999999,
|
||||
'monthly_ai_credit_limit': 999999,
|
||||
'monthly_image_count': 999999,
|
||||
'daily_image_generation_limit': 999999,
|
||||
'monthly_cluster_ai_credits': 999999,
|
||||
'monthly_content_ai_credits': 999999,
|
||||
'monthly_image_ai_credits': 999999,
|
||||
'max_ahrefs_queries': 999999,
|
||||
'included_credits': 999999,
|
||||
'is_active': True,
|
||||
'features': ['ai_writer', 'image_gen', 'auto_publish', 'custom_prompts', 'unlimited'],
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Generated by IGNY8 Phase 1: Simplify Credits & Limits
|
||||
# Migration: Remove unused limit fields, add Ahrefs query tracking
|
||||
# Date: January 5, 2026
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.core.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""
|
||||
Simplify the credits and limits system:
|
||||
|
||||
PLAN MODEL:
|
||||
- REMOVE: max_clusters, max_content_ideas, max_content_words,
|
||||
max_images_basic, max_images_premium, max_image_prompts
|
||||
- ADD: max_ahrefs_queries (monthly keyword research queries)
|
||||
|
||||
ACCOUNT MODEL:
|
||||
- REMOVE: usage_content_ideas, usage_content_words, usage_images_basic,
|
||||
usage_images_premium, usage_image_prompts
|
||||
- ADD: usage_ahrefs_queries
|
||||
|
||||
RATIONALE:
|
||||
All consumption is now controlled by credits only. The only non-credit
|
||||
limits are: sites, users, keywords (hard limits) and ahrefs_queries (monthly).
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0018_add_country_remove_intent_seedkeyword'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# STEP 1: Add new Ahrefs fields FIRST (before removing old ones)
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_ahrefs_queries',
|
||||
field=models.IntegerField(
|
||||
default=0,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
help_text='Monthly Ahrefs keyword research queries (0 = disabled)'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='usage_ahrefs_queries',
|
||||
field=models.IntegerField(
|
||||
default=0,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
help_text='Ahrefs queries used this month'
|
||||
),
|
||||
),
|
||||
|
||||
# STEP 2: Remove unused Plan fields
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='max_clusters',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='max_content_ideas',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='max_content_words',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='max_images_basic',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='max_images_premium',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='max_image_prompts',
|
||||
),
|
||||
|
||||
# STEP 3: Remove unused Account fields
|
||||
migrations.RemoveField(
|
||||
model_name='account',
|
||||
name='usage_content_ideas',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='account',
|
||||
name='usage_content_words',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='account',
|
||||
name='usage_images_basic',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='account',
|
||||
name='usage_images_premium',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='account',
|
||||
name='usage_image_prompts',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-06 00:11
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0019_simplify_credits_limits'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaccount',
|
||||
name='usage_content_ideas',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaccount',
|
||||
name='usage_content_words',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaccount',
|
||||
name='usage_image_prompts',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaccount',
|
||||
name='usage_images_basic',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaccount',
|
||||
name='usage_images_premium',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalaccount',
|
||||
name='usage_ahrefs_queries',
|
||||
field=models.IntegerField(default=0, help_text='Ahrefs queries used this month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
]
|
||||
@@ -108,11 +108,7 @@ class Account(SoftDeletableModel):
|
||||
tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number")
|
||||
|
||||
# Monthly usage tracking (reset on billing cycle)
|
||||
usage_content_ideas = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Content ideas generated this month")
|
||||
usage_content_words = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Content words generated this month")
|
||||
usage_images_basic = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Basic AI images this month")
|
||||
usage_images_premium = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Premium AI images this month")
|
||||
usage_image_prompts = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Image prompts this month")
|
||||
usage_ahrefs_queries = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Ahrefs queries used this month")
|
||||
usage_period_start = models.DateTimeField(null=True, blank=True, help_text="Current billing period start")
|
||||
usage_period_end = models.DateTimeField(null=True, blank=True, help_text="Current billing period end")
|
||||
|
||||
@@ -157,12 +153,152 @@ class Account(SoftDeletableModel):
|
||||
# System accounts bypass all filtering restrictions
|
||||
return self.slug in ['aws-admin', 'default-account', 'default']
|
||||
|
||||
def soft_delete(self, user=None, reason=None, retention_days=None):
|
||||
def soft_delete(self, user=None, reason=None, retention_days=None, cascade=True):
|
||||
"""
|
||||
Soft delete the account and optionally cascade to all related objects.
|
||||
Args:
|
||||
user: User performing the deletion
|
||||
reason: Reason for deletion
|
||||
retention_days: Days before permanent deletion
|
||||
cascade: If True, also soft-delete related objects that support soft delete,
|
||||
and hard-delete objects that don't support soft delete
|
||||
"""
|
||||
if self.is_system_account():
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied("System account cannot be deleted.")
|
||||
|
||||
if cascade:
|
||||
self._cascade_delete_related(user=user, reason=reason, retention_days=retention_days, hard_delete=False)
|
||||
|
||||
return super().soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||
|
||||
def _cascade_delete_related(self, user=None, reason=None, retention_days=None, hard_delete=False):
|
||||
"""
|
||||
Delete all related objects when account is deleted.
|
||||
For soft delete: soft-deletes objects with SoftDeletableModel, hard-deletes others
|
||||
For hard delete: hard-deletes everything
|
||||
"""
|
||||
from igny8_core.common.soft_delete import SoftDeletableModel
|
||||
|
||||
# List of related objects to delete (in order to avoid FK constraint issues)
|
||||
# Related names from Account reverse relations
|
||||
related_names = [
|
||||
# Content & Planning related (delete first due to dependencies)
|
||||
'contentclustermap_set',
|
||||
'contentattribute_set',
|
||||
'contenttaxonomy_set',
|
||||
'content_set',
|
||||
'images_set',
|
||||
'contentideas_set',
|
||||
'tasks_set',
|
||||
'keywords_set',
|
||||
'clusters_set',
|
||||
'strategy_set',
|
||||
# Automation
|
||||
'automation_runs',
|
||||
'automation_configs',
|
||||
# Publishing & Integration
|
||||
'syncevent_set',
|
||||
'publishingsettings_set',
|
||||
'publishingrecord_set',
|
||||
'deploymentrecord_set',
|
||||
'siteintegration_set',
|
||||
# Notifications & Optimization
|
||||
'notification_set',
|
||||
'optimizationtask_set',
|
||||
# AI & Settings
|
||||
'aitasklog_set',
|
||||
'aiprompt_set',
|
||||
'aisettings_set',
|
||||
'authorprofile_set',
|
||||
# Billing (preserve invoices/payments for audit, delete others)
|
||||
'planlimitusage_set',
|
||||
'creditusagelog_set',
|
||||
'credittransaction_set',
|
||||
'accountpaymentmethod_set',
|
||||
'payment_set',
|
||||
'invoice_set',
|
||||
# Settings
|
||||
'modulesettings_set',
|
||||
'moduleenablesettings_set',
|
||||
'integrationsettings_set',
|
||||
'user_settings',
|
||||
'accountsettings_set',
|
||||
# Core (last due to dependencies)
|
||||
'sector_set',
|
||||
'site_set',
|
||||
# Users (delete after sites to avoid FK issues, owner is SET_NULL)
|
||||
'users',
|
||||
# Subscription (OneToOne)
|
||||
'subscription',
|
||||
]
|
||||
|
||||
for related_name in related_names:
|
||||
try:
|
||||
related = getattr(self, related_name, None)
|
||||
if related is None:
|
||||
continue
|
||||
|
||||
# Handle OneToOne fields (subscription)
|
||||
if hasattr(related, 'pk'):
|
||||
# It's a single object (OneToOneField)
|
||||
if hard_delete:
|
||||
related.hard_delete() if hasattr(related, 'hard_delete') else related.delete()
|
||||
elif isinstance(related, SoftDeletableModel):
|
||||
related.soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||
else:
|
||||
# Non-soft-deletable single object - hard delete
|
||||
related.delete()
|
||||
else:
|
||||
# It's a RelatedManager (ForeignKey)
|
||||
queryset = related.all()
|
||||
if queryset.exists():
|
||||
if hard_delete:
|
||||
# Hard delete all
|
||||
if hasattr(queryset, 'hard_delete'):
|
||||
queryset.hard_delete()
|
||||
else:
|
||||
for obj in queryset:
|
||||
if hasattr(obj, 'hard_delete'):
|
||||
obj.hard_delete()
|
||||
else:
|
||||
obj.delete()
|
||||
else:
|
||||
# Soft delete if supported, otherwise hard delete
|
||||
model = queryset.model
|
||||
if issubclass(model, SoftDeletableModel):
|
||||
for obj in queryset:
|
||||
obj.soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||
else:
|
||||
queryset.delete()
|
||||
except Exception as e:
|
||||
# Log but don't fail - some relations may not exist
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Failed to delete related {related_name} for account {self.pk}: {e}")
|
||||
|
||||
def hard_delete_with_cascade(self, using=None, keep_parents=False):
|
||||
"""
|
||||
Permanently delete the account and ALL related objects.
|
||||
This bypasses soft-delete and removes everything from the database.
|
||||
USE WITH CAUTION - this cannot be undone!
|
||||
"""
|
||||
if self.is_system_account():
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied("System account cannot be deleted.")
|
||||
|
||||
# Clear owner reference first to avoid FK constraint issues
|
||||
# (owner is SET_NULL but we're deleting the user who is the owner)
|
||||
if self.owner:
|
||||
self.owner = None
|
||||
self.save(update_fields=['owner'])
|
||||
|
||||
# Cascade hard-delete all related objects first
|
||||
self._cascade_delete_related(hard_delete=True)
|
||||
|
||||
# Finally hard-delete the account itself
|
||||
return super().hard_delete(using=using, keep_parents=keep_parents)
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
return self.soft_delete()
|
||||
|
||||
@@ -216,37 +352,12 @@ class Plan(models.Model):
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum total keywords allowed (hard limit)"
|
||||
)
|
||||
max_clusters = models.IntegerField(
|
||||
default=100,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum AI keyword clusters allowed (hard limit)"
|
||||
)
|
||||
|
||||
# Monthly Limits (Reset on billing cycle)
|
||||
max_content_ideas = models.IntegerField(
|
||||
default=300,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum AI content ideas per month"
|
||||
)
|
||||
max_content_words = models.IntegerField(
|
||||
default=100000,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum content words per month (e.g., 100000 = 100K words)"
|
||||
)
|
||||
max_images_basic = models.IntegerField(
|
||||
default=300,
|
||||
max_ahrefs_queries = models.IntegerField(
|
||||
default=0,
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text="Maximum basic AI images per month"
|
||||
)
|
||||
max_images_premium = models.IntegerField(
|
||||
default=60,
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text="Maximum premium AI images per month (DALL-E)"
|
||||
)
|
||||
max_image_prompts = models.IntegerField(
|
||||
default=300,
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text="Maximum image prompts per month"
|
||||
help_text="Monthly Ahrefs keyword research queries (0 = disabled)"
|
||||
)
|
||||
|
||||
# Billing & Credits (Phase 0: Credit-only system)
|
||||
|
||||
@@ -13,9 +13,7 @@ class PlanSerializer(serializers.ModelSerializer):
|
||||
'id', 'name', 'slug', 'price', 'original_price', 'billing_cycle', 'annual_discount_percent',
|
||||
'is_featured', 'features', 'is_active',
|
||||
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
|
||||
'max_keywords', 'max_clusters',
|
||||
'max_content_ideas', 'max_content_words',
|
||||
'max_images_basic', 'max_images_premium', 'max_image_prompts',
|
||||
'max_keywords', 'max_ahrefs_queries',
|
||||
'included_credits', 'extra_credit_price', 'allow_credit_topup',
|
||||
'auto_credit_topup_threshold', 'auto_credit_topup_amount',
|
||||
'stripe_product_id', 'stripe_price_id', 'credits_per_month'
|
||||
@@ -55,7 +53,7 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'owner', 'plan', 'plan_id',
|
||||
'credits', 'status', 'payment_method',
|
||||
'subscription', 'created_at'
|
||||
'subscription', 'billing_country', 'created_at'
|
||||
]
|
||||
read_only_fields = ['owner', 'created_at']
|
||||
|
||||
@@ -408,11 +406,20 @@ class RegisterSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
# Generate unique slug for account
|
||||
base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account'
|
||||
slug = base_slug
|
||||
# Clean the base slug: lowercase, replace spaces and underscores with hyphens
|
||||
import re
|
||||
import random
|
||||
import string
|
||||
base_slug = re.sub(r'[^a-z0-9-]', '', account_name.lower().replace(' ', '-').replace('_', '-'))[:40] or 'account'
|
||||
|
||||
# Add random suffix to prevent collisions (especially during concurrent registrations)
|
||||
random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||
slug = f"{base_slug}-{random_suffix}"
|
||||
|
||||
# Ensure uniqueness with fallback counter
|
||||
counter = 1
|
||||
while Account.objects.filter(slug=slug).exists():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
slug = f"{base_slug}-{random_suffix}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Create account with status and credits seeded (0 for paid pending)
|
||||
|
||||
@@ -109,16 +109,38 @@ class RegisterView(APIView):
|
||||
refresh_expires_at = timezone.now() + get_refresh_token_expiry()
|
||||
|
||||
user_serializer = UserSerializer(user)
|
||||
|
||||
# Build response data
|
||||
response_data = {
|
||||
'user': user_serializer.data,
|
||||
'tokens': {
|
||||
'access': access_token,
|
||||
'refresh': refresh_token,
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
}
|
||||
}
|
||||
|
||||
# NOTE: Payment checkout is NO LONGER created at registration
|
||||
# User will complete payment on /account/plans after signup
|
||||
# This simplifies the signup flow and consolidates all payment handling
|
||||
|
||||
# Send welcome email (if enabled in settings)
|
||||
try:
|
||||
from igny8_core.modules.system.email_models import EmailSettings
|
||||
from igny8_core.business.billing.services.email_service import send_welcome_email
|
||||
|
||||
email_settings = EmailSettings.get_settings()
|
||||
if email_settings.send_welcome_emails and account:
|
||||
send_welcome_email(user, account)
|
||||
except Exception as e:
|
||||
# Don't fail registration if email fails
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send welcome email for user {user.id}: {e}")
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'user': user_serializer.data,
|
||||
'tokens': {
|
||||
'access': access_token,
|
||||
'refresh': refresh_token,
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
}
|
||||
},
|
||||
data=response_data,
|
||||
message='Registration successful',
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
request=request
|
||||
@@ -263,6 +285,128 @@ class LoginView(APIView):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=['Authentication'],
|
||||
summary='Request Password Reset',
|
||||
description='Request password reset email'
|
||||
)
|
||||
class PasswordResetRequestView(APIView):
|
||||
"""Request password reset endpoint - sends email with reset token."""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
from .serializers import RequestPasswordResetSerializer
|
||||
from .models import PasswordResetToken
|
||||
|
||||
serializer = RequestPasswordResetSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
email = serializer.validated_data['email']
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
# Don't reveal if email exists - return success anyway
|
||||
return success_response(
|
||||
message='If an account with that email exists, a password reset link has been sent.',
|
||||
request=request
|
||||
)
|
||||
|
||||
# Generate secure token
|
||||
import secrets
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# Create reset token (expires in 1 hour)
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
expires_at = timezone.now() + timedelta(hours=1)
|
||||
|
||||
PasswordResetToken.objects.create(
|
||||
user=user,
|
||||
token=token,
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
# Send password reset email
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[PASSWORD_RESET] Attempting to send reset email to: {email}")
|
||||
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import send_password_reset_email
|
||||
result = send_password_reset_email(user, token)
|
||||
logger.info(f"[PASSWORD_RESET] Email send result: {result}")
|
||||
print(f"[PASSWORD_RESET] Email send result: {result}") # Console output
|
||||
except Exception as e:
|
||||
logger.error(f"[PASSWORD_RESET] Failed to send password reset email: {e}", exc_info=True)
|
||||
print(f"[PASSWORD_RESET] ERROR: {e}") # Console output
|
||||
|
||||
return success_response(
|
||||
message='If an account with that email exists, a password reset link has been sent.',
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=['Authentication'],
|
||||
summary='Reset Password',
|
||||
description='Reset password using token from email'
|
||||
)
|
||||
class PasswordResetConfirmView(APIView):
|
||||
"""Confirm password reset with token."""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
from .serializers import ResetPasswordSerializer
|
||||
from .models import PasswordResetToken
|
||||
from django.utils import timezone
|
||||
|
||||
serializer = ResetPasswordSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
token = serializer.validated_data['token']
|
||||
new_password = serializer.validated_data['new_password']
|
||||
|
||||
try:
|
||||
reset_token = PasswordResetToken.objects.get(
|
||||
token=token,
|
||||
used=False,
|
||||
expires_at__gt=timezone.now()
|
||||
)
|
||||
except PasswordResetToken.DoesNotExist:
|
||||
return error_response(
|
||||
error='Invalid or expired reset token',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Reset password
|
||||
user = reset_token.user
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
# Mark token as used
|
||||
reset_token.used = True
|
||||
reset_token.save()
|
||||
|
||||
return success_response(
|
||||
message='Password reset successfully. You can now log in with your new password.',
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=['Authentication'],
|
||||
summary='Change Password',
|
||||
@@ -378,6 +522,77 @@ class RefreshTokenView(APIView):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=['Authentication'],
|
||||
summary='Get Country List',
|
||||
description='Returns list of countries for registration country selection'
|
||||
)
|
||||
class CountryListView(APIView):
|
||||
"""Returns list of countries for signup dropdown"""
|
||||
permission_classes = [permissions.AllowAny] # Public endpoint
|
||||
|
||||
def get(self, request):
|
||||
"""Get list of countries with codes and names"""
|
||||
# Comprehensive list of countries for billing purposes
|
||||
countries = [
|
||||
{'code': 'US', 'name': 'United States'},
|
||||
{'code': 'GB', 'name': 'United Kingdom'},
|
||||
{'code': 'CA', 'name': 'Canada'},
|
||||
{'code': 'AU', 'name': 'Australia'},
|
||||
{'code': 'DE', 'name': 'Germany'},
|
||||
{'code': 'FR', 'name': 'France'},
|
||||
{'code': 'ES', 'name': 'Spain'},
|
||||
{'code': 'IT', 'name': 'Italy'},
|
||||
{'code': 'NL', 'name': 'Netherlands'},
|
||||
{'code': 'BE', 'name': 'Belgium'},
|
||||
{'code': 'CH', 'name': 'Switzerland'},
|
||||
{'code': 'AT', 'name': 'Austria'},
|
||||
{'code': 'SE', 'name': 'Sweden'},
|
||||
{'code': 'NO', 'name': 'Norway'},
|
||||
{'code': 'DK', 'name': 'Denmark'},
|
||||
{'code': 'FI', 'name': 'Finland'},
|
||||
{'code': 'IE', 'name': 'Ireland'},
|
||||
{'code': 'PT', 'name': 'Portugal'},
|
||||
{'code': 'PL', 'name': 'Poland'},
|
||||
{'code': 'CZ', 'name': 'Czech Republic'},
|
||||
{'code': 'NZ', 'name': 'New Zealand'},
|
||||
{'code': 'SG', 'name': 'Singapore'},
|
||||
{'code': 'HK', 'name': 'Hong Kong'},
|
||||
{'code': 'JP', 'name': 'Japan'},
|
||||
{'code': 'KR', 'name': 'South Korea'},
|
||||
{'code': 'IN', 'name': 'India'},
|
||||
{'code': 'PK', 'name': 'Pakistan'},
|
||||
{'code': 'BD', 'name': 'Bangladesh'},
|
||||
{'code': 'AE', 'name': 'United Arab Emirates'},
|
||||
{'code': 'SA', 'name': 'Saudi Arabia'},
|
||||
{'code': 'ZA', 'name': 'South Africa'},
|
||||
{'code': 'NG', 'name': 'Nigeria'},
|
||||
{'code': 'EG', 'name': 'Egypt'},
|
||||
{'code': 'KE', 'name': 'Kenya'},
|
||||
{'code': 'BR', 'name': 'Brazil'},
|
||||
{'code': 'MX', 'name': 'Mexico'},
|
||||
{'code': 'AR', 'name': 'Argentina'},
|
||||
{'code': 'CL', 'name': 'Chile'},
|
||||
{'code': 'CO', 'name': 'Colombia'},
|
||||
{'code': 'PE', 'name': 'Peru'},
|
||||
{'code': 'MY', 'name': 'Malaysia'},
|
||||
{'code': 'TH', 'name': 'Thailand'},
|
||||
{'code': 'VN', 'name': 'Vietnam'},
|
||||
{'code': 'PH', 'name': 'Philippines'},
|
||||
{'code': 'ID', 'name': 'Indonesia'},
|
||||
{'code': 'TR', 'name': 'Turkey'},
|
||||
{'code': 'RU', 'name': 'Russia'},
|
||||
{'code': 'UA', 'name': 'Ukraine'},
|
||||
{'code': 'RO', 'name': 'Romania'},
|
||||
{'code': 'GR', 'name': 'Greece'},
|
||||
{'code': 'IL', 'name': 'Israel'},
|
||||
{'code': 'TW', 'name': 'Taiwan'},
|
||||
]
|
||||
# Sort alphabetically by name
|
||||
countries.sort(key=lambda x: x['name'])
|
||||
return Response({'countries': countries})
|
||||
|
||||
|
||||
@extend_schema(exclude=True) # Exclude from public API documentation - internal authenticated endpoint
|
||||
class MeView(APIView):
|
||||
"""Get current user information."""
|
||||
@@ -395,12 +610,86 @@ class MeView(APIView):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=['Authentication'],
|
||||
summary='Unsubscribe from Emails',
|
||||
description='Unsubscribe a user from marketing, billing, or all email notifications'
|
||||
)
|
||||
class UnsubscribeView(APIView):
|
||||
"""Handle email unsubscribe requests with signed URLs."""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
Process unsubscribe request.
|
||||
|
||||
Expected payload:
|
||||
- email: The email address to unsubscribe
|
||||
- type: Type of emails to unsubscribe from (marketing, billing, all)
|
||||
- ts: Timestamp from signed URL
|
||||
- sig: HMAC signature from signed URL
|
||||
"""
|
||||
from igny8_core.business.billing.services.email_service import verify_unsubscribe_signature
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
email = request.data.get('email')
|
||||
email_type = request.data.get('type', 'all')
|
||||
timestamp = request.data.get('ts')
|
||||
signature = request.data.get('sig')
|
||||
|
||||
# Validate required fields
|
||||
if not email or not timestamp or not signature:
|
||||
return error_response(
|
||||
error='Missing required parameters',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
try:
|
||||
timestamp = int(timestamp)
|
||||
except (ValueError, TypeError):
|
||||
return error_response(
|
||||
error='Invalid timestamp',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Verify signature
|
||||
if not verify_unsubscribe_signature(email, email_type, timestamp, signature):
|
||||
return error_response(
|
||||
error='Invalid or expired unsubscribe link',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Log the unsubscribe request
|
||||
# In production, update user preferences or use email provider's suppression list
|
||||
logger.info(f'Unsubscribe request processed: email={email}, type={email_type}')
|
||||
|
||||
# TODO: Implement preference storage
|
||||
# Options:
|
||||
# 1. Add email preference fields to User model
|
||||
# 2. Use Resend's suppression list API
|
||||
# 3. Create EmailPreferences model
|
||||
|
||||
return success_response(
|
||||
message=f'Successfully unsubscribed from {email_type} emails',
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
|
||||
path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'),
|
||||
path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'),
|
||||
path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
|
||||
path('password-reset/', csrf_exempt(PasswordResetRequestView.as_view()), name='auth-password-reset-request'),
|
||||
path('password-reset/confirm/', csrf_exempt(PasswordResetConfirmView.as_view()), name='auth-password-reset-confirm'),
|
||||
path('me/', MeView.as_view(), name='auth-me'),
|
||||
path('countries/', CountryListView.as_view(), name='auth-countries'),
|
||||
path('unsubscribe/', csrf_exempt(UnsubscribeView.as_view()), name='auth-unsubscribe'),
|
||||
]
|
||||
|
||||
|
||||
@@ -1267,16 +1267,21 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
expires_at=expires_at
|
||||
)
|
||||
|
||||
# Send email (async via Celery if available, otherwise sync)
|
||||
# Send password reset email using the email service
|
||||
try:
|
||||
from igny8_core.modules.system.tasks import send_password_reset_email
|
||||
send_password_reset_email.delay(user.id, token)
|
||||
except:
|
||||
# Fallback to sync email sending
|
||||
from igny8_core.business.billing.services.email_service import send_password_reset_email
|
||||
send_password_reset_email(user, token)
|
||||
except Exception as e:
|
||||
# Fallback to Django's send_mail if email service fails
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to send password reset email via email service: {e}")
|
||||
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
|
||||
reset_url = f"{request.scheme}://{request.get_host()}/reset-password?token={token}"
|
||||
frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com')
|
||||
reset_url = f"{frontend_url}/reset-password?token={token}"
|
||||
|
||||
send_mail(
|
||||
subject='Reset Your IGNY8 Password',
|
||||
|
||||
@@ -9,14 +9,18 @@ from django.contrib import messages
|
||||
from django.utils.html import format_html
|
||||
from unfold.admin import ModelAdmin
|
||||
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
|
||||
from .models import (
|
||||
CreditCostConfig,
|
||||
AccountPaymentMethod,
|
||||
Invoice,
|
||||
Payment,
|
||||
CreditPackage,
|
||||
PaymentMethodConfig,
|
||||
)
|
||||
# NOTE: Most billing models are now registered in modules/billing/admin.py
|
||||
# This file is kept for reference but all registrations are commented out
|
||||
# to avoid AlreadyRegistered errors
|
||||
|
||||
# from .models import (
|
||||
# CreditCostConfig,
|
||||
# AccountPaymentMethod,
|
||||
# Invoice,
|
||||
# Payment,
|
||||
# CreditPackage,
|
||||
# PaymentMethodConfig,
|
||||
# )
|
||||
|
||||
|
||||
# CreditCostConfig - DUPLICATE - Registered in modules/billing/admin.py with better features
|
||||
@@ -47,97 +51,21 @@ from .models import (
|
||||
# ...existing implementation...
|
||||
|
||||
|
||||
# PaymentMethodConfig and AccountPaymentMethod are kept here as they're not duplicated
|
||||
# or have minimal implementations that don't conflict
|
||||
# AccountPaymentMethod - DUPLICATE - Registered in modules/billing/admin.py with AccountAdminMixin
|
||||
# Commenting out to avoid AlreadyRegistered error
|
||||
# The version in modules/billing/admin.py is preferred as it includes AccountAdminMixin
|
||||
|
||||
from import_export.admin import ExportMixin
|
||||
from import_export import resources
|
||||
|
||||
|
||||
class AccountPaymentMethodResource(resources.ModelResource):
|
||||
"""Resource class for exporting Account Payment Methods"""
|
||||
class Meta:
|
||||
model = AccountPaymentMethod
|
||||
fields = ('id', 'display_name', 'type', 'account__name', 'is_default',
|
||||
'is_enabled', 'is_verified', 'country_code', 'created_at')
|
||||
export_order = fields
|
||||
|
||||
|
||||
@admin.register(AccountPaymentMethod)
|
||||
class AccountPaymentMethodAdmin(ExportMixin, Igny8ModelAdmin):
|
||||
resource_class = AccountPaymentMethodResource
|
||||
list_display = [
|
||||
'display_name',
|
||||
'type',
|
||||
'account',
|
||||
'is_default',
|
||||
'is_enabled',
|
||||
'country_code',
|
||||
'is_verified',
|
||||
'updated_at',
|
||||
]
|
||||
list_filter = ['type', 'is_default', 'is_enabled', 'is_verified', 'country_code']
|
||||
search_fields = ['display_name', 'account__name', 'account__id']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
actions = [
|
||||
'bulk_enable',
|
||||
'bulk_disable',
|
||||
'bulk_set_default',
|
||||
'bulk_delete_methods',
|
||||
]
|
||||
fieldsets = (
|
||||
('Payment Method', {
|
||||
'fields': ('account', 'type', 'display_name', 'is_default', 'is_enabled', 'is_verified', 'country_code')
|
||||
}),
|
||||
('Instructions / Metadata', {
|
||||
'fields': ('instructions', 'metadata')
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
def bulk_enable(self, request, queryset):
|
||||
updated = queryset.update(is_enabled=True)
|
||||
self.message_user(request, f'{updated} payment method(s) enabled.', messages.SUCCESS)
|
||||
bulk_enable.short_description = 'Enable selected payment methods'
|
||||
|
||||
def bulk_disable(self, request, queryset):
|
||||
updated = queryset.update(is_enabled=False)
|
||||
self.message_user(request, f'{updated} payment method(s) disabled.', messages.SUCCESS)
|
||||
bulk_disable.short_description = 'Disable selected payment methods'
|
||||
|
||||
def bulk_set_default(self, request, queryset):
|
||||
from django import forms
|
||||
|
||||
if 'apply' in request.POST:
|
||||
method_id = request.POST.get('payment_method')
|
||||
if method_id:
|
||||
method = AccountPaymentMethod.objects.get(pk=method_id)
|
||||
# Unset all others for this account
|
||||
AccountPaymentMethod.objects.filter(account=method.account).update(is_default=False)
|
||||
method.is_default = True
|
||||
method.save()
|
||||
self.message_user(request, f'{method.display_name} set as default for {method.account.name}.', messages.SUCCESS)
|
||||
return
|
||||
|
||||
class PaymentMethodForm(forms.Form):
|
||||
payment_method = forms.ModelChoiceField(
|
||||
queryset=queryset,
|
||||
label="Select Payment Method to Set as Default"
|
||||
)
|
||||
|
||||
from django.shortcuts import render
|
||||
return render(request, 'admin/bulk_action_form.html', {
|
||||
'title': 'Set Default Payment Method',
|
||||
'queryset': queryset,
|
||||
'form': PaymentMethodForm(),
|
||||
'action': 'bulk_set_default',
|
||||
})
|
||||
bulk_set_default.short_description = 'Set as default'
|
||||
|
||||
def bulk_delete_methods(self, request, queryset):
|
||||
count = queryset.count()
|
||||
queryset.delete()
|
||||
self.message_user(request, f'{count} payment method(s) deleted.', messages.SUCCESS)
|
||||
bulk_delete_methods.short_description = 'Delete selected payment methods'
|
||||
# from import_export.admin import ExportMixin
|
||||
# from import_export import resources
|
||||
#
|
||||
# class AccountPaymentMethodResource(resources.ModelResource):
|
||||
# """Resource class for exporting Account Payment Methods"""
|
||||
# class Meta:
|
||||
# model = AccountPaymentMethod
|
||||
# fields = ('id', 'display_name', 'type', 'account__name', 'is_default',
|
||||
# 'is_enabled', 'is_verified', 'country_code', 'created_at')
|
||||
# export_order = fields
|
||||
#
|
||||
# @admin.register(AccountPaymentMethod)
|
||||
# class AccountPaymentMethodAdmin(ExportMixin, Igny8ModelAdmin):
|
||||
# ... (see modules/billing/admin.py for active registration)
|
||||
@@ -192,19 +192,32 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
@action(detail=False, methods=['get'], url_path='payment-methods', permission_classes=[AllowAny])
|
||||
def list_payment_methods(self, request):
|
||||
"""
|
||||
Get available payment methods (global only).
|
||||
Get available payment methods filtered by country code.
|
||||
Public endpoint - only returns enabled payment methods.
|
||||
Does not expose sensitive configuration details.
|
||||
|
||||
Note: Country-specific filtering has been removed per Phase 1.1.2.
|
||||
The country_code field is retained for future use but currently ignored.
|
||||
All enabled payment methods are returned regardless of country_code value.
|
||||
|
||||
Query Parameters:
|
||||
- country_code: ISO 2-letter country code (e.g., 'US', 'PK')
|
||||
|
||||
Returns methods for:
|
||||
1. Specified country (country_code=XX)
|
||||
2. Global methods (country_code='*')
|
||||
"""
|
||||
# Return all enabled payment methods (global approach - no country filtering)
|
||||
# Country-specific filtering removed per Task 1.1.2 of Master Implementation Plan
|
||||
methods = PaymentMethodConfig.objects.filter(
|
||||
is_enabled=True
|
||||
).order_by('sort_order')
|
||||
country_code = request.query_params.get('country_code', '').upper()
|
||||
|
||||
if country_code:
|
||||
# Filter by specific country OR global methods
|
||||
methods = PaymentMethodConfig.objects.filter(
|
||||
is_enabled=True
|
||||
).filter(
|
||||
Q(country_code=country_code) | Q(country_code='*')
|
||||
).order_by('sort_order')
|
||||
else:
|
||||
# No country specified - return only global methods
|
||||
methods = PaymentMethodConfig.objects.filter(
|
||||
is_enabled=True,
|
||||
country_code='*'
|
||||
).order_by('sort_order')
|
||||
|
||||
# Serialize using the proper serializer
|
||||
serializer = PaymentMethodConfigSerializer(methods, many=True)
|
||||
@@ -606,7 +619,7 @@ class BillingViewSet(viewsets.GenericViewSet):
|
||||
|
||||
class InvoiceViewSet(AccountModelViewSet):
|
||||
"""ViewSet for user-facing invoices"""
|
||||
queryset = Invoice.objects.all().select_related('account')
|
||||
queryset = Invoice.objects.all().select_related('account', 'subscription', 'subscription__plan')
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
pagination_class = CustomPageNumberPagination
|
||||
|
||||
@@ -617,6 +630,43 @@ class InvoiceViewSet(AccountModelViewSet):
|
||||
queryset = queryset.filter(account=self.request.account)
|
||||
return queryset.order_by('-invoice_date', '-created_at')
|
||||
|
||||
def _serialize_invoice(self, invoice):
|
||||
"""Serialize an invoice with all needed fields"""
|
||||
# Build subscription data if exists
|
||||
subscription_data = None
|
||||
if invoice.subscription:
|
||||
plan_data = None
|
||||
if invoice.subscription.plan:
|
||||
plan_data = {
|
||||
'id': invoice.subscription.plan.id,
|
||||
'name': invoice.subscription.plan.name,
|
||||
'slug': invoice.subscription.plan.slug,
|
||||
}
|
||||
subscription_data = {
|
||||
'id': invoice.subscription.id,
|
||||
'plan': plan_data,
|
||||
}
|
||||
|
||||
return {
|
||||
'id': invoice.id,
|
||||
'invoice_number': invoice.invoice_number,
|
||||
'status': invoice.status,
|
||||
'total': str(invoice.total), # Alias for compatibility
|
||||
'total_amount': str(invoice.total),
|
||||
'subtotal': str(invoice.subtotal),
|
||||
'tax_amount': str(invoice.tax),
|
||||
'currency': invoice.currency,
|
||||
'invoice_date': invoice.invoice_date.isoformat(),
|
||||
'due_date': invoice.due_date.isoformat(),
|
||||
'paid_at': invoice.paid_at.isoformat() if invoice.paid_at else None,
|
||||
'line_items': invoice.line_items,
|
||||
'billing_email': invoice.billing_email,
|
||||
'notes': invoice.notes,
|
||||
'payment_method': invoice.payment_method,
|
||||
'subscription': subscription_data,
|
||||
'created_at': invoice.created_at.isoformat(),
|
||||
}
|
||||
|
||||
def list(self, request):
|
||||
"""List invoices for current account"""
|
||||
queryset = self.get_queryset()
|
||||
@@ -630,25 +680,7 @@ class InvoiceViewSet(AccountModelViewSet):
|
||||
page = paginator.paginate_queryset(queryset, request)
|
||||
|
||||
# Serialize invoice data
|
||||
results = []
|
||||
for invoice in (page if page is not None else []):
|
||||
results.append({
|
||||
'id': invoice.id,
|
||||
'invoice_number': invoice.invoice_number,
|
||||
'status': invoice.status,
|
||||
'total': str(invoice.total), # Alias for compatibility
|
||||
'total_amount': str(invoice.total),
|
||||
'subtotal': str(invoice.subtotal),
|
||||
'tax_amount': str(invoice.tax),
|
||||
'currency': invoice.currency,
|
||||
'invoice_date': invoice.invoice_date.isoformat(),
|
||||
'due_date': invoice.due_date.isoformat(),
|
||||
'paid_at': invoice.paid_at.isoformat() if invoice.paid_at else None,
|
||||
'line_items': invoice.line_items,
|
||||
'billing_email': invoice.billing_email,
|
||||
'notes': invoice.notes,
|
||||
'created_at': invoice.created_at.isoformat(),
|
||||
})
|
||||
results = [self._serialize_invoice(invoice) for invoice in (page if page is not None else [])]
|
||||
|
||||
return paginated_response(
|
||||
{'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results},
|
||||
@@ -659,24 +691,7 @@ class InvoiceViewSet(AccountModelViewSet):
|
||||
"""Get invoice detail"""
|
||||
try:
|
||||
invoice = self.get_queryset().get(pk=pk)
|
||||
data = {
|
||||
'id': invoice.id,
|
||||
'invoice_number': invoice.invoice_number,
|
||||
'status': invoice.status,
|
||||
'total': str(invoice.total), # Alias for compatibility
|
||||
'total_amount': str(invoice.total),
|
||||
'subtotal': str(invoice.subtotal),
|
||||
'tax_amount': str(invoice.tax),
|
||||
'currency': invoice.currency,
|
||||
'invoice_date': invoice.invoice_date.isoformat(),
|
||||
'due_date': invoice.due_date.isoformat(),
|
||||
'paid_at': invoice.paid_at.isoformat() if invoice.paid_at else None,
|
||||
'line_items': invoice.line_items,
|
||||
'billing_email': invoice.billing_email,
|
||||
'notes': invoice.notes,
|
||||
'created_at': invoice.created_at.isoformat(),
|
||||
}
|
||||
return success_response(data=data, request=request)
|
||||
return success_response(data=self._serialize_invoice(invoice), request=request)
|
||||
except Invoice.DoesNotExist:
|
||||
return error_response(error='Invoice not found', status_code=404, request=request)
|
||||
|
||||
@@ -684,14 +699,38 @@ class InvoiceViewSet(AccountModelViewSet):
|
||||
def download_pdf(self, request, pk=None):
|
||||
"""Download invoice PDF"""
|
||||
try:
|
||||
invoice = self.get_queryset().get(pk=pk)
|
||||
invoice = self.get_queryset().select_related(
|
||||
'account', 'account__owner', 'subscription', 'subscription__plan'
|
||||
).get(pk=pk)
|
||||
pdf_bytes = InvoiceService.generate_pdf(invoice)
|
||||
|
||||
# Build descriptive filename
|
||||
plan_name = ''
|
||||
if invoice.subscription and invoice.subscription.plan:
|
||||
plan_name = invoice.subscription.plan.name.replace(' ', '-')
|
||||
elif invoice.metadata and 'plan_name' in invoice.metadata:
|
||||
plan_name = invoice.metadata.get('plan_name', '').replace(' ', '-')
|
||||
|
||||
date_str = invoice.invoice_date.strftime('%Y-%m-%d') if invoice.invoice_date else ''
|
||||
|
||||
filename_parts = ['IGNY8', 'Invoice', invoice.invoice_number]
|
||||
if plan_name:
|
||||
filename_parts.append(plan_name)
|
||||
if date_str:
|
||||
filename_parts.append(date_str)
|
||||
|
||||
filename = '-'.join(filename_parts) + '.pdf'
|
||||
|
||||
response = HttpResponse(pdf_bytes, content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="invoice-{invoice.invoice_number}.pdf"'
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
except Invoice.DoesNotExist:
|
||||
return error_response(error='Invoice not found', status_code=404, request=request)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'PDF generation failed for invoice {pk}: {str(e)}', exc_info=True)
|
||||
return error_response(error=f'Failed to generate PDF: {str(e)}', status_code=500, request=request)
|
||||
|
||||
|
||||
class PaymentViewSet(AccountModelViewSet):
|
||||
@@ -766,6 +805,7 @@ class PaymentViewSet(AccountModelViewSet):
|
||||
payment_method = request.data.get('payment_method', 'bank_transfer')
|
||||
reference = request.data.get('reference', '')
|
||||
notes = request.data.get('notes', '')
|
||||
currency = request.data.get('currency', 'USD')
|
||||
|
||||
if not amount:
|
||||
return error_response(error='Amount is required', status_code=400, request=request)
|
||||
@@ -775,18 +815,30 @@ class PaymentViewSet(AccountModelViewSet):
|
||||
invoice = None
|
||||
if invoice_id:
|
||||
invoice = Invoice.objects.get(id=invoice_id, account=account)
|
||||
# Use invoice currency if not explicitly provided
|
||||
if not request.data.get('currency') and invoice:
|
||||
currency = invoice.currency
|
||||
|
||||
payment = Payment.objects.create(
|
||||
account=account,
|
||||
invoice=invoice,
|
||||
amount=amount,
|
||||
currency='USD',
|
||||
currency=currency,
|
||||
payment_method=payment_method,
|
||||
status='pending_approval',
|
||||
manual_reference=reference,
|
||||
manual_notes=notes,
|
||||
)
|
||||
|
||||
# Send payment confirmation email
|
||||
try:
|
||||
from igny8_core.business.billing.services.email_service import BillingEmailService
|
||||
BillingEmailService.send_payment_confirmation_email(payment, account)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Failed to send payment confirmation email: {str(e)}')
|
||||
|
||||
return success_response(
|
||||
data={'id': payment.id, 'status': payment.status},
|
||||
message='Manual payment submitted for approval',
|
||||
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
Management command to backfill usage tracking for existing content.
|
||||
Usage: python manage.py backfill_usage [account_id]
|
||||
|
||||
NOTE: Since the simplification of limits (Jan 2026), this command only
|
||||
tracks Ahrefs queries. All other usage is tracked via CreditUsageLog.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.apps import apps
|
||||
@@ -9,7 +12,7 @@ from igny8_core.auth.models import Account
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Backfill usage tracking for existing content'
|
||||
help = 'Backfill usage tracking for existing content (Ahrefs queries only)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
@@ -30,10 +33,6 @@ class Command(BaseCommand):
|
||||
else:
|
||||
accounts = Account.objects.filter(plan__isnull=False).select_related('plan')
|
||||
|
||||
ContentIdeas = apps.get_model('planner', 'ContentIdeas')
|
||||
Content = apps.get_model('writer', 'Content')
|
||||
Images = apps.get_model('writer', 'Images')
|
||||
|
||||
total_accounts = accounts.count()
|
||||
self.stdout.write(f'Processing {total_accounts} account(s)...\n')
|
||||
|
||||
@@ -43,45 +42,14 @@ class Command(BaseCommand):
|
||||
self.stdout.write(f'Plan: {account.plan.name if account.plan else "No Plan"}')
|
||||
self.stdout.write('=' * 60)
|
||||
|
||||
# Count content ideas
|
||||
ideas_count = ContentIdeas.objects.filter(account=account).count()
|
||||
self.stdout.write(f'Content Ideas: {ideas_count}')
|
||||
# Ahrefs queries are tracked in CreditUsageLog with operation_type='ahrefs_query'
|
||||
# We don't backfill these as they should be tracked in real-time going forward
|
||||
# This command is primarily for verification
|
||||
|
||||
# Count content words
|
||||
from django.db.models import Sum
|
||||
total_words = Content.objects.filter(account=account).aggregate(
|
||||
total=Sum('word_count')
|
||||
)['total'] or 0
|
||||
self.stdout.write(f'Content Words: {total_words}')
|
||||
|
||||
# Count images
|
||||
total_images = Images.objects.filter(account=account).count()
|
||||
images_with_prompts = Images.objects.filter(
|
||||
account=account, prompt__isnull=False
|
||||
).exclude(prompt='').count()
|
||||
self.stdout.write(f'Total Images: {total_images}')
|
||||
self.stdout.write(f'Images with Prompts: {images_with_prompts}')
|
||||
|
||||
# Update account usage fields
|
||||
with transaction.atomic():
|
||||
account.usage_content_ideas = ideas_count
|
||||
account.usage_content_words = total_words
|
||||
account.usage_images_basic = total_images
|
||||
account.usage_images_premium = 0 # Premium not implemented yet
|
||||
account.usage_image_prompts = images_with_prompts
|
||||
account.save(update_fields=[
|
||||
'usage_content_ideas', 'usage_content_words',
|
||||
'usage_images_basic', 'usage_images_premium', 'usage_image_prompts',
|
||||
'updated_at'
|
||||
])
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('\n✅ Updated usage tracking:'))
|
||||
self.stdout.write(f' usage_content_ideas: {account.usage_content_ideas}')
|
||||
self.stdout.write(f' usage_content_words: {account.usage_content_words}')
|
||||
self.stdout.write(f' usage_images_basic: {account.usage_images_basic}')
|
||||
self.stdout.write(f' usage_images_premium: {account.usage_images_premium}')
|
||||
self.stdout.write(f' usage_image_prompts: {account.usage_image_prompts}\n')
|
||||
self.stdout.write(f'Ahrefs queries used this month: {account.usage_ahrefs_queries}')
|
||||
self.stdout.write(self.style.SUCCESS('\n✅ Verified usage tracking'))
|
||||
self.stdout.write(f' usage_ahrefs_queries: {account.usage_ahrefs_queries}\n')
|
||||
|
||||
self.stdout.write('=' * 60)
|
||||
self.stdout.write(self.style.SUCCESS('✅ Backfill complete!'))
|
||||
self.stdout.write(self.style.SUCCESS('✅ Verification complete!'))
|
||||
self.stdout.write('=' * 60)
|
||||
|
||||
@@ -114,65 +114,48 @@ class CreditUsageLog(AccountBaseModel):
|
||||
|
||||
class CreditCostConfig(models.Model):
|
||||
"""
|
||||
Token-based credit pricing configuration.
|
||||
ALL operations use token-to-credit conversion.
|
||||
Fixed credit costs per operation type.
|
||||
|
||||
Per final-model-schemas.md:
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| operation_type | CharField(50) PK | Yes | Unique operation ID |
|
||||
| display_name | CharField(100) | Yes | Human-readable |
|
||||
| base_credits | IntegerField | Yes | Fixed credits per operation |
|
||||
| is_active | BooleanField | Yes | Enable/disable |
|
||||
| description | TextField | No | Admin notes |
|
||||
"""
|
||||
# Operation identification
|
||||
# Operation identification (Primary Key)
|
||||
operation_type = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
choices=CreditUsageLog.OPERATION_TYPE_CHOICES,
|
||||
help_text="AI operation type"
|
||||
primary_key=True,
|
||||
help_text="Unique operation ID (e.g., 'article_generation', 'image_generation')"
|
||||
)
|
||||
|
||||
# Token-to-credit ratio (tokens per 1 credit)
|
||||
tokens_per_credit = models.IntegerField(
|
||||
default=100,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Number of tokens that equal 1 credit (e.g., 100 tokens = 1 credit)"
|
||||
# Human-readable name
|
||||
display_name = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Human-readable name"
|
||||
)
|
||||
|
||||
# Minimum credits (for very small token usage)
|
||||
min_credits = models.IntegerField(
|
||||
# Fixed credits per operation
|
||||
base_credits = models.IntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text="Minimum credits to charge regardless of token usage"
|
||||
help_text="Fixed credits per operation"
|
||||
)
|
||||
|
||||
# Price per credit (for revenue reporting)
|
||||
price_per_credit_usd = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
default=Decimal('0.01'),
|
||||
validators=[MinValueValidator(Decimal('0.0001'))],
|
||||
help_text="USD price per credit (for revenue reporting)"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
display_name = models.CharField(max_length=100, help_text="Human-readable name")
|
||||
description = models.TextField(blank=True, help_text="What this operation does")
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(default=True, help_text="Enable/disable this operation")
|
||||
|
||||
|
||||
# Audit fields
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='credit_cost_updates',
|
||||
help_text="Admin who last updated"
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Enable/disable this operation"
|
||||
)
|
||||
|
||||
# Change tracking
|
||||
previous_tokens_per_credit = models.IntegerField(
|
||||
null=True,
|
||||
# Admin notes
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Tokens per credit before last update (for audit trail)"
|
||||
help_text="Admin notes about this operation"
|
||||
)
|
||||
|
||||
# History tracking
|
||||
@@ -186,18 +169,7 @@ class CreditCostConfig(models.Model):
|
||||
ordering = ['operation_type']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.display_name} - {self.tokens_per_credit} tokens/credit"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Track token ratio changes
|
||||
if self.pk:
|
||||
try:
|
||||
old = CreditCostConfig.objects.get(pk=self.pk)
|
||||
if old.tokens_per_credit != self.tokens_per_credit:
|
||||
self.previous_tokens_per_credit = old.tokens_per_credit
|
||||
except CreditCostConfig.DoesNotExist:
|
||||
pass
|
||||
super().save(*args, **kwargs)
|
||||
return f"{self.display_name} - {self.base_credits} credits"
|
||||
|
||||
|
||||
class BillingConfiguration(models.Model):
|
||||
@@ -426,6 +398,20 @@ class Invoice(AccountBaseModel):
|
||||
def tax_amount(self):
|
||||
return self.tax
|
||||
|
||||
@property
|
||||
def tax_rate(self):
|
||||
"""Get tax rate from metadata if stored"""
|
||||
if self.metadata and 'tax_rate' in self.metadata:
|
||||
return self.metadata['tax_rate']
|
||||
return 0
|
||||
|
||||
@property
|
||||
def discount_amount(self):
|
||||
"""Get discount amount from metadata if stored"""
|
||||
if self.metadata and 'discount_amount' in self.metadata:
|
||||
return self.metadata['discount_amount']
|
||||
return 0
|
||||
|
||||
@property
|
||||
def total_amount(self):
|
||||
return self.total
|
||||
@@ -515,6 +501,7 @@ class Payment(AccountBaseModel):
|
||||
manual_reference = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Bank transfer reference, wallet transaction ID, etc."
|
||||
)
|
||||
manual_notes = models.TextField(blank=True, help_text="Admin notes for manual payments")
|
||||
@@ -554,9 +541,24 @@ class Payment(AccountBaseModel):
|
||||
models.Index(fields=['account', 'payment_method']),
|
||||
models.Index(fields=['invoice', 'status']),
|
||||
]
|
||||
constraints = [
|
||||
# Ensure manual_reference is unique when not null/empty
|
||||
# This prevents duplicate bank transfer references
|
||||
models.UniqueConstraint(
|
||||
fields=['manual_reference'],
|
||||
name='unique_manual_reference_when_not_null',
|
||||
condition=models.Q(manual_reference__isnull=False) & ~models.Q(manual_reference='')
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Payment {self.id} - {self.get_payment_method_display()} - {self.amount} {self.currency}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Normalize empty manual_reference to NULL for proper uniqueness handling"""
|
||||
if self.manual_reference == '':
|
||||
self.manual_reference = None
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class CreditPackage(models.Model):
|
||||
@@ -606,8 +608,10 @@ class CreditPackage(models.Model):
|
||||
|
||||
class PaymentMethodConfig(models.Model):
|
||||
"""
|
||||
Configure payment methods availability per country
|
||||
Allows enabling/disabling manual payments by region
|
||||
Configure payment methods availability per country.
|
||||
|
||||
For online payments (stripe, paypal): Credentials stored in IntegrationProvider.
|
||||
For manual payments (bank_transfer, local_wallet): Bank/wallet details stored here.
|
||||
"""
|
||||
# Use centralized choices
|
||||
PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
|
||||
@@ -615,7 +619,7 @@ class PaymentMethodConfig(models.Model):
|
||||
country_code = models.CharField(
|
||||
max_length=2,
|
||||
db_index=True,
|
||||
help_text="ISO 2-letter country code (e.g., US, GB, IN)"
|
||||
help_text="ISO 2-letter country code (e.g., US, GB, PK) or '*' for global"
|
||||
)
|
||||
payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES)
|
||||
is_enabled = models.BooleanField(default=True)
|
||||
@@ -624,21 +628,17 @@ class PaymentMethodConfig(models.Model):
|
||||
display_name = models.CharField(max_length=100, blank=True)
|
||||
instructions = models.TextField(blank=True, help_text="Payment instructions for users")
|
||||
|
||||
# Manual payment details (for bank_transfer/local_wallet)
|
||||
# Manual payment details (for bank_transfer only)
|
||||
bank_name = models.CharField(max_length=255, blank=True)
|
||||
account_number = models.CharField(max_length=255, blank=True)
|
||||
routing_number = models.CharField(max_length=255, blank=True)
|
||||
swift_code = models.CharField(max_length=255, blank=True)
|
||||
account_title = models.CharField(max_length=255, blank=True, help_text="Account holder name")
|
||||
routing_number = models.CharField(max_length=255, blank=True, help_text="Routing/Sort code")
|
||||
swift_code = models.CharField(max_length=255, blank=True, help_text="SWIFT/BIC code for international")
|
||||
iban = models.CharField(max_length=255, blank=True, help_text="IBAN for international transfers")
|
||||
|
||||
# Additional fields for local wallets
|
||||
wallet_type = models.CharField(max_length=100, blank=True, help_text="E.g., PayTM, PhonePe, etc.")
|
||||
wallet_id = models.CharField(max_length=255, blank=True)
|
||||
|
||||
# Webhook configuration (Stripe/PayPal)
|
||||
webhook_url = models.URLField(blank=True, help_text="Webhook URL for payment gateway callbacks")
|
||||
webhook_secret = models.CharField(max_length=255, blank=True, help_text="Webhook secret for signature verification")
|
||||
api_key = models.CharField(max_length=255, blank=True, help_text="API key for payment gateway integration")
|
||||
api_secret = models.CharField(max_length=255, blank=True, help_text="API secret for payment gateway integration")
|
||||
wallet_type = models.CharField(max_length=100, blank=True, help_text="E.g., JazzCash, EasyPaisa, etc.")
|
||||
wallet_id = models.CharField(max_length=255, blank=True, help_text="Mobile number or wallet ID")
|
||||
|
||||
# Order/priority
|
||||
sort_order = models.IntegerField(default=0)
|
||||
@@ -696,18 +696,34 @@ class AccountPaymentMethod(AccountBaseModel):
|
||||
|
||||
class AIModelConfig(models.Model):
|
||||
"""
|
||||
AI Model Configuration - Database-driven model pricing and capabilities.
|
||||
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES from constants.py
|
||||
All AI models (text + image) with pricing and credit configuration.
|
||||
Single Source of Truth for Models.
|
||||
|
||||
Two pricing models:
|
||||
- Text models: Cost per 1M tokens (input/output), credits calculated AFTER AI call
|
||||
- Image models: Cost per image, credits calculated BEFORE AI call
|
||||
Per final-model-schemas.md:
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| id | AutoField PK | Auto | |
|
||||
| model_name | CharField(100) | Yes | gpt-5.1, dall-e-3, runware:97@1 |
|
||||
| model_type | CharField(20) | Yes | text / image |
|
||||
| provider | CharField(50) | Yes | Links to IntegrationProvider |
|
||||
| display_name | CharField(200) | Yes | Human-readable |
|
||||
| is_default | BooleanField | Yes | One default per type |
|
||||
| is_active | BooleanField | Yes | Enable/disable |
|
||||
| cost_per_1k_input | DecimalField | No | Provider cost (USD) - text models |
|
||||
| cost_per_1k_output | DecimalField | No | Provider cost (USD) - text models |
|
||||
| tokens_per_credit | IntegerField | No | Text: tokens per 1 credit (e.g., 1000) |
|
||||
| credits_per_image | IntegerField | No | Image: credits per image (e.g., 1, 5, 15) |
|
||||
| quality_tier | CharField(20) | No | basic / quality / premium |
|
||||
| max_tokens | IntegerField | No | Model token limit |
|
||||
| context_window | IntegerField | No | Model context size |
|
||||
| capabilities | JSONField | No | vision, function_calling, etc. |
|
||||
| created_at | DateTime | Auto | |
|
||||
| updated_at | DateTime | Auto | |
|
||||
"""
|
||||
|
||||
MODEL_TYPE_CHOICES = [
|
||||
('text', 'Text Generation'),
|
||||
('image', 'Image Generation'),
|
||||
('embedding', 'Embedding'),
|
||||
]
|
||||
|
||||
PROVIDER_CHOICES = [
|
||||
@@ -717,145 +733,112 @@ class AIModelConfig(models.Model):
|
||||
('google', 'Google'),
|
||||
]
|
||||
|
||||
QUALITY_TIER_CHOICES = [
|
||||
('basic', 'Basic'),
|
||||
('quality', 'Quality'),
|
||||
('premium', 'Premium'),
|
||||
]
|
||||
|
||||
# Basic Information
|
||||
model_name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text="Model identifier used in API calls (e.g., 'gpt-4o-mini', 'dall-e-3')"
|
||||
)
|
||||
|
||||
display_name = models.CharField(
|
||||
max_length=200,
|
||||
help_text="Human-readable name shown in UI (e.g., 'GPT-4o mini - Fast & Affordable')"
|
||||
help_text="Model identifier (e.g., 'gpt-5.1', 'dall-e-3', 'runware:97@1')"
|
||||
)
|
||||
|
||||
model_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=MODEL_TYPE_CHOICES,
|
||||
db_index=True,
|
||||
help_text="Type of model - determines which pricing fields are used"
|
||||
help_text="text / image"
|
||||
)
|
||||
|
||||
provider = models.CharField(
|
||||
max_length=50,
|
||||
choices=PROVIDER_CHOICES,
|
||||
db_index=True,
|
||||
help_text="AI provider (OpenAI, Anthropic, etc.)"
|
||||
help_text="Links to IntegrationProvider"
|
||||
)
|
||||
|
||||
# Text Model Pricing (Only for model_type='text')
|
||||
input_cost_per_1m = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal('0.0001'))],
|
||||
help_text="Cost per 1 million input tokens (USD). For text models only."
|
||||
)
|
||||
|
||||
output_cost_per_1m = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal('0.0001'))],
|
||||
help_text="Cost per 1 million output tokens (USD). For text models only."
|
||||
)
|
||||
|
||||
context_window = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum input tokens (context length). For text models only."
|
||||
)
|
||||
|
||||
max_output_tokens = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum output tokens per request. For text models only."
|
||||
)
|
||||
|
||||
# Image Model Pricing (Only for model_type='image')
|
||||
cost_per_image = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
null=True,
|
||||
blank=True,
|
||||
validators=[MinValueValidator(Decimal('0.0001'))],
|
||||
help_text="Fixed cost per image generation (USD). For image models only."
|
||||
)
|
||||
|
||||
valid_sizes = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Array of valid image sizes (e.g., ["1024x1024", "1024x1792"]). For image models only.'
|
||||
)
|
||||
|
||||
# Capabilities
|
||||
supports_json_mode = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True for models with JSON response format support"
|
||||
)
|
||||
|
||||
supports_vision = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True for models that can analyze images"
|
||||
)
|
||||
|
||||
supports_function_calling = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True for models with function calling capability"
|
||||
)
|
||||
|
||||
# Status & Configuration
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
db_index=True,
|
||||
help_text="Enable/disable model without deleting"
|
||||
display_name = models.CharField(
|
||||
max_length=200,
|
||||
help_text="Human-readable name"
|
||||
)
|
||||
|
||||
is_default = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Mark as default model for its type (only one per type)"
|
||||
help_text="One default per type"
|
||||
)
|
||||
|
||||
sort_order = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Control order in dropdown lists (lower numbers first)"
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
db_index=True,
|
||||
help_text="Enable/disable"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Admin notes about model usage, strengths, limitations"
|
||||
)
|
||||
|
||||
release_date = models.DateField(
|
||||
# Text Model Pricing (cost per 1K tokens)
|
||||
cost_per_1k_input = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=6,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When model was released/added"
|
||||
help_text="Provider cost per 1K input tokens (USD) - text models"
|
||||
)
|
||||
|
||||
deprecation_date = models.DateField(
|
||||
cost_per_1k_output = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=6,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When model will be removed"
|
||||
help_text="Provider cost per 1K output tokens (USD) - text models"
|
||||
)
|
||||
|
||||
# Audit Fields
|
||||
# Credit Configuration
|
||||
tokens_per_credit = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Text: tokens per 1 credit (e.g., 1000, 10000)"
|
||||
)
|
||||
|
||||
credits_per_image = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Image: credits per image (e.g., 1, 5, 15)"
|
||||
)
|
||||
|
||||
quality_tier = models.CharField(
|
||||
max_length=20,
|
||||
choices=QUALITY_TIER_CHOICES,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="basic / quality / premium - for image models"
|
||||
)
|
||||
|
||||
# Model Limits
|
||||
max_tokens = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Model token limit"
|
||||
)
|
||||
|
||||
context_window = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Model context size"
|
||||
)
|
||||
|
||||
# Capabilities
|
||||
capabilities = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Capabilities: vision, function_calling, json_mode, etc."
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='ai_model_updates',
|
||||
help_text="Admin who last updated"
|
||||
)
|
||||
|
||||
# History tracking
|
||||
history = HistoricalRecords()
|
||||
@@ -865,7 +848,7 @@ class AIModelConfig(models.Model):
|
||||
db_table = 'igny8_ai_model_config'
|
||||
verbose_name = 'AI Model Configuration'
|
||||
verbose_name_plural = 'AI Model Configurations'
|
||||
ordering = ['model_type', 'sort_order', 'model_name']
|
||||
ordering = ['model_type', 'model_name']
|
||||
indexes = [
|
||||
models.Index(fields=['model_type', 'is_active']),
|
||||
models.Index(fields=['provider', 'is_active']),
|
||||
@@ -878,52 +861,138 @@ class AIModelConfig(models.Model):
|
||||
def save(self, *args, **kwargs):
|
||||
"""Ensure only one is_default per model_type"""
|
||||
if self.is_default:
|
||||
# Unset other defaults for same model_type
|
||||
AIModelConfig.objects.filter(
|
||||
model_type=self.model_type,
|
||||
is_default=True
|
||||
).exclude(pk=self.pk).update(is_default=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_cost_for_tokens(self, input_tokens, output_tokens):
|
||||
"""Calculate cost for text models based on token usage"""
|
||||
if self.model_type != 'text':
|
||||
raise ValueError("get_cost_for_tokens only applies to text models")
|
||||
|
||||
if not self.input_cost_per_1m or not self.output_cost_per_1m:
|
||||
raise ValueError(f"Model {self.model_name} missing cost_per_1m values")
|
||||
|
||||
cost = (
|
||||
(Decimal(input_tokens) * self.input_cost_per_1m) +
|
||||
(Decimal(output_tokens) * self.output_cost_per_1m)
|
||||
) / Decimal('1000000')
|
||||
|
||||
return cost
|
||||
@classmethod
|
||||
def get_default_text_model(cls):
|
||||
"""Get the default text generation model"""
|
||||
return cls.objects.filter(model_type='text', is_default=True, is_active=True).first()
|
||||
|
||||
def get_cost_for_images(self, num_images):
|
||||
"""Calculate cost for image models"""
|
||||
if self.model_type != 'image':
|
||||
raise ValueError("get_cost_for_images only applies to image models")
|
||||
|
||||
if not self.cost_per_image:
|
||||
raise ValueError(f"Model {self.model_name} missing cost_per_image")
|
||||
|
||||
return self.cost_per_image * Decimal(num_images)
|
||||
@classmethod
|
||||
def get_default_image_model(cls):
|
||||
"""Get the default image generation model"""
|
||||
return cls.objects.filter(model_type='image', is_default=True, is_active=True).first()
|
||||
|
||||
def validate_size(self, size):
|
||||
"""Check if size is valid for this image model"""
|
||||
if self.model_type != 'image':
|
||||
raise ValueError("validate_size only applies to image models")
|
||||
|
||||
if not self.valid_sizes:
|
||||
return True # No size restrictions
|
||||
|
||||
return size in self.valid_sizes
|
||||
@classmethod
|
||||
def get_image_models_by_tier(cls):
|
||||
"""Get all active image models grouped by quality tier"""
|
||||
return cls.objects.filter(
|
||||
model_type='image',
|
||||
is_active=True
|
||||
).order_by('quality_tier', 'model_name')
|
||||
|
||||
|
||||
class WebhookEvent(models.Model):
|
||||
"""
|
||||
Store all incoming webhook events for audit and replay capability.
|
||||
|
||||
def get_display_with_pricing(self):
|
||||
"""For dropdowns: show model with pricing"""
|
||||
if self.model_type == 'text':
|
||||
return f"{self.display_name} - ${self.input_cost_per_1m}/${self.output_cost_per_1m} per 1M"
|
||||
elif self.model_type == 'image':
|
||||
return f"{self.display_name} - ${self.cost_per_image} per image"
|
||||
return self.display_name
|
||||
This model provides:
|
||||
- Audit trail of all webhook events
|
||||
- Idempotency verification (via event_id)
|
||||
- Ability to replay failed events
|
||||
- Debugging and monitoring
|
||||
"""
|
||||
PROVIDER_CHOICES = [
|
||||
('stripe', 'Stripe'),
|
||||
('paypal', 'PayPal'),
|
||||
]
|
||||
|
||||
# Unique identifier from the payment provider
|
||||
event_id = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text="Unique event ID from the payment provider"
|
||||
)
|
||||
|
||||
# Payment provider
|
||||
provider = models.CharField(
|
||||
max_length=20,
|
||||
choices=PROVIDER_CHOICES,
|
||||
db_index=True,
|
||||
help_text="Payment provider (stripe or paypal)"
|
||||
)
|
||||
|
||||
# Event type (e.g., 'checkout.session.completed', 'PAYMENT.CAPTURE.COMPLETED')
|
||||
event_type = models.CharField(
|
||||
max_length=100,
|
||||
db_index=True,
|
||||
help_text="Event type from the provider"
|
||||
)
|
||||
|
||||
# Full payload for debugging and replay
|
||||
payload = models.JSONField(
|
||||
help_text="Full webhook payload"
|
||||
)
|
||||
|
||||
# Processing status
|
||||
processed = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Whether this event has been successfully processed"
|
||||
)
|
||||
processed_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the event was processed"
|
||||
)
|
||||
|
||||
# Error tracking
|
||||
error_message = models.TextField(
|
||||
blank=True,
|
||||
help_text="Error message if processing failed"
|
||||
)
|
||||
retry_count = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Number of processing attempts"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'billing'
|
||||
db_table = 'igny8_webhook_events'
|
||||
verbose_name = 'Webhook Event'
|
||||
verbose_name_plural = 'Webhook Events'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['provider', 'event_type']),
|
||||
models.Index(fields=['processed', 'created_at']),
|
||||
models.Index(fields=['provider', 'processed']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.provider}:{self.event_type} - {self.event_id[:20]}..."
|
||||
|
||||
@classmethod
|
||||
def record_event(cls, event_id: str, provider: str, event_type: str, payload: dict):
|
||||
"""
|
||||
Record a webhook event. Returns (event, created) tuple.
|
||||
If the event already exists, returns the existing event.
|
||||
"""
|
||||
return cls.objects.get_or_create(
|
||||
event_id=event_id,
|
||||
defaults={
|
||||
'provider': provider,
|
||||
'event_type': event_type,
|
||||
'payload': payload,
|
||||
}
|
||||
)
|
||||
|
||||
def mark_processed(self):
|
||||
"""Mark the event as successfully processed"""
|
||||
from django.utils import timezone
|
||||
self.processed = True
|
||||
self.processed_at = timezone.now()
|
||||
self.save(update_fields=['processed', 'processed_at'])
|
||||
|
||||
def mark_failed(self, error_message: str):
|
||||
"""Mark the event as failed with error message"""
|
||||
self.error_message = error_message
|
||||
self.retry_count += 1
|
||||
self.save(update_fields=['error_message', 'retry_count'])
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Credit Service for managing credit transactions and deductions
|
||||
"""
|
||||
import math
|
||||
import logging
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
|
||||
@@ -8,10 +10,151 @@ from igny8_core.business.billing.constants import CREDIT_COSTS
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError, CreditCalculationError
|
||||
from igny8_core.auth.models import Account
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_low_credits_warning(account, previous_balance):
|
||||
"""
|
||||
Check if credits have fallen below threshold and send warning email.
|
||||
Only sends if this is the first time falling below threshold.
|
||||
"""
|
||||
try:
|
||||
from igny8_core.modules.system.email_models import EmailSettings
|
||||
from .email_service import BillingEmailService
|
||||
|
||||
settings = EmailSettings.get_settings()
|
||||
if not settings.send_low_credit_warnings:
|
||||
return
|
||||
|
||||
threshold = settings.low_credit_threshold
|
||||
|
||||
# Only send if we CROSSED below the threshold (wasn't already below)
|
||||
if account.credits < threshold <= previous_balance:
|
||||
logger.info(f"Credits fell below threshold for account {account.id}: {account.credits} < {threshold}")
|
||||
BillingEmailService.send_low_credits_warning(
|
||||
account=account,
|
||||
current_credits=account.credits,
|
||||
threshold=threshold
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check/send low credits warning: {e}")
|
||||
|
||||
|
||||
class CreditService:
|
||||
"""Service for managing credits - Token-based only"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_credits_for_image(model_name: str, num_images: int = 1) -> int:
|
||||
"""
|
||||
Calculate credits for image generation based on AIModelConfig.credits_per_image.
|
||||
|
||||
Args:
|
||||
model_name: The AI model name (e.g., 'dall-e-3', 'flux-1-1-pro')
|
||||
num_images: Number of images to generate
|
||||
|
||||
Returns:
|
||||
int: Credits required
|
||||
|
||||
Raises:
|
||||
CreditCalculationError: If model not found or has no credits_per_image
|
||||
"""
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
|
||||
try:
|
||||
model = AIModelConfig.objects.filter(
|
||||
model_name=model_name,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if not model:
|
||||
raise CreditCalculationError(f"Model {model_name} not found or inactive")
|
||||
|
||||
if model.credits_per_image is None:
|
||||
raise CreditCalculationError(
|
||||
f"Model {model_name} has no credits_per_image configured"
|
||||
)
|
||||
|
||||
credits = model.credits_per_image * num_images
|
||||
|
||||
logger.info(
|
||||
f"Calculated credits for {model_name}: "
|
||||
f"{num_images} images × {model.credits_per_image} = {credits} credits"
|
||||
)
|
||||
|
||||
return credits
|
||||
|
||||
except AIModelConfig.DoesNotExist:
|
||||
raise CreditCalculationError(f"Model {model_name} not found")
|
||||
|
||||
@staticmethod
|
||||
def calculate_credits_from_tokens_by_model(model_name: str, total_tokens: int) -> int:
|
||||
"""
|
||||
Calculate credits from token usage based on AIModelConfig.tokens_per_credit.
|
||||
|
||||
This is the model-specific version that uses the model's configured rate.
|
||||
For operation-based calculation, use calculate_credits_from_tokens().
|
||||
|
||||
Args:
|
||||
model_name: The AI model name (e.g., 'gpt-4o', 'claude-3-5-sonnet')
|
||||
total_tokens: Total tokens used (input + output)
|
||||
|
||||
Returns:
|
||||
int: Credits required (minimum 1)
|
||||
|
||||
Raises:
|
||||
CreditCalculationError: If model not found
|
||||
"""
|
||||
from igny8_core.business.billing.models import AIModelConfig, BillingConfiguration
|
||||
|
||||
try:
|
||||
model = AIModelConfig.objects.filter(
|
||||
model_name=model_name,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if model and model.tokens_per_credit:
|
||||
tokens_per_credit = model.tokens_per_credit
|
||||
else:
|
||||
# Fallback to global default
|
||||
billing_config = BillingConfiguration.get_config()
|
||||
tokens_per_credit = billing_config.default_tokens_per_credit
|
||||
logger.info(
|
||||
f"Model {model_name} has no tokens_per_credit, "
|
||||
f"using default: {tokens_per_credit}"
|
||||
)
|
||||
|
||||
if tokens_per_credit <= 0:
|
||||
raise CreditCalculationError(
|
||||
f"Invalid tokens_per_credit for {model_name}: {tokens_per_credit}"
|
||||
)
|
||||
|
||||
# Get rounding mode
|
||||
billing_config = BillingConfiguration.get_config()
|
||||
rounding_mode = billing_config.credit_rounding_mode
|
||||
|
||||
credits_float = total_tokens / tokens_per_credit
|
||||
|
||||
if rounding_mode == 'up':
|
||||
credits = math.ceil(credits_float)
|
||||
elif rounding_mode == 'down':
|
||||
credits = math.floor(credits_float)
|
||||
else: # nearest
|
||||
credits = round(credits_float)
|
||||
|
||||
# Minimum 1 credit
|
||||
credits = max(credits, 1)
|
||||
|
||||
logger.info(
|
||||
f"Calculated credits for {model_name}: "
|
||||
f"{total_tokens} tokens ÷ {tokens_per_credit} = {credits} credits"
|
||||
)
|
||||
|
||||
return credits
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating credits for {model_name}: {e}")
|
||||
raise CreditCalculationError(f"Error calculating credits: {e}")
|
||||
|
||||
@staticmethod
|
||||
def calculate_credits_from_tokens(operation_type, tokens_input, tokens_output):
|
||||
"""
|
||||
@@ -186,6 +329,9 @@ class CreditService:
|
||||
# Check sufficient credits (legacy: amount is already calculated)
|
||||
CreditService.check_credits_legacy(account, amount)
|
||||
|
||||
# Store previous balance for low credits check
|
||||
previous_balance = account.credits
|
||||
|
||||
# Deduct from account.credits
|
||||
account.credits -= amount
|
||||
account.save(update_fields=['credits'])
|
||||
@@ -214,6 +360,9 @@ class CreditService:
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
# Check and send low credits warning if applicable
|
||||
_check_low_credits_warning(account, previous_balance)
|
||||
|
||||
return account.credits
|
||||
|
||||
@staticmethod
|
||||
@@ -323,4 +472,56 @@ class CreditService:
|
||||
)
|
||||
|
||||
return account.credits
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def deduct_credits_for_image(
|
||||
account,
|
||||
model_name: str,
|
||||
num_images: int = 1,
|
||||
description: str = None,
|
||||
metadata: dict = None,
|
||||
cost_usd: float = None,
|
||||
related_object_type: str = None,
|
||||
related_object_id: int = None
|
||||
):
|
||||
"""
|
||||
Deduct credits for image generation based on model's credits_per_image.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
model_name: AI model used (e.g., 'dall-e-3', 'flux-1-1-pro')
|
||||
num_images: Number of images generated
|
||||
description: Optional description
|
||||
metadata: Optional metadata dict
|
||||
cost_usd: Optional cost in USD
|
||||
related_object_type: Optional related object type
|
||||
related_object_id: Optional related object ID
|
||||
|
||||
Returns:
|
||||
int: New credit balance
|
||||
"""
|
||||
credits_required = CreditService.calculate_credits_for_image(model_name, num_images)
|
||||
|
||||
if account.credits < credits_required:
|
||||
raise InsufficientCreditsError(
|
||||
f"Insufficient credits. Required: {credits_required}, Available: {account.credits}"
|
||||
)
|
||||
|
||||
if not description:
|
||||
description = f"Image generation: {num_images} images with {model_name} = {credits_required} credits"
|
||||
|
||||
return CreditService.deduct_credits(
|
||||
account=account,
|
||||
amount=credits_required,
|
||||
operation_type='image_generation',
|
||||
description=description,
|
||||
metadata=metadata,
|
||||
cost_usd=cost_usd,
|
||||
model_used=model_name,
|
||||
tokens_input=None,
|
||||
tokens_output=None,
|
||||
related_object_type=related_object_type,
|
||||
related_object_id=related_object_id
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,32 +14,65 @@ from ....auth.models import Account, Subscription
|
||||
class InvoiceService:
|
||||
"""Service for managing invoices"""
|
||||
|
||||
@staticmethod
|
||||
def get_pending_invoice(subscription: Subscription) -> Optional[Invoice]:
|
||||
"""
|
||||
Get pending invoice for a subscription.
|
||||
Used to find existing invoice during payment processing instead of creating duplicates.
|
||||
"""
|
||||
return Invoice.objects.filter(
|
||||
subscription=subscription,
|
||||
status='pending'
|
||||
).order_by('-created_at').first()
|
||||
|
||||
@staticmethod
|
||||
def get_or_create_subscription_invoice(
|
||||
subscription: Subscription,
|
||||
billing_period_start: datetime,
|
||||
billing_period_end: datetime
|
||||
) -> tuple[Invoice, bool]:
|
||||
"""
|
||||
Get existing pending invoice or create new one.
|
||||
Returns tuple of (invoice, created) where created is True if new invoice was created.
|
||||
"""
|
||||
# First try to find existing pending invoice for this subscription
|
||||
existing = InvoiceService.get_pending_invoice(subscription)
|
||||
if existing:
|
||||
return existing, False
|
||||
|
||||
# Create new invoice if none exists
|
||||
invoice = InvoiceService.create_subscription_invoice(
|
||||
subscription=subscription,
|
||||
billing_period_start=billing_period_start,
|
||||
billing_period_end=billing_period_end
|
||||
)
|
||||
return invoice, True
|
||||
|
||||
@staticmethod
|
||||
def generate_invoice_number(account: Account) -> str:
|
||||
"""
|
||||
Generate unique invoice number with atomic locking to prevent duplicates
|
||||
Format: INV-{ACCOUNT_ID}-{YEAR}{MONTH}-{COUNTER}
|
||||
Format: INV-{YY}{MM}{COUNTER} (e.g., INV-26010001)
|
||||
"""
|
||||
from django.db import transaction
|
||||
|
||||
now = timezone.now()
|
||||
prefix = f"INV-{account.id}-{now.year}{now.month:02d}"
|
||||
prefix = f"INV-{now.year % 100:02d}{now.month:02d}"
|
||||
|
||||
# Use atomic transaction with SELECT FOR UPDATE to prevent race conditions
|
||||
with transaction.atomic():
|
||||
# Lock the invoice table for this account/month to get accurate count
|
||||
# Lock the invoice table for this month to get accurate count
|
||||
count = Invoice.objects.select_for_update().filter(
|
||||
account=account,
|
||||
created_at__year=now.year,
|
||||
created_at__month=now.month
|
||||
).count()
|
||||
|
||||
invoice_number = f"{prefix}-{count + 1:04d}"
|
||||
invoice_number = f"{prefix}{count + 1:04d}"
|
||||
|
||||
# Double-check uniqueness (should not happen with lock, but safety check)
|
||||
while Invoice.objects.filter(invoice_number=invoice_number).exists():
|
||||
count += 1
|
||||
invoice_number = f"{prefix}-{count + 1:04d}"
|
||||
invoice_number = f"{prefix}{count + 1:04d}"
|
||||
|
||||
return invoice_number
|
||||
|
||||
@@ -52,6 +85,11 @@ class InvoiceService:
|
||||
) -> Invoice:
|
||||
"""
|
||||
Create invoice for subscription billing period
|
||||
|
||||
SIMPLIFIED CURRENCY LOGIC:
|
||||
- ALL invoices are in USD (consistent for accounting)
|
||||
- PKR equivalent is calculated and stored in metadata for display purposes
|
||||
- Bank transfer users see PKR equivalent but invoice is technically USD
|
||||
"""
|
||||
account = subscription.account
|
||||
plan = subscription.plan
|
||||
@@ -74,12 +112,15 @@ class InvoiceService:
|
||||
invoice_date = timezone.now().date()
|
||||
due_date = invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET)
|
||||
|
||||
# Get currency based on billing country
|
||||
# ALWAYS use USD for invoices (simplified accounting)
|
||||
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
|
||||
currency = get_currency_for_country(account.billing_country)
|
||||
|
||||
# Convert plan price to local currency
|
||||
local_price = convert_usd_to_local(float(plan.price), account.billing_country)
|
||||
currency = 'USD'
|
||||
usd_price = float(plan.price)
|
||||
|
||||
# Calculate local equivalent for display purposes (if applicable)
|
||||
local_currency = get_currency_for_country(account.billing_country) if account.billing_country else 'USD'
|
||||
local_equivalent = convert_usd_to_local(usd_price, account.billing_country) if local_currency != 'USD' else usd_price
|
||||
|
||||
invoice = Invoice.objects.create(
|
||||
account=account,
|
||||
@@ -95,16 +136,19 @@ class InvoiceService:
|
||||
'billing_period_end': billing_period_end.isoformat(),
|
||||
'subscription_id': subscription.id, # Keep in metadata for backward compatibility
|
||||
'usd_price': str(plan.price), # Store original USD price
|
||||
'exchange_rate': str(local_price / float(plan.price) if plan.price > 0 else 1.0)
|
||||
'local_currency': local_currency, # Store local currency code for display
|
||||
'local_equivalent': str(round(local_equivalent, 2)), # Store local equivalent for display
|
||||
'exchange_rate': str(local_equivalent / usd_price if usd_price > 0 else 1.0),
|
||||
'payment_method': account.payment_method
|
||||
}
|
||||
)
|
||||
|
||||
# Add line item for subscription with converted price
|
||||
# Add line item for subscription in USD
|
||||
invoice.add_line_item(
|
||||
description=f"{plan.name} Plan - {billing_period_start.strftime('%b %Y')}",
|
||||
quantity=1,
|
||||
unit_price=Decimal(str(local_price)),
|
||||
amount=Decimal(str(local_price))
|
||||
unit_price=Decimal(str(usd_price)),
|
||||
amount=Decimal(str(usd_price))
|
||||
)
|
||||
|
||||
invoice.calculate_totals()
|
||||
@@ -120,16 +164,23 @@ class InvoiceService:
|
||||
) -> Invoice:
|
||||
"""
|
||||
Create invoice for credit package purchase
|
||||
|
||||
SIMPLIFIED CURRENCY LOGIC:
|
||||
- ALL invoices are in USD (consistent for accounting)
|
||||
- PKR equivalent is calculated and stored in metadata for display purposes
|
||||
"""
|
||||
from igny8_core.business.billing.config import INVOICE_DUE_DATE_OFFSET
|
||||
invoice_date = timezone.now().date()
|
||||
|
||||
# Get currency based on billing country
|
||||
# ALWAYS use USD for invoices (simplified accounting)
|
||||
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
|
||||
currency = get_currency_for_country(account.billing_country)
|
||||
|
||||
# Convert credit package price to local currency
|
||||
local_price = convert_usd_to_local(float(credit_package.price), account.billing_country)
|
||||
currency = 'USD'
|
||||
usd_price = float(credit_package.price)
|
||||
|
||||
# Calculate local equivalent for display purposes (if applicable)
|
||||
local_currency = get_currency_for_country(account.billing_country) if account.billing_country else 'USD'
|
||||
local_equivalent = convert_usd_to_local(usd_price, account.billing_country) if local_currency != 'USD' else usd_price
|
||||
|
||||
invoice = Invoice.objects.create(
|
||||
account=account,
|
||||
@@ -143,16 +194,19 @@ class InvoiceService:
|
||||
'credit_package_id': credit_package.id,
|
||||
'credit_amount': credit_package.credits,
|
||||
'usd_price': str(credit_package.price), # Store original USD price
|
||||
'exchange_rate': str(local_price / float(credit_package.price) if credit_package.price > 0 else 1.0)
|
||||
'local_currency': local_currency, # Store local currency code for display
|
||||
'local_equivalent': str(round(local_equivalent, 2)), # Store local equivalent for display
|
||||
'exchange_rate': str(local_equivalent / usd_price if usd_price > 0 else 1.0),
|
||||
'payment_method': account.payment_method
|
||||
},
|
||||
)
|
||||
|
||||
# Add line item for credit package with converted price
|
||||
# Add line item for credit package in USD
|
||||
invoice.add_line_item(
|
||||
description=f"{credit_package.name} - {credit_package.credits:,} Credits",
|
||||
quantity=1,
|
||||
unit_price=Decimal(str(local_price)),
|
||||
amount=Decimal(str(local_price))
|
||||
unit_price=Decimal(str(usd_price)),
|
||||
amount=Decimal(str(usd_price))
|
||||
)
|
||||
|
||||
invoice.calculate_totals()
|
||||
@@ -212,10 +266,21 @@ class InvoiceService:
|
||||
transaction_id: Optional[str] = None
|
||||
) -> Invoice:
|
||||
"""
|
||||
Mark invoice as paid
|
||||
Mark invoice as paid and record payment details
|
||||
|
||||
Args:
|
||||
invoice: Invoice to mark as paid
|
||||
payment_method: Payment method used ('stripe', 'paypal', 'bank_transfer', etc.)
|
||||
transaction_id: External transaction ID (Stripe payment intent, PayPal capture ID, etc.)
|
||||
"""
|
||||
invoice.status = 'paid'
|
||||
invoice.paid_at = timezone.now()
|
||||
invoice.payment_method = payment_method
|
||||
|
||||
# For Stripe payments, store the transaction ID in stripe_invoice_id field
|
||||
if payment_method == 'stripe' and transaction_id:
|
||||
invoice.stripe_invoice_id = transaction_id
|
||||
|
||||
invoice.save()
|
||||
|
||||
return invoice
|
||||
@@ -239,43 +304,13 @@ class InvoiceService:
|
||||
@staticmethod
|
||||
def generate_pdf(invoice: Invoice) -> bytes:
|
||||
"""
|
||||
Generate PDF for invoice
|
||||
|
||||
TODO: Implement PDF generation using reportlab or weasyprint
|
||||
For now, return placeholder
|
||||
Generate professional PDF invoice using ReportLab
|
||||
"""
|
||||
from io import BytesIO
|
||||
from igny8_core.business.billing.services.pdf_service import InvoicePDFGenerator
|
||||
|
||||
# Placeholder - implement PDF generation
|
||||
buffer = BytesIO()
|
||||
|
||||
# Simple text representation for now
|
||||
content = f"""
|
||||
INVOICE #{invoice.invoice_number}
|
||||
|
||||
Bill To: {invoice.account.name}
|
||||
Email: {invoice.billing_email}
|
||||
|
||||
Date: {invoice.created_at.strftime('%Y-%m-%d')}
|
||||
Due Date: {invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else 'N/A'}
|
||||
|
||||
Line Items:
|
||||
"""
|
||||
for item in invoice.line_items:
|
||||
content += f" {item['description']} - ${item['amount']}\n"
|
||||
|
||||
content += f"""
|
||||
Subtotal: ${invoice.subtotal}
|
||||
Tax: ${invoice.tax_amount}
|
||||
Total: ${invoice.total_amount}
|
||||
|
||||
Status: {invoice.status.upper()}
|
||||
"""
|
||||
|
||||
buffer.write(content.encode('utf-8'))
|
||||
buffer.seek(0)
|
||||
|
||||
return buffer.getvalue()
|
||||
# Use the professional PDF generator
|
||||
pdf_buffer = InvoicePDFGenerator.generate_invoice_pdf(invoice)
|
||||
return pdf_buffer.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def get_account_invoices(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Limit Service for Plan Limit Enforcement
|
||||
Manages hard limits (sites, users, keywords, clusters) and monthly limits (ideas, words, images, prompts)
|
||||
Manages hard limits (sites, users, keywords) and monthly limits (ahrefs_queries)
|
||||
"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
@@ -18,12 +18,12 @@ class LimitExceededError(Exception):
|
||||
|
||||
|
||||
class HardLimitExceededError(LimitExceededError):
|
||||
"""Raised when a hard limit (sites, users, keywords, clusters) is exceeded"""
|
||||
"""Raised when a hard limit (sites, users, keywords) is exceeded"""
|
||||
pass
|
||||
|
||||
|
||||
class MonthlyLimitExceededError(LimitExceededError):
|
||||
"""Raised when a monthly limit (ideas, words, images, prompts) is exceeded"""
|
||||
"""Raised when a monthly limit (ahrefs_queries) is exceeded"""
|
||||
pass
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ class LimitService:
|
||||
"""Service for managing and enforcing plan limits"""
|
||||
|
||||
# Map limit types to model/field names
|
||||
# Simplified to only 3 hard limits: sites, users, keywords
|
||||
HARD_LIMIT_MAPPINGS = {
|
||||
'sites': {
|
||||
'model': 'igny8_core_auth.Site',
|
||||
@@ -39,10 +40,10 @@ class LimitService:
|
||||
'filter_field': 'account',
|
||||
},
|
||||
'users': {
|
||||
'model': 'igny8_core_auth.SiteUserAccess',
|
||||
'model': 'igny8_core_auth.User',
|
||||
'plan_field': 'max_users',
|
||||
'display_name': 'Team Users',
|
||||
'filter_field': 'site__account',
|
||||
'display_name': 'Team Members',
|
||||
'filter_field': 'account',
|
||||
},
|
||||
'keywords': {
|
||||
'model': 'planner.Keywords',
|
||||
@@ -50,39 +51,15 @@ class LimitService:
|
||||
'display_name': 'Keywords',
|
||||
'filter_field': 'account',
|
||||
},
|
||||
'clusters': {
|
||||
'model': 'planner.Clusters',
|
||||
'plan_field': 'max_clusters',
|
||||
'display_name': 'Clusters',
|
||||
'filter_field': 'account',
|
||||
},
|
||||
}
|
||||
|
||||
# Simplified to only 1 monthly limit: ahrefs_queries
|
||||
# All other consumption is controlled by credits only
|
||||
MONTHLY_LIMIT_MAPPINGS = {
|
||||
'content_ideas': {
|
||||
'plan_field': 'max_content_ideas',
|
||||
'usage_field': 'usage_content_ideas',
|
||||
'display_name': 'Content Ideas',
|
||||
},
|
||||
'content_words': {
|
||||
'plan_field': 'max_content_words',
|
||||
'usage_field': 'usage_content_words',
|
||||
'display_name': 'Content Words',
|
||||
},
|
||||
'images_basic': {
|
||||
'plan_field': 'max_images_basic',
|
||||
'usage_field': 'usage_images_basic',
|
||||
'display_name': 'Basic Images',
|
||||
},
|
||||
'images_premium': {
|
||||
'plan_field': 'max_images_premium',
|
||||
'usage_field': 'usage_images_premium',
|
||||
'display_name': 'Premium Images',
|
||||
},
|
||||
'image_prompts': {
|
||||
'plan_field': 'max_image_prompts',
|
||||
'usage_field': 'usage_image_prompts',
|
||||
'display_name': 'Image Prompts',
|
||||
'ahrefs_queries': {
|
||||
'plan_field': 'max_ahrefs_queries',
|
||||
'usage_field': 'usage_ahrefs_queries',
|
||||
'display_name': 'Keyword Research Queries',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -318,11 +295,8 @@ class LimitService:
|
||||
Returns:
|
||||
dict: Summary of reset operation
|
||||
"""
|
||||
account.usage_content_ideas = 0
|
||||
account.usage_content_words = 0
|
||||
account.usage_images_basic = 0
|
||||
account.usage_images_premium = 0
|
||||
account.usage_image_prompts = 0
|
||||
# Reset only ahrefs_queries (the only monthly limit now)
|
||||
account.usage_ahrefs_queries = 0
|
||||
|
||||
old_period_end = account.usage_period_end
|
||||
|
||||
@@ -341,8 +315,7 @@ class LimitService:
|
||||
account.usage_period_end = new_period_end
|
||||
|
||||
account.save(update_fields=[
|
||||
'usage_content_ideas', 'usage_content_words',
|
||||
'usage_images_basic', 'usage_images_premium', 'usage_image_prompts',
|
||||
'usage_ahrefs_queries',
|
||||
'usage_period_start', 'usage_period_end', 'updated_at'
|
||||
])
|
||||
|
||||
@@ -353,5 +326,5 @@ class LimitService:
|
||||
'old_period_end': old_period_end.isoformat() if old_period_end else None,
|
||||
'new_period_start': new_period_start.isoformat(),
|
||||
'new_period_end': new_period_end.isoformat(),
|
||||
'limits_reset': 5,
|
||||
'limits_reset': 1,
|
||||
}
|
||||
|
||||
@@ -105,11 +105,15 @@ class PaymentService:
|
||||
) -> Payment:
|
||||
"""
|
||||
Mark payment as completed and update invoice
|
||||
For automatic payments (Stripe/PayPal), sets approved_at but leaves approved_by as None
|
||||
"""
|
||||
from .invoice_service import InvoiceService
|
||||
|
||||
payment.status = 'succeeded'
|
||||
payment.processed_at = timezone.now()
|
||||
# For automatic payments, set approved_at to indicate when payment was verified
|
||||
# approved_by stays None to indicate it was automated, not manual approval
|
||||
payment.approved_at = timezone.now()
|
||||
|
||||
if transaction_id:
|
||||
payment.transaction_reference = transaction_id
|
||||
|
||||
679
backend/igny8_core/business/billing/services/paypal_service.py
Normal file
679
backend/igny8_core/business/billing/services/paypal_service.py
Normal file
@@ -0,0 +1,679 @@
|
||||
"""
|
||||
PayPal Service - REST API v2 integration
|
||||
|
||||
Handles:
|
||||
- Order creation and capture for one-time payments
|
||||
- Subscription management
|
||||
- Webhook verification
|
||||
|
||||
Configuration stored in IntegrationProvider model (provider_id='paypal')
|
||||
|
||||
Endpoints:
|
||||
- Sandbox: https://api-m.sandbox.paypal.com
|
||||
- Production: https://api-m.paypal.com
|
||||
"""
|
||||
import requests
|
||||
import base64
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from django.conf import settings
|
||||
from igny8_core.modules.system.models import IntegrationProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PayPalConfigurationError(Exception):
|
||||
"""Raised when PayPal is not properly configured"""
|
||||
pass
|
||||
|
||||
|
||||
class PayPalAPIError(Exception):
|
||||
"""Raised when PayPal API returns an error"""
|
||||
def __init__(self, message: str, status_code: int = None, response: dict = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.response = response
|
||||
|
||||
|
||||
class PayPalService:
|
||||
"""Service for PayPal payment operations using REST API v2"""
|
||||
|
||||
SANDBOX_URL = 'https://api-m.sandbox.paypal.com'
|
||||
PRODUCTION_URL = 'https://api-m.paypal.com'
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize PayPal service with credentials from IntegrationProvider.
|
||||
|
||||
Raises:
|
||||
PayPalConfigurationError: If PayPal provider not configured or missing credentials
|
||||
"""
|
||||
provider = IntegrationProvider.get_provider('paypal')
|
||||
if not provider:
|
||||
raise PayPalConfigurationError(
|
||||
"PayPal provider not configured. Add 'paypal' provider in admin."
|
||||
)
|
||||
|
||||
if not provider.api_key or not provider.api_secret:
|
||||
raise PayPalConfigurationError(
|
||||
"PayPal client credentials not configured. "
|
||||
"Set api_key (Client ID) and api_secret (Client Secret) in provider."
|
||||
)
|
||||
|
||||
self.client_id = provider.api_key
|
||||
self.client_secret = provider.api_secret
|
||||
self.is_sandbox = provider.is_sandbox
|
||||
self.provider = provider
|
||||
self.config = provider.config or {}
|
||||
|
||||
# Set base URL
|
||||
if provider.api_endpoint:
|
||||
self.base_url = provider.api_endpoint.rstrip('/')
|
||||
else:
|
||||
self.base_url = self.SANDBOX_URL if self.is_sandbox else self.PRODUCTION_URL
|
||||
|
||||
# Cache access token
|
||||
self._access_token = None
|
||||
self._token_expires_at = None
|
||||
|
||||
# Configuration
|
||||
self.currency = self.config.get('currency', 'USD')
|
||||
self.webhook_id = self.config.get('webhook_id', '')
|
||||
|
||||
logger.info(
|
||||
f"PayPal service initialized (sandbox={self.is_sandbox}, "
|
||||
f"base_url={self.base_url})"
|
||||
)
|
||||
|
||||
@property
|
||||
def frontend_url(self) -> str:
|
||||
"""Get frontend URL from Django settings"""
|
||||
return getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
|
||||
|
||||
@property
|
||||
def return_url(self) -> str:
|
||||
"""Get return URL for PayPal redirects"""
|
||||
return self.config.get(
|
||||
'return_url',
|
||||
f'{self.frontend_url}/account/plans?paypal=success'
|
||||
)
|
||||
|
||||
@property
|
||||
def cancel_url(self) -> str:
|
||||
"""Get cancel URL for PayPal redirects"""
|
||||
return self.config.get(
|
||||
'cancel_url',
|
||||
f'{self.frontend_url}/account/plans?paypal=cancel'
|
||||
)
|
||||
|
||||
# ========== Authentication ==========
|
||||
|
||||
def _get_access_token(self) -> str:
|
||||
"""
|
||||
Get OAuth 2.0 access token from PayPal.
|
||||
|
||||
Returns:
|
||||
str: Access token
|
||||
|
||||
Raises:
|
||||
PayPalAPIError: If token request fails
|
||||
"""
|
||||
import time
|
||||
|
||||
# Return cached token if still valid
|
||||
if self._access_token and self._token_expires_at:
|
||||
if time.time() < self._token_expires_at - 60: # 60 second buffer
|
||||
return self._access_token
|
||||
|
||||
# Create Basic auth header
|
||||
auth_string = f'{self.client_id}:{self.client_secret}'
|
||||
auth_bytes = base64.b64encode(auth_string.encode()).decode()
|
||||
|
||||
response = requests.post(
|
||||
f'{self.base_url}/v1/oauth2/token',
|
||||
headers={
|
||||
'Authorization': f'Basic {auth_bytes}',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
data='grant_type=client_credentials',
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"PayPal token request failed: {response.text}")
|
||||
raise PayPalAPIError(
|
||||
"Failed to obtain PayPal access token",
|
||||
status_code=response.status_code,
|
||||
response=response.json() if response.text else None
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
self._access_token = data['access_token']
|
||||
self._token_expires_at = time.time() + data.get('expires_in', 32400)
|
||||
|
||||
logger.debug("PayPal access token obtained successfully")
|
||||
return self._access_token
|
||||
|
||||
def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
json_data: dict = None,
|
||||
params: dict = None,
|
||||
timeout: int = 30,
|
||||
) -> dict:
|
||||
"""
|
||||
Make authenticated API request to PayPal.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
endpoint: API endpoint (e.g., '/v2/checkout/orders')
|
||||
json_data: JSON body data
|
||||
params: Query parameters
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
dict: Response JSON
|
||||
|
||||
Raises:
|
||||
PayPalAPIError: If request fails
|
||||
"""
|
||||
token = self._get_access_token()
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
url = f'{self.base_url}{endpoint}'
|
||||
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Handle no content response
|
||||
if response.status_code == 204:
|
||||
return {}
|
||||
|
||||
# Parse JSON response
|
||||
try:
|
||||
response_data = response.json() if response.text else {}
|
||||
except Exception:
|
||||
response_data = {'raw': response.text}
|
||||
|
||||
# Check for errors
|
||||
if response.status_code >= 400:
|
||||
error_msg = response_data.get('message', str(response_data))
|
||||
logger.error(f"PayPal API error: {error_msg}")
|
||||
raise PayPalAPIError(
|
||||
f"PayPal API error: {error_msg}",
|
||||
status_code=response.status_code,
|
||||
response=response_data
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
# ========== Order Operations ==========
|
||||
|
||||
def create_order(
|
||||
self,
|
||||
account,
|
||||
amount: float,
|
||||
currency: str = None,
|
||||
description: str = '',
|
||||
return_url: str = None,
|
||||
cancel_url: str = None,
|
||||
metadata: dict = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create PayPal order for one-time payment.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
amount: Payment amount
|
||||
currency: Currency code (default from config)
|
||||
description: Payment description
|
||||
return_url: URL to redirect after approval
|
||||
cancel_url: URL to redirect on cancellation
|
||||
metadata: Additional metadata to store
|
||||
|
||||
Returns:
|
||||
dict: Order data including order_id and approval_url
|
||||
"""
|
||||
currency = currency or self.currency
|
||||
return_url = return_url or self.return_url
|
||||
cancel_url = cancel_url or self.cancel_url
|
||||
|
||||
# Build order payload
|
||||
order_data = {
|
||||
'intent': 'CAPTURE',
|
||||
'purchase_units': [{
|
||||
'amount': {
|
||||
'currency_code': currency,
|
||||
'value': f'{amount:.2f}',
|
||||
},
|
||||
'description': description or 'IGNY8 Payment',
|
||||
'custom_id': str(account.id),
|
||||
'reference_id': str(account.id),
|
||||
}],
|
||||
'application_context': {
|
||||
'return_url': return_url,
|
||||
'cancel_url': cancel_url,
|
||||
'brand_name': 'IGNY8',
|
||||
'landing_page': 'BILLING',
|
||||
'user_action': 'PAY_NOW',
|
||||
'shipping_preference': 'NO_SHIPPING',
|
||||
}
|
||||
}
|
||||
|
||||
# Create order
|
||||
response = self._make_request('POST', '/v2/checkout/orders', json_data=order_data)
|
||||
|
||||
# Extract approval URL
|
||||
approval_url = None
|
||||
for link in response.get('links', []):
|
||||
if link.get('rel') == 'approve':
|
||||
approval_url = link.get('href')
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"Created PayPal order {response.get('id')} for account {account.id}, "
|
||||
f"amount {currency} {amount}"
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': response.get('id'),
|
||||
'status': response.get('status'),
|
||||
'approval_url': approval_url,
|
||||
'links': response.get('links', []),
|
||||
}
|
||||
|
||||
def create_credit_order(
|
||||
self,
|
||||
account,
|
||||
credit_package,
|
||||
return_url: str = None,
|
||||
cancel_url: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create PayPal order for credit package purchase.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
credit_package: CreditPackage model instance
|
||||
return_url: URL to redirect after approval
|
||||
cancel_url: URL to redirect on cancellation
|
||||
|
||||
Returns:
|
||||
dict: Order data including order_id and approval_url
|
||||
"""
|
||||
return_url = return_url or f'{self.frontend_url}/account/usage?paypal=success'
|
||||
cancel_url = cancel_url or f'{self.frontend_url}/account/usage?paypal=cancel'
|
||||
|
||||
# Add credit package info to custom_id for webhook processing
|
||||
order = self.create_order(
|
||||
account=account,
|
||||
amount=float(credit_package.price),
|
||||
description=f'{credit_package.name} - {credit_package.credits} credits',
|
||||
return_url=f'{return_url}&package_id={credit_package.id}',
|
||||
cancel_url=cancel_url,
|
||||
)
|
||||
|
||||
# Store package info in order
|
||||
order['credit_package_id'] = str(credit_package.id)
|
||||
order['credit_amount'] = credit_package.credits
|
||||
|
||||
return order
|
||||
|
||||
def capture_order(self, order_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Capture payment for approved order.
|
||||
|
||||
Call this after customer approves the order at PayPal.
|
||||
|
||||
Args:
|
||||
order_id: PayPal order ID
|
||||
|
||||
Returns:
|
||||
dict: Capture result with payment details
|
||||
"""
|
||||
response = self._make_request(
|
||||
'POST',
|
||||
f'/v2/checkout/orders/{order_id}/capture'
|
||||
)
|
||||
|
||||
# Extract capture details
|
||||
capture_id = None
|
||||
amount = None
|
||||
currency = None
|
||||
|
||||
if response.get('purchase_units'):
|
||||
captures = response['purchase_units'][0].get('payments', {}).get('captures', [])
|
||||
if captures:
|
||||
capture = captures[0]
|
||||
capture_id = capture.get('id')
|
||||
amount = capture.get('amount', {}).get('value')
|
||||
currency = capture.get('amount', {}).get('currency_code')
|
||||
|
||||
logger.info(
|
||||
f"Captured PayPal order {order_id}, capture_id={capture_id}, "
|
||||
f"amount={currency} {amount}"
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': response.get('id'),
|
||||
'status': response.get('status'),
|
||||
'capture_id': capture_id,
|
||||
'amount': amount,
|
||||
'currency': currency,
|
||||
'payer': response.get('payer', {}),
|
||||
'custom_id': response.get('purchase_units', [{}])[0].get('custom_id'),
|
||||
}
|
||||
|
||||
def get_order(self, order_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get order details.
|
||||
|
||||
Args:
|
||||
order_id: PayPal order ID
|
||||
|
||||
Returns:
|
||||
dict: Order details
|
||||
"""
|
||||
response = self._make_request('GET', f'/v2/checkout/orders/{order_id}')
|
||||
|
||||
return {
|
||||
'order_id': response.get('id'),
|
||||
'status': response.get('status'),
|
||||
'intent': response.get('intent'),
|
||||
'payer': response.get('payer', {}),
|
||||
'purchase_units': response.get('purchase_units', []),
|
||||
'create_time': response.get('create_time'),
|
||||
'update_time': response.get('update_time'),
|
||||
}
|
||||
|
||||
# ========== Subscription Operations ==========
|
||||
|
||||
def create_subscription(
|
||||
self,
|
||||
account,
|
||||
plan_id: str,
|
||||
return_url: str = None,
|
||||
cancel_url: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create PayPal subscription.
|
||||
|
||||
Requires plan to be created in PayPal dashboard first.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
plan_id: PayPal Plan ID (created in PayPal dashboard)
|
||||
return_url: URL to redirect after approval
|
||||
cancel_url: URL to redirect on cancellation
|
||||
|
||||
Returns:
|
||||
dict: Subscription data including approval_url
|
||||
"""
|
||||
return_url = return_url or self.return_url
|
||||
cancel_url = cancel_url or self.cancel_url
|
||||
|
||||
subscription_data = {
|
||||
'plan_id': plan_id,
|
||||
'custom_id': str(account.id),
|
||||
'application_context': {
|
||||
'return_url': return_url,
|
||||
'cancel_url': cancel_url,
|
||||
'brand_name': 'IGNY8',
|
||||
'locale': 'en-US',
|
||||
'shipping_preference': 'NO_SHIPPING',
|
||||
'user_action': 'SUBSCRIBE_NOW',
|
||||
'payment_method': {
|
||||
'payer_selected': 'PAYPAL',
|
||||
'payee_preferred': 'IMMEDIATE_PAYMENT_REQUIRED',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response = self._make_request(
|
||||
'POST',
|
||||
'/v1/billing/subscriptions',
|
||||
json_data=subscription_data
|
||||
)
|
||||
|
||||
# Extract approval URL
|
||||
approval_url = None
|
||||
for link in response.get('links', []):
|
||||
if link.get('rel') == 'approve':
|
||||
approval_url = link.get('href')
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"Created PayPal subscription {response.get('id')} for account {account.id}"
|
||||
)
|
||||
|
||||
return {
|
||||
'subscription_id': response.get('id'),
|
||||
'status': response.get('status'),
|
||||
'approval_url': approval_url,
|
||||
'links': response.get('links', []),
|
||||
}
|
||||
|
||||
def get_subscription(self, subscription_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get subscription details.
|
||||
|
||||
Args:
|
||||
subscription_id: PayPal subscription ID
|
||||
|
||||
Returns:
|
||||
dict: Subscription details
|
||||
"""
|
||||
response = self._make_request(
|
||||
'GET',
|
||||
f'/v1/billing/subscriptions/{subscription_id}'
|
||||
)
|
||||
|
||||
return {
|
||||
'subscription_id': response.get('id'),
|
||||
'status': response.get('status'),
|
||||
'plan_id': response.get('plan_id'),
|
||||
'start_time': response.get('start_time'),
|
||||
'billing_info': response.get('billing_info', {}),
|
||||
'custom_id': response.get('custom_id'),
|
||||
}
|
||||
|
||||
def cancel_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
reason: str = 'Customer requested cancellation'
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Cancel PayPal subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: PayPal subscription ID
|
||||
reason: Reason for cancellation
|
||||
|
||||
Returns:
|
||||
dict: Cancellation result
|
||||
"""
|
||||
self._make_request(
|
||||
'POST',
|
||||
f'/v1/billing/subscriptions/{subscription_id}/cancel',
|
||||
json_data={'reason': reason}
|
||||
)
|
||||
|
||||
logger.info(f"Cancelled PayPal subscription {subscription_id}")
|
||||
|
||||
return {
|
||||
'subscription_id': subscription_id,
|
||||
'status': 'CANCELLED',
|
||||
}
|
||||
|
||||
def suspend_subscription(self, subscription_id: str, reason: str = '') -> Dict[str, Any]:
|
||||
"""
|
||||
Suspend PayPal subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: PayPal subscription ID
|
||||
reason: Reason for suspension
|
||||
|
||||
Returns:
|
||||
dict: Suspension result
|
||||
"""
|
||||
self._make_request(
|
||||
'POST',
|
||||
f'/v1/billing/subscriptions/{subscription_id}/suspend',
|
||||
json_data={'reason': reason}
|
||||
)
|
||||
|
||||
logger.info(f"Suspended PayPal subscription {subscription_id}")
|
||||
|
||||
return {
|
||||
'subscription_id': subscription_id,
|
||||
'status': 'SUSPENDED',
|
||||
}
|
||||
|
||||
def activate_subscription(self, subscription_id: str, reason: str = '') -> Dict[str, Any]:
|
||||
"""
|
||||
Activate/reactivate PayPal subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: PayPal subscription ID
|
||||
reason: Reason for activation
|
||||
|
||||
Returns:
|
||||
dict: Activation result
|
||||
"""
|
||||
self._make_request(
|
||||
'POST',
|
||||
f'/v1/billing/subscriptions/{subscription_id}/activate',
|
||||
json_data={'reason': reason}
|
||||
)
|
||||
|
||||
logger.info(f"Activated PayPal subscription {subscription_id}")
|
||||
|
||||
return {
|
||||
'subscription_id': subscription_id,
|
||||
'status': 'ACTIVE',
|
||||
}
|
||||
|
||||
# ========== Webhook Verification ==========
|
||||
|
||||
def verify_webhook_signature(
|
||||
self,
|
||||
headers: dict,
|
||||
body: dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Verify webhook signature from PayPal.
|
||||
|
||||
Args:
|
||||
headers: Request headers (dict-like)
|
||||
body: Request body (parsed JSON dict)
|
||||
|
||||
Returns:
|
||||
bool: True if signature is valid
|
||||
"""
|
||||
if not self.webhook_id:
|
||||
logger.warning("PayPal webhook_id not configured, skipping verification")
|
||||
return True # Optionally fail open or closed based on security policy
|
||||
|
||||
verification_data = {
|
||||
'auth_algo': headers.get('PAYPAL-AUTH-ALGO'),
|
||||
'cert_url': headers.get('PAYPAL-CERT-URL'),
|
||||
'transmission_id': headers.get('PAYPAL-TRANSMISSION-ID'),
|
||||
'transmission_sig': headers.get('PAYPAL-TRANSMISSION-SIG'),
|
||||
'transmission_time': headers.get('PAYPAL-TRANSMISSION-TIME'),
|
||||
'webhook_id': self.webhook_id,
|
||||
'webhook_event': body,
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._make_request(
|
||||
'POST',
|
||||
'/v1/notifications/verify-webhook-signature',
|
||||
json_data=verification_data
|
||||
)
|
||||
|
||||
is_valid = response.get('verification_status') == 'SUCCESS'
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"PayPal webhook verification failed: {response.get('verification_status')}"
|
||||
)
|
||||
|
||||
return is_valid
|
||||
|
||||
except PayPalAPIError as e:
|
||||
logger.error(f"PayPal webhook verification error: {e}")
|
||||
return False
|
||||
|
||||
# ========== Refunds ==========
|
||||
|
||||
def refund_capture(
|
||||
self,
|
||||
capture_id: str,
|
||||
amount: float = None,
|
||||
currency: str = None,
|
||||
note: str = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Refund a captured payment.
|
||||
|
||||
Args:
|
||||
capture_id: PayPal capture ID
|
||||
amount: Amount to refund (None for full refund)
|
||||
currency: Currency code
|
||||
note: Note to payer
|
||||
|
||||
Returns:
|
||||
dict: Refund details
|
||||
"""
|
||||
refund_data = {}
|
||||
|
||||
if amount:
|
||||
refund_data['amount'] = {
|
||||
'value': f'{amount:.2f}',
|
||||
'currency_code': currency or self.currency,
|
||||
}
|
||||
|
||||
if note:
|
||||
refund_data['note_to_payer'] = note
|
||||
|
||||
response = self._make_request(
|
||||
'POST',
|
||||
f'/v2/payments/captures/{capture_id}/refund',
|
||||
json_data=refund_data if refund_data else None
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Refunded PayPal capture {capture_id}, refund_id={response.get('id')}"
|
||||
)
|
||||
|
||||
return {
|
||||
'refund_id': response.get('id'),
|
||||
'status': response.get('status'),
|
||||
'amount': response.get('amount', {}).get('value'),
|
||||
'currency': response.get('amount', {}).get('currency_code'),
|
||||
}
|
||||
|
||||
|
||||
# Convenience function
|
||||
def get_paypal_service() -> PayPalService:
|
||||
"""
|
||||
Get PayPalService instance.
|
||||
|
||||
Returns:
|
||||
PayPalService: Initialized service
|
||||
|
||||
Raises:
|
||||
PayPalConfigurationError: If PayPal not configured
|
||||
"""
|
||||
return PayPalService()
|
||||
@@ -9,17 +9,32 @@ from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image, HRFlowable
|
||||
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER
|
||||
from django.conf import settings
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Logo path - check multiple possible locations
|
||||
LOGO_PATHS = [
|
||||
'/data/app/igny8/frontend/public/images/logo/IGNY8_LIGHT_LOGO.png',
|
||||
'/app/static/images/logo/IGNY8_LIGHT_LOGO.png',
|
||||
]
|
||||
|
||||
|
||||
class InvoicePDFGenerator:
|
||||
"""Generate PDF invoices"""
|
||||
|
||||
@staticmethod
|
||||
def get_logo_path():
|
||||
"""Find the logo file from possible locations"""
|
||||
for path in LOGO_PATHS:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def generate_invoice_pdf(invoice):
|
||||
"""
|
||||
@@ -39,8 +54,8 @@ class InvoicePDFGenerator:
|
||||
pagesize=letter,
|
||||
rightMargin=0.75*inch,
|
||||
leftMargin=0.75*inch,
|
||||
topMargin=0.75*inch,
|
||||
bottomMargin=0.75*inch
|
||||
topMargin=0.5*inch,
|
||||
bottomMargin=0.5*inch
|
||||
)
|
||||
|
||||
# Container for PDF elements
|
||||
@@ -51,17 +66,19 @@ class InvoicePDFGenerator:
|
||||
title_style = ParagraphStyle(
|
||||
'CustomTitle',
|
||||
parent=styles['Heading1'],
|
||||
fontSize=24,
|
||||
fontSize=28,
|
||||
textColor=colors.HexColor('#1f2937'),
|
||||
spaceAfter=30,
|
||||
spaceAfter=0,
|
||||
fontName='Helvetica-Bold',
|
||||
)
|
||||
|
||||
heading_style = ParagraphStyle(
|
||||
'CustomHeading',
|
||||
parent=styles['Heading2'],
|
||||
fontSize=14,
|
||||
textColor=colors.HexColor('#374151'),
|
||||
spaceAfter=12,
|
||||
fontSize=12,
|
||||
textColor=colors.HexColor('#1f2937'),
|
||||
spaceAfter=8,
|
||||
fontName='Helvetica-Bold',
|
||||
)
|
||||
|
||||
normal_style = ParagraphStyle(
|
||||
@@ -69,145 +86,292 @@ class InvoicePDFGenerator:
|
||||
parent=styles['Normal'],
|
||||
fontSize=10,
|
||||
textColor=colors.HexColor('#4b5563'),
|
||||
fontName='Helvetica',
|
||||
)
|
||||
|
||||
# Header
|
||||
elements.append(Paragraph('INVOICE', title_style))
|
||||
elements.append(Spacer(1, 0.2*inch))
|
||||
label_style = ParagraphStyle(
|
||||
'LabelStyle',
|
||||
parent=styles['Normal'],
|
||||
fontSize=9,
|
||||
textColor=colors.HexColor('#6b7280'),
|
||||
fontName='Helvetica',
|
||||
)
|
||||
|
||||
# Company info and invoice details side by side
|
||||
company_data = [
|
||||
['<b>From:</b>', f'<b>Invoice #:</b> {invoice.invoice_number}'],
|
||||
[getattr(settings, 'COMPANY_NAME', 'Igny8'), f'<b>Date:</b> {invoice.created_at.strftime("%B %d, %Y")}'],
|
||||
[getattr(settings, 'COMPANY_ADDRESS', ''), f'<b>Due Date:</b> {invoice.due_date.strftime("%B %d, %Y")}'],
|
||||
[getattr(settings, 'COMPANY_EMAIL', settings.DEFAULT_FROM_EMAIL), f'<b>Status:</b> {invoice.status.upper()}'],
|
||||
]
|
||||
value_style = ParagraphStyle(
|
||||
'ValueStyle',
|
||||
parent=styles['Normal'],
|
||||
fontSize=10,
|
||||
textColor=colors.HexColor('#1f2937'),
|
||||
fontName='Helvetica-Bold',
|
||||
)
|
||||
|
||||
company_table = Table(company_data, colWidths=[3.5*inch, 3*inch])
|
||||
company_table.setStyle(TableStyle([
|
||||
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 10),
|
||||
('TEXTCOLOR', (0, 0), (-1, -1), colors.HexColor('#4b5563')),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
|
||||
right_align_style = ParagraphStyle(
|
||||
'RightAlign',
|
||||
parent=styles['Normal'],
|
||||
fontSize=10,
|
||||
textColor=colors.HexColor('#4b5563'),
|
||||
alignment=TA_RIGHT,
|
||||
fontName='Helvetica',
|
||||
)
|
||||
|
||||
right_bold_style = ParagraphStyle(
|
||||
'RightBold',
|
||||
parent=styles['Normal'],
|
||||
fontSize=10,
|
||||
textColor=colors.HexColor('#1f2937'),
|
||||
alignment=TA_RIGHT,
|
||||
fontName='Helvetica-Bold',
|
||||
)
|
||||
|
||||
# Header with Logo and Invoice title
|
||||
logo_path = InvoicePDFGenerator.get_logo_path()
|
||||
header_data = []
|
||||
|
||||
if logo_path:
|
||||
try:
|
||||
logo = Image(logo_path, width=1.5*inch, height=0.5*inch)
|
||||
logo.hAlign = 'LEFT'
|
||||
header_data = [[logo, Paragraph('INVOICE', title_style)]]
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load logo: {e}")
|
||||
header_data = [[Paragraph('IGNY8', title_style), Paragraph('INVOICE', title_style)]]
|
||||
else:
|
||||
header_data = [[Paragraph('IGNY8', title_style), Paragraph('INVOICE', title_style)]]
|
||||
|
||||
header_table = Table(header_data, colWidths=[3.5*inch, 3*inch])
|
||||
header_table.setStyle(TableStyle([
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('ALIGN', (0, 0), (0, 0), 'LEFT'),
|
||||
('ALIGN', (1, 0), (1, 0), 'RIGHT'),
|
||||
]))
|
||||
elements.append(company_table)
|
||||
elements.append(header_table)
|
||||
elements.append(Spacer(1, 0.3*inch))
|
||||
|
||||
# Bill to section
|
||||
elements.append(Paragraph('<b>Bill To:</b>', heading_style))
|
||||
bill_to_data = [
|
||||
[invoice.account.name],
|
||||
[invoice.account.owner.email],
|
||||
# Divider line
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e5e7eb'), spaceAfter=20))
|
||||
|
||||
# Invoice details section (right side info)
|
||||
invoice_info = [
|
||||
[Paragraph('Invoice Number:', label_style), Paragraph(invoice.invoice_number, value_style)],
|
||||
[Paragraph('Date:', label_style), Paragraph(invoice.created_at.strftime("%B %d, %Y"), value_style)],
|
||||
[Paragraph('Due Date:', label_style), Paragraph(invoice.due_date.strftime("%B %d, %Y"), value_style)],
|
||||
[Paragraph('Status:', label_style), Paragraph(invoice.status.upper(), value_style)],
|
||||
]
|
||||
|
||||
if hasattr(invoice.account, 'billing_email') and invoice.account.billing_email:
|
||||
bill_to_data.append([f'Billing: {invoice.account.billing_email}'])
|
||||
invoice_info_table = Table(invoice_info, colWidths=[1.2*inch, 2*inch])
|
||||
invoice_info_table.setStyle(TableStyle([
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
]))
|
||||
|
||||
for line in bill_to_data:
|
||||
elements.append(Paragraph(line[0], normal_style))
|
||||
# From and To section
|
||||
company_name = getattr(settings, 'COMPANY_NAME', 'Igny8')
|
||||
company_email = getattr(settings, 'COMPANY_EMAIL', settings.DEFAULT_FROM_EMAIL)
|
||||
|
||||
elements.append(Spacer(1, 0.3*inch))
|
||||
from_section = [
|
||||
Paragraph('FROM', heading_style),
|
||||
Paragraph(company_name, value_style),
|
||||
Paragraph(company_email, normal_style),
|
||||
]
|
||||
|
||||
customer_name = invoice.account.name if invoice.account else 'N/A'
|
||||
customer_email = invoice.account.owner.email if invoice.account and invoice.account.owner else invoice.account.billing_email if invoice.account else 'N/A'
|
||||
billing_email = invoice.account.billing_email if invoice.account and hasattr(invoice.account, 'billing_email') and invoice.account.billing_email else None
|
||||
|
||||
to_section = [
|
||||
Paragraph('BILL TO', heading_style),
|
||||
Paragraph(customer_name, value_style),
|
||||
Paragraph(customer_email, normal_style),
|
||||
]
|
||||
if billing_email and billing_email != customer_email:
|
||||
to_section.append(Paragraph(f'Billing: {billing_email}', normal_style))
|
||||
|
||||
# Create from/to layout
|
||||
from_content = []
|
||||
for item in from_section:
|
||||
from_content.append([item])
|
||||
from_table = Table(from_content, colWidths=[3*inch])
|
||||
|
||||
to_content = []
|
||||
for item in to_section:
|
||||
to_content.append([item])
|
||||
to_table = Table(to_content, colWidths=[3*inch])
|
||||
|
||||
# Main info layout with From, To, and Invoice details
|
||||
main_info = [[from_table, to_table, invoice_info_table]]
|
||||
main_info_table = Table(main_info, colWidths=[2.3*inch, 2.3*inch, 2.4*inch])
|
||||
main_info_table.setStyle(TableStyle([
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
]))
|
||||
|
||||
elements.append(main_info_table)
|
||||
elements.append(Spacer(1, 0.4*inch))
|
||||
|
||||
# Line items table
|
||||
elements.append(Paragraph('<b>Items:</b>', heading_style))
|
||||
elements.append(Paragraph('ITEMS', heading_style))
|
||||
elements.append(Spacer(1, 0.1*inch))
|
||||
|
||||
# Table header
|
||||
# Table header - use Paragraph for proper rendering
|
||||
line_items_data = [
|
||||
['Description', 'Quantity', 'Unit Price', 'Amount']
|
||||
[
|
||||
Paragraph('Description', ParagraphStyle('Header', fontName='Helvetica-Bold', fontSize=10, textColor=colors.HexColor('#374151'))),
|
||||
Paragraph('Qty', ParagraphStyle('Header', fontName='Helvetica-Bold', fontSize=10, textColor=colors.HexColor('#374151'), alignment=TA_CENTER)),
|
||||
Paragraph('Unit Price', ParagraphStyle('Header', fontName='Helvetica-Bold', fontSize=10, textColor=colors.HexColor('#374151'), alignment=TA_RIGHT)),
|
||||
Paragraph('Amount', ParagraphStyle('Header', fontName='Helvetica-Bold', fontSize=10, textColor=colors.HexColor('#374151'), alignment=TA_RIGHT)),
|
||||
]
|
||||
]
|
||||
|
||||
# Get line items
|
||||
for item in invoice.line_items.all():
|
||||
# Get line items - line_items is a JSON field (list of dicts)
|
||||
items = invoice.line_items or []
|
||||
for item in items:
|
||||
unit_price = float(item.get('unit_price', 0))
|
||||
amount = float(item.get('amount', 0))
|
||||
line_items_data.append([
|
||||
item.description,
|
||||
str(item.quantity),
|
||||
f'{invoice.currency} {item.unit_price:.2f}',
|
||||
f'{invoice.currency} {item.total_price:.2f}'
|
||||
Paragraph(item.get('description', ''), normal_style),
|
||||
Paragraph(str(item.get('quantity', 1)), ParagraphStyle('Center', parent=normal_style, alignment=TA_CENTER)),
|
||||
Paragraph(f'{invoice.currency} {unit_price:.2f}', right_align_style),
|
||||
Paragraph(f'{invoice.currency} {amount:.2f}', right_align_style),
|
||||
])
|
||||
|
||||
# Add subtotal, tax, total rows
|
||||
line_items_data.append(['', '', '<b>Subtotal:</b>', f'<b>{invoice.currency} {invoice.subtotal:.2f}</b>'])
|
||||
|
||||
if invoice.tax_amount and invoice.tax_amount > 0:
|
||||
line_items_data.append(['', '', f'Tax ({invoice.tax_rate}%):', f'{invoice.currency} {invoice.tax_amount:.2f}'])
|
||||
|
||||
if invoice.discount_amount and invoice.discount_amount > 0:
|
||||
line_items_data.append(['', '', 'Discount:', f'-{invoice.currency} {invoice.discount_amount:.2f}'])
|
||||
|
||||
line_items_data.append(['', '', '<b>Total:</b>', f'<b>{invoice.currency} {invoice.total_amount:.2f}</b>'])
|
||||
# Add empty row for spacing before totals
|
||||
line_items_data.append(['', '', '', ''])
|
||||
|
||||
# Create table
|
||||
line_items_table = Table(
|
||||
line_items_data,
|
||||
colWidths=[3*inch, 1*inch, 1.25*inch, 1.25*inch]
|
||||
colWidths=[3.2*inch, 0.8*inch, 1.25*inch, 1.25*inch]
|
||||
)
|
||||
|
||||
num_items = len(items)
|
||||
line_items_table.setStyle(TableStyle([
|
||||
# Header row
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#f3f4f6')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#1f2937')),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 10),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
('TOPPADDING', (0, 0), (-1, 0), 12),
|
||||
|
||||
# Body rows
|
||||
('FONTNAME', (0, 1), (-1, -4), 'Helvetica'),
|
||||
('FONTSIZE', (0, 1), (-1, -4), 9),
|
||||
('TEXTCOLOR', (0, 1), (-1, -4), colors.HexColor('#4b5563')),
|
||||
('ROWBACKGROUNDS', (0, 1), (-1, -4), [colors.white, colors.HexColor('#f9fafb')]),
|
||||
('ROWBACKGROUNDS', (0, 1), (-1, num_items), [colors.white, colors.HexColor('#f9fafb')]),
|
||||
|
||||
# Summary rows (last 3-4 rows)
|
||||
('FONTNAME', (0, -4), (-1, -1), 'Helvetica'),
|
||||
('FONTSIZE', (0, -4), (-1, -1), 9),
|
||||
('ALIGN', (2, 0), (2, -1), 'RIGHT'),
|
||||
('ALIGN', (3, 0), (3, -1), 'RIGHT'),
|
||||
# Alignment
|
||||
('ALIGN', (1, 0), (1, -1), 'CENTER'),
|
||||
('ALIGN', (2, 0), (-1, -1), 'RIGHT'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
|
||||
# Grid
|
||||
('GRID', (0, 0), (-1, -4), 0.5, colors.HexColor('#e5e7eb')),
|
||||
('LINEABOVE', (2, -4), (-1, -4), 1, colors.HexColor('#d1d5db')),
|
||||
('LINEABOVE', (2, -1), (-1, -1), 2, colors.HexColor('#1f2937')),
|
||||
# Grid for items only
|
||||
('LINEBELOW', (0, 0), (-1, 0), 1, colors.HexColor('#d1d5db')),
|
||||
('LINEBELOW', (0, num_items), (-1, num_items), 1, colors.HexColor('#e5e7eb')),
|
||||
|
||||
# Padding
|
||||
('TOPPADDING', (0, 0), (-1, -1), 8),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 10),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 10),
|
||||
('TOPPADDING', (0, 1), (-1, -1), 10),
|
||||
('BOTTOMPADDING', (0, 1), (-1, -1), 10),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 8),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 8),
|
||||
]))
|
||||
|
||||
elements.append(line_items_table)
|
||||
elements.append(Spacer(1, 0.2*inch))
|
||||
|
||||
# Totals section - right aligned
|
||||
totals_data = [
|
||||
[Paragraph('Subtotal:', right_align_style), Paragraph(f'{invoice.currency} {float(invoice.subtotal):.2f}', right_bold_style)],
|
||||
]
|
||||
|
||||
tax_amount = float(invoice.tax or 0)
|
||||
if tax_amount > 0:
|
||||
tax_rate = invoice.metadata.get('tax_rate', 0) if invoice.metadata else 0
|
||||
totals_data.append([
|
||||
Paragraph(f'Tax ({tax_rate}%):', right_align_style),
|
||||
Paragraph(f'{invoice.currency} {tax_amount:.2f}', right_align_style)
|
||||
])
|
||||
|
||||
discount_amount = float(invoice.metadata.get('discount_amount', 0)) if invoice.metadata else 0
|
||||
if discount_amount > 0:
|
||||
totals_data.append([
|
||||
Paragraph('Discount:', right_align_style),
|
||||
Paragraph(f'-{invoice.currency} {discount_amount:.2f}', right_align_style)
|
||||
])
|
||||
|
||||
totals_data.append([
|
||||
Paragraph('Total:', ParagraphStyle('TotalLabel', fontName='Helvetica-Bold', fontSize=12, textColor=colors.HexColor('#1f2937'), alignment=TA_RIGHT)),
|
||||
Paragraph(f'{invoice.currency} {float(invoice.total):.2f}', ParagraphStyle('TotalValue', fontName='Helvetica-Bold', fontSize=12, textColor=colors.HexColor('#1f2937'), alignment=TA_RIGHT))
|
||||
])
|
||||
|
||||
totals_table = Table(totals_data, colWidths=[1.5*inch, 1.5*inch])
|
||||
totals_table.setStyle(TableStyle([
|
||||
('ALIGN', (0, 0), (-1, -1), 'RIGHT'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 6),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
|
||||
('LINEABOVE', (0, -1), (-1, -1), 2, colors.HexColor('#1f2937')),
|
||||
]))
|
||||
|
||||
# Right-align the totals table
|
||||
totals_wrapper = Table([[totals_table]], colWidths=[6.5*inch])
|
||||
totals_wrapper.setStyle(TableStyle([
|
||||
('ALIGN', (0, 0), (0, 0), 'RIGHT'),
|
||||
]))
|
||||
elements.append(totals_wrapper)
|
||||
elements.append(Spacer(1, 0.4*inch))
|
||||
|
||||
# Payment information
|
||||
if invoice.status == 'paid':
|
||||
elements.append(Paragraph('<b>Payment Information:</b>', heading_style))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e5e7eb'), spaceBefore=10, spaceAfter=15))
|
||||
elements.append(Paragraph('PAYMENT INFORMATION', heading_style))
|
||||
|
||||
payment = invoice.payments.filter(status='succeeded').first()
|
||||
if payment:
|
||||
payment_method = payment.get_payment_method_display() if hasattr(payment, 'get_payment_method_display') else str(payment.payment_method)
|
||||
payment_date = payment.processed_at.strftime("%B %d, %Y") if payment.processed_at else 'N/A'
|
||||
|
||||
payment_info = [
|
||||
f'Payment Method: {payment.get_payment_method_display()}',
|
||||
f'Paid On: {payment.processed_at.strftime("%B %d, %Y")}',
|
||||
[Paragraph('Payment Method:', label_style), Paragraph(payment_method, value_style)],
|
||||
[Paragraph('Paid On:', label_style), Paragraph(payment_date, value_style)],
|
||||
]
|
||||
|
||||
if payment.manual_reference:
|
||||
payment_info.append(f'Reference: {payment.manual_reference}')
|
||||
|
||||
for line in payment_info:
|
||||
elements.append(Paragraph(line, normal_style))
|
||||
payment_info.append([Paragraph('Reference:', label_style), Paragraph(payment.manual_reference, value_style)])
|
||||
|
||||
payment_table = Table(payment_info, colWidths=[1.5*inch, 3*inch])
|
||||
payment_table.setStyle(TableStyle([
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
]))
|
||||
elements.append(payment_table)
|
||||
elements.append(Spacer(1, 0.2*inch))
|
||||
|
||||
# Footer / Notes
|
||||
if invoice.notes:
|
||||
elements.append(Spacer(1, 0.2*inch))
|
||||
elements.append(Paragraph('<b>Notes:</b>', heading_style))
|
||||
elements.append(Paragraph('NOTES', heading_style))
|
||||
elements.append(Paragraph(invoice.notes, normal_style))
|
||||
|
||||
# Terms
|
||||
elements.append(Spacer(1, 0.3*inch))
|
||||
elements.append(Paragraph('<b>Terms & Conditions:</b>', heading_style))
|
||||
terms = getattr(settings, 'INVOICE_TERMS', 'Payment is due within 7 days of invoice date.')
|
||||
elements.append(Paragraph(terms, normal_style))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e5e7eb'), spaceAfter=15))
|
||||
|
||||
terms_style = ParagraphStyle(
|
||||
'Terms',
|
||||
parent=styles['Normal'],
|
||||
fontSize=8,
|
||||
textColor=colors.HexColor('#9ca3af'),
|
||||
fontName='Helvetica',
|
||||
)
|
||||
terms = getattr(settings, 'INVOICE_TERMS', 'Payment is due within 7 days of invoice date. Thank you for your business!')
|
||||
elements.append(Paragraph(f'Terms & Conditions: {terms}', terms_style))
|
||||
|
||||
# Footer with company info
|
||||
elements.append(Spacer(1, 0.2*inch))
|
||||
footer_style = ParagraphStyle(
|
||||
'Footer',
|
||||
parent=styles['Normal'],
|
||||
fontSize=8,
|
||||
textColor=colors.HexColor('#9ca3af'),
|
||||
fontName='Helvetica',
|
||||
alignment=TA_CENTER,
|
||||
)
|
||||
elements.append(Paragraph(f'Generated by IGNY8 • {company_email}', footer_style))
|
||||
|
||||
# Build PDF
|
||||
doc.build(elements)
|
||||
|
||||
627
backend/igny8_core/business/billing/services/stripe_service.py
Normal file
627
backend/igny8_core/business/billing/services/stripe_service.py
Normal file
@@ -0,0 +1,627 @@
|
||||
"""
|
||||
Stripe Service - Wrapper for Stripe API operations
|
||||
|
||||
Handles:
|
||||
- Checkout sessions for subscriptions and credit packages
|
||||
- Billing portal sessions for subscription management
|
||||
- Webhook event construction and verification
|
||||
- Customer management
|
||||
|
||||
Configuration stored in IntegrationProvider model (provider_id='stripe')
|
||||
"""
|
||||
import stripe
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from igny8_core.modules.system.models import IntegrationProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StripeConfigurationError(Exception):
|
||||
"""Raised when Stripe is not properly configured"""
|
||||
pass
|
||||
|
||||
|
||||
class StripeService:
|
||||
"""Service for Stripe payment operations"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize Stripe service with credentials from IntegrationProvider.
|
||||
|
||||
Raises:
|
||||
StripeConfigurationError: If Stripe provider not configured or missing credentials
|
||||
"""
|
||||
provider = IntegrationProvider.get_provider('stripe')
|
||||
if not provider:
|
||||
raise StripeConfigurationError(
|
||||
"Stripe provider not configured. Add 'stripe' provider in admin."
|
||||
)
|
||||
|
||||
if not provider.api_secret:
|
||||
raise StripeConfigurationError(
|
||||
"Stripe secret key not configured. Set api_secret in provider."
|
||||
)
|
||||
|
||||
self.is_sandbox = provider.is_sandbox
|
||||
self.provider = provider
|
||||
|
||||
# Set Stripe API key
|
||||
stripe.api_key = provider.api_secret
|
||||
|
||||
# Store keys for reference
|
||||
self.publishable_key = provider.api_key
|
||||
self.webhook_secret = provider.webhook_secret
|
||||
self.config = provider.config or {}
|
||||
|
||||
# Default currency from config
|
||||
self.currency = self.config.get('currency', 'usd')
|
||||
|
||||
logger.info(
|
||||
f"Stripe service initialized (sandbox={self.is_sandbox}, "
|
||||
f"currency={self.currency})"
|
||||
)
|
||||
|
||||
@property
|
||||
def frontend_url(self) -> str:
|
||||
"""Get frontend URL from Django settings"""
|
||||
return getattr(settings, 'FRONTEND_URL', 'http://localhost:3000')
|
||||
|
||||
def get_publishable_key(self) -> str:
|
||||
"""Return publishable key for frontend use"""
|
||||
return self.publishable_key
|
||||
|
||||
# ========== Customer Management ==========
|
||||
|
||||
def _get_or_create_customer(self, account) -> str:
|
||||
"""
|
||||
Get existing Stripe customer or create new one.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
|
||||
Returns:
|
||||
str: Stripe customer ID
|
||||
"""
|
||||
# Return existing customer if available
|
||||
if account.stripe_customer_id:
|
||||
try:
|
||||
# Verify customer still exists in Stripe
|
||||
stripe.Customer.retrieve(account.stripe_customer_id)
|
||||
return account.stripe_customer_id
|
||||
except stripe.error.InvalidRequestError:
|
||||
# Customer was deleted, create new one
|
||||
logger.warning(
|
||||
f"Stripe customer {account.stripe_customer_id} not found, creating new"
|
||||
)
|
||||
|
||||
# Create new customer
|
||||
customer = stripe.Customer.create(
|
||||
email=account.billing_email or account.owner.email,
|
||||
name=account.name,
|
||||
metadata={
|
||||
'account_id': str(account.id),
|
||||
'environment': 'sandbox' if self.is_sandbox else 'production'
|
||||
},
|
||||
)
|
||||
|
||||
# Save customer ID to account
|
||||
account.stripe_customer_id = customer.id
|
||||
account.save(update_fields=['stripe_customer_id', 'updated_at'])
|
||||
|
||||
logger.info(f"Created Stripe customer {customer.id} for account {account.id}")
|
||||
|
||||
return customer.id
|
||||
|
||||
def get_customer(self, account) -> Optional[Dict]:
|
||||
"""
|
||||
Get Stripe customer details.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
|
||||
Returns:
|
||||
dict: Customer data or None if not found
|
||||
"""
|
||||
if not account.stripe_customer_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
customer = stripe.Customer.retrieve(account.stripe_customer_id)
|
||||
return {
|
||||
'id': customer.id,
|
||||
'email': customer.email,
|
||||
'name': customer.name,
|
||||
'created': customer.created,
|
||||
'default_source': customer.default_source,
|
||||
}
|
||||
except stripe.error.InvalidRequestError:
|
||||
return None
|
||||
|
||||
# ========== Checkout Sessions ==========
|
||||
|
||||
def create_checkout_session(
|
||||
self,
|
||||
account,
|
||||
plan,
|
||||
success_url: Optional[str] = None,
|
||||
cancel_url: Optional[str] = None,
|
||||
allow_promotion_codes: bool = True,
|
||||
trial_period_days: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create Stripe Checkout session for new subscription.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
plan: Plan model instance with stripe_price_id
|
||||
success_url: URL to redirect after successful payment
|
||||
cancel_url: URL to redirect if payment is canceled
|
||||
allow_promotion_codes: Allow discount codes in checkout
|
||||
trial_period_days: Optional trial period (overrides plan default)
|
||||
|
||||
Returns:
|
||||
dict: Session data with checkout_url and session_id
|
||||
|
||||
Raises:
|
||||
ValueError: If plan has no stripe_price_id
|
||||
"""
|
||||
if not plan.stripe_price_id:
|
||||
raise ValueError(
|
||||
f"Plan '{plan.name}' (id={plan.id}) has no stripe_price_id configured"
|
||||
)
|
||||
|
||||
# Get or create customer
|
||||
customer_id = self._get_or_create_customer(account)
|
||||
|
||||
# Build URLs
|
||||
if not success_url:
|
||||
success_url = f'{self.frontend_url}/account/plans?success=true&session_id={{CHECKOUT_SESSION_ID}}'
|
||||
if not cancel_url:
|
||||
cancel_url = f'{self.frontend_url}/account/plans?canceled=true'
|
||||
|
||||
# Build subscription data
|
||||
subscription_data = {
|
||||
'metadata': {
|
||||
'account_id': str(account.id),
|
||||
'plan_id': str(plan.id),
|
||||
}
|
||||
}
|
||||
|
||||
if trial_period_days:
|
||||
subscription_data['trial_period_days'] = trial_period_days
|
||||
|
||||
# Create checkout session
|
||||
session = stripe.checkout.Session.create(
|
||||
customer=customer_id,
|
||||
payment_method_types=self.config.get('payment_methods', ['card']),
|
||||
mode='subscription',
|
||||
line_items=[{
|
||||
'price': plan.stripe_price_id,
|
||||
'quantity': 1,
|
||||
}],
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
allow_promotion_codes=allow_promotion_codes,
|
||||
metadata={
|
||||
'account_id': str(account.id),
|
||||
'plan_id': str(plan.id),
|
||||
'type': 'subscription',
|
||||
},
|
||||
subscription_data=subscription_data,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created Stripe checkout session {session.id} for account {account.id}, "
|
||||
f"plan {plan.name}"
|
||||
)
|
||||
|
||||
return {
|
||||
'checkout_url': session.url,
|
||||
'session_id': session.id,
|
||||
}
|
||||
|
||||
def create_credit_checkout_session(
|
||||
self,
|
||||
account,
|
||||
credit_package,
|
||||
success_url: Optional[str] = None,
|
||||
cancel_url: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create Stripe Checkout session for one-time credit purchase.
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
credit_package: CreditPackage model instance
|
||||
success_url: URL to redirect after successful payment
|
||||
cancel_url: URL to redirect if payment is canceled
|
||||
|
||||
Returns:
|
||||
dict: Session data with checkout_url and session_id
|
||||
"""
|
||||
# Get or create customer
|
||||
customer_id = self._get_or_create_customer(account)
|
||||
|
||||
# Build URLs
|
||||
if not success_url:
|
||||
success_url = f'{self.frontend_url}/account/usage?purchase=success&session_id={{CHECKOUT_SESSION_ID}}'
|
||||
if not cancel_url:
|
||||
cancel_url = f'{self.frontend_url}/account/usage?purchase=canceled'
|
||||
|
||||
# Use existing Stripe price if available, otherwise create price_data
|
||||
if credit_package.stripe_price_id:
|
||||
line_items = [{
|
||||
'price': credit_package.stripe_price_id,
|
||||
'quantity': 1,
|
||||
}]
|
||||
else:
|
||||
# Create price_data for dynamic pricing
|
||||
line_items = [{
|
||||
'price_data': {
|
||||
'currency': self.currency,
|
||||
'product_data': {
|
||||
'name': credit_package.name,
|
||||
'description': f'{credit_package.credits} credits',
|
||||
},
|
||||
'unit_amount': int(credit_package.price * 100), # Convert to cents
|
||||
},
|
||||
'quantity': 1,
|
||||
}]
|
||||
|
||||
# Create checkout session
|
||||
session = stripe.checkout.Session.create(
|
||||
customer=customer_id,
|
||||
payment_method_types=self.config.get('payment_methods', ['card']),
|
||||
mode='payment',
|
||||
line_items=line_items,
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
metadata={
|
||||
'account_id': str(account.id),
|
||||
'credit_package_id': str(credit_package.id),
|
||||
'credit_amount': str(credit_package.credits),
|
||||
'type': 'credit_purchase',
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created Stripe credit checkout session {session.id} for account {account.id}, "
|
||||
f"package {credit_package.name} ({credit_package.credits} credits)"
|
||||
)
|
||||
|
||||
return {
|
||||
'checkout_url': session.url,
|
||||
'session_id': session.id,
|
||||
}
|
||||
|
||||
def get_checkout_session(self, session_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Retrieve checkout session details.
|
||||
|
||||
Args:
|
||||
session_id: Stripe checkout session ID
|
||||
|
||||
Returns:
|
||||
dict: Session data or None if not found
|
||||
"""
|
||||
try:
|
||||
session = stripe.checkout.Session.retrieve(session_id)
|
||||
return {
|
||||
'id': session.id,
|
||||
'status': session.status,
|
||||
'payment_status': session.payment_status,
|
||||
'customer': session.customer,
|
||||
'subscription': session.subscription,
|
||||
'metadata': session.metadata,
|
||||
'amount_total': session.amount_total,
|
||||
'currency': session.currency,
|
||||
}
|
||||
except stripe.error.InvalidRequestError as e:
|
||||
logger.error(f"Failed to retrieve checkout session {session_id}: {e}")
|
||||
return None
|
||||
|
||||
# ========== Billing Portal ==========
|
||||
|
||||
def create_billing_portal_session(
|
||||
self,
|
||||
account,
|
||||
return_url: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create Stripe Billing Portal session for subscription management.
|
||||
|
||||
Allows customers to:
|
||||
- Update payment method
|
||||
- View billing history
|
||||
- Cancel subscription
|
||||
- Update billing info
|
||||
|
||||
Args:
|
||||
account: Account model instance
|
||||
return_url: URL to return to after portal session
|
||||
|
||||
Returns:
|
||||
dict: Portal session data with portal_url
|
||||
|
||||
Raises:
|
||||
ValueError: If account has no Stripe customer
|
||||
"""
|
||||
if not self.config.get('billing_portal_enabled', True):
|
||||
raise ValueError("Billing portal is disabled in configuration")
|
||||
|
||||
# Get or create customer
|
||||
customer_id = self._get_or_create_customer(account)
|
||||
|
||||
if not return_url:
|
||||
return_url = f'{self.frontend_url}/account/plans'
|
||||
|
||||
# Create billing portal session
|
||||
session = stripe.billing_portal.Session.create(
|
||||
customer=customer_id,
|
||||
return_url=return_url,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created Stripe billing portal session for account {account.id}"
|
||||
)
|
||||
|
||||
return {
|
||||
'portal_url': session.url,
|
||||
}
|
||||
|
||||
# ========== Subscription Management ==========
|
||||
|
||||
def get_subscription(self, subscription_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get subscription details from Stripe.
|
||||
|
||||
Args:
|
||||
subscription_id: Stripe subscription ID
|
||||
|
||||
Returns:
|
||||
dict: Subscription data or None if not found
|
||||
"""
|
||||
try:
|
||||
sub = stripe.Subscription.retrieve(subscription_id)
|
||||
return {
|
||||
'id': sub.id,
|
||||
'status': sub.status,
|
||||
'current_period_start': sub.current_period_start,
|
||||
'current_period_end': sub.current_period_end,
|
||||
'cancel_at_period_end': sub.cancel_at_period_end,
|
||||
'canceled_at': sub.canceled_at,
|
||||
'ended_at': sub.ended_at,
|
||||
'customer': sub.customer,
|
||||
'items': [{
|
||||
'id': item.id,
|
||||
'price_id': item.price.id,
|
||||
'quantity': item.quantity,
|
||||
} for item in sub['items'].data],
|
||||
'metadata': sub.metadata,
|
||||
}
|
||||
except stripe.error.InvalidRequestError as e:
|
||||
logger.error(f"Failed to retrieve subscription {subscription_id}: {e}")
|
||||
return None
|
||||
|
||||
def cancel_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
at_period_end: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Cancel a Stripe subscription.
|
||||
|
||||
Args:
|
||||
subscription_id: Stripe subscription ID
|
||||
at_period_end: If True, cancel at end of billing period
|
||||
|
||||
Returns:
|
||||
dict: Updated subscription data
|
||||
"""
|
||||
if at_period_end:
|
||||
sub = stripe.Subscription.modify(
|
||||
subscription_id,
|
||||
cancel_at_period_end=True
|
||||
)
|
||||
logger.info(f"Subscription {subscription_id} marked for cancellation at period end")
|
||||
else:
|
||||
sub = stripe.Subscription.delete(subscription_id)
|
||||
logger.info(f"Subscription {subscription_id} canceled immediately")
|
||||
|
||||
return {
|
||||
'id': sub.id,
|
||||
'status': sub.status,
|
||||
'cancel_at_period_end': sub.cancel_at_period_end,
|
||||
}
|
||||
|
||||
def update_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
new_price_id: str,
|
||||
proration_behavior: str = 'create_prorations'
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update subscription to a new plan/price.
|
||||
|
||||
Args:
|
||||
subscription_id: Stripe subscription ID
|
||||
new_price_id: New Stripe price ID
|
||||
proration_behavior: How to handle proration
|
||||
- 'create_prorations': Prorate the change
|
||||
- 'none': No proration
|
||||
- 'always_invoice': Invoice immediately
|
||||
|
||||
Returns:
|
||||
dict: Updated subscription data
|
||||
"""
|
||||
# Get current subscription
|
||||
sub = stripe.Subscription.retrieve(subscription_id)
|
||||
|
||||
# Update the subscription item
|
||||
updated = stripe.Subscription.modify(
|
||||
subscription_id,
|
||||
items=[{
|
||||
'id': sub['items'].data[0].id,
|
||||
'price': new_price_id,
|
||||
}],
|
||||
proration_behavior=proration_behavior,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Updated subscription {subscription_id} to price {new_price_id}"
|
||||
)
|
||||
|
||||
return {
|
||||
'id': updated.id,
|
||||
'status': updated.status,
|
||||
'current_period_end': updated.current_period_end,
|
||||
}
|
||||
|
||||
# ========== Webhook Handling ==========
|
||||
|
||||
def construct_webhook_event(
|
||||
self,
|
||||
payload: bytes,
|
||||
sig_header: str
|
||||
) -> stripe.Event:
|
||||
"""
|
||||
Verify and construct webhook event from Stripe.
|
||||
|
||||
Args:
|
||||
payload: Raw request body
|
||||
sig_header: Stripe-Signature header value
|
||||
|
||||
Returns:
|
||||
stripe.Event: Verified event object
|
||||
|
||||
Raises:
|
||||
stripe.error.SignatureVerificationError: If signature is invalid
|
||||
"""
|
||||
if not self.webhook_secret:
|
||||
raise StripeConfigurationError(
|
||||
"Webhook secret not configured. Set webhook_secret in provider."
|
||||
)
|
||||
|
||||
return stripe.Webhook.construct_event(
|
||||
payload, sig_header, self.webhook_secret
|
||||
)
|
||||
|
||||
# ========== Invoice Operations ==========
|
||||
|
||||
def get_invoice(self, invoice_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get invoice details from Stripe.
|
||||
|
||||
Args:
|
||||
invoice_id: Stripe invoice ID
|
||||
|
||||
Returns:
|
||||
dict: Invoice data or None if not found
|
||||
"""
|
||||
try:
|
||||
invoice = stripe.Invoice.retrieve(invoice_id)
|
||||
return {
|
||||
'id': invoice.id,
|
||||
'status': invoice.status,
|
||||
'amount_due': invoice.amount_due,
|
||||
'amount_paid': invoice.amount_paid,
|
||||
'currency': invoice.currency,
|
||||
'customer': invoice.customer,
|
||||
'subscription': invoice.subscription,
|
||||
'invoice_pdf': invoice.invoice_pdf,
|
||||
'hosted_invoice_url': invoice.hosted_invoice_url,
|
||||
}
|
||||
except stripe.error.InvalidRequestError as e:
|
||||
logger.error(f"Failed to retrieve invoice {invoice_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_upcoming_invoice(self, customer_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get upcoming invoice for a customer.
|
||||
|
||||
Args:
|
||||
customer_id: Stripe customer ID
|
||||
|
||||
Returns:
|
||||
dict: Upcoming invoice preview or None
|
||||
"""
|
||||
try:
|
||||
invoice = stripe.Invoice.upcoming(customer=customer_id)
|
||||
return {
|
||||
'amount_due': invoice.amount_due,
|
||||
'currency': invoice.currency,
|
||||
'next_payment_attempt': invoice.next_payment_attempt,
|
||||
'lines': [{
|
||||
'description': line.description,
|
||||
'amount': line.amount,
|
||||
} for line in invoice.lines.data],
|
||||
}
|
||||
except stripe.error.InvalidRequestError:
|
||||
return None
|
||||
|
||||
# ========== Refunds ==========
|
||||
|
||||
def create_refund(
|
||||
self,
|
||||
payment_intent_id: Optional[str] = None,
|
||||
charge_id: Optional[str] = None,
|
||||
amount: Optional[int] = None,
|
||||
reason: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a refund for a payment.
|
||||
|
||||
Args:
|
||||
payment_intent_id: Stripe PaymentIntent ID
|
||||
charge_id: Stripe Charge ID (alternative to payment_intent_id)
|
||||
amount: Amount to refund in cents (None for full refund)
|
||||
reason: Reason for refund ('duplicate', 'fraudulent', 'requested_by_customer')
|
||||
|
||||
Returns:
|
||||
dict: Refund data
|
||||
"""
|
||||
params = {}
|
||||
|
||||
if payment_intent_id:
|
||||
params['payment_intent'] = payment_intent_id
|
||||
elif charge_id:
|
||||
params['charge'] = charge_id
|
||||
else:
|
||||
raise ValueError("Either payment_intent_id or charge_id required")
|
||||
|
||||
if amount:
|
||||
params['amount'] = amount
|
||||
|
||||
if reason:
|
||||
params['reason'] = reason
|
||||
|
||||
refund = stripe.Refund.create(**params)
|
||||
|
||||
logger.info(
|
||||
f"Created refund {refund.id} for "
|
||||
f"{'payment_intent ' + payment_intent_id if payment_intent_id else 'charge ' + charge_id}"
|
||||
)
|
||||
|
||||
return {
|
||||
'id': refund.id,
|
||||
'amount': refund.amount,
|
||||
'status': refund.status,
|
||||
'reason': refund.reason,
|
||||
}
|
||||
|
||||
|
||||
# Convenience function
|
||||
def get_stripe_service() -> StripeService:
|
||||
"""
|
||||
Get StripeService instance.
|
||||
|
||||
Returns:
|
||||
StripeService: Initialized service
|
||||
|
||||
Raises:
|
||||
StripeConfigurationError: If Stripe not configured
|
||||
"""
|
||||
return StripeService()
|
||||
@@ -172,7 +172,7 @@ def _attempt_stripe_renewal(subscription: Subscription, invoice: Invoice) -> boo
|
||||
payment_method='stripe',
|
||||
status='processing',
|
||||
stripe_payment_intent_id=intent.id,
|
||||
metadata={'renewal': True}
|
||||
metadata={'renewal': True, 'auto_approved': True}
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -210,7 +210,7 @@ def _attempt_paypal_renewal(subscription: Subscription, invoice: Invoice) -> boo
|
||||
payment_method='paypal',
|
||||
status='processing',
|
||||
paypal_order_id=subscription.metadata['paypal_subscription_id'],
|
||||
metadata={'renewal': True}
|
||||
metadata={'renewal': True, 'auto_approved': True}
|
||||
)
|
||||
return True
|
||||
else:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Billing routes including bank transfer confirmation and credit endpoints."""
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import (
|
||||
from .billing_views import (
|
||||
BillingViewSet,
|
||||
InvoiceViewSet,
|
||||
PaymentViewSet,
|
||||
@@ -15,6 +15,24 @@ from igny8_core.modules.billing.views import (
|
||||
CreditTransactionViewSet,
|
||||
AIModelConfigViewSet,
|
||||
)
|
||||
# Payment gateway views
|
||||
from .views.stripe_views import (
|
||||
StripeConfigView,
|
||||
StripeCheckoutView,
|
||||
StripeCreditCheckoutView,
|
||||
StripeBillingPortalView,
|
||||
StripeReturnVerificationView,
|
||||
stripe_webhook,
|
||||
)
|
||||
from .views.paypal_views import (
|
||||
PayPalConfigView,
|
||||
PayPalCreateOrderView,
|
||||
PayPalCreateSubscriptionOrderView,
|
||||
PayPalCaptureOrderView,
|
||||
PayPalCreateSubscriptionView,
|
||||
PayPalReturnVerificationView,
|
||||
paypal_webhook,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'admin', BillingViewSet, basename='billing-admin')
|
||||
@@ -35,4 +53,21 @@ urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
# User-facing usage summary endpoint for plan limits
|
||||
path('usage-summary/', get_usage_summary, name='usage-summary'),
|
||||
|
||||
# Stripe endpoints
|
||||
path('stripe/config/', StripeConfigView.as_view(), name='stripe-config'),
|
||||
path('stripe/checkout/', StripeCheckoutView.as_view(), name='stripe-checkout'),
|
||||
path('stripe/credit-checkout/', StripeCreditCheckoutView.as_view(), name='stripe-credit-checkout'),
|
||||
path('stripe/billing-portal/', StripeBillingPortalView.as_view(), name='stripe-billing-portal'),
|
||||
path('stripe/verify-return/', StripeReturnVerificationView.as_view(), name='stripe-verify-return'),
|
||||
path('webhooks/stripe/', stripe_webhook, name='stripe-webhook'),
|
||||
|
||||
# PayPal endpoints
|
||||
path('paypal/config/', PayPalConfigView.as_view(), name='paypal-config'),
|
||||
path('paypal/create-order/', PayPalCreateOrderView.as_view(), name='paypal-create-order'),
|
||||
path('paypal/create-subscription-order/', PayPalCreateSubscriptionOrderView.as_view(), name='paypal-create-subscription-order'),
|
||||
path('paypal/capture-order/', PayPalCaptureOrderView.as_view(), name='paypal-capture-order'),
|
||||
path('paypal/create-subscription/', PayPalCreateSubscriptionView.as_view(), name='paypal-create-subscription'),
|
||||
path('paypal/verify-return/', PayPalReturnVerificationView.as_view(), name='paypal-verify-return'),
|
||||
path('webhooks/paypal/', paypal_webhook, name='paypal-webhook'),
|
||||
]
|
||||
|
||||
@@ -5,6 +5,8 @@ API endpoints for generating and downloading invoice PDFs
|
||||
from django.http import HttpResponse
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from igny8_core.business.billing.models import Invoice
|
||||
from igny8_core.business.billing.services.pdf_service import InvoicePDFGenerator
|
||||
from igny8_core.business.billing.utils.errors import not_found_response
|
||||
@@ -22,20 +24,46 @@ def download_invoice_pdf(request, invoice_id):
|
||||
GET /api/v1/billing/invoices/<id>/pdf/
|
||||
"""
|
||||
try:
|
||||
invoice = Invoice.objects.prefetch_related('line_items').get(
|
||||
# Note: line_items is a JSONField, not a related model - no prefetch needed
|
||||
invoice = Invoice.objects.select_related('account', 'account__owner', 'subscription', 'subscription__plan').get(
|
||||
id=invoice_id,
|
||||
account=request.user.account
|
||||
)
|
||||
except Invoice.DoesNotExist:
|
||||
return not_found_response('Invoice', invoice_id)
|
||||
|
||||
# Generate PDF
|
||||
pdf_buffer = InvoicePDFGenerator.generate_invoice_pdf(invoice)
|
||||
|
||||
# Return PDF response
|
||||
response = HttpResponse(pdf_buffer.read(), content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="invoice_{invoice.invoice_number}.pdf"'
|
||||
|
||||
logger.info(f'Invoice PDF downloaded: {invoice.invoice_number} by user {request.user.id}')
|
||||
|
||||
return response
|
||||
try:
|
||||
# Generate PDF
|
||||
pdf_buffer = InvoicePDFGenerator.generate_invoice_pdf(invoice)
|
||||
|
||||
# Build descriptive filename: IGNY8-Invoice-INV123456-Growth-2026-01-08.pdf
|
||||
plan_name = ''
|
||||
if invoice.subscription and invoice.subscription.plan:
|
||||
plan_name = invoice.subscription.plan.name.replace(' ', '-')
|
||||
elif invoice.metadata and 'plan_name' in invoice.metadata:
|
||||
plan_name = invoice.metadata['plan_name'].replace(' ', '-')
|
||||
|
||||
date_str = invoice.invoice_date.strftime('%Y-%m-%d') if invoice.invoice_date else ''
|
||||
|
||||
filename_parts = ['IGNY8', 'Invoice', invoice.invoice_number]
|
||||
if plan_name:
|
||||
filename_parts.append(plan_name)
|
||||
if date_str:
|
||||
filename_parts.append(date_str)
|
||||
|
||||
filename = '-'.join(filename_parts) + '.pdf'
|
||||
|
||||
# Return PDF response
|
||||
response = HttpResponse(pdf_buffer.read(), content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
|
||||
logger.info(f'Invoice PDF downloaded: {invoice.invoice_number} by user {request.user.id}')
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to generate PDF for invoice {invoice_id}: {str(e)}', exc_info=True)
|
||||
return Response(
|
||||
{'error': 'Failed to generate PDF', 'detail': str(e)},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
1106
backend/igny8_core/business/billing/views/paypal_views.py
Normal file
1106
backend/igny8_core/business/billing/views/paypal_views.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -160,20 +160,18 @@ def initiate_refund(request, payment_id):
|
||||
def _process_stripe_refund(payment: Payment, amount: Decimal, reason: str) -> bool:
|
||||
"""Process Stripe refund"""
|
||||
try:
|
||||
import stripe
|
||||
from igny8_core.business.billing.utils.payment_gateways import get_stripe_client
|
||||
from igny8_core.business.billing.services.stripe_service import StripeService
|
||||
|
||||
stripe_client = get_stripe_client()
|
||||
stripe_service = StripeService()
|
||||
|
||||
refund = stripe_client.Refund.create(
|
||||
payment_intent=payment.stripe_payment_intent_id,
|
||||
refund = stripe_service.create_refund(
|
||||
payment_intent_id=payment.stripe_payment_intent_id,
|
||||
amount=int(amount * 100), # Convert to cents
|
||||
reason='requested_by_customer',
|
||||
metadata={'reason': reason}
|
||||
)
|
||||
|
||||
payment.metadata['stripe_refund_id'] = refund.id
|
||||
return refund.status == 'succeeded'
|
||||
payment.metadata['stripe_refund_id'] = refund.get('id')
|
||||
return refund.get('status') == 'succeeded'
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Stripe refund failed for payment {payment.id}: {str(e)}")
|
||||
@@ -183,25 +181,19 @@ def _process_stripe_refund(payment: Payment, amount: Decimal, reason: str) -> bo
|
||||
def _process_paypal_refund(payment: Payment, amount: Decimal, reason: str) -> bool:
|
||||
"""Process PayPal refund"""
|
||||
try:
|
||||
from igny8_core.business.billing.utils.payment_gateways import get_paypal_client
|
||||
from igny8_core.business.billing.services.paypal_service import PayPalService
|
||||
|
||||
paypal_client = get_paypal_client()
|
||||
paypal_service = PayPalService()
|
||||
|
||||
refund_request = {
|
||||
'amount': {
|
||||
'value': str(amount),
|
||||
'currency_code': payment.currency
|
||||
},
|
||||
'note_to_payer': reason
|
||||
}
|
||||
|
||||
refund = paypal_client.payments.captures.refund(
|
||||
payment.paypal_capture_id,
|
||||
refund_request
|
||||
refund = paypal_service.refund_capture(
|
||||
capture_id=payment.paypal_capture_id,
|
||||
amount=float(amount),
|
||||
currency=payment.currency,
|
||||
note=reason,
|
||||
)
|
||||
|
||||
payment.metadata['paypal_refund_id'] = refund.id
|
||||
return refund.status == 'COMPLETED'
|
||||
payment.metadata['paypal_refund_id'] = refund.get('id')
|
||||
return refund.get('status') == 'COMPLETED'
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"PayPal refund failed for payment {payment.id}: {str(e)}")
|
||||
|
||||
1016
backend/igny8_core/business/billing/views/stripe_views.py
Normal file
1016
backend/igny8_core/business/billing/views/stripe_views.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -568,10 +568,33 @@ class Images(SoftDeletableModel, SiteSectorBaseModel):
|
||||
models.Index(fields=['content', 'position']),
|
||||
models.Index(fields=['task', 'position']),
|
||||
]
|
||||
# Ensure unique position per content+image_type combination
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['content', 'image_type', 'position'],
|
||||
name='unique_content_image_type_position',
|
||||
condition=models.Q(is_deleted=False)
|
||||
),
|
||||
]
|
||||
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = models.Manager()
|
||||
|
||||
@property
|
||||
def aspect_ratio(self):
|
||||
"""
|
||||
Determine aspect ratio based on position for layout rendering.
|
||||
Position 0, 2: square (1:1)
|
||||
Position 1, 3: landscape (16:9 or similar)
|
||||
Featured: always landscape
|
||||
"""
|
||||
if self.image_type == 'featured':
|
||||
return 'landscape'
|
||||
elif self.image_type == 'in_article':
|
||||
# Even positions are square, odd positions are landscape
|
||||
return 'square' if (self.position or 0) % 2 == 0 else 'landscape'
|
||||
return 'square' # Default
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Track image usage when creating new images"""
|
||||
is_new = self.pk is None
|
||||
|
||||
@@ -68,6 +68,10 @@ class DefaultsService:
|
||||
Returns:
|
||||
Tuple of (Site, PublishingSettings, AutomationConfig)
|
||||
"""
|
||||
# Check hard limit for sites BEFORE creating
|
||||
from igny8_core.business.billing.services.limit_service import LimitService, HardLimitExceededError
|
||||
LimitService.check_hard_limit(self.account, 'sites', additional_count=1)
|
||||
|
||||
# Create the site
|
||||
site = Site.objects.create(
|
||||
account=self.account,
|
||||
|
||||
152
backend/igny8_core/management/commands/cleanup_user_data.py
Normal file
152
backend/igny8_core/management/commands/cleanup_user_data.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Management command to clean up all user-generated data (DESTRUCTIVE).
|
||||
This is used before V1.0 production launch to start with a clean database.
|
||||
|
||||
⚠️ WARNING: This permanently deletes ALL user data!
|
||||
|
||||
Usage:
|
||||
# DRY RUN (recommended first):
|
||||
python manage.py cleanup_user_data --dry-run
|
||||
|
||||
# ACTUAL CLEANUP (after reviewing dry-run):
|
||||
python manage.py cleanup_user_data --confirm
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clean up all user-generated data (DESTRUCTIVE - for pre-launch cleanup)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--confirm',
|
||||
action='store_true',
|
||||
help='Confirm you want to delete all user data'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be deleted without actually deleting'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if not options['confirm'] and not options['dry_run']:
|
||||
self.stdout.write(
|
||||
self.style.ERROR('\n⚠️ ERROR: Must use --confirm or --dry-run flag\n')
|
||||
)
|
||||
self.stdout.write('Usage:')
|
||||
self.stdout.write(' python manage.py cleanup_user_data --dry-run # See what will be deleted')
|
||||
self.stdout.write(' python manage.py cleanup_user_data --confirm # Actually delete data\n')
|
||||
return
|
||||
|
||||
# Safety check: Prevent running in production unless explicitly allowed
|
||||
if getattr(settings, 'ENVIRONMENT', 'production') == 'production' and options['confirm']:
|
||||
self.stdout.write(
|
||||
self.style.ERROR('\n⚠️ BLOCKED: Cannot run cleanup in PRODUCTION environment!\n')
|
||||
)
|
||||
self.stdout.write('To allow this, temporarily set ENVIRONMENT to "staging" in settings.\n')
|
||||
return
|
||||
|
||||
# Import models
|
||||
from igny8_core.auth.models import Site, CustomUser
|
||||
from igny8_core.business.planning.models import Keywords, Clusters
|
||||
from igny8_core.business.content.models import ContentIdea, Tasks, Content, Images
|
||||
from igny8_core.modules.publisher.models import PublishingRecord
|
||||
from igny8_core.business.integration.models import WordPressSyncEvent
|
||||
from igny8_core.modules.billing.models import CreditTransaction, CreditUsageLog, Order
|
||||
from igny8_core.modules.system.models import Notification
|
||||
from igny8_core.modules.writer.models import AutomationRun
|
||||
|
||||
# Define models to clear (ORDER MATTERS - foreign keys)
|
||||
# Delete child records before parent records
|
||||
models_to_clear = [
|
||||
('Notifications', Notification),
|
||||
('Credit Usage Logs', CreditUsageLog),
|
||||
('Credit Transactions', CreditTransaction),
|
||||
('Orders', Order),
|
||||
('WordPress Sync Events', WordPressSyncEvent),
|
||||
('Publishing Records', PublishingRecord),
|
||||
('Automation Runs', AutomationRun),
|
||||
('Images', Images),
|
||||
('Content', Content),
|
||||
('Tasks', Tasks),
|
||||
('Content Ideas', ContentIdea),
|
||||
('Clusters', Clusters),
|
||||
('Keywords', Keywords),
|
||||
('Sites', Site), # Sites should be near last (many foreign keys)
|
||||
# Note: We do NOT delete CustomUser - keep admin users
|
||||
]
|
||||
|
||||
if options['dry_run']:
|
||||
self.stdout.write(self.style.WARNING('\n' + '=' * 70))
|
||||
self.stdout.write(self.style.WARNING('DRY RUN - No data will be deleted'))
|
||||
self.stdout.write(self.style.WARNING('=' * 70 + '\n'))
|
||||
|
||||
total_records = 0
|
||||
for name, model in models_to_clear:
|
||||
count = model.objects.count()
|
||||
total_records += count
|
||||
status = '✓' if count > 0 else '·'
|
||||
self.stdout.write(f' {status} Would delete {count:6d} {name}')
|
||||
|
||||
# Count users (not deleted)
|
||||
user_count = CustomUser.objects.count()
|
||||
self.stdout.write(f'\n → Keeping {user_count:6d} Users (not deleted)')
|
||||
|
||||
self.stdout.write(f'\n Total records to delete: {total_records:,}')
|
||||
self.stdout.write('\n' + '=' * 70)
|
||||
self.stdout.write(self.style.SUCCESS('\nTo proceed with actual deletion, run:'))
|
||||
self.stdout.write(' python manage.py cleanup_user_data --confirm\n')
|
||||
return
|
||||
|
||||
# ACTUAL DELETION
|
||||
self.stdout.write(self.style.ERROR('\n' + '=' * 70))
|
||||
self.stdout.write(self.style.ERROR('⚠️ DELETING ALL USER DATA - THIS CANNOT BE UNDONE!'))
|
||||
self.stdout.write(self.style.ERROR('=' * 70 + '\n'))
|
||||
|
||||
# Final confirmation prompt
|
||||
confirm_text = input('Type "DELETE ALL DATA" to proceed: ')
|
||||
if confirm_text != 'DELETE ALL DATA':
|
||||
self.stdout.write(self.style.WARNING('\nAborted. Data was NOT deleted.\n'))
|
||||
return
|
||||
|
||||
self.stdout.write('\nProceeding with deletion...\n')
|
||||
|
||||
deleted_counts = {}
|
||||
failed_deletions = []
|
||||
|
||||
with transaction.atomic():
|
||||
for name, model in models_to_clear:
|
||||
try:
|
||||
count = model.objects.count()
|
||||
if count > 0:
|
||||
model.objects.all().delete()
|
||||
deleted_counts[name] = count
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'✓ Deleted {count:6d} {name}')
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'· Skipped {count:6d} {name} (already empty)')
|
||||
)
|
||||
except Exception as e:
|
||||
failed_deletions.append((name, str(e)))
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'✗ Failed to delete {name}: {str(e)}')
|
||||
)
|
||||
|
||||
# Summary
|
||||
total_deleted = sum(deleted_counts.values())
|
||||
self.stdout.write('\n' + '=' * 70)
|
||||
self.stdout.write(self.style.SUCCESS(f'\nUser Data Cleanup Complete!\n'))
|
||||
self.stdout.write(f' Total records deleted: {total_deleted:,}')
|
||||
self.stdout.write(f' Failed deletions: {len(failed_deletions)}')
|
||||
|
||||
if failed_deletions:
|
||||
self.stdout.write(self.style.WARNING('\nFailed deletions:'))
|
||||
for name, error in failed_deletions:
|
||||
self.stdout.write(f' - {name}: {error}')
|
||||
|
||||
self.stdout.write('\n' + '=' * 70 + '\n')
|
||||
122
backend/igny8_core/management/commands/export_system_config.py
Normal file
122
backend/igny8_core/management/commands/export_system_config.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Management command to export system configuration data to JSON files.
|
||||
This exports Plans, Credit Costs, AI Models, Industries, Sectors, Seed Keywords, etc.
|
||||
|
||||
Usage:
|
||||
python manage.py export_system_config --output-dir=backups/config
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core import serializers
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Export system configuration data to JSON files for V1.0 backup'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--output-dir',
|
||||
default='backups/config',
|
||||
help='Output directory for config files (relative to project root)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
output_dir = options['output_dir']
|
||||
|
||||
# Make output_dir absolute if it's relative
|
||||
if not os.path.isabs(output_dir):
|
||||
# Get project root (parent of manage.py)
|
||||
import sys
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
output_dir = os.path.join(project_root, '..', output_dir)
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'\nExporting system configuration to: {output_dir}\n'))
|
||||
|
||||
# Import models
|
||||
from igny8_core.modules.billing.models import Plan, CreditCostConfig
|
||||
from igny8_core.modules.system.models import AIModelConfig, GlobalIntegrationSettings
|
||||
from igny8_core.auth.models import Industry, Sector, SeedKeyword, AuthorProfile
|
||||
from igny8_core.ai.models import Prompt, PromptVariable
|
||||
|
||||
# Define what to export
|
||||
exports = {
|
||||
'plans': (Plan.objects.all(), 'Subscription Plans'),
|
||||
'credit_costs': (CreditCostConfig.objects.all(), 'Credit Cost Configurations'),
|
||||
'ai_models': (AIModelConfig.objects.all(), 'AI Model Configurations'),
|
||||
'global_integrations': (GlobalIntegrationSettings.objects.all(), 'Global Integration Settings'),
|
||||
'industries': (Industry.objects.all(), 'Industries'),
|
||||
'sectors': (Sector.objects.all(), 'Sectors'),
|
||||
'seed_keywords': (SeedKeyword.objects.all(), 'Seed Keywords'),
|
||||
'author_profiles': (AuthorProfile.objects.all(), 'Author Profiles'),
|
||||
'prompts': (Prompt.objects.all(), 'AI Prompts'),
|
||||
'prompt_variables': (PromptVariable.objects.all(), 'Prompt Variables'),
|
||||
}
|
||||
|
||||
successful_exports = []
|
||||
failed_exports = []
|
||||
|
||||
for name, (queryset, description) in exports.items():
|
||||
try:
|
||||
count = queryset.count()
|
||||
data = serializers.serialize('json', queryset, indent=2)
|
||||
filepath = os.path.join(output_dir, f'{name}.json')
|
||||
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(data)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'✓ Exported {count:4d} {description:30s} → {name}.json')
|
||||
)
|
||||
successful_exports.append(name)
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'✗ Failed to export {description}: {str(e)}')
|
||||
)
|
||||
failed_exports.append((name, str(e)))
|
||||
|
||||
# Export metadata
|
||||
metadata = {
|
||||
'exported_at': datetime.now().isoformat(),
|
||||
'django_version': self.get_django_version(),
|
||||
'database': self.get_database_info(),
|
||||
'successful_exports': successful_exports,
|
||||
'failed_exports': failed_exports,
|
||||
'export_count': len(successful_exports),
|
||||
}
|
||||
|
||||
metadata_path = os.path.join(output_dir, 'export_metadata.json')
|
||||
with open(metadata_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'\n✓ Metadata saved to export_metadata.json'))
|
||||
|
||||
# Summary
|
||||
self.stdout.write('\n' + '=' * 70)
|
||||
self.stdout.write(self.style.SUCCESS(f'\nSystem Configuration Export Complete!\n'))
|
||||
self.stdout.write(f' Successful: {len(successful_exports)} exports')
|
||||
self.stdout.write(f' Failed: {len(failed_exports)} exports')
|
||||
self.stdout.write(f' Location: {output_dir}\n')
|
||||
|
||||
if failed_exports:
|
||||
self.stdout.write(self.style.WARNING('\nFailed exports:'))
|
||||
for name, error in failed_exports:
|
||||
self.stdout.write(f' - {name}: {error}')
|
||||
|
||||
self.stdout.write('=' * 70 + '\n')
|
||||
|
||||
def get_django_version(self):
|
||||
import django
|
||||
return django.get_version()
|
||||
|
||||
def get_database_info(self):
|
||||
from django.conf import settings
|
||||
db_config = settings.DATABASES.get('default', {})
|
||||
return {
|
||||
'engine': db_config.get('ENGINE', '').split('.')[-1],
|
||||
'name': db_config.get('NAME', ''),
|
||||
}
|
||||
@@ -519,6 +519,30 @@ class PaymentMethodConfigAdmin(Igny8ModelAdmin):
|
||||
search_fields = ['country_code', 'display_name', 'payment_method']
|
||||
list_editable = ['is_enabled', 'sort_order']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Payment Method', {
|
||||
'fields': ('country_code', 'payment_method', 'display_name', 'is_enabled', 'sort_order')
|
||||
}),
|
||||
('Instructions', {
|
||||
'fields': ('instructions',),
|
||||
'description': 'Instructions shown to users for this payment method'
|
||||
}),
|
||||
('Bank Transfer Details', {
|
||||
'fields': ('bank_name', 'account_title', 'account_number', 'routing_number', 'swift_code', 'iban'),
|
||||
'classes': ('collapse',),
|
||||
'description': 'Only for bank_transfer payment method'
|
||||
}),
|
||||
('Local Wallet Details', {
|
||||
'fields': ('wallet_type', 'wallet_id'),
|
||||
'classes': ('collapse',),
|
||||
'description': 'Only for local_wallet payment method (JazzCash, EasyPaisa, etc.)'
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(AccountPaymentMethod)
|
||||
@@ -552,19 +576,18 @@ class AccountPaymentMethodAdmin(AccountAdminMixin, Igny8ModelAdmin):
|
||||
|
||||
@admin.register(CreditCostConfig)
|
||||
class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
||||
"""
|
||||
Admin for Credit Cost Configuration.
|
||||
Per final-model-schemas.md - Fixed credits per operation type.
|
||||
"""
|
||||
list_display = [
|
||||
'operation_type',
|
||||
'display_name',
|
||||
'tokens_per_credit_display',
|
||||
'price_per_credit_usd',
|
||||
'min_credits',
|
||||
'is_active',
|
||||
'cost_change_indicator',
|
||||
'updated_at',
|
||||
'updated_by'
|
||||
'base_credits_display',
|
||||
'is_active_icon',
|
||||
]
|
||||
|
||||
list_filter = ['is_active', 'updated_at']
|
||||
list_filter = ['is_active']
|
||||
search_fields = ['operation_type', 'display_name', 'description']
|
||||
actions = ['bulk_activate', 'bulk_deactivate']
|
||||
|
||||
@@ -572,60 +595,30 @@ class CreditCostConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
||||
('Operation', {
|
||||
'fields': ('operation_type', 'display_name', 'description')
|
||||
}),
|
||||
('Token-to-Credit Configuration', {
|
||||
'fields': ('tokens_per_credit', 'min_credits', 'price_per_credit_usd', 'is_active'),
|
||||
'description': 'Configure how tokens are converted to credits for this operation'
|
||||
}),
|
||||
('Audit Trail', {
|
||||
'fields': ('previous_tokens_per_credit', 'updated_by', 'created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
('Credits', {
|
||||
'fields': ('base_credits', 'is_active'),
|
||||
'description': 'Fixed credits charged per operation'
|
||||
}),
|
||||
)
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at', 'previous_tokens_per_credit']
|
||||
|
||||
def tokens_per_credit_display(self, obj):
|
||||
"""Show token ratio with color coding"""
|
||||
if obj.tokens_per_credit <= 50:
|
||||
color = 'red' # Expensive (low tokens per credit)
|
||||
elif obj.tokens_per_credit <= 100:
|
||||
color = 'orange'
|
||||
else:
|
||||
color = 'green' # Cheap (high tokens per credit)
|
||||
def base_credits_display(self, obj):
|
||||
"""Show base credits with formatting"""
|
||||
return format_html(
|
||||
'<span style="color: {}; font-weight: bold;">{} tokens/credit</span>',
|
||||
color,
|
||||
obj.tokens_per_credit
|
||||
'<span style="font-weight: bold;">{} credits</span>',
|
||||
obj.base_credits
|
||||
)
|
||||
tokens_per_credit_display.short_description = 'Token Ratio'
|
||||
base_credits_display.short_description = 'Credits'
|
||||
|
||||
def cost_change_indicator(self, obj):
|
||||
"""Show if token ratio changed recently"""
|
||||
if obj.previous_tokens_per_credit is not None:
|
||||
if obj.tokens_per_credit < obj.previous_tokens_per_credit:
|
||||
icon = '📈' # More expensive (fewer tokens per credit)
|
||||
color = 'red'
|
||||
elif obj.tokens_per_credit > obj.previous_tokens_per_credit:
|
||||
icon = '📉' # Cheaper (more tokens per credit)
|
||||
color = 'green'
|
||||
else:
|
||||
icon = '➡️' # Same
|
||||
color = 'gray'
|
||||
|
||||
def is_active_icon(self, obj):
|
||||
"""Active status icon"""
|
||||
if obj.is_active:
|
||||
return format_html(
|
||||
'{} <span style="color: {};">({} → {})</span>',
|
||||
icon,
|
||||
color,
|
||||
obj.previous_tokens_per_credit,
|
||||
obj.tokens_per_credit
|
||||
'<span style="color: green; font-size: 18px;" title="Active">●</span>'
|
||||
)
|
||||
return '—'
|
||||
cost_change_indicator.short_description = 'Recent Change'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Track who made the change"""
|
||||
obj.updated_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
return format_html(
|
||||
'<span style="color: red; font-size: 18px;" title="Inactive">●</span>'
|
||||
)
|
||||
is_active_icon.short_description = 'Active'
|
||||
|
||||
@admin.action(description='Activate selected configurations')
|
||||
def bulk_activate(self, request, queryset):
|
||||
@@ -763,67 +756,60 @@ class BillingConfigurationAdmin(Igny8ModelAdmin):
|
||||
@admin.register(AIModelConfig)
|
||||
class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
||||
"""
|
||||
Admin for AI Model Configuration - Database-driven model pricing
|
||||
Replaces hardcoded MODEL_RATES and IMAGE_MODEL_RATES
|
||||
Admin for AI Model Configuration - Single Source of Truth for Models.
|
||||
Per final-model-schemas.md
|
||||
"""
|
||||
list_display = [
|
||||
'model_name',
|
||||
'display_name_short',
|
||||
'model_type_badge',
|
||||
'provider_badge',
|
||||
'pricing_display',
|
||||
'credit_display',
|
||||
'quality_tier',
|
||||
'is_active_icon',
|
||||
'is_default_icon',
|
||||
'sort_order',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'model_type',
|
||||
'provider',
|
||||
'quality_tier',
|
||||
'is_active',
|
||||
'is_default',
|
||||
'supports_json_mode',
|
||||
'supports_vision',
|
||||
'supports_function_calling',
|
||||
]
|
||||
|
||||
search_fields = ['model_name', 'display_name', 'description']
|
||||
search_fields = ['model_name', 'display_name']
|
||||
|
||||
ordering = ['model_type', 'sort_order', 'model_name']
|
||||
ordering = ['model_type', 'model_name']
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at', 'updated_by']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {
|
||||
'fields': ('model_name', 'display_name', 'model_type', 'provider', 'description'),
|
||||
'description': 'Core model identification and classification'
|
||||
'fields': ('model_name', 'model_type', 'provider', 'display_name'),
|
||||
'description': 'Core model identification'
|
||||
}),
|
||||
('Text Model Pricing', {
|
||||
'fields': ('input_cost_per_1m', 'output_cost_per_1m', 'context_window', 'max_output_tokens'),
|
||||
'description': 'Pricing and limits for TEXT models only (leave blank for image models)',
|
||||
'fields': ('cost_per_1k_input', 'cost_per_1k_output', 'tokens_per_credit', 'max_tokens', 'context_window'),
|
||||
'description': 'For TEXT models only',
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Image Model Pricing', {
|
||||
'fields': ('cost_per_image', 'valid_sizes'),
|
||||
'description': 'Pricing and configuration for IMAGE models only (leave blank for text models)',
|
||||
'fields': ('credits_per_image', 'quality_tier'),
|
||||
'description': 'For IMAGE models only',
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Capabilities', {
|
||||
'fields': ('supports_json_mode', 'supports_vision', 'supports_function_calling'),
|
||||
'description': 'Model features and capabilities'
|
||||
}),
|
||||
('Status & Display', {
|
||||
'fields': ('is_active', 'is_default', 'sort_order'),
|
||||
'description': 'Control model availability and ordering in dropdowns'
|
||||
}),
|
||||
('Lifecycle', {
|
||||
'fields': ('release_date', 'deprecation_date'),
|
||||
'description': 'Model release and deprecation dates',
|
||||
'fields': ('capabilities',),
|
||||
'description': 'JSON: vision, function_calling, json_mode, etc.',
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Audit Trail', {
|
||||
'fields': ('created_at', 'updated_at', 'updated_by'),
|
||||
('Status', {
|
||||
'fields': ('is_active', 'is_default'),
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
@@ -831,8 +817,8 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
||||
# Custom display methods
|
||||
def display_name_short(self, obj):
|
||||
"""Truncated display name for list view"""
|
||||
if len(obj.display_name) > 50:
|
||||
return obj.display_name[:47] + '...'
|
||||
if len(obj.display_name) > 40:
|
||||
return obj.display_name[:37] + '...'
|
||||
return obj.display_name
|
||||
display_name_short.short_description = 'Display Name'
|
||||
|
||||
@@ -841,7 +827,6 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
||||
colors = {
|
||||
'text': '#3498db', # Blue
|
||||
'image': '#e74c3c', # Red
|
||||
'embedding': '#2ecc71', # Green
|
||||
}
|
||||
color = colors.get(obj.model_type, '#95a5a6')
|
||||
return format_html(
|
||||
@@ -855,10 +840,10 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
||||
def provider_badge(self, obj):
|
||||
"""Colored badge for provider"""
|
||||
colors = {
|
||||
'openai': '#10a37f', # OpenAI green
|
||||
'anthropic': '#d97757', # Anthropic orange
|
||||
'runware': '#6366f1', # Purple
|
||||
'google': '#4285f4', # Google blue
|
||||
'openai': '#10a37f',
|
||||
'anthropic': '#d97757',
|
||||
'runware': '#6366f1',
|
||||
'google': '#4285f4',
|
||||
}
|
||||
color = colors.get(obj.provider, '#95a5a6')
|
||||
return format_html(
|
||||
@@ -869,23 +854,20 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
||||
)
|
||||
provider_badge.short_description = 'Provider'
|
||||
|
||||
def pricing_display(self, obj):
|
||||
"""Format pricing based on model type"""
|
||||
if obj.model_type == 'text':
|
||||
def credit_display(self, obj):
|
||||
"""Format credit info based on model type"""
|
||||
if obj.model_type == 'text' and obj.tokens_per_credit:
|
||||
return format_html(
|
||||
'<span style="color: #2c3e50; font-family: monospace;">'
|
||||
'${} / ${} per 1M</span>',
|
||||
obj.input_cost_per_1m,
|
||||
obj.output_cost_per_1m
|
||||
'<span style="font-family: monospace;">{} tokens/credit</span>',
|
||||
obj.tokens_per_credit
|
||||
)
|
||||
elif obj.model_type == 'image':
|
||||
elif obj.model_type == 'image' and obj.credits_per_image:
|
||||
return format_html(
|
||||
'<span style="color: #2c3e50; font-family: monospace;">'
|
||||
'${} per image</span>',
|
||||
obj.cost_per_image
|
||||
'<span style="font-family: monospace;">{} credits/image</span>',
|
||||
obj.credits_per_image
|
||||
)
|
||||
return '-'
|
||||
pricing_display.short_description = 'Pricing'
|
||||
credit_display.short_description = 'Credits'
|
||||
|
||||
def is_active_icon(self, obj):
|
||||
"""Active status icon"""
|
||||
@@ -915,41 +897,27 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
||||
def bulk_activate(self, request, queryset):
|
||||
"""Enable selected models"""
|
||||
count = queryset.update(is_active=True)
|
||||
self.message_user(
|
||||
request,
|
||||
f'{count} model(s) activated successfully.',
|
||||
messages.SUCCESS
|
||||
)
|
||||
self.message_user(request, f'{count} model(s) activated.', messages.SUCCESS)
|
||||
bulk_activate.short_description = 'Activate selected models'
|
||||
|
||||
def bulk_deactivate(self, request, queryset):
|
||||
"""Disable selected models"""
|
||||
count = queryset.update(is_active=False)
|
||||
self.message_user(
|
||||
request,
|
||||
f'{count} model(s) deactivated successfully.',
|
||||
messages.WARNING
|
||||
)
|
||||
self.message_user(request, f'{count} model(s) deactivated.', messages.WARNING)
|
||||
bulk_deactivate.short_description = 'Deactivate selected models'
|
||||
|
||||
def set_as_default(self, request, queryset):
|
||||
"""Set one model as default for its type"""
|
||||
if queryset.count() != 1:
|
||||
self.message_user(
|
||||
request,
|
||||
'Please select exactly one model to set as default.',
|
||||
messages.ERROR
|
||||
)
|
||||
self.message_user(request, 'Select exactly one model.', messages.ERROR)
|
||||
return
|
||||
|
||||
model = queryset.first()
|
||||
# Unset other defaults for same type
|
||||
AIModelConfig.objects.filter(
|
||||
model_type=model.model_type,
|
||||
is_default=True
|
||||
).exclude(pk=model.pk).update(is_default=False)
|
||||
|
||||
# Set this as default
|
||||
model.is_default = True
|
||||
model.save()
|
||||
|
||||
@@ -958,9 +926,4 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
|
||||
f'{model.model_name} is now the default {model.get_model_type_display()} model.',
|
||||
messages.SUCCESS
|
||||
)
|
||||
set_as_default.short_description = 'Set as default model (for its type)'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Track who made the change"""
|
||||
obj.updated_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
set_as_default.short_description = 'Set as default model'
|
||||
|
||||
@@ -29,23 +29,10 @@ class Command(BaseCommand):
|
||||
],
|
||||
'Planner': [
|
||||
('max_keywords', 'Max Keywords'),
|
||||
('max_clusters', 'Max Clusters'),
|
||||
('max_content_ideas', 'Max Content Ideas'),
|
||||
('daily_cluster_limit', 'Daily Cluster Limit'),
|
||||
('max_ahrefs_queries', 'Max Ahrefs Queries'),
|
||||
],
|
||||
'Writer': [
|
||||
('monthly_word_count_limit', 'Monthly Word Count Limit'),
|
||||
('daily_content_tasks', 'Daily Content Tasks'),
|
||||
],
|
||||
'Images': [
|
||||
('monthly_image_count', 'Monthly Image Count'),
|
||||
('daily_image_generation_limit', 'Daily Image Generation Limit'),
|
||||
],
|
||||
'AI Credits': [
|
||||
('monthly_ai_credit_limit', 'Monthly AI Credit Limit'),
|
||||
('monthly_cluster_ai_credits', 'Monthly Cluster AI Credits'),
|
||||
('monthly_content_ai_credits', 'Monthly Content AI Credits'),
|
||||
('monthly_image_ai_credits', 'Monthly Image AI Credits'),
|
||||
'Credits': [
|
||||
('included_credits', 'Included Credits'),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Migration: Update Runware model configurations in AIModelConfig
|
||||
|
||||
This migration:
|
||||
1. Updates runware:97@1 to have display_name "Hi Dream Full - Standard"
|
||||
2. Adds Bria 3.2 model as civitai:618692@691639
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_runware_models(apps, schema_editor):
|
||||
"""Update Runware models in AIModelConfig"""
|
||||
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
|
||||
|
||||
# Update existing runware:97@1 model
|
||||
AIModelConfig.objects.update_or_create(
|
||||
model_name='runware:97@1',
|
||||
defaults={
|
||||
'display_name': 'Hi Dream Full - Standard',
|
||||
'model_type': 'image',
|
||||
'provider': 'runware',
|
||||
'cost_per_image': Decimal('0.008'),
|
||||
'valid_sizes': ['512x512', '768x768', '1024x1024', '1024x1792', '1792x1024'],
|
||||
'supports_json_mode': False,
|
||||
'supports_vision': False,
|
||||
'supports_function_calling': False,
|
||||
'is_active': True,
|
||||
'is_default': True, # Make this the default Runware model
|
||||
'sort_order': 10,
|
||||
'description': 'Hi Dream Full - Standard quality image generation via Runware',
|
||||
}
|
||||
)
|
||||
|
||||
# Add Bria 3.2 Premium model
|
||||
AIModelConfig.objects.update_or_create(
|
||||
model_name='civitai:618692@691639',
|
||||
defaults={
|
||||
'display_name': 'Bria 3.2 - Premium',
|
||||
'model_type': 'image',
|
||||
'provider': 'runware',
|
||||
'cost_per_image': Decimal('0.012'),
|
||||
'valid_sizes': ['512x512', '768x768', '1024x1024', '1024x1792', '1792x1024'],
|
||||
'supports_json_mode': False,
|
||||
'supports_vision': False,
|
||||
'supports_function_calling': False,
|
||||
'is_active': True,
|
||||
'is_default': False,
|
||||
'sort_order': 11,
|
||||
'description': 'Bria 3.2 - Premium quality image generation via Runware/Civitai',
|
||||
}
|
||||
)
|
||||
|
||||
# Optionally remove the old runware:100@1 and runware:101@1 models if they exist
|
||||
AIModelConfig.objects.filter(
|
||||
model_name__in=['runware:100@1', 'runware:101@1']
|
||||
).update(is_active=False)
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
"""Reverse the migration"""
|
||||
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
|
||||
|
||||
# Restore old display name
|
||||
AIModelConfig.objects.filter(model_name='runware:97@1').update(
|
||||
display_name='Runware Standard',
|
||||
is_default=False,
|
||||
)
|
||||
|
||||
# Remove Bria 3.2 model
|
||||
AIModelConfig.objects.filter(model_name='civitai:618692@691639').delete()
|
||||
|
||||
# Re-activate old models
|
||||
AIModelConfig.objects.filter(
|
||||
model_name__in=['runware:100@1', 'runware:101@1']
|
||||
).update(is_active=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0022_fix_historical_calculation_mode_null'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_runware_models, reverse_migration),
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Migration: Update Runware/Image model configurations for new model structure
|
||||
|
||||
This migration:
|
||||
1. Updates runware:97@1 to "Hi Dream Full - Basic"
|
||||
2. Adds Bria 3.2 model as bria:10@1 (correct AIR ID)
|
||||
3. Adds Nano Banana (Google) as google:4@2 (Premium tier)
|
||||
4. Removes old civitai model reference
|
||||
5. Adds one_liner_description field values
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_image_models(apps, schema_editor):
|
||||
"""Update image models in AIModelConfig"""
|
||||
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
|
||||
|
||||
# Update existing runware:97@1 model
|
||||
AIModelConfig.objects.update_or_create(
|
||||
model_name='runware:97@1',
|
||||
defaults={
|
||||
'display_name': 'Hi Dream Full - Basic',
|
||||
'model_type': 'image',
|
||||
'provider': 'runware',
|
||||
'cost_per_image': Decimal('0.006'), # Basic tier, cheaper
|
||||
'valid_sizes': ['1024x1024', '1280x768', '768x1280'],
|
||||
'supports_json_mode': False,
|
||||
'supports_vision': False,
|
||||
'supports_function_calling': False,
|
||||
'is_active': True,
|
||||
'is_default': True,
|
||||
'sort_order': 10,
|
||||
'description': 'Fast & affordable image generation. Steps: 20, CFG: 7. Good for quick iterations.',
|
||||
}
|
||||
)
|
||||
|
||||
# Add Bria 3.2 model with correct AIR ID
|
||||
AIModelConfig.objects.update_or_create(
|
||||
model_name='bria:10@1',
|
||||
defaults={
|
||||
'display_name': 'Bria 3.2 - Quality',
|
||||
'model_type': 'image',
|
||||
'provider': 'runware', # Via Runware API
|
||||
'cost_per_image': Decimal('0.010'), # Quality tier
|
||||
'valid_sizes': ['1024x1024', '1344x768', '768x1344', '1216x832', '832x1216'],
|
||||
'supports_json_mode': False,
|
||||
'supports_vision': False,
|
||||
'supports_function_calling': False,
|
||||
'is_active': True,
|
||||
'is_default': False,
|
||||
'sort_order': 11,
|
||||
'description': 'Commercial-safe AI. Steps: 8, prompt enhancement enabled. Licensed training data.',
|
||||
}
|
||||
)
|
||||
|
||||
# Add Nano Banana (Google) Premium model
|
||||
AIModelConfig.objects.update_or_create(
|
||||
model_name='google:4@2',
|
||||
defaults={
|
||||
'display_name': 'Nano Banana - Premium',
|
||||
'model_type': 'image',
|
||||
'provider': 'runware', # Via Runware API
|
||||
'cost_per_image': Decimal('0.015'), # Premium tier
|
||||
'valid_sizes': ['1024x1024', '1376x768', '768x1376', '1264x848', '848x1264'],
|
||||
'supports_json_mode': False,
|
||||
'supports_vision': False,
|
||||
'supports_function_calling': False,
|
||||
'is_active': True,
|
||||
'is_default': False,
|
||||
'sort_order': 12,
|
||||
'description': 'Google Gemini 3 Pro. Best quality, text rendering, advanced reasoning. Premium pricing.',
|
||||
}
|
||||
)
|
||||
|
||||
# Deactivate old civitai model (replaced by correct bria:10@1)
|
||||
AIModelConfig.objects.filter(
|
||||
model_name='civitai:618692@691639'
|
||||
).update(is_active=False)
|
||||
|
||||
# Deactivate other old models
|
||||
AIModelConfig.objects.filter(
|
||||
model_name__in=['runware:100@1', 'runware:101@1']
|
||||
).update(is_active=False)
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
"""Reverse the migration"""
|
||||
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
|
||||
|
||||
# Restore old display names
|
||||
AIModelConfig.objects.filter(model_name='runware:97@1').update(
|
||||
display_name='Hi Dream Full - Standard',
|
||||
)
|
||||
|
||||
# Remove new models
|
||||
AIModelConfig.objects.filter(model_name__in=['bria:10@1', 'google:4@2']).delete()
|
||||
|
||||
# Re-activate old models
|
||||
AIModelConfig.objects.filter(
|
||||
model_name__in=['runware:100@1', 'runware:101@1', 'civitai:618692@691639']
|
||||
).update(is_active=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0023_update_runware_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_image_models, reverse_migration),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-04 06:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0024_update_image_models_v2'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aimodelconfig',
|
||||
name='credits_per_image',
|
||||
field=models.IntegerField(blank=True, help_text='Fixed credits per image generated. For image models only. (e.g., 1, 5, 15)', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aimodelconfig',
|
||||
name='quality_tier',
|
||||
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], help_text='Quality tier for frontend UI display (Basic/Quality/Premium). For image models.', max_length=20, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aimodelconfig',
|
||||
name='tokens_per_credit',
|
||||
field=models.IntegerField(blank=True, help_text='Number of tokens that equal 1 credit. For text models only. (e.g., 1000, 10000)', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='credits_per_image',
|
||||
field=models.IntegerField(blank=True, help_text='Fixed credits per image generated. For image models only. (e.g., 1, 5, 15)', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='quality_tier',
|
||||
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], help_text='Quality tier for frontend UI display (Basic/Quality/Premium). For image models.', max_length=20, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='tokens_per_credit',
|
||||
field=models.IntegerField(blank=True, help_text='Number of tokens that equal 1 credit. For text models only. (e.g., 1000, 10000)', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
# Generated manually for data migration
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def populate_aimodel_credit_fields(apps, schema_editor):
|
||||
"""
|
||||
Populate credit calculation fields in AIModelConfig.
|
||||
- Text models: tokens_per_credit (how many tokens = 1 credit)
|
||||
- Image models: credits_per_image (fixed credits per image) + quality_tier
|
||||
"""
|
||||
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
|
||||
|
||||
# Text models: tokens_per_credit
|
||||
text_model_credits = {
|
||||
'gpt-4o-mini': 10000, # Cheap model: 10k tokens = 1 credit
|
||||
'gpt-4o': 1000, # Premium model: 1k tokens = 1 credit
|
||||
'gpt-5.1': 1000, # Default model: 1k tokens = 1 credit
|
||||
'gpt-5.2': 1000, # Future model
|
||||
'gpt-4.1': 1000, # Legacy
|
||||
'gpt-4-turbo-preview': 500, # Expensive
|
||||
}
|
||||
|
||||
for model_name, tokens_per_credit in text_model_credits.items():
|
||||
AIModelConfig.objects.filter(
|
||||
model_name=model_name,
|
||||
model_type='text'
|
||||
).update(tokens_per_credit=tokens_per_credit)
|
||||
|
||||
# Image models: credits_per_image + quality_tier
|
||||
image_model_credits = {
|
||||
'runware:97@1': {'credits_per_image': 1, 'quality_tier': 'basic'}, # Basic - cheap
|
||||
'dall-e-3': {'credits_per_image': 5, 'quality_tier': 'quality'}, # Quality - mid
|
||||
'google:4@2': {'credits_per_image': 15, 'quality_tier': 'premium'}, # Premium - expensive
|
||||
'dall-e-2': {'credits_per_image': 2, 'quality_tier': 'basic'}, # Legacy
|
||||
}
|
||||
|
||||
for model_name, credits_data in image_model_credits.items():
|
||||
AIModelConfig.objects.filter(
|
||||
model_name=model_name,
|
||||
model_type='image'
|
||||
).update(**credits_data)
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
"""Clear credit fields"""
|
||||
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
|
||||
AIModelConfig.objects.all().update(
|
||||
tokens_per_credit=None,
|
||||
credits_per_image=None,
|
||||
quality_tier=None
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0025_add_aimodel_credit_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(populate_aimodel_credit_fields, reverse_migration),
|
||||
]
|
||||
@@ -0,0 +1,356 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-04 10:40
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0026_populate_aimodel_credits'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='aimodelconfig',
|
||||
options={'ordering': ['model_type', 'model_name'], 'verbose_name': 'AI Model Configuration', 'verbose_name_plural': 'AI Model Configurations'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='aimodelconfig',
|
||||
name='cost_per_image',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='aimodelconfig',
|
||||
name='deprecation_date',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='aimodelconfig',
|
||||
name='description',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='aimodelconfig',
|
||||
name='input_cost_per_1m',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='aimodelconfig',
|
||||
name='max_output_tokens',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='aimodelconfig',
|
||||
name='output_cost_per_1m',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='aimodelconfig',
|
||||
name='release_date',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='aimodelconfig',
|
||||
name='sort_order',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='aimodelconfig',
|
||||
name='supports_function_calling',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='aimodelconfig',
|
||||
name='supports_json_mode',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='aimodelconfig',
|
||||
name='supports_vision',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='aimodelconfig',
|
||||
name='updated_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='aimodelconfig',
|
||||
name='valid_sizes',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='creditcostconfig',
|
||||
name='created_at',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='creditcostconfig',
|
||||
name='id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='creditcostconfig',
|
||||
name='min_credits',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='creditcostconfig',
|
||||
name='previous_tokens_per_credit',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='creditcostconfig',
|
||||
name='price_per_credit_usd',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='creditcostconfig',
|
||||
name='tokens_per_credit',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='creditcostconfig',
|
||||
name='updated_at',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='creditcostconfig',
|
||||
name='updated_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='cost_per_image',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='deprecation_date',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='description',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='input_cost_per_1m',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='max_output_tokens',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='output_cost_per_1m',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='release_date',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='sort_order',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='supports_function_calling',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='supports_json_mode',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='supports_vision',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='updated_by',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='valid_sizes',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalcreditcostconfig',
|
||||
name='created_at',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalcreditcostconfig',
|
||||
name='id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalcreditcostconfig',
|
||||
name='min_credits',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalcreditcostconfig',
|
||||
name='previous_tokens_per_credit',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalcreditcostconfig',
|
||||
name='price_per_credit_usd',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalcreditcostconfig',
|
||||
name='tokens_per_credit',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalcreditcostconfig',
|
||||
name='updated_at',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='historicalcreditcostconfig',
|
||||
name='updated_by',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aimodelconfig',
|
||||
name='capabilities',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Capabilities: vision, function_calling, json_mode, etc.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aimodelconfig',
|
||||
name='cost_per_1k_input',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Provider cost per 1K input tokens (USD) - text models', max_digits=10, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aimodelconfig',
|
||||
name='cost_per_1k_output',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Provider cost per 1K output tokens (USD) - text models', max_digits=10, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aimodelconfig',
|
||||
name='max_tokens',
|
||||
field=models.IntegerField(blank=True, help_text='Model token limit', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='creditcostconfig',
|
||||
name='base_credits',
|
||||
field=models.IntegerField(default=1, help_text='Fixed credits per operation', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='capabilities',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Capabilities: vision, function_calling, json_mode, etc.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='cost_per_1k_input',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Provider cost per 1K input tokens (USD) - text models', max_digits=10, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='cost_per_1k_output',
|
||||
field=models.DecimalField(blank=True, decimal_places=6, help_text='Provider cost per 1K output tokens (USD) - text models', max_digits=10, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='max_tokens',
|
||||
field=models.IntegerField(blank=True, help_text='Model token limit', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='historicalcreditcostconfig',
|
||||
name='base_credits',
|
||||
field=models.IntegerField(default=1, help_text='Fixed credits per operation', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aimodelconfig',
|
||||
name='context_window',
|
||||
field=models.IntegerField(blank=True, help_text='Model context size', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aimodelconfig',
|
||||
name='credits_per_image',
|
||||
field=models.IntegerField(blank=True, help_text='Image: credits per image (e.g., 1, 5, 15)', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aimodelconfig',
|
||||
name='display_name',
|
||||
field=models.CharField(help_text='Human-readable name', max_length=200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aimodelconfig',
|
||||
name='is_active',
|
||||
field=models.BooleanField(db_index=True, default=True, help_text='Enable/disable'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aimodelconfig',
|
||||
name='is_default',
|
||||
field=models.BooleanField(db_index=True, default=False, help_text='One default per type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aimodelconfig',
|
||||
name='model_name',
|
||||
field=models.CharField(db_index=True, help_text="Model identifier (e.g., 'gpt-5.1', 'dall-e-3', 'runware:97@1')", max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aimodelconfig',
|
||||
name='model_type',
|
||||
field=models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation')], db_index=True, help_text='text / image', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aimodelconfig',
|
||||
name='provider',
|
||||
field=models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('google', 'Google')], db_index=True, help_text='Links to IntegrationProvider', max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aimodelconfig',
|
||||
name='quality_tier',
|
||||
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], help_text='basic / quality / premium - for image models', max_length=20, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aimodelconfig',
|
||||
name='tokens_per_credit',
|
||||
field=models.IntegerField(blank=True, help_text='Text: tokens per 1 credit (e.g., 1000, 10000)', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='creditcostconfig',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, help_text='Admin notes about this operation'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='creditcostconfig',
|
||||
name='operation_type',
|
||||
field=models.CharField(help_text="Unique operation ID (e.g., 'article_generation', 'image_generation')", max_length=50, primary_key=True, serialize=False, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='context_window',
|
||||
field=models.IntegerField(blank=True, help_text='Model context size', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='credits_per_image',
|
||||
field=models.IntegerField(blank=True, help_text='Image: credits per image (e.g., 1, 5, 15)', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='display_name',
|
||||
field=models.CharField(help_text='Human-readable name', max_length=200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='is_active',
|
||||
field=models.BooleanField(db_index=True, default=True, help_text='Enable/disable'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='is_default',
|
||||
field=models.BooleanField(db_index=True, default=False, help_text='One default per type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='model_name',
|
||||
field=models.CharField(db_index=True, help_text="Model identifier (e.g., 'gpt-5.1', 'dall-e-3', 'runware:97@1')", max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='model_type',
|
||||
field=models.CharField(choices=[('text', 'Text Generation'), ('image', 'Image Generation')], db_index=True, help_text='text / image', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='provider',
|
||||
field=models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic'), ('runware', 'Runware'), ('google', 'Google')], db_index=True, help_text='Links to IntegrationProvider', max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='quality_tier',
|
||||
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], help_text='basic / quality / premium - for image models', max_length=20, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalaimodelconfig',
|
||||
name='tokens_per_credit',
|
||||
field=models.IntegerField(blank=True, help_text='Text: tokens per 1 credit (e.g., 1000, 10000)', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalcreditcostconfig',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, help_text='Admin notes about this operation'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalcreditcostconfig',
|
||||
name='operation_type',
|
||||
field=models.CharField(db_index=True, help_text="Unique operation ID (e.g., 'article_generation', 'image_generation')", max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-07 03:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0027_model_schema_update'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='api_key',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='api_secret',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='webhook_secret',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='webhook_url',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='account_title',
|
||||
field=models.CharField(blank=True, help_text='Account holder name', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='iban',
|
||||
field=models.CharField(blank=True, help_text='IBAN for international transfers', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='country_code',
|
||||
field=models.CharField(db_index=True, help_text="ISO 2-letter country code (e.g., US, GB, PK) or '*' for global", max_length=2),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='routing_number',
|
||||
field=models.CharField(blank=True, help_text='Routing/Sort code', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='swift_code',
|
||||
field=models.CharField(blank=True, help_text='SWIFT/BIC code for international', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='wallet_id',
|
||||
field=models.CharField(blank=True, help_text='Mobile number or wallet ID', max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='paymentmethodconfig',
|
||||
name='wallet_type',
|
||||
field=models.CharField(blank=True, help_text='E.g., JazzCash, EasyPaisa, etc.', max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-07 12:26
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0028_cleanup_payment_method_config'),
|
||||
('igny8_core_auth', '0020_fix_historical_account'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WebhookEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('event_id', models.CharField(db_index=True, help_text='Unique event ID from the payment provider', max_length=255, unique=True)),
|
||||
('provider', models.CharField(choices=[('stripe', 'Stripe'), ('paypal', 'PayPal')], db_index=True, help_text='Payment provider (stripe or paypal)', max_length=20)),
|
||||
('event_type', models.CharField(db_index=True, help_text='Event type from the provider', max_length=100)),
|
||||
('payload', models.JSONField(help_text='Full webhook payload')),
|
||||
('processed', models.BooleanField(db_index=True, default=False, help_text='Whether this event has been successfully processed')),
|
||||
('processed_at', models.DateTimeField(blank=True, help_text='When the event was processed', null=True)),
|
||||
('error_message', models.TextField(blank=True, help_text='Error message if processing failed')),
|
||||
('retry_count', models.IntegerField(default=0, help_text='Number of processing attempts')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Webhook Event',
|
||||
'verbose_name_plural': 'Webhook Events',
|
||||
'db_table': 'igny8_webhook_events',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalpayment',
|
||||
name='manual_reference',
|
||||
field=models.CharField(blank=True, help_text='Bank transfer reference, wallet transaction ID, etc.', max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='payment',
|
||||
name='manual_reference',
|
||||
field=models.CharField(blank=True, help_text='Bank transfer reference, wallet transaction ID, etc.', max_length=255, null=True),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='payment',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('manual_reference__isnull', False), models.Q(('manual_reference', ''), _negated=True)), fields=('manual_reference',), name='unique_manual_reference_when_not_null'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='webhookevent',
|
||||
index=models.Index(fields=['provider', 'event_type'], name='igny8_webho_provide_ee8a78_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='webhookevent',
|
||||
index=models.Index(fields=['processed', 'created_at'], name='igny8_webho_process_88c670_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='webhookevent',
|
||||
index=models.Index(fields=['provider', 'processed'], name='igny8_webho_provide_df293b_idx'),
|
||||
),
|
||||
]
|
||||
@@ -255,6 +255,23 @@ class AIModelConfigSerializer(serializers.Serializer):
|
||||
)
|
||||
valid_sizes = serializers.ListField(read_only=True, allow_null=True)
|
||||
|
||||
# Credit calculation fields (NEW)
|
||||
credits_per_image = serializers.IntegerField(
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
help_text="Credits charged per image generation"
|
||||
)
|
||||
tokens_per_credit = serializers.IntegerField(
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
help_text="Tokens per credit for text models"
|
||||
)
|
||||
quality_tier = serializers.CharField(
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
help_text="Quality tier: basic, quality, or premium"
|
||||
)
|
||||
|
||||
# Capabilities
|
||||
supports_json_mode = serializers.BooleanField(read_only=True)
|
||||
supports_vision = serializers.BooleanField(read_only=True)
|
||||
|
||||
@@ -789,7 +789,7 @@ class AIModelConfigViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
is_default_bool = is_default.lower() in ['true', '1', 'yes']
|
||||
queryset = queryset.filter(is_default=is_default_bool)
|
||||
|
||||
return queryset.order_by('model_type', 'sort_order', 'model_name')
|
||||
return queryset.order_by('model_type', 'model_name')
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return serializer class"""
|
||||
|
||||
@@ -12,8 +12,14 @@ __all__ = [
|
||||
'Strategy',
|
||||
# Global settings models
|
||||
'GlobalIntegrationSettings',
|
||||
'AccountIntegrationOverride',
|
||||
'GlobalAIPrompt',
|
||||
'GlobalAuthorProfile',
|
||||
'GlobalStrategy',
|
||||
# New centralized models
|
||||
'IntegrationProvider',
|
||||
'AISettings',
|
||||
# Email models
|
||||
'EmailSettings',
|
||||
'EmailTemplate',
|
||||
'EmailLog',
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
System Module Admin
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django import forms
|
||||
from unfold.admin import ModelAdmin
|
||||
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
|
||||
from .models import AIPrompt, IntegrationSettings, AuthorProfile, Strategy
|
||||
@@ -31,7 +32,7 @@ class AIPromptResource(resources.ModelResource):
|
||||
# Import settings admin
|
||||
from .settings_admin import (
|
||||
SystemSettingsAdmin, AccountSettingsAdmin, UserSettingsAdmin,
|
||||
ModuleSettingsAdmin, AISettingsAdmin
|
||||
ModuleSettingsAdmin
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -333,16 +334,61 @@ class StrategyAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||
# GLOBAL SETTINGS ADMIN - Platform-wide defaults
|
||||
# =============================================================================
|
||||
|
||||
class GlobalIntegrationSettingsForm(forms.ModelForm):
|
||||
"""Custom form for GlobalIntegrationSettings with dynamic choices from AIModelConfig"""
|
||||
|
||||
class Meta:
|
||||
model = GlobalIntegrationSettings
|
||||
fields = '__all__'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Load choices dynamically from AIModelConfig
|
||||
from igny8_core.modules.system.global_settings_models import (
|
||||
get_text_model_choices,
|
||||
get_image_model_choices,
|
||||
get_provider_choices,
|
||||
)
|
||||
|
||||
# OpenAI text model choices
|
||||
openai_choices = get_text_model_choices()
|
||||
openai_text_choices = [(m, d) for m, d in openai_choices if 'gpt' in m.lower() or 'openai' in m.lower()]
|
||||
if openai_text_choices:
|
||||
self.fields['openai_model'].choices = openai_text_choices
|
||||
|
||||
# DALL-E image model choices
|
||||
dalle_choices = get_image_model_choices(provider='openai')
|
||||
if dalle_choices:
|
||||
self.fields['dalle_model'].choices = dalle_choices
|
||||
|
||||
# Runware image model choices
|
||||
runware_choices = get_image_model_choices(provider='runware')
|
||||
if runware_choices:
|
||||
self.fields['runware_model'].choices = runware_choices
|
||||
|
||||
# Image service provider choices (only OpenAI and Runware for now)
|
||||
image_providers = get_provider_choices(model_type='image')
|
||||
# Filter to only OpenAI and Runware
|
||||
allowed_image_providers = [
|
||||
(p, d) for p, d in image_providers
|
||||
if p in ('openai', 'runware')
|
||||
]
|
||||
if allowed_image_providers:
|
||||
self.fields['default_image_service'].choices = allowed_image_providers
|
||||
|
||||
|
||||
@admin.register(GlobalIntegrationSettings)
|
||||
class GlobalIntegrationSettingsAdmin(Igny8ModelAdmin):
|
||||
"""Admin for global integration settings (singleton)"""
|
||||
form = GlobalIntegrationSettingsForm
|
||||
list_display = ["id", "is_active", "last_updated", "updated_by"]
|
||||
readonly_fields = ["last_updated"]
|
||||
readonly_fields = ["last_updated", "openai_max_tokens", "anthropic_max_tokens"]
|
||||
|
||||
fieldsets = (
|
||||
("OpenAI Settings", {
|
||||
"fields": ("openai_api_key", "openai_model", "openai_temperature", "openai_max_tokens"),
|
||||
"description": "Global OpenAI configuration used by all accounts (unless overridden)"
|
||||
"description": "Global OpenAI configuration used by all accounts (unless overridden). Max tokens is loaded from AI Model Configuration."
|
||||
}),
|
||||
("Image Generation - Default Service", {
|
||||
"fields": ("default_image_service",),
|
||||
@@ -357,7 +403,7 @@ class GlobalIntegrationSettingsAdmin(Igny8ModelAdmin):
|
||||
"description": "Global Runware image generation configuration"
|
||||
}),
|
||||
("Universal Image Settings", {
|
||||
"fields": ("image_quality", "image_style", "max_in_article_images", "desktop_image_size", "mobile_image_size"),
|
||||
"fields": ("image_quality", "image_style", "max_in_article_images", "desktop_image_size"),
|
||||
"description": "Image quality, style, and sizing settings that apply to ALL providers (DALL-E, Runware, etc.)"
|
||||
}),
|
||||
("Status", {
|
||||
@@ -365,6 +411,49 @@ class GlobalIntegrationSettingsAdmin(Igny8ModelAdmin):
|
||||
}),
|
||||
)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Make max_tokens fields readonly - they are populated from AI Model Configuration"""
|
||||
readonly = list(super().get_readonly_fields(request, obj))
|
||||
if 'openai_max_tokens' not in readonly:
|
||||
readonly.append('openai_max_tokens')
|
||||
if 'anthropic_max_tokens' not in readonly:
|
||||
readonly.append('anthropic_max_tokens')
|
||||
return readonly
|
||||
|
||||
def openai_max_tokens(self, obj):
|
||||
"""Display max tokens from the selected OpenAI model's configuration"""
|
||||
from igny8_core.modules.system.global_settings_models import get_model_max_tokens
|
||||
max_tokens = get_model_max_tokens(obj.openai_model) if obj else None
|
||||
if max_tokens:
|
||||
return f"{max_tokens:,} (from AI Model Configuration)"
|
||||
return obj.openai_max_tokens if obj else "8192 (default)"
|
||||
openai_max_tokens.short_description = "Max Output Tokens"
|
||||
|
||||
def anthropic_max_tokens(self, obj):
|
||||
"""Display max tokens from the selected Anthropic model's configuration"""
|
||||
from igny8_core.modules.system.global_settings_models import get_model_max_tokens
|
||||
max_tokens = get_model_max_tokens(obj.anthropic_model) if obj else None
|
||||
if max_tokens:
|
||||
return f"{max_tokens:,} (from AI Model Configuration)"
|
||||
return obj.anthropic_max_tokens if obj else "8192 (default)"
|
||||
anthropic_max_tokens.short_description = "Max Output Tokens"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Update max_tokens from model config on save"""
|
||||
from igny8_core.modules.system.global_settings_models import get_model_max_tokens
|
||||
|
||||
# Update OpenAI max tokens from model config
|
||||
openai_max = get_model_max_tokens(obj.openai_model)
|
||||
if openai_max:
|
||||
obj.openai_max_tokens = openai_max
|
||||
|
||||
# Update Anthropic max tokens from model config
|
||||
anthropic_max = get_model_max_tokens(obj.anthropic_model)
|
||||
if anthropic_max:
|
||||
obj.anthropic_max_tokens = anthropic_max
|
||||
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Only allow one instance (singleton pattern)"""
|
||||
return not GlobalIntegrationSettings.objects.exists()
|
||||
@@ -498,3 +587,115 @@ class GlobalModuleSettingsAdmin(Igny8ModelAdmin):
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
|
||||
# IntegrationProvider Admin (centralized API keys)
|
||||
from .models import IntegrationProvider
|
||||
|
||||
|
||||
@admin.register(IntegrationProvider)
|
||||
class IntegrationProviderAdmin(Igny8ModelAdmin):
|
||||
"""
|
||||
Admin for IntegrationProvider - Centralized API key management.
|
||||
Per final-model-schemas.md
|
||||
"""
|
||||
|
||||
list_display = [
|
||||
'provider_id',
|
||||
'display_name',
|
||||
'provider_type',
|
||||
'is_active',
|
||||
'is_sandbox',
|
||||
'has_api_key',
|
||||
'updated_at',
|
||||
]
|
||||
list_filter = ['provider_type', 'is_active', 'is_sandbox']
|
||||
search_fields = ['provider_id', 'display_name']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Provider Info', {
|
||||
'fields': ('provider_id', 'display_name', 'provider_type')
|
||||
}),
|
||||
('API Configuration', {
|
||||
'fields': ('api_key', 'api_secret', 'webhook_secret', 'api_endpoint'),
|
||||
'description': 'Enter API keys and endpoints. These are platform-wide.'
|
||||
}),
|
||||
('Extra Config', {
|
||||
'fields': ('config',),
|
||||
'classes': ('collapse',),
|
||||
'description': 'JSON config for provider-specific settings'
|
||||
}),
|
||||
('Status', {
|
||||
'fields': ('is_active', 'is_sandbox')
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('updated_by', 'created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def has_api_key(self, obj):
|
||||
"""Show if API key is configured"""
|
||||
return bool(obj.api_key)
|
||||
has_api_key.boolean = True
|
||||
has_api_key.short_description = 'API Key Set'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Set updated_by to current user"""
|
||||
obj.updated_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
# SystemAISettings Admin (new simplified AI settings)
|
||||
from .ai_settings import SystemAISettings
|
||||
|
||||
|
||||
@admin.register(SystemAISettings)
|
||||
class SystemAISettingsAdmin(Igny8ModelAdmin):
|
||||
"""
|
||||
Admin for SystemAISettings - System-wide AI defaults (Singleton).
|
||||
Per final-model-schemas.md
|
||||
"""
|
||||
|
||||
list_display = [
|
||||
'id',
|
||||
'temperature',
|
||||
'max_tokens',
|
||||
'image_style',
|
||||
'image_quality',
|
||||
'max_images_per_article',
|
||||
'updated_at',
|
||||
]
|
||||
readonly_fields = ['updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('AI Parameters', {
|
||||
'fields': ('temperature', 'max_tokens'),
|
||||
'description': 'System-wide defaults for AI text generation. Accounts can override via AccountSettings.'
|
||||
}),
|
||||
('Image Generation', {
|
||||
'fields': ('image_style', 'image_quality', 'max_images_per_article', 'image_size'),
|
||||
'description': 'System-wide defaults for image generation. Accounts can override via AccountSettings.'
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('updated_by', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Only allow one instance (singleton)"""
|
||||
return not SystemAISettings.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Prevent deletion of singleton"""
|
||||
return False
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Set updated_by to current user"""
|
||||
obj.updated_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
# Import Email Admin (EmailSettings, EmailTemplate, EmailLog)
|
||||
from .email_admin import EmailSettingsAdmin, EmailTemplateAdmin, EmailLogAdmin
|
||||
|
||||
195
backend/igny8_core/modules/system/ai_settings.py
Normal file
195
backend/igny8_core/modules/system/ai_settings.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
AI Settings - System-wide AI defaults (Singleton)
|
||||
|
||||
This is the clean, simplified model for AI configuration.
|
||||
Replaces the deprecated GlobalIntegrationSettings.
|
||||
|
||||
API keys are stored in IntegrationProvider.
|
||||
Model definitions are in AIModelConfig.
|
||||
This model only stores system-wide defaults for AI parameters.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SystemAISettings(models.Model):
|
||||
"""
|
||||
System-wide AI defaults. Singleton (pk=1).
|
||||
|
||||
Removed fields (now elsewhere):
|
||||
- All *_api_key fields → IntegrationProvider
|
||||
- All *_model fields → AIModelConfig.is_default
|
||||
- default_text_provider → AIModelConfig.is_default where model_type='text'
|
||||
- default_image_service → AIModelConfig.is_default where model_type='image'
|
||||
|
||||
Accounts can override these via AccountSettings with keys like:
|
||||
- ai.temperature
|
||||
- ai.max_tokens
|
||||
- ai.image_style
|
||||
- ai.image_quality
|
||||
- ai.max_images
|
||||
"""
|
||||
|
||||
IMAGE_STYLE_CHOICES = [
|
||||
('photorealistic', 'Photorealistic'),
|
||||
('illustration', 'Illustration'),
|
||||
('3d_render', '3D Render'),
|
||||
('minimal_flat', 'Minimal / Flat Design'),
|
||||
('artistic', 'Artistic / Painterly'),
|
||||
('cartoon', 'Cartoon / Stylized'),
|
||||
]
|
||||
|
||||
IMAGE_QUALITY_CHOICES = [
|
||||
('standard', 'Standard'),
|
||||
('hd', 'HD'),
|
||||
]
|
||||
|
||||
IMAGE_SIZE_CHOICES = [
|
||||
('1024x1024', '1024x1024 (Square)'),
|
||||
('1792x1024', '1792x1024 (Landscape)'),
|
||||
('1024x1792', '1024x1792 (Portrait)'),
|
||||
]
|
||||
|
||||
# AI Parameters
|
||||
temperature = models.FloatField(
|
||||
default=0.7,
|
||||
help_text="AI temperature (0.0-2.0). Higher = more creative."
|
||||
)
|
||||
max_tokens = models.IntegerField(
|
||||
default=8192,
|
||||
help_text="Max response tokens"
|
||||
)
|
||||
|
||||
# Image Generation Settings
|
||||
image_style = models.CharField(
|
||||
max_length=30,
|
||||
default='photorealistic',
|
||||
choices=IMAGE_STYLE_CHOICES,
|
||||
help_text="Default image style"
|
||||
)
|
||||
image_quality = models.CharField(
|
||||
max_length=20,
|
||||
default='standard',
|
||||
choices=IMAGE_QUALITY_CHOICES,
|
||||
help_text="Default image quality (standard/hd)"
|
||||
)
|
||||
max_images_per_article = models.IntegerField(
|
||||
default=4,
|
||||
help_text="Max in-article images (1-8)"
|
||||
)
|
||||
image_size = models.CharField(
|
||||
max_length=20,
|
||||
default='1024x1024',
|
||||
choices=IMAGE_SIZE_CHOICES,
|
||||
help_text="Default image dimensions"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='system_ai_settings_updates'
|
||||
)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_system_ai_settings'
|
||||
verbose_name = 'System AI Settings'
|
||||
verbose_name_plural = 'System AI Settings'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Enforce singleton - always use pk=1"""
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Prevent deletion of singleton"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
"""Get or create the singleton instance"""
|
||||
obj, created = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
def __str__(self):
|
||||
return "System AI Settings"
|
||||
|
||||
# Helper methods for getting effective settings with account overrides
|
||||
@classmethod
|
||||
def get_effective_temperature(cls, account=None) -> float:
|
||||
"""Get temperature, checking account override first"""
|
||||
if account:
|
||||
override = cls._get_account_override(account, 'ai.temperature')
|
||||
if override is not None:
|
||||
return float(override)
|
||||
return cls.get_instance().temperature
|
||||
|
||||
@classmethod
|
||||
def get_effective_max_tokens(cls, account=None) -> int:
|
||||
"""Get max_tokens, checking account override first"""
|
||||
if account:
|
||||
override = cls._get_account_override(account, 'ai.max_tokens')
|
||||
if override is not None:
|
||||
return int(override)
|
||||
return cls.get_instance().max_tokens
|
||||
|
||||
@classmethod
|
||||
def get_effective_image_style(cls, account=None) -> str:
|
||||
"""Get image_style, checking account override first"""
|
||||
if account:
|
||||
override = cls._get_account_override(account, 'ai.image_style')
|
||||
if override is not None:
|
||||
return str(override)
|
||||
return cls.get_instance().image_style
|
||||
|
||||
@classmethod
|
||||
def get_effective_image_quality(cls, account=None) -> str:
|
||||
"""Get image_quality, checking account override first"""
|
||||
if account:
|
||||
override = cls._get_account_override(account, 'ai.image_quality')
|
||||
if override is not None:
|
||||
return str(override)
|
||||
return cls.get_instance().image_quality
|
||||
|
||||
@classmethod
|
||||
def get_effective_max_images(cls, account=None) -> int:
|
||||
"""Get max_images_per_article, checking account override first"""
|
||||
if account:
|
||||
override = cls._get_account_override(account, 'ai.max_images')
|
||||
if override is not None:
|
||||
return int(override)
|
||||
return cls.get_instance().max_images_per_article
|
||||
|
||||
@classmethod
|
||||
def get_effective_image_size(cls, account=None) -> str:
|
||||
"""Get image_size, checking account override first"""
|
||||
if account:
|
||||
override = cls._get_account_override(account, 'ai.image_size')
|
||||
if override is not None:
|
||||
return str(override)
|
||||
return cls.get_instance().image_size
|
||||
|
||||
@staticmethod
|
||||
def _get_account_override(account, key: str):
|
||||
"""Get account-specific override from AccountSettings"""
|
||||
try:
|
||||
from igny8_core.modules.system.settings_models import AccountSettings
|
||||
setting = AccountSettings.objects.filter(
|
||||
account=account,
|
||||
key=key
|
||||
).first()
|
||||
if setting and setting.config:
|
||||
return setting.config.get('value')
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get account override for {key}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Alias for backward compatibility and clearer naming
|
||||
AISettings = SystemAISettings
|
||||
446
backend/igny8_core/modules/system/email_admin.py
Normal file
446
backend/igny8_core/modules/system/email_admin.py
Normal file
@@ -0,0 +1,446 @@
|
||||
"""
|
||||
Email Admin Configuration for IGNY8
|
||||
|
||||
Provides admin interface for managing:
|
||||
- Email Settings (global configuration)
|
||||
- Email Templates (template metadata and testing)
|
||||
- Email Logs (sent email history)
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import path, reverse
|
||||
from django.shortcuts import render, redirect
|
||||
from django.contrib import messages
|
||||
from django.http import JsonResponse
|
||||
|
||||
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
||||
from igny8_core.admin.base import Igny8ModelAdmin
|
||||
from .email_models import EmailSettings, EmailTemplate, EmailLog
|
||||
|
||||
|
||||
@admin.register(EmailSettings)
|
||||
class EmailSettingsAdmin(Igny8ModelAdmin):
|
||||
"""
|
||||
Admin for EmailSettings - Global email configuration (Singleton)
|
||||
"""
|
||||
|
||||
list_display = [
|
||||
'from_email',
|
||||
'from_name',
|
||||
'email_provider',
|
||||
'reply_to_email',
|
||||
'send_welcome_emails',
|
||||
'send_billing_emails',
|
||||
'updated_at',
|
||||
]
|
||||
readonly_fields = ['updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Email Provider', {
|
||||
'fields': ('email_provider',),
|
||||
'description': 'Select the active email service provider. Configure SMTP settings below if using SMTP.',
|
||||
}),
|
||||
('SMTP Configuration', {
|
||||
'fields': (
|
||||
'smtp_host',
|
||||
'smtp_port',
|
||||
'smtp_username',
|
||||
'smtp_password',
|
||||
'smtp_use_tls',
|
||||
'smtp_use_ssl',
|
||||
'smtp_timeout',
|
||||
),
|
||||
'description': 'SMTP server settings. Required when email_provider is set to SMTP.',
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
('Sender Configuration', {
|
||||
'fields': ('from_email', 'from_name', 'reply_to_email'),
|
||||
'description': 'Default sender settings. Email address must be verified in Resend (if using Resend) or configured in SMTP server.',
|
||||
}),
|
||||
('Company Branding', {
|
||||
'fields': ('company_name', 'company_address', 'logo_url'),
|
||||
'description': 'Company information shown in email templates.',
|
||||
}),
|
||||
('Support Links', {
|
||||
'fields': ('support_email', 'support_url', 'unsubscribe_url'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
('Email Types', {
|
||||
'fields': (
|
||||
'send_welcome_emails',
|
||||
'send_billing_emails',
|
||||
'send_subscription_emails',
|
||||
'send_low_credit_warnings',
|
||||
),
|
||||
'description': 'Enable/disable specific email types globally.',
|
||||
}),
|
||||
('Thresholds', {
|
||||
'fields': ('low_credit_threshold', 'renewal_reminder_days'),
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('updated_by', 'updated_at'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
change_form_template = 'admin/system/emailsettings/change_form.html'
|
||||
|
||||
def get_urls(self):
|
||||
"""Add custom URL for test email"""
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path(
|
||||
'test-email/',
|
||||
self.admin_site.admin_view(self.test_email_view),
|
||||
name='system_emailsettings_test_email'
|
||||
),
|
||||
path(
|
||||
'send-test-email/',
|
||||
self.admin_site.admin_view(self.send_test_email),
|
||||
name='system_emailsettings_send_test'
|
||||
),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def test_email_view(self, request):
|
||||
"""Show test email form"""
|
||||
settings = EmailSettings.get_settings()
|
||||
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
'title': 'Send Test Email',
|
||||
'settings': settings,
|
||||
'opts': self.model._meta,
|
||||
'default_from_email': settings.from_email,
|
||||
'default_to_email': request.user.email,
|
||||
}
|
||||
|
||||
return render(request, 'admin/system/emailsettings/test_email.html', context)
|
||||
|
||||
def send_test_email(self, request):
|
||||
"""Send test email to verify configuration"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'POST required'}, status=405)
|
||||
|
||||
from django.utils import timezone
|
||||
from igny8_core.business.billing.services.email_service import EmailService
|
||||
|
||||
to_email = request.POST.get('to_email', request.user.email)
|
||||
subject = request.POST.get('subject', 'IGNY8 Test Email')
|
||||
|
||||
# Create fresh EmailService instance to pick up latest settings
|
||||
service = EmailService()
|
||||
settings = EmailSettings.get_settings()
|
||||
|
||||
test_html = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; padding: 20px;">
|
||||
<h1 style="color: #6366f1;">IGNY8 Email Test</h1>
|
||||
<p>This is a test email to verify your email configuration.</p>
|
||||
|
||||
<h3>Configuration Details:</h3>
|
||||
<ul>
|
||||
<li><strong>Provider:</strong> {settings.email_provider.upper()}</li>
|
||||
<li><strong>From:</strong> {settings.from_name} <{settings.from_email}></li>
|
||||
<li><strong>Reply-To:</strong> {settings.reply_to_email}</li>
|
||||
<li><strong>Sent At:</strong> {timezone.now().strftime('%Y-%m-%d %H:%M:%S UTC')}</li>
|
||||
</ul>
|
||||
|
||||
<p style="color: #22c55e; font-weight: bold;">
|
||||
✓ If you received this email, your email configuration is working correctly!
|
||||
</p>
|
||||
|
||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid #e5e7eb;">
|
||||
<p style="font-size: 12px; color: #6b7280;">
|
||||
This is an automated test email from IGNY8 Admin.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
try:
|
||||
result = service.send_transactional(
|
||||
to=to_email,
|
||||
subject=subject,
|
||||
html=test_html,
|
||||
tags=['test', 'admin-test'],
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
# Log the test email
|
||||
EmailLog.objects.create(
|
||||
message_id=result.get('id', ''),
|
||||
to_email=to_email,
|
||||
from_email=settings.from_email,
|
||||
subject=subject,
|
||||
template_name='admin_test',
|
||||
status='sent',
|
||||
provider=result.get('provider', settings.email_provider),
|
||||
tags=['test', 'admin-test'],
|
||||
)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f'Test email sent successfully to {to_email} via {result.get("provider", "unknown").upper()}!'
|
||||
)
|
||||
else:
|
||||
messages.error(request, f'Failed to send: {result.get("error", "Unknown error")}')
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f'Error sending test email: {str(e)}')
|
||||
|
||||
return redirect(reverse('admin:system_emailsettings_changelist'))
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Only allow one instance (singleton)"""
|
||||
return not EmailSettings.objects.exists()
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Prevent deletion of singleton"""
|
||||
return False
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Set updated_by to current user"""
|
||||
obj.updated_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
@admin.register(EmailTemplate)
|
||||
class EmailTemplateAdmin(Igny8ModelAdmin):
|
||||
"""
|
||||
Admin for EmailTemplate - Manage email templates and testing
|
||||
"""
|
||||
|
||||
list_display = [
|
||||
'display_name',
|
||||
'template_type',
|
||||
'template_name',
|
||||
'is_active',
|
||||
'send_count',
|
||||
'last_sent_at',
|
||||
'test_email_button',
|
||||
]
|
||||
list_filter = ['template_type', 'is_active']
|
||||
search_fields = ['display_name', 'template_name', 'description']
|
||||
readonly_fields = ['send_count', 'last_sent_at', 'created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Template Info', {
|
||||
'fields': ('template_name', 'template_path', 'display_name', 'description'),
|
||||
}),
|
||||
('Email Settings', {
|
||||
'fields': ('template_type', 'default_subject'),
|
||||
}),
|
||||
('Context Configuration', {
|
||||
'fields': ('required_context', 'sample_context'),
|
||||
'description': 'Define required variables and sample data for testing.',
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
('Status', {
|
||||
'fields': ('is_active',),
|
||||
}),
|
||||
('Statistics', {
|
||||
'fields': ('send_count', 'last_sent_at'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
def test_email_button(self, obj):
|
||||
"""Add test email button in list view"""
|
||||
url = reverse('admin:system_emailtemplate_test', args=[obj.pk])
|
||||
return format_html(
|
||||
'<a class="button" href="{}" style="padding: 4px 12px; background: #6366f1; color: white; '
|
||||
'border-radius: 4px; text-decoration: none; font-size: 12px;">Test</a>',
|
||||
url
|
||||
)
|
||||
test_email_button.short_description = 'Test'
|
||||
test_email_button.allow_tags = True
|
||||
|
||||
def get_urls(self):
|
||||
"""Add custom URL for test email"""
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path(
|
||||
'<int:template_id>/test/',
|
||||
self.admin_site.admin_view(self.test_email_view),
|
||||
name='system_emailtemplate_test'
|
||||
),
|
||||
path(
|
||||
'<int:template_id>/send-test/',
|
||||
self.admin_site.admin_view(self.send_test_email),
|
||||
name='system_emailtemplate_send_test'
|
||||
),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def test_email_view(self, request, template_id):
|
||||
"""Show test email form"""
|
||||
template = EmailTemplate.objects.get(pk=template_id)
|
||||
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
'title': f'Test Email: {template.display_name}',
|
||||
'template': template,
|
||||
'opts': self.model._meta,
|
||||
}
|
||||
|
||||
return render(request, 'admin/system/emailtemplate/test_email.html', context)
|
||||
|
||||
def send_test_email(self, request, template_id):
|
||||
"""Send test email"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'POST required'}, status=405)
|
||||
|
||||
import json
|
||||
from django.utils import timezone
|
||||
from igny8_core.business.billing.services.email_service import get_email_service
|
||||
|
||||
template = EmailTemplate.objects.get(pk=template_id)
|
||||
|
||||
to_email = request.POST.get('to_email', request.user.email)
|
||||
custom_context = request.POST.get('context', '{}')
|
||||
|
||||
try:
|
||||
context = json.loads(custom_context) if custom_context else {}
|
||||
except json.JSONDecodeError:
|
||||
context = template.sample_context or {}
|
||||
|
||||
# Merge sample context with any custom values
|
||||
final_context = {**(template.sample_context or {}), **context}
|
||||
|
||||
# Add default context values
|
||||
final_context.setdefault('user_name', 'Test User')
|
||||
final_context.setdefault('account_name', 'Test Account')
|
||||
final_context.setdefault('frontend_url', 'https://app.igny8.com')
|
||||
|
||||
service = get_email_service()
|
||||
|
||||
try:
|
||||
result = service.send_transactional(
|
||||
to=to_email,
|
||||
subject=f'[TEST] {template.default_subject}',
|
||||
template=template.template_path,
|
||||
context=final_context,
|
||||
tags=['test', template.template_type],
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
# Update template stats
|
||||
template.send_count += 1
|
||||
template.last_sent_at = timezone.now()
|
||||
template.save(update_fields=['send_count', 'last_sent_at'])
|
||||
|
||||
# Log the email
|
||||
EmailLog.objects.create(
|
||||
message_id=result.get('id', ''),
|
||||
to_email=to_email,
|
||||
from_email=service.from_email,
|
||||
subject=f'[TEST] {template.default_subject}',
|
||||
template_name=template.template_name,
|
||||
status='sent',
|
||||
provider=result.get('provider', 'resend'),
|
||||
tags=['test', template.template_type],
|
||||
)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f'Test email sent successfully to {to_email}! (ID: {result.get("id", "N/A")})'
|
||||
)
|
||||
else:
|
||||
messages.error(request, f'Failed to send: {result.get("error", "Unknown error")}')
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f'Error sending test email: {str(e)}')
|
||||
|
||||
return redirect(reverse('admin:system_emailtemplate_changelist'))
|
||||
|
||||
|
||||
@admin.register(EmailLog)
|
||||
class EmailLogAdmin(Igny8ModelAdmin):
|
||||
"""
|
||||
Admin for EmailLog - View sent email history
|
||||
"""
|
||||
|
||||
list_display = [
|
||||
'sent_at',
|
||||
'to_email',
|
||||
'subject_truncated',
|
||||
'template_name',
|
||||
'status_badge',
|
||||
'provider',
|
||||
'message_id_short',
|
||||
]
|
||||
list_filter = ['status', 'provider', 'template_name', 'sent_at']
|
||||
search_fields = ['to_email', 'subject', 'message_id']
|
||||
readonly_fields = [
|
||||
'message_id', 'to_email', 'from_email', 'subject',
|
||||
'template_name', 'status', 'provider', 'error_message',
|
||||
'tags', 'sent_at'
|
||||
]
|
||||
date_hierarchy = 'sent_at'
|
||||
|
||||
fieldsets = (
|
||||
('Email Details', {
|
||||
'fields': ('to_email', 'from_email', 'subject'),
|
||||
}),
|
||||
('Delivery Info', {
|
||||
'fields': ('status', 'provider', 'message_id'),
|
||||
}),
|
||||
('Template', {
|
||||
'fields': ('template_name', 'tags'),
|
||||
}),
|
||||
('Error Info', {
|
||||
'fields': ('error_message',),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
('Timestamp', {
|
||||
'fields': ('sent_at',),
|
||||
}),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Logs are created automatically"""
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Logs are read-only"""
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Allow deletion for cleanup"""
|
||||
return request.user.is_superuser
|
||||
|
||||
def subject_truncated(self, obj):
|
||||
"""Truncate long subjects"""
|
||||
if len(obj.subject) > 50:
|
||||
return f'{obj.subject[:50]}...'
|
||||
return obj.subject
|
||||
subject_truncated.short_description = 'Subject'
|
||||
|
||||
def message_id_short(self, obj):
|
||||
"""Show truncated message ID"""
|
||||
if obj.message_id:
|
||||
return f'{obj.message_id[:20]}...' if len(obj.message_id) > 20 else obj.message_id
|
||||
return '-'
|
||||
message_id_short.short_description = 'Message ID'
|
||||
|
||||
def status_badge(self, obj):
|
||||
"""Show status with color badge"""
|
||||
colors = {
|
||||
'sent': '#3b82f6',
|
||||
'delivered': '#22c55e',
|
||||
'failed': '#ef4444',
|
||||
'bounced': '#f59e0b',
|
||||
}
|
||||
color = colors.get(obj.status, '#6b7280')
|
||||
return format_html(
|
||||
'<span style="background: {}; color: white; padding: 2px 8px; '
|
||||
'border-radius: 4px; font-size: 11px;">{}</span>',
|
||||
color, obj.status.upper()
|
||||
)
|
||||
status_badge.short_description = 'Status'
|
||||
status_badge.allow_tags = True
|
||||
338
backend/igny8_core/modules/system/email_models.py
Normal file
338
backend/igny8_core/modules/system/email_models.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Email Configuration Models for IGNY8
|
||||
|
||||
Provides database-driven email settings, template management, and send test functionality.
|
||||
Works with the existing EmailService and IntegrationProvider models.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class EmailSettings(models.Model):
|
||||
"""
|
||||
Global email settings - singleton model for email configuration.
|
||||
|
||||
Stores default email settings that can be managed through Django admin.
|
||||
These settings work alongside IntegrationProvider (resend) configuration.
|
||||
"""
|
||||
|
||||
EMAIL_PROVIDER_CHOICES = [
|
||||
('resend', 'Resend'),
|
||||
('smtp', 'SMTP'),
|
||||
]
|
||||
|
||||
# Email provider selection
|
||||
email_provider = models.CharField(
|
||||
max_length=20,
|
||||
choices=EMAIL_PROVIDER_CHOICES,
|
||||
default='resend',
|
||||
help_text='Active email service provider'
|
||||
)
|
||||
|
||||
# SMTP Configuration
|
||||
smtp_host = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text='SMTP server hostname (e.g., smtp.gmail.com)'
|
||||
)
|
||||
smtp_port = models.IntegerField(
|
||||
default=587,
|
||||
help_text='SMTP server port (587 for TLS, 465 for SSL, 25 for plain)'
|
||||
)
|
||||
smtp_username = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text='SMTP authentication username'
|
||||
)
|
||||
smtp_password = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text='SMTP authentication password'
|
||||
)
|
||||
smtp_use_tls = models.BooleanField(
|
||||
default=True,
|
||||
help_text='Use TLS encryption (recommended for port 587)'
|
||||
)
|
||||
smtp_use_ssl = models.BooleanField(
|
||||
default=False,
|
||||
help_text='Use SSL encryption (for port 465)'
|
||||
)
|
||||
smtp_timeout = models.IntegerField(
|
||||
default=30,
|
||||
help_text='SMTP connection timeout in seconds'
|
||||
)
|
||||
|
||||
# Default sender settings
|
||||
from_email = models.EmailField(
|
||||
default='noreply@igny8.com',
|
||||
help_text='Default sender email address (must be verified in Resend)'
|
||||
)
|
||||
from_name = models.CharField(
|
||||
max_length=100,
|
||||
default='IGNY8',
|
||||
help_text='Default sender display name'
|
||||
)
|
||||
reply_to_email = models.EmailField(
|
||||
default='support@igny8.com',
|
||||
help_text='Default reply-to email address'
|
||||
)
|
||||
|
||||
# Company branding for emails
|
||||
company_name = models.CharField(
|
||||
max_length=100,
|
||||
default='IGNY8',
|
||||
help_text='Company name shown in emails'
|
||||
)
|
||||
company_address = models.TextField(
|
||||
blank=True,
|
||||
help_text='Company address for email footer (CAN-SPAM compliance)'
|
||||
)
|
||||
logo_url = models.URLField(
|
||||
blank=True,
|
||||
help_text='URL to company logo for emails'
|
||||
)
|
||||
|
||||
# Support links
|
||||
support_email = models.EmailField(
|
||||
default='support@igny8.com',
|
||||
help_text='Support email shown in emails'
|
||||
)
|
||||
support_url = models.URLField(
|
||||
blank=True,
|
||||
help_text='Link to support/help center'
|
||||
)
|
||||
unsubscribe_url = models.URLField(
|
||||
blank=True,
|
||||
help_text='URL for email unsubscribe (for marketing emails)'
|
||||
)
|
||||
|
||||
# Feature flags
|
||||
send_welcome_emails = models.BooleanField(
|
||||
default=True,
|
||||
help_text='Send welcome email on user registration'
|
||||
)
|
||||
send_billing_emails = models.BooleanField(
|
||||
default=True,
|
||||
help_text='Send payment confirmation, invoice emails'
|
||||
)
|
||||
send_subscription_emails = models.BooleanField(
|
||||
default=True,
|
||||
help_text='Send subscription renewal reminders'
|
||||
)
|
||||
send_low_credit_warnings = models.BooleanField(
|
||||
default=True,
|
||||
help_text='Send low credit warning emails'
|
||||
)
|
||||
|
||||
# Credit warning threshold
|
||||
low_credit_threshold = models.IntegerField(
|
||||
default=100,
|
||||
help_text='Send warning when credits fall below this value'
|
||||
)
|
||||
renewal_reminder_days = models.IntegerField(
|
||||
default=7,
|
||||
help_text='Days before subscription renewal to send reminder'
|
||||
)
|
||||
|
||||
# Audit
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='email_settings_updates'
|
||||
)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_email_settings'
|
||||
verbose_name = 'Email Settings'
|
||||
verbose_name_plural = 'Email Settings'
|
||||
|
||||
def __str__(self):
|
||||
return f'Email Settings (from: {self.from_email})'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Ensure only one instance exists (singleton)"""
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_settings(cls):
|
||||
"""Get singleton settings instance, creating if needed"""
|
||||
obj, _ = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
|
||||
class EmailTemplate(models.Model):
|
||||
"""
|
||||
Email template metadata - tracks available email templates
|
||||
and their usage/configuration.
|
||||
|
||||
Templates are stored as Django templates in templates/emails/.
|
||||
This model provides admin visibility and test sending capability.
|
||||
"""
|
||||
|
||||
TEMPLATE_TYPE_CHOICES = [
|
||||
('auth', 'Authentication'),
|
||||
('billing', 'Billing'),
|
||||
('notification', 'Notification'),
|
||||
('marketing', 'Marketing'),
|
||||
]
|
||||
|
||||
# Template identification
|
||||
template_name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
help_text='Template file name without extension (e.g., "welcome")'
|
||||
)
|
||||
template_path = models.CharField(
|
||||
max_length=200,
|
||||
help_text='Full template path (e.g., "emails/welcome.html")'
|
||||
)
|
||||
|
||||
# Display info
|
||||
display_name = models.CharField(
|
||||
max_length=100,
|
||||
help_text='Human-readable template name'
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text='Description of when this template is used'
|
||||
)
|
||||
template_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=TEMPLATE_TYPE_CHOICES,
|
||||
default='notification'
|
||||
)
|
||||
|
||||
# Default subject
|
||||
default_subject = models.CharField(
|
||||
max_length=200,
|
||||
help_text='Default email subject line'
|
||||
)
|
||||
|
||||
# Required context variables
|
||||
required_context = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text='List of required context variables for this template'
|
||||
)
|
||||
|
||||
# Sample context for testing
|
||||
sample_context = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text='Sample context for test sending (JSON)'
|
||||
)
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
help_text='Whether this template is currently in use'
|
||||
)
|
||||
|
||||
# Stats
|
||||
send_count = models.IntegerField(
|
||||
default=0,
|
||||
help_text='Number of emails sent using this template'
|
||||
)
|
||||
last_sent_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Last time an email was sent with this template'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_email_templates'
|
||||
verbose_name = 'Email Template'
|
||||
verbose_name_plural = 'Email Templates'
|
||||
ordering = ['template_type', 'display_name']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.display_name} ({self.template_type})'
|
||||
|
||||
|
||||
class EmailLog(models.Model):
|
||||
"""
|
||||
Log of sent emails for audit and debugging.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('sent', 'Sent'),
|
||||
('delivered', 'Delivered'),
|
||||
('failed', 'Failed'),
|
||||
('bounced', 'Bounced'),
|
||||
]
|
||||
|
||||
# Email identification
|
||||
message_id = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
help_text='Provider message ID (from Resend)'
|
||||
)
|
||||
|
||||
# Recipients
|
||||
to_email = models.EmailField(
|
||||
help_text='Recipient email'
|
||||
)
|
||||
from_email = models.EmailField(
|
||||
help_text='Sender email'
|
||||
)
|
||||
|
||||
# Content
|
||||
subject = models.CharField(
|
||||
max_length=500,
|
||||
help_text='Email subject'
|
||||
)
|
||||
template_name = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text='Template used (if any)'
|
||||
)
|
||||
|
||||
# Status
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='sent'
|
||||
)
|
||||
provider = models.CharField(
|
||||
max_length=50,
|
||||
default='resend',
|
||||
help_text='Email provider used'
|
||||
)
|
||||
|
||||
# Error tracking
|
||||
error_message = models.TextField(
|
||||
blank=True,
|
||||
help_text='Error message if failed'
|
||||
)
|
||||
|
||||
# Metadata
|
||||
tags = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text='Email tags for categorization'
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
sent_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_email_log'
|
||||
verbose_name = 'Email Log'
|
||||
verbose_name_plural = 'Email Logs'
|
||||
ordering = ['-sent_at']
|
||||
indexes = [
|
||||
models.Index(fields=['to_email', 'sent_at']),
|
||||
models.Index(fields=['status', 'sent_at']),
|
||||
models.Index(fields=['template_name', 'sent_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.subject} → {self.to_email} ({self.status})'
|
||||
@@ -5,6 +5,126 @@ Accounts can override model selection and parameters (but NOT API keys).
|
||||
"""
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_text_model_choices():
|
||||
"""
|
||||
Get text model choices from AIModelConfig database.
|
||||
Returns list of tuples (model_name, display_name) for active text models.
|
||||
Falls back to hardcoded defaults if database unavailable.
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
models = AIModelConfig.objects.filter(
|
||||
model_type='text',
|
||||
is_active=True
|
||||
).order_by('model_name')
|
||||
|
||||
if models.exists():
|
||||
return [(m.model_name, m.display_name) for m in models]
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load text models from database: {e}")
|
||||
|
||||
# Fallback to hardcoded defaults
|
||||
return [
|
||||
('gpt-4o-mini', 'GPT-4o mini - $0.15 / $0.60 per 1M tokens'),
|
||||
('gpt-4o', 'GPT-4o - $2.50 / $10.00 per 1M tokens'),
|
||||
]
|
||||
|
||||
|
||||
def get_image_model_choices(provider=None):
|
||||
"""
|
||||
Get image model choices from AIModelConfig database.
|
||||
Optionally filter by provider (openai, runware, etc.)
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
qs = AIModelConfig.objects.filter(
|
||||
model_type='image',
|
||||
is_active=True
|
||||
)
|
||||
if provider:
|
||||
qs = qs.filter(provider=provider)
|
||||
qs = qs.order_by('model_name')
|
||||
|
||||
if qs.exists():
|
||||
return [(m.model_name, m.display_name) for m in qs]
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load image models from database: {e}")
|
||||
|
||||
# Fallback based on provider
|
||||
if provider == 'openai':
|
||||
return [
|
||||
('dall-e-3', 'DALL·E 3 - $0.040 per image'),
|
||||
('dall-e-2', 'DALL·E 2 - $0.020 per image'),
|
||||
]
|
||||
elif provider == 'runware':
|
||||
return [
|
||||
('runware:97@1', 'Hi Dream Full - Basic'),
|
||||
('bria:10@1', 'Bria 3.2 - Quality'),
|
||||
('google:4@2', 'Nano Banana - Premium'),
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def get_provider_choices(model_type='text'):
|
||||
"""
|
||||
Get provider choices from AIModelConfig database.
|
||||
Returns unique providers for the given model type.
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
providers = list(AIModelConfig.objects.filter(
|
||||
model_type=model_type,
|
||||
is_active=True
|
||||
).values_list('provider', flat=True).distinct())
|
||||
|
||||
provider_display = {
|
||||
'openai': 'OpenAI DALL-E' if model_type == 'image' else 'OpenAI',
|
||||
'anthropic': 'Anthropic (Claude)',
|
||||
'runware': 'Runware',
|
||||
'google': 'Google',
|
||||
}
|
||||
|
||||
if providers:
|
||||
# Use dict to ensure unique entries
|
||||
unique_providers = {}
|
||||
for p in providers:
|
||||
if p not in unique_providers:
|
||||
unique_providers[p] = provider_display.get(p, p.title())
|
||||
return [(p, d) for p, d in unique_providers.items()]
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load providers from database: {e}")
|
||||
|
||||
# Fallback
|
||||
if model_type == 'text':
|
||||
return [('openai', 'OpenAI (GPT)'), ('anthropic', 'Anthropic (Claude)')]
|
||||
elif model_type == 'image':
|
||||
return [('openai', 'OpenAI DALL-E'), ('runware', 'Runware')]
|
||||
return []
|
||||
|
||||
|
||||
def get_model_max_tokens(model_name):
|
||||
"""
|
||||
Get max_output_tokens for a specific model from AIModelConfig.
|
||||
Returns None if model not found or doesn't have max_output_tokens.
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
model_config = AIModelConfig.objects.filter(
|
||||
model_name=model_name,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if model_config and model_config.max_output_tokens:
|
||||
return model_config.max_output_tokens
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get max tokens for model {model_name}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class GlobalIntegrationSettings(models.Model):
|
||||
@@ -52,11 +172,21 @@ class GlobalIntegrationSettings(models.Model):
|
||||
]
|
||||
|
||||
RUNWARE_MODEL_CHOICES = [
|
||||
('runware:97@1', 'Runware 97@1 - Versatile Model'),
|
||||
('runware:100@1', 'Runware 100@1 - High Quality'),
|
||||
('runware:101@1', 'Runware 101@1 - Fast Generation'),
|
||||
('runware:97@1', 'Hi Dream Full - Basic'),
|
||||
('bria:10@1', 'Bria 3.2 - Quality'),
|
||||
('google:4@2', 'Nano Banana - Premium'),
|
||||
]
|
||||
|
||||
# Model-specific landscape sizes (square is always 1024x1024 for all models)
|
||||
MODEL_LANDSCAPE_SIZES = {
|
||||
'runware:97@1': '1280x768', # Hi Dream Full landscape
|
||||
'bria:10@1': '1344x768', # Bria 3.2 landscape (16:9)
|
||||
'google:4@2': '1376x768', # Nano Banana landscape (16:9)
|
||||
}
|
||||
|
||||
# Default square size (universal across all models)
|
||||
DEFAULT_SQUARE_SIZE = '1024x1024'
|
||||
|
||||
BRIA_MODEL_CHOICES = [
|
||||
('bria-2.3', 'Bria 2.3 - High Quality ($0.015/image)'),
|
||||
('bria-2.3-fast', 'Bria 2.3 Fast - Quick Generation ($0.010/image)'),
|
||||
@@ -68,18 +198,31 @@ class GlobalIntegrationSettings(models.Model):
|
||||
('hd', 'HD'),
|
||||
]
|
||||
|
||||
IMAGE_STYLE_CHOICES = [
|
||||
('vivid', 'Vivid'),
|
||||
('natural', 'Natural'),
|
||||
('realistic', 'Realistic'),
|
||||
('artistic', 'Artistic'),
|
||||
('cartoon', 'Cartoon'),
|
||||
# Image style choices with descriptions - used by both Runware and OpenAI
|
||||
# Format: (value, label, description)
|
||||
IMAGE_STYLE_OPTIONS = [
|
||||
('photorealistic', 'Photorealistic', 'Ultra realistic photography style, natural lighting, real world look'),
|
||||
('illustration', 'Illustration', 'Digital illustration, clean lines, artistic but not realistic'),
|
||||
('3d_render', '3D Render', 'Computer generated 3D style, modern, polished, depth and lighting'),
|
||||
('minimal_flat', 'Minimal / Flat Design', 'Simple shapes, flat colors, modern UI and graphic design look'),
|
||||
('artistic', 'Artistic / Painterly', 'Expressive, painted or hand drawn aesthetic'),
|
||||
('cartoon', 'Cartoon / Stylized', 'Playful, exaggerated forms, animated or mascot style'),
|
||||
]
|
||||
|
||||
# Choices for Django model field (value, label only)
|
||||
IMAGE_STYLE_CHOICES = [(opt[0], opt[1]) for opt in IMAGE_STYLE_OPTIONS]
|
||||
|
||||
# OpenAI DALL-E specific styles with descriptions (subset)
|
||||
DALLE_STYLE_OPTIONS = [
|
||||
('natural', 'Natural', 'More realistic, photographic style'),
|
||||
('vivid', 'Vivid', 'Hyper-real, dramatic, artistic'),
|
||||
]
|
||||
|
||||
DALLE_STYLE_CHOICES = [(opt[0], opt[1]) for opt in DALLE_STYLE_OPTIONS]
|
||||
|
||||
IMAGE_SERVICE_CHOICES = [
|
||||
('openai', 'OpenAI DALL-E'),
|
||||
('runware', 'Runware'),
|
||||
('bria', 'Bria AI'),
|
||||
]
|
||||
|
||||
ANTHROPIC_MODEL_CHOICES = [
|
||||
@@ -206,8 +349,8 @@ class GlobalIntegrationSettings(models.Model):
|
||||
help_text="Default image quality for all providers (accounts can override if plan allows)"
|
||||
)
|
||||
image_style = models.CharField(
|
||||
max_length=20,
|
||||
default='realistic',
|
||||
max_length=30,
|
||||
default='photorealistic',
|
||||
choices=IMAGE_STYLE_CHOICES,
|
||||
help_text="Default image style for all providers (accounts can override if plan allows)"
|
||||
)
|
||||
@@ -218,13 +361,9 @@ class GlobalIntegrationSettings(models.Model):
|
||||
desktop_image_size = models.CharField(
|
||||
max_length=20,
|
||||
default='1024x1024',
|
||||
help_text="Default desktop image size (accounts can override if plan allows)"
|
||||
)
|
||||
mobile_image_size = models.CharField(
|
||||
max_length=20,
|
||||
default='512x512',
|
||||
help_text="Default mobile image size (accounts can override if plan allows)"
|
||||
help_text="Default image size for in-article images (accounts can override if plan allows)"
|
||||
)
|
||||
# Note: mobile_image_size removed - no longer needed
|
||||
|
||||
# Metadata
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
@@ -109,16 +109,15 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
)
|
||||
|
||||
try:
|
||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
# Get platform API keys
|
||||
global_settings = GlobalIntegrationSettings.get_instance()
|
||||
# Get platform API keys from IntegrationProvider (centralized)
|
||||
api_key = ModelRegistry.get_api_key(integration_type)
|
||||
|
||||
# Get config from request (model selection)
|
||||
config = request.data.get('config', {}) if isinstance(request.data.get('config'), dict) else {}
|
||||
|
||||
if integration_type == 'openai':
|
||||
api_key = global_settings.openai_api_key
|
||||
if not api_key:
|
||||
return error_response(
|
||||
error='Platform OpenAI API key not configured. Please contact administrator.',
|
||||
@@ -128,7 +127,6 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
return self._test_openai(api_key, config, request)
|
||||
|
||||
elif integration_type == 'runware':
|
||||
api_key = global_settings.runware_api_key
|
||||
if not api_key:
|
||||
return error_response(
|
||||
error='Platform Runware API key not configured. Please contact administrator.',
|
||||
@@ -212,10 +210,13 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
output_tokens = usage.get('completion_tokens', 0)
|
||||
total_tokens = usage.get('total_tokens', 0)
|
||||
|
||||
# Calculate cost using model rates (reference plugin: line 274-275)
|
||||
from igny8_core.utils.ai_processor import MODEL_RATES
|
||||
rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00})
|
||||
cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1000000
|
||||
# Calculate cost using ModelRegistry (database-driven)
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
cost = float(ModelRegistry.calculate_cost(
|
||||
model,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens
|
||||
))
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -521,31 +522,13 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get API key from saved settings for the specified provider only
|
||||
# Get API key from IntegrationProvider (centralized, platform-wide)
|
||||
logger.info(f"[generate_image] Step 3: Getting API key for provider: {provider}")
|
||||
from .models import IntegrationSettings
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
# Only fetch settings for the specified provider
|
||||
api_key = None
|
||||
integration_enabled = False
|
||||
integration_type = provider # 'openai' or 'runware'
|
||||
|
||||
try:
|
||||
integration_settings = IntegrationSettings.objects.get(
|
||||
integration_type=integration_type,
|
||||
account=account
|
||||
)
|
||||
api_key = integration_settings.config.get('apiKey')
|
||||
integration_enabled = integration_settings.is_active
|
||||
logger.info(f"[generate_image] {integration_type.upper()} settings found: enabled={integration_enabled}, has_key={bool(api_key)}")
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
logger.warning(f"[generate_image] {integration_type.upper()} settings not found in database")
|
||||
api_key = None
|
||||
integration_enabled = False
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_image] Error getting {integration_type.upper()} settings: {e}")
|
||||
api_key = None
|
||||
integration_enabled = False
|
||||
api_key = ModelRegistry.get_api_key(provider)
|
||||
integration_enabled = api_key is not None
|
||||
logger.info(f"[generate_image] {provider.upper()} API key: enabled={integration_enabled}, has_key={bool(api_key)}")
|
||||
|
||||
# Validate provider and API key
|
||||
logger.info(f"[generate_image] Step 4: Validating {provider} provider and API key")
|
||||
@@ -635,8 +618,8 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
def save_settings(self, request, pk=None):
|
||||
"""
|
||||
Save integration settings (account overrides only).
|
||||
- Saves model/parameter overrides to IntegrationSettings
|
||||
- NEVER saves API keys (those are platform-wide)
|
||||
- Saves model/parameter overrides to AccountSettings (key-value store)
|
||||
- NEVER saves API keys (those are platform-wide via IntegrationProvider)
|
||||
- Free plan: Should be blocked at frontend level
|
||||
"""
|
||||
integration_type = pk
|
||||
@@ -689,62 +672,47 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
# TODO: Check if Free plan - they shouldn't be able to save overrides
|
||||
# This should be blocked at frontend level, but add backend check too
|
||||
|
||||
from .models import IntegrationSettings
|
||||
|
||||
# Build clean config with only allowed overrides
|
||||
clean_config = {}
|
||||
from .settings_models import AccountSettings
|
||||
|
||||
# Save account overrides to AccountSettings (key-value store)
|
||||
saved_keys = []
|
||||
|
||||
if integration_type == 'openai':
|
||||
# Only allow model, temperature, max_tokens overrides
|
||||
if 'model' in config:
|
||||
clean_config['model'] = config['model']
|
||||
if 'temperature' in config:
|
||||
clean_config['temperature'] = config['temperature']
|
||||
if 'max_tokens' in config:
|
||||
clean_config['max_tokens'] = config['max_tokens']
|
||||
# Save OpenAI-specific overrides to AccountSettings
|
||||
key_mappings = {
|
||||
'temperature': 'ai.temperature',
|
||||
'max_tokens': 'ai.max_tokens',
|
||||
}
|
||||
for config_key, account_key in key_mappings.items():
|
||||
if config_key in config:
|
||||
AccountSettings.objects.update_or_create(
|
||||
account=account,
|
||||
key=account_key,
|
||||
defaults={'config': {'value': config[config_key]}}
|
||||
)
|
||||
saved_keys.append(account_key)
|
||||
|
||||
elif integration_type == 'image_generation':
|
||||
# Map service to provider if service is provided
|
||||
if 'service' in config:
|
||||
clean_config['service'] = config['service']
|
||||
clean_config['provider'] = config['service']
|
||||
if 'provider' in config:
|
||||
clean_config['provider'] = config['provider']
|
||||
clean_config['service'] = config['provider']
|
||||
|
||||
# Model selection (service-specific)
|
||||
if 'model' in config:
|
||||
clean_config['model'] = config['model']
|
||||
if 'imageModel' in config:
|
||||
clean_config['imageModel'] = config['imageModel']
|
||||
clean_config['model'] = config['imageModel'] # Also store in 'model' for consistency
|
||||
if 'runwareModel' in config:
|
||||
clean_config['runwareModel'] = config['runwareModel']
|
||||
|
||||
# Universal image settings (applies to all providers)
|
||||
for key in ['image_type', 'image_quality', 'image_style', 'max_in_article_images', 'image_format',
|
||||
'desktop_enabled', 'mobile_enabled', 'featured_image_size', 'desktop_image_size']:
|
||||
if key in config:
|
||||
clean_config[key] = config[key]
|
||||
# Save image generation overrides to AccountSettings
|
||||
key_mappings = {
|
||||
'image_type': 'ai.image_style',
|
||||
'image_style': 'ai.image_style',
|
||||
'image_quality': 'ai.image_quality',
|
||||
'max_in_article_images': 'ai.max_images',
|
||||
'desktop_image_size': 'ai.image_size',
|
||||
}
|
||||
for config_key, account_key in key_mappings.items():
|
||||
if config_key in config:
|
||||
AccountSettings.objects.update_or_create(
|
||||
account=account,
|
||||
key=account_key,
|
||||
defaults={'config': {'value': config[config_key]}}
|
||||
)
|
||||
saved_keys.append(account_key)
|
||||
|
||||
# Get or create integration settings
|
||||
logger.info(f"[save_settings] Saving clean config: {clean_config}")
|
||||
integration_settings, created = IntegrationSettings.objects.get_or_create(
|
||||
integration_type=integration_type,
|
||||
account=account,
|
||||
defaults={'config': clean_config, 'is_active': True}
|
||||
)
|
||||
logger.info(f"[save_settings] Result: created={created}, id={integration_settings.id}")
|
||||
|
||||
if not created:
|
||||
integration_settings.config = clean_config
|
||||
integration_settings.is_active = True
|
||||
integration_settings.save()
|
||||
logger.info(f"[save_settings] Updated existing settings")
|
||||
|
||||
logger.info(f"[save_settings] Successfully saved overrides for {integration_type}")
|
||||
logger.info(f"[save_settings] Saved to AccountSettings: {saved_keys}")
|
||||
return success_response(
|
||||
data={'config': clean_config},
|
||||
data={'saved_keys': saved_keys},
|
||||
message=f'{integration_type.upper()} settings saved successfully',
|
||||
request=request
|
||||
)
|
||||
@@ -787,42 +755,36 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
logger.warning(f"Error getting account from user: {e}")
|
||||
account = None
|
||||
|
||||
from .models import IntegrationSettings
|
||||
from igny8_core.modules.system.global_settings_models import GlobalIntegrationSettings
|
||||
from igny8_core.modules.system.ai_settings import SystemAISettings
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
# Get global defaults
|
||||
global_settings = GlobalIntegrationSettings.get_instance()
|
||||
|
||||
# Build response with global defaults
|
||||
# Build response using SystemAISettings (singleton) + AccountSettings overrides
|
||||
if integration_type == 'openai':
|
||||
# Get default model from AIModelConfig
|
||||
default_model = ModelRegistry.get_default_model('text') or 'gpt-4o-mini'
|
||||
|
||||
# Get max_tokens from AIModelConfig for the model
|
||||
max_tokens = SystemAISettings.get_effective_max_tokens(account)
|
||||
try:
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
model_config = AIModelConfig.objects.filter(
|
||||
model_name=default_model,
|
||||
is_active=True
|
||||
).first()
|
||||
if model_config and model_config.max_output_tokens:
|
||||
max_tokens = model_config.max_output_tokens
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response_data = {
|
||||
'id': 'openai',
|
||||
'enabled': True, # Always enabled (platform-wide)
|
||||
'model': global_settings.openai_model,
|
||||
'temperature': global_settings.openai_temperature,
|
||||
'max_tokens': global_settings.openai_max_tokens,
|
||||
'model': default_model,
|
||||
'temperature': SystemAISettings.get_effective_temperature(account),
|
||||
'max_tokens': max_tokens,
|
||||
'using_global': True, # Flag to show it's using global
|
||||
}
|
||||
|
||||
# Check for account overrides
|
||||
if account:
|
||||
try:
|
||||
integration_settings = IntegrationSettings.objects.get(
|
||||
integration_type=integration_type,
|
||||
account=account,
|
||||
is_active=True
|
||||
)
|
||||
config = integration_settings.config or {}
|
||||
if config.get('model'):
|
||||
response_data['model'] = config['model']
|
||||
response_data['using_global'] = False
|
||||
if config.get('temperature') is not None:
|
||||
response_data['temperature'] = config['temperature']
|
||||
if config.get('max_tokens'):
|
||||
response_data['max_tokens'] = config['max_tokens']
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
pass
|
||||
|
||||
elif integration_type == 'runware':
|
||||
response_data = {
|
||||
'id': 'runware',
|
||||
@@ -831,64 +793,42 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
}
|
||||
|
||||
elif integration_type == 'image_generation':
|
||||
# Get default service and model based on global settings
|
||||
default_service = global_settings.default_image_service
|
||||
default_model = global_settings.dalle_model if default_service == 'openai' else global_settings.runware_model
|
||||
# Model-specific landscape sizes
|
||||
MODEL_LANDSCAPE_SIZES = {
|
||||
'runware:97@1': '1280x768',
|
||||
'bria:10@1': '1344x768',
|
||||
'google:4@2': '1376x768',
|
||||
}
|
||||
|
||||
# Get default image model from AIModelConfig
|
||||
default_model = ModelRegistry.get_default_model('image')
|
||||
if default_model:
|
||||
model_config = ModelRegistry.get_model(default_model)
|
||||
default_service = model_config.provider if model_config else 'openai'
|
||||
else:
|
||||
default_service = 'openai'
|
||||
default_model = 'dall-e-3'
|
||||
|
||||
model_landscape_size = MODEL_LANDSCAPE_SIZES.get(default_model, '1280x768')
|
||||
|
||||
response_data = {
|
||||
'id': 'image_generation',
|
||||
'enabled': True,
|
||||
'service': default_service, # From global settings
|
||||
'provider': default_service, # Alias for service
|
||||
'model': default_model, # Service-specific default model
|
||||
'imageModel': global_settings.dalle_model, # OpenAI model
|
||||
'runwareModel': global_settings.runware_model, # Runware model
|
||||
'image_type': global_settings.image_style, # Use image_style as default
|
||||
'image_quality': global_settings.image_quality, # Universal quality
|
||||
'image_style': global_settings.image_style, # Universal style
|
||||
'max_in_article_images': global_settings.max_in_article_images,
|
||||
'service': default_service,
|
||||
'provider': default_service,
|
||||
'model': default_model,
|
||||
'imageModel': default_model if default_service == 'openai' else 'dall-e-3',
|
||||
'runwareModel': default_model if default_service != 'openai' else None,
|
||||
'image_type': SystemAISettings.get_effective_image_style(account),
|
||||
'image_quality': SystemAISettings.get_effective_image_quality(account),
|
||||
'image_style': SystemAISettings.get_effective_image_style(account),
|
||||
'max_in_article_images': SystemAISettings.get_effective_max_images(account),
|
||||
'image_format': 'webp',
|
||||
'desktop_enabled': True,
|
||||
'mobile_enabled': True,
|
||||
'featured_image_size': global_settings.dalle_size,
|
||||
'desktop_image_size': global_settings.desktop_image_size,
|
||||
'mobile_image_size': global_settings.mobile_image_size,
|
||||
'featured_image_size': model_landscape_size,
|
||||
'desktop_image_size': SystemAISettings.get_effective_image_size(account),
|
||||
'using_global': True,
|
||||
}
|
||||
|
||||
# Check for account overrides
|
||||
if account:
|
||||
try:
|
||||
integration_settings = IntegrationSettings.objects.get(
|
||||
integration_type=integration_type,
|
||||
account=account,
|
||||
is_active=True
|
||||
)
|
||||
config = integration_settings.config or {}
|
||||
# Override with account settings
|
||||
if config:
|
||||
response_data['using_global'] = False
|
||||
# Service/provider
|
||||
if 'service' in config:
|
||||
response_data['service'] = config['service']
|
||||
response_data['provider'] = config['service']
|
||||
if 'provider' in config:
|
||||
response_data['provider'] = config['provider']
|
||||
response_data['service'] = config['provider']
|
||||
# Models
|
||||
if 'model' in config:
|
||||
response_data['model'] = config['model']
|
||||
if 'imageModel' in config:
|
||||
response_data['imageModel'] = config['imageModel']
|
||||
if 'runwareModel' in config:
|
||||
response_data['runwareModel'] = config['runwareModel']
|
||||
# Universal image settings
|
||||
for key in ['image_type', 'image_quality', 'image_style', 'max_in_article_images', 'image_format',
|
||||
'desktop_enabled', 'mobile_enabled', 'featured_image_size', 'desktop_image_size']:
|
||||
if key in config:
|
||||
response_data[key] = config[key]
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
# Other integration types - return empty
|
||||
response_data = {
|
||||
@@ -910,9 +850,16 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='image_generation', url_name='image_generation_settings')
|
||||
def get_image_generation_settings(self, request):
|
||||
"""Get image generation settings for current account
|
||||
Normal users fallback to system account (aws-admin) settings
|
||||
"""Get image generation settings for current account.
|
||||
|
||||
Architecture:
|
||||
1. SystemAISettings (singleton) provides system-wide defaults
|
||||
2. AccountSettings (key-value) provides per-account overrides
|
||||
3. API keys come from IntegrationProvider (accounts cannot override API keys)
|
||||
"""
|
||||
from igny8_core.modules.system.ai_settings import SystemAISettings
|
||||
from igny8_core.ai.model_registry import ModelRegistry
|
||||
|
||||
account = getattr(request, 'account', None)
|
||||
|
||||
if not account:
|
||||
@@ -920,89 +867,72 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
user = getattr(request, 'user', None)
|
||||
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
|
||||
account = getattr(user, 'account', None)
|
||||
# Fallback to default account
|
||||
if not account:
|
||||
from igny8_core.auth.models import Account
|
||||
try:
|
||||
account = Account.objects.first()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not account:
|
||||
return error_response(
|
||||
error='Account not found',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
request=request
|
||||
)
|
||||
# Model-specific landscape sizes
|
||||
MODEL_LANDSCAPE_SIZES = {
|
||||
'runware:97@1': '1280x768', # Hi Dream Full landscape
|
||||
'bria:10@1': '1344x768', # Bria 3.2 landscape (16:9)
|
||||
'google:4@2': '1376x768', # Nano Banana landscape (16:9)
|
||||
'dall-e-3': '1792x1024', # DALL-E 3 landscape
|
||||
'dall-e-2': '1024x1024', # DALL-E 2 square only
|
||||
}
|
||||
|
||||
try:
|
||||
from .models import IntegrationSettings
|
||||
from igny8_core.auth.models import Account
|
||||
# Get default image model from AIModelConfig
|
||||
default_model = ModelRegistry.get_default_model('image')
|
||||
if default_model:
|
||||
model_config = ModelRegistry.get_model(default_model)
|
||||
provider = model_config.provider if model_config else 'openai'
|
||||
model = default_model
|
||||
else:
|
||||
provider = 'openai'
|
||||
model = 'dall-e-3'
|
||||
|
||||
# Try to get settings for user's account first
|
||||
try:
|
||||
integration = IntegrationSettings.objects.get(
|
||||
account=account,
|
||||
integration_type='image_generation',
|
||||
is_active=True
|
||||
)
|
||||
logger.info(f"[get_image_generation_settings] Found settings for account {account.id}")
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
# Fallback to system account (aws-admin) settings - normal users use centralized settings
|
||||
logger.info(f"[get_image_generation_settings] No settings for account {account.id}, falling back to system account")
|
||||
try:
|
||||
system_account = Account.objects.get(slug='aws-admin')
|
||||
integration = IntegrationSettings.objects.get(
|
||||
account=system_account,
|
||||
integration_type='image_generation',
|
||||
is_active=True
|
||||
)
|
||||
logger.info(f"[get_image_generation_settings] Using system account (aws-admin) settings")
|
||||
except (Account.DoesNotExist, IntegrationSettings.DoesNotExist):
|
||||
logger.error("[get_image_generation_settings] No image generation settings found in aws-admin account")
|
||||
return error_response(
|
||||
error='Image generation settings not configured in aws-admin account',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
# Get model-specific landscape size
|
||||
model_landscape_size = MODEL_LANDSCAPE_SIZES.get(model, '1280x768')
|
||||
default_featured_size = model_landscape_size if provider == 'runware' else '1792x1024'
|
||||
|
||||
config = integration.config or {}
|
||||
# Get image style from SystemAISettings with AccountSettings overrides
|
||||
image_style = SystemAISettings.get_effective_image_style(account)
|
||||
|
||||
# Debug: Log what's actually in the config
|
||||
logger.info(f"[get_image_generation_settings] Full config: {config}")
|
||||
logger.info(f"[get_image_generation_settings] Config keys: {list(config.keys())}")
|
||||
logger.info(f"[get_image_generation_settings] model field: {config.get('model')}")
|
||||
logger.info(f"[get_image_generation_settings] imageModel field: {config.get('imageModel')}")
|
||||
# Style options - loaded from SystemAISettings model choices
|
||||
# Runware: Uses all styles with prompt enhancement
|
||||
# OpenAI DALL-E: Only supports 'natural' or 'vivid'
|
||||
if provider == 'openai':
|
||||
available_styles = [
|
||||
{'value': 'vivid', 'label': 'Vivid', 'description': 'Dramatic, hyper-realistic style'},
|
||||
{'value': 'natural', 'label': 'Natural', 'description': 'Natural, realistic style'},
|
||||
]
|
||||
# Map stored style to DALL-E compatible
|
||||
if image_style not in ['vivid', 'natural']:
|
||||
image_style = 'natural' # Default to natural for photorealistic
|
||||
else:
|
||||
available_styles = [
|
||||
{'value': opt[0], 'label': opt[1]}
|
||||
for opt in SystemAISettings.IMAGE_STYLE_CHOICES
|
||||
]
|
||||
# Default to photorealistic for Runware if not set
|
||||
if not image_style or image_style in ['natural', 'vivid']:
|
||||
image_style = 'photorealistic'
|
||||
|
||||
# Get model - try 'model' first, then 'imageModel' as fallback
|
||||
model = config.get('model') or config.get('imageModel') or 'dall-e-3'
|
||||
|
||||
# Set defaults for image sizes if not present
|
||||
provider = config.get('provider', 'openai')
|
||||
default_featured_size = '1280x832' if provider == 'runware' else '1024x1024'
|
||||
logger.info(f"[get_image_generation_settings] Returning: provider={provider}, model={model}, image_style={image_style}")
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'config': {
|
||||
'provider': config.get('provider', 'openai'),
|
||||
'provider': provider,
|
||||
'model': model,
|
||||
'image_type': config.get('image_type', 'realistic'),
|
||||
'max_in_article_images': config.get('max_in_article_images'),
|
||||
'image_format': config.get('image_format', 'webp'),
|
||||
'desktop_enabled': config.get('desktop_enabled', True),
|
||||
'mobile_enabled': config.get('mobile_enabled', True),
|
||||
'featured_image_size': config.get('featured_image_size', default_featured_size),
|
||||
'desktop_image_size': config.get('desktop_image_size', '1024x1024'),
|
||||
'image_type': image_style,
|
||||
'available_styles': available_styles,
|
||||
'max_in_article_images': SystemAISettings.get_effective_max_images(account),
|
||||
'image_format': 'webp',
|
||||
'desktop_enabled': True,
|
||||
'featured_image_size': default_featured_size,
|
||||
'desktop_image_size': SystemAISettings.get_effective_image_size(account),
|
||||
}
|
||||
},
|
||||
request=request
|
||||
)
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
return error_response(
|
||||
error='Image generation settings not configured',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[get_image_generation_settings] Error: {str(e)}", exc_info=True)
|
||||
return error_response(
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated migration for updating Runware image models
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0013_add_anthropic_integration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Update runware_model field with new model choices
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='runware_model',
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
('runware:97@1', 'Hi Dream Full - Basic'),
|
||||
('bria:10@1', 'Bria 3.2 - Quality'),
|
||||
('google:4@2', 'Nano Banana - Premium'),
|
||||
],
|
||||
default='runware:97@1',
|
||||
help_text='Default Runware model (accounts can override if plan allows)',
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
# Update desktop_image_size help text (mobile removed)
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='desktop_image_size',
|
||||
field=models.CharField(
|
||||
default='1024x1024',
|
||||
help_text='Default image size for in-article images (accounts can override if plan allows)',
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
# Remove mobile_image_size field if it exists (safe removal)
|
||||
migrations.RemoveField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='mobile_image_size',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-04 06:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0014_update_runware_models'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='anthropic_model',
|
||||
field=models.CharField(choices=[('claude-3-5-sonnet-20241022', 'Claude 3.5 Sonnet - $3.00 / $15.00 per 1M tokens'), ('claude-3-5-haiku-20241022', 'Claude 3.5 Haiku - $1.00 / $5.00 per 1M tokens'), ('claude-3-opus-20240229', 'Claude 3 Opus - $15.00 / $75.00 per 1M tokens'), ('claude-3-sonnet-20240229', 'Claude 3 Sonnet - $3.00 / $15.00 per 1M tokens'), ('claude-3-haiku-20240307', 'Claude 3 Haiku - $0.25 / $1.25 per 1M tokens')], default='claude-3-5-sonnet-20241022', help_text='Default Claude model (accounts can override if plan allows)', max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='default_image_service',
|
||||
field=models.CharField(choices=[('openai', 'OpenAI DALL-E'), ('runware', 'Runware')], default='openai', help_text='Default image generation service for all accounts (openai=DALL-E, runware=Runware, bria=Bria)', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalintegrationsettings',
|
||||
name='image_style',
|
||||
field=models.CharField(choices=[('photorealistic', 'Photorealistic'), ('illustration', 'Illustration'), ('3d_render', '3D Render'), ('minimal_flat', 'Minimal / Flat Design'), ('artistic', 'Artistic / Painterly'), ('cartoon', 'Cartoon / Stylized')], default='photorealistic', help_text='Default image style for all providers (accounts can override if plan allows)', max_length=30),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalmodulesettings',
|
||||
name='linker_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Enable Linker module platform-wide (Phase 2)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalmodulesettings',
|
||||
name='optimizer_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Enable Optimizer module platform-wide (Phase 2)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalmodulesettings',
|
||||
name='site_builder_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Enable Site Builder module platform-wide (DEPRECATED)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='moduleenablesettings',
|
||||
name='linker_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Enable Linker module (Phase 2)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='moduleenablesettings',
|
||||
name='optimizer_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Enable Optimizer module (Phase 2)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='moduleenablesettings',
|
||||
name='site_builder_enabled',
|
||||
field=models.BooleanField(default=False, help_text='Enable Site Builder module (DEPRECATED)'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IntegrationProvider',
|
||||
fields=[
|
||||
('provider_id', models.CharField(help_text="Unique identifier (e.g., 'openai', 'stripe', 'resend')", max_length=50, primary_key=True, serialize=False, unique=True)),
|
||||
('display_name', models.CharField(help_text='Human-readable name', max_length=100)),
|
||||
('provider_type', models.CharField(choices=[('ai', 'AI Provider'), ('email', 'Email Service'), ('payment', 'Payment Gateway'), ('storage', 'Storage Service'), ('analytics', 'Analytics'), ('other', 'Other')], db_index=True, default='ai', max_length=20)),
|
||||
('api_key', models.CharField(blank=True, help_text='Primary API key or token', max_length=500)),
|
||||
('api_secret', models.CharField(blank=True, help_text='Secondary secret (for OAuth, Stripe secret key, etc.)', max_length=500)),
|
||||
('webhook_secret', models.CharField(blank=True, help_text='Webhook signing secret (Stripe, PayPal)', max_length=500)),
|
||||
('api_endpoint', models.URLField(blank=True, help_text='Custom API endpoint (if not default)')),
|
||||
('webhook_url', models.URLField(blank=True, help_text='Webhook URL configured at provider')),
|
||||
('config', models.JSONField(blank=True, default=dict, help_text='Provider-specific config: rate limits, regions, modes, etc.')),
|
||||
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||
('is_sandbox', models.BooleanField(default=False, help_text='True if using sandbox/test mode (Stripe test keys, PayPal sandbox)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_provider_updates', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Integration Provider',
|
||||
'verbose_name_plural': 'Integration Providers',
|
||||
'db_table': 'igny8_integration_providers',
|
||||
'ordering': ['provider_type', 'display_name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,136 @@
|
||||
# Generated manually for data migration
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def populate_integration_providers(apps, schema_editor):
|
||||
"""
|
||||
Populate IntegrationProvider with all 3rd party integrations.
|
||||
API keys will need to be configured in Django admin after migration.
|
||||
"""
|
||||
IntegrationProvider = apps.get_model('system', 'IntegrationProvider')
|
||||
|
||||
providers = [
|
||||
# AI Providers
|
||||
{
|
||||
'provider_id': 'openai',
|
||||
'display_name': 'OpenAI',
|
||||
'provider_type': 'ai',
|
||||
'api_key': '', # To be configured in admin
|
||||
'config': {
|
||||
'default_model': 'gpt-5.1',
|
||||
'models': ['gpt-4o-mini', 'gpt-4o', 'gpt-5.1', 'dall-e-3'],
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'provider_id': 'runware',
|
||||
'display_name': 'Runware',
|
||||
'provider_type': 'ai',
|
||||
'api_key': '', # To be configured in admin
|
||||
'config': {
|
||||
'default_model': 'runware:97@1',
|
||||
'models': ['runware:97@1', 'google:4@2'],
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
{
|
||||
'provider_id': 'anthropic',
|
||||
'display_name': 'Anthropic (Claude)',
|
||||
'provider_type': 'ai',
|
||||
'api_key': '',
|
||||
'config': {
|
||||
'default_model': 'claude-3-5-sonnet-20241022',
|
||||
},
|
||||
'is_active': False, # Not currently used
|
||||
},
|
||||
{
|
||||
'provider_id': 'google',
|
||||
'display_name': 'Google Cloud',
|
||||
'provider_type': 'ai',
|
||||
'api_key': '',
|
||||
'config': {},
|
||||
'is_active': False, # Future: Gemini
|
||||
},
|
||||
|
||||
# Payment Providers
|
||||
{
|
||||
'provider_id': 'stripe',
|
||||
'display_name': 'Stripe',
|
||||
'provider_type': 'payment',
|
||||
'api_key': '', # Public key
|
||||
'api_secret': '', # Secret key
|
||||
'webhook_secret': '',
|
||||
'config': {
|
||||
'currency': 'usd',
|
||||
},
|
||||
'is_active': True,
|
||||
'is_sandbox': True, # Start in test mode
|
||||
},
|
||||
{
|
||||
'provider_id': 'paypal',
|
||||
'display_name': 'PayPal',
|
||||
'provider_type': 'payment',
|
||||
'api_key': '', # Client ID
|
||||
'api_secret': '', # Client Secret
|
||||
'webhook_secret': '',
|
||||
'api_endpoint': 'https://api-m.sandbox.paypal.com', # Sandbox endpoint
|
||||
'config': {
|
||||
'currency': 'usd',
|
||||
},
|
||||
'is_active': True,
|
||||
'is_sandbox': True,
|
||||
},
|
||||
|
||||
# Email Providers
|
||||
{
|
||||
'provider_id': 'resend',
|
||||
'display_name': 'Resend',
|
||||
'provider_type': 'email',
|
||||
'api_key': '',
|
||||
'config': {
|
||||
'from_email': 'noreply@igny8.com',
|
||||
'from_name': 'IGNY8',
|
||||
},
|
||||
'is_active': True,
|
||||
},
|
||||
|
||||
# Storage Providers (Future)
|
||||
{
|
||||
'provider_id': 'cloudflare_r2',
|
||||
'display_name': 'Cloudflare R2',
|
||||
'provider_type': 'storage',
|
||||
'api_key': '', # Access Key ID
|
||||
'api_secret': '', # Secret Access Key
|
||||
'config': {
|
||||
'bucket': '',
|
||||
'endpoint': '',
|
||||
},
|
||||
'is_active': False,
|
||||
},
|
||||
]
|
||||
|
||||
for provider_data in providers:
|
||||
IntegrationProvider.objects.update_or_create(
|
||||
provider_id=provider_data['provider_id'],
|
||||
defaults=provider_data
|
||||
)
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
"""Remove seeded providers"""
|
||||
IntegrationProvider = apps.get_model('system', 'IntegrationProvider')
|
||||
IntegrationProvider.objects.filter(
|
||||
provider_id__in=['openai', 'runware', 'anthropic', 'google', 'stripe', 'paypal', 'resend', 'cloudflare_r2']
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0015_add_integration_provider'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(populate_integration_providers, reverse_migration),
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-04 08:43
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0016_populate_integration_providers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# AccountIntegrationOverride was already deleted in a previous migration
|
||||
# Keeping this migration empty for now
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-04 08:43
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0017_create_ai_settings'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SystemAISettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('temperature', models.FloatField(default=0.7, help_text='AI temperature (0.0-2.0). Higher = more creative.')),
|
||||
('max_tokens', models.IntegerField(default=8192, help_text='Max response tokens')),
|
||||
('image_style', models.CharField(choices=[('photorealistic', 'Photorealistic'), ('illustration', 'Illustration'), ('3d_render', '3D Render'), ('minimal_flat', 'Minimal / Flat Design'), ('artistic', 'Artistic / Painterly'), ('cartoon', 'Cartoon / Stylized')], default='photorealistic', help_text='Default image style', max_length=30)),
|
||||
('image_quality', models.CharField(choices=[('standard', 'Standard'), ('hd', 'HD')], default='standard', help_text='Default image quality (standard/hd)', max_length=20)),
|
||||
('max_images_per_article', models.IntegerField(default=4, help_text='Max in-article images (1-8)')),
|
||||
('image_size', models.CharField(choices=[('1024x1024', '1024x1024 (Square)'), ('1792x1024', '1792x1024 (Landscape)'), ('1024x1792', '1024x1792 (Portrait)')], default='1024x1024', help_text='Default image dimensions', max_length=20)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='system_ai_settings_updates', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'System AI Settings',
|
||||
'verbose_name_plural': 'System AI Settings',
|
||||
'db_table': 'igny8_system_ai_settings',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
# Generated by Django 5.2.9 on 2026-01-04 10:40
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0018_create_ai_settings_table'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='accountsettings',
|
||||
name='config',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='accountsettings',
|
||||
name='is_active',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='integrationprovider',
|
||||
name='webhook_url',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='modulesettings',
|
||||
name='config',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accountsettings',
|
||||
name='value',
|
||||
field=models.JSONField(default=dict, help_text='Setting value'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='accountsettings',
|
||||
name='key',
|
||||
field=models.CharField(db_index=True, help_text='Setting key', max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='api_endpoint',
|
||||
field=models.URLField(blank=True, help_text='Custom endpoint (optional)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='api_key',
|
||||
field=models.CharField(blank=True, help_text='Primary API key', max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='api_secret',
|
||||
field=models.CharField(blank=True, help_text='Secondary secret (Stripe, PayPal)', max_length=500),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='config',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Provider-specific config'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='is_active',
|
||||
field=models.BooleanField(db_index=True, default=True, help_text='Enable/disable provider'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='is_sandbox',
|
||||
field=models.BooleanField(default=False, help_text='Test mode flag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='provider_type',
|
||||
field=models.CharField(choices=[('ai', 'AI Provider'), ('payment', 'Payment Gateway'), ('email', 'Email Service'), ('storage', 'Storage Service')], db_index=True, default='ai', help_text='ai / payment / email / storage', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(blank=True, help_text='Audit trail', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_provider_updates', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='integrationprovider',
|
||||
name='webhook_secret',
|
||||
field=models.CharField(blank=True, help_text='Webhook signing secret', max_length=500),
|
||||
),
|
||||
# AccountIntegrationOverride table doesn't exist in DB, so skip delete
|
||||
# migrations.DeleteModel(
|
||||
# name='AccountIntegrationOverride',
|
||||
# ),
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-08 01:23
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0019_model_schema_update'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EmailTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('template_name', models.CharField(help_text='Template file name without extension (e.g., "welcome")', max_length=100, unique=True)),
|
||||
('template_path', models.CharField(help_text='Full template path (e.g., "emails/welcome.html")', max_length=200)),
|
||||
('display_name', models.CharField(help_text='Human-readable template name', max_length=100)),
|
||||
('description', models.TextField(blank=True, help_text='Description of when this template is used')),
|
||||
('template_type', models.CharField(choices=[('auth', 'Authentication'), ('billing', 'Billing'), ('notification', 'Notification'), ('marketing', 'Marketing')], default='notification', max_length=20)),
|
||||
('default_subject', models.CharField(help_text='Default email subject line', max_length=200)),
|
||||
('required_context', models.JSONField(blank=True, default=list, help_text='List of required context variables for this template')),
|
||||
('sample_context', models.JSONField(blank=True, default=dict, help_text='Sample context for test sending (JSON)')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this template is currently in use')),
|
||||
('send_count', models.IntegerField(default=0, help_text='Number of emails sent using this template')),
|
||||
('last_sent_at', models.DateTimeField(blank=True, help_text='Last time an email was sent with this template', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Email Template',
|
||||
'verbose_name_plural': 'Email Templates',
|
||||
'db_table': 'igny8_email_templates',
|
||||
'ordering': ['template_type', 'display_name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EmailLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('message_id', models.CharField(blank=True, help_text='Provider message ID (from Resend)', max_length=200)),
|
||||
('to_email', models.EmailField(help_text='Recipient email', max_length=254)),
|
||||
('from_email', models.EmailField(help_text='Sender email', max_length=254)),
|
||||
('subject', models.CharField(help_text='Email subject', max_length=500)),
|
||||
('template_name', models.CharField(blank=True, help_text='Template used (if any)', max_length=100)),
|
||||
('status', models.CharField(choices=[('sent', 'Sent'), ('delivered', 'Delivered'), ('failed', 'Failed'), ('bounced', 'Bounced')], default='sent', max_length=20)),
|
||||
('provider', models.CharField(default='resend', help_text='Email provider used', max_length=50)),
|
||||
('error_message', models.TextField(blank=True, help_text='Error message if failed')),
|
||||
('tags', models.JSONField(blank=True, default=list, help_text='Email tags for categorization')),
|
||||
('sent_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Email Log',
|
||||
'verbose_name_plural': 'Email Logs',
|
||||
'db_table': 'igny8_email_log',
|
||||
'ordering': ['-sent_at'],
|
||||
'indexes': [models.Index(fields=['to_email', 'sent_at'], name='igny8_email_to_emai_f0efbd_idx'), models.Index(fields=['status', 'sent_at'], name='igny8_email_status_7107f0_idx'), models.Index(fields=['template_name', 'sent_at'], name='igny8_email_templat_e979b9_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EmailSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('from_email', models.EmailField(default='noreply@igny8.com', help_text='Default sender email address (must be verified in Resend)', max_length=254)),
|
||||
('from_name', models.CharField(default='IGNY8', help_text='Default sender display name', max_length=100)),
|
||||
('reply_to_email', models.EmailField(default='support@igny8.com', help_text='Default reply-to email address', max_length=254)),
|
||||
('company_name', models.CharField(default='IGNY8', help_text='Company name shown in emails', max_length=100)),
|
||||
('company_address', models.TextField(blank=True, help_text='Company address for email footer (CAN-SPAM compliance)')),
|
||||
('logo_url', models.URLField(blank=True, help_text='URL to company logo for emails')),
|
||||
('support_email', models.EmailField(default='support@igny8.com', help_text='Support email shown in emails', max_length=254)),
|
||||
('support_url', models.URLField(blank=True, help_text='Link to support/help center')),
|
||||
('unsubscribe_url', models.URLField(blank=True, help_text='URL for email unsubscribe (for marketing emails)')),
|
||||
('send_welcome_emails', models.BooleanField(default=True, help_text='Send welcome email on user registration')),
|
||||
('send_billing_emails', models.BooleanField(default=True, help_text='Send payment confirmation, invoice emails')),
|
||||
('send_subscription_emails', models.BooleanField(default=True, help_text='Send subscription renewal reminders')),
|
||||
('send_low_credit_warnings', models.BooleanField(default=True, help_text='Send low credit warning emails')),
|
||||
('low_credit_threshold', models.IntegerField(default=100, help_text='Send warning when credits fall below this value')),
|
||||
('renewal_reminder_days', models.IntegerField(default=7, help_text='Days before subscription renewal to send reminder')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_settings_updates', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Email Settings',
|
||||
'verbose_name_plural': 'Email Settings',
|
||||
'db_table': 'igny8_email_settings',
|
||||
},
|
||||
),
|
||||
# Note: AccountIntegrationOverride delete removed - table doesn't exist in DB
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-08 06:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0020_add_email_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='emailsettings',
|
||||
name='email_provider',
|
||||
field=models.CharField(choices=[('resend', 'Resend'), ('smtp', 'SMTP')], default='resend', help_text='Active email service provider', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='emailsettings',
|
||||
name='smtp_host',
|
||||
field=models.CharField(blank=True, help_text='SMTP server hostname (e.g., smtp.gmail.com)', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='emailsettings',
|
||||
name='smtp_password',
|
||||
field=models.CharField(blank=True, help_text='SMTP authentication password', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='emailsettings',
|
||||
name='smtp_port',
|
||||
field=models.IntegerField(default=587, help_text='SMTP server port (587 for TLS, 465 for SSL, 25 for plain)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='emailsettings',
|
||||
name='smtp_timeout',
|
||||
field=models.IntegerField(default=30, help_text='SMTP connection timeout in seconds'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='emailsettings',
|
||||
name='smtp_use_ssl',
|
||||
field=models.BooleanField(default=False, help_text='Use SSL encryption (for port 465)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='emailsettings',
|
||||
name='smtp_use_tls',
|
||||
field=models.BooleanField(default=True, help_text='Use TLS encryption (recommended for port 587)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='emailsettings',
|
||||
name='smtp_username',
|
||||
field=models.CharField(blank=True, help_text='SMTP authentication username', max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -2,6 +2,7 @@
|
||||
System module models - for global settings and prompts
|
||||
"""
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from igny8_core.auth.models import AccountBaseModel
|
||||
|
||||
# Import settings models
|
||||
@@ -10,6 +11,141 @@ from .settings_models import (
|
||||
)
|
||||
|
||||
|
||||
class IntegrationProvider(models.Model):
|
||||
"""
|
||||
Centralized storage for ALL external service API keys.
|
||||
|
||||
Per final-model-schemas.md:
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| provider_id | CharField(50) PK | Yes | openai, runware, stripe, paypal, resend |
|
||||
| display_name | CharField(100) | Yes | Human-readable name |
|
||||
| provider_type | CharField(20) | Yes | ai / payment / email / storage |
|
||||
| api_key | CharField(500) | No | Primary API key |
|
||||
| api_secret | CharField(500) | No | Secondary secret (Stripe, PayPal) |
|
||||
| webhook_secret | CharField(500) | No | Webhook signing secret |
|
||||
| api_endpoint | URLField | No | Custom endpoint (optional) |
|
||||
| config | JSONField | No | Provider-specific config |
|
||||
| is_active | BooleanField | Yes | Enable/disable provider |
|
||||
| is_sandbox | BooleanField | Yes | Test mode flag |
|
||||
| updated_by | FK(User) | No | Audit trail |
|
||||
| created_at | DateTime | Auto | |
|
||||
| updated_at | DateTime | Auto | |
|
||||
"""
|
||||
PROVIDER_TYPE_CHOICES = [
|
||||
('ai', 'AI Provider'),
|
||||
('payment', 'Payment Gateway'),
|
||||
('email', 'Email Service'),
|
||||
('storage', 'Storage Service'),
|
||||
]
|
||||
|
||||
# Primary Key
|
||||
provider_id = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
primary_key=True,
|
||||
help_text="Unique identifier (e.g., 'openai', 'stripe', 'resend')"
|
||||
)
|
||||
|
||||
# Display name
|
||||
display_name = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Human-readable name"
|
||||
)
|
||||
|
||||
# Provider type
|
||||
provider_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=PROVIDER_TYPE_CHOICES,
|
||||
default='ai',
|
||||
db_index=True,
|
||||
help_text="ai / payment / email / storage"
|
||||
)
|
||||
|
||||
# Authentication
|
||||
api_key = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="Primary API key"
|
||||
)
|
||||
api_secret = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="Secondary secret (Stripe, PayPal)"
|
||||
)
|
||||
webhook_secret = models.CharField(
|
||||
max_length=500,
|
||||
blank=True,
|
||||
help_text="Webhook signing secret"
|
||||
)
|
||||
|
||||
# Endpoints
|
||||
api_endpoint = models.URLField(
|
||||
blank=True,
|
||||
help_text="Custom endpoint (optional)"
|
||||
)
|
||||
|
||||
# Configuration
|
||||
config = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Provider-specific config"
|
||||
)
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
db_index=True,
|
||||
help_text="Enable/disable provider"
|
||||
)
|
||||
is_sandbox = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Test mode flag"
|
||||
)
|
||||
|
||||
# Audit
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='integration_provider_updates',
|
||||
help_text="Audit trail"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_integration_providers'
|
||||
verbose_name = 'Integration Provider'
|
||||
verbose_name_plural = 'Integration Providers'
|
||||
ordering = ['provider_type', 'display_name']
|
||||
|
||||
def __str__(self):
|
||||
status = "Active" if self.is_active else "Inactive"
|
||||
mode = "(Sandbox)" if self.is_sandbox else ""
|
||||
return f"{self.display_name} - {status} {mode}".strip()
|
||||
|
||||
@classmethod
|
||||
def get_provider(cls, provider_id: str):
|
||||
"""Get provider by ID, returns None if not found or inactive"""
|
||||
try:
|
||||
return cls.objects.get(provider_id=provider_id, is_active=True)
|
||||
except cls.DoesNotExist:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_api_key(cls, provider_id: str) -> str:
|
||||
"""Get API key for a provider"""
|
||||
provider = cls.get_provider(provider_id)
|
||||
return provider.api_key if provider else ""
|
||||
|
||||
@classmethod
|
||||
def get_providers_by_type(cls, provider_type: str):
|
||||
"""Get all active providers of a type"""
|
||||
return cls.objects.filter(provider_type=provider_type, is_active=True)
|
||||
|
||||
|
||||
class AIPrompt(AccountBaseModel):
|
||||
"""
|
||||
Account-specific AI Prompt templates.
|
||||
|
||||
@@ -17,11 +17,29 @@ class SystemSettingsAdmin(ModelAdmin):
|
||||
|
||||
@admin.register(AccountSettings)
|
||||
class AccountSettingsAdmin(AccountAdminMixin, ModelAdmin):
|
||||
list_display = ['account', 'key', 'is_active', 'updated_at']
|
||||
list_filter = ['is_active', 'account']
|
||||
"""
|
||||
AccountSettings - Generic key-value store for account-specific settings.
|
||||
Per final-model-schemas.md
|
||||
"""
|
||||
list_display = ['account', 'key', 'updated_at']
|
||||
list_filter = ['account']
|
||||
search_fields = ['key', 'account__name']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Account & Key', {
|
||||
'fields': ('account', 'key')
|
||||
}),
|
||||
('Value', {
|
||||
'fields': ('value',),
|
||||
'description': 'JSON value for this setting'
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def get_account_display(self, obj):
|
||||
"""Safely get account name"""
|
||||
try:
|
||||
|
||||
@@ -7,7 +7,6 @@ from igny8_core.auth.models import AccountBaseModel
|
||||
|
||||
class BaseSettings(AccountBaseModel):
|
||||
"""Base class for all account-scoped settings models"""
|
||||
config = models.JSONField(default=dict, help_text="Settings configuration as JSON")
|
||||
is_active = models.BooleanField(default=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -35,9 +34,39 @@ class SystemSettings(models.Model):
|
||||
return f"SystemSetting: {self.key}"
|
||||
|
||||
|
||||
class AccountSettings(BaseSettings):
|
||||
"""Account-level settings"""
|
||||
key = models.CharField(max_length=255, db_index=True, help_text="Settings key identifier")
|
||||
class AccountSettings(AccountBaseModel):
|
||||
"""
|
||||
Generic key-value store for account-specific settings.
|
||||
|
||||
Per final-model-schemas.md:
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| id | AutoField PK | Auto | |
|
||||
| account | FK(Account) | Yes | |
|
||||
| key | CharField(100) | Yes | Setting key |
|
||||
| value | JSONField | Yes | Setting value |
|
||||
| created_at | DateTime | Auto | |
|
||||
| updated_at | DateTime | Auto | |
|
||||
|
||||
AI-Related Keys (override AISettings defaults):
|
||||
- ai.temperature
|
||||
- ai.max_tokens
|
||||
- ai.image_style
|
||||
- ai.image_quality
|
||||
- ai.max_images
|
||||
- ai.image_quality_tier
|
||||
"""
|
||||
key = models.CharField(
|
||||
max_length=100,
|
||||
db_index=True,
|
||||
help_text="Setting key"
|
||||
)
|
||||
value = models.JSONField(
|
||||
default=dict,
|
||||
help_text="Setting value"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_account_settings'
|
||||
|
||||
@@ -3,6 +3,7 @@ Serializers for Settings Models
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||
from .ai_settings import SystemAISettings
|
||||
from .validators import validate_settings_schema
|
||||
|
||||
|
||||
@@ -71,3 +72,21 @@ class AISettingsSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at', 'account']
|
||||
|
||||
|
||||
class SystemAISettingsSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for SystemAISettings (singleton) with AccountSettings overrides.
|
||||
Per the plan: GET/PUT /api/v1/accounts/settings/ai/
|
||||
"""
|
||||
# Content Generation
|
||||
temperature = serializers.FloatField(min_value=0.0, max_value=2.0)
|
||||
max_tokens = serializers.IntegerField(min_value=100, max_value=32000)
|
||||
|
||||
# Image Generation
|
||||
image_quality_tier = serializers.CharField(max_length=20)
|
||||
image_style = serializers.CharField(max_length=30)
|
||||
max_images = serializers.IntegerField(min_value=1, max_value=8)
|
||||
|
||||
# Read-only metadata
|
||||
quality_tiers = serializers.ListField(read_only=True)
|
||||
styles = serializers.ListField(read_only=True)
|
||||
|
||||
@@ -15,10 +15,14 @@ from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||
from .global_settings_models import GlobalModuleSettings
|
||||
from .ai_settings import SystemAISettings
|
||||
from .settings_serializers import (
|
||||
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
|
||||
ModuleSettingsSerializer, AISettingsSerializer
|
||||
ModuleSettingsSerializer, AISettingsSerializer, SystemAISettingsSerializer
|
||||
)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -510,3 +514,184 @@ class AISettingsViewSet(AccountModelViewSet):
|
||||
|
||||
serializer.save(account=account)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['AI Settings']),
|
||||
)
|
||||
class ContentGenerationSettingsViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
ViewSet for Content Generation Settings per the plan.
|
||||
|
||||
GET /api/v1/accounts/settings/ai/ - Get merged SystemAISettings + AccountSettings
|
||||
PUT /api/v1/accounts/settings/ai/ - Save account overrides to AccountSettings
|
||||
|
||||
This endpoint returns:
|
||||
- content_generation: temperature, max_tokens
|
||||
- image_generation: quality_tiers, selected_tier, styles, selected_style, max_images
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
authentication_classes = [JWTAuthentication]
|
||||
throttle_scope = 'system'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def _get_account(self, request):
|
||||
"""Get account from request"""
|
||||
account = getattr(request, 'account', None)
|
||||
if not account:
|
||||
user = getattr(request, 'user', None)
|
||||
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
|
||||
account = getattr(user, 'account', None)
|
||||
return account
|
||||
|
||||
def list(self, request):
|
||||
"""
|
||||
GET /api/v1/accounts/settings/ai/
|
||||
|
||||
Returns merged AI settings (SystemAISettings + AccountSettings overrides)
|
||||
Response structure per the plan:
|
||||
{
|
||||
"content_generation": { "temperature": 0.7, "max_tokens": 8192 },
|
||||
"image_generation": {
|
||||
"quality_tiers": [...],
|
||||
"selected_tier": "quality",
|
||||
"styles": [...],
|
||||
"selected_style": "photorealistic",
|
||||
"max_images": 4,
|
||||
"max_allowed": 8
|
||||
}
|
||||
}
|
||||
"""
|
||||
account = self._get_account(request)
|
||||
|
||||
try:
|
||||
from igny8_core.business.billing.models import AIModelConfig
|
||||
|
||||
# Get quality tiers from AIModelConfig (image models)
|
||||
quality_tiers = []
|
||||
for model in AIModelConfig.objects.filter(model_type='image', is_active=True).order_by('credits_per_image'):
|
||||
tier = model.quality_tier or 'basic'
|
||||
# Avoid duplicates
|
||||
if not any(t['tier'] == tier for t in quality_tiers):
|
||||
quality_tiers.append({
|
||||
'tier': tier,
|
||||
'credits': model.credits_per_image or 1,
|
||||
'label': tier.title(),
|
||||
'description': f"{model.display_name} quality",
|
||||
'model': model.model_name,
|
||||
})
|
||||
|
||||
# Ensure we have at least basic tiers
|
||||
if not quality_tiers:
|
||||
quality_tiers = [
|
||||
{'tier': 'basic', 'credits': 1, 'label': 'Basic', 'description': 'Fast, simple images'},
|
||||
{'tier': 'quality', 'credits': 5, 'label': 'Quality', 'description': 'Balanced quality'},
|
||||
{'tier': 'premium', 'credits': 15, 'label': 'Premium', 'description': 'Best quality'},
|
||||
]
|
||||
|
||||
# Get styles from SystemAISettings model choices
|
||||
styles = [
|
||||
{'value': opt[0], 'label': opt[1]}
|
||||
for opt in SystemAISettings.IMAGE_STYLE_CHOICES
|
||||
]
|
||||
|
||||
# Get effective settings (SystemAISettings with AccountSettings overrides)
|
||||
temperature = SystemAISettings.get_effective_temperature(account)
|
||||
max_tokens = SystemAISettings.get_effective_max_tokens(account)
|
||||
image_style = SystemAISettings.get_effective_image_style(account)
|
||||
max_images = SystemAISettings.get_effective_max_images(account)
|
||||
|
||||
# Get selected quality tier from AccountSettings
|
||||
selected_tier = 'quality' # Default
|
||||
if account:
|
||||
tier_setting = AccountSettings.objects.filter(
|
||||
account=account,
|
||||
key='ai.image_quality_tier'
|
||||
).first()
|
||||
if tier_setting and tier_setting.config:
|
||||
selected_tier = tier_setting.config.get('value', 'quality')
|
||||
|
||||
response_data = {
|
||||
'content_generation': {
|
||||
'temperature': temperature,
|
||||
'max_tokens': max_tokens,
|
||||
},
|
||||
'image_generation': {
|
||||
'quality_tiers': quality_tiers,
|
||||
'selected_tier': selected_tier,
|
||||
'styles': styles,
|
||||
'selected_style': image_style,
|
||||
'max_images': max_images,
|
||||
'max_allowed': 8,
|
||||
}
|
||||
}
|
||||
|
||||
return success_response(data=response_data, request=request)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting AI settings: {e}", exc_info=True)
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
def create(self, request):
|
||||
"""
|
||||
PUT/POST /api/v1/accounts/settings/ai/
|
||||
|
||||
Save account-specific overrides to AccountSettings.
|
||||
Request body per the plan:
|
||||
{
|
||||
"temperature": 0.8,
|
||||
"max_tokens": 4096,
|
||||
"image_quality_tier": "premium",
|
||||
"image_style": "illustration",
|
||||
"max_images": 6
|
||||
}
|
||||
"""
|
||||
account = self._get_account(request)
|
||||
|
||||
if not account:
|
||||
return error_response(
|
||||
error='Account not found',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
try:
|
||||
data = request.data
|
||||
saved_keys = []
|
||||
|
||||
# Map request fields to AccountSettings keys
|
||||
key_mappings = {
|
||||
'temperature': 'ai.temperature',
|
||||
'max_tokens': 'ai.max_tokens',
|
||||
'image_quality_tier': 'ai.image_quality_tier',
|
||||
'image_style': 'ai.image_style',
|
||||
'max_images': 'ai.max_images',
|
||||
}
|
||||
|
||||
for field, account_key in key_mappings.items():
|
||||
if field in data:
|
||||
AccountSettings.objects.update_or_create(
|
||||
account=account,
|
||||
key=account_key,
|
||||
defaults={'config': {'value': data[field]}}
|
||||
)
|
||||
saved_keys.append(account_key)
|
||||
|
||||
logger.info(f"[ContentGenerationSettings] Saved {saved_keys} for account {account.id}")
|
||||
|
||||
return success_response(
|
||||
data={'saved_keys': saved_keys},
|
||||
message='AI settings saved successfully',
|
||||
request=request
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving AI settings: {e}", exc_info=True)
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""
|
||||
Test script to verify URL patterns are correctly registered
|
||||
Run this with: python manage.py shell < test_urls.py
|
||||
"""
|
||||
from django.urls import resolve, reverse
|
||||
from django.test import RequestFactory
|
||||
|
||||
# Test URL resolution
|
||||
try:
|
||||
# Test the generate endpoint
|
||||
url_path = '/api/v1/system/settings/integrations/image_generation/generate/'
|
||||
resolved = resolve(url_path)
|
||||
print(f"✅ URL resolved: {url_path}")
|
||||
print(f" View: {resolved.func}")
|
||||
print(f" Args: {resolved.args}")
|
||||
print(f" Kwargs: {resolved.kwargs}")
|
||||
except Exception as e:
|
||||
print(f"❌ URL NOT resolved: {url_path}")
|
||||
print(f" Error: {e}")
|
||||
|
||||
# Test reverse
|
||||
try:
|
||||
reversed_url = reverse('integration-settings-generate', kwargs={'pk': 'image_generation'})
|
||||
print(f"✅ Reverse URL: {reversed_url}")
|
||||
except Exception as e:
|
||||
print(f"❌ Reverse failed: {e}")
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated migration for Images model unique constraint
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('writer', '0015_add_publishing_scheduler_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Add unique constraint for content + image_type + position
|
||||
# This ensures no duplicate positions for the same image type within a content
|
||||
migrations.AddConstraint(
|
||||
model_name='images',
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(('is_deleted', False)),
|
||||
fields=('content', 'image_type', 'position'),
|
||||
name='unique_content_image_type_position',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -76,6 +76,7 @@ class ImagesSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Images model"""
|
||||
task_title = serializers.SerializerMethodField()
|
||||
content_title = serializers.SerializerMethodField()
|
||||
aspect_ratio = serializers.ReadOnlyField() # Expose aspect_ratio property
|
||||
|
||||
class Meta:
|
||||
model = Images
|
||||
@@ -92,11 +93,12 @@ class ImagesSerializer(serializers.ModelSerializer):
|
||||
'caption',
|
||||
'status',
|
||||
'position',
|
||||
'aspect_ratio',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'account_id',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id', 'aspect_ratio']
|
||||
|
||||
def get_task_title(self, obj):
|
||||
"""Get task title"""
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
"""
|
||||
Stage 1 Backend Refactor - Basic Tests
|
||||
Test the refactored models and serializers
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.planning.models import Clusters
|
||||
from igny8_core.business.content.models import Tasks, Content, ContentTaxonomy
|
||||
from igny8_core.modules.writer.serializers import TasksSerializer, ContentSerializer, ContentTaxonomySerializer
|
||||
|
||||
|
||||
class TestClusterModel(TestCase):
|
||||
"""Test Cluster model after Stage 1 refactor"""
|
||||
|
||||
def test_cluster_fields_removed(self):
|
||||
"""Verify deprecated fields are removed"""
|
||||
cluster = Clusters()
|
||||
|
||||
# These fields should NOT exist
|
||||
assert not hasattr(cluster, 'context_type'), "context_type should be removed"
|
||||
assert not hasattr(cluster, 'dimension_meta'), "dimension_meta should be removed"
|
||||
|
||||
# These fields SHOULD exist
|
||||
assert hasattr(cluster, 'name'), "name field should exist"
|
||||
assert hasattr(cluster, 'keywords'), "keywords field should exist"
|
||||
|
||||
|
||||
class TestTasksModel(TestCase):
|
||||
"""Test Tasks model after Stage 1 refactor"""
|
||||
|
||||
def test_tasks_fields_removed(self):
|
||||
"""Verify deprecated fields are removed"""
|
||||
task = Tasks()
|
||||
|
||||
# These fields should NOT exist
|
||||
assert not hasattr(task, 'cluster_role'), "cluster_role should be removed"
|
||||
assert not hasattr(task, 'idea_id'), "idea_id should be removed"
|
||||
assert not hasattr(task, 'content_record'), "content_record should be removed"
|
||||
assert not hasattr(task, 'entity_type'), "entity_type should be removed"
|
||||
|
||||
def test_tasks_fields_added(self):
|
||||
"""Verify new fields are added"""
|
||||
task = Tasks()
|
||||
|
||||
# These fields SHOULD exist
|
||||
assert hasattr(task, 'content_type'), "content_type should be added"
|
||||
assert hasattr(task, 'content_structure'), "content_structure should be added"
|
||||
assert hasattr(task, 'taxonomy_term_id'), "taxonomy_term_id should be added"
|
||||
|
||||
def test_tasks_status_choices(self):
|
||||
"""Verify status choices are simplified"""
|
||||
# Status should only have 'queued' and 'completed'
|
||||
status_choices = [choice[0] for choice in Tasks._meta.get_field('status').choices]
|
||||
assert 'queued' in status_choices, "queued should be a valid status"
|
||||
assert 'completed' in status_choices, "completed should be a valid status"
|
||||
assert len(status_choices) == 2, "Should only have 2 status choices"
|
||||
|
||||
|
||||
class TestContentModel(TestCase):
|
||||
"""Test Content model after Stage 1 refactor"""
|
||||
|
||||
def test_content_fields_removed(self):
|
||||
"""Verify deprecated fields are removed"""
|
||||
content = Content()
|
||||
|
||||
# These fields should NOT exist
|
||||
assert not hasattr(content, 'task'), "task FK should be removed"
|
||||
assert not hasattr(content, 'html_content'), "html_content should be removed (use content_html)"
|
||||
assert not hasattr(content, 'entity_type'), "entity_type should be removed"
|
||||
assert not hasattr(content, 'cluster_role'), "cluster_role should be removed"
|
||||
assert not hasattr(content, 'sync_status'), "sync_status should be removed"
|
||||
|
||||
def test_content_fields_added(self):
|
||||
"""Verify new fields are added"""
|
||||
content = Content()
|
||||
|
||||
# These fields SHOULD exist
|
||||
assert hasattr(content, 'title'), "title should be added"
|
||||
assert hasattr(content, 'content_html'), "content_html should be added"
|
||||
assert hasattr(content, 'cluster_id'), "cluster_id should be added"
|
||||
assert hasattr(content, 'content_type'), "content_type should be added"
|
||||
assert hasattr(content, 'content_structure'), "content_structure should be added"
|
||||
assert hasattr(content, 'taxonomy_terms'), "taxonomy_terms M2M should exist"
|
||||
|
||||
def test_content_status_choices(self):
|
||||
"""Verify status choices are simplified"""
|
||||
# Status should only have 'draft' and 'published'
|
||||
status_choices = [choice[0] for choice in Content._meta.get_field('status').choices]
|
||||
assert 'draft' in status_choices, "draft should be a valid status"
|
||||
assert 'published' in status_choices, "published should be a valid status"
|
||||
assert len(status_choices) == 2, "Should only have 2 status choices"
|
||||
|
||||
|
||||
class TestContentTaxonomyModel(TestCase):
|
||||
"""Test ContentTaxonomy model after Stage 1 refactor"""
|
||||
|
||||
def test_taxonomy_fields_removed(self):
|
||||
"""Verify deprecated fields are removed"""
|
||||
taxonomy = ContentTaxonomy()
|
||||
|
||||
# These fields should NOT exist
|
||||
assert not hasattr(taxonomy, 'description'), "description should be removed"
|
||||
assert not hasattr(taxonomy, 'parent'), "parent FK should be removed"
|
||||
assert not hasattr(taxonomy, 'sync_status'), "sync_status should be removed"
|
||||
assert not hasattr(taxonomy, 'count'), "count should be removed"
|
||||
assert not hasattr(taxonomy, 'metadata'), "metadata should be removed"
|
||||
assert not hasattr(taxonomy, 'clusters'), "clusters M2M should be removed"
|
||||
|
||||
def test_taxonomy_type_includes_cluster(self):
|
||||
"""Verify taxonomy_type includes 'cluster' option"""
|
||||
type_choices = [choice[0] for choice in ContentTaxonomy._meta.get_field('taxonomy_type').choices]
|
||||
assert 'category' in type_choices, "category should be a valid type"
|
||||
assert 'post_tag' in type_choices, "post_tag should be a valid type"
|
||||
assert 'cluster' in type_choices, "cluster should be a valid type"
|
||||
|
||||
|
||||
class TestTasksSerializer(TestCase):
|
||||
"""Test TasksSerializer after Stage 1 refactor"""
|
||||
|
||||
def test_serializer_fields(self):
|
||||
"""Verify serializer has correct fields"""
|
||||
serializer = TasksSerializer()
|
||||
fields = serializer.fields.keys()
|
||||
|
||||
# Should have new fields
|
||||
assert 'content_type' in fields, "content_type should be in serializer"
|
||||
assert 'content_structure' in fields, "content_structure should be in serializer"
|
||||
assert 'taxonomy_term_id' in fields, "taxonomy_term_id should be in serializer"
|
||||
assert 'cluster_id' in fields, "cluster_id should be in serializer"
|
||||
|
||||
# Should NOT have deprecated fields
|
||||
assert 'idea_title' not in fields, "idea_title should not be in serializer"
|
||||
assert 'cluster_role' not in fields, "cluster_role should not be in serializer"
|
||||
|
||||
|
||||
class TestContentSerializer(TestCase):
|
||||
"""Test ContentSerializer after Stage 1 refactor"""
|
||||
|
||||
def test_serializer_fields(self):
|
||||
"""Verify serializer has correct fields"""
|
||||
serializer = ContentSerializer()
|
||||
fields = serializer.fields.keys()
|
||||
|
||||
# Should have new fields
|
||||
assert 'title' in fields, "title should be in serializer"
|
||||
assert 'content_html' in fields, "content_html should be in serializer"
|
||||
assert 'cluster_id' in fields, "cluster_id should be in serializer"
|
||||
assert 'content_type' in fields, "content_type should be in serializer"
|
||||
assert 'content_structure' in fields, "content_structure should be in serializer"
|
||||
assert 'taxonomy_terms_data' in fields, "taxonomy_terms_data should be in serializer"
|
||||
|
||||
# Should NOT have deprecated fields
|
||||
assert 'task_id' not in fields, "task_id should not be in serializer"
|
||||
assert 'entity_type' not in fields, "entity_type should not be in serializer"
|
||||
assert 'cluster_role' not in fields, "cluster_role should not be in serializer"
|
||||
|
||||
|
||||
class TestContentTaxonomySerializer(TestCase):
|
||||
"""Test ContentTaxonomySerializer after Stage 1 refactor"""
|
||||
|
||||
def test_serializer_fields(self):
|
||||
"""Verify serializer has correct fields"""
|
||||
serializer = ContentTaxonomySerializer()
|
||||
fields = serializer.fields.keys()
|
||||
|
||||
# Should have these fields
|
||||
assert 'id' in fields
|
||||
assert 'name' in fields
|
||||
assert 'slug' in fields
|
||||
assert 'taxonomy_type' in fields
|
||||
|
||||
# Should NOT have deprecated fields
|
||||
assert 'description' not in fields, "description should not be in serializer"
|
||||
assert 'parent' not in fields, "parent should not be in serializer"
|
||||
assert 'sync_status' not in fields, "sync_status should not be in serializer"
|
||||
assert 'cluster_names' not in fields, "cluster_names should not be in serializer"
|
||||
|
||||
|
||||
# Run tests with: python manage.py test igny8_core.modules.writer.tests.test_stage1_refactor
|
||||
# Or with pytest: pytest backend/igny8_core/modules/writer/tests/test_stage1_refactor.py -v
|
||||
@@ -637,8 +637,9 @@ IMPORT_EXPORT_USE_TRANSACTIONS = True
|
||||
|
||||
UNFOLD = {
|
||||
"SITE_TITLE": "IGNY8 Administration",
|
||||
"SITE_HEADER": "IGNY8 Admin",
|
||||
"SITE_HEADER": "", # Empty to hide text, logo will be shown instead
|
||||
"SITE_URL": "/",
|
||||
"SITE_LOGO": lambda request: "/static/admin/img/logo.png",
|
||||
"SITE_SYMBOL": "rocket_launch", # Symbol from Material icons
|
||||
"SHOW_HISTORY": True, # Show history for models with simple_history
|
||||
"SHOW_VIEW_ON_SITE": True, # Show "View on site" button
|
||||
@@ -657,17 +658,196 @@ UNFOLD = {
|
||||
"950": "2 6 23",
|
||||
},
|
||||
},
|
||||
"EXTENSIONS": {
|
||||
"modeltranslation": {
|
||||
"flags": {
|
||||
"en": "🇬🇧",
|
||||
"fr": "🇫🇷",
|
||||
},
|
||||
},
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"show_search": True,
|
||||
"show_all_applications": False, # MUST be False - we provide custom sidebar_navigation
|
||||
"show_all_applications": False,
|
||||
"navigation": [
|
||||
# Dashboard & Reports
|
||||
{
|
||||
"title": "Dashboard & Reports",
|
||||
"icon": "dashboard",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Dashboard", "icon": "home", "link": lambda request: "/admin/dashboard/"},
|
||||
{"title": "Revenue Report", "icon": "attach_money", "link": lambda request: "/admin/reports/revenue/"},
|
||||
{"title": "Usage Report", "icon": "data_usage", "link": lambda request: "/admin/reports/usage/"},
|
||||
{"title": "Content Report", "icon": "article", "link": lambda request: "/admin/reports/content/"},
|
||||
{"title": "Data Quality", "icon": "verified", "link": lambda request: "/admin/reports/data-quality/"},
|
||||
{"title": "Token Usage", "icon": "token", "link": lambda request: "/admin/reports/token-usage/"},
|
||||
{"title": "AI Cost Analysis", "icon": "psychology", "link": lambda request: "/admin/reports/ai-cost-analysis/"},
|
||||
],
|
||||
},
|
||||
# Accounts & Users
|
||||
{
|
||||
"title": "Accounts & Users",
|
||||
"icon": "group",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Accounts", "icon": "business", "link": lambda request: "/admin/igny8_core_auth/account/"},
|
||||
{"title": "Users", "icon": "person", "link": lambda request: "/admin/igny8_core_auth/user/"},
|
||||
{"title": "Sites", "icon": "language", "link": lambda request: "/admin/igny8_core_auth/site/"},
|
||||
{"title": "Sectors", "icon": "category", "link": lambda request: "/admin/igny8_core_auth/sector/"},
|
||||
{"title": "Site Access", "icon": "lock", "link": lambda request: "/admin/igny8_core_auth/siteuseraccess/"},
|
||||
],
|
||||
},
|
||||
# Plans & Billing
|
||||
{
|
||||
"title": "Plans & Billing",
|
||||
"icon": "payments",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Plans", "icon": "workspace_premium", "link": lambda request: "/admin/igny8_core_auth/plan/"},
|
||||
{"title": "Subscriptions", "icon": "subscriptions", "link": lambda request: "/admin/igny8_core_auth/subscription/"},
|
||||
{"title": "Invoices", "icon": "receipt_long", "link": lambda request: "/admin/billing/invoice/"},
|
||||
{"title": "Payments", "icon": "paid", "link": lambda request: "/admin/billing/payment/"},
|
||||
{"title": "Credit Packages", "icon": "card_giftcard", "link": lambda request: "/admin/billing/creditpackage/"},
|
||||
{"title": "Payment Methods (Global)", "icon": "credit_card", "link": lambda request: "/admin/billing/paymentmethodconfig/"},
|
||||
{"title": "Account Payment Methods", "icon": "account_balance_wallet", "link": lambda request: "/admin/billing/accountpaymentmethod/"},
|
||||
],
|
||||
},
|
||||
# Credits
|
||||
{
|
||||
"title": "Credits",
|
||||
"icon": "toll",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Transactions", "icon": "swap_horiz", "link": lambda request: "/admin/billing/credittransaction/"},
|
||||
{"title": "Usage Log", "icon": "history", "link": lambda request: "/admin/billing/creditusagelog/"},
|
||||
{"title": "Plan Limits", "icon": "speed", "link": lambda request: "/admin/billing/planlimitusage/"},
|
||||
],
|
||||
},
|
||||
# Planning
|
||||
{
|
||||
"title": "Planning",
|
||||
"icon": "map",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Keywords", "icon": "key", "link": lambda request: "/admin/planner/keywords/"},
|
||||
{"title": "Clusters", "icon": "hub", "link": lambda request: "/admin/planner/clusters/"},
|
||||
{"title": "Content Ideas", "icon": "lightbulb", "link": lambda request: "/admin/planner/contentideas/"},
|
||||
],
|
||||
},
|
||||
# Writing
|
||||
{
|
||||
"title": "Writing",
|
||||
"icon": "edit_note",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Tasks", "icon": "task_alt", "link": lambda request: "/admin/writer/tasks/"},
|
||||
{"title": "Content", "icon": "description", "link": lambda request: "/admin/writer/content/"},
|
||||
{"title": "Images", "icon": "image", "link": lambda request: "/admin/writer/images/"},
|
||||
{"title": "Image Prompts", "icon": "auto_awesome", "link": lambda request: "/admin/writer/imageprompts/"},
|
||||
],
|
||||
},
|
||||
# Taxonomy
|
||||
{
|
||||
"title": "Taxonomy",
|
||||
"icon": "label",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Taxonomies", "icon": "sell", "link": lambda request: "/admin/writer/contenttaxonomy/"},
|
||||
{"title": "Relations", "icon": "link", "link": lambda request: "/admin/writer/contenttaxonomyrelation/"},
|
||||
{"title": "Attributes", "icon": "tune", "link": lambda request: "/admin/writer/contentattribute/"},
|
||||
{"title": "Cluster Maps", "icon": "account_tree", "link": lambda request: "/admin/writer/contentclustermap/"},
|
||||
],
|
||||
},
|
||||
# Publishing
|
||||
{
|
||||
"title": "Publishing",
|
||||
"icon": "publish",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Integrations", "icon": "extension", "link": lambda request: "/admin/integration/siteintegration/"},
|
||||
{"title": "Publishing Records", "icon": "cloud_upload", "link": lambda request: "/admin/publishing/publishingrecord/"},
|
||||
{"title": "Deployments", "icon": "rocket", "link": lambda request: "/admin/publishing/deploymentrecord/"},
|
||||
{"title": "Sync Events", "icon": "sync", "link": lambda request: "/admin/integration/syncevent/"},
|
||||
],
|
||||
},
|
||||
# Automation
|
||||
{
|
||||
"title": "Automation",
|
||||
"icon": "smart_toy",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Configs", "icon": "settings_suggest", "link": lambda request: "/admin/automation/automationconfig/"},
|
||||
{"title": "Runs", "icon": "play_circle", "link": lambda request: "/admin/automation/automationrun/"},
|
||||
],
|
||||
},
|
||||
# AI Configuration
|
||||
{
|
||||
"title": "AI Configuration",
|
||||
"icon": "psychology",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "AI Models", "icon": "model_training", "link": lambda request: "/admin/billing/aimodelconfig/"},
|
||||
{"title": "Credit Costs", "icon": "calculate", "link": lambda request: "/admin/billing/creditcostconfig/"},
|
||||
{"title": "Billing Config", "icon": "tune", "link": lambda request: "/admin/billing/billingconfiguration/"},
|
||||
{"title": "AI Task Logs", "icon": "history", "link": lambda request: "/admin/ai/aitasklog/"},
|
||||
],
|
||||
},
|
||||
# Email Settings (NEW)
|
||||
{
|
||||
"title": "Email Settings",
|
||||
"icon": "email",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Email Configuration", "icon": "settings", "link": lambda request: "/admin/system/emailsettings/"},
|
||||
{"title": "Email Templates", "icon": "article", "link": lambda request: "/admin/system/emailtemplate/"},
|
||||
{"title": "Email Logs", "icon": "history", "link": lambda request: "/admin/system/emaillog/"},
|
||||
{"title": "Resend Provider", "icon": "key", "link": lambda request: "/admin/system/integrationprovider/resend/change/"},
|
||||
],
|
||||
},
|
||||
# Global Settings
|
||||
{
|
||||
"title": "Global Settings",
|
||||
"icon": "settings",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Integration Providers", "icon": "key", "link": lambda request: "/admin/system/integrationprovider/"},
|
||||
{"title": "System AI Settings", "icon": "psychology", "link": lambda request: "/admin/system/systemaisettings/"},
|
||||
{"title": "Module Settings", "icon": "view_module", "link": lambda request: "/admin/system/globalmodulesettings/"},
|
||||
{"title": "AI Prompts", "icon": "smart_toy", "link": lambda request: "/admin/system/globalaiprompt/"},
|
||||
{"title": "Author Profiles", "icon": "person_outline", "link": lambda request: "/admin/system/globalauthorprofile/"},
|
||||
{"title": "Strategies", "icon": "strategy", "link": lambda request: "/admin/system/globalstrategy/"},
|
||||
],
|
||||
},
|
||||
# Resources
|
||||
{
|
||||
"title": "Resources",
|
||||
"icon": "inventory_2",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Industries", "icon": "factory", "link": lambda request: "/admin/igny8_core_auth/industry/"},
|
||||
{"title": "Industry Sectors", "icon": "domain", "link": lambda request: "/admin/igny8_core_auth/industrysector/"},
|
||||
{"title": "Seed Keywords", "icon": "eco", "link": lambda request: "/admin/igny8_core_auth/seedkeyword/"},
|
||||
],
|
||||
},
|
||||
# Logs & Monitoring
|
||||
{
|
||||
"title": "Logs & Monitoring",
|
||||
"icon": "monitor_heart",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "System Health", "icon": "health_and_safety", "link": lambda request: "/admin/monitoring/system-health/"},
|
||||
{"title": "API Monitor", "icon": "api", "link": lambda request: "/admin/monitoring/api-monitor/"},
|
||||
{"title": "Debug Console", "icon": "terminal", "link": lambda request: "/admin/monitoring/debug-console/"},
|
||||
{"title": "Celery Tasks", "icon": "schedule", "link": lambda request: "/admin/django_celery_results/taskresult/"},
|
||||
{"title": "Admin Log", "icon": "history", "link": lambda request: "/admin/admin/logentry/"},
|
||||
],
|
||||
},
|
||||
# Django Admin
|
||||
{
|
||||
"title": "Django Admin",
|
||||
"icon": "admin_panel_settings",
|
||||
"collapsible": True,
|
||||
"items": [
|
||||
{"title": "Groups", "icon": "groups", "link": lambda request: "/admin/auth/group/"},
|
||||
{"title": "Permissions", "icon": "security", "link": lambda request: "/admin/auth/permission/"},
|
||||
{"title": "Content Types", "icon": "dns", "link": lambda request: "/admin/contenttypes/contenttype/"},
|
||||
{"title": "Sessions", "icon": "badge", "link": lambda request: "/admin/sessions/session/"},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -678,3 +858,6 @@ STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET', '')
|
||||
PAYPAL_CLIENT_ID = os.getenv('PAYPAL_CLIENT_ID', '')
|
||||
PAYPAL_CLIENT_SECRET = os.getenv('PAYPAL_CLIENT_SECRET', '')
|
||||
PAYPAL_API_BASE = os.getenv('PAYPAL_API_BASE', 'https://api-m.sandbox.paypal.com')
|
||||
|
||||
# Frontend URL for redirects (Stripe/PayPal success/cancel URLs)
|
||||
FRONTEND_URL = os.getenv('FRONTEND_URL', 'https://app.igny8.com')
|
||||
|
||||
44
backend/igny8_core/static/admin/img/logo-dark.svg
Normal file
44
backend/igny8_core/static/admin/img/logo-dark.svg
Normal file
@@ -0,0 +1,44 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 8.42105C0 3.77023 3.77023 0 8.42105 0H23.5789C28.2298 0 32 3.77023 32 8.42105V23.5789C32 28.2298 28.2298 32 23.5789 32H8.42105C3.77023 32 0 28.2298 0 23.5789V8.42105Z" fill="#465FFF"/>
|
||||
<g filter="url(#filter0_d_1884_16361)">
|
||||
<path d="M8.42383 8.42152C8.42383 7.49135 9.17787 6.7373 10.108 6.7373C11.0382 6.7373 11.7922 7.49135 11.7922 8.42152V23.5794C11.7922 24.5096 11.0382 25.2636 10.108 25.2636C9.17787 25.2636 8.42383 24.5096 8.42383 23.5794V8.42152Z" fill="white"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_d_1884_16361)">
|
||||
<path d="M14.7422 15.1569C14.7422 14.2267 15.4962 13.4727 16.4264 13.4727C17.3566 13.4727 18.1106 14.2267 18.1106 15.1569V23.5779C18.1106 24.5081 17.3566 25.2621 16.4264 25.2621C15.4962 25.2621 14.7422 24.5081 14.7422 23.5779V15.1569Z" fill="white" fill-opacity="0.9" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_d_1884_16361)">
|
||||
<path d="M21.0547 10.9459C21.0547 10.0158 21.8087 9.26172 22.7389 9.26172C23.6691 9.26172 24.4231 10.0158 24.4231 10.9459V23.5775C24.4231 24.5077 23.6691 25.2617 22.7389 25.2617C21.8087 25.2617 21.0547 24.5077 21.0547 23.5775V10.9459Z" fill="white" fill-opacity="0.7" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_1884_16361" x="7.42383" y="6.2373" width="5.36841" height="20.5264" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.5"/>
|
||||
<feGaussianBlur stdDeviation="0.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1884_16361"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1884_16361" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_1884_16361" x="13.7422" y="12.9727" width="5.36841" height="13.7891" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.5"/>
|
||||
<feGaussianBlur stdDeviation="0.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1884_16361"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1884_16361" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter2_d_1884_16361" x="20.0547" y="8.76172" width="5.36841" height="18" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.5"/>
|
||||
<feGaussianBlur stdDeviation="0.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1884_16361"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1884_16361" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
53
backend/igny8_core/static/admin/img/logo-full-dark.svg
Normal file
53
backend/igny8_core/static/admin/img/logo-full-dark.svg
Normal file
@@ -0,0 +1,53 @@
|
||||
<svg width="154" height="32" viewBox="0 0 154 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 8.42105C0 3.77023 3.77023 0 8.42105 0H23.5789C28.2298 0 32 3.77023 32 8.42105V23.5789C32 28.2298 28.2298 32 23.5789 32H8.42105C3.77023 32 0 28.2298 0 23.5789V8.42105Z" fill="#465FFF"/>
|
||||
<g filter="url(#filter0_d_1608_324)">
|
||||
<path d="M8.42383 8.42152C8.42383 7.49135 9.17787 6.7373 10.108 6.7373C11.0382 6.7373 11.7922 7.49135 11.7922 8.42152V23.5794C11.7922 24.5096 11.0382 25.2636 10.108 25.2636C9.17787 25.2636 8.42383 24.5096 8.42383 23.5794V8.42152Z" fill="white"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_d_1608_324)">
|
||||
<path d="M14.7422 15.1569C14.7422 14.2267 15.4962 13.4727 16.4264 13.4727C17.3566 13.4727 18.1106 14.2267 18.1106 15.1569V23.5779C18.1106 24.5081 17.3566 25.2621 16.4264 25.2621C15.4962 25.2621 14.7422 24.5081 14.7422 23.5779V15.1569Z" fill="white" fill-opacity="0.9" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_d_1608_324)">
|
||||
<path d="M21.0547 10.9459C21.0547 10.0158 21.8087 9.26172 22.7389 9.26172C23.6691 9.26172 24.4231 10.0158 24.4231 10.9459V23.5775C24.4231 24.5077 23.6691 25.2617 22.7389 25.2617C21.8087 25.2617 21.0547 24.5077 21.0547 23.5775V10.9459Z" fill="white" fill-opacity="0.7" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<path d="M44 10.1149H49.0885V24.6909H52.1321V10.1149H57.2206V7.30912H44V10.1149Z" fill="white"/>
|
||||
<path d="M60.6175 25C62.4484 25 64.0416 24.1678 64.5409 22.9551L64.7549 24.6909H67.2992V17.5575C67.2992 14.2999 65.3494 12.5878 62.1869 12.5878C59.0006 12.5878 56.9081 14.2523 56.9081 16.7966H59.3811C59.3811 15.5601 60.3322 14.8468 62.0442 14.8468C63.5184 14.8468 64.4696 15.4888 64.4696 17.0819V17.3435L60.9504 17.605C58.1684 17.819 56.599 19.1744 56.599 21.3382C56.599 23.5495 58.1208 25 60.6175 25ZM61.5686 22.8124C60.2609 22.8124 59.5475 22.2893 59.5475 21.2193C59.5475 20.2682 60.2371 19.6737 62.0442 19.5073L64.4934 19.317V19.9353C64.4934 21.7424 63.352 22.8124 61.5686 22.8124Z" fill="white"/>
|
||||
<path d="M71.5995 10.5905C72.5506 10.5905 73.3353 9.80581 73.3353 8.83091C73.3353 7.85601 72.5506 7.09511 71.5995 7.09511C70.6008 7.09511 69.8161 7.85601 69.8161 8.83091C69.8161 9.80581 70.6008 10.5905 71.5995 10.5905ZM70.149 24.6909H73.0499V12.9445H70.149V24.6909Z" fill="white"/>
|
||||
<path d="M78.9718 24.6909V7H76.0946V24.6909H78.9718Z" fill="white"/>
|
||||
<path d="M83.9408 24.6909L85.3437 20.6724H91.8352L93.2381 24.6909H96.4481L90.1707 7.30912H87.0558L80.7784 24.6909H83.9408ZM88.2209 12.4927C88.3873 12.0172 88.53 11.4941 88.6013 11.1612C88.6489 11.5178 88.8153 12.041 88.958 12.4927L90.9554 18.1044H86.2473L88.2209 12.4927Z" fill="white"/>
|
||||
<path d="M102.493 25C104.276 25 105.798 24.2153 106.511 22.86L106.701 24.6909H109.364V7H106.487V14.4425C105.75 13.2774 104.3 12.5878 102.659 12.5878C99.1161 12.5878 96.9761 15.2034 96.9761 18.8653C96.9761 22.5033 99.0923 25 102.493 25ZM103.135 22.3369C101.113 22.3369 99.877 20.8626 99.877 18.7701C99.877 16.6777 101.113 15.1797 103.135 15.1797C105.156 15.1797 106.464 16.6539 106.464 18.7701C106.464 20.8864 105.156 22.3369 103.135 22.3369Z" fill="white"/>
|
||||
<path d="M115.289 24.6909V18.033C115.289 16.1308 116.406 15.2272 117.785 15.2272C119.164 15.2272 120.044 16.107 120.044 17.7477V24.6909H122.945V18.033C122.945 16.107 124.015 15.2034 125.418 15.2034C126.797 15.2034 127.701 16.0832 127.701 17.7715V24.6909H130.578V17.0106C130.578 14.2999 129.008 12.5878 126.155 12.5878C124.372 12.5878 122.993 13.4676 122.398 14.823C121.78 13.4676 120.543 12.5878 118.76 12.5878C117.072 12.5878 115.883 13.3487 115.289 14.3236L115.051 12.9445H112.388V24.6909H115.289Z" fill="white"/>
|
||||
<path d="M134.876 10.5905C135.827 10.5905 136.612 9.80581 136.612 8.83091C136.612 7.85601 135.827 7.09511 134.876 7.09511C133.877 7.09511 133.093 7.85601 133.093 8.83091C133.093 9.80581 133.877 10.5905 134.876 10.5905ZM133.426 24.6909H136.327V12.9445H133.426V24.6909Z" fill="white"/>
|
||||
<path d="M142.225 24.6909V18.3659C142.225 16.4637 143.318 15.2272 145.102 15.2272C146.6 15.2272 147.575 16.1783 147.575 18.1519V24.6909H150.476V17.4624C150.476 14.4188 148.954 12.5878 146.005 12.5878C144.412 12.5878 142.985 13.2774 142.248 14.4663L142.011 12.9445H139.324V24.6909H142.225Z" fill="white"/>
|
||||
<defs>
|
||||
<filter id="filter0_d_1608_324" x="7.42383" y="6.2373" width="5.36841" height="20.5264" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.5"/>
|
||||
<feGaussianBlur stdDeviation="0.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1608_324"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1608_324" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_1608_324" x="13.7422" y="12.9727" width="5.36841" height="13.7896" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.5"/>
|
||||
<feGaussianBlur stdDeviation="0.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1608_324"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1608_324" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter2_d_1608_324" x="20.0547" y="8.76172" width="5.36841" height="18" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.5"/>
|
||||
<feGaussianBlur stdDeviation="0.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1608_324"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1608_324" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
44
backend/igny8_core/static/admin/img/logo-light.svg
Normal file
44
backend/igny8_core/static/admin/img/logo-light.svg
Normal file
@@ -0,0 +1,44 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 8.42105C0 3.77023 3.77023 0 8.42105 0H23.5789C28.2298 0 32 3.77023 32 8.42105V23.5789C32 28.2298 28.2298 32 23.5789 32H8.42105C3.77023 32 0 28.2298 0 23.5789V8.42105Z" fill="#465FFF"/>
|
||||
<g filter="url(#filter0_d_1884_16361)">
|
||||
<path d="M8.42383 8.42152C8.42383 7.49135 9.17787 6.7373 10.108 6.7373C11.0382 6.7373 11.7922 7.49135 11.7922 8.42152V23.5794C11.7922 24.5096 11.0382 25.2636 10.108 25.2636C9.17787 25.2636 8.42383 24.5096 8.42383 23.5794V8.42152Z" fill="white"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_d_1884_16361)">
|
||||
<path d="M14.7422 15.1569C14.7422 14.2267 15.4962 13.4727 16.4264 13.4727C17.3566 13.4727 18.1106 14.2267 18.1106 15.1569V23.5779C18.1106 24.5081 17.3566 25.2621 16.4264 25.2621C15.4962 25.2621 14.7422 24.5081 14.7422 23.5779V15.1569Z" fill="white" fill-opacity="0.9" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_d_1884_16361)">
|
||||
<path d="M21.0547 10.9459C21.0547 10.0158 21.8087 9.26172 22.7389 9.26172C23.6691 9.26172 24.4231 10.0158 24.4231 10.9459V23.5775C24.4231 24.5077 23.6691 25.2617 22.7389 25.2617C21.8087 25.2617 21.0547 24.5077 21.0547 23.5775V10.9459Z" fill="white" fill-opacity="0.7" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_1884_16361" x="7.42383" y="6.2373" width="5.36841" height="20.5264" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.5"/>
|
||||
<feGaussianBlur stdDeviation="0.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1884_16361"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1884_16361" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_1884_16361" x="13.7422" y="12.9727" width="5.36841" height="13.7891" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.5"/>
|
||||
<feGaussianBlur stdDeviation="0.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1884_16361"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1884_16361" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter2_d_1884_16361" x="20.0547" y="8.76172" width="5.36841" height="18" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.5"/>
|
||||
<feGaussianBlur stdDeviation="0.5"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1884_16361"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1884_16361" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
BIN
backend/igny8_core/static/admin/img/logo.png
Normal file
BIN
backend/igny8_core/static/admin/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -1,3 +1,7 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}{{ title }} | IGNY8 Admin{% endblock %}
|
||||
|
||||
{% block branding %}
|
||||
{% include "unfold/helpers/site_branding.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block submit_buttons_bottom %}
|
||||
<div class="submit-row">
|
||||
<input type="submit" value="{% trans 'Save' %}" class="default" name="_save">
|
||||
<input type="submit" value="{% trans 'Save and continue editing' %}" name="_continue">
|
||||
|
||||
<a href="{% url 'admin:system_emailsettings_test_email' %}"
|
||||
class="button"
|
||||
style="float: right; background: #3b82f6; color: white; padding: 10px 20px; text-decoration: none; border-radius: 6px; margin-left: 10px;">
|
||||
📧 Send Test Email
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 800px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="margin-top: 0; color: #1f2937;">Send Test Email</h2>
|
||||
|
||||
<div style="background: #f0f9ff; border-radius: 6px; padding: 16px; margin-bottom: 24px; border-left: 4px solid #3b82f6;">
|
||||
<h4 style="margin: 0 0 12px 0; color: #1e40af;">Current Configuration</h4>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
||||
<p style="margin: 0; color: #1e3a5f; font-size: 14px;">
|
||||
<strong>Provider:</strong>
|
||||
<span style="background: {% if settings.email_provider == 'resend' %}#dbeafe{% else %}#fef3c7{% endif %};
|
||||
padding: 2px 8px; border-radius: 4px;">
|
||||
{{ settings.email_provider|upper }}
|
||||
</span>
|
||||
</p>
|
||||
<p style="margin: 0; color: #1e3a5f; font-size: 14px;">
|
||||
<strong>From Email:</strong> {{ settings.from_email }}
|
||||
</p>
|
||||
<p style="margin: 0; color: #1e3a5f; font-size: 14px;">
|
||||
<strong>From Name:</strong> {{ settings.from_name }}
|
||||
</p>
|
||||
<p style="margin: 0; color: #1e3a5f; font-size: 14px;">
|
||||
<strong>Reply-To:</strong> {{ settings.reply_to_email }}
|
||||
</p>
|
||||
</div>
|
||||
{% if settings.email_provider == 'smtp' %}
|
||||
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #bfdbfe;">
|
||||
<p style="margin: 0 0 4px 0; color: #1e3a5f; font-size: 14px;">
|
||||
<strong>SMTP Server:</strong> {{ settings.smtp_host }}:{{ settings.smtp_port }}
|
||||
</p>
|
||||
<p style="margin: 0; color: #1e3a5f; font-size: 14px;">
|
||||
<strong>Encryption:</strong>
|
||||
{% if settings.smtp_use_ssl %}SSL{% elif settings.smtp_use_tls %}TLS{% else %}None{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'admin:system_emailsettings_send_test' %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
|
||||
Send Test Email To:
|
||||
</label>
|
||||
<input type="email" name="to_email" value="{{ default_to_email }}"
|
||||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px;"
|
||||
placeholder="recipient@example.com" required>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
|
||||
Subject:
|
||||
</label>
|
||||
<input type="text" name="subject" value="IGNY8 Test Email"
|
||||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<button type="submit"
|
||||
style="background: #6366f1; color: white; padding: 12px 24px; border: none;
|
||||
border-radius: 6px; font-weight: 500; cursor: pointer; font-size: 14px;">
|
||||
📧 Send Test Email
|
||||
</button>
|
||||
<a href="{% url 'admin:system_emailsettings_changelist' %}"
|
||||
style="background: #e5e7eb; color: #374151; padding: 12px 24px;
|
||||
border-radius: 6px; font-weight: 500; text-decoration: none; font-size: 14px;">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="background: #f0fdf4; border-radius: 8px; padding: 16px; margin-top: 20px; border-left: 4px solid #22c55e;">
|
||||
<h4 style="margin: 0 0 8px 0; color: #166534;">✅ What This Test Does</h4>
|
||||
<ul style="margin: 0; padding-left: 20px; color: #15803d; font-size: 14px;">
|
||||
<li>Sends a test email using your currently selected provider ({{ settings.email_provider|upper }})</li>
|
||||
<li>Verifies that your email configuration is working correctly</li>
|
||||
<li>Logs the email in Email Logs for tracking</li>
|
||||
<li>Shows the provider used and configuration details in the email body</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if settings.email_provider == 'smtp' and not settings.smtp_host %}
|
||||
<div style="background: #fef2f2; border-radius: 8px; padding: 16px; margin-top: 20px; border-left: 4px solid #ef4444;">
|
||||
<h4 style="margin: 0 0 8px 0; color: #991b1b;">⚠️ SMTP Not Configured</h4>
|
||||
<p style="margin: 0; color: #b91c1c; font-size: 14px;">
|
||||
You have selected SMTP as your email provider, but SMTP settings are not configured.
|
||||
Please go back and configure your SMTP server settings, or switch to Resend.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div style="max-width: 800px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="margin-top: 0; color: #1f2937;">Test Email: {{ template.display_name }}</h2>
|
||||
|
||||
<div style="background: #f8fafc; border-radius: 6px; padding: 16px; margin-bottom: 24px;">
|
||||
<p style="margin: 0 0 8px 0; color: #64748b; font-size: 14px;">
|
||||
<strong>Template Path:</strong> {{ template.template_path }}
|
||||
</p>
|
||||
<p style="margin: 0 0 8px 0; color: #64748b; font-size: 14px;">
|
||||
<strong>Type:</strong> {{ template.get_template_type_display }}
|
||||
</p>
|
||||
<p style="margin: 0; color: #64748b; font-size: 14px;">
|
||||
<strong>Description:</strong> {{ template.description|default:"No description" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'admin:system_emailtemplate_send_test' template.pk %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
|
||||
Send Test Email To:
|
||||
</label>
|
||||
<input type="email" name="to_email" value="{{ user.email }}"
|
||||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px;"
|
||||
placeholder="recipient@example.com" required>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
|
||||
Subject (Preview):
|
||||
</label>
|
||||
<input type="text" disabled value="[TEST] {{ template.default_subject }}"
|
||||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; background: #f9fafb; color: #6b7280;">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; font-weight: 500; color: #374151; margin-bottom: 8px;">
|
||||
Context Variables (JSON):
|
||||
</label>
|
||||
<textarea name="context" rows="8"
|
||||
style="width: 100%; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; font-family: monospace;"
|
||||
placeholder='{"user_name": "Test User", "account_name": "Test Account"}'>{{ template.sample_context|default:"{}"|safe }}</textarea>
|
||||
<p style="color: #6b7280; font-size: 12px; margin-top: 4px;">
|
||||
Required variables: {{ template.required_context|default:"None specified"|safe }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<button type="submit"
|
||||
style="background: #6366f1; color: white; padding: 12px 24px; border: none;
|
||||
border-radius: 6px; font-weight: 500; cursor: pointer; font-size: 14px;">
|
||||
📧 Send Test Email
|
||||
</button>
|
||||
<a href="{% url 'admin:system_emailtemplate_changelist' %}"
|
||||
style="background: #e5e7eb; color: #374151; padding: 12px 24px;
|
||||
border-radius: 6px; font-weight: 500; text-decoration: none; font-size: 14px;">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="background: #fef3c7; border-radius: 8px; padding: 16px; margin-top: 20px;">
|
||||
<h4 style="margin: 0 0 8px 0; color: #92400e;">⚠️ Testing Tips</h4>
|
||||
<ul style="margin: 0; padding-left: 20px; color: #78350f; font-size: 14px;">
|
||||
<li>Test emails are prefixed with [TEST] in the subject line</li>
|
||||
<li>Make sure your Resend API key is configured in Integration Providers</li>
|
||||
<li>Use sample_context to pre-fill test data for this template</li>
|
||||
<li>Check Email Logs after sending to verify delivery status</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
178
backend/igny8_core/templates/emails/base.html
Normal file
178
backend/igny8_core/templates/emails/base.html
Normal file
@@ -0,0 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ company_name|default:"IGNY8" }}{% endblock %}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
background-color: #0c1e35;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
.header img {
|
||||
max-height: 40px;
|
||||
width: auto;
|
||||
}
|
||||
.header .logo-text {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.content {
|
||||
background-color: #ffffff;
|
||||
padding: 40px 30px;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px 20px;
|
||||
background-color: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
.footer a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 14px 32px;
|
||||
background-color: #3b82f6;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
.button-secondary {
|
||||
background-color: #64748b;
|
||||
}
|
||||
.button-secondary:hover {
|
||||
background-color: #475569;
|
||||
}
|
||||
.info-box {
|
||||
background-color: #eff6ff;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding: 16px 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
.warning-box {
|
||||
background-color: #fef3c7;
|
||||
border-left: 4px solid #f59e0b;
|
||||
padding: 16px 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
.success-box {
|
||||
background-color: #dcfce7;
|
||||
border-left: 4px solid #22c55e;
|
||||
padding: 16px 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
.error-box {
|
||||
background-color: #fee2e2;
|
||||
border-left: 4px solid #ef4444;
|
||||
padding: 16px 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
color: #0f172a;
|
||||
margin-top: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
a {
|
||||
color: #3b82f6;
|
||||
}
|
||||
.details-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.details-table td {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.details-table td:first-child {
|
||||
color: #64748b;
|
||||
width: 40%;
|
||||
font-size: 14px;
|
||||
}
|
||||
.details-table td:last-child {
|
||||
font-weight: 500;
|
||||
color: #0f172a;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: #e2e8f0;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.text-muted {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.text-small {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<a href="{{ frontend_url }}">
|
||||
{% if logo_url %}
|
||||
<img src="{{ logo_url }}" alt="{{ company_name|default:'IGNY8' }}" />
|
||||
{% else %}
|
||||
<span class="logo-text">{{ company_name|default:"IGNY8" }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<div class="footer">
|
||||
{% if company_address %}
|
||||
<p style="margin-bottom: 12px;">{{ company_address }}</p>
|
||||
{% endif %}
|
||||
<p style="margin-bottom: 8px;">
|
||||
<a href="{{ frontend_url }}/privacy">Privacy Policy</a> |
|
||||
<a href="{{ frontend_url }}/terms">Terms of Service</a>{% if unsubscribe_url %} |
|
||||
<a href="{{ unsubscribe_url }}">Email Preferences</a>{% endif %}
|
||||
</p>
|
||||
<p style="color: #94a3b8; margin-top: 16px;">
|
||||
© {{ current_year|default:"2026" }} {{ company_name|default:"IGNY8" }}. All rights reserved.
|
||||
</p>
|
||||
{% if recipient_email %}<p style="color: #94a3b8; font-size: 11px; margin-top: 8px;">This email was sent to {{ recipient_email }}</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
33
backend/igny8_core/templates/emails/email_verification.html
Normal file
33
backend/igny8_core/templates/emails/email_verification.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Verify Your Email{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Verify Your Email Address</h1>
|
||||
|
||||
<p>Hi {{ user_name }},</p>
|
||||
|
||||
<p>Thanks for signing up! Please verify your email address by clicking the button below:</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ verification_url }}" class="button">Verify Email</a>
|
||||
</p>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>Why verify?</strong>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
|
||||
<li>Secure your account</li>
|
||||
<li>Receive important notifications</li>
|
||||
<li>Access all features</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>If you didn't create an account, you can safely ignore this email.</p>
|
||||
|
||||
<p>If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #6b7280; font-size: 14px;">{{ verification_url }}</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
39
backend/igny8_core/templates/emails/low_credits.html
Normal file
39
backend/igny8_core/templates/emails/low_credits.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Low Credits Warning{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Low Credits Warning</h1>
|
||||
|
||||
<p>Hi {{ account_name }},</p>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>Heads up!</strong> Your credit balance is running low.
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">Credit Status</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Current Balance</td>
|
||||
<td><strong style="color: #dc2626;">{{ current_credits }} credits</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Warning Threshold</td>
|
||||
<td>{{ threshold }} credits</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>To avoid service interruption, we recommend topping up your credits soon.</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ topup_url }}" class="button">Buy More Credits</a>
|
||||
</p>
|
||||
|
||||
<p><strong>Tip:</strong> Consider enabling auto-top-up in your account settings to never run out of credits again!</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
31
backend/igny8_core/templates/emails/password_reset.html
Normal file
31
backend/igny8_core/templates/emails/password_reset.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Reset Your Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Reset Your Password</h1>
|
||||
|
||||
<p>Hi {{ user_name }},</p>
|
||||
|
||||
<p>We received a request to reset your password. Click the button below to create a new password:</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ reset_url }}" class="button">Reset Password</a>
|
||||
</p>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>Important:</strong>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
|
||||
<li>This link expires in 24 hours</li>
|
||||
<li>If you didn't request this, you can safely ignore this email</li>
|
||||
<li>Your password won't change until you create a new one</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>If the button doesn't work, copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #6b7280; font-size: 14px;">{{ reset_url }}</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
47
backend/igny8_core/templates/emails/payment_approved.html
Normal file
47
backend/igny8_core/templates/emails/payment_approved.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Payment Approved{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Payment Approved!</h1>
|
||||
|
||||
<p>Hi {{ account_name }},</p>
|
||||
|
||||
<div class="success-box">
|
||||
<strong>Great news!</strong> Your payment has been approved and your account is now active.
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">Payment Details</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Invoice</td>
|
||||
<td><strong>#{{ invoice_number }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Amount</td>
|
||||
<td><strong>{{ currency }} {{ amount }}</strong></td>
|
||||
</tr>
|
||||
{% if plan_name and plan_name != 'N/A' %}
|
||||
<tr>
|
||||
<td>Plan</td>
|
||||
<td>{{ plan_name }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>Approved</td>
|
||||
<td>{{ approved_at|date:"F j, Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>You can now access all features of your plan. Log in to get started!</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ dashboard_url }}" class="button">Go to Dashboard</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Thank you for choosing {{ company_name|default:"IGNY8" }}!<br>
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Payment Confirmation Received{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Payment Confirmation Received</h1>
|
||||
|
||||
<p>Hi {{ account_name }},</p>
|
||||
|
||||
<p>We have received your payment confirmation and it is now being reviewed by our team.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">Payment Details</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Invoice</td>
|
||||
<td><strong>#{{ invoice_number }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Amount</td>
|
||||
<td><strong>{{ currency }} {{ amount }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Payment Method</td>
|
||||
<td>{{ payment_method }}</td>
|
||||
</tr>
|
||||
{% if manual_reference %}
|
||||
<tr>
|
||||
<td>Reference</td>
|
||||
<td>{{ manual_reference }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>Submitted</td>
|
||||
<td>{{ created_at|date:"F j, Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>We typically process payments within 1-2 business days. You'll receive another email once your payment has been approved and your account is activated.</p>
|
||||
|
||||
<p>
|
||||
Thank you for your patience,<br>
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
39
backend/igny8_core/templates/emails/payment_failed.html
Normal file
39
backend/igny8_core/templates/emails/payment_failed.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Payment Failed{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Payment Failed - Action Required</h1>
|
||||
|
||||
<p>Hi {{ account_name }},</p>
|
||||
|
||||
<div class="warning-box">
|
||||
We were unable to process your payment for the <strong>{{ plan_name }}</strong> plan.
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">Details</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Plan</td>
|
||||
<td>{{ plan_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reason</td>
|
||||
<td style="color: #dc2626;">{{ failure_reason }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>Please update your payment method to continue your subscription and avoid service interruption.</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ billing_url }}" class="button">Update Payment Method</a>
|
||||
</p>
|
||||
|
||||
<p>If you need assistance, please contact our support team{% if support_url %} at <a href="{{ support_url }}">{{ support_url }}</a> or{% endif %} by replying to this email.</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
49
backend/igny8_core/templates/emails/payment_rejected.html
Normal file
49
backend/igny8_core/templates/emails/payment_rejected.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Payment Declined{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Payment Declined</h1>
|
||||
|
||||
<p>Hi {{ account_name }},</p>
|
||||
|
||||
<div class="warning-box">
|
||||
Unfortunately, we were unable to approve your payment for Invoice <strong>#{{ invoice_number }}</strong>.
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">Details</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Invoice</td>
|
||||
<td><strong>#{{ invoice_number }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Amount</td>
|
||||
<td>{{ currency }} {{ amount }}</td>
|
||||
</tr>
|
||||
{% if manual_reference %}
|
||||
<tr>
|
||||
<td>Reference</td>
|
||||
<td>{{ manual_reference }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>Reason</td>
|
||||
<td style="color: #dc2626;">{{ reason }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>You can retry your payment by logging into your account:</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ billing_url }}" class="button">Retry Payment</a>
|
||||
</p>
|
||||
|
||||
<p>If you believe this is an error or have questions, please contact our support team{% if support_url %} at <a href="{{ support_url }}">{{ support_url }}</a> or{% endif %} by replying to this email.</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
49
backend/igny8_core/templates/emails/refund_notification.html
Normal file
49
backend/igny8_core/templates/emails/refund_notification.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Refund Processed{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Refund Processed</h1>
|
||||
|
||||
<p>Hi {{ user_name }},</p>
|
||||
|
||||
<div class="success-box">
|
||||
Your refund has been processed successfully.
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">Refund Details</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Invoice</td>
|
||||
<td><strong>#{{ invoice_number }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Original Amount</td>
|
||||
<td>{{ currency }} {{ original_amount }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Refund Amount</td>
|
||||
<td><strong>{{ currency }} {{ refund_amount }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reason</td>
|
||||
<td>{{ reason }}</td>
|
||||
</tr>
|
||||
{% if refunded_at %}
|
||||
<tr>
|
||||
<td>Processed</td>
|
||||
<td>{{ refunded_at|date:"F j, Y H:i" }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>The refund will appear in your original payment method within 5-10 business days, depending on your bank or card issuer.</p>
|
||||
|
||||
<p>If you have any questions about this refund, please contact our support team{% if support_url %} at <a href="{{ support_url }}">{{ support_url }}</a> or{% endif %} by replying to this email.</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "emails/base.html" %}
|
||||
{% block title %}Subscription Activated{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Your Subscription is Active!</h1>
|
||||
|
||||
<p>Hi {{ account_name }},</p>
|
||||
|
||||
<div class="success-box">
|
||||
Your <strong>{{ plan_name }}</strong> subscription is now active!
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3 style="margin-top: 0;">What's Included</h3>
|
||||
<table class="details-table">
|
||||
<tr>
|
||||
<td>Plan</td>
|
||||
<td><strong>{{ plan_name }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Credits</td>
|
||||
<td><strong>{{ included_credits }}</strong> credits added to your account</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Active Until</td>
|
||||
<td>{{ period_end|date:"F j, Y" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p>You now have full access to all features included in your plan. Start exploring what {{ company_name|default:"IGNY8" }} can do for you!</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ dashboard_url }}" class="button">Go to Dashboard</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Thank you for choosing {{ company_name|default:"IGNY8" }}!<br>
|
||||
The {{ company_name|default:"IGNY8" }} Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user