diff --git a/backend/igny8_core/admin/site.py b/backend/igny8_core/admin/site.py index fe3a383c..d7a33934 100644 --- a/backend/igny8_core/admin/site.py +++ b/backend/igny8_core/admin/site.py @@ -160,6 +160,7 @@ class Igny8AdminSite(UnfoldAdminSite): 'Global Settings': { 'models': [ ('system', 'GlobalIntegrationSettings'), + ('system', 'GlobalModuleSettings'), ('system', 'GlobalAIPrompt'), ('system', 'GlobalAuthorProfile'), ('system', 'GlobalStrategy'), diff --git a/backend/igny8_core/ai/functions/generate_image_prompts.py b/backend/igny8_core/ai/functions/generate_image_prompts.py index ea89575b..e8990d20 100644 --- a/backend/igny8_core/ai/functions/generate_image_prompts.py +++ b/backend/igny8_core/ai/functions/generate_image_prompts.py @@ -112,7 +112,7 @@ class GenerateImagePromptsFunction(BaseAIFunction): return prompt def parse_response(self, response: str, step_tracker=None) -> Dict: - """Parse AI response - same pattern as other functions""" + """Parse AI response with new structure including captions""" ai_core = AICore(account=getattr(self, 'account', None)) json_data = ai_core.extract_json(response) @@ -123,9 +123,28 @@ class GenerateImagePromptsFunction(BaseAIFunction): if 'featured_prompt' not in json_data: raise ValueError("Missing 'featured_prompt' in AI response") + if 'featured_caption' not in json_data: + raise ValueError("Missing 'featured_caption' in AI response") + if 'in_article_prompts' not in json_data: raise ValueError("Missing 'in_article_prompts' in AI response") + # Validate in_article_prompts structure (should be list of objects with prompt & caption) + in_article_prompts = json_data.get('in_article_prompts', []) + if in_article_prompts: + for idx, item in enumerate(in_article_prompts): + if isinstance(item, dict): + if 'prompt' not in item: + raise ValueError(f"Missing 'prompt' in in_article_prompts[{idx}]") + if 'caption' not in item: + raise ValueError(f"Missing 'caption' in in_article_prompts[{idx}]") + else: + # Legacy format (just string) - convert to new format + in_article_prompts[idx] = { + 'prompt': str(item), + 'caption': '' # Empty caption for legacy data + } + return json_data def save_output( @@ -151,23 +170,33 @@ class GenerateImagePromptsFunction(BaseAIFunction): prompts_created = 0 with transaction.atomic(): - # Save featured image prompt - use content instead of task + # Save featured image prompt with caption Images.objects.update_or_create( content=content, image_type='featured', defaults={ 'prompt': parsed['featured_prompt'], + 'caption': parsed.get('featured_caption', ''), 'status': 'pending', 'position': 0, } ) prompts_created += 1 - # Save in-article image prompts + # Save in-article image prompts with captions in_article_prompts = parsed.get('in_article_prompts', []) h2_headings = extracted.get('h2_headings', []) - for idx, prompt_text in enumerate(in_article_prompts[:max_images]): + for idx, prompt_data in enumerate(in_article_prompts[:max_images]): + # Handle both new format (dict with prompt & caption) and legacy format (string) + if isinstance(prompt_data, dict): + prompt_text = prompt_data.get('prompt', '') + caption_text = prompt_data.get('caption', '') + else: + # Legacy format - just a string prompt + prompt_text = str(prompt_data) + caption_text = '' + heading = h2_headings[idx] if idx < len(h2_headings) else f"Section {idx + 1}" Images.objects.update_or_create( @@ -176,6 +205,7 @@ class GenerateImagePromptsFunction(BaseAIFunction): position=idx + 1, defaults={ 'prompt': prompt_text, + 'caption': caption_text, 'status': 'pending', } ) diff --git a/backend/igny8_core/business/content/models.py b/backend/igny8_core/business/content/models.py index 0ec42922..268d5435 100644 --- a/backend/igny8_core/business/content/models.py +++ b/backend/igny8_core/business/content/models.py @@ -436,6 +436,7 @@ class Images(SoftDeletableModel, SiteSectorBaseModel): image_url = models.CharField(max_length=500, blank=True, null=True, help_text="URL of the generated/stored image") image_path = models.CharField(max_length=500, blank=True, null=True, help_text="Local path if stored locally") prompt = models.TextField(blank=True, null=True, help_text="Image generation prompt used") + caption = models.TextField(blank=True, null=True, help_text="Image caption (40-60 words) to display with the image") status = models.CharField(max_length=50, default='pending', help_text="Status: pending, generated, failed") position = models.IntegerField(default=0, help_text="Position for in-article images ordering") created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/igny8_core/modules/system/admin.py b/backend/igny8_core/modules/system/admin.py index aeb9c3e1..2cdc8bc2 100644 --- a/backend/igny8_core/modules/system/admin.py +++ b/backend/igny8_core/modules/system/admin.py @@ -10,6 +10,7 @@ from .global_settings_models import ( GlobalAIPrompt, GlobalAuthorProfile, GlobalStrategy, + GlobalModuleSettings, ) from django.contrib import messages @@ -445,3 +446,55 @@ class GlobalStrategyAdmin(ImportExportMixin, Igny8ModelAdmin): }), ) + +@admin.register(GlobalModuleSettings) +class GlobalModuleSettingsAdmin(Igny8ModelAdmin): + """ + Admin for global module enable/disable settings. + Singleton model - only one record exists. + Controls which modules are available platform-wide. + """ + + def has_add_permission(self, request): + """Only allow one instance""" + return not GlobalModuleSettings.objects.exists() + + def has_delete_permission(self, request, obj=None): + """Prevent deletion of singleton""" + return False + + fieldsets = ( + ('Module Availability (Platform-Wide)', { + 'fields': ( + 'planner_enabled', + 'writer_enabled', + 'thinker_enabled', + 'automation_enabled', + 'site_builder_enabled', + 'linker_enabled', + 'optimizer_enabled', + 'publisher_enabled', + ), + 'description': 'Control which modules are available across the entire platform. Disabled modules will not load for ANY user.' + }), + ('Metadata', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ['created_at', 'updated_at'] + + list_display = [ + 'id', + 'planner_enabled', + 'writer_enabled', + 'thinker_enabled', + 'automation_enabled', + 'site_builder_enabled', + 'linker_enabled', + 'optimizer_enabled', + 'publisher_enabled', + 'updated_at', + ] + diff --git a/backend/igny8_core/modules/system/global_settings_models.py b/backend/igny8_core/modules/system/global_settings_models.py index f36bc4f3..6c0e5a7c 100644 --- a/backend/igny8_core/modules/system/global_settings_models.py +++ b/backend/igny8_core/modules/system/global_settings_models.py @@ -345,3 +345,74 @@ class GlobalStrategy(models.Model): def __str__(self): return f"{self.name} ({self.get_category_display()})" + + +class GlobalModuleSettings(models.Model): + """ + Global module enable/disable settings (platform-wide). + Singleton model - only one record exists (pk=1). + Controls which modules are available across the entire platform. + No per-account overrides allowed - this is admin-only control. + """ + planner_enabled = models.BooleanField( + default=True, + help_text="Enable Planner module platform-wide" + ) + writer_enabled = models.BooleanField( + default=True, + help_text="Enable Writer module platform-wide" + ) + thinker_enabled = models.BooleanField( + default=True, + help_text="Enable Thinker module platform-wide" + ) + automation_enabled = models.BooleanField( + default=True, + help_text="Enable Automation module platform-wide" + ) + site_builder_enabled = models.BooleanField( + default=True, + help_text="Enable Site Builder module platform-wide" + ) + linker_enabled = models.BooleanField( + default=True, + help_text="Enable Linker module platform-wide" + ) + optimizer_enabled = models.BooleanField( + default=True, + help_text="Enable Optimizer module platform-wide" + ) + publisher_enabled = models.BooleanField( + default=True, + help_text="Enable Publisher module platform-wide" + ) + updated_at = models.DateTimeField(auto_now=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'igny8_global_module_settings' + verbose_name = 'Global Module Settings' + verbose_name_plural = 'Global Module Settings' + + def __str__(self): + return "Global Module Settings" + + def save(self, *args, **kwargs): + """Enforce singleton pattern""" + self.pk = 1 + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + """Prevent deletion""" + pass + + @classmethod + def get_instance(cls): + """Get or create the singleton instance""" + obj, created = cls.objects.get_or_create(pk=1) + return obj + + def is_module_enabled(self, module_name: str) -> bool: + """Check if a module is enabled""" + field_name = f"{module_name}_enabled" + return getattr(self, field_name, False) diff --git a/backend/igny8_core/modules/system/migrations/0010_globalmodulesettings_and_more.py b/backend/igny8_core/modules/system/migrations/0010_globalmodulesettings_and_more.py new file mode 100644 index 00000000..acbf9cc9 --- /dev/null +++ b/backend/igny8_core/modules/system/migrations/0010_globalmodulesettings_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.9 on 2025-12-20 21:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('system', '0009_fix_variables_optional'), + ] + + operations = [ + migrations.CreateModel( + name='GlobalModuleSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('planner_enabled', models.BooleanField(default=True, help_text='Enable Planner module platform-wide')), + ('writer_enabled', models.BooleanField(default=True, help_text='Enable Writer module platform-wide')), + ('thinker_enabled', models.BooleanField(default=True, help_text='Enable Thinker module platform-wide')), + ('automation_enabled', models.BooleanField(default=True, help_text='Enable Automation module platform-wide')), + ('site_builder_enabled', models.BooleanField(default=True, help_text='Enable Site Builder module platform-wide')), + ('linker_enabled', models.BooleanField(default=True, help_text='Enable Linker module platform-wide')), + ('optimizer_enabled', models.BooleanField(default=True, help_text='Enable Optimizer module platform-wide')), + ('publisher_enabled', models.BooleanField(default=True, help_text='Enable Publisher module platform-wide')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Global Module Settings', + 'verbose_name_plural': 'Global Module Settings', + 'db_table': 'igny8_global_module_settings', + }, + ), + # AccountIntegrationOverride was already removed in migration 0004 + # migrations.DeleteModel(name='AccountIntegrationOverride'), + ] diff --git a/backend/igny8_core/modules/system/serializers.py b/backend/igny8_core/modules/system/serializers.py index 2312d277..fd3bd900 100644 --- a/backend/igny8_core/modules/system/serializers.py +++ b/backend/igny8_core/modules/system/serializers.py @@ -9,6 +9,7 @@ class AIPromptSerializer(serializers.ModelSerializer): """Serializer for AI Prompts""" prompt_type_display = serializers.CharField(source='get_prompt_type_display', read_only=True) + default_prompt = serializers.SerializerMethodField() class Meta: model = AIPrompt @@ -23,6 +24,18 @@ class AIPromptSerializer(serializers.ModelSerializer): 'created_at', ] read_only_fields = ['id', 'created_at', 'updated_at', 'default_prompt'] + + def get_default_prompt(self, obj): + """Get live default prompt from GlobalAIPrompt""" + from .global_settings_models import GlobalAIPrompt + try: + global_prompt = GlobalAIPrompt.objects.get( + prompt_type=obj.prompt_type, + is_active=True + ) + return global_prompt.prompt_value + except GlobalAIPrompt.DoesNotExist: + return f"ERROR: Global prompt '{obj.prompt_type}' not configured in admin" class AuthorProfileSerializer(serializers.ModelSerializer): diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index c4d8d47f..54f2f2c9 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -293,41 +293,55 @@ class ModuleSettingsViewSet(AccountModelViewSet): ) class ModuleEnableSettingsViewSet(AccountModelViewSet): """ - ViewSet for managing module enable/disable settings - Unified API Standard v1.0 compliant - One record per account - Read access: All authenticated users - Write access: Admins/Owners only + ViewSet for GLOBAL module enable/disable settings (read-only). + Returns platform-wide module availability. + Only superadmin can modify via Django Admin. """ queryset = ModuleEnableSettings.objects.all() serializer_class = ModuleEnableSettingsSerializer + http_method_names = ['get'] # Read-only authentication_classes = [JWTAuthentication] throttle_scope = 'system' throttle_classes = [DebugScopedRateThrottle] def get_permissions(self): - """ - Allow read access to all authenticated users, - but restrict write access to admins/owners - """ - if self.action in ['list', 'retrieve', 'get_current']: - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] - else: - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner] - return [permission() for permission in permission_classes] + """Read-only for all authenticated users""" + return [IsAuthenticatedAndActive(), HasTenantAccess()] def get_queryset(self): - """Get module enable settings for current account""" - # Return queryset filtered by account - but list() will handle get_or_create - queryset = super().get_queryset() - # Filter by account if available - account = getattr(self.request, 'account', None) - if not account: - user = getattr(self.request, 'user', None) - if user: - account = getattr(user, 'account', None) - if account: - queryset = queryset.filter(account=account) + """Return empty queryset (not used - we return global settings)""" + return ModuleEnableSettings.objects.none() + + def list(self, request, *args, **kwargs): + """Return global module settings (platform-wide)""" + try: + from igny8_core.modules.system.global_settings_models import GlobalModuleSettings + global_settings = GlobalModuleSettings.get_instance() + + data = { + 'id': 1, + 'planner_enabled': global_settings.planner_enabled, + 'writer_enabled': global_settings.writer_enabled, + 'thinker_enabled': global_settings.thinker_enabled, + 'automation_enabled': global_settings.automation_enabled, + 'site_builder_enabled': global_settings.site_builder_enabled, + 'linker_enabled': global_settings.linker_enabled, + 'optimizer_enabled': global_settings.optimizer_enabled, + 'publisher_enabled': global_settings.publisher_enabled, + 'created_at': global_settings.created_at.isoformat() if global_settings.created_at else None, + 'updated_at': global_settings.updated_at.isoformat() if global_settings.updated_at else None, + } + return success_response(data=data, request=request) + except Exception as e: + return error_response( + error=str(e), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) + + def retrieve(self, request, pk=None, *args, **kwargs): + """Same as list - return global settings""" + return self.list(request) return queryset @action(detail=False, methods=['get', 'put'], url_path='current', url_name='current') diff --git a/backend/igny8_core/modules/system/utils.py b/backend/igny8_core/modules/system/utils.py index 35579bbd..2780bc5f 100644 --- a/backend/igny8_core/modules/system/utils.py +++ b/backend/igny8_core/modules/system/utils.py @@ -5,335 +5,22 @@ from typing import Optional def get_default_prompt(prompt_type: str) -> str: - """Get default prompt value from GlobalAIPrompt ONLY - single source of truth""" + """ + Get default prompt value from GlobalAIPrompt ONLY - single source of truth. + No hardcoded fallbacks. Admin must configure prompts in GlobalAIPrompt table. + """ from .global_settings_models import GlobalAIPrompt try: global_prompt = GlobalAIPrompt.objects.get(prompt_type=prompt_type, is_active=True) return global_prompt.prompt_value except GlobalAIPrompt.DoesNotExist: - return f"ERROR: Global prompt '{prompt_type}' not configured in admin. Please configure it at: admin/system/globalaiprompt/" - 'clustering': """You are a semantic strategist and SEO architecture engine. Your task is to analyze the provided keyword list and group them into meaningful, intent-driven topic clusters that reflect how real users search, think, and act online. - -Return a single JSON object with a "clusters" array. Each cluster must follow this structure: - -{ - "name": "[Descriptive cluster name — natural, SEO-relevant, clearly expressing the topic]", - "description": "[1–2 concise sentences explaining what this cluster covers and why these keywords belong together]", - "keywords": ["keyword 1", "keyword 2", "keyword 3", "..."] -} - -CLUSTERING STRATEGY: - -1. Keyword-first, structure-follows: - - Do NOT rely on assumed categories or existing content structures. - - Begin purely from the meaning, intent, and behavioral connection between keywords. - -2. Use multi-dimensional grouping logic: - - Group keywords by these behavioral dimensions: - • Search Intent → informational, commercial, transactional, navigational - • Use-Case or Problem → what the user is trying to achieve or solve - • Function or Feature → how something works or what it does - • Persona or Audience → who the content or product serves - • Context → location, time, season, platform, or device - - Combine 2–3 dimensions naturally where they make sense. - -3. Model real search behavior: - - Favor clusters that form natural user journeys such as: - • Problem ➝ Solution - • General ➝ Specific - • Product ➝ Use-case - • Buyer ➝ Benefit - • Tool ➝ Function - • Task ➝ Method - - Each cluster should feel like a real topic hub users would explore in depth. - -4. Avoid superficial groupings: - - Do not cluster keywords just because they share words. - - Do not force-fit outliers or unrelated keywords. - - Exclude keywords that don't logically connect to any cluster. - -5. Quality rules: - - Each cluster should include between 3–10 strongly related keywords. - - Never duplicate a keyword across multiple clusters. - - Prioritize semantic strength, search intent, and usefulness for SEO-driven content structure. - - It's better to output fewer, high-quality clusters than many weak or shallow ones. - -INPUT FORMAT: -{ - "keywords": [IGNY8_KEYWORDS] -} - -OUTPUT FORMAT: -Return ONLY the final JSON object in this format: -{ - "clusters": [ - { - "name": "...", - "description": "...", - "keywords": ["...", "...", "..."] - } - ] -} - -Do not include any explanations, text, or commentary outside the JSON output. -""", - - 'ideas': """Generate SEO-optimized, high-quality content ideas and outlines for each keyword cluster. -Input: -Clusters: [IGNY8_CLUSTERS] -Keywords: [IGNY8_CLUSTER_KEYWORDS] - -Output: JSON with "ideas" array. -Each cluster → 1 cluster_hub + 2–4 supporting ideas. -Each idea must include: -title, description, content_type, content_structure, cluster_id, estimated_word_count (1500–2200), and covered_keywords. - -Outline Rules: - -Intro: 1 hook (30–40 words) + 2 intro paragraphs (50–60 words each). - -5–8 H2 sections, each with 2–3 H3s. - -Each H2 ≈ 250–300 words, mixed content (paragraphs, lists, tables, blockquotes). - -Vary section format and tone; no bullets or lists at start. - -Tables have columns; blockquotes = expert POV or data insight. - -Use depth, examples, and real context. - -Avoid repetitive structure. - -Tone: Professional editorial flow. No generic phrasing. Use varied sentence openings and realistic examples. - -Output JSON Example: - -{ - "ideas": [ - { - "title": "Best Organic Cotton Duvet Covers for All Seasons", - "description": { - "introduction": { - "hook": "Transform your sleep with organic cotton that blends comfort and sustainability.", - "paragraphs": [ - {"content_type": "paragraph", "details": "Overview of organic cotton's rise in bedding industry."}, - {"content_type": "paragraph", "details": "Why consumers prefer organic bedding over synthetic alternatives."} - ] - }, - "H2": [ - { - "heading": "Why Choose Organic Cotton for Bedding?", - "subsections": [ - {"subheading": "Health and Skin Benefits", "content_type": "paragraph", "details": "Discuss hypoallergenic and chemical-free aspects."}, - {"subheading": "Environmental Sustainability", "content_type": "list", "details": "Eco benefits like low water use, no pesticides."}, - {"subheading": "Long-Term Cost Savings", "content_type": "table", "details": "Compare durability and pricing over time."} - ] - } - ] - }, - "content_type": "post", - "content_structure": "review", - "cluster_id": 12, - "estimated_word_count": 1800, - "covered_keywords": "organic duvet covers, eco-friendly bedding, sustainable sheets" - } - ] -}""", - - 'content_generation': """You are an editorial content strategist. Your task is to generate a complete JSON response object that includes all the fields listed below, based on the provided content idea, keyword cluster, and keyword list. - -Only the `content` field should contain HTML inside JSON object. - -================== -Generate a complete JSON response object matching this structure: -================== - -{ - "title": "[Blog title using the primary keyword — full sentence case]", - "meta_title": "[Meta title under 60 characters — natural, optimized, and compelling]", - "meta_description": "[Meta description under 160 characters — clear and enticing summary]", - "content": "[HTML content — full editorial structure with

,

,

,