master - part 2
This commit is contained in:
@@ -887,9 +887,36 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
stages.append(stage_data)
|
stages.append(stage_data)
|
||||||
|
|
||||||
# Calculate global progress
|
# Calculate global progress
|
||||||
|
# Stages 1-6 are automation stages, Stage 7 is manual review (not counted)
|
||||||
|
# Progress = weighted average of stages 1-6 completion
|
||||||
global_percentage = 0
|
global_percentage = 0
|
||||||
if total_initial > 0:
|
if run.status == 'completed':
|
||||||
global_percentage = round((total_processed / total_initial) * 100)
|
# If run is completed (after Stage 6), show 100%
|
||||||
|
global_percentage = 100
|
||||||
|
elif run.status in ('cancelled', 'failed'):
|
||||||
|
# Keep current progress for cancelled/failed
|
||||||
|
if total_initial > 0:
|
||||||
|
global_percentage = round((total_processed / total_initial) * 100)
|
||||||
|
else:
|
||||||
|
# Calculate based on completed stages (1-6 only)
|
||||||
|
# Each of the 6 automation stages contributes ~16.67% to total
|
||||||
|
completed_stages = min(max(run.current_stage - 1, 0), 6)
|
||||||
|
stage_weight = 100 / 6 # Each stage is ~16.67%
|
||||||
|
|
||||||
|
# Base progress from completed stages
|
||||||
|
base_progress = completed_stages * stage_weight
|
||||||
|
|
||||||
|
# Add partial progress from current stage
|
||||||
|
current_stage_progress = 0
|
||||||
|
if run.current_stage <= 6:
|
||||||
|
current_result = getattr(run, f'stage_{run.current_stage}_result', None)
|
||||||
|
current_initial = initial_snapshot.get(f'stage_{run.current_stage}_initial', 0)
|
||||||
|
if current_initial > 0 and current_result:
|
||||||
|
processed_key = processed_keys.get(run.current_stage, '')
|
||||||
|
current_processed = current_result.get(processed_key, 0)
|
||||||
|
current_stage_progress = (current_processed / current_initial) * stage_weight
|
||||||
|
|
||||||
|
global_percentage = round(base_progress + current_stage_progress)
|
||||||
|
|
||||||
# Calculate duration
|
# Calculate duration
|
||||||
duration_seconds = 0
|
duration_seconds = 0
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Migration: Simplify payment methods to global (remove country-specific filtering)
|
||||||
|
|
||||||
|
This migration:
|
||||||
|
1. Updates existing PaymentMethodConfig records to use country_code='*' (global)
|
||||||
|
2. Removes duplicate payment methods per country, keeping only one global config per method
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_to_global_payment_methods(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Convert country-specific payment methods to global.
|
||||||
|
For each payment_method type, keep only one configuration with country_code='*'
|
||||||
|
"""
|
||||||
|
PaymentMethodConfig = apps.get_model('billing', 'PaymentMethodConfig')
|
||||||
|
|
||||||
|
# Get all unique payment methods
|
||||||
|
payment_methods = PaymentMethodConfig.objects.values_list('payment_method', flat=True).distinct()
|
||||||
|
|
||||||
|
for method in payment_methods:
|
||||||
|
# Get all configs for this payment method
|
||||||
|
configs = PaymentMethodConfig.objects.filter(payment_method=method).order_by('sort_order', 'id')
|
||||||
|
|
||||||
|
if configs.exists():
|
||||||
|
# Keep the first one and make it global
|
||||||
|
first_config = configs.first()
|
||||||
|
first_config.country_code = '*'
|
||||||
|
first_config.save(update_fields=['country_code'])
|
||||||
|
|
||||||
|
# Delete duplicates (other country-specific versions)
|
||||||
|
configs.exclude(id=first_config.id).delete()
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_migration(apps, schema_editor):
|
||||||
|
"""Reverse is a no-op - can't restore original country codes"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0007_simplify_payment_statuses'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(migrate_to_global_payment_methods, reverse_migration),
|
||||||
|
]
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
Migration: Seed AIModelConfig from constants.py
|
||||||
|
|
||||||
|
This migration populates the AIModelConfig table with the current models
|
||||||
|
from ai/constants.py, enabling database-driven model configuration.
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def seed_ai_models(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Seed AIModelConfig with models from constants.py
|
||||||
|
"""
|
||||||
|
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
|
||||||
|
|
||||||
|
# Text Models (from MODEL_RATES)
|
||||||
|
text_models = [
|
||||||
|
{
|
||||||
|
'model_name': 'gpt-4.1',
|
||||||
|
'display_name': 'GPT-4.1 - Balanced Performance',
|
||||||
|
'model_type': 'text',
|
||||||
|
'provider': 'openai',
|
||||||
|
'input_cost_per_1m': Decimal('2.00'),
|
||||||
|
'output_cost_per_1m': Decimal('8.00'),
|
||||||
|
'context_window': 128000,
|
||||||
|
'max_output_tokens': 16384,
|
||||||
|
'supports_json_mode': True,
|
||||||
|
'supports_vision': True,
|
||||||
|
'supports_function_calling': True,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': True, # Default text model
|
||||||
|
'sort_order': 1,
|
||||||
|
'description': 'Default model - good balance of cost and capability',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'model_name': 'gpt-4o-mini',
|
||||||
|
'display_name': 'GPT-4o Mini - Fast & Affordable',
|
||||||
|
'model_type': 'text',
|
||||||
|
'provider': 'openai',
|
||||||
|
'input_cost_per_1m': Decimal('0.15'),
|
||||||
|
'output_cost_per_1m': Decimal('0.60'),
|
||||||
|
'context_window': 128000,
|
||||||
|
'max_output_tokens': 16384,
|
||||||
|
'supports_json_mode': True,
|
||||||
|
'supports_vision': True,
|
||||||
|
'supports_function_calling': True,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
'sort_order': 2,
|
||||||
|
'description': 'Best for high-volume tasks where cost matters',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'model_name': 'gpt-4o',
|
||||||
|
'display_name': 'GPT-4o - High Quality',
|
||||||
|
'model_type': 'text',
|
||||||
|
'provider': 'openai',
|
||||||
|
'input_cost_per_1m': Decimal('2.50'),
|
||||||
|
'output_cost_per_1m': Decimal('10.00'),
|
||||||
|
'context_window': 128000,
|
||||||
|
'max_output_tokens': 16384,
|
||||||
|
'supports_json_mode': True,
|
||||||
|
'supports_vision': True,
|
||||||
|
'supports_function_calling': True,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
'sort_order': 3,
|
||||||
|
'description': 'Premium model for complex tasks requiring best quality',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'model_name': 'gpt-5.1',
|
||||||
|
'display_name': 'GPT-5.1 - Latest Generation',
|
||||||
|
'model_type': 'text',
|
||||||
|
'provider': 'openai',
|
||||||
|
'input_cost_per_1m': Decimal('1.25'),
|
||||||
|
'output_cost_per_1m': Decimal('10.00'),
|
||||||
|
'context_window': 200000,
|
||||||
|
'max_output_tokens': 32768,
|
||||||
|
'supports_json_mode': True,
|
||||||
|
'supports_vision': True,
|
||||||
|
'supports_function_calling': True,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
'sort_order': 4,
|
||||||
|
'description': 'Next-gen model with improved reasoning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'model_name': 'gpt-5.2',
|
||||||
|
'display_name': 'GPT-5.2 - Most Advanced',
|
||||||
|
'model_type': 'text',
|
||||||
|
'provider': 'openai',
|
||||||
|
'input_cost_per_1m': Decimal('1.75'),
|
||||||
|
'output_cost_per_1m': Decimal('14.00'),
|
||||||
|
'context_window': 200000,
|
||||||
|
'max_output_tokens': 65536,
|
||||||
|
'supports_json_mode': True,
|
||||||
|
'supports_vision': True,
|
||||||
|
'supports_function_calling': True,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
'sort_order': 5,
|
||||||
|
'description': 'Most capable model for enterprise-grade tasks',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Image Models (from IMAGE_MODEL_RATES)
|
||||||
|
image_models = [
|
||||||
|
{
|
||||||
|
'model_name': 'dall-e-3',
|
||||||
|
'display_name': 'DALL-E 3 - Premium Images',
|
||||||
|
'model_type': 'image',
|
||||||
|
'provider': 'openai',
|
||||||
|
'cost_per_image': Decimal('0.040'),
|
||||||
|
'valid_sizes': ['1024x1024', '1024x1792', '1792x1024'],
|
||||||
|
'supports_json_mode': False,
|
||||||
|
'supports_vision': False,
|
||||||
|
'supports_function_calling': False,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': True, # Default image model
|
||||||
|
'sort_order': 1,
|
||||||
|
'description': 'Best quality image generation, good for hero images and marketing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'model_name': 'dall-e-2',
|
||||||
|
'display_name': 'DALL-E 2 - Standard Images',
|
||||||
|
'model_type': 'image',
|
||||||
|
'provider': 'openai',
|
||||||
|
'cost_per_image': Decimal('0.020'),
|
||||||
|
'valid_sizes': ['256x256', '512x512', '1024x1024'],
|
||||||
|
'supports_json_mode': False,
|
||||||
|
'supports_vision': False,
|
||||||
|
'supports_function_calling': False,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
'sort_order': 2,
|
||||||
|
'description': 'Lower cost option for bulk image generation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'model_name': 'gpt-image-1',
|
||||||
|
'display_name': 'GPT Image 1 - Advanced',
|
||||||
|
'model_type': 'image',
|
||||||
|
'provider': 'openai',
|
||||||
|
'cost_per_image': Decimal('0.042'),
|
||||||
|
'valid_sizes': ['1024x1024', '1024x1792', '1792x1024'],
|
||||||
|
'supports_json_mode': False,
|
||||||
|
'supports_vision': False,
|
||||||
|
'supports_function_calling': False,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
'sort_order': 3,
|
||||||
|
'description': 'Advanced image model with enhanced capabilities',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'model_name': 'gpt-image-1-mini',
|
||||||
|
'display_name': 'GPT Image 1 Mini - Fast',
|
||||||
|
'model_type': 'image',
|
||||||
|
'provider': 'openai',
|
||||||
|
'cost_per_image': Decimal('0.011'),
|
||||||
|
'valid_sizes': ['1024x1024'],
|
||||||
|
'supports_json_mode': False,
|
||||||
|
'supports_vision': False,
|
||||||
|
'supports_function_calling': False,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
'sort_order': 4,
|
||||||
|
'description': 'Fastest and most affordable image model',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Runware Image Models (from existing integration)
|
||||||
|
runware_models = [
|
||||||
|
{
|
||||||
|
'model_name': 'runware:100@1',
|
||||||
|
'display_name': 'Runware Standard',
|
||||||
|
'model_type': 'image',
|
||||||
|
'provider': 'runware',
|
||||||
|
'cost_per_image': Decimal('0.008'),
|
||||||
|
'valid_sizes': ['512x512', '768x768', '1024x1024'],
|
||||||
|
'supports_json_mode': False,
|
||||||
|
'supports_vision': False,
|
||||||
|
'supports_function_calling': False,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
'sort_order': 10,
|
||||||
|
'description': 'Runware image generation - most affordable',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create all models
|
||||||
|
all_models = text_models + image_models + runware_models
|
||||||
|
|
||||||
|
for model_data in all_models:
|
||||||
|
AIModelConfig.objects.update_or_create(
|
||||||
|
model_name=model_data['model_name'],
|
||||||
|
defaults=model_data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_migration(apps, schema_editor):
|
||||||
|
"""Remove seeded models"""
|
||||||
|
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
|
||||||
|
seeded_models = [
|
||||||
|
'gpt-4.1', 'gpt-4o-mini', 'gpt-4o', 'gpt-5.1', 'gpt-5.2',
|
||||||
|
'dall-e-3', 'dall-e-2', 'gpt-image-1', 'gpt-image-1-mini',
|
||||||
|
'runware:100@1'
|
||||||
|
]
|
||||||
|
AIModelConfig.objects.filter(model_name__in=seeded_models).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0008_global_payment_methods'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_ai_models, reverse_migration),
|
||||||
|
]
|
||||||
@@ -197,17 +197,18 @@ class BillingViewSet(viewsets.GenericViewSet):
|
|||||||
Does not expose sensitive configuration details.
|
Does not expose sensitive configuration details.
|
||||||
|
|
||||||
Query params:
|
Query params:
|
||||||
country: ISO 2-letter country code (default: 'US')
|
country: ISO 2-letter country code (optional, defaults to global '*')
|
||||||
|
|
||||||
Returns payment methods filtered by country.
|
Returns payment methods - prioritizes global methods (country_code='*').
|
||||||
"""
|
"""
|
||||||
country = request.GET.get('country', 'US').upper()
|
country = request.GET.get('country', '*').upper()
|
||||||
|
|
||||||
# Get country-specific methods
|
# Get global methods first (country_code='*'), then country-specific as fallback
|
||||||
methods = PaymentMethodConfig.objects.filter(
|
methods = PaymentMethodConfig.objects.filter(
|
||||||
country_code=country,
|
|
||||||
is_enabled=True
|
is_enabled=True
|
||||||
).order_by('sort_order')
|
).filter(
|
||||||
|
Q(country_code='*') | Q(country_code=country)
|
||||||
|
).order_by('sort_order').distinct()
|
||||||
|
|
||||||
# Serialize using the proper serializer
|
# Serialize using the proper serializer
|
||||||
serializer = PaymentMethodConfigSerializer(methods, many=True)
|
serializer = PaymentMethodConfigSerializer(methods, many=True)
|
||||||
|
|||||||
@@ -96,17 +96,15 @@ class ClustersAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
|
|||||||
@admin.register(Keywords)
|
@admin.register(Keywords)
|
||||||
class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
|
class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
|
||||||
resource_class = KeywordsResource
|
resource_class = KeywordsResource
|
||||||
list_display = ['keyword', 'seed_keyword', 'site', 'sector', 'cluster', 'volume', 'difficulty', 'country', 'status', 'created_at']
|
# Use actual DB fields and custom methods with @admin.display for computed values
|
||||||
|
list_display = ['get_keyword', 'seed_keyword', 'site', 'sector', 'cluster', 'get_volume', 'get_difficulty', 'get_country', 'status', 'created_at']
|
||||||
list_editable = ['status'] # Enable inline editing for status
|
list_editable = ['status'] # Enable inline editing for status
|
||||||
list_select_related = ['site', 'sector', 'cluster', 'seed_keyword', 'seed_keyword__industry', 'seed_keyword__sector', 'account']
|
list_select_related = ['site', 'sector', 'cluster', 'seed_keyword', 'seed_keyword__industry', 'seed_keyword__sector', 'account']
|
||||||
list_filter = [
|
list_filter = [
|
||||||
('status', ChoicesDropdownFilter),
|
('status', ChoicesDropdownFilter),
|
||||||
('country', ChoicesDropdownFilter),
|
|
||||||
('site', RelatedDropdownFilter),
|
('site', RelatedDropdownFilter),
|
||||||
('sector', RelatedDropdownFilter),
|
('sector', RelatedDropdownFilter),
|
||||||
('cluster', RelatedDropdownFilter),
|
('cluster', RelatedDropdownFilter),
|
||||||
('volume', RangeNumericFilter),
|
|
||||||
('difficulty', RangeNumericFilter),
|
|
||||||
('created_at', RangeDateFilter),
|
('created_at', RangeDateFilter),
|
||||||
]
|
]
|
||||||
search_fields = ['seed_keyword__keyword']
|
search_fields = ['seed_keyword__keyword']
|
||||||
@@ -119,6 +117,30 @@ class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
|
|||||||
'bulk_soft_delete',
|
'bulk_soft_delete',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@admin.display(description='Keyword')
|
||||||
|
def get_keyword(self, obj):
|
||||||
|
"""Get keyword from seed_keyword"""
|
||||||
|
return obj.seed_keyword.keyword if obj.seed_keyword else '-'
|
||||||
|
|
||||||
|
@admin.display(description='Volume')
|
||||||
|
def get_volume(self, obj):
|
||||||
|
"""Get volume from override or seed_keyword"""
|
||||||
|
if obj.volume_override is not None:
|
||||||
|
return obj.volume_override
|
||||||
|
return obj.seed_keyword.volume if obj.seed_keyword else 0
|
||||||
|
|
||||||
|
@admin.display(description='Difficulty')
|
||||||
|
def get_difficulty(self, obj):
|
||||||
|
"""Get difficulty from override or seed_keyword"""
|
||||||
|
if obj.difficulty_override is not None:
|
||||||
|
return obj.difficulty_override
|
||||||
|
return obj.seed_keyword.difficulty if obj.seed_keyword else 0
|
||||||
|
|
||||||
|
@admin.display(description='Country')
|
||||||
|
def get_country(self, obj):
|
||||||
|
"""Get country from seed_keyword"""
|
||||||
|
return obj.seed_keyword.country if obj.seed_keyword else 'US'
|
||||||
|
|
||||||
def get_site_display(self, obj):
|
def get_site_display(self, obj):
|
||||||
"""Safely get site name"""
|
"""Safely get site name"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -934,6 +934,45 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
|||||||
# Save with all required fields explicitly
|
# Save with all required fields explicitly
|
||||||
serializer.save(account=account, site=site, sector=sector)
|
serializer.save(account=account, site=site, sector=sector)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='summary', url_name='summary')
|
||||||
|
def summary(self, request):
|
||||||
|
"""
|
||||||
|
Get aggregate summary metrics for clusters.
|
||||||
|
Returns total keywords count and total volume across all clusters (unfiltered).
|
||||||
|
Used for header metrics display.
|
||||||
|
"""
|
||||||
|
from django.db.models import Sum, Count, Case, When, F, IntegerField
|
||||||
|
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
|
||||||
|
# Get cluster IDs
|
||||||
|
cluster_ids = list(queryset.values_list('id', flat=True))
|
||||||
|
|
||||||
|
# Aggregate keyword stats across all clusters
|
||||||
|
keyword_stats = (
|
||||||
|
Keywords.objects
|
||||||
|
.filter(cluster_id__in=cluster_ids)
|
||||||
|
.aggregate(
|
||||||
|
total_keywords=Count('id'),
|
||||||
|
total_volume=Sum(
|
||||||
|
Case(
|
||||||
|
When(volume_override__isnull=False, then=F('volume_override')),
|
||||||
|
default=F('seed_keyword__volume'),
|
||||||
|
output_field=IntegerField()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data={
|
||||||
|
'total_clusters': len(cluster_ids),
|
||||||
|
'total_keywords': keyword_stats['total_keywords'] or 0,
|
||||||
|
'total_volume': keyword_stats['total_volume'] or 0,
|
||||||
|
},
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete')
|
@action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete')
|
||||||
def bulk_delete(self, request):
|
def bulk_delete(self, request):
|
||||||
"""Bulk delete clusters"""
|
"""Bulk delete clusters"""
|
||||||
|
|||||||
@@ -371,16 +371,16 @@ class GlobalModuleSettings(models.Model):
|
|||||||
help_text="Enable Automation module platform-wide"
|
help_text="Enable Automation module platform-wide"
|
||||||
)
|
)
|
||||||
site_builder_enabled = models.BooleanField(
|
site_builder_enabled = models.BooleanField(
|
||||||
default=True,
|
default=False, # DEPRECATED: SiteBuilder module is disabled - Phase 2 feature
|
||||||
help_text="Enable Site Builder module platform-wide"
|
help_text="Enable Site Builder module platform-wide (DEPRECATED)"
|
||||||
)
|
)
|
||||||
linker_enabled = models.BooleanField(
|
linker_enabled = models.BooleanField(
|
||||||
default=True,
|
default=False, # Phase 2 feature - not active
|
||||||
help_text="Enable Linker module platform-wide"
|
help_text="Enable Linker module platform-wide (Phase 2)"
|
||||||
)
|
)
|
||||||
optimizer_enabled = models.BooleanField(
|
optimizer_enabled = models.BooleanField(
|
||||||
default=True,
|
default=False, # Phase 2 feature - not active
|
||||||
help_text="Enable Optimizer module platform-wide"
|
help_text="Enable Optimizer module platform-wide (Phase 2)"
|
||||||
)
|
)
|
||||||
publisher_enabled = models.BooleanField(
|
publisher_enabled = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Migration: Disable SiteBuilder, Linker, Optimizer by default
|
||||||
|
|
||||||
|
These modules are deprecated (SiteBuilder) or Phase 2 features (Linker, Optimizer).
|
||||||
|
This migration updates defaults and sets existing records to disabled.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def disable_phase2_modules(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Disable SiteBuilder, Linker, and Optimizer in existing settings records.
|
||||||
|
These are Phase 2 features not currently active.
|
||||||
|
"""
|
||||||
|
GlobalModuleSettings = apps.get_model('system', 'GlobalModuleSettings')
|
||||||
|
ModuleEnableSettings = apps.get_model('system', 'ModuleEnableSettings')
|
||||||
|
|
||||||
|
# Update GlobalModuleSettings (singleton pk=1)
|
||||||
|
GlobalModuleSettings.objects.filter(pk=1).update(
|
||||||
|
site_builder_enabled=False,
|
||||||
|
linker_enabled=False,
|
||||||
|
optimizer_enabled=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update all ModuleEnableSettings (per-account settings)
|
||||||
|
ModuleEnableSettings.objects.all().update(
|
||||||
|
site_builder_enabled=False,
|
||||||
|
linker_enabled=False,
|
||||||
|
optimizer_enabled=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_migration(apps, schema_editor):
|
||||||
|
"""Re-enable modules (reverting to old defaults)"""
|
||||||
|
GlobalModuleSettings = apps.get_model('system', 'GlobalModuleSettings')
|
||||||
|
ModuleEnableSettings = apps.get_model('system', 'ModuleEnableSettings')
|
||||||
|
|
||||||
|
GlobalModuleSettings.objects.filter(pk=1).update(
|
||||||
|
site_builder_enabled=True,
|
||||||
|
linker_enabled=True,
|
||||||
|
optimizer_enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
ModuleEnableSettings.objects.all().update(
|
||||||
|
site_builder_enabled=True,
|
||||||
|
linker_enabled=True,
|
||||||
|
optimizer_enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('system', '0010_globalmodulesettings_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(disable_phase2_modules, reverse_migration),
|
||||||
|
]
|
||||||
@@ -98,9 +98,9 @@ class ModuleEnableSettings(AccountBaseModel):
|
|||||||
writer_enabled = models.BooleanField(default=True, help_text="Enable Writer module")
|
writer_enabled = models.BooleanField(default=True, help_text="Enable Writer module")
|
||||||
thinker_enabled = models.BooleanField(default=True, help_text="Enable Thinker module")
|
thinker_enabled = models.BooleanField(default=True, help_text="Enable Thinker module")
|
||||||
automation_enabled = models.BooleanField(default=True, help_text="Enable Automation module")
|
automation_enabled = models.BooleanField(default=True, help_text="Enable Automation module")
|
||||||
site_builder_enabled = models.BooleanField(default=True, help_text="Enable Site Builder module")
|
site_builder_enabled = models.BooleanField(default=False, help_text="Enable Site Builder module (DEPRECATED)")
|
||||||
linker_enabled = models.BooleanField(default=True, help_text="Enable Linker module")
|
linker_enabled = models.BooleanField(default=False, help_text="Enable Linker module (Phase 2)")
|
||||||
optimizer_enabled = models.BooleanField(default=True, help_text="Enable Optimizer module")
|
optimizer_enabled = models.BooleanField(default=False, help_text="Enable Optimizer module (Phase 2)")
|
||||||
publisher_enabled = models.BooleanField(default=True, help_text="Enable Publisher module")
|
publisher_enabled = models.BooleanField(default=True, help_text="Enable Publisher module")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -189,6 +189,12 @@
|
|||||||
/* Menu icon sizing - consistent across sidebar */
|
/* Menu icon sizing - consistent across sidebar */
|
||||||
@utility menu-item-icon-size {
|
@utility menu-item-icon-size {
|
||||||
@apply w-5 h-5 flex-shrink-0;
|
@apply w-5 h-5 flex-shrink-0;
|
||||||
|
|
||||||
|
/* Force SVG icons to inherit parent size */
|
||||||
|
& svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility menu-item-icon-active {
|
@utility menu-item-icon-active {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|||||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
import {
|
import {
|
||||||
fetchClusters,
|
fetchClusters,
|
||||||
|
fetchClustersSummary,
|
||||||
fetchImages,
|
fetchImages,
|
||||||
createCluster,
|
createCluster,
|
||||||
updateCluster,
|
updateCluster,
|
||||||
@@ -43,6 +44,8 @@ export default function Clusters() {
|
|||||||
const [totalWithIdeas, setTotalWithIdeas] = useState(0);
|
const [totalWithIdeas, setTotalWithIdeas] = useState(0);
|
||||||
const [totalReady, setTotalReady] = useState(0);
|
const [totalReady, setTotalReady] = useState(0);
|
||||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||||
|
const [totalVolume, setTotalVolume] = useState(0);
|
||||||
|
const [totalKeywords, setTotalKeywords] = useState(0);
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -84,24 +87,31 @@ export default function Clusters() {
|
|||||||
// Load total metrics for footer widget (not affected by pagination)
|
// Load total metrics for footer widget (not affected by pagination)
|
||||||
const loadTotalMetrics = useCallback(async () => {
|
const loadTotalMetrics = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
// Get clusters with status='mapped' (those that have ideas)
|
// Fetch summary metrics in parallel with status counts
|
||||||
const mappedRes = await fetchClusters({
|
const [summaryRes, mappedRes, newRes, imagesRes] = await Promise.all([
|
||||||
page_size: 1,
|
fetchClustersSummary(activeSector?.id),
|
||||||
...(activeSector?.id && { sector_id: activeSector.id }),
|
fetchClusters({
|
||||||
status: 'mapped',
|
page_size: 1,
|
||||||
});
|
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||||
|
status: 'mapped',
|
||||||
|
}),
|
||||||
|
fetchClusters({
|
||||||
|
page_size: 1,
|
||||||
|
...(activeSector?.id && { sector_id: activeSector.id }),
|
||||||
|
status: 'new',
|
||||||
|
}),
|
||||||
|
fetchImages({ page_size: 1 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Set summary metrics
|
||||||
|
setTotalVolume(summaryRes.total_volume || 0);
|
||||||
|
setTotalKeywords(summaryRes.total_keywords || 0);
|
||||||
|
|
||||||
|
// Set status counts
|
||||||
setTotalWithIdeas(mappedRes.count || 0);
|
setTotalWithIdeas(mappedRes.count || 0);
|
||||||
|
|
||||||
// Get clusters with status='new' (those that are ready for ideas)
|
|
||||||
const newRes = await fetchClusters({
|
|
||||||
page_size: 1,
|
|
||||||
...(activeSector?.id && { sector_id: activeSector.id }),
|
|
||||||
status: 'new',
|
|
||||||
});
|
|
||||||
setTotalReady(newRes.count || 0);
|
setTotalReady(newRes.count || 0);
|
||||||
|
|
||||||
// Get actual total images count
|
// Set images count
|
||||||
const imagesRes = await fetchImages({ page_size: 1 });
|
|
||||||
setTotalImagesCount(imagesRes.count || 0);
|
setTotalImagesCount(imagesRes.count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading total metrics:', error);
|
console.error('Error loading total metrics:', error);
|
||||||
@@ -403,12 +413,12 @@ export default function Clusters() {
|
|||||||
value = totalReady;
|
value = totalReady;
|
||||||
break;
|
break;
|
||||||
case 'Keywords':
|
case 'Keywords':
|
||||||
// Sum of keywords across all clusters on current page (this is acceptable for display)
|
// Use totalKeywords from summary endpoint (aggregate across all clusters)
|
||||||
value = clusters.reduce((sum: number, c) => sum + (c.keywords_count || 0), 0);
|
value = totalKeywords;
|
||||||
break;
|
break;
|
||||||
case 'Volume':
|
case 'Volume':
|
||||||
// Sum of volume across all clusters on current page (this is acceptable for display)
|
// Use totalVolume from summary endpoint (aggregate across all clusters)
|
||||||
value = clusters.reduce((sum: number, c) => sum + (c.total_volume || 0), 0);
|
value = totalVolume;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
value = metric.calculate({ clusters, totalCount });
|
value = metric.calculate({ clusters, totalCount });
|
||||||
@@ -421,7 +431,7 @@ export default function Clusters() {
|
|||||||
tooltip: (metric as any).tooltip,
|
tooltip: (metric as any).tooltip,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [pageConfig?.headerMetrics, clusters, totalCount, totalReady, totalWithIdeas]);
|
}, [pageConfig?.headerMetrics, clusters, totalCount, totalReady, totalWithIdeas, totalVolume, totalKeywords]);
|
||||||
|
|
||||||
const resetForm = useCallback(() => {
|
const resetForm = useCallback(() => {
|
||||||
setFormData({
|
setFormData({
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export default function Review() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||||
|
|
||||||
|
// Total metrics for footer widget (not page-filtered)
|
||||||
|
const [totalDrafts, setTotalDrafts] = useState(0);
|
||||||
|
const [totalApproved, setTotalApproved] = useState(0);
|
||||||
|
const [totalTasks, setTotalTasks] = useState(0);
|
||||||
|
|
||||||
// Filter state - default to review status
|
// Filter state - default to review status
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('review'); // Default to review
|
const [statusFilter, setStatusFilter] = useState('review'); // Default to review
|
||||||
@@ -85,19 +90,30 @@ export default function Review() {
|
|||||||
loadContent();
|
loadContent();
|
||||||
}, [loadContent]);
|
}, [loadContent]);
|
||||||
|
|
||||||
// Load total images count
|
// Load total images count and other metrics for footer widget
|
||||||
useEffect(() => {
|
const loadTotalMetrics = useCallback(async () => {
|
||||||
const loadImageCount = async () => {
|
try {
|
||||||
try {
|
// Fetch counts in parallel for performance
|
||||||
const imagesRes = await fetchImages({ page_size: 1 });
|
const [imagesRes, draftsRes, approvedRes, tasksRes] = await Promise.all([
|
||||||
setTotalImagesCount(imagesRes.count || 0);
|
fetchImages({ page_size: 1 }),
|
||||||
} catch (error) {
|
fetchContent({ page_size: 1, status: 'draft' }),
|
||||||
console.error('Error loading image count:', error);
|
fetchContent({ page_size: 1, status: 'approved' }),
|
||||||
}
|
fetchAPI<{ count: number }>('/writer/tasks/?page_size=1'),
|
||||||
};
|
]);
|
||||||
loadImageCount();
|
|
||||||
|
setTotalImagesCount(imagesRes.count || 0);
|
||||||
|
setTotalDrafts(draftsRes.count || 0);
|
||||||
|
setTotalApproved(approvedRes.count || 0);
|
||||||
|
setTotalTasks(tasksRes.count || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading metrics:', error);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTotalMetrics();
|
||||||
|
}, [loadTotalMetrics]);
|
||||||
|
|
||||||
// Listen for site and sector changes and refresh data
|
// Listen for site and sector changes and refresh data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSiteChange = () => {
|
const handleSiteChange = () => {
|
||||||
@@ -494,24 +510,24 @@ export default function Review() {
|
|||||||
pipeline: [
|
pipeline: [
|
||||||
{
|
{
|
||||||
fromLabel: 'Tasks',
|
fromLabel: 'Tasks',
|
||||||
fromValue: 0,
|
fromValue: totalTasks,
|
||||||
fromHref: '/writer/tasks',
|
fromHref: '/writer/tasks',
|
||||||
actionLabel: 'Generate Content',
|
actionLabel: 'Generate Content',
|
||||||
toLabel: 'Drafts',
|
toLabel: 'Drafts',
|
||||||
toValue: 0,
|
toValue: totalDrafts,
|
||||||
toHref: '/writer/content',
|
toHref: '/writer/content',
|
||||||
progress: 0,
|
progress: totalTasks > 0 ? Math.round((totalDrafts / totalTasks) * 100) : 0,
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fromLabel: 'Drafts',
|
fromLabel: 'Drafts',
|
||||||
fromValue: 0,
|
fromValue: totalDrafts,
|
||||||
fromHref: '/writer/content',
|
fromHref: '/writer/content',
|
||||||
actionLabel: 'Generate Images',
|
actionLabel: 'Generate Images',
|
||||||
toLabel: 'Images',
|
toLabel: 'Images',
|
||||||
toValue: totalImagesCount,
|
toValue: totalImagesCount,
|
||||||
toHref: '/writer/images',
|
toHref: '/writer/images',
|
||||||
progress: 0,
|
progress: totalDrafts > 0 ? Math.round((totalImagesCount / totalDrafts) * 100) : 0,
|
||||||
color: 'purple',
|
color: 'purple',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -520,9 +536,9 @@ export default function Review() {
|
|||||||
fromHref: '/writer/review',
|
fromHref: '/writer/review',
|
||||||
actionLabel: 'Review & Publish',
|
actionLabel: 'Review & Publish',
|
||||||
toLabel: 'Published',
|
toLabel: 'Published',
|
||||||
toValue: 0,
|
toValue: totalApproved,
|
||||||
toHref: '/writer/published',
|
toHref: '/writer/approved',
|
||||||
progress: 0,
|
progress: totalCount > 0 ? Math.round((totalApproved / (totalCount + totalApproved)) * 100) : 0,
|
||||||
color: 'green',
|
color: 'green',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -530,7 +546,7 @@ export default function Review() {
|
|||||||
{ label: 'Tasks', href: '/writer/tasks' },
|
{ label: 'Tasks', href: '/writer/tasks' },
|
||||||
{ label: 'Content', href: '/writer/content' },
|
{ label: 'Content', href: '/writer/content' },
|
||||||
{ label: 'Images', href: '/writer/images' },
|
{ label: 'Images', href: '/writer/images' },
|
||||||
{ label: 'Published', href: '/writer/published' },
|
{ label: 'Published', href: '/writer/approved' },
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
completion={{
|
completion={{
|
||||||
@@ -541,9 +557,9 @@ export default function Review() {
|
|||||||
{ label: 'Ideas Generated', value: 0, color: 'amber' },
|
{ label: 'Ideas Generated', value: 0, color: 'amber' },
|
||||||
],
|
],
|
||||||
writerItems: [
|
writerItems: [
|
||||||
{ label: 'Content Generated', value: 0, color: 'blue' },
|
{ label: 'Content Generated', value: totalDrafts + totalCount + totalApproved, color: 'blue' },
|
||||||
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
{ label: 'Images Created', value: totalImagesCount, color: 'purple' },
|
||||||
{ label: 'Articles Published', value: 0, color: 'green' },
|
{ label: 'Articles Published', value: totalApproved, color: 'green' },
|
||||||
],
|
],
|
||||||
analyticsHref: '/account/usage',
|
analyticsHref: '/account/usage',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -852,6 +852,35 @@ export async function bulkUpdateClustersStatus(ids: number[], status: string): P
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClustersSummary {
|
||||||
|
total_clusters: number;
|
||||||
|
total_keywords: number;
|
||||||
|
total_volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchClustersSummary(sectorId?: number): Promise<ClustersSummary> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
// Auto-add site filter
|
||||||
|
const activeSiteId = getActiveSiteId();
|
||||||
|
if (activeSiteId) {
|
||||||
|
params.append('site_id', activeSiteId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sector filter if provided or get active
|
||||||
|
if (sectorId !== undefined) {
|
||||||
|
params.append('sector_id', sectorId.toString());
|
||||||
|
} else {
|
||||||
|
const activeSectorId = getActiveSectorId();
|
||||||
|
if (activeSectorId !== null && activeSectorId !== undefined) {
|
||||||
|
params.append('sector_id', activeSectorId.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
return fetchAPI(`/v1/planner/clusters/summary/${queryString ? `?${queryString}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function autoClusterKeywords(keywordIds: number[], sectorId?: number): Promise<{ success: boolean; task_id?: string; clusters_created?: number; keywords_updated?: number; message?: string; error?: string }> {
|
export async function autoClusterKeywords(keywordIds: number[], sectorId?: number): Promise<{ success: boolean; task_id?: string; clusters_created?: number; keywords_updated?: number; message?: string; error?: string }> {
|
||||||
const endpoint = `/v1/planner/keywords/auto_cluster/`;
|
const endpoint = `/v1/planner/keywords/auto_cluster/`;
|
||||||
const requestBody = { ids: keywordIds, sector_id: sectorId };
|
const requestBody = { ids: keywordIds, sector_id: sectorId };
|
||||||
|
|||||||
Reference in New Issue
Block a user