31 Commits

Author SHA1 Message Date
alorig
e9f02f5e9f latest 2 days work excluding problemetic code 2026-01-12 14:23:05 +05:00
IGNY8 VPS (Salman)
b390e02aa5 Creditsupdates in fotoer wdigets adn hoemapge and singe site settigns page #Run MIgration 0033 #MAJOR 2026-01-12 07:22:08 +00:00
IGNY8 VPS (Salman)
368601f68c header footer metrics update and credits by site fixes 2026-01-12 05:28:36 +00:00
IGNY8 VPS (Salman)
95d8ade942 footer widgets and tuomation page fixes 2026-01-12 02:26:25 +00:00
IGNY8 VPS (Salman)
90b9d6aadc wp plugin and app fixes adn automation page update 2026-01-12 01:12:08 +00:00
IGNY8 VPS (Salman)
3925ddf894 django bacekdn opeartioanl fixes and site wp integration api fixes 2026-01-11 21:54:08 +00:00
IGNY8 VPS (Salman)
00ef985a5f version 1.7.1 2026-01-11 18:56:07 +00:00
IGNY8 VPS (Salman)
75e5b148f5 reorg 2026-01-11 16:58:57 +00:00
IGNY8 VPS (Salman)
e9369df151 widgets and other fixes 2026-01-11 15:24:52 +00:00
IGNY8 VPS (Salman)
747770ac58 upto phase 4 completed 2026-01-10 14:59:57 +00:00
IGNY8 VPS (Salman)
6e15ffb49b reorg docs 2026-01-10 14:35:13 +00:00
IGNY8 VPS (Salman)
854b3efd45 image s imsages images model fixes new model see dream 2026-01-10 13:16:05 +00:00
IGNY8 VPS (Salman)
6fb0411f56 image strugles 2 2026-01-10 11:54:31 +00:00
IGNY8 VPS (Salman)
1246f8ac5d image strugles 2026-01-10 11:54:18 +00:00
IGNY8 VPS (Salman)
622e66b0fb iamge genration credits fixing - not fixed 2026-01-10 09:39:17 +00:00
IGNY8 VPS (Salman)
60f981cafd v 1.7.0 2026-01-10 08:47:36 +00:00
IGNY8 VPS (Salman)
b0c941dba5 1.3.3 2026-01-10 08:05:04 +00:00
IGNY8 VPS (Salman)
b6b6ae7a84 1 2026-01-10 07:47:58 +00:00
IGNY8 VPS (Salman)
067eb59344 1.3.2 2026-01-10 07:47:41 +00:00
IGNY8 VPS (Salman)
af95454049 1.3.1 2026-01-10 07:28:11 +00:00
IGNY8 VPS (Salman)
ceee9ba34d wp 1.3.0 2026-01-10 07:04:24 +00:00
IGNY8 VPS (Salman)
be2c190eca revert to last healthy 2026-01-10 06:48:16 +00:00
IGNY8 VPS (Salman)
9e785f141c temaplte fixes in app and in plugin 2026-01-10 06:17:38 +00:00
IGNY8 VPS (Salman)
975eab46cf fixes for ai and iamge related models bacekedn 2026-01-10 05:11:24 +00:00
IGNY8 VPS (Salman)
0c693dc1cc Image genartiona dn temaplte design redesign 2026-01-10 03:58:02 +00:00
IGNY8 VPS (Salman)
ce66dadc00 reorg docs 2026-01-10 02:42:31 +00:00
IGNY8 VPS (Salman)
346d3f0531 pluign updates 2026-01-10 01:42:20 +00:00
IGNY8 VPS (Salman)
a86524a6b1 versioning and wp plugin updates 2026-01-10 00:26:00 +00:00
IGNY8 VPS (Salman)
0ea3a30909 PLugin versioning fixes 2026-01-09 23:36:03 +00:00
IGNY8 VPS (Salman)
4343f62140 Plugin packaging and docs 2026-01-09 22:45:30 +00:00
IGNY8 VPS (Salman)
80f1709a2e plugin distribution system 2026-01-09 21:38:14 +00:00
204 changed files with 17178 additions and 2060 deletions

View File

@@ -1,7 +1,7 @@
# IGNY8 Change Log
**Current Version:** 1.6.2
**Last Updated:** January 8, 2026
**Current Version:** 1.7.1
**Last Updated:** January 11, 2026
---
@@ -9,6 +9,8 @@
| Version | Date | Summary |
|---------|------|---------|
| 1.7.1 | Jan 11, 2026 | **Bug Fixes & Improvements** - SeeDream AI image model integration, Image generation credit fixes, Widget improvements, Template optimizations, Phase 4 completion (Email/Notifications QA) |
| 1.7.0 | Jan 10, 2026 | **Major** - Pre-Launch Cleanup Complete (Phases 1, 5, 6): Code cleanup, UX improvements, data backup tools; WordPress plugin distribution system; Template design improvements; AI model fixes |
| 1.6.2 | Jan 8, 2026 | **Design Refinements** - Updated marketing site gradients to brand colors (primary + success), reduced shadow weights, simplified automation icons, added Upcoming Features page |
| 1.6.1 | Jan 8, 2026 | **Email System** - SMTP configuration, email templates, password reset flow, email verification, unsubscribe functionality |
| 1.6.0 | Jan 8, 2026 | **Major** - Payment System Refactor Complete: Stripe, PayPal, Bank Transfer flows finalized; Simplified signup (no payment redirect); Country-based payment rules; Webhook security; PDF invoices |
@@ -39,6 +41,365 @@
---
## v1.7.1 - January 11, 2026
### Bug Fixes, Improvements, and Phase 4 Completion
This release includes critical bug fixes for image generation, SeeDream AI model integration, widget improvements across multiple pages, and completion of Phase 4 (Email & Notifications QA) from the pre-launch checklist.
---
### 🖼️ Image Generation & AI Models
**SeeDream AI Model Integration:**
- Added new SeeDream AI model via migration [0031_add_seedream_model.py](backend/igny8_core/business/billing/migrations/0031_add_seedream_model.py)
- Configured as high-quality image generation model with appropriate credit costs
- Updated landscape/portrait/square size configurations
- Integrated with model registry and credit system
**Image Generation Credit Fixes:**
- Fixed credit deduction logic in [tasks.py](backend/igny8_core/ai/tasks.py)
- Resolved issues with credit verification before image generation
- Fixed AITaskLog logging for image generation operations
- Improved error handling for insufficient credits scenarios
**Model Configuration Updates:**
- Updated image size configurations in migration [0030_add_aimodel_image_sizes.py](backend/igny8_core/business/billing/migrations/0030_add_aimodel_image_sizes.py)
- Added landscape_size, portrait_size, square_size fields to AIModelConfig
- Enhanced model registry with better size handling
---
### 📊 Widget & UI Improvements
**Dashboard Widgets:**
- Fixed WorkflowCompletionWidget data loading and display issues
- Improved useWorkflowStats hook for better performance and accuracy
- Updated CurrentProcessingCardV2 with better state handling
- Enhanced dashboard widgets layout and responsiveness
**Page-Level Improvements:**
- **Keywords Page**: Improved table layout, filtering, and bulk actions
- **Clusters Page**: Enhanced clustering UI and workflow visualization
- **Ideas Page**: Better idea management and status tracking
- **Tasks Page**: Improved task queue display and processing indicators
- **Content Page**: Enhanced content review and editing interface
- **Sites Pages**: Improved site listing and settings management
- **Publisher/Calendar**: Better content scheduling interface
- **Help Page**: Updated documentation links and support resources
**Color System:**
- Added new color tokens to [colors.config.ts](frontend/src/config/colors.config.ts)
- Improved color consistency across components
---
### 🔧 Backend Improvements
**AI Core & Processing:**
- Refactored [ai_core.py](backend/igny8_core/ai/ai_core.py) for better model handling
- Updated [generate_images.py](backend/igny8_core/ai/functions/generate_images.py) with improved error handling
- Enhanced [ai_processor.py](backend/igny8_core/utils/ai_processor.py) for better task processing
- Improved model registry in [model_registry.py](backend/igny8_core/ai/model_registry.py)
**Automation Service:**
- Enhanced [automation_service.py](backend/igny8_core/business/automation/services/automation_service.py)
- Improved stage processing and error recovery
- Better credit tracking across automation stages
**Credit Service:**
- Updated [credit_service.py](backend/igny8_core/business/billing/services/credit_service.py)
- Fixed credit calculation for image generation
- Improved credit logging and transaction history
**System Settings:**
- Enhanced [ai_settings.py](backend/igny8_core/modules/system/ai_settings.py) with default quality tier settings
- Updated [integration_views.py](backend/igny8_core/modules/system/integration_views.py) for better API handling
- Improved [settings_views.py](backend/igny8_core/modules/system/settings_views.py)
---
### ✅ Phase 4 Completion: Email & Notifications QA
**Email System Verification:**
- Verified SMTP configuration and email delivery
- Tested password reset email flow
- Confirmed email verification functionality
- Validated unsubscribe mechanisms
- Tested notification emails for AI task completion/failures
**Notification System:**
- Verified notification creation for all AI operations
- Tested notification delivery for automation stages
- Confirmed notification filtering and bulk actions
- Validated notification preferences and settings
---
### 📚 Documentation Updates
**Updated Documentation:**
- [BILLING.md](docs/10-MODULES/BILLING.md) - Updated credit system documentation
- [CREDIT-SYSTEM.md](docs/40-WORKFLOWS/CREDIT-SYSTEM.md) - Enhanced credit workflow documentation
- [FINAL-PRELAUNCH-PENDING.md](docs/plans/FINAL-PRELAUNCH-PENDING.md) - Updated with Phase 4 completion
**New Documentation:**
- [COMPREHENSIVE-SYSTEM-FIX-PLAN-JAN-10-2026.md](docs/plans/implemented/COMPREHENSIVE-SYSTEM-FIX-PLAN-JAN-10-2026.md) - System fix documentation
- [FOOTER-WIDGETS-AUDIT.md](docs/plans/implemented/FOOTER-WIDGETS-AUDIT.md) - Widget audit results
- [IMAGE-GENERATION-GAPS.md](docs/plans/implemented/IMAGE-GENERATION-GAPS.md) - Image generation issues and fixes
**Documentation Reorganization:**
- Moved completed documentation to `docs/plans/implemented/` folder
- Better organization of planning vs. implemented features
---
### 🐛 Bug Fixes
- Fixed credit deduction not working properly for image generation
- Resolved widget data loading issues on Dashboard
- Fixed automation stage processing errors
- Corrected image size configuration for different models
- Fixed notification creation timing issues
- Resolved UI responsiveness issues on various pages
---
### 🔄 Code Cleanup & Organization
- Reorganized documentation structure
- Improved code comments and documentation
- Enhanced error messages for better debugging
- Cleaned up unused code and imports
- Improved type safety in TypeScript components
---
### 📁 Files Changed
| File | Changes |
|------|---------|
| `backend/igny8_core/business/billing/services/credit_service.py` | Added `check_credits_for_image()` method |
| `backend/igny8_core/ai/tasks.py` | Added credit check, AITaskLog logging, notifications |
---
### 📋 Credit Usage Logging Locations
| Table | Purpose |
|-------|---------|
| `CreditTransaction` | Financial ledger entry (via `deduct_credits_for_image`) |
| `CreditUsageLog` | Detailed usage log with model, cost, credits |
| `AITaskLog` | AI task execution tracking |
| `Notification` | User notifications for completion/failure |
---
## v1.7.0 - January 10, 2026
### Pre-Launch Cleanup & Plugin Distribution System
This major release completes critical pre-launch phases (1, 5, 6) with comprehensive code cleanup, UX improvements, and data management tools. It also introduces the WordPress plugin distribution infrastructure and improves template rendering.
---
### 🧹 Phase 1: Code Cleanup & Technical Debt - COMPLETE ✅
**Code Quality Improvements:**
- Removed 3,218 lines of legacy code
- Deleted 24 files and cleaned up 11 empty directories
- Removed 17 console.log/debug statements:
- UserProfile components (UserMetaCard, UserAddressCard, UserInfoCard)
- Automation/ConfigModal
- ImageQueueModal (8 statements)
- ImageGenerationCard (7 statements)
- Applied ESLint auto-fixes (9 errors fixed)
- All TypeScript strict mode checks passing
**Removed Test Files:**
- `test-module-settings.html` (manual API test)
- `test_urls.py` (one-time URL verification)
- `test_stage1_refactor.py` (stage 1 verification)
**Deleted Unused Components:**
- ecommerce/ template components (7 files)
- sample-components/ (2 HTML files)
- charts/bar/ and charts/line/
- tables/BasicTables/
- CurrentProcessingCard.old.tsx
**Production Status:**
- ✅ Build time: ~9 seconds
- ✅ All tests passing
- ✅ ESLint compliance
- ✅ TypeScript strict mode
- ✅ Production-ready codebase
---
### 🎨 Phase 5: UX Improvements - COMPLETE ✅
**Enhanced Search Modal:**
- Added search filters: All, Workflow, Setup, Account, Help
- Implemented recent searches (localStorage, max 5)
- Added category display in results
- Improved result filtering by type and category
- Deep linking to help sections with auto-expand accordions
**Smart Search Features:**
- Intelligent phrase matching (strips filler words: how, to, what, is)
- Basic stemming support (tasks → task)
- Duplicate prevention using Set
- Enhanced matching logic:
- Direct keyword match
- Normalized term match
- Question text match
**Comprehensive Keyword Coverage:**
Added 10+ keyword categories:
- task, cluster, billing, invoice, payment
- plan, usage, schedule, wordpress
- writing, picture, user, ai
**Help System:**
- Added 25+ help questions across 8 topics
- Context snippets and highlighting
- Suggested questions
- Fixed duplicate keywords in navigation
---
### 💾 Phase 6: Data Backup & Cleanup Tools - COMPLETE ✅
**New Django Management Commands:**
1. **export_system_config**
- Exports system configuration to JSON
- Includes: Plans, Credit Costs, AI Models, Industries, Sectors, etc.
- Metadata with export timestamp and stats
- Usage: `python manage.py export_system_config --output-dir=backups/config`
2. **cleanup_user_data**
- Safely deletes all user-generated data
- DRY-RUN mode to preview deletions
- Confirmation prompt for safety
- Production environment protection
- Deletes: Sites, Keywords, Content, Images, Transactions, Logs
- Preserves: System config and user accounts
- Usage: `python manage.py cleanup_user_data --dry-run`
- Usage: `python manage.py cleanup_user_data --confirm`
**Documentation:**
- Comprehensive 300+ line backup and cleanup guide
- Atomic transactions and safety features
- Ready for V1.0 production database preparation
---
### 🔌 WordPress Plugin Distribution System
**Infrastructure Setup:**
- Full plugin distribution system implemented
- Directory structure: `/plugins/{platform}/source/` and `/dist/`
- Database models: Plugin, PluginVersion, PluginInstallation, PluginDownload
- API endpoints for download, check-update, register, health-check
- WordPress auto-update mechanism via `pre_set_site_transient_update_plugins` hook
- Build scripts for ZIP generation with versioning
- Security: signed URLs, checksums (MD5/SHA256), rate limiting
- Monitoring and analytics dashboard widgets
**Plugin Versioning:**
- Semantic versioning system
- Automated packaging and distribution
- Update check mechanism
- Health check endpoints
- Installation tracking
**Platform Ready:**
- WordPress: Production ready
- Shopify: Infrastructure ready
- Custom sites: Infrastructure ready
---
### 🤖 AI & Backend Improvements
**AI Model Fixes:**
- Fixed AI and image-related models in backend
- Improved image generation reliability
- Better error handling for AI tasks
- Enhanced prompt handling
- Improved aspect ratio detection
**Template Rendering:**
- Template design improvements
- Better content section handling
- Image layout enhancements
- Improved prose styling
---
### 📚 Documentation Updates
**Documentation Reorganization:**
- Restructured docs folder
- Updated plugin distribution documentation
- Added comprehensive system guides
- Improved workflow documentation
**New/Updated Docs:**
- Plugin distribution system guide
- Backup and cleanup guide (300+ lines)
- Pre-launch checklist updates
- Version update workflow
---
### 🔧 Technical Changes
**Build & Development:**
- 23 commits since v1.6.2
- 24+ files modified
- Major merge: Phase 1, 5, 6 implementation
- Build optimizations
**Database & API:**
- Enhanced model relationships
- Improved query optimization
- Better API endpoint structure
- Rate limiting enhancements
**Frontend:**
- Component cleanup and organization
- Better state management
- Improved type safety
- Search modal enhancements
---
### 🚀 Production Readiness Status
**Completed:**
- ✅ Phase 1: Code cleanup complete
- ✅ Phase 5: UX improvements complete
- ✅ Phase 6: Data backup & cleanup tools ready
- ✅ Plugin distribution system operational
- ✅ All tests passing
- ✅ Production build ready
**Deferred to Post-Launch:**
- Image regeneration feature (Phase 9)
- Additional AI model integrations
- Advanced automation features
**Next Steps:**
- Phase 7: User acceptance testing
- Performance optimization
- Final pre-launch checklist
- V1.0 production deployment
---
## v1.6.2 - January 8, 2026
### Marketing Site Design Refinements

View File

@@ -1,8 +1,9 @@
"""
Base Admin Mixins for account and site/sector filtering
Base Admin Mixins for account and site/sector filtering.
"""
from django.contrib import admin
from django.contrib import admin, messages
from django.core.exceptions import PermissionDenied
from django.db import models, transaction
class AccountAdminMixin:
@@ -110,7 +111,7 @@ class SiteSectorAdminMixin:
# ============================================================================
# Custom ModelAdmin for Sidebar Fix
# Custom ModelAdmin for Sidebar Fix + Delete Fix
# ============================================================================
from unfold.admin import ModelAdmin as UnfoldModelAdmin
@@ -118,12 +119,15 @@ from unfold.admin import ModelAdmin as UnfoldModelAdmin
class Igny8ModelAdmin(UnfoldModelAdmin):
"""
Custom ModelAdmin that ensures sidebar_navigation is set correctly on ALL pages
Custom ModelAdmin that:
1. Ensures sidebar_navigation is set correctly on ALL pages
2. Uses standard Django filters
Django's ModelAdmin views don't call AdminSite.each_context(),
so we override them to inject our custom sidebar.
"""
# Standard Django filters
pass
def _inject_sidebar_context(self, request, extra_context=None):
"""Helper to inject custom sidebar into context"""
if extra_context is None:

View File

@@ -1026,11 +1026,28 @@ class AICore:
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:
elif runware_model.startswith('bytedance:'):
# Seedream 4.5 (bytedance:seedream@4.5) - High quality ByteDance model
# Uses basic format - no steps, CFGScale, negativePrompt, or special providerSettings needed
# Remove negativePrompt as it's not supported
if 'negativePrompt' in inference_task:
del inference_task['negativePrompt']
# Enforce minimum size for Seedream (min 3,686,400 pixels ~ 1920x1920)
current_pixels = width * height
if current_pixels < 3686400:
# Use default Seedream square size
inference_task['width'] = 2048
inference_task['height'] = 2048
width, height = 2048, 2048
print(f"[AI][{function_name}] Using Seedream 4.5 config: basic format (no negativePrompt), width={inference_task['width']}, height={inference_task['height']}")
elif runware_model.startswith('runware:'):
# 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")
else:
# Unknown model - use basic format without extra parameters
print(f"[AI][{function_name}] Using basic format for unknown model: {runware_model}")
payload = [
{

View File

@@ -444,6 +444,9 @@ class AIEngine:
tokens_input = raw_response.get('input_tokens', 0)
tokens_output = raw_response.get('output_tokens', 0)
# Extract site_id from save_result (could be from content, cluster, or task)
site_id = save_result.get('site_id') or save_result.get('site')
# Deduct credits based on actual token usage
CreditService.deduct_credits_for_operation(
account=self.account,
@@ -454,6 +457,7 @@ class AIEngine:
model_used=raw_response.get('model', ''),
related_object_type=self._get_related_object_type(function_name),
related_object_id=save_result.get('id') or save_result.get('cluster_id') or save_result.get('task_id'),
site=site_id,
metadata={
'function_name': function_name,
'clusters_created': clusters_created,

View File

@@ -340,6 +340,7 @@ class AutoClusterFunction(BaseAIFunction):
return {
'count': clusters_created,
'clusters_created': clusters_created,
'keywords_updated': keywords_updated
'keywords_updated': keywords_updated,
'site_id': site.id if site else None
}

View File

@@ -329,6 +329,7 @@ class GenerateContentFunction(BaseAIFunction):
'content_id': content_record.id,
'task_id': task.id,
'word_count': word_count,
'site_id': task.site_id if task.site_id else None,
}

View File

@@ -233,9 +233,19 @@ class GenerateIdeasFunction(BaseAIFunction):
cluster.status = 'mapped'
cluster.save()
# Get site_id from the first cluster if available
site_id = None
if clusters:
first_cluster = clusters[0]
if first_cluster.site_id:
site_id = first_cluster.site_id
elif first_cluster.sector and first_cluster.sector.site_id:
site_id = first_cluster.sector.site_id
return {
'count': ideas_created,
'ideas_created': ideas_created
'ideas_created': ideas_created,
'site_id': site_id
}

View File

@@ -214,6 +214,7 @@ class GenerateImagePromptsFunction(BaseAIFunction):
return {
'count': prompts_created,
'prompts_created': prompts_created,
'site_id': content.site_id if content.site_id else None
}
# Helper methods

View File

@@ -70,22 +70,37 @@ class GenerateImagesFunction(BaseAIFunction):
# 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
from igny8_core.business.billing.models import AIModelConfig
# Get effective settings (AISettings + AccountSettings overrides)
image_style = AISettings.get_effective_image_style(account)
max_images = AISettings.get_effective_max_images(account)
quality_tier = AISettings.get_effective_quality_tier(account)
# 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
# Get image model based on user's selected quality tier
selected_model = None
if quality_tier:
selected_model = AIModelConfig.objects.filter(
model_type='image',
quality_tier=quality_tier,
is_active=True
).first()
# Fall back to default model if no tier match
if not selected_model:
default_model_name = ModelRegistry.get_default_model('image')
if default_model_name:
selected_model = ModelRegistry.get_model(default_model_name)
# Set provider and model from selected model
if selected_model:
provider = selected_model.provider if selected_model.provider else 'openai'
model = selected_model.model_name
else:
provider = 'openai'
model = 'dall-e-3'
logger.info(f"Using image settings: provider={provider}, model={model}, style={image_style}, max={max_images}")
logger.info(f"Using image settings: provider={provider}, model={model}, tier={quality_tier}, style={image_style}, max={max_images}")
return {
'tasks': tasks,
@@ -181,7 +196,8 @@ class GenerateImagesFunction(BaseAIFunction):
return {
'count': 1,
'images_created': 1,
'image_id': image.id
'image_id': image.id,
'site_id': task.site_id if task.site_id else None
}

View File

@@ -150,6 +150,7 @@ class OptimizeContentFunction(BaseAIFunction):
'html_content': optimized_html,
'meta_title': optimized_meta_title,
'meta_description': optimized_meta_description,
'site_id': content.site_id if content.site_id else None
}
# Helper methods

View File

@@ -118,9 +118,9 @@ class ModelRegistry:
# Handle AIModelConfig instance
if rate_type == 'input':
return model.input_cost_per_1m or Decimal('0')
return model.cost_per_1k_input or Decimal('0')
elif rate_type == 'output':
return model.output_cost_per_1m or Decimal('0')
return model.cost_per_1k_output or Decimal('0')
elif rate_type == 'image':
return model.cost_per_image or Decimal('0')

View File

@@ -158,7 +158,8 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
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
from igny8_core.business.billing.exceptions import InsufficientCreditsError, CreditCalculationError
logger.info("=" * 80)
logger.info(f"process_image_generation_queue STARTED")
logger.info(f" - Task ID: {self.request.id}")
@@ -166,7 +167,7 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
logger.info(f" - Account ID: {account_id}")
logger.info(f" - Content ID: {content_id}")
logger.info("=" * 80)
account = None
if account_id:
from igny8_core.auth.models import Account
@@ -186,22 +187,42 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
logger.info("[process_image_generation_queue] Step 1: Loading image generation settings")
from igny8_core.modules.system.ai_settings import AISettings
from igny8_core.ai.model_registry import ModelRegistry
from igny8_core.business.billing.models import AIModelConfig
# Get effective settings
image_type = AISettings.get_effective_image_style(account)
image_format = 'webp' # Default format
# 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
# Get user's selected quality tier (from account settings)
quality_tier = AISettings.get_effective_quality_tier(account)
logger.info(f"[process_image_generation_queue] User's quality tier: {quality_tier}")
# Find image model based on quality tier (DYNAMIC from database)
model_config = None
if quality_tier:
model_config = AIModelConfig.objects.filter(
model_type='image',
quality_tier=quality_tier,
is_active=True
).first()
# Fallback to default image model if no tier match
if not model_config:
default_model = ModelRegistry.get_default_model('image')
if default_model:
model_config = ModelRegistry.get_model(default_model)
# Set provider and model from database config
if model_config:
provider = model_config.provider or 'openai'
model = model_config.model_name
else:
# Ultimate fallback (should never happen if database is configured)
logger.warning("[process_image_generation_queue] No image model found in database, using fallback")
provider = 'openai'
model = 'dall-e-3'
logger.info(f"[process_image_generation_queue] Using PROVIDER: {provider}, MODEL: {model} from settings")
logger.info(f"[process_image_generation_queue] Using PROVIDER: {provider}, MODEL: {model} from AIModelConfig")
# Style to prompt enhancement mapping
# These style descriptors are added to the image prompt for better results
@@ -224,25 +245,23 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
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')
# Load image sizes from AIModelConfig (DYNAMIC from database)
# model_config was loaded above based on quality tier
if model_config:
# Get sizes from database (single source of truth)
model_landscape_size = model_config.landscape_size or '1792x1024'
model_square_size = model_config.square_size or '1024x1024'
logger.info(f"[process_image_generation_queue] Loaded sizes from AIModelConfig: landscape={model_landscape_size}, square={model_square_size}")
else:
# Fallback sizes if no model config (should never happen)
model_landscape_size = '1792x1024'
model_square_size = '1024x1024'
logger.warning(f"[process_image_generation_queue] No model config, using fallback sizes")
# 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_square_size = model_square_size
in_article_landscape_size = model_landscape_size
logger.info(f"[process_image_generation_queue] Settings loaded:")
@@ -285,7 +304,38 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
# Initialize AICore
ai_core = AICore(account=account)
# Credit check before processing images
if account:
logger.info(f"[process_image_generation_queue] Step 3: Checking credits for {total_images} images")
try:
required_credits = CreditService.check_credits_for_image(
account=account,
model_name=model,
num_images=total_images
)
logger.info(f"[process_image_generation_queue] Credit check passed: {required_credits} credits required, {account.credits} available")
except InsufficientCreditsError as e:
error_msg = str(e)
logger.error(f"[process_image_generation_queue] Insufficient credits: {error_msg}")
return {
'success': False,
'error': error_msg,
'error_type': 'InsufficientCreditsError',
'total_images': total_images,
'completed': 0,
'failed': total_images,
'results': []
}
except CreditCalculationError as e:
# Model not found or no credits_per_image configured - log warning but continue
# This allows backward compatibility if model not configured
logger.warning(f"[process_image_generation_queue] Credit calculation warning: {e}")
logger.warning(f"[process_image_generation_queue] Proceeding without credit check (model may not be configured)")
except Exception as e:
# Don't fail for unexpected credit check errors - log and continue
logger.warning(f"[process_image_generation_queue] Unexpected credit check error: {e}")
# Process each image sequentially
for index, image_id in enumerate(image_ids, 1):
try:
@@ -712,7 +762,7 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
else:
# Deduct credits for successful image generation
credits_deducted = 0
cost_usd = result.get('cost_usd', 0)
cost_usd = result.get('cost', 0) or result.get('cost_usd', 0) # generate_image returns 'cost'
if account:
try:
credits_deducted = CreditService.deduct_credits_for_image(
@@ -802,7 +852,66 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
logger.info(f" - Completed: {completed}")
logger.info(f" - Failed: {failed}")
logger.info("=" * 80)
# Log to AITaskLog for consistency with other AI functions
if account:
try:
from igny8_core.ai.models import AITaskLog
import time
# Calculate total cost from results
total_cost = sum(r.get('cost', 0) for r in results if r.get('status') == 'completed')
AITaskLog.objects.create(
task_id=self.request.id,
function_name='generate_images',
account=account,
phase='DONE' if completed > 0 else 'ERROR',
message=f'Generated {completed} images ({failed} failed)' if completed > 0 else f'All {total_images} images failed',
status='success' if completed > 0 else 'error',
duration=0, # Could track actual duration if needed
cost=total_cost,
tokens=0, # Image generation doesn't use tokens
request_steps=[{
'phase': 'IMAGE_GENERATION',
'status': 'success' if completed > 0 else 'error',
'message': f'Processed {total_images} images: {completed} completed, {failed} failed'
}],
response_steps=[],
error=None if completed > 0 else f'All {total_images} images failed',
payload={'image_ids': image_ids, 'content_id': content_id},
result={
'total_images': total_images,
'completed': completed,
'failed': failed,
'model': model,
'provider': provider
}
)
logger.info(f"[process_image_generation_queue] AITaskLog entry created")
except Exception as log_error:
logger.warning(f"[process_image_generation_queue] Failed to create AITaskLog: {log_error}")
# Create notification for image generation completion
if account:
try:
from igny8_core.business.notifications.services import NotificationService
if completed > 0:
NotificationService.notify_images_complete(
account=account,
image_count=completed
)
elif failed > 0:
NotificationService.notify_images_failed(
account=account,
error=f'{failed} images failed to generate',
image_count=failed
)
logger.info(f"[process_image_generation_queue] Notification created")
except Exception as notif_error:
logger.warning(f"[process_image_generation_queue] Failed to create notification: {notif_error}")
return {
'success': True,
'total_images': total_images,

View File

@@ -303,6 +303,10 @@ class DashboardStatsViewSet(viewsets.ViewSet):
account=account,
created_at__gte=start_date
)
# Filter by site if provided
if site_id:
usage_query = usage_query.filter(site_id=site_id)
# Get operations grouped by type
operations_data = usage_query.values('operation_type').annotate(

View File

@@ -2,14 +2,14 @@
Admin interface for auth models
"""
from django import forms
from django.contrib import admin
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from unfold.admin import ModelAdmin, TabularInline
from simple_history.admin import SimpleHistoryAdmin
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword, PasswordResetToken
from import_export.admin import ExportMixin, ImportExportMixin
from import_export import resources
from import_export import resources, fields, widgets
class AccountAdminForm(forms.ModelForm):
@@ -676,15 +676,36 @@ class SiteAdmin(ImportExportMixin, AccountAdminMixin, Igny8ModelAdmin):
get_api_key_status.short_description = 'API Key'
def generate_api_keys(self, request, queryset):
"""Generate API keys for selected sites"""
"""Generate API keys for selected sites. API key is stored ONLY in Site.wp_api_key (single source of truth)."""
import secrets
from igny8_core.business.integration.models import SiteIntegration
updated_count = 0
for site in queryset:
if not site.wp_api_key:
site.wp_api_key = f"igny8_{''.join(secrets.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(40))}"
api_key = f"igny8_{''.join(secrets.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(40))}"
# SINGLE SOURCE OF TRUTH: Store API key ONLY in Site.wp_api_key
site.wp_api_key = api_key
site.save()
# Ensure SiteIntegration exists for status tracking (without API key)
SiteIntegration.objects.get_or_create(
site=site,
platform='wordpress',
defaults={
'account': site.account,
'platform': 'wordpress',
'platform_type': 'cms',
'is_active': True,
'sync_enabled': True,
'credentials_json': {}, # Empty - API key is on Site model
'config_json': {}
}
)
updated_count += 1
self.message_user(request, f'Generated API keys for {updated_count} site(s). Sites with existing keys were skipped.')
self.message_user(request, f'Generated API keys for {updated_count} site(s). API keys stored in Site.wp_api_key (single source of truth).')
generate_api_keys.short_description = 'Generate WordPress API Keys'
def bulk_set_status_active(self, request, queryset):
@@ -877,7 +898,10 @@ class IndustrySectorResource(resources.ModelResource):
class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin):
resource_class = IndustrySectorResource
list_display = ['name', 'slug', 'industry', 'is_active']
list_filter = ['is_active', 'industry']
list_filter = [
('is_active', ChoicesDropdownFilter),
('industry', RelatedDropdownFilter),
]
search_fields = ['name', 'slug', 'description']
readonly_fields = ['created_at', 'updated_at']
actions = [
@@ -903,13 +927,53 @@ class IndustrySectorAdmin(ImportExportMixin, Igny8ModelAdmin):
class SeedKeywordResource(resources.ModelResource):
"""Resource class for importing/exporting Seed Keywords"""
industry = fields.Field(
column_name='industry',
attribute='industry',
widget=widgets.ForeignKeyWidget(Industry, 'name')
)
sector = fields.Field(
column_name='sector',
attribute='sector',
widget=widgets.ForeignKeyWidget(IndustrySector, 'name')
)
class Meta:
model = SeedKeyword
fields = ('id', 'keyword', 'industry__name', 'sector__name', 'volume',
fields = ('id', 'keyword', 'industry', 'sector', 'volume',
'difficulty', 'country', 'is_active', 'created_at')
export_order = fields
import_id_fields = ('id',)
import_id_fields = ('keyword', 'industry', 'sector') # Use natural keys for import
skip_unchanged = True
def before_import_row(self, row, **kwargs):
"""Clean and validate row data before import"""
# Ensure volume is an integer
if 'volume' in row:
try:
row['volume'] = int(row['volume']) if row['volume'] else 0
except (ValueError, TypeError):
row['volume'] = 0
# Ensure difficulty is an integer between 0-100
if 'difficulty' in row:
try:
difficulty = int(row['difficulty']) if row['difficulty'] else 0
row['difficulty'] = max(0, min(100, difficulty)) # Clamp to 0-100
except (ValueError, TypeError):
row['difficulty'] = 0
# Ensure country is valid
if 'country' in row:
valid_countries = [code for code, name in SeedKeyword.COUNTRY_CHOICES]
if row['country'] not in valid_countries:
row['country'] = 'US' # Default to US if invalid
# Set defaults for optional fields
if 'is_active' not in row or row['is_active'] == '':
row['is_active'] = True
return row
@admin.register(SeedKeyword)
@@ -921,11 +985,10 @@ class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin):
search_fields = ['keyword']
readonly_fields = ['created_at', 'updated_at']
actions = [
'delete_selected',
'bulk_activate',
'bulk_deactivate',
'bulk_update_country',
] # Enable bulk delete
]
fieldsets = (
('Keyword Info', {
@@ -939,18 +1002,38 @@ class SeedKeywordAdmin(ImportExportMixin, Igny8ModelAdmin):
}),
)
def has_delete_permission(self, request, obj=None):
"""Allow deletion for superusers and developers"""
return request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer())
def bulk_activate(self, request, queryset):
updated = queryset.update(is_active=True)
self.message_user(request, f'{updated} seed keyword(s) activated.', messages.SUCCESS)
"""Activate selected keywords"""
try:
updated = queryset.update(is_active=True)
self.message_user(
request,
f'{updated} seed keyword(s) activated successfully.',
messages.SUCCESS
)
except Exception as e:
self.message_user(
request,
f'Error activating keywords: {str(e)}',
messages.ERROR
)
bulk_activate.short_description = 'Activate selected keywords'
def bulk_deactivate(self, request, queryset):
updated = queryset.update(is_active=False)
self.message_user(request, f'{updated} seed keyword(s) deactivated.', messages.SUCCESS)
"""Deactivate selected keywords"""
try:
updated = queryset.update(is_active=False)
self.message_user(
request,
f'{updated} seed keyword(s) deactivated successfully.',
messages.SUCCESS
)
except Exception as e:
self.message_user(
request,
f'Error deactivating keywords: {str(e)}',
messages.ERROR
)
bulk_deactivate.short_description = 'Deactivate selected keywords'
def bulk_update_country(self, request, queryset):

View File

@@ -15,8 +15,8 @@ from igny8_core.business.automation.models import AutomationRun, AutomationConfi
from igny8_core.business.automation.services.automation_logger import AutomationLogger
from django.conf import settings
from igny8_core.auth.models import Account, Site
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
from igny8_core.modules.writer.models import Tasks, Content, Images
from igny8_core.business.planning.models import Keywords, Clusters, ContentIdeas
from igny8_core.business.content.models import Tasks, Content, Images
from igny8_core.ai.models import AITaskLog
from igny8_core.ai.engine import AIEngine
@@ -1627,44 +1627,66 @@ class AutomationService:
stage_number, approved_count, time_elapsed, 0
)
# Check if auto-publish is enabled and queue approved content for publishing
published_count = 0
# Check if auto-publish is enabled and schedule approved content for publishing
scheduled_count = 0
if publishing_settings.auto_publish_enabled and approved_count > 0:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Auto-publish enabled - queuing {len(content_ids)} content items for publishing"
stage_number, f"Auto-publish enabled - scheduling {len(content_ids)} content items for publishing"
)
# Get WordPress integration for this site
from igny8_core.business.integration.models import SiteIntegration
wp_integration = SiteIntegration.objects.filter(
site=self.site,
platform='wordpress',
is_active=True
).first()
if wp_integration:
from igny8_core.tasks.wordpress_publishing import publish_content_to_wordpress
try:
# Import scheduling helper function
from igny8_core.tasks.publishing_scheduler import _calculate_available_slots
for content_id in content_ids:
try:
# Queue publish task
publish_content_to_wordpress.delay(
content_id=content_id,
site_integration_id=wp_integration.id
)
published_count += 1
except Exception as e:
logger.error(f"[AutomationService] Failed to queue publish for content {content_id}: {str(e)}")
# Get approved content that needs scheduling
approved_content = Content.objects.filter(
id__in=content_ids,
status='approved',
site_status='not_published',
scheduled_publish_at__isnull=True
).order_by('created_at')
if approved_content.exists():
# Calculate available publishing slots
available_slots = _calculate_available_slots(publishing_settings, self.site)
# Assign slots to content
from django.utils import timezone
for i, content in enumerate(approved_content):
if i >= len(available_slots):
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"No more publishing slots available - {scheduled_count} scheduled, {len(content_ids) - scheduled_count} will be scheduled later"
)
break
# Schedule this content
scheduled_time = available_slots[i]
content.scheduled_publish_at = scheduled_time
content.site_status = 'scheduled'
content.site_status_updated_at = timezone.now()
content.save(update_fields=['scheduled_publish_at', 'site_status', 'site_status_updated_at'])
scheduled_count += 1
logger.info(f"[AutomationService] Scheduled content {content.id} '{content.title}' for {scheduled_time}")
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Scheduled {scheduled_count} content items for automatic publishing"
)
else:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, "Approved content already scheduled or published"
)
except Exception as e:
error_msg = f"Failed to schedule content for publishing: {str(e)}"
logger.error(f"[AutomationService] {error_msg}", exc_info=True)
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, f"Queued {published_count} content items for WordPress publishing"
)
else:
self.logger.log_stage_progress(
self.run.run_id, self.account.id, self.site.id,
stage_number, "No active WordPress integration found - skipping auto-publish"
stage_number, error_msg
)
self.run.stage_7_result = {
@@ -1674,7 +1696,7 @@ class AutomationService:
'content_ids': content_ids,
'time_elapsed': time_elapsed,
'in_progress': False,
'auto_published_count': published_count if publishing_settings.auto_publish_enabled else 0,
'scheduled_count': scheduled_count if publishing_settings.auto_publish_enabled else 0,
'auto_publish_enabled': publishing_settings.auto_publish_enabled,
}
self.run.status = 'completed'
@@ -1685,7 +1707,7 @@ class AutomationService:
cache.delete(f'automation_lock_{self.site.id}')
logger.info(f"[AutomationService] Stage 7 complete: {approved_count} content pieces approved" +
(f", {published_count} queued for publishing" if published_count > 0 else " (ready for publishing)"))
(f", {scheduled_count} scheduled for publishing" if scheduled_count > 0 else " (ready for manual publishing)"))
def pause_automation(self):
"""Pause current automation run"""

View File

@@ -516,6 +516,57 @@ class AutomationViewSet(viewsets.ViewSet):
]
})
@extend_schema(tags=['Automation'])
@action(detail=False, methods=['get'])
def eligibility(self, request):
"""
GET /api/v1/automation/eligibility/?site_id=123
Check if site is eligible for automation.
A site is eligible if it has ANY data in the pipeline:
- At least one keyword, OR
- At least one cluster, OR
- At least one idea, OR
- At least one task, OR
- At least one content item, OR
- At least one image
Sites with zero data across ALL entities are not eligible.
"""
site, error_response = self._get_site(request)
if error_response:
return error_response
from igny8_core.business.planning.models import Keywords, Clusters, ContentIdeas
from igny8_core.business.content.models import Tasks, Content, Images
# Check total counts for each entity
keywords_total = Keywords.objects.filter(site=site, disabled=False).count()
clusters_total = Clusters.objects.filter(site=site, disabled=False).count()
ideas_total = ContentIdeas.objects.filter(site=site).count()
tasks_total = Tasks.objects.filter(site=site).count()
content_total = Content.objects.filter(site=site).count()
images_total = Images.objects.filter(site=site).count()
# Site is eligible if ANY of these totals is > 0
total_items = keywords_total + clusters_total + ideas_total + tasks_total + content_total + images_total
is_eligible = total_items > 0
# Provide details for the UI
return Response({
'is_eligible': is_eligible,
'totals': {
'keywords': keywords_total,
'clusters': clusters_total,
'ideas': ideas_total,
'tasks': tasks_total,
'content': content_total,
'images': images_total,
},
'total_items': total_items,
'message': None if is_eligible else 'This site has no data yet. Add keywords in the Planner module to get started with automation.'
})
@extend_schema(tags=['Automation'])
@action(detail=False, methods=['get'], url_path='current_processing')
def current_processing(self, request):

View File

@@ -85,7 +85,16 @@ class CreditUsageLog(AccountBaseModel):
('content', 'Content Generation'), # Legacy
('images', 'Image Generation'), # Legacy
]
# Site relationship - stored at creation time for proper filtering
site = models.ForeignKey(
'igny8_core_auth.Site',
on_delete=models.CASCADE,
null=True,
blank=True,
help_text='Site where the operation was performed'
)
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES, db_index=True)
credits_used = models.IntegerField(validators=[MinValueValidator(0)])
cost_usd = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
@@ -105,6 +114,8 @@ class CreditUsageLog(AccountBaseModel):
models.Index(fields=['account', 'operation_type']),
models.Index(fields=['account', 'created_at']),
models.Index(fields=['account', 'operation_type', 'created_at']),
models.Index(fields=['site', 'created_at']),
models.Index(fields=['account', 'site', 'created_at']),
]
def __str__(self):
@@ -736,6 +747,7 @@ class AIModelConfig(models.Model):
QUALITY_TIER_CHOICES = [
('basic', 'Basic'),
('quality', 'Quality'),
('quality_option2', 'Quality-Option2'),
('premium', 'Premium'),
]
@@ -816,6 +828,27 @@ class AIModelConfig(models.Model):
help_text="basic / quality / premium - for image models"
)
# Image Size Configuration (for image models)
landscape_size = models.CharField(
max_length=20,
null=True,
blank=True,
help_text="Landscape image size for this model (e.g., '1792x1024', '1280x768')"
)
square_size = models.CharField(
max_length=20,
default='1024x1024',
blank=True,
help_text="Square image size for this model (e.g., '1024x1024')"
)
valid_sizes = models.JSONField(
default=list,
blank=True,
help_text="List of valid sizes for this model (e.g., ['1024x1024', '1792x1024'])"
)
# Model Limits
max_tokens = models.IntegerField(
null=True,
@@ -884,6 +917,21 @@ class AIModelConfig(models.Model):
model_type='image',
is_active=True
).order_by('quality_tier', 'model_name')
def validate_size(self, size: str) -> bool:
"""Validate that the given size is valid for this image model"""
if not self.valid_sizes:
# If no valid_sizes defined, accept common sizes
return True
return size in self.valid_sizes
def get_landscape_size(self) -> str:
"""Get the landscape size for this model"""
return self.landscape_size or '1792x1024'
def get_square_size(self) -> str:
"""Get the square size for this model"""
return self.square_size or '1024x1024'
class WebhookEvent(models.Model):

View File

@@ -285,13 +285,13 @@ class CreditService:
def check_credits_for_tokens(account, operation_type, estimated_tokens_input, estimated_tokens_output):
"""
Check if account has sufficient credits based on estimated token usage.
Args:
account: Account instance
operation_type: Type of operation
estimated_tokens_input: Estimated input tokens
estimated_tokens_output: Estimated output tokens
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
@@ -303,13 +303,43 @@ class CreditService:
f"Insufficient credits. Required: {required}, Available: {account.credits}"
)
return True
@staticmethod
def check_credits_for_image(account, model_name: str, num_images: int = 1):
"""
Check if account has sufficient credits for image generation.
Args:
account: Account instance
model_name: AI model name (e.g., 'dall-e-3', 'runware:97@1')
num_images: Number of images to generate
Raises:
InsufficientCreditsError: If account doesn't have enough credits
CreditCalculationError: If model not found or has no credits_per_image
Returns:
int: Required credits for the operation
"""
required = CreditService.calculate_credits_for_image(model_name, num_images)
if account.credits < required:
raise InsufficientCreditsError(
f"Insufficient credits for image generation. Required: {required}, Available: {account.credits}"
)
logger.info(
f"Credit check passed for image generation: "
f"{num_images} images with {model_name} = {required} credits (available: {account.credits})"
)
return required
@staticmethod
@transaction.atomic
def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None, site=None):
"""
Deduct credits and log transaction.
Args:
account: Account instance
amount: Number of credits to deduct
@@ -322,20 +352,21 @@ class CreditService:
tokens_output: Optional output tokens
related_object_type: Optional related object type
related_object_id: Optional related object ID
site: Optional Site instance or site_id
Returns:
int: New credit balance
"""
# 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'])
# Create CreditTransaction
CreditTransaction.objects.create(
account=account,
@@ -345,10 +376,11 @@ class CreditService:
description=description,
metadata=metadata or {}
)
# Create CreditUsageLog
CreditUsageLog.objects.create(
account=account,
site=site,
operation_type=operation_type,
credits_used=amount,
cost_usd=cost_usd,
@@ -359,30 +391,31 @@ class CreditService:
related_object_id=related_object_id,
metadata=metadata or {}
)
# Check and send low credits warning if applicable
_check_low_credits_warning(account, previous_balance)
return account.credits
@staticmethod
@transaction.atomic
def deduct_credits_for_operation(
account,
operation_type,
tokens_input,
account,
operation_type,
tokens_input,
tokens_output,
description=None,
metadata=None,
cost_usd=None,
model_used=None,
related_object_type=None,
related_object_id=None
description=None,
metadata=None,
cost_usd=None,
model_used=None,
related_object_type=None,
related_object_id=None,
site=None
):
"""
Deduct credits for an operation based on actual token usage.
This is the ONLY way to deduct credits in the token-based system.
Args:
account: Account instance
operation_type: Type of operation
@@ -394,10 +427,11 @@ class CreditService:
model_used: Optional AI model used
related_object_type: Optional related object type
related_object_id: Optional related object ID
site: Optional Site instance or site_id
Returns:
int: New credit balance
Raises:
ValueError: If tokens_input or tokens_output not provided
"""
@@ -407,18 +441,18 @@ class CreditService:
f"tokens_input and tokens_output are REQUIRED for credit deduction. "
f"Got: tokens_input={tokens_input}, tokens_output={tokens_output}"
)
# Calculate credits from actual token usage
credits_required = CreditService.calculate_credits_from_tokens(
operation_type, tokens_input, tokens_output
)
# Check sufficient credits
if account.credits < credits_required:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {credits_required}, Available: {account.credits}"
)
# Auto-generate description if not provided
if not description:
total_tokens = tokens_input + tokens_output
@@ -426,7 +460,7 @@ class CreditService:
f"{operation_type}: {total_tokens} tokens "
f"({tokens_input} in, {tokens_output} out) = {credits_required} credits"
)
return CreditService.deduct_credits(
account=account,
amount=credits_required,
@@ -438,7 +472,8 @@ class CreditService:
tokens_input=tokens_input,
tokens_output=tokens_output,
related_object_type=related_object_type,
related_object_id=related_object_id
related_object_id=related_object_id,
site=site
)
@staticmethod
@@ -483,11 +518,12 @@ class CreditService:
metadata: dict = None,
cost_usd: float = None,
related_object_type: str = None,
related_object_id: int = None
related_object_id: int = None,
site = 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')
@@ -497,20 +533,21 @@ class CreditService:
cost_usd: Optional cost in USD
related_object_type: Optional related object type
related_object_id: Optional related object ID
site: Optional Site instance or site_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,
@@ -522,6 +559,7 @@ class CreditService:
tokens_input=None,
tokens_output=None,
related_object_type=related_object_type,
related_object_id=related_object_id
related_object_id=related_object_id,
site=site
)

View File

@@ -244,14 +244,15 @@ class IntegrationService:
}
}
# Get API key from site
# Get API key from Site.wp_api_key (SINGLE source of truth)
# API key is stored on Site model for authentication by APIKeyAuthentication
api_key = integration.site.wp_api_key
if not api_key:
return {
'success': False,
'message': 'API key not configured.',
'details': {}
'message': 'API key not configured. Generate an API key in Site Settings.',
'details': {'site_id': integration.site.id, 'site_name': integration.site.name}
}
# Initialize health check results

View File

@@ -39,8 +39,8 @@ class SyncMetadataService:
try:
# Get WordPress site URL and API key
site_url = integration.config_json.get('site_url', '')
credentials = integration.get_credentials()
api_key = credentials.get('api_key', '')
# API key is stored in Site.wp_api_key (SINGLE source of truth)
api_key = integration.site.wp_api_key or ''
if not site_url:
return {
@@ -51,7 +51,7 @@ class SyncMetadataService:
if not api_key:
return {
'success': False,
'error': 'Missing api_key in integration credentials'
'error': 'API key not configured for site. Generate one in Site Settings.'
}
# Call WordPress metadata endpoint

View File

@@ -142,44 +142,67 @@ class Keywords(SoftDeletableModel, SiteSectorBaseModel):
@property
def keyword(self):
"""Get keyword text from seed_keyword"""
return self.seed_keyword.keyword if self.seed_keyword else ''
try:
return self.seed_keyword.keyword if self.seed_keyword else ''
except self.__class__.seed_keyword.RelatedObjectDoesNotExist:
return ''
@property
def volume(self):
"""Get volume from override or seed_keyword"""
return self.volume_override if self.volume_override is not None else (self.seed_keyword.volume if self.seed_keyword else 0)
try:
seed_kw = self.seed_keyword
except self.__class__.seed_keyword.RelatedObjectDoesNotExist:
seed_kw = None
return self.volume_override if self.volume_override is not None else (seed_kw.volume if seed_kw else 0)
@property
def difficulty(self):
"""Get difficulty from override or seed_keyword"""
return self.difficulty_override if self.difficulty_override is not None else (self.seed_keyword.difficulty if self.seed_keyword else 0)
try:
seed_kw = self.seed_keyword
except self.__class__.seed_keyword.RelatedObjectDoesNotExist:
seed_kw = None
return self.difficulty_override if self.difficulty_override is not None else (seed_kw.difficulty if seed_kw else 0)
@property
def country(self):
"""Get country from seed_keyword"""
return self.seed_keyword.country if self.seed_keyword else 'US'
try:
return self.seed_keyword.country if self.seed_keyword else 'US'
except self.__class__.seed_keyword.RelatedObjectDoesNotExist:
return 'US'
def save(self, *args, **kwargs):
"""Validate that seed_keyword's industry/sector matches site's industry/sector"""
if self.seed_keyword and self.site and self.sector:
# Skip validation if seed_keyword is None (during soft delete or orphaned)
try:
seed_kw = self.seed_keyword
except self.__class__.seed_keyword.RelatedObjectDoesNotExist:
seed_kw = None
if seed_kw and self.site and self.sector:
# Validate industry match
if self.site.industry != self.seed_keyword.industry:
if self.site.industry != seed_kw.industry:
from django.core.exceptions import ValidationError
raise ValidationError(
f"SeedKeyword industry ({self.seed_keyword.industry.name}) must match site industry ({self.site.industry.name})"
f"SeedKeyword industry ({seed_kw.industry.name}) must match site industry ({self.site.industry.name})"
)
# Validate sector match (site sector's industry_sector must match seed_keyword's sector)
if self.sector.industry_sector != self.seed_keyword.sector:
if self.sector.industry_sector != seed_kw.sector:
from django.core.exceptions import ValidationError
raise ValidationError(
f"SeedKeyword sector ({self.seed_keyword.sector.name}) must match site sector's industry sector ({self.sector.industry_sector.name if self.sector.industry_sector else 'None'})"
f"SeedKeyword sector ({seed_kw.sector.name}) must match site sector's industry sector ({self.sector.industry_sector.name if self.sector.industry_sector else 'None'})"
)
super().save(*args, **kwargs)
def __str__(self):
return self.keyword
try:
return self.seed_keyword.keyword if self.seed_keyword else f'Keyword #{self.pk}'
except self.__class__.seed_keyword.RelatedObjectDoesNotExist:
return f'Keyword #{self.pk} (orphaned)'
class ContentIdeas(SoftDeletableModel, SiteSectorBaseModel):

View File

@@ -257,12 +257,16 @@ class WordPressAdapter(BaseAdapter):
featured_image_url = image_url
logger.info(f"[WordPressAdapter._publish_via_api_key] 🖼️ Featured image: {image_url[:80]}...")
elif image.image_type == 'in_article' and image_url:
is_featured = False # In-article images are never featured
gallery_images.append({
'url': image_url,
'alt': getattr(image, 'alt', '') or '',
'caption': getattr(image, 'caption', '') or ''
'caption': getattr(image, 'caption', '') or '',
'prompt': getattr(image, 'prompt', '') or '',
'position': getattr(image, 'position', 0),
'is_featured': is_featured
})
logger.info(f"[WordPressAdapter._publish_via_api_key] 🖼️ Gallery image {len(gallery_images)}")
logger.info(f"[WordPressAdapter._publish_via_api_key] 🖼️ Gallery image {len(gallery_images)} (pos={getattr(image, 'position', 0)}, prompt={bool(getattr(image, 'prompt', ''))})")
except Exception as e:
logger.warning(f"[WordPressAdapter._publish_via_api_key] ⚠️ Could not load images: {e}")

View File

@@ -139,9 +139,13 @@ class PublisherService:
if integration:
logger.info(f"[PublisherService._publish_to_destination] ✅ Integration found: id={integration.id}")
# Merge config_json and credentials_json
# Merge config_json (site_url, etc.)
destination_config.update(integration.config_json or {})
destination_config.update(integration.get_credentials() or {})
# API key is stored in Site.wp_api_key (SINGLE source of truth)
if integration.site.wp_api_key:
destination_config['api_key'] = integration.site.wp_api_key
logger.info(f"[PublisherService._publish_to_destination] 🔑 Config merged: has_api_key={bool(destination_config.get('api_key'))}, has_site_url={bool(destination_config.get('site_url'))}")
# Ensure site_url is set (from config or from site model)
@@ -342,13 +346,16 @@ class PublisherService:
destinations = []
for integration in integrations:
config = integration.config_json.copy()
credentials = integration.get_credentials()
destination_config = {
'platform': integration.platform,
**config,
**credentials
}
# API key is stored in Site.wp_api_key (SINGLE source of truth)
if integration.site.wp_api_key:
destination_config['api_key'] = integration.site.wp_api_key
destinations.append(destination_config)
# Also add 'sites' destination if not in platforms filter or if platforms is None

View File

@@ -0,0 +1,100 @@
"""
Management command to ensure SiteIntegration records exist for sites with WordPress API keys.
API key is stored ONLY in Site.wp_api_key (single source of truth).
SiteIntegration is used for integration status/config only, NOT for credential storage.
"""
from django.core.management.base import BaseCommand
from igny8_core.auth.models import Site
from igny8_core.business.integration.models import SiteIntegration
class Command(BaseCommand):
help = 'Ensure SiteIntegration records exist for sites with WordPress API keys'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be done without making changes',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN MODE - No changes will be made'))
# Find all sites with wp_api_key
sites_with_key = Site.objects.filter(wp_api_key__isnull=False).exclude(wp_api_key='')
self.stdout.write(f'Found {sites_with_key.count()} sites with wp_api_key')
created_count = 0
already_exists_count = 0
cleared_credentials_count = 0
for site in sites_with_key:
try:
# Check if SiteIntegration exists
integration = SiteIntegration.objects.filter(
site=site,
platform='wordpress'
).first()
if integration:
# Check if credentials_json has api_key (should be cleared)
if integration.credentials_json.get('api_key'):
if not dry_run:
integration.credentials_json = {} # Clear - API key is on Site model
integration.save(update_fields=['credentials_json'])
self.stdout.write(
self.style.WARNING(
f'{site.name} (ID: {site.id}) - Cleared api_key from credentials_json (now stored in Site.wp_api_key only)'
)
)
cleared_credentials_count += 1
else:
self.stdout.write(
self.style.SUCCESS(
f'{site.name} (ID: {site.id}) - Integration exists, API key correctly stored in Site.wp_api_key only'
)
)
already_exists_count += 1
else:
# Create new SiteIntegration (for status tracking, not credentials)
if not dry_run:
integration = SiteIntegration.objects.create(
account=site.account,
site=site,
platform='wordpress',
platform_type='cms',
is_active=True,
sync_enabled=False, # Don't enable sync by default
credentials_json={}, # Empty - API key is on Site model
config_json={}
)
self.stdout.write(
self.style.SUCCESS(
f' + {site.name} (ID: {site.id}) - Created SiteIntegration (API key stays in Site.wp_api_key)'
)
)
created_count += 1
except Exception as e:
self.stdout.write(
self.style.ERROR(
f'{site.name} (ID: {site.id}) - Error: {str(e)}'
)
)
# Summary
self.stdout.write('\\n' + '='*60)
self.stdout.write(self.style.SUCCESS(f'Summary:'))
self.stdout.write(f' Created: {created_count}')
self.stdout.write(f' Cleared credentials_json: {cleared_credentials_count}')
self.stdout.write(f' Already correct: {already_exists_count}')
self.stdout.write(f' Total processed: {created_count + cleared_credentials_count + already_exists_count}')
if dry_run:
self.stdout.write('\\n' + self.style.WARNING('DRY RUN - Run without --dry-run to apply changes'))

View File

@@ -20,7 +20,6 @@ from igny8_core.business.billing.models import (
from .models import CreditTransaction, CreditUsageLog, AccountPaymentMethod
from import_export.admin import ExportMixin, ImportExportMixin
from import_export import resources
from rangefilter.filters import DateRangeFilter
class CreditTransactionResource(resources.ModelResource):
@@ -36,7 +35,7 @@ class CreditTransactionResource(resources.ModelResource):
class CreditTransactionAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
resource_class = CreditTransactionResource
list_display = ['id', 'account', 'transaction_type', 'amount', 'balance_after', 'description', 'created_at']
list_filter = ['transaction_type', ('created_at', DateRangeFilter), 'account']
list_filter = ['transaction_type', 'created_at', 'account']
search_fields = ['description', 'account__name']
readonly_fields = ['created_at']
date_hierarchy = 'created_at'
@@ -188,7 +187,7 @@ class PaymentAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
'approved_by',
'processed_at',
]
list_filter = ['status', 'payment_method', 'currency', ('created_at', DateRangeFilter), ('processed_at', DateRangeFilter)]
list_filter = ['status', 'payment_method', 'currency', 'created_at', 'processed_at']
search_fields = [
'invoice__invoice_number',
'account__name',
@@ -653,12 +652,7 @@ class PlanLimitUsageAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
'period_display',
'created_at',
]
list_filter = [
'limit_type',
('period_start', DateRangeFilter),
('period_end', DateRangeFilter),
'account',
]
list_filter = ['limit_type', 'period_start', 'period_end', 'account']
search_fields = ['account__name']
readonly_fields = ['created_at', 'updated_at']
date_hierarchy = 'period_start'
@@ -800,6 +794,11 @@ class AIModelConfigAdmin(SimpleHistoryAdmin, Igny8ModelAdmin):
'description': 'For IMAGE models only',
'classes': ('collapse',)
}),
('Image Model Sizes', {
'fields': ('landscape_size', 'square_size', 'valid_sizes'),
'description': 'For IMAGE models: specify supported image dimensions',
'classes': ('collapse',)
}),
('Capabilities', {
'fields': ('capabilities',),
'description': 'JSON: vision, function_calling, json_mode, etc.',

View File

@@ -0,0 +1,51 @@
# Generated manually - Add image size fields to AIModelConfig
from django.db import migrations, models
class Migration(migrations.Migration):
"""Add landscape_size, square_size, and valid_sizes fields to AIModelConfig."""
dependencies = [
('billing', '0029_add_webhook_event_and_manual_reference_constraint'),
]
operations = [
# Declare the fields in Django schema so migrations can use them
# These fields already exist in DB, so state_operations syncs Django's understanding
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.AddField(
model_name='aimodelconfig',
name='landscape_size',
field=models.CharField(
blank=True,
help_text="Landscape image size for this model (e.g., '1792x1024', '1280x768')",
max_length=20,
null=True,
),
),
migrations.AddField(
model_name='aimodelconfig',
name='square_size',
field=models.CharField(
blank=True,
default='1024x1024',
help_text="Square image size for this model (e.g., '1024x1024')",
max_length=20,
),
),
migrations.AddField(
model_name='aimodelconfig',
name='valid_sizes',
field=models.JSONField(
blank=True,
default=list,
help_text="List of valid sizes for this model (e.g., ['1024x1024', '1792x1024'])",
),
),
],
database_operations=[
# No DB operations - fields already exist via direct SQL
],
),
]

View File

@@ -0,0 +1,80 @@
# Generated manually - Add Seedream 4.5 image model and update quality_tier choices
from decimal import Decimal
from django.db import migrations, models
def add_seedream_model(apps, schema_editor):
"""
Add ByteDance Seedream 4.5 image model via Runware.
Model specs:
- model: bytedance:seedream@4.5
- Square size: 2048x2048
- Landscape size: 2304x1728
- Quality tier: quality_option2
- Credits per image: 5
"""
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
AIModelConfig.objects.update_or_create(
model_name='bytedance:seedream@4.5',
defaults={
'display_name': 'Seedream 4.5 - High Quality',
'model_type': 'image',
'provider': 'runware',
'is_default': False,
'is_active': True,
'credits_per_image': 5,
'quality_tier': 'quality_option2',
'landscape_size': '2304x1728',
'square_size': '2048x2048',
'valid_sizes': ['2048x2048', '2304x1728', '2560x1440', '1728x2304', '1440x2560'],
'capabilities': {
'max_sequential_images': 4,
'high_resolution': True,
'provider_settings': {
'bytedance': {
'maxSequentialImages': 4
}
}
},
}
)
print("✅ Added Seedream 4.5 image model")
def reverse_migration(apps, schema_editor):
"""Remove Seedream model"""
AIModelConfig = apps.get_model('billing', 'AIModelConfig')
AIModelConfig.objects.filter(model_name='bytedance:seedream@4.5').delete()
print("❌ Removed Seedream 4.5 image model")
class Migration(migrations.Migration):
"""Add Seedream 4.5 image model and update quality_tier choices."""
dependencies = [
('billing', '0030_add_aimodel_image_sizes'),
]
operations = [
# Update quality_tier field choices to include quality_option2
migrations.AlterField(
model_name='aimodelconfig',
name='quality_tier',
field=models.CharField(
blank=True,
choices=[
('basic', 'Basic'),
('quality', 'Quality'),
('quality_option2', 'Quality-Option2'),
('premium', 'Premium'),
],
help_text='basic / quality / quality_option2 / premium - for image models',
max_length=20,
null=True,
),
),
# Add the Seedream model
migrations.RunPython(add_seedream_model, reverse_migration),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.10 on 2026-01-10 12:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0031_add_seedream_model'),
]
operations = [
migrations.AddField(
model_name='historicalaimodelconfig',
name='landscape_size',
field=models.CharField(blank=True, help_text="Landscape image size for this model (e.g., '1792x1024', '1280x768')", max_length=20, null=True),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='square_size',
field=models.CharField(blank=True, default='1024x1024', help_text="Square image size for this model (e.g., '1024x1024')", max_length=20),
),
migrations.AddField(
model_name='historicalaimodelconfig',
name='valid_sizes',
field=models.JSONField(blank=True, default=list, help_text="List of valid sizes for this model (e.g., ['1024x1024', '1792x1024'])"),
),
migrations.AlterField(
model_name='aimodelconfig',
name='quality_tier',
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('quality_option2', 'Quality-Option2'), ('premium', 'Premium')], help_text='basic / quality / premium - for image models', max_length=20, null=True),
),
migrations.AlterField(
model_name='historicalaimodelconfig',
name='quality_tier',
field=models.CharField(blank=True, choices=[('basic', 'Basic'), ('quality', 'Quality'), ('quality_option2', 'Quality-Option2'), ('premium', 'Premium')], help_text='basic / quality / premium - for image models', max_length=20, null=True),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.10 on 2026-01-12 06:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0032_historicalaimodelconfig_landscape_size_and_more'),
('igny8_core_auth', '0020_fix_historical_account'),
]
operations = [
migrations.AddField(
model_name='creditusagelog',
name='site',
field=models.ForeignKey(blank=True, help_text='Site where the operation was performed', null=True, on_delete=django.db.models.deletion.CASCADE, to='igny8_core_auth.site'),
),
migrations.AddIndex(
model_name='creditusagelog',
index=models.Index(fields=['site', 'created_at'], name='igny8_credi_site_id_b628ed_idx'),
),
migrations.AddIndex(
model_name='creditusagelog',
index=models.Index(fields=['account', 'site', 'created_at'], name='igny8_credi_tenant__ca31e1_idx'),
),
]

View File

@@ -0,0 +1,90 @@
# Generated by Django 5.2.10 on 2026-01-12 07:02
from django.db import migrations
def backfill_site_id(apps, schema_editor):
"""
Backfill site_id for existing CreditUsageLog records by looking up
the related objects (Content, Images, ContentIdeas, Clusters).
"""
CreditUsageLog = apps.get_model('billing', 'CreditUsageLog')
Content = apps.get_model('writer', 'Content')
Images = apps.get_model('writer', 'Images')
ContentIdeas = apps.get_model('planner', 'ContentIdeas')
Clusters = apps.get_model('planner', 'Clusters')
# Backfill for content records
content_logs = CreditUsageLog.objects.filter(
site__isnull=True,
related_object_type='content',
related_object_id__isnull=False
)
for log in content_logs:
try:
content = Content.objects.get(id=log.related_object_id)
log.site_id = content.site_id
log.save(update_fields=['site'])
except Content.DoesNotExist:
pass
# Backfill for image records
image_logs = CreditUsageLog.objects.filter(
site__isnull=True,
related_object_type='image',
related_object_id__isnull=False
)
for log in image_logs:
try:
image = Images.objects.get(id=log.related_object_id)
log.site_id = image.site_id
log.save(update_fields=['site'])
except Images.DoesNotExist:
pass
# Backfill for content idea records
idea_logs = CreditUsageLog.objects.filter(
site__isnull=True,
related_object_type='content_idea',
related_object_id__isnull=False
)
for log in idea_logs:
try:
idea = ContentIdeas.objects.get(id=log.related_object_id)
log.site_id = idea.site_id
log.save(update_fields=['site'])
except ContentIdeas.DoesNotExist:
pass
# Backfill for cluster records
cluster_logs = CreditUsageLog.objects.filter(
site__isnull=True,
related_object_type='cluster',
related_object_id__isnull=False
)
for log in cluster_logs:
try:
cluster = Clusters.objects.get(id=log.related_object_id)
log.site_id = cluster.site_id
log.save(update_fields=['site'])
except Clusters.DoesNotExist:
pass
def reverse_backfill(apps, schema_editor):
"""Reverse migration - set site_id back to NULL for backfilled records."""
# We don't reverse this as we can't distinguish which were backfilled
pass
class Migration(migrations.Migration):
dependencies = [
('billing', '0033_add_site_to_credit_usage_log'),
('writer', '0016_images_unique_position_constraint'),
('planner', '0008_soft_delete'),
]
operations = [
migrations.RunPython(backfill_site_id, reverse_backfill),
]

View File

@@ -287,7 +287,9 @@ class AIModelConfigSerializer(serializers.Serializer):
def get_pricing_display(self, obj):
"""Generate pricing display string based on model type"""
if obj.model_type == 'text':
return f"${obj.input_cost_per_1m}/{obj.output_cost_per_1m} per 1M"
input_cost = obj.cost_per_1k_input or 0
output_cost = obj.cost_per_1k_output or 0
return f"${input_cost}/{output_cost} per 1K tokens"
elif obj.model_type == 'image':
return f"${obj.cost_per_image} per image"
return ""

View File

@@ -165,6 +165,14 @@ class CreditUsageViewSet(AccountModelViewSet):
created_at__gte=start_date,
created_at__lte=end_date
)
# Filter by site if provided
site_id = request.query_params.get('site_id')
if site_id:
try:
usage_logs = usage_logs.filter(site_id=int(site_id))
except (ValueError, TypeError):
pass
# Calculate totals
total_credits_used = usage_logs.aggregate(total=Sum('credits_used'))['total'] or 0

View File

@@ -67,25 +67,23 @@ class IntegrationViewSet(SiteSectorModelViewSet):
api_key = serializers.SerializerMethodField()
def get_api_key(self, obj):
"""Return the API key from encrypted credentials"""
credentials = obj.get_credentials()
return credentials.get('api_key', '')
"""Return the API key from Site.wp_api_key (SINGLE source of truth)"""
# API key is stored on Site model, not in SiteIntegration credentials
return obj.site.wp_api_key or ''
def validate(self, data):
"""
Custom validation for WordPress integrations.
API key is the only required authentication method.
API key is stored on Site model, not in SiteIntegration.
"""
validated_data = super().validate(data)
# For WordPress platform, require API key only
# For WordPress platform, check API key exists on Site (not in credentials_json)
if validated_data.get('platform') == 'wordpress':
credentials = validated_data.get('credentials_json', {})
# API key is required for all WordPress integrations
if not credentials.get('api_key'):
site = validated_data.get('site') or getattr(self.instance, 'site', None)
if site and not site.wp_api_key:
raise serializers.ValidationError({
'credentials_json': 'API key is required for WordPress integration.'
'site': 'Site must have an API key generated before creating WordPress integration.'
})
return validated_data
@@ -198,7 +196,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
# Try to find an existing integration for this site+platform
integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first()
# If not found, create and save the integration to database
# If not found, create and save the integration to database (for status tracking, not credentials)
integration_created = False
if not integration:
integration = SiteIntegration.objects.create(
@@ -207,7 +205,7 @@ class IntegrationViewSet(SiteSectorModelViewSet):
platform='wordpress',
platform_type='cms',
config_json={'site_url': site_url} if site_url else {},
credentials_json={'api_key': api_key} if api_key else {},
credentials_json={}, # API key is stored in Site.wp_api_key, not here
is_active=True,
sync_enabled=True
)
@@ -805,27 +803,38 @@ class IntegrationViewSet(SiteSectorModelViewSet):
random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10))
api_key = f"igny8_site_{site_id}_{timestamp}_{random_suffix}"
# Get or create SiteIntegration
# SINGLE SOURCE OF TRUTH: Store API key ONLY in Site.wp_api_key
# This is where APIKeyAuthentication validates against
site.wp_api_key = api_key
site.save(update_fields=['wp_api_key'])
# Get or create SiteIntegration (for integration status/config, NOT credentials)
integration, created = SiteIntegration.objects.get_or_create(
site=site,
platform='wordpress',
defaults={
'integration_type': 'wordpress',
'account': site.account,
'platform': 'wordpress',
'platform_type': 'cms',
'is_active': True,
'credentials_json': {'api_key': api_key},
'sync_enabled': True,
'credentials_json': {}, # Empty - API key is on Site model
'config_json': {}
}
)
# If integration already exists, update the API key
# If integration already exists, just ensure it's active
if not created:
credentials = integration.get_credentials()
credentials['api_key'] = api_key
integration.credentials_json = credentials
integration.is_active = True
integration.sync_enabled = True
# Clear any old credentials_json API key (migrate to Site.wp_api_key)
if integration.credentials_json.get('api_key'):
integration.credentials_json = {}
integration.save()
logger.info(
f"Generated new API key for site {site.name} (ID: {site_id}), "
f"integration {'created' if created else 'updated'}"
f"stored in Site.wp_api_key (single source of truth)"
)
# Serialize the integration with the new key

View File

@@ -122,10 +122,10 @@ def wordpress_status_webhook(request):
request=request
)
# Verify API key matches integration
stored_api_key = integration.credentials_json.get('api_key')
# Verify API key matches Site.wp_api_key (SINGLE source of truth)
stored_api_key = integration.site.wp_api_key
if not stored_api_key or stored_api_key != api_key:
logger.error(f"[wordpress_status_webhook] Invalid API key for integration {integration.id}")
logger.error(f"[wordpress_status_webhook] Invalid API key for site {integration.site.id}")
return error_response(
error='Invalid API key',
status_code=http_status.HTTP_401_UNAUTHORIZED,
@@ -293,8 +293,8 @@ def wordpress_metadata_webhook(request):
request=request
)
# Verify API key
stored_api_key = integration.credentials_json.get('api_key')
# Verify API key against Site.wp_api_key (SINGLE source of truth)
stored_api_key = integration.site.wp_api_key
if not stored_api_key or stored_api_key != api_key:
return error_response(
error='Invalid API key',

View File

@@ -1,12 +1,6 @@
from django.contrib import admin
from django.contrib import messages
from unfold.admin import ModelAdmin
from unfold.contrib.filters.admin import (
RangeDateFilter,
RangeNumericFilter,
RelatedDropdownFilter,
ChoicesDropdownFilter,
)
from igny8_core.admin.base import SiteSectorAdminMixin, Igny8ModelAdmin
from .models import Keywords, Clusters, ContentIdeas
from import_export.admin import ExportMixin, ImportExportMixin
@@ -40,13 +34,7 @@ class ClustersAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ClustersResource
list_display = ['name', 'site', 'sector', 'keywords_count', 'volume', 'status', 'created_at']
list_select_related = ['site', 'sector', 'account']
list_filter = [
('status', ChoicesDropdownFilter),
('site', RelatedDropdownFilter),
('sector', RelatedDropdownFilter),
('volume', RangeNumericFilter),
('created_at', RangeDateFilter),
]
list_filter = ['status', 'site', 'sector', 'volume', 'created_at']
search_fields = ['name']
ordering = ['name']
autocomplete_fields = ['site', 'sector']
@@ -100,13 +88,7 @@ class KeywordsAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
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_select_related = ['site', 'sector', 'cluster', 'seed_keyword', 'seed_keyword__industry', 'seed_keyword__sector', 'account']
list_filter = [
('status', ChoicesDropdownFilter),
('site', RelatedDropdownFilter),
('sector', RelatedDropdownFilter),
('cluster', RelatedDropdownFilter),
('created_at', RangeDateFilter),
]
list_filter = ['status', 'site', 'sector', 'cluster', 'created_at']
search_fields = ['seed_keyword__keyword']
ordering = ['-created_at']
autocomplete_fields = ['cluster', 'site', 'sector', 'seed_keyword']
@@ -243,16 +225,7 @@ class ContentIdeasAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin
resource_class = ContentIdeasResource
list_display = ['idea_title', 'site', 'sector', 'description_preview', 'content_type', 'content_structure', 'status', 'keyword_cluster', 'estimated_word_count', 'created_at']
list_select_related = ['site', 'sector', 'keyword_cluster', 'account']
list_filter = [
('status', ChoicesDropdownFilter),
('content_type', ChoicesDropdownFilter),
('content_structure', ChoicesDropdownFilter),
('site', RelatedDropdownFilter),
('sector', RelatedDropdownFilter),
('keyword_cluster', RelatedDropdownFilter),
('estimated_word_count', RangeNumericFilter),
('created_at', RangeDateFilter),
]
list_filter = ['status', 'content_type', 'content_structure', 'site', 'sector', 'keyword_cluster', 'estimated_word_count', 'created_at']
search_fields = ['idea_title', 'target_keywords', 'description']
ordering = ['-created_at']
readonly_fields = ['created_at', 'updated_at']

View File

@@ -2,6 +2,7 @@ from rest_framework import viewsets, filters, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
import django_filters
from django.db import transaction
from django.db.models import Max, Count, Sum, Q
from django.http import HttpResponse
@@ -23,6 +24,37 @@ from igny8_core.business.planning.services.ideas_service import IdeasService
from igny8_core.business.billing.exceptions import InsufficientCreditsError
# Custom FilterSets with date range filtering support
class KeywordsFilter(django_filters.FilterSet):
"""Custom filter for Keywords with date range support"""
created_at__gte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='gte')
created_at__lte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='lte')
class Meta:
model = Keywords
fields = ['status', 'cluster_id', 'seed_keyword__country', 'seed_keyword_id', 'created_at__gte', 'created_at__lte']
class ClustersFilter(django_filters.FilterSet):
"""Custom filter for Clusters with date range support"""
created_at__gte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='gte')
created_at__lte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='lte')
class Meta:
model = Clusters
fields = ['status', 'created_at__gte', 'created_at__lte']
class ContentIdeasFilter(django_filters.FilterSet):
"""Custom filter for ContentIdeas with date range support"""
created_at__gte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='gte')
created_at__lte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='lte')
class Meta:
model = ContentIdeas
fields = ['status', 'keyword_cluster_id', 'content_type', 'content_structure', 'created_at__gte', 'created_at__lte']
@extend_schema_view(
list=extend_schema(tags=['Planner']),
create=extend_schema(tags=['Planner']),
@@ -54,8 +86,8 @@ class KeywordViewSet(SiteSectorModelViewSet):
ordering_fields = ['created_at', 'seed_keyword__volume', 'seed_keyword__difficulty']
ordering = ['-created_at'] # Default ordering (newest first)
# Filter configuration - filter by status, cluster_id, and seed_keyword fields
filterset_fields = ['status', 'cluster_id', 'seed_keyword__country', 'seed_keyword_id']
# Filter configuration - use custom filterset for date range filtering
filterset_class = KeywordsFilter
def get_queryset(self):
"""
@@ -803,8 +835,8 @@ class ClusterViewSet(SiteSectorModelViewSet):
ordering_fields = ['name', 'created_at', 'keywords_count', 'volume', 'difficulty']
ordering = ['name'] # Default ordering
# Filter configuration
filterset_fields = ['status']
# Filter configuration - use custom filterset for date range filtering
filterset_class = ClustersFilter
def get_queryset(self):
"""
@@ -1111,8 +1143,8 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
ordering_fields = ['idea_title', 'created_at', 'estimated_word_count']
ordering = ['-created_at'] # Default ordering (newest first)
# Filter configuration (updated for new structure)
filterset_fields = ['status', 'keyword_cluster_id', 'content_type', 'content_structure']
# Filter configuration - use custom filterset for date range filtering
filterset_class = ContentIdeasFilter
def perform_create(self, serializer):
"""Require explicit site_id and sector_id - no defaults."""

View File

@@ -47,6 +47,13 @@ class SystemAISettings(models.Model):
('hd', 'HD'),
]
QUALITY_TIER_CHOICES = [
('basic', 'Basic'),
('quality', 'Quality'),
('quality_option2', 'Quality-Option2'),
('premium', 'Premium'),
]
IMAGE_SIZE_CHOICES = [
('1024x1024', '1024x1024 (Square)'),
('1792x1024', '1792x1024 (Landscape)'),
@@ -70,6 +77,12 @@ class SystemAISettings(models.Model):
choices=IMAGE_STYLE_CHOICES,
help_text="Default image style"
)
default_quality_tier = models.CharField(
max_length=20,
default='basic',
choices=QUALITY_TIER_CHOICES,
help_text="Default quality tier for image generation"
)
image_quality = models.CharField(
max_length=20,
default='standard',
@@ -78,7 +91,11 @@ class SystemAISettings(models.Model):
)
max_images_per_article = models.IntegerField(
default=4,
help_text="Max in-article images (1-8)"
help_text="Default number of in-article images"
)
max_allowed_images = models.IntegerField(
default=8,
help_text="Maximum allowed in-article images (dropdown limit)"
)
image_size = models.CharField(
max_length=20,
@@ -175,6 +192,45 @@ class SystemAISettings(models.Model):
return str(override)
return cls.get_instance().image_size
@classmethod
def get_effective_quality_tier(cls, account=None) -> str:
"""Get quality_tier, checking account override first, then default image model"""
if account:
# Check consolidated ai_settings first
try:
from igny8_core.modules.system.settings_models import AccountSettings
setting = AccountSettings.objects.filter(
account=account,
key='ai_settings'
).first()
if setting and setting.value:
tier = setting.value.get('quality_tier')
if tier:
return str(tier)
except Exception as e:
logger.debug(f"Could not get quality_tier from ai_settings: {e}")
# Fall back to individual key
override = cls._get_account_override(account, 'ai.quality_tier')
if override is not None:
return str(override)
# No account override - get tier from DEFAULT image model (is_default=True)
try:
from igny8_core.business.billing.models import AIModelConfig
default_model = AIModelConfig.objects.filter(
model_type='image',
is_default=True,
is_active=True
).first()
if default_model and default_model.quality_tier:
return default_model.quality_tier
except Exception as e:
logger.debug(f"Could not get default image model tier: {e}")
# Ultimate fallback
return cls.get_instance().default_quality_tier
@staticmethod
def _get_account_override(account, key: str):
"""Get account-specific override from AccountSettings"""

View File

@@ -793,23 +793,38 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
}
elif integration_type == 'image_generation':
# Model-specific landscape sizes
MODEL_LANDSCAPE_SIZES = {
'runware:97@1': '1280x768',
'bria:10@1': '1344x768',
'google:4@2': '1376x768',
}
from igny8_core.business.billing.models import AIModelConfig
from igny8_core.modules.system.ai_settings import AISettings
# 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'
# Get user's quality tier (from account settings)
quality_tier = AISettings.get_effective_quality_tier(account)
# Find image model based on quality tier (DYNAMIC from database)
model_config = None
if quality_tier:
model_config = AIModelConfig.objects.filter(
model_type='image',
quality_tier=quality_tier,
is_active=True
).first()
# Fallback to default image model
if not model_config:
default_model = ModelRegistry.get_default_model('image')
if default_model:
model_config = ModelRegistry.get_model(default_model)
# Extract settings from model config
if model_config:
default_service = model_config.provider or 'openai'
default_model = model_config.model_name
model_landscape_size = model_config.landscape_size or '1792x1024'
model_square_size = model_config.square_size or '1024x1024'
else:
default_service = 'openai'
default_model = 'dall-e-3'
model_landscape_size = MODEL_LANDSCAPE_SIZES.get(default_model, '1280x768')
model_landscape_size = '1792x1024'
model_square_size = '1024x1024'
response_data = {
'id': 'image_generation',
@@ -826,8 +841,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
'image_format': 'webp',
'desktop_enabled': True,
'featured_image_size': model_landscape_size,
'in_article_landscape_size': model_landscape_size,
'in_article_square_size': model_square_size,
'desktop_image_size': SystemAISettings.get_effective_image_size(account),
'using_global': True,
'quality_tier': quality_tier,
}
else:
# Other integration types - return empty
@@ -856,9 +874,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
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)
4. Model config (sizes, etc.) from AIModelConfig (DYNAMIC, single source of truth)
"""
from igny8_core.modules.system.ai_settings import SystemAISettings
from igny8_core.modules.system.ai_settings import SystemAISettings, AISettings
from igny8_core.ai.model_registry import ModelRegistry
from igny8_core.business.billing.models import AIModelConfig
account = getattr(request, 'account', None)
@@ -868,29 +888,36 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
account = getattr(user, 'account', None)
# 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:
# 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
# Get user's quality tier from account settings (DYNAMIC)
quality_tier = AISettings.get_effective_quality_tier(account)
# Find image model based on quality tier (DYNAMIC from database)
model_config = None
if quality_tier:
model_config = AIModelConfig.objects.filter(
model_type='image',
quality_tier=quality_tier,
is_active=True
).first()
# Fallback to default image model
if not model_config:
default_model = ModelRegistry.get_default_model('image')
if default_model:
model_config = ModelRegistry.get_model(default_model)
# Extract settings from model config (SINGLE SOURCE OF TRUTH)
if model_config:
provider = model_config.provider or 'openai'
model = model_config.model_name
model_landscape_size = model_config.landscape_size or '1792x1024'
model_square_size = model_config.square_size or '1024x1024'
else:
provider = 'openai'
model = 'dall-e-3'
# 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'
model_landscape_size = '1792x1024'
model_square_size = '1024x1024'
# Get image style from SystemAISettings with AccountSettings overrides
image_style = SystemAISettings.get_effective_image_style(account)
@@ -915,7 +942,7 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
if not image_style or image_style in ['natural', 'vivid']:
image_style = 'photorealistic'
logger.info(f"[get_image_generation_settings] Returning: provider={provider}, model={model}, image_style={image_style}")
logger.info(f"[get_image_generation_settings] Returning: provider={provider}, model={model}, image_style={image_style}, quality_tier={quality_tier}")
return success_response(
data={
@@ -927,8 +954,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
'max_in_article_images': SystemAISettings.get_effective_max_images(account),
'image_format': 'webp',
'desktop_enabled': True,
'featured_image_size': default_featured_size,
'featured_image_size': model_landscape_size,
'in_article_landscape_size': model_landscape_size,
'in_article_square_size': model_square_size,
'desktop_image_size': SystemAISettings.get_effective_image_size(account),
'quality_tier': quality_tier,
}
},
request=request

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.10 on 2026-01-10 04:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0021_add_smtp_email_settings'),
]
operations = [
migrations.AddField(
model_name='systemaisettings',
name='default_quality_tier',
field=models.CharField(choices=[('basic', 'Basic'), ('quality', 'Quality'), ('premium', 'Premium')], default='basic', help_text='Default quality tier for image generation', max_length=20),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.10 on 2026-01-10 04:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0022_systemaisettings_default_quality_tier_and_more'),
]
operations = [
migrations.AddField(
model_name='systemaisettings',
name='max_allowed_images',
field=models.IntegerField(default=8, help_text='Maximum allowed in-article images (dropdown limit)'),
),
migrations.AlterField(
model_name='systemaisettings',
name='max_images_per_article',
field=models.IntegerField(default=4, help_text='Default number of in-article images'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.10 on 2026-01-10 12:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0023_systemaisettings_max_allowed_images_and_more'),
]
operations = [
migrations.AlterField(
model_name='systemaisettings',
name='default_quality_tier',
field=models.CharField(choices=[('basic', 'Basic'), ('quality', 'Quality'), ('quality_option2', 'Quality-Option2'), ('premium', 'Premium')], default='basic', help_text='Default quality tier for image generation', max_length=20),
),
# Removed DeleteModel for AccountIntegrationOverride - table doesn't exist
]

View File

@@ -183,8 +183,8 @@ class ContentSettingsViewSet(viewsets.ViewSet):
setting = AccountSettings.objects.get(account=account, key=pk)
return success_response(data={
'key': setting.key,
'config': setting.config,
'is_active': setting.is_active,
'config': setting.value, # Model uses 'value', frontend expects 'config'
'is_active': getattr(setting, 'is_active', True),
}, request=request)
except AccountSettings.DoesNotExist:
# Return default settings if not yet saved
@@ -234,17 +234,17 @@ class ContentSettingsViewSet(viewsets.ViewSet):
# Filter to only valid fields
filtered_config = {k: v for k, v in config.items() if k in valid_fields}
# Get or create setting
# Get or create setting - model uses 'value' field
setting, created = AccountSettings.objects.update_or_create(
account=account,
key=pk,
defaults={'config': filtered_config, 'is_active': True}
defaults={'value': filtered_config}
)
return success_response(data={
'key': setting.key,
'config': setting.config,
'is_active': setting.is_active,
'config': setting.value, # Model uses 'value', frontend expects 'config'
'is_active': getattr(setting, 'is_active', True),
'message': 'Settings saved successfully',
}, request=request)
@@ -528,12 +528,17 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet):
This endpoint returns:
- content_generation: temperature, max_tokens
- image_generation: quality_tiers, selected_tier, styles, selected_style, max_images
Settings are stored in a single AccountSettings record with key='ai_settings'
"""
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
authentication_classes = [JWTAuthentication]
throttle_scope = 'system'
throttle_classes = [DebugScopedRateThrottle]
# Single key for all AI settings per account
AI_SETTINGS_KEY = 'ai_settings'
def _get_account(self, request):
"""Get account from request"""
account = getattr(request, 'account', None)
@@ -543,6 +548,20 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet):
account = getattr(user, 'account', None)
return account
def _get_account_ai_settings(self, account):
"""Get consolidated AI settings for account, returns dict with all settings"""
if not account:
return {}
setting = AccountSettings.objects.filter(
account=account,
key=self.AI_SETTINGS_KEY
).first()
if setting and setting.value:
return setting.value
return {}
def list(self, request):
"""
GET /api/v1/accounts/settings/ai/
@@ -566,16 +585,21 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet):
try:
from igny8_core.business.billing.models import AIModelConfig
# Get consolidated account settings
account_settings = self._get_account_ai_settings(account)
# 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):
# Format label: quality_option2 -> "Quality-Option2"
tier_label = tier.replace('_', '-').title() if tier else 'Basic'
quality_tiers.append({
'tier': tier,
'credits': model.credits_per_image or 1,
'label': tier.title(),
'label': tier_label,
'description': f"{model.display_name} quality",
'model': model.model_name,
})
@@ -594,21 +618,40 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet):
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 system defaults
system_defaults = SystemAISettings.get_instance()
# 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')
# Get default image model (is_default=True) - SINGLE SOURCE OF TRUTH
default_image_model = AIModelConfig.get_default_image_model()
# Determine default tier from default model (not hardcoded)
default_tier = default_image_model.quality_tier if default_image_model else 'basic'
# Apply account overrides or use system defaults
temperature = account_settings.get('temperature', system_defaults.temperature)
max_tokens = account_settings.get('max_tokens', system_defaults.max_tokens)
image_style = account_settings.get('image_style', system_defaults.image_style)
max_images = account_settings.get('max_images', system_defaults.max_images_per_article)
# Get selected tier: account override > default model's tier
selected_tier = account_settings.get('quality_tier') or default_tier
# Try to find model matching selected tier
selected_model = AIModelConfig.objects.filter(
model_type='image',
quality_tier=selected_tier,
is_active=True
).first() or default_image_model
# Get image sizes from the selected model
featured_image_size = '1792x1024' # Default
landscape_image_size = '1792x1024' # Default
square_image_size = '1024x1024' # Default
if selected_model:
landscape_image_size = selected_model.landscape_size or '1792x1024'
square_image_size = selected_model.square_size or '1024x1024'
featured_image_size = landscape_image_size # Featured uses landscape
response_data = {
'content_generation': {
@@ -621,7 +664,13 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet):
'styles': styles,
'selected_style': image_style,
'max_images': max_images,
'max_allowed': 8,
'max_allowed': system_defaults.max_allowed_images,
# Image sizes based on selected model
'featured_image_size': featured_image_size,
'landscape_image_size': landscape_image_size,
'square_image_size': square_image_size,
'model_name': selected_model.model_name if selected_model else None,
'model_display_name': selected_model.display_name if selected_model else None,
}
}
@@ -639,14 +688,13 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet):
"""
PUT/POST /api/v1/accounts/settings/ai/
Save account-specific overrides to AccountSettings.
Request body per the plan:
Save account-specific overrides to a single AccountSettings record.
All AI settings are stored in one record with key='ai_settings'.
Accepts nested structure:
{
"temperature": 0.8,
"max_tokens": 4096,
"image_quality_tier": "premium",
"image_style": "illustration",
"max_images": 6
"content_generation": { "temperature": 0.8, "max_tokens": 4096 },
"image_generation": { "quality_tier": "premium", "image_style": "illustration", "max_images_per_article": 6 }
}
"""
account = self._get_account(request)
@@ -660,30 +708,38 @@ class ContentGenerationSettingsViewSet(viewsets.ViewSet):
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',
}
# Get existing settings or start fresh
existing_settings = self._get_account_ai_settings(account)
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)
# Handle nested structure from frontend
content_gen = data.get('content_generation', {})
image_gen = data.get('image_generation', {})
logger.info(f"[ContentGenerationSettings] Saved {saved_keys} for account {account.id}")
# Update with new values (only if provided)
if content_gen.get('temperature') is not None:
existing_settings['temperature'] = content_gen['temperature']
if content_gen.get('max_tokens') is not None:
existing_settings['max_tokens'] = content_gen['max_tokens']
if image_gen.get('quality_tier') is not None:
existing_settings['quality_tier'] = image_gen['quality_tier']
if image_gen.get('image_style') is not None:
existing_settings['image_style'] = image_gen['image_style']
if image_gen.get('max_images_per_article') is not None:
existing_settings['max_images'] = image_gen['max_images_per_article']
# Save as single consolidated record
setting, created = AccountSettings.objects.update_or_create(
account=account,
key=self.AI_SETTINGS_KEY,
defaults={'value': existing_settings}
)
logger.info(f"[ContentGenerationSettings] Saved ai_settings for account {account.id}: {existing_settings}")
return success_response(
data={'saved_keys': saved_keys},
data={'settings': existing_settings},
message='AI settings saved successfully',
request=request
)

View File

@@ -1,12 +1,6 @@
from django.contrib import admin
from django.contrib import messages
from unfold.admin import ModelAdmin, TabularInline
from unfold.contrib.filters.admin import (
RangeDateFilter,
RangeNumericFilter,
RelatedDropdownFilter,
ChoicesDropdownFilter,
)
from igny8_core.admin.base import SiteSectorAdminMixin, Igny8ModelAdmin
from .models import Tasks, Images, Content, ImagePrompts
from igny8_core.business.content.models import ContentTaxonomy, ContentAttribute, ContentTaxonomyRelation, ContentClusterMap
@@ -39,15 +33,7 @@ class TasksAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = TaskResource
list_display = ['title', 'content_type', 'content_structure', 'site', 'sector', 'status', 'cluster', 'created_at']
list_editable = ['status'] # Enable inline editing for status
list_filter = [
('status', ChoicesDropdownFilter),
('content_type', ChoicesDropdownFilter),
('content_structure', ChoicesDropdownFilter),
('site', RelatedDropdownFilter),
('sector', RelatedDropdownFilter),
('cluster', RelatedDropdownFilter),
('created_at', RangeDateFilter),
]
list_filter = ['status', 'content_type', 'content_structure', 'site', 'sector', 'cluster', 'created_at']
search_fields = ['title', 'description']
ordering = ['-created_at']
readonly_fields = ['created_at', 'updated_at']
@@ -315,13 +301,7 @@ class ImagePromptsAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ImagePromptsResource
list_display = ['get_content_title', 'site', 'sector', 'image_type', 'get_prompt_preview', 'status', 'created_at']
list_filter = [
('image_type', ChoicesDropdownFilter),
('status', ChoicesDropdownFilter),
('site', RelatedDropdownFilter),
('sector', RelatedDropdownFilter),
('created_at', RangeDateFilter),
]
list_filter = ['image_type', 'status', 'site', 'sector', 'created_at']
search_fields = ['content__title', 'prompt', 'caption']
ordering = ['-created_at']
readonly_fields = ['get_content_title', 'site', 'sector', 'image_type', 'prompt', 'caption',
@@ -450,17 +430,7 @@ class ContentResource(resources.ModelResource):
class ContentAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ContentResource
list_display = ['title', 'content_type', 'content_structure', 'site', 'sector', 'source', 'status', 'word_count', 'get_taxonomy_count', 'created_at']
list_filter = [
('status', ChoicesDropdownFilter),
('content_type', ChoicesDropdownFilter),
('content_structure', ChoicesDropdownFilter),
('source', ChoicesDropdownFilter),
('site', RelatedDropdownFilter),
('sector', RelatedDropdownFilter),
('cluster', RelatedDropdownFilter),
('word_count', RangeNumericFilter),
('created_at', RangeDateFilter),
]
list_filter = ['status', 'content_type', 'content_structure', 'source', 'site', 'sector', 'cluster', 'word_count', 'created_at']
search_fields = ['title', 'content_html', 'external_url', 'meta_title', 'primary_keyword']
ordering = ['-created_at']
readonly_fields = ['created_at', 'updated_at', 'word_count', 'get_tags_display', 'get_categories_display']

View File

@@ -2,6 +2,7 @@ from rest_framework import viewsets, filters, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
import django_filters
from django.db import transaction, models
from django.db.models import Q
from drf_spectacular.utils import extend_schema, extend_schema_view
@@ -24,6 +25,37 @@ from igny8_core.business.content.services.metadata_mapping_service import Metada
from igny8_core.business.billing.exceptions import InsufficientCreditsError
# Custom FilterSets with date range filtering support
class TasksFilter(django_filters.FilterSet):
"""Custom filter for Tasks with date range support"""
created_at__gte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='gte')
created_at__lte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='lte')
class Meta:
model = Tasks
fields = ['status', 'cluster_id', 'content_type', 'content_structure', 'created_at__gte', 'created_at__lte']
class ImagesFilter(django_filters.FilterSet):
"""Custom filter for Images with date range support"""
created_at__gte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='gte')
created_at__lte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='lte')
class Meta:
model = Images
fields = ['task_id', 'content_id', 'image_type', 'status', 'created_at__gte', 'created_at__lte']
class ContentFilter(django_filters.FilterSet):
"""Custom filter for Content with date range support"""
created_at__gte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='gte')
created_at__lte = django_filters.IsoDateTimeFilter(field_name='created_at', lookup_expr='lte')
class Meta:
model = Content
fields = ['cluster_id', 'status', 'content_type', 'content_structure', 'source', 'created_at__gte', 'created_at__lte']
@extend_schema_view(
list=extend_schema(tags=['Writer']),
@@ -56,8 +88,8 @@ class TasksViewSet(SiteSectorModelViewSet):
ordering_fields = ['title', 'created_at', 'status']
ordering = ['-created_at'] # Default ordering (newest first)
# Filter configuration - Stage 1: removed entity_type, cluster_role
filterset_fields = ['status', 'cluster_id', 'content_type', 'content_structure']
# Filter configuration - use custom filterset for date range filtering
filterset_class = TasksFilter
def perform_create(self, serializer):
"""Require explicit site_id and sector_id - no defaults."""
@@ -265,7 +297,7 @@ class ImagesViewSet(SiteSectorModelViewSet):
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
ordering_fields = ['created_at', 'position', 'id']
ordering = ['-id'] # Sort by ID descending (newest first)
filterset_fields = ['task_id', 'content_id', 'image_type', 'status']
filterset_class = ImagesFilter
def perform_create(self, serializer):
"""Override to automatically set account, site, and sector"""
@@ -743,13 +775,8 @@ class ContentViewSet(SiteSectorModelViewSet):
ordering_fields = ['created_at', 'updated_at', 'status']
ordering = ['-created_at']
# Stage 1: removed task_id, entity_type, content_format, cluster_role, sync_status, external_type
filterset_fields = [
'cluster_id',
'status',
'content_type',
'content_structure',
'source',
]
# Use custom filterset for date range filtering
filterset_class = ContentFilter
def get_queryset(self):
"""Override to support status__in filtering for multiple statuses"""

View File

@@ -0,0 +1 @@
# IGNY8 Plugin Distribution System

View File

@@ -0,0 +1,239 @@
"""
Django Admin Configuration for Plugin Distribution System
"""
from django import forms
from django.contrib import admin
from django.utils.html import format_html
from django.urls import reverse
from unfold.admin import ModelAdmin, TabularInline
from .models import Plugin, PluginVersion, PluginInstallation, PluginDownload
class PluginVersionForm(forms.ModelForm):
"""
Simplified form for creating new plugin versions.
Auto-fills most fields from the latest version, only requires:
- Plugin (select)
- Version number
- Changelog
- Status (defaults to 'draft')
All other fields are either:
- Auto-filled from previous version (min_api_version, min_php_version, etc.)
- Auto-generated on release (file_path, file_size, checksum)
- Auto-calculated (version_code)
"""
class Meta:
model = PluginVersion
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# If this is a new version (no instance), auto-fill from latest
if not self.instance.pk:
# Check if plugin is already selected (from initial data or POST data)
plugin_id = None
# Try to get plugin from POST data (when form is submitted)
if self.data:
plugin_id = self.data.get('plugin')
# Try to get plugin from initial data (when form is pre-filled)
elif self.initial:
plugin_id = self.initial.get('plugin')
if plugin_id:
try:
plugin = Plugin.objects.get(pk=plugin_id)
latest = plugin.get_latest_version()
if latest:
# Auto-fill from latest version (only if not POST)
if not self.data:
if 'min_api_version' in self.fields:
self.fields['min_api_version'].initial = latest.min_api_version
if 'min_platform_version' in self.fields:
self.fields['min_platform_version'].initial = latest.min_platform_version
if 'min_php_version' in self.fields:
self.fields['min_php_version'].initial = latest.min_php_version
if 'force_update' in self.fields:
self.fields['force_update'].initial = False
except (Plugin.DoesNotExist, ValueError, TypeError):
pass
# Set helpful help texts for fields that exist in the form
# (some fields may be readonly and not in self.fields)
if 'version' in self.fields:
self.fields['version'].help_text = "Semantic version (e.g., 1.2.0)"
if 'changelog' in self.fields:
self.fields['changelog'].help_text = "What's new in this version"
class PluginVersionInline(TabularInline):
"""Inline admin for plugin versions."""
model = PluginVersion
extra = 0
fields = ['version', 'status', 'file_size', 'released_at', 'download_count']
readonly_fields = ['download_count']
ordering = ['-version_code']
def download_count(self, obj):
return obj.get_download_count()
download_count.short_description = 'Downloads'
@admin.register(Plugin)
class PluginAdmin(ModelAdmin):
"""Admin configuration for Plugin model."""
list_display = ['name', 'slug', 'platform', 'is_active', 'latest_version', 'total_downloads', 'created_at']
list_filter = ['platform', 'is_active', 'created_at']
search_fields = ['name', 'slug', 'description']
readonly_fields = ['created_at', 'updated_at', 'total_downloads', 'active_installations']
fieldsets = [
('Basic Info', {
'fields': ['name', 'slug', 'platform', 'description', 'homepage_url', 'is_active']
}),
('Statistics', {
'fields': ['total_downloads', 'active_installations'],
'classes': ['collapse']
}),
('Timestamps', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
inlines = [PluginVersionInline]
def latest_version(self, obj):
latest = obj.get_latest_version()
if latest:
return f"v{latest.version}"
return "-"
latest_version.short_description = 'Latest Version'
def total_downloads(self, obj):
return obj.get_download_count()
total_downloads.short_description = 'Total Downloads'
def active_installations(self, obj):
return PluginInstallation.objects.filter(plugin=obj, is_active=True).count()
active_installations.short_description = 'Active Installations'
@admin.register(PluginVersion)
class PluginVersionAdmin(ModelAdmin):
"""Admin configuration for PluginVersion model."""
form = PluginVersionForm
list_display = ['plugin', 'version', 'status', 'file_size_display', 'download_count', 'released_at']
list_filter = ['plugin', 'status', 'released_at']
search_fields = ['plugin__name', 'version', 'changelog']
readonly_fields = ['version_code', 'file_path', 'file_size', 'checksum', 'created_at', 'released_at', 'download_count']
fieldsets = [
('Required Fields', {
'fields': ['plugin', 'version', 'status', 'changelog'],
'description': 'Only these fields are required. Others are auto-filled from previous version or auto-generated.'
}),
('Requirements (Auto-filled from previous version)', {
'fields': ['min_api_version', 'min_platform_version', 'min_php_version'],
'classes': ['collapse']
}),
('File Info (Auto-generated on release)', {
'fields': ['file_path', 'file_size', 'checksum'],
'classes': ['collapse']
}),
('Advanced Options', {
'fields': ['version_code', 'force_update'],
'classes': ['collapse']
}),
('Metadata', {
'fields': ['released_at', 'download_count', 'created_at'],
'classes': ['collapse']
}),
]
actions = ['release_versions', 'mark_as_update_ready', 'mark_as_deprecated']
def file_size_display(self, obj):
if obj.file_size:
kb = obj.file_size / 1024
if kb > 1024:
return f"{kb / 1024:.1f} MB"
return f"{kb:.1f} KB"
return "-"
file_size_display.short_description = 'Size'
def download_count(self, obj):
return obj.get_download_count()
download_count.short_description = 'Downloads'
@admin.action(description="✅ Release selected versions (builds ZIP automatically)")
def release_versions(self, request, queryset):
from django.utils import timezone
count = 0
for version in queryset.filter(status='draft'):
version.status = 'released'
version.save() # Triggers signal to build ZIP
count += 1
self.message_user(request, f"Released {count} version(s). ZIP files are being built automatically.")
@admin.action(description="🗑️ Mark as deprecated")
def mark_as_deprecated(self, request, queryset):
count = queryset.update(status='deprecated')
self.message_user(request, f"Marked {count} version(s) as deprecated")
@admin.register(PluginInstallation)
class PluginInstallationAdmin(ModelAdmin):
"""Admin configuration for PluginInstallation model."""
list_display = ['site', 'plugin', 'current_version', 'is_active', 'health_status', 'last_health_check']
list_filter = ['plugin', 'is_active', 'health_status']
search_fields = ['site__name', 'plugin__name']
readonly_fields = ['created_at', 'updated_at']
fieldsets = [
('Installation', {
'fields': ['site', 'plugin', 'current_version', 'is_active']
}),
('Health', {
'fields': ['health_status', 'last_health_check']
}),
('Updates', {
'fields': ['pending_update', 'update_notified_at']
}),
('Timestamps', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
@admin.register(PluginDownload)
class PluginDownloadAdmin(ModelAdmin):
"""Admin configuration for PluginDownload model."""
list_display = ['plugin', 'version', 'site', 'download_type', 'ip_address', 'created_at']
list_filter = ['plugin', 'download_type', 'created_at']
search_fields = ['plugin__name', 'site__name', 'ip_address']
readonly_fields = ['created_at']
date_hierarchy = 'created_at'
fieldsets = [
('Download Info', {
'fields': ['plugin', 'version', 'download_type']
}),
('Context', {
'fields': ['site', 'account', 'ip_address', 'user_agent']
}),
('Timestamp', {
'fields': ['created_at']
}),
]

View File

@@ -0,0 +1,16 @@
"""
Plugin Distribution System App Configuration
"""
from django.apps import AppConfig
class PluginsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.plugins'
label = 'plugins'
verbose_name = 'Plugin Distribution'
def ready(self):
"""Import signal handlers when app is ready."""
# Import signals to register handlers
from . import signals # noqa: F401

View File

@@ -0,0 +1 @@
# Plugin management commands

View File

@@ -0,0 +1 @@
# Plugin management commands

View File

@@ -0,0 +1,164 @@
"""
Django management command to build a plugin and register its version.
Usage:
python manage.py build_plugin --plugin=igny8-wp-bridge --version=1.0.1 [--changelog="Bug fixes"] [--release]
"""
import subprocess
import hashlib
import os
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from igny8_core.plugins.models import Plugin, PluginVersion
from igny8_core.plugins.utils import (
create_plugin_zip,
get_plugin_file_path,
calculate_checksum,
parse_version_to_code,
)
class Command(BaseCommand):
help = 'Build and register a new plugin version'
def add_arguments(self, parser):
parser.add_argument(
'--plugin',
type=str,
required=True,
help='Plugin slug (e.g., igny8-wp-bridge)'
)
parser.add_argument(
'--plugin-version',
type=str,
required=True,
dest='plugin_version',
help='Version string (e.g., 1.0.1)'
)
parser.add_argument(
'--changelog',
type=str,
default='',
help='Changelog for this version'
)
parser.add_argument(
'--release',
action='store_true',
help='Immediately release this version'
)
parser.add_argument(
'--force',
action='store_true',
help='Overwrite existing version'
)
parser.add_argument(
'--no-build',
action='store_true',
help='Skip building, only register existing ZIP'
)
def handle(self, *args, **options):
plugin_slug = options['plugin']
version = options['plugin_version']
changelog = options['changelog']
release = options['release']
force = options['force']
no_build = options['no_build']
# Validate version format
import re
if not re.match(r'^\d+\.\d+\.\d+$', version):
raise CommandError('Version must follow semantic versioning (e.g., 1.0.0)')
# Get or create plugin
try:
plugin = Plugin.objects.get(slug=plugin_slug)
self.stdout.write(f"Found plugin: {plugin.name}")
except Plugin.DoesNotExist:
# Create plugin if it doesn't exist
if plugin_slug == 'igny8-wp-bridge':
plugin = Plugin.objects.create(
name='IGNY8 WordPress Bridge',
slug='igny8-wp-bridge',
platform='wordpress',
description='Lightweight bridge plugin that connects WordPress to IGNY8 API for one-way content publishing.',
homepage_url='https://igny8.com',
is_active=True,
)
self.stdout.write(self.style.SUCCESS(f"Created plugin: {plugin.name}"))
else:
raise CommandError(f"Plugin not found: {plugin_slug}")
# Check if version already exists
existing = PluginVersion.objects.filter(plugin=plugin, version=version).first()
if existing and not force:
raise CommandError(
f"Version {version} already exists. Use --force to overwrite."
)
# Build plugin ZIP
if not no_build:
self.stdout.write(f"Building {plugin.name} v{version}...")
file_path, checksum, file_size = create_plugin_zip(
platform=plugin.platform,
plugin_slug=plugin.slug,
version=version,
update_version=True
)
if not file_path:
raise CommandError("Failed to build plugin ZIP")
self.stdout.write(self.style.SUCCESS(f"Built: {file_path}"))
self.stdout.write(f" Size: {file_size:,} bytes")
self.stdout.write(f" Checksum: {checksum}")
else:
# Find existing ZIP
file_path = f"{plugin.slug}-v{version}.zip"
full_path = get_plugin_file_path(plugin.platform, file_path)
if not full_path:
raise CommandError(f"ZIP file not found: {file_path}")
checksum = calculate_checksum(str(full_path))
file_size = full_path.stat().st_size
self.stdout.write(f"Using existing ZIP: {file_path}")
# Calculate version code
version_code = parse_version_to_code(version)
# Create or update version record
if existing:
existing.version_code = version_code
existing.file_path = file_path
existing.file_size = file_size
existing.checksum = checksum
existing.changelog = changelog
if release:
existing.status = 'released'
existing.released_at = timezone.now()
existing.save()
self.stdout.write(self.style.SUCCESS(f"Updated version {version}"))
else:
plugin_version = PluginVersion.objects.create(
plugin=plugin,
version=version,
version_code=version_code,
file_path=file_path,
file_size=file_size,
checksum=checksum,
changelog=changelog,
status='released' if release else 'draft',
released_at=timezone.now() if release else None,
)
self.stdout.write(self.style.SUCCESS(f"Created version {version}"))
if release:
self.stdout.write(self.style.SUCCESS(f"Version {version} is now released!"))
else:
self.stdout.write(
f"Version {version} created as draft. "
f"Use --release to make it available for download."
)

View File

@@ -0,0 +1,99 @@
"""
Django management command to push a plugin update to all installations.
Usage:
python manage.py push_plugin_update --plugin=igny8-wp-bridge --version=1.0.1
"""
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from igny8_core.plugins.models import Plugin, PluginVersion, PluginInstallation
class Command(BaseCommand):
help = 'Push a plugin update to all active installations'
def add_arguments(self, parser):
parser.add_argument(
'--plugin',
type=str,
required=True,
help='Plugin slug (e.g., igny8-wp-bridge)'
)
parser.add_argument(
'--plugin-version',
type=str,
required=True,
dest='plugin_version',
help='Version string (e.g., 1.0.1)'
)
parser.add_argument(
'--force',
action='store_true',
help='Mark as force update (critical security fix)'
)
def handle(self, *args, **options):
plugin_slug = options['plugin']
version_str = options['plugin_version']
force = options['force']
# Get plugin
try:
plugin = Plugin.objects.get(slug=plugin_slug)
except Plugin.DoesNotExist:
raise CommandError(f"Plugin not found: {plugin_slug}")
# Get version
try:
version = PluginVersion.objects.get(plugin=plugin, version=version_str)
except PluginVersion.DoesNotExist:
raise CommandError(f"Version not found: {version_str}")
# Check if released
if version.status not in ['released', 'update_ready']:
raise CommandError(
f"Version {version_str} must be released before pushing update. "
f"Current status: {version.status}"
)
# Mark as update ready
version.status = 'update_ready'
if force:
version.force_update = True
version.save(update_fields=['status', 'force_update'])
# Update all installations
installations = PluginInstallation.objects.filter(
plugin=plugin,
is_active=True
).exclude(current_version=version)
updated_count = installations.update(
pending_update=version,
update_notified_at=timezone.now()
)
self.stdout.write(
self.style.SUCCESS(
f"Pushed {plugin.name} v{version_str} to {updated_count} installations"
)
)
if force:
self.stdout.write(
self.style.WARNING(
"This is marked as a FORCE update (critical security fix)"
)
)
# Show summary
total_installations = PluginInstallation.objects.filter(
plugin=plugin,
is_active=True
).count()
already_updated = total_installations - updated_count
if already_updated > 0:
self.stdout.write(
f" {already_updated} installations already have v{version_str}"
)

View File

@@ -0,0 +1,149 @@
"""
Django management command to register an existing plugin version.
Useful for registering a ZIP file that was built manually or externally.
Usage:
python manage.py register_plugin_version --plugin=igny8-wp-bridge --version=1.0.1 [--changelog="Bug fixes"]
"""
import os
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from igny8_core.plugins.models import Plugin, PluginVersion
from igny8_core.plugins.utils import (
get_plugin_file_path,
calculate_checksum,
parse_version_to_code,
get_dist_path,
)
class Command(BaseCommand):
help = 'Register an existing plugin ZIP file as a version'
def add_arguments(self, parser):
parser.add_argument(
'--plugin',
type=str,
required=True,
help='Plugin slug (e.g., igny8-wp-bridge)'
)
parser.add_argument(
'--plugin-version',
type=str,
required=True,
dest='plugin_version',
help='Version string (e.g., 1.0.1)'
)
parser.add_argument(
'--changelog',
type=str,
default='',
help='Changelog for this version'
)
parser.add_argument(
'--release',
action='store_true',
help='Immediately release this version'
)
parser.add_argument(
'--min-php',
type=str,
default='7.4',
help='Minimum PHP version required'
)
def handle(self, *args, **options):
plugin_slug = options['plugin']
version_str = options['plugin_version']
changelog = options['changelog']
release = options['release']
min_php = options['min_php']
# Validate version format
import re
if not re.match(r'^\d+\.\d+\.\d+$', version_str):
raise CommandError('Version must follow semantic versioning (e.g., 1.0.0)')
# Get plugin
try:
plugin = Plugin.objects.get(slug=plugin_slug)
except Plugin.DoesNotExist:
# Create WordPress plugin if it doesn't exist
if plugin_slug == 'igny8-wp-bridge':
plugin = Plugin.objects.create(
name='IGNY8 WordPress Bridge',
slug='igny8-wp-bridge',
platform='wordpress',
description='Lightweight bridge plugin that connects WordPress to IGNY8 API for one-way content publishing.',
homepage_url='https://igny8.com',
is_active=True,
)
self.stdout.write(self.style.SUCCESS(f"Created plugin: {plugin.name}"))
else:
raise CommandError(f"Plugin not found: {plugin_slug}")
# Check if version already exists
if PluginVersion.objects.filter(plugin=plugin, version=version_str).exists():
raise CommandError(f"Version {version_str} already exists for {plugin.name}")
# Check if ZIP file exists
file_name = f"{plugin.slug}-v{version_str}.zip"
file_path = get_plugin_file_path(plugin.platform, file_name)
if not file_path:
# Also check for latest symlink
dist_path = get_dist_path(plugin.platform)
latest_link = dist_path / f"{plugin.slug}-latest.zip"
if latest_link.exists():
self.stdout.write(
self.style.WARNING(
f"ZIP file not found: {file_name}\n"
f"Found latest symlink. Creating expected filename..."
)
)
# Could copy or rename, but for now just fail
raise CommandError(
f"ZIP file not found: {file_name}\n"
f"Expected at: {dist_path}/{file_name}\n"
f"Build the plugin first with: ./scripts/build-wp-plugin.sh {version_str}"
)
# Calculate checksum and file size
checksum = calculate_checksum(str(file_path))
file_size = file_path.stat().st_size
# Calculate version code
version_code = parse_version_to_code(version_str)
# Create version record
plugin_version = PluginVersion.objects.create(
plugin=plugin,
version=version_str,
version_code=version_code,
file_path=file_name,
file_size=file_size,
checksum=checksum,
changelog=changelog,
min_php_version=min_php,
status='released' if release else 'draft',
released_at=timezone.now() if release else None,
)
self.stdout.write(
self.style.SUCCESS(
f"Registered {plugin.name} v{version_str}\n"
f" File: {file_name}\n"
f" Size: {file_size:,} bytes\n"
f" Checksum: {checksum}\n"
f" Status: {'released' if release else 'draft'}"
)
)
if not release:
self.stdout.write(
f"\nTo release this version, run:\n"
f" python manage.py release_plugin --plugin={plugin_slug} --version={version_str}"
)

View File

@@ -0,0 +1,70 @@
"""
Django management command to release a plugin version.
Usage:
python manage.py release_plugin --plugin=igny8-wp-bridge --version=1.0.1
"""
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from igny8_core.plugins.models import Plugin, PluginVersion
class Command(BaseCommand):
help = 'Release a plugin version (make it available for download)'
def add_arguments(self, parser):
parser.add_argument(
'--plugin',
type=str,
required=True,
help='Plugin slug (e.g., igny8-wp-bridge)'
)
parser.add_argument(
'--plugin-version',
type=str,
required=True,
dest='plugin_version',
help='Version string (e.g., 1.0.1)'
)
def handle(self, *args, **options):
plugin_slug = options['plugin']
version_str = options['plugin_version']
# Get plugin
try:
plugin = Plugin.objects.get(slug=plugin_slug)
except Plugin.DoesNotExist:
raise CommandError(f"Plugin not found: {plugin_slug}")
# Get version
try:
version = PluginVersion.objects.get(plugin=plugin, version=version_str)
except PluginVersion.DoesNotExist:
raise CommandError(f"Version not found: {version_str}")
# Check if already released
if version.status in ['released', 'update_ready']:
self.stdout.write(
self.style.WARNING(f"Version {version_str} is already released")
)
return
# Check if file exists
if not version.file_path:
raise CommandError(
f"No file associated with version {version_str}. "
f"Build the plugin first with: python manage.py build_plugin"
)
# Release
version.status = 'released'
version.released_at = timezone.now()
version.save(update_fields=['status', 'released_at'])
self.stdout.write(
self.style.SUCCESS(
f"Released {plugin.name} v{version_str}\n"
f" Download URL: /api/plugins/{plugin.slug}/download/"
)
)

View File

@@ -0,0 +1,107 @@
# Generated by Django 5.2.10 on 2026-01-09 20:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('igny8_core_auth', '0020_fix_historical_account'),
]
operations = [
migrations.CreateModel(
name='Plugin',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text="Human-readable plugin name (e.g., 'IGNY8 WP Bridge')", max_length=100)),
('slug', models.SlugField(help_text="URL-safe identifier (e.g., 'igny8-wp-bridge')", unique=True)),
('platform', models.CharField(choices=[('wordpress', 'WordPress'), ('shopify', 'Shopify'), ('custom', 'Custom Site')], help_text='Target platform for this plugin', max_length=20)),
('description', models.TextField(blank=True, help_text='Plugin description for display in download pages')),
('homepage_url', models.URLField(blank=True, help_text='Plugin homepage or documentation URL')),
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this plugin is available for download')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Plugin',
'verbose_name_plural': 'Plugins',
'db_table': 'plugins',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='PluginVersion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version', models.CharField(help_text="Semantic version string (e.g., '1.0.0', '1.0.1')", max_length=20)),
('version_code', models.IntegerField(help_text='Numeric version for comparison (1.0.1 -> 10001)')),
('status', models.CharField(choices=[('draft', 'Draft'), ('testing', 'Testing'), ('staged', 'Staged'), ('released', 'Released'), ('update_ready', 'Update Ready'), ('deprecated', 'Deprecated')], db_index=True, default='draft', help_text='Release status of this version', max_length=20)),
('file_path', models.CharField(help_text='Relative path to ZIP file in dist/ directory', max_length=500)),
('file_size', models.IntegerField(default=0, help_text='File size in bytes')),
('checksum', models.CharField(blank=True, help_text='SHA256 checksum for integrity verification', max_length=64)),
('changelog', models.TextField(blank=True, help_text="What's new in this version (supports Markdown)")),
('min_api_version', models.CharField(default='1.0', help_text='Minimum IGNY8 API version required', max_length=20)),
('min_platform_version', models.CharField(blank=True, help_text='Minimum platform version (e.g., WordPress 5.0)', max_length=20)),
('min_php_version', models.CharField(default='7.4', help_text='Minimum PHP version required (for WordPress plugins)', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('released_at', models.DateTimeField(blank=True, help_text='When this version was released', null=True)),
('force_update', models.BooleanField(default=False, help_text='Force update for critical security fixes')),
('plugin', models.ForeignKey(help_text='Plugin this version belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='plugins.plugin')),
],
options={
'verbose_name': 'Plugin Version',
'verbose_name_plural': 'Plugin Versions',
'db_table': 'plugin_versions',
'ordering': ['-version_code'],
'unique_together': {('plugin', 'version')},
},
),
migrations.CreateModel(
name='PluginInstallation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether the plugin is currently active')),
('last_health_check', models.DateTimeField(blank=True, help_text='Last successful health check timestamp', null=True)),
('health_status', models.CharField(choices=[('healthy', 'Healthy'), ('outdated', 'Outdated'), ('error', 'Error'), ('unknown', 'Unknown')], default='unknown', help_text='Current health status', max_length=20)),
('update_notified_at', models.DateTimeField(blank=True, help_text='When site was notified about pending update', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('plugin', models.ForeignKey(help_text='Installed plugin', on_delete=django.db.models.deletion.CASCADE, related_name='installations', to='plugins.plugin')),
('site', models.ForeignKey(help_text='Site where plugin is installed', on_delete=django.db.models.deletion.CASCADE, related_name='plugin_installations', to='igny8_core_auth.site')),
('current_version', models.ForeignKey(help_text='Currently installed version', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='current_installations', to='plugins.pluginversion')),
('pending_update', models.ForeignKey(blank=True, help_text='Pending version update', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pending_installations', to='plugins.pluginversion')),
],
options={
'verbose_name': 'Plugin Installation',
'verbose_name_plural': 'Plugin Installations',
'db_table': 'plugin_installations',
'ordering': ['-updated_at'],
'unique_together': {('site', 'plugin')},
},
),
migrations.CreateModel(
name='PluginDownload',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent', models.CharField(blank=True, max_length=500)),
('download_type', models.CharField(choices=[('manual', 'Manual Download'), ('update', 'Auto Update'), ('api', 'API Download')], default='manual', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('account', models.ForeignKey(blank=True, help_text='Account that initiated the download', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='plugin_downloads', to='igny8_core_auth.account')),
('plugin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='downloads', to='plugins.plugin')),
('site', models.ForeignKey(blank=True, help_text='Site that initiated the download (if authenticated)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='plugin_downloads', to='igny8_core_auth.site')),
('version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='downloads', to='plugins.pluginversion')),
],
options={
'verbose_name': 'Plugin Download',
'verbose_name_plural': 'Plugin Downloads',
'db_table': 'plugin_downloads',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['plugin', 'created_at'], name='plugin_down_plugin__5771ff_idx'), models.Index(fields=['version', 'created_at'], name='plugin_down_version_2bcf49_idx')],
},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.10 on 2026-01-09 23:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('plugins', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='pluginversion',
name='file_path',
field=models.CharField(blank=True, default='', help_text='Relative path to ZIP file in dist/ directory. Auto-generated on release.', max_length=500),
),
migrations.AlterField(
model_name='pluginversion',
name='version_code',
field=models.IntegerField(blank=True, help_text='Numeric version for comparison (1.0.1 -> 10001). Auto-calculated.', null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.10 on 2026-01-09 23:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('plugins', '0002_allow_blank_autogenerated_fields'),
]
operations = [
migrations.AlterField(
model_name='pluginversion',
name='status',
field=models.CharField(choices=[('draft', 'Draft'), ('released', 'Released'), ('deprecated', 'Deprecated')], db_index=True, default='draft', help_text='Release status of this version', max_length=20),
),
]

View File

@@ -0,0 +1,385 @@
"""
Plugin Distribution System Models
Tracks plugins, versions, and installations across sites.
"""
from django.db import models
from django.conf import settings
from django.utils import timezone
class Plugin(models.Model):
"""
Represents a plugin type (WordPress, Shopify, etc.)
Each plugin can have multiple versions and be installed on multiple sites.
"""
PLATFORM_CHOICES = [
('wordpress', 'WordPress'),
('shopify', 'Shopify'),
('custom', 'Custom Site'),
]
name = models.CharField(
max_length=100,
help_text="Human-readable plugin name (e.g., 'IGNY8 WP Bridge')"
)
slug = models.SlugField(
unique=True,
help_text="URL-safe identifier (e.g., 'igny8-wp-bridge')"
)
platform = models.CharField(
max_length=20,
choices=PLATFORM_CHOICES,
help_text="Target platform for this plugin"
)
description = models.TextField(
blank=True,
help_text="Plugin description for display in download pages"
)
homepage_url = models.URLField(
blank=True,
help_text="Plugin homepage or documentation URL"
)
is_active = models.BooleanField(
default=True,
db_index=True,
help_text="Whether this plugin is available for download"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'plugins'
ordering = ['name']
verbose_name = 'Plugin'
verbose_name_plural = 'Plugins'
def __str__(self):
return f"{self.name} ({self.platform})"
def get_latest_version(self):
"""Get the latest released version of this plugin."""
return self.versions.filter(
status='released'
).first()
def get_download_count(self):
"""Get total download count across all versions."""
return self.downloads.count()
class PluginVersion(models.Model):
"""
Tracks each version of a plugin.
Versions follow semantic versioning (major.minor.patch).
"""
STATUS_CHOICES = [
('draft', 'Draft'), # In development - NOT available for download
('released', 'Released'), # Available for download and updates
('deprecated', 'Deprecated'), # Old version, not recommended
]
plugin = models.ForeignKey(
Plugin,
on_delete=models.CASCADE,
related_name='versions',
help_text="Plugin this version belongs to"
)
version = models.CharField(
max_length=20,
help_text="Semantic version string (e.g., '1.0.0', '1.0.1')"
)
version_code = models.IntegerField(
null=True,
blank=True,
help_text="Numeric version for comparison (1.0.1 -> 10001). Auto-calculated."
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='draft',
db_index=True,
help_text="Release status of this version"
)
# File info
file_path = models.CharField(
max_length=500,
blank=True,
default='',
help_text="Relative path to ZIP file in dist/ directory. Auto-generated on release."
)
file_size = models.IntegerField(
default=0,
help_text="File size in bytes"
)
checksum = models.CharField(
max_length=64,
blank=True,
help_text="SHA256 checksum for integrity verification"
)
# Release info
changelog = models.TextField(
blank=True,
help_text="What's new in this version (supports Markdown)"
)
min_api_version = models.CharField(
max_length=20,
default='1.0',
help_text="Minimum IGNY8 API version required"
)
min_platform_version = models.CharField(
max_length=20,
blank=True,
help_text="Minimum platform version (e.g., WordPress 5.0)"
)
min_php_version = models.CharField(
max_length=10,
default='7.4',
help_text="Minimum PHP version required (for WordPress plugins)"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
released_at = models.DateTimeField(
null=True,
blank=True,
help_text="When this version was released"
)
# Auto-update control
force_update = models.BooleanField(
default=False,
help_text="Force update for critical security fixes"
)
class Meta:
db_table = 'plugin_versions'
unique_together = ['plugin', 'version']
ordering = ['-version_code']
verbose_name = 'Plugin Version'
verbose_name_plural = 'Plugin Versions'
def __str__(self):
return f"{self.plugin.name} v{self.version}"
def save(self, *args, **kwargs):
"""Calculate version_code from version string if not set."""
if not self.version_code:
self.version_code = self.parse_version_code(self.version)
super().save(*args, **kwargs)
@staticmethod
def parse_version_code(version_string):
"""
Convert version string to numeric code for comparison.
'1.0.0' -> 10000
'1.0.1' -> 10001
'1.2.3' -> 10203
'2.0.0' -> 20000
"""
try:
parts = version_string.split('.')
major = int(parts[0]) if len(parts) > 0 else 0
minor = int(parts[1]) if len(parts) > 1 else 0
patch = int(parts[2]) if len(parts) > 2 else 0
return major * 10000 + minor * 100 + patch
except (ValueError, IndexError):
return 0
def release(self):
"""Mark this version as released."""
self.status = 'released'
self.released_at = timezone.now()
self.save(update_fields=['status', 'released_at'])
def get_download_count(self):
"""Get download count for this version."""
return self.downloads.count()
class PluginInstallation(models.Model):
"""
Tracks where plugins are installed (per site).
This allows us to:
- Notify sites about available updates
- Track version distribution
- Monitor plugin health
"""
site = models.ForeignKey(
'igny8_core_auth.Site',
on_delete=models.CASCADE,
related_name='plugin_installations',
help_text="Site where plugin is installed"
)
plugin = models.ForeignKey(
Plugin,
on_delete=models.CASCADE,
related_name='installations',
help_text="Installed plugin"
)
current_version = models.ForeignKey(
PluginVersion,
on_delete=models.SET_NULL,
null=True,
related_name='current_installations',
help_text="Currently installed version"
)
# Installation status
is_active = models.BooleanField(
default=True,
db_index=True,
help_text="Whether the plugin is currently active"
)
last_health_check = models.DateTimeField(
null=True,
blank=True,
help_text="Last successful health check timestamp"
)
health_status = models.CharField(
max_length=20,
default='unknown',
choices=[
('healthy', 'Healthy'),
('outdated', 'Outdated'),
('error', 'Error'),
('unknown', 'Unknown'),
],
help_text="Current health status"
)
# Update tracking
pending_update = models.ForeignKey(
PluginVersion,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='pending_installations',
help_text="Pending version update"
)
update_notified_at = models.DateTimeField(
null=True,
blank=True,
help_text="When site was notified about pending update"
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'plugin_installations'
unique_together = ['site', 'plugin']
ordering = ['-updated_at']
verbose_name = 'Plugin Installation'
verbose_name_plural = 'Plugin Installations'
def __str__(self):
version_str = f" v{self.current_version.version}" if self.current_version else ""
return f"{self.plugin.name}{version_str} on {self.site.name}"
def check_for_update(self):
"""Check if an update is available for this installation."""
latest = self.plugin.get_latest_version()
if not latest or not self.current_version:
return None
if latest.version_code > self.current_version.version_code:
return latest
return None
def update_health_status(self):
"""Update health status based on current state."""
latest = self.plugin.get_latest_version()
if not self.current_version:
self.health_status = 'unknown'
elif latest and latest.version_code > self.current_version.version_code:
self.health_status = 'outdated'
else:
self.health_status = 'healthy'
self.last_health_check = timezone.now()
self.save(update_fields=['health_status', 'last_health_check'])
class PluginDownload(models.Model):
"""
Tracks plugin download events for analytics.
"""
plugin = models.ForeignKey(
Plugin,
on_delete=models.CASCADE,
related_name='downloads'
)
version = models.ForeignKey(
PluginVersion,
on_delete=models.CASCADE,
related_name='downloads'
)
# Download context
site = models.ForeignKey(
'igny8_core_auth.Site',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='plugin_downloads',
help_text="Site that initiated the download (if authenticated)"
)
account = models.ForeignKey(
'igny8_core_auth.Account',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='plugin_downloads',
help_text="Account that initiated the download"
)
# Request info
ip_address = models.GenericIPAddressField(
null=True,
blank=True
)
user_agent = models.CharField(
max_length=500,
blank=True
)
# Download type
download_type = models.CharField(
max_length=20,
default='manual',
choices=[
('manual', 'Manual Download'),
('update', 'Auto Update'),
('api', 'API Download'),
]
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'plugin_downloads'
ordering = ['-created_at']
indexes = [
models.Index(fields=['plugin', 'created_at']),
models.Index(fields=['version', 'created_at']),
]
verbose_name = 'Plugin Download'
verbose_name_plural = 'Plugin Downloads'
def __str__(self):
return f"Download: {self.plugin.name} v{self.version.version}"

View File

@@ -0,0 +1,238 @@
"""
Plugin Distribution System Serializers
"""
from rest_framework import serializers
from .models import Plugin, PluginVersion, PluginInstallation, PluginDownload
class PluginVersionSerializer(serializers.ModelSerializer):
"""Serializer for plugin versions."""
download_count = serializers.SerializerMethodField()
class Meta:
model = PluginVersion
fields = [
'id',
'version',
'version_code',
'status',
'file_size',
'checksum',
'changelog',
'min_api_version',
'min_platform_version',
'min_php_version',
'force_update',
'released_at',
'created_at',
'download_count',
]
read_only_fields = ['id', 'version_code', 'created_at', 'download_count']
def get_download_count(self, obj):
return obj.get_download_count()
class PluginSerializer(serializers.ModelSerializer):
"""Serializer for plugins."""
latest_version = serializers.SerializerMethodField()
download_count = serializers.SerializerMethodField()
class Meta:
model = Plugin
fields = [
'id',
'name',
'slug',
'platform',
'description',
'homepage_url',
'is_active',
'latest_version',
'download_count',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at']
def get_latest_version(self, obj):
latest = obj.get_latest_version()
if latest:
return {
'version': latest.version,
'version_code': latest.version_code,
'released_at': latest.released_at,
'changelog': latest.changelog,
}
return None
def get_download_count(self, obj):
return obj.get_download_count()
class PluginDetailSerializer(PluginSerializer):
"""Detailed serializer for plugins including versions."""
versions = PluginVersionSerializer(many=True, read_only=True)
class Meta(PluginSerializer.Meta):
fields = PluginSerializer.Meta.fields + ['versions']
class PluginInfoSerializer(serializers.Serializer):
"""
Serializer for WordPress plugin info endpoint.
Returns data in WordPress-compatible format.
"""
name = serializers.CharField()
slug = serializers.CharField()
version = serializers.CharField()
author = serializers.CharField(default='IGNY8')
homepage = serializers.URLField()
description = serializers.CharField()
changelog = serializers.CharField()
download_url = serializers.URLField()
file_size = serializers.IntegerField()
requires_php = serializers.CharField()
tested_wp = serializers.CharField(default='6.7')
class CheckUpdateSerializer(serializers.Serializer):
"""Serializer for update check response."""
update_available = serializers.BooleanField()
current_version = serializers.CharField()
latest_version = serializers.CharField(allow_null=True)
latest_version_code = serializers.IntegerField(allow_null=True)
download_url = serializers.URLField(allow_null=True)
changelog = serializers.CharField(allow_null=True)
info_url = serializers.URLField(allow_null=True)
force_update = serializers.BooleanField(default=False)
checksum = serializers.CharField(allow_null=True)
class RegisterInstallationSerializer(serializers.Serializer):
"""Serializer for registering plugin installations."""
site_id = serializers.IntegerField()
version = serializers.CharField()
def validate_site_id(self, value):
from igny8_core.auth.models import Site
try:
Site.objects.get(id=value)
except Site.DoesNotExist:
raise serializers.ValidationError("Site not found")
return value
class HealthCheckSerializer(serializers.Serializer):
"""Serializer for plugin health check."""
site_id = serializers.IntegerField()
version = serializers.CharField()
status = serializers.ChoiceField(choices=['active', 'inactive', 'error'])
error_message = serializers.CharField(required=False, allow_blank=True)
class PluginInstallationSerializer(serializers.ModelSerializer):
"""Serializer for plugin installations."""
plugin_name = serializers.CharField(source='plugin.name', read_only=True)
plugin_slug = serializers.CharField(source='plugin.slug', read_only=True)
current_version_str = serializers.CharField(source='current_version.version', read_only=True)
site_name = serializers.CharField(source='site.name', read_only=True)
pending_update_version = serializers.SerializerMethodField()
class Meta:
model = PluginInstallation
fields = [
'id',
'site',
'site_name',
'plugin',
'plugin_name',
'plugin_slug',
'current_version',
'current_version_str',
'is_active',
'last_health_check',
'health_status',
'pending_update',
'pending_update_version',
'update_notified_at',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at']
def get_pending_update_version(self, obj):
if obj.pending_update:
return obj.pending_update.version
return None
class PluginDownloadSerializer(serializers.ModelSerializer):
"""Serializer for plugin downloads."""
plugin_name = serializers.CharField(source='plugin.name', read_only=True)
version_str = serializers.CharField(source='version.version', read_only=True)
class Meta:
model = PluginDownload
fields = [
'id',
'plugin',
'plugin_name',
'version',
'version_str',
'site',
'account',
'download_type',
'created_at',
]
read_only_fields = ['id', 'created_at']
# Admin Serializers
class AdminPluginVersionCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating new plugin versions (admin)."""
class Meta:
model = PluginVersion
fields = [
'version',
'status',
'changelog',
'min_api_version',
'min_platform_version',
'min_php_version',
'force_update',
]
def validate_version(self, value):
"""Ensure version follows semantic versioning."""
import re
if not re.match(r'^\d+\.\d+\.\d+$', value):
raise serializers.ValidationError(
"Version must follow semantic versioning (e.g., 1.0.0)"
)
return value
class AdminPluginVersionUploadSerializer(serializers.Serializer):
"""Serializer for uploading plugin ZIP file."""
file = serializers.FileField()
def validate_file(self, value):
"""Ensure uploaded file is a ZIP."""
if not value.name.endswith('.zip'):
raise serializers.ValidationError("File must be a ZIP archive")
# Check file size (max 50MB)
max_size = 50 * 1024 * 1024
if value.size > max_size:
raise serializers.ValidationError(
f"File too large. Maximum size is {max_size / 1024 / 1024}MB"
)
return value

View File

@@ -0,0 +1,86 @@
"""
Plugin Distribution System Signals
Handles automatic ZIP creation when plugin versions are released.
"""
import logging
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from django.utils import timezone
from .models import PluginVersion
from .utils import create_plugin_zip
logger = logging.getLogger(__name__)
@receiver(pre_save, sender=PluginVersion)
def auto_build_plugin_on_release(sender, instance, **kwargs):
"""
Automatically build ZIP package when a version is marked as released.
This ensures:
1. ZIP file is always up-to-date with source code
2. File size and checksum are auto-calculated
3. No manual intervention needed for releases
Triggers on:
- New version created with status 'released'
- Existing version status changed to 'released'
Note: Only 'released' status makes the version available for download.
"""
# Only build ZIP when status is 'released'
should_build = False
if not instance.pk:
# New instance - build if status is released
if instance.status == 'released':
should_build = True
logger.info(f"New plugin version {instance.plugin.slug} v{instance.version} created with status 'released' - building ZIP")
else:
# Existing instance - check if status changed to released
try:
old_instance = PluginVersion.objects.get(pk=instance.pk)
old_status = old_instance.status
new_status = instance.status
# Build if moving to released from any other status
if new_status == 'released' and old_status != 'released':
should_build = True
logger.info(f"Building plugin ZIP for {instance.plugin.slug} v{instance.version} (status: {old_status} -> released)")
elif old_status == 'released' and new_status == 'released':
# No status change, already released - no rebuild
return
except PluginVersion.DoesNotExist:
return
if not should_build:
return
# Build the ZIP
try:
file_path, checksum, file_size = create_plugin_zip(
platform=instance.plugin.platform,
plugin_slug=instance.plugin.slug,
version=instance.version,
update_version=True
)
if not file_path:
logger.error(f"Failed to build ZIP for {instance.plugin.slug} v{instance.version}")
return
# Update the instance with new file info
instance.file_path = file_path
instance.checksum = checksum
instance.file_size = file_size
# Set released_at if not already set
if not instance.released_at:
instance.released_at = timezone.now()
logger.info(f"Built plugin ZIP: {file_path} ({file_size} bytes, checksum: {checksum[:16]}...)")
except Exception as e:
logger.error(f"Error building ZIP for {instance.plugin.slug} v{instance.version}: {e}")

View File

@@ -0,0 +1,62 @@
"""
Plugin Distribution System URL Routes
"""
from django.urls import path
from . import views
app_name = 'plugins'
urlpatterns = [
# ============================================================================
# Public Endpoints (No Auth Required)
# ============================================================================
# Download latest version of a plugin
path('<slug:slug>/download/', views.download_plugin, name='download'),
# Check for updates (called by installed plugins)
path('<slug:slug>/check-update/', views.check_update, name='check-update'),
# Get plugin information (WordPress compatible)
path('<slug:slug>/info/', views.plugin_info, name='info'),
# Get latest plugin for a platform
path('<str:platform>/latest/', views.get_latest_plugin, name='latest-by-platform'),
# ============================================================================
# Authenticated Endpoints
# ============================================================================
# Register plugin installation
path('<slug:slug>/register-installation/', views.register_installation, name='register-installation'),
# Report health status
path('<slug:slug>/health-check/', views.health_check, name='health-check'),
# Track download (for analytics)
path('<slug:slug>/track-download/', views.track_download, name='track-download'),
]
# Admin URL patterns (to be included under /api/admin/plugins/)
admin_urlpatterns = [
# List all plugins / Create new plugin
path('', views.AdminPluginListView.as_view(), name='admin-plugin-list'),
# Plugin versions management
path('<slug:slug>/versions/', views.AdminPluginVersionsView.as_view(), name='admin-versions'),
path('<slug:slug>/versions/<str:version>/', views.AdminPluginVersionDetailView.as_view(), name='admin-version-detail'),
# Release a version
path('<slug:slug>/versions/<str:version>/release/', views.admin_release_version, name='admin-release'),
# Push update to installations
path('<slug:slug>/versions/<str:version>/push-update/', views.admin_push_update, name='admin-push-update'),
# View installations
path('installations/', views.AdminPluginInstallationsView.as_view(), name='admin-all-installations'),
path('<slug:slug>/installations/', views.AdminPluginInstallationsView.as_view(), name='admin-installations'),
# Statistics
path('stats/', views.AdminPluginStatsView.as_view(), name='admin-all-stats'),
path('<slug:slug>/stats/', views.AdminPluginStatsView.as_view(), name='admin-stats'),
]

View File

@@ -0,0 +1,324 @@
"""
Plugin Distribution System Utilities
Helper functions for plugin management, ZIP creation, and versioning.
"""
import os
import hashlib
import zipfile
import shutil
import logging
from pathlib import Path
from typing import Optional, Tuple
from django.conf import settings
logger = logging.getLogger(__name__)
# Base paths for plugin storage
PLUGINS_ROOT = Path('/data/app/igny8/plugins')
WORDPRESS_SOURCE = PLUGINS_ROOT / 'wordpress' / 'source'
WORDPRESS_DIST = PLUGINS_ROOT / 'wordpress' / 'dist'
SHOPIFY_SOURCE = PLUGINS_ROOT / 'shopify' / 'source'
SHOPIFY_DIST = PLUGINS_ROOT / 'shopify' / 'dist'
CUSTOM_SOURCE = PLUGINS_ROOT / 'custom-site' / 'source'
CUSTOM_DIST = PLUGINS_ROOT / 'custom-site' / 'dist'
PLATFORM_PATHS = {
'wordpress': {
'source': WORDPRESS_SOURCE,
'dist': WORDPRESS_DIST,
},
'shopify': {
'source': SHOPIFY_SOURCE,
'dist': SHOPIFY_DIST,
},
'custom': {
'source': CUSTOM_SOURCE,
'dist': CUSTOM_DIST,
},
}
def get_source_path(platform: str, plugin_slug: str) -> Path:
"""Get the source directory path for a plugin."""
paths = PLATFORM_PATHS.get(platform)
if not paths:
raise ValueError(f"Unknown platform: {platform}")
return paths['source'] / plugin_slug
def get_dist_path(platform: str) -> Path:
"""Get the distribution directory path for a platform."""
paths = PLATFORM_PATHS.get(platform)
if not paths:
raise ValueError(f"Unknown platform: {platform}")
return paths['dist']
def calculate_checksum(file_path: str) -> str:
"""Calculate SHA256 checksum of a file."""
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256_hash.update(chunk)
return sha256_hash.hexdigest()
def parse_version_to_code(version_string: str) -> int:
"""
Convert version string to numeric code for comparison.
'1.0.0' -> 10000
'1.0.1' -> 10001
'1.2.3' -> 10203
'2.0.0' -> 20000
"""
try:
parts = version_string.split('.')
major = int(parts[0]) if len(parts) > 0 else 0
minor = int(parts[1]) if len(parts) > 1 else 0
patch = int(parts[2]) if len(parts) > 2 else 0
return major * 10000 + minor * 100 + patch
except (ValueError, IndexError):
return 0
def code_to_version(version_code: int) -> str:
"""Convert version code back to version string."""
major = version_code // 10000
minor = (version_code % 10000) // 100
patch = version_code % 100
return f"{major}.{minor}.{patch}"
def update_php_version(file_path: str, new_version: str) -> bool:
"""
Update version numbers in a PHP plugin file.
Updates both the plugin header and any $version variables.
"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
import re
# Update "Version: X.X.X" in plugin header
content = re.sub(
r'(Version:\s*)\d+\.\d+\.\d+',
f'\\g<1>{new_version}',
content
)
# Update IGNY8_BRIDGE_VERSION constant
content = re.sub(
r"(define\s*\(\s*'IGNY8_BRIDGE_VERSION'\s*,\s*')[^']+(')",
f"\\g<1>{new_version}\\g<2>",
content
)
# Update $version variable
content = re.sub(
r"(\$version\s*=\s*')[^']+(')",
f"\\g<1>{new_version}\\g<2>",
content
)
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
return True
except Exception as e:
logger.error(f"Failed to update PHP version: {e}")
return False
def create_plugin_zip(
platform: str,
plugin_slug: str,
version: str,
update_version: bool = True
) -> Tuple[Optional[str], Optional[str], Optional[int]]:
"""
Create a ZIP file for plugin distribution.
Args:
platform: Target platform ('wordpress', 'shopify', 'custom')
plugin_slug: Plugin slug (e.g., 'igny8-wp-bridge')
version: Version string (e.g., '1.0.1')
update_version: Whether to update version numbers in source files
Returns:
Tuple of (file_path, checksum, file_size) or (None, None, None) on error
"""
try:
source_path = get_source_path(platform, plugin_slug)
dist_path = get_dist_path(platform)
if not source_path.exists():
logger.error(f"Source path does not exist: {source_path}")
return None, None, None
# Ensure dist directory exists
dist_path.mkdir(parents=True, exist_ok=True)
# Create temp directory for packaging
import tempfile
temp_dir = tempfile.mkdtemp()
package_dir = Path(temp_dir) / plugin_slug
try:
# Copy source files to temp directory
shutil.copytree(source_path, package_dir)
# Update version in main plugin file if requested
if update_version and platform == 'wordpress':
main_file = package_dir / f"{plugin_slug}.php"
# Handle renamed plugin file
if not main_file.exists():
main_file = package_dir / "igny8-bridge.php"
if main_file.exists():
update_php_version(str(main_file), version)
# Remove unwanted files
patterns_to_remove = [
'**/__pycache__',
'**/*.pyc',
'**/.git',
'**/.gitignore',
'**/tests',
'**/tester',
'**/.DS_Store',
'**/*.bak',
'**/*.tmp',
'**/*.log',
]
for pattern in patterns_to_remove:
for path in package_dir.glob(pattern):
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
# Create ZIP file
zip_filename = f"{plugin_slug}-v{version}.zip"
zip_path = dist_path / zip_filename
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(package_dir):
for file in files:
file_path = Path(root) / file
arcname = file_path.relative_to(temp_dir)
zipf.write(file_path, arcname)
# Create/update latest symlink
latest_link = dist_path / f"{plugin_slug}-latest.zip"
if latest_link.exists() or latest_link.is_symlink():
latest_link.unlink()
latest_link.symlink_to(zip_filename)
# Calculate checksum and file size
checksum = calculate_checksum(str(zip_path))
file_size = zip_path.stat().st_size
logger.info(f"Created plugin ZIP: {zip_path} ({file_size} bytes)")
return zip_filename, checksum, file_size
finally:
# Clean up temp directory
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception as e:
logger.exception(f"Failed to create plugin ZIP: {e}")
return None, None, None
def get_plugin_file_path(platform: str, file_name: str) -> Optional[Path]:
"""Get the full path to a plugin ZIP file."""
dist_path = get_dist_path(platform)
file_path = dist_path / file_name
if file_path.exists():
return file_path
return None
def verify_checksum(file_path: str, expected_checksum: str) -> bool:
"""Verify a file's checksum matches expected value."""
actual_checksum = calculate_checksum(file_path)
return actual_checksum == expected_checksum
def get_installed_version_from_header(file_path: str) -> Optional[str]:
"""Extract version from a PHP plugin file header."""
try:
with open(file_path, 'r', encoding='utf-8') as f:
# Read first 8KB which should contain the header
content = f.read(8192)
import re
match = re.search(r'Version:\s*(\d+\.\d+\.\d+)', content)
if match:
return match.group(1)
return None
except Exception as e:
logger.error(f"Failed to extract version from {file_path}: {e}")
return None
def list_available_versions(platform: str, plugin_slug: str) -> list:
"""List all available versions for a plugin."""
dist_path = get_dist_path(platform)
versions = []
import re
pattern = re.compile(rf'^{re.escape(plugin_slug)}-v(\d+\.\d+\.\d+)\.zip$')
for file in dist_path.iterdir():
if file.is_file():
match = pattern.match(file.name)
if match:
versions.append({
'version': match.group(1),
'version_code': parse_version_to_code(match.group(1)),
'file_name': file.name,
'file_size': file.stat().st_size,
})
# Sort by version_code descending
versions.sort(key=lambda x: x['version_code'], reverse=True)
return versions
def cleanup_old_versions(platform: str, plugin_slug: str, keep_count: int = 5) -> int:
"""
Remove old version ZIP files, keeping the most recent ones.
Args:
platform: Target platform
plugin_slug: Plugin slug
keep_count: Number of recent versions to keep
Returns:
Number of versions removed
"""
versions = list_available_versions(platform, plugin_slug)
removed = 0
if len(versions) > keep_count:
versions_to_remove = versions[keep_count:]
dist_path = get_dist_path(platform)
for version_info in versions_to_remove:
file_path = dist_path / version_info['file_name']
try:
file_path.unlink()
removed += 1
logger.info(f"Removed old version: {file_path}")
except Exception as e:
logger.error(f"Failed to remove {file_path}: {e}")
return removed

View File

@@ -0,0 +1,658 @@
"""
Plugin Distribution System Views
API endpoints for plugin distribution, updates, and management.
"""
import logging
from django.http import FileResponse, Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny, IsAuthenticated, IsAdminUser
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Plugin, PluginVersion, PluginInstallation, PluginDownload
from .serializers import (
PluginSerializer,
PluginDetailSerializer,
PluginVersionSerializer,
PluginInfoSerializer,
CheckUpdateSerializer,
RegisterInstallationSerializer,
HealthCheckSerializer,
PluginInstallationSerializer,
AdminPluginVersionCreateSerializer,
AdminPluginVersionUploadSerializer,
)
from .utils import get_plugin_file_path, parse_version_to_code
logger = logging.getLogger(__name__)
# ============================================================================
# Public Endpoints (No Auth Required)
# ============================================================================
@api_view(['GET'])
@permission_classes([AllowAny])
def download_plugin(request, slug):
"""
Download the latest version of a plugin.
GET /api/plugins/{slug}/download/
Returns the ZIP file for the latest released version.
"""
plugin = get_object_or_404(Plugin, slug=slug, is_active=True)
latest = plugin.get_latest_version()
if not latest:
return Response(
{'error': 'No version available for download'},
status=status.HTTP_404_NOT_FOUND
)
file_path = get_plugin_file_path(plugin.platform, latest.file_path)
if not file_path or not file_path.exists():
logger.error(f"Plugin file not found: {latest.file_path}")
return Response(
{'error': 'Plugin file not found'},
status=status.HTTP_404_NOT_FOUND
)
# Track download
PluginDownload.objects.create(
plugin=plugin,
version=latest,
ip_address=get_client_ip(request),
user_agent=request.META.get('HTTP_USER_AGENT', '')[:500],
download_type='manual',
)
response = FileResponse(
open(file_path, 'rb'),
content_type='application/zip'
)
response['Content-Disposition'] = f'attachment; filename="{latest.file_path}"'
response['Content-Length'] = latest.file_size
return response
@api_view(['GET'])
@permission_classes([AllowAny])
def check_update(request, slug):
"""
Check if an update is available for a plugin.
GET /api/plugins/{slug}/check-update/?current_version=1.0.0
Called by installed plugins to check for updates.
Returns update availability and download URL if available.
"""
plugin = get_object_or_404(Plugin, slug=slug, is_active=True)
current_version = request.query_params.get('current_version', '0.0.0')
current_code = parse_version_to_code(current_version)
latest = plugin.get_latest_version()
if not latest:
return Response({
'update_available': False,
'current_version': current_version,
'latest_version': None,
'latest_version_code': None,
'download_url': None,
'changelog': None,
'info_url': None,
'force_update': False,
'checksum': None,
})
update_available = latest.version_code > current_code
# Build download URL
download_url = None
if update_available:
download_url = request.build_absolute_uri(f'/api/plugins/{slug}/download/')
response_data = {
'update_available': update_available,
'current_version': current_version,
'latest_version': latest.version if update_available else None,
'latest_version_code': latest.version_code if update_available else None,
'download_url': download_url,
'changelog': latest.changelog if update_available else None,
'info_url': request.build_absolute_uri(f'/api/plugins/{slug}/info/'),
'force_update': latest.force_update if update_available else False,
'checksum': latest.checksum if update_available else None,
}
serializer = CheckUpdateSerializer(response_data)
return Response(serializer.data)
@api_view(['GET'])
@permission_classes([AllowAny])
def plugin_info(request, slug):
"""
Get plugin information in WordPress-compatible format.
GET /api/plugins/{slug}/info/
Returns detailed plugin information for WordPress plugin info modal.
"""
plugin = get_object_or_404(Plugin, slug=slug, is_active=True)
latest = plugin.get_latest_version()
if not latest:
return Response(
{'error': 'No version available'},
status=status.HTTP_404_NOT_FOUND
)
info_data = {
'name': plugin.name,
'slug': plugin.slug,
'version': latest.version,
'author': 'IGNY8',
'homepage': plugin.homepage_url or 'https://igny8.com',
'description': plugin.description,
'changelog': latest.changelog,
'download_url': request.build_absolute_uri(f'/api/plugins/{slug}/download/'),
'file_size': latest.file_size,
'requires_php': latest.min_php_version,
'tested_wp': '6.7',
}
serializer = PluginInfoSerializer(info_data)
return Response(serializer.data)
@api_view(['GET'])
@permission_classes([AllowAny])
def get_latest_plugin(request, platform):
"""
Get the latest plugin for a platform.
GET /api/plugins/{platform}/latest/
Returns plugin info and download URL for the specified platform.
"""
valid_platforms = ['wordpress', 'shopify', 'custom']
if platform not in valid_platforms:
return Response(
{'error': f'Invalid platform. Must be one of: {valid_platforms}'},
status=status.HTTP_400_BAD_REQUEST
)
plugin = Plugin.objects.filter(platform=platform, is_active=True).first()
if not plugin:
return Response(
{'error': f'No plugin available for platform: {platform}'},
status=status.HTTP_404_NOT_FOUND
)
latest = plugin.get_latest_version()
if not latest:
return Response(
{'error': 'No version available'},
status=status.HTTP_404_NOT_FOUND
)
return Response({
'name': plugin.name,
'slug': plugin.slug,
'version': latest.version,
'download_url': request.build_absolute_uri(f'/api/plugins/{plugin.slug}/download/'),
'file_size': latest.file_size,
'platform': plugin.platform,
'description': plugin.description,
'changelog': latest.changelog,
'released_at': latest.released_at,
})
# ============================================================================
# Authenticated Endpoints
# ============================================================================
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def register_installation(request, slug):
"""
Register a plugin installation on a site.
POST /api/plugins/{slug}/register-installation/
Body: { "site_id": 123, "version": "1.0.0" }
Called after plugin is activated on a site.
"""
plugin = get_object_or_404(Plugin, slug=slug, is_active=True)
serializer = RegisterInstallationSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
from igny8_core.auth.models import Site
site_id = serializer.validated_data['site_id']
version_str = serializer.validated_data['version']
# Verify site belongs to user's account
site = get_object_or_404(Site, id=site_id)
if hasattr(request, 'account') and site.account_id != request.account.id:
return Response(
{'error': 'Site does not belong to your account'},
status=status.HTTP_403_FORBIDDEN
)
# Find version record
try:
version = PluginVersion.objects.get(plugin=plugin, version=version_str)
except PluginVersion.DoesNotExist:
version = None
# Create or update installation record
installation, created = PluginInstallation.objects.update_or_create(
site=site,
plugin=plugin,
defaults={
'current_version': version,
'is_active': True,
'last_health_check': timezone.now(),
'health_status': 'healthy',
}
)
action = 'registered' if created else 'updated'
logger.info(f"Plugin installation {action}: {plugin.name} v{version_str} on site {site.name}")
return Response({
'success': True,
'message': f'Installation {action} successfully',
'installation_id': installation.id,
})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def health_check(request, slug):
"""
Report plugin health status.
POST /api/plugins/{slug}/health-check/
Body: { "site_id": 123, "version": "1.0.0", "status": "active" }
Called periodically by installed plugins to report status.
"""
plugin = get_object_or_404(Plugin, slug=slug, is_active=True)
serializer = HealthCheckSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
from igny8_core.auth.models import Site
site_id = serializer.validated_data['site_id']
version_str = serializer.validated_data['version']
plugin_status = serializer.validated_data['status']
site = get_object_or_404(Site, id=site_id)
try:
installation = PluginInstallation.objects.get(site=site, plugin=plugin)
except PluginInstallation.DoesNotExist:
# Auto-register if not exists
try:
version = PluginVersion.objects.get(plugin=plugin, version=version_str)
except PluginVersion.DoesNotExist:
version = None
installation = PluginInstallation.objects.create(
site=site,
plugin=plugin,
current_version=version,
is_active=plugin_status == 'active',
)
# Update installation status
installation.last_health_check = timezone.now()
installation.is_active = plugin_status == 'active'
if plugin_status == 'error':
installation.health_status = 'error'
else:
installation.update_health_status()
installation.save()
# Check if update is available
update_available = installation.check_for_update()
return Response({
'success': True,
'health_status': installation.health_status,
'update_available': update_available is not None,
'latest_version': update_available.version if update_available else None,
})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def track_download(request, slug):
"""
Track a plugin download for analytics.
POST /api/plugins/{slug}/track-download/
Called before redirecting to download URL.
"""
plugin = get_object_or_404(Plugin, slug=slug, is_active=True)
latest = plugin.get_latest_version()
if not latest:
return Response(
{'error': 'No version available'},
status=status.HTTP_404_NOT_FOUND
)
# Get site if provided
site = None
site_id = request.data.get('site_id')
if site_id:
from igny8_core.auth.models import Site
try:
site = Site.objects.get(id=site_id)
except Site.DoesNotExist:
pass
# Get account from request
account = getattr(request, 'account', None)
# Track download
PluginDownload.objects.create(
plugin=plugin,
version=latest,
site=site,
account=account,
ip_address=get_client_ip(request),
user_agent=request.META.get('HTTP_USER_AGENT', '')[:500],
download_type='manual',
)
return Response({
'success': True,
'download_url': request.build_absolute_uri(f'/api/plugins/{slug}/download/'),
})
# ============================================================================
# Admin Endpoints
# ============================================================================
class AdminPluginListView(APIView):
"""List all plugins and their versions (admin)."""
permission_classes = [IsAdminUser]
def get(self, request):
plugins = Plugin.objects.prefetch_related('versions').all()
serializer = PluginDetailSerializer(plugins, many=True)
return Response(serializer.data)
def post(self, request):
"""Create a new plugin."""
serializer = PluginSerializer(data=request.data)
if serializer.is_valid():
plugin = serializer.save()
return Response(
PluginSerializer(plugin).data,
status=status.HTTP_201_CREATED
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class AdminPluginVersionsView(APIView):
"""Manage plugin versions (admin)."""
permission_classes = [IsAdminUser]
def get(self, request, slug):
"""List all versions for a plugin."""
plugin = get_object_or_404(Plugin, slug=slug)
versions = plugin.versions.all()
serializer = PluginVersionSerializer(versions, many=True)
return Response(serializer.data)
def post(self, request, slug):
"""Create a new version for a plugin."""
plugin = get_object_or_404(Plugin, slug=slug)
serializer = AdminPluginVersionCreateSerializer(data=request.data)
if serializer.is_valid():
# Check if version already exists
version_str = serializer.validated_data['version']
if PluginVersion.objects.filter(plugin=plugin, version=version_str).exists():
return Response(
{'error': f'Version {version_str} already exists'},
status=status.HTTP_400_BAD_REQUEST
)
version = serializer.save(plugin=plugin)
return Response(
PluginVersionSerializer(version).data,
status=status.HTTP_201_CREATED
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class AdminPluginVersionDetailView(APIView):
"""Manage a specific plugin version (admin)."""
permission_classes = [IsAdminUser]
def get(self, request, slug, version):
"""Get version details."""
plugin = get_object_or_404(Plugin, slug=slug)
version_obj = get_object_or_404(PluginVersion, plugin=plugin, version=version)
serializer = PluginVersionSerializer(version_obj)
return Response(serializer.data)
def patch(self, request, slug, version):
"""Update version details."""
plugin = get_object_or_404(Plugin, slug=slug)
version_obj = get_object_or_404(PluginVersion, plugin=plugin, version=version)
serializer = PluginVersionSerializer(version_obj, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, version):
"""Delete a version (only drafts)."""
plugin = get_object_or_404(Plugin, slug=slug)
version_obj = get_object_or_404(PluginVersion, plugin=plugin, version=version)
if version_obj.status not in ['draft', 'testing']:
return Response(
{'error': 'Can only delete draft or testing versions'},
status=status.HTTP_400_BAD_REQUEST
)
version_obj.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['POST'])
@permission_classes([IsAdminUser])
def admin_release_version(request, slug, version):
"""
Release a plugin version.
POST /api/admin/plugins/{slug}/versions/{version}/release/
Makes the version available for download.
"""
plugin = get_object_or_404(Plugin, slug=slug)
version_obj = get_object_or_404(PluginVersion, plugin=plugin, version=version)
if version_obj.status in ['released', 'update_ready']:
return Response(
{'error': 'Version is already released'},
status=status.HTTP_400_BAD_REQUEST
)
if not version_obj.file_path:
return Response(
{'error': 'No file uploaded for this version'},
status=status.HTTP_400_BAD_REQUEST
)
version_obj.release()
logger.info(f"Released plugin version: {plugin.name} v{version}")
return Response({
'success': True,
'message': f'Version {version} released successfully',
'released_at': version_obj.released_at,
})
@api_view(['POST'])
@permission_classes([IsAdminUser])
def admin_push_update(request, slug, version):
"""
Push update to all installed sites.
POST /api/admin/plugins/{slug}/versions/{version}/push-update/
Sets status to 'update_ready' and notifies installations.
"""
plugin = get_object_or_404(Plugin, slug=slug)
version_obj = get_object_or_404(PluginVersion, plugin=plugin, version=version)
if version_obj.status not in ['released', 'update_ready']:
return Response(
{'error': 'Version must be released before pushing update'},
status=status.HTTP_400_BAD_REQUEST
)
# Mark as update ready
version_obj.status = 'update_ready'
version_obj.save(update_fields=['status'])
# Update all installations with pending update
installations = PluginInstallation.objects.filter(
plugin=plugin,
is_active=True
).exclude(current_version=version_obj)
updated_count = installations.update(
pending_update=version_obj,
update_notified_at=timezone.now()
)
logger.info(f"Pushed update {plugin.name} v{version} to {updated_count} installations")
return Response({
'success': True,
'message': f'Update pushed to {updated_count} installations',
'installations_notified': updated_count,
})
class AdminPluginInstallationsView(APIView):
"""View plugin installations (admin)."""
permission_classes = [IsAdminUser]
def get(self, request, slug=None):
"""List installations, optionally filtered by plugin."""
if slug:
plugin = get_object_or_404(Plugin, slug=slug)
installations = PluginInstallation.objects.filter(plugin=plugin)
else:
installations = PluginInstallation.objects.all()
installations = installations.select_related(
'site', 'plugin', 'current_version', 'pending_update'
)
serializer = PluginInstallationSerializer(installations, many=True)
return Response(serializer.data)
class AdminPluginStatsView(APIView):
"""Get plugin statistics (admin)."""
permission_classes = [IsAdminUser]
def get(self, request, slug=None):
"""Get download and installation statistics."""
from django.db.models import Count
from django.db.models.functions import TruncDate
plugins_qs = Plugin.objects.all()
if slug:
plugins_qs = plugins_qs.filter(slug=slug)
stats = []
for plugin in plugins_qs:
# Version distribution
version_dist = PluginInstallation.objects.filter(
plugin=plugin,
is_active=True
).values('current_version__version').annotate(
count=Count('id')
).order_by('-count')
# Download counts by day (last 30 days)
from datetime import timedelta
thirty_days_ago = timezone.now() - timedelta(days=30)
daily_downloads = PluginDownload.objects.filter(
plugin=plugin,
created_at__gte=thirty_days_ago
).annotate(
date=TruncDate('created_at')
).values('date').annotate(
count=Count('id')
).order_by('date')
# Health status distribution
health_dist = PluginInstallation.objects.filter(
plugin=plugin
).values('health_status').annotate(
count=Count('id')
)
stats.append({
'plugin': plugin.slug,
'name': plugin.name,
'total_downloads': plugin.get_download_count(),
'active_installations': PluginInstallation.objects.filter(
plugin=plugin, is_active=True
).count(),
'version_distribution': list(version_dist),
'daily_downloads': list(daily_downloads),
'health_distribution': list(health_dist),
})
if slug and stats:
return Response(stats[0])
return Response(stats)
# ============================================================================
# Helper Functions
# ============================================================================
def get_client_ip(request):
"""Extract client IP from request."""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0].strip()
else:
ip = request.META.get('REMOTE_ADDR')
return ip

View File

@@ -73,6 +73,7 @@ INSTALLED_APPS = [
'igny8_core.modules.optimizer.apps.OptimizerConfig',
'igny8_core.modules.publisher.apps.PublisherConfig',
'igny8_core.modules.integration.apps.IntegrationConfig',
'igny8_core.plugins.apps.PluginsConfig', # Plugin Distribution System
]
# System module needs explicit registration for admin
@@ -763,14 +764,16 @@ UNFOLD = {
{"title": "Sync Events", "icon": "sync", "link": lambda request: "/admin/integration/syncevent/"},
],
},
# Automation
# Plugin Management
{
"title": "Automation",
"icon": "smart_toy",
"title": "Plugin Management",
"icon": "extension",
"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/"},
{"title": "Plugins", "icon": "apps", "link": lambda request: "/admin/plugins/plugin/"},
{"title": "Plugin Versions", "icon": "new_releases", "link": lambda request: "/admin/plugins/pluginversion/"},
{"title": "Installations", "icon": "cloud_download", "link": lambda request: "/admin/plugins/plugininstallation/"},
{"title": "Downloads", "icon": "download", "link": lambda request: "/admin/plugins/plugindownload/"},
],
},
# AI Configuration
@@ -779,9 +782,10 @@ UNFOLD = {
"icon": "psychology",
"collapsible": True,
"items": [
{"title": "System AI Settings", "icon": "tune", "link": lambda request: "/admin/system/systemaisettings/"},
{"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": "Credit Costs by Function", "icon": "calculate", "link": lambda request: "/admin/billing/creditcostconfig/"},
{"title": "Billing Configuration", "icon": "payments", "link": lambda request: "/admin/billing/billingconfiguration/"},
{"title": "AI Task Logs", "icon": "history", "link": lambda request: "/admin/ai/aitasklog/"},
],
},
@@ -804,13 +808,25 @@ UNFOLD = {
"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": "Global AI Prompts", "icon": "chat", "link": lambda request: "/admin/system/globalaiprompt/"},
{"title": "Automation Configs", "icon": "settings_suggest", "link": lambda request: "/admin/automation/automationconfig/"},
{"title": "Automation Runs", "icon": "play_circle", "link": lambda request: "/admin/automation/automationrun/"},
{"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/"},
],
},
# System Configuration
{
"title": "System Configuration",
"icon": "tune",
"collapsible": True,
"items": [
{"title": "Account Settings (All Settings)", "icon": "account_circle", "link": lambda request: "/admin/system/accountsettings/"},
{"title": "User Settings", "icon": "person_search", "link": lambda request: "/admin/system/usersettings/"},
{"title": "Module Settings", "icon": "view_module", "link": lambda request: "/admin/system/modulesettings/"},
],
},
# Resources
{
"title": "Resources",

View File

@@ -61,8 +61,8 @@ def publish_content_to_wordpress(self, content_id: int, site_integration_id: int
site_domain = base_url.replace('https://', '').replace('http://', '').split('/')[0] if base_url else 'unknown'
log_prefix = f"[{site_id}-{site_domain}]"
# Extract API key from credentials
api_key = site_integration.get_credentials().get('api_key', '')
# API key is stored in Site.wp_api_key (SINGLE source of truth)
api_key = site_integration.site.wp_api_key or ''
publish_logger.info(f" ✅ Content loaded:")
publish_logger.info(f" {log_prefix} Title: '{content.title}'")
@@ -258,7 +258,8 @@ def publish_content_to_wordpress(self, content_id: int, site_integration_id: int
# STEP 8: Send API request to WordPress
base_url = site_integration.config_json.get('site_url', '') or site_integration.config_json.get('base_url', '')
api_key = site_integration.get_credentials().get('api_key', '')
# API key is stored in Site.wp_api_key (SINGLE source of truth)
api_key = site_integration.site.wp_api_key or ''
if not base_url:
error_msg = "No base_url/site_url configured in integration"
@@ -266,7 +267,7 @@ def publish_content_to_wordpress(self, content_id: int, site_integration_id: int
return {"success": False, "error": error_msg}
if not api_key:
error_msg = "No API key configured in integration"
error_msg = "No API key configured for site. Generate one in Site Settings."
publish_logger.error(f" {log_prefix}{error_msg}")
return {"success": False, "error": error_msg}

View File

@@ -27,6 +27,7 @@ from igny8_core.auth.views import (
seedkeyword_csv_template, seedkeyword_csv_import
)
from igny8_core.utils.geo_views import GeoDetectView
from igny8_core.plugins.urls import admin_urlpatterns as plugins_admin_urls
urlpatterns = [
# CSV Import/Export for admin - MUST come before admin/ to avoid being caught by admin.site.urls
@@ -51,6 +52,9 @@ urlpatterns = [
path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints
path('api/v1/integration/', include('igny8_core.modules.integration.urls')), # Integration endpoints
path('api/v1/geo/detect/', GeoDetectView.as_view(), name='geo-detect'), # Geo detection for signup routing
# Plugin Distribution System
path('api/plugins/', include('igny8_core.plugins.urls')), # Public plugin endpoints
path('api/admin/plugins/', include((plugins_admin_urls, 'plugins-admin'))), # Admin plugin management
# OpenAPI Schema and Documentation
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),

View File

@@ -845,24 +845,55 @@ Make sure each prompt is detailed enough for image generation, describing the vi
# Reference: image-generation.php lines 79-97
import uuid
logger.info(f"[AIProcessor.generate_image] Runware API key check: has_key={bool(api_key)}, key_length={len(api_key) if api_key else 0}, key_preview={api_key[:10] + '...' + api_key[-4:] if api_key and len(api_key) > 14 else 'N/A'}")
# Build base inference task
inference_task = {
'taskType': 'imageInference',
'taskUUID': str(uuid.uuid4()),
'positivePrompt': prompt,
'negativePrompt': negative_prompt,
'model': runware_model,
'width': width,
'height': height,
'numberResults': 1,
'outputFormat': kwargs.get('format', 'webp')
}
# Model-specific parameter configuration
if runware_model.startswith('bria:'):
# Bria models need steps
inference_task['steps'] = 20
elif runware_model.startswith('google:'):
# Google models use resolution instead of width/height
del inference_task['width']
del inference_task['height']
inference_task['resolution'] = '1k'
elif runware_model.startswith('bytedance:'):
# Seedream models use basic format - no steps, CFGScale, negativePrompt needed
# Also require minimum 3,686,400 pixels (roughly 1920x1920)
if 'negativePrompt' in inference_task:
del inference_task['negativePrompt']
# Enforce minimum size for Seedream (min ~1920x1920, use 2048x2048 for square)
current_pixels = width * height
if current_pixels < 3686400:
# Use default Seedream square size
inference_task['width'] = 2048
inference_task['height'] = 2048
logger.info(f"[AIProcessor.generate_image] Seedream requires min 3.6M pixels, adjusted to 2048x2048")
elif runware_model.startswith('runware:'):
# Hi Dream Full - needs steps and CFGScale
inference_task['steps'] = 30
inference_task['CFGScale'] = 7.5
else:
# Unknown model - use basic format
pass
payload = [
{
'taskType': 'authentication',
'apiKey': api_key
},
{
'taskType': 'imageInference',
'taskUUID': str(uuid.uuid4()),
'positivePrompt': prompt,
'negativePrompt': negative_prompt,
'model': runware_model,
'width': width,
'height': height,
'steps': 30,
'CFGScale': 7.5,
'numberResults': 1,
'outputFormat': kwargs.get('format', 'webp')
}
inference_task
]
logger.info(f"[AIProcessor.generate_image] Runware request payload: model={runware_model}, width={width}, height={height}, prompt_length={len(prompt)}")

View File

@@ -0,0 +1,11 @@
keyword,industry,sector,volume,difficulty,country,is_active
best massage chairs,Health & Wellness,Massage Products,5400,45,US,True
deep tissue massage chair,Health & Wellness,Massage Products,720,52,US,True
shiatsu massage chair,Health & Wellness,Massage Products,1200,48,US,True
zero gravity massage chair,Health & Wellness,Massage Products,890,50,US,True
affordable massage chair,Health & Wellness,Massage Products,320,35,US,True
professional massage chair,Health & Wellness,Massage Products,280,42,US,True
massage chair benefits,Health & Wellness,Massage Products,450,25,US,True
full body massage chair,Health & Wellness,Massage Products,650,40,US,True
portable massage chair,Health & Wellness,Massage Products,390,38,US,True
electric massage chair,Health & Wellness,Massage Products,510,43,US,True
1 keyword industry sector volume difficulty country is_active
2 best massage chairs Health & Wellness Massage Products 5400 45 US True
3 deep tissue massage chair Health & Wellness Massage Products 720 52 US True
4 shiatsu massage chair Health & Wellness Massage Products 1200 48 US True
5 zero gravity massage chair Health & Wellness Massage Products 890 50 US True
6 affordable massage chair Health & Wellness Massage Products 320 35 US True
7 professional massage chair Health & Wellness Massage Products 280 42 US True
8 massage chair benefits Health & Wellness Massage Products 450 25 US True
9 full body massage chair Health & Wellness Massage Products 650 40 US True
10 portable massage chair Health & Wellness Massage Products 390 38 US True
11 electric massage chair Health & Wellness Massage Products 510 43 US True

View File

@@ -1,7 +1,7 @@
# Billing Module
**Last Verified:** January 8, 2026
**Status:** ✅ Active (Payment System Refactored January 2026)
**Last Verified:** January 10, 2026
**Status:** ✅ Active (Image Generation Credit System Complete January 2026)
**Backend Path:** `backend/igny8_core/modules/billing/` + `backend/igny8_core/business/billing/`
**Frontend Path:** `frontend/src/pages/Billing/` + `frontend/src/pages/Account/`
@@ -208,6 +208,36 @@ credits = CreditService.calculate_credits_for_image(
# Returns: 15 (3 images × 5 credits)
```
### Check Credits for Image Generation (NEW v1.7.1)
```python
# Pre-check credits before image generation starts
# Raises InsufficientCreditsError if not enough credits
required = CreditService.check_credits_for_image(
account=account,
model_name='dall-e-3', # Model with credits_per_image = 5
num_images=3
)
# Returns: 15 (required credits) if sufficient, raises exception if not
```
### Deduct Credits for Image Generation (v1.7.1 Verified)
```python
# Called after each successful image generation
credits_deducted = CreditService.deduct_credits_for_image(
account=account,
model_name='dall-e-3',
num_images=1,
description='Image generation: Article Title',
metadata={'image_id': 123, 'content_id': 456},
cost_usd=0.04,
related_object_type='image',
related_object_id=123
)
# Logs to CreditTransaction and CreditUsageLog
```
### Calculate Credits from Tokens by Model (NEW v1.4.0)
```python
@@ -327,5 +357,7 @@ Displays:
| Feature | Status | Description |
|---------|--------|-------------|
| ~~AI Model Config database~~ | ✅ v1.4.0 | Model pricing moved to AIModelConfig |
| Image model quality tiers | ✅ v1.4.0 | credits_per_image per quality tier |
| Credit service enhancements | 🔄 v1.5.0 | Model-specific calculation methods |
| ~~Image model quality tiers~~ | ✅ v1.4.0 | credits_per_image per quality tier |
| ~~Credit service enhancements~~ | v1.7.1 | Model-specific calculation methods |
| ~~Image generation credit check~~ | ✅ v1.7.1 | Pre-generation credit verification |
| ~~Image generation logging~~ | ✅ v1.7.1 | AITaskLog + notifications for images |

View File

@@ -1,7 +1,7 @@
# Credit System
**Last Verified:** January 5, 2026
**Status:**Simplified (v1.5.0)
**Last Verified:** January 10, 2026
**Status:**Complete (v1.7.1 - Image Generation Credits)
---
@@ -93,7 +93,7 @@ Credits calculated from actual token usage:
| Content Generation | gpt-4o | 1,000 |
| Content Optimization | gpt-4o-mini | 10,000 |
### Fixed-Cost Operations (Image AI)
### Fixed-Cost Operations (Image AI) - v1.7.1 Complete
Credits per image based on quality tier:
@@ -103,6 +103,13 @@ Credits per image based on quality tier:
| Quality | dall-e-3 | 5 |
| Premium | google:4@2 | 15 |
**Image Generation Credit Flow (v1.7.1):**
1. Pre-check: `CreditService.check_credits_for_image()` verifies sufficient credits
2. Generation: Images generated via `process_image_generation_queue` Celery task
3. Post-deduct: `CreditService.deduct_credits_for_image()` called per successful image
4. Logging: `CreditUsageLog` + `CreditTransaction` + `AITaskLog` entries created
5. Notifications: `NotificationService.notify_images_complete/failed()` called
### Free Operations
| Operation | Cost |
@@ -452,3 +459,58 @@ All AI operations logged in `CreditUsageLog` with:
- Model used
- Token counts
- Related object metadata
---
## Image Generation Credit System (v1.7.1)
### Implementation Details
**Files:**
- `CreditService.check_credits_for_image()` - [credit_service.py:307-335](../backend/igny8_core/business/billing/services/credit_service.py#L307-L335)
- `process_image_generation_queue` credit check - [tasks.py:290-319](../backend/igny8_core/ai/tasks.py#L290-L319)
- `deduct_credits_for_image()` - [tasks.py:745-770](../backend/igny8_core/ai/tasks.py#L745-L770)
- AITaskLog logging - [tasks.py:838-875](../backend/igny8_core/ai/tasks.py#L838-L875)
- Notifications - [tasks.py:877-895](../backend/igny8_core/ai/tasks.py#L877-L895)
### Credit Flow for Image Generation
```
1. User triggers image generation
2. CreditService.check_credits_for_image(account, model, num_images)
- Calculates: credits_per_image × num_images
- Raises InsufficientCreditsError if balance < required
3. process_image_generation_queue() processes each image
4. For each successful image:
CreditService.deduct_credits_for_image()
- Creates CreditUsageLog entry
- Creates CreditTransaction entry
- Updates account.credits balance
5. After all images processed:
- AITaskLog entry created
- Notification created (success or failure)
```
### Logging Locations
| Table | What's Logged | When |
|-------|---------------|------|
| CreditTransaction | Credit deduction (financial ledger) | Per image |
| CreditUsageLog | Usage details (model, cost, credits) | Per image |
| AITaskLog | Task execution summary | After batch |
| Notification | User notification | After batch |
### Automation Compatibility
Image generation credits work identically for:
- Manual image generation (from UI)
- Automation Stage 6 (scheduled/manual automation runs)
Both call `process_image_generation_queue` which handles:
- Credit checking before generation
- Credit deduction after each successful image
- Proper logging to all tables

View File

@@ -1,7 +1,7 @@
# WordPress Integration & Publishing Flow - Complete Technical Documentation
**Last Updated:** January 1, 2026
**Version:** 1.3.0
**Last Updated:** January 10, 2026
**Version:** 1.7.0
**Status:** Production Active
---
@@ -15,8 +15,9 @@
5. [Webhook Sync Flow (WordPress → IGNY8)](#5-webhook-sync-flow-wordpress--igny8)
6. [Metadata Sync Flow](#6-metadata-sync-flow)
7. [Data Models & Storage](#7-data-models--storage)
8. [Current Implementation Gaps](#8-current-implementation-gaps)
9. [Flow Diagrams](#9-flow-diagrams)
8. [Plugin Distribution System](#8-plugin-distribution-system)
9. [Current Implementation](#9-current-implementation)
10. [Flow Diagrams](#10-flow-diagrams)
---
@@ -28,6 +29,8 @@ IGNY8 integrates with WordPress sites through a **custom WordPress plugin** (`ig
- Receives content from IGNY8 via a custom REST endpoint (`/wp-json/igny8/v1/publish`)
- Sends status updates back to IGNY8 via webhooks
- Authenticates using API keys stored in both systems
- Auto-updates via IGNY8 plugin distribution system (v1.7.0+)
- Supports advanced template rendering with image layouts
### Communication Pattern
@@ -40,6 +43,9 @@ IGNY8 App ←→ WordPress Site
│ HTTP POST │
│←─────────────┤ Webhook status updates via /api/v1/integration/webhooks/wordpress/status/
│ │
│ HTTP GET │
├─────────────→│ Check plugin updates via /wp-json/igny8/v1/check-update
│ │
```
### Key Components
@@ -48,10 +54,20 @@ IGNY8 App ←→ WordPress Site
|-----------|----------|---------|
| SiteIntegration Model | `business/integration/models.py` | Stores WordPress credentials & config |
| SyncEvent Model | `business/integration/models.py` | Logs all sync operations |
| Plugin Models | `plugins/models.py` | Plugin versioning and distribution |
| Celery Task | `tasks/wordpress_publishing.py` | Background publishing worker |
| Webhook Handler | `modules/integration/webhooks.py` | Receives WordPress status updates |
| Frontend Form | `components/sites/WordPressIntegrationForm.tsx` | User setup UI |
### Recent Updates (v1.7.0)
**Plugin Distribution System:**
- ✅ Automated plugin distribution and updates
- ✅ WordPress plugin v1.3.3 with template improvements
- ✅ Image layout fixes (square/landscape positioning)
- ✅ Auto-update mechanism via WordPress hooks
- ✅ Health check and monitoring endpoints
---
## 2. Integration Setup Flow
@@ -60,10 +76,30 @@ IGNY8 App ←→ WordPress Site
1. WordPress 5.6+ with REST API enabled
2. Pretty permalinks enabled (Settings → Permalinks)
3. IGNY8 WordPress Bridge plugin installed and activated
3. IGNY8 WordPress Bridge plugin v1.3.0+ installed and activated
4. No security plugins blocking REST API
### 2.2 Setup Steps (User Flow)
### 2.2 Plugin Installation (Updated v1.7.0)
**Option 1: Manual Download & Install**
- User downloads latest plugin from IGNY8 app
- Frontend: Site Settings → WordPress Integration → Download Plugin
- Download endpoint: `https://api.igny8.com/api/plugins/igny8-wp-bridge/download/`
- Installs in WordPress: Plugins → Add New → Upload ZIP
**Option 2: Direct URL Install**
- WordPress admin → Plugins → Add New → Upload Plugin
- Paste ZIP URL with signed download token
- Plugin installs and auto-registers with IGNY8
**Plugin Auto-Update:**
- WordPress checks for updates every 12 hours
- Plugin hooks into `pre_set_site_transient_update_plugins`
- Compares current version with latest from IGNY8 API
- Notifies admin if update available
- Can be updated via WordPress admin (one-click)
### 2.3 Setup Steps (User Flow)
**Step 1: User navigates to Site Settings**
- Frontend: `/sites/{id}/settings` → WordPress Integration section
@@ -74,11 +110,9 @@ IGNY8 App ←→ WordPress Site
- Body: `{ "site_id": 123 }`
- Backend creates/updates `SiteIntegration` record with new API key
**Step 3: User copies API key and configures WordPress plugin**
- User downloads plugin from GitHub releases
- Installs in WordPress: Plugins → Add New → Upload
**Step 3: User configures WordPress plugin**
- Configures plugin with:
- IGNY8 API URL: `https://app.igny8.com`
- IGNY8 API URL: `https://api.igny8.com`
- Site API Key: (copied from IGNY8)
- Site ID: (shown in IGNY8)
@@ -86,10 +120,10 @@ IGNY8 App ←→ WordPress Site
- User clicks "Test Connection" in either app
- IGNY8 calls: `GET {wordpress_url}/wp-json/wp/v2/users/me`
- Uses API key in `X-IGNY8-API-KEY` header
- Success: Connection verified, `is_active` set to true
- Success: Connection verified, `is_active` set to true, plugin registers installation
- Failure: Error message displayed
### 2.3 Data Created During Setup
### 2.4 Data Created During Setup
**SiteIntegration Record:**
```json
@@ -463,18 +497,124 @@ Refreshes understanding of WordPress site:
- WordPress plugin must download and create attachment
- If plugin doesn't handle this, no featured image
### 8.5 Missing: Conflict Resolution
---
**Problem:** If content is edited in both IGNY8 and WordPress, there's no merge strategy.
## 8. Plugin Distribution System
**Current State:**
- Last write wins
- No version tracking
- No conflict detection
### 8.1 Architecture (v1.7.0)
**Plugin Distribution Components:**
- `Plugin` model - Multi-platform plugin registry
- `PluginVersion` model - Version tracking with files and checksums
- `PluginInstallation` model - Installation tracking per site
- `PluginDownload` model - Download analytics
**Distribution Endpoints:**
```
GET /api/plugins/igny8-wp-bridge/download/ - Download latest ZIP
POST /api/plugins/igny8-wp-bridge/check-update/ - Check for updates
GET /api/plugins/igny8-wp-bridge/info/ - Plugin metadata
POST /api/plugins/igny8-wp-bridge/register/ - Register installation
POST /api/plugins/igny8-wp-bridge/health-check/ - Health monitoring
```
### 8.2 Auto-Update Mechanism
**WordPress Side:**
1. Plugin hooks into `pre_set_site_transient_update_plugins`
2. Calls `/api/plugins/igny8-wp-bridge/check-update/` with current version
3. Receives update info if newer version available
4. WordPress displays update notification
5. User clicks "Update Now" (or auto-update runs)
6. WordPress downloads ZIP from `/download/` endpoint
7. Installs and activates new version
**IGNY8 Side:**
1. Developer updates plugin source code
2. Creates new `PluginVersion` in Django admin
3. Changes status to "released"
4. Signal automatically builds ZIP with checksums
5. Files stored in `/plugins/wordpress/dist/`
6. WordPress sites can now download/update
### 8.3 Version History (Recent)
| Version | Date | Changes |
|---------|------|---------|
| 1.3.3 | Jan 10, 2026 | Template design: Square image grid fixes, landscape positioning, direct styling for images without captions |
| 1.3.2 | Jan 9, 2026 | Template rendering improvements, image layout enhancements |
| 1.3.1 | Jan 9, 2026 | Plugin versioning updates |
| 1.3.0 | Jan 8, 2026 | Distribution system release, auto-update mechanism |
### 8.4 Security Features
- **Signed URLs:** Download links expire after configurable time
- **Checksums:** MD5 and SHA256 verification
- **Rate Limiting:** Per IP/site download limits
- **API Authentication:** Required for sensitive operations
- **Version Verification:** WordPress validates plugin before update
### 8.5 Monitoring
**Installation Tracking:**
- Total installations per version
- Active installations by site
- Version distribution analytics
**Download Analytics:**
- Download counts per version
- Geographic distribution
- Failed download attempts
**Health Checks:**
- Plugin health status per installation
- Error reporting from WordPress sites
- Version compatibility tracking
---
## 9. Flow Diagrams
## 9. Current Implementation
### 9.1 Production Status (v1.7.0)
**✅ Fully Operational:**
- WordPress plugin distribution system
- Auto-update mechanism
- Template rendering with advanced layouts
- Image positioning (square/landscape)
- Content publishing (manual + automation)
- Webhook status sync
- API authentication
- Health monitoring
**✅ Template Features (v1.3.3):**
- Square images: Side-by-side layout (left/right aligned)
- Landscape images: Full-width with max 1024px
- Images appear after first paragraph in sections
- Direct border-radius/shadow on images without captions
- Responsive design for mobile/tablet
### 9.2 Known Limitations
**Missing Features:**
- Bi-directional content sync (WordPress → IGNY8 editing)
- Conflict resolution for dual edits
- Advanced metadata sync beyond basics
- Multi-site WordPress network support
- Custom post type publishing
### 9.3 Future Enhancements
**Planned:**
- Image regeneration feature (Phase 9)
- Advanced template customization
- Custom field mapping
- Bulk publishing operations
- Real-time sync notifications
---
## 10. Flow Diagrams
### 9.1 Integration Setup
@@ -486,26 +626,33 @@ Refreshes understanding of WordPress site:
│ 1. Open Site Settings │
├─────────────────>│ │
│ │ │
│ 2. Generate API Key
│ 2. Download Plugin
├─────────────────>│ │
│ │ │
│<─────────────────┤ │
│ 3. Display API Key
│ 3. Plugin ZIP │
│ │ │
│ 4. Install Plugin──────────────────────┼──────────>
│ │ │
│ 5. Enter API Key in Plugin─────────────┼──────────>
│ │ │
│ 6. Test Connection │
│ 5. Generate API Key
├─────────────────>│ │
│ 7. GET /wp-json/...
<─────────────────┤
│ 6. Display API Key │
│ │ │
│ 7. Enter API Key in Plugin─────────────┼──────────>
│ │ │
│ 8. Test Connection │
├─────────────────>│ │
│ │ 9. GET /wp-json/... │
│ ├────────────────────>│
│ │<────────────────────┤
│<─────────────────┤ 8. Success
│<─────────────────┤ 10. Success │
│ │ │
│ │ 11. Register Install│
│ │<────────────────────┤
```
### 9.2 Manual Publishing
### 10.2 Manual Publishing
```
┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌───────────────┐
@@ -534,7 +681,43 @@ Refreshes understanding of WordPress site:
│ │ │ │
```
### 9.3 Webhook Status Sync
### 10.3 Plugin Auto-Update Flow
```
┌───────────────┐ ┌──────────────┐
│ WordPress │ │ IGNY8 API │
└───────┬───────┘ └──────┬───────┘
│ │
│ 1. Check for updates (cron)
│ POST /check-update/│
├───────────────────>│
│ (current: 1.3.2) │
│ │
│<───────────────────┤
│ 2. Update available│
│ (latest: 1.3.3) │
│ │
│ 3. User clicks │
│ "Update Now" │
│ │
│ GET /download/ │
├───────────────────>│
│ │
│<───────────────────┤
│ 4. ZIP file │
│ │
│ 5. Install & Activate
│ │
│ POST /register/ │
├───────────────────>│
│ (new version info) │
│ │
│<───────────────────┤
│ 6. Registration OK │
│ │
```
### 10.4 Webhook Status Sync
```
┌───────────────┐ ┌──────────────┐

File diff suppressed because it is too large Load Diff

130
docs/60-PLUGINS/INDEX.md Normal file
View File

@@ -0,0 +1,130 @@
# Plugin Management Documentation
**Last Updated:** January 10, 2026
**Version:** 1.7.0
**Status:** Production
This section covers plugin distribution, management, and integration from the IGNY8 app perspective.
## Contents
1. [WORDPRESS-INTEGRATION.md](WORDPRESS-INTEGRATION.md) - Complete guide for WordPress integration management
2. [PLUGIN-UPDATE-WORKFLOW.md](PLUGIN-UPDATE-WORKFLOW.md) - How plugin updates work and post-update checklist
## Overview
The IGNY8 plugin distribution system provides a comprehensive infrastructure for managing, distributing, and updating plugins across multiple platforms (WordPress, Shopify, custom sites). The system includes automated versioning, security features, update mechanisms, and monitoring.
## Quick Reference
### API Endpoints (Production)
| Endpoint | Purpose | Method |
|----------|---------|--------|
| `https://api.igny8.com/api/plugins/igny8-wp-bridge/download/` | Download latest plugin ZIP | GET |
| `https://api.igny8.com/api/plugins/igny8-wp-bridge/check-update/` | Check for updates (called by WP plugin) | POST |
| `https://api.igny8.com/api/plugins/igny8-wp-bridge/info/` | Get plugin metadata | GET |
| `https://api.igny8.com/api/plugins/igny8-wp-bridge/register/` | Register new installation | POST |
| `https://api.igny8.com/api/plugins/igny8-wp-bridge/health-check/` | Plugin health check | POST |
### Key Directories
| Path | Purpose |
|------|---------|
| `/data/app/igny8/plugins/wordpress/source/igny8-wp-bridge/` | WordPress plugin source code |
| `/data/app/igny8/plugins/wordpress/dist/` | Distribution ZIP files |
| `/data/app/igny8/plugins/shopify/` | Shopify integration (planned) |
| `/data/app/igny8/plugins/custom-site/` | Custom site integration (planned) |
| `/data/app/igny8/backend/igny8_core/plugins/` | Django plugin management app |
### Database Models
- `Plugin` - Plugin registry (name, slug, platform, status)
- `PluginVersion` - Version tracking with file info, checksums, changelog
- `PluginInstallation` - Track installations per site with version info
- `PluginDownload` - Download analytics and tracking
### Current Plugin Status
| Platform | Plugin Name | Current Version | Status |
|----------|-------------|-----------------|--------|
| WordPress | IGNY8 WP Bridge | 1.3.3 | Production |
| Shopify | IGNY8 Shopify | - | Infrastructure Ready |
| Custom | IGNY8 Bridge | - | Infrastructure Ready |
## System Features
### Distribution System
- Automated ZIP package generation
- Version-based file management
- MD5 and SHA256 checksums
- Signed download URLs
- Rate limiting and security
### Update Mechanism
- WordPress auto-update via `pre_set_site_transient_update_plugins` hook
- Version comparison and update notifications
- Changelog display
- Compatibility checks
### Monitoring & Analytics
- Installation tracking per site
- Download counts and analytics
- Health check endpoints
- Version distribution tracking
- Error logging and monitoring
### Security Features
- Signed URLs with expiration
- Checksum verification (MD5/SHA256)
- Rate limiting per IP/site
- API authentication for sensitive endpoints
- Production environment protection
## Integration Flow
```
Developer → Update Source Code → Create PluginVersion in Admin
Automatic: Build ZIP, Calculate Checksums, Store in /dist/
WordPress Site → Check for Updates → Download via API
Install/Update → Register Installation → Health Check
```
## Version Progression (Last 20 Commits)
**WordPress Plugin Versions:**
- v1.3.3 - Template design improvements, image layout fixes
- v1.3.2 - Template fixes in app and plugin
- v1.3.1 - WordPress plugin updates
- v1.3.0 - Initial distribution system release
**Infrastructure Updates:**
- Plugin distribution system implemented (v1.7.0)
- Database models for multi-platform support
- API endpoints for lifecycle management
- Automated packaging and versioning
- Security and monitoring features
## Documentation Structure
### App-Side Documentation (This Folder)
- Plugin distribution infrastructure
- API endpoint documentation
- Database models and admin interface
- Integration workflows
- Security and monitoring
### Plugin-Side Documentation
See `/plugins/wordpress/source/igny8-wp-bridge/docs/` for:
- Plugin PHP code and functions
- WordPress hooks and filters
- Template system
- Content publishing flow
- Plugin-specific configuration
---
**Note**: For internal plugin details (PHP functions, hooks, code structure), see the documentation inside the plugin directory at `/plugins/wordpress/source/igny8-wp-bridge/docs/`.

View File

@@ -0,0 +1,690 @@
# Plugin Update Workflow
**Last Updated:** January 10, 2026
**Version:** 1.7.0
**Status:** Production
**Scope:** How to release plugin updates and what happens automatically vs manually
---
## 🎯 Quick Start: Simplified Release Process
The plugin release process has been **simplified** to require only 3 fields:
1. **Version** (e.g., 1.3.3)
2. **Changelog** (what's new)
3. **Status** (draft → released)
All other fields are either:
- ✅ Auto-filled from previous version
- ✅ Auto-generated on release (file path, size, checksum)
- ✅ Auto-calculated (version code)
**Release in 3 clicks:**
```
Django Admin → Add Plugin Version → Enter 3 fields → Save → Change status to 'released'
```
---
## Recent Release History (v1.7.0)
### WordPress Plugin Progression
| Version | Date | Type | Key Changes |
|---------|------|------|-------------|
| 1.3.3 | Jan 10, 2026 | Patch | Template design: Square image grid fixes, landscape image positioning, direct border-radius/shadow on images without captions |
| 1.3.2 | Jan 9, 2026 | Patch | Template fixes in app and plugin, image layout improvements |
| 1.3.1 | Jan 9, 2026 | Patch | WordPress plugin versioning updates |
| 1.3.0 | Jan 8, 2026 | Minor | Initial distribution system release, automated updates |
### Distribution System Status
- ✅ Automated ZIP generation: Operational
- ✅ Checksum calculation: MD5 + SHA256
- ✅ WordPress auto-update: Active
- ✅ Health checks: Implemented
- ✅ Installation tracking: Complete
---
## Table of Contents
1. [Update Workflow Overview](#1-update-workflow-overview)
2. [What Happens Automatically](#2-what-happens-automatically)
3. [What Requires Manual Action](#3-what-requires-manual-action)
4. [Step-by-Step: Releasing a New Version](#4-step-by-step-releasing-a-new-version)
5. [Post-Update Verification Checklist](#5-post-update-verification-checklist)
6. [Version Numbering](#6-version-numbering)
7. [Rollback Procedure](#7-rollback-procedure)
8. [Emergency Updates](#8-emergency-updates)
9. [Recent Updates & Lessons Learned](#9-recent-updates--lessons-learned)
---
## 1. Update Workflow Overview
### The Big Picture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Plugin Update Lifecycle │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ 1. DEVELOP │ Make changes to plugin source code │
│ │ ↓ │ Location: /plugins/wordpress/source/igny8-wp-bridge/ │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ 2. VERSION │ Update version in PHP header + constant │
│ │ ↓ │ File: igny8-bridge.php │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ 3. RELEASE │ Create PluginVersion in Django admin │
│ │ ↓ │ Set status = "released" or "update_ready" │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ 4. AUTO-BUILD│ ✅ AUTOMATIC: ZIP created, checksums calculated │
│ │ ↓ │ Signal handler builds package on status change │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ 5. VERIFY │ Test download, check contents, verify endpoints │
│ │ ↓ │ See verification checklist below │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ │
│ │ 6. AVAILABLE │ WordPress sites can now see and install update │
│ │ │ Users update via WP admin or auto-update │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 2. What Happens Automatically
When you change a `PluginVersion` status to `released` or `update_ready`, the following happens **automatically** via Django signals:
### ✅ Automatic Actions
| Action | Details |
|--------|---------|
| **ZIP Creation** | Source files packaged into distribution ZIP |
| **Version Update** | Version number updated in PHP files inside ZIP |
| **File Cleanup** | Tests, .git, __pycache__, .bak files removed from ZIP |
| **Checksum Calculation** | SHA256 hash generated |
| **File Size Recording** | Byte count stored in database |
| **File Path Update** | `file_path` field populated |
| **Released Date** | `released_at` timestamp set |
| **Symlink Update** | `*-latest.zip` symlink updated |
### How It Works (Signal Handler)
```python
# backend/igny8_core/plugins/signals.py
@receiver(pre_save, sender=PluginVersion)
def auto_build_plugin_on_release(sender, instance, **kwargs):
"""
Triggered when PluginVersion.status changes to:
- 'released'
- 'update_ready'
Actions:
1. Calls create_plugin_zip() utility
2. Updates instance.file_path
3. Updates instance.file_size
4. Updates instance.checksum
5. Sets instance.released_at
"""
```
### What WordPress Sites See
Once released, the `check-update` API returns:
```json
{
"update_available": true,
"latest_version": "1.2.0",
"download_url": "https://api.igny8.com/api/plugins/igny8-wp-bridge/download/",
"changelog": "Your changelog here"
}
```
WordPress will show "Update Available" in:
- Plugins page
- Dashboard → Updates
- Admin bar (if enabled)
---
## 3. What Requires Manual Action
### ❌ Manual Steps Required
| Action | When | How |
|--------|------|-----|
| **Update Source Version** | Before release | Edit `igny8-bridge.php` header |
| **Create PluginVersion Record** | Each release | Django admin: just enter version, changelog, status |
| **Verify After Release** | After status change | Run verification checklist |
| **Mark Old Version Deprecated** | After successful release | Change old version status |
**Note:** The form is simplified - most fields auto-fill from previous version or are auto-generated.
### Source Version Update Locations
When releasing a new version, update these in the source:
**File:** `/plugins/wordpress/source/igny8-wp-bridge/igny8-bridge.php`
```php
/**
* Plugin Name: IGNY8 WordPress Bridge
* Version: 1.2.0 ← UPDATE THIS
*/
define('IGNY8_BRIDGE_VERSION', '1.2.0'); AND THIS
```
**Note:** The ZIP build process also updates these, but it's good practice to keep source in sync.
---
## 4. Step-by-Step: Releasing a New Version
### The Simplified Process
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Simplified Version Release (3 Required Fields) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Django Admin → Add Plugin Version │
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Plugin: [IGNY8 WordPress Bridge ▼] ← Select plugin │ │
│ │ Version: [1.2.0] ← New version │ │
│ │ Status: [draft ▼] ← Start as draft │ │
│ │ Changelog: [Bug fixes and improvements] ← What's new │ │
│ │ │ │
│ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │
│ │ Everything below is AUTO-FILLED or AUTO-GENERATED: │ │
│ │ │ │
│ │ Min API Version: 1.0 (from previous version) │ │
│ │ Min PHP Version: 7.4 (from previous version) │ │
│ │ File Path: (empty) → Auto-generated on release │ │
│ │ File Size: (empty) → Auto-calculated on release │ │
│ │ Checksum: (empty) → Auto-calculated on release │ │
│ │ Version Code: (empty) → Auto-calculated from version │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ Click [Save] → Status changes to 'released' → ZIP builds automatically │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### Step 1: Make Code Changes
```bash
cd /data/app/igny8/plugins/wordpress/source/igny8-wp-bridge/
# Make your changes to PHP files
# Test locally if possible
```
### Step 2: Update Version Number
Edit `igny8-bridge.php`:
```php
/**
* Plugin Name: IGNY8 WordPress Bridge
* Version: 1.2.0 ← New version
*/
define('IGNY8_BRIDGE_VERSION', '1.2.0'); Match here
```
### Step 3: Create PluginVersion in Django Admin
1. Go to: `https://api.igny8.com/backend/`
2. Navigate to: **Plugin Distribution****Plugin Versions**
3. Click **Add Plugin Version**
4. Fill in **ONLY** these required fields:
- **Plugin:** IGNY8 WordPress Bridge
- **Version:** 1.2.0
- **Status:** draft (initially)
- **Changelog:** Describe changes
5. Click **Save**
**Note:** All other fields are auto-filled:
- `min_api_version`, `min_platform_version`, `min_php_version` → Copied from previous version
- `file_path`, `file_size`, `checksum` → Auto-generated when you release
- `version_code` → Auto-calculated from version number
### Step 4: Release the Version
**Option A: Release via Status Change**
1. Edit the PluginVersion you just created
2. Change **Status** to `released`
3. Click **Save**
**Option B: Release via Bulk Action**
1. Select the version(s) in the list
2. Choose **Actions****✅ Release selected versions**
3. Click **Go**
**What happens:** Signal triggers auto-build → ZIP created → database updated with file info
**Note:** You can also use the action **📢 Mark as update ready** to immediately notify WordPress sites.
### Step 5: Verify Release
Run the [verification checklist](#5-post-update-verification-checklist) below.
### Step 6: Deprecate Old Version (Optional)
1. Find the previous version in Django admin
2. Change **Status** to `deprecated`
3. Save
---
## 5. Post-Update Verification Checklist
### Quick Verification Script
Run this single command to verify everything:
```bash
#!/bin/bash
# Complete Plugin Release Verification Script
# Usage: Save as verify-plugin.sh and run: bash verify-plugin.sh 1.1.2
VERSION=${1:-"latest"}
PLUGIN_SLUG="igny8-wp-bridge"
echo "=========================================="
echo "Plugin Release Verification: v${VERSION}"
echo "=========================================="
echo ""
# 1. Check ZIP file exists
echo "1. Checking ZIP file..."
if [ "$VERSION" = "latest" ]; then
ls -lh /data/app/igny8/plugins/wordpress/dist/${PLUGIN_SLUG}-latest.zip 2>/dev/null && echo " ✓ Latest ZIP exists" || echo " ✗ Latest ZIP not found"
else
ls -lh /data/app/igny8/plugins/wordpress/dist/${PLUGIN_SLUG}-v${VERSION}.zip 2>/dev/null && echo " ✓ ZIP v${VERSION} exists" || echo " ✗ ZIP v${VERSION} not found"
fi
echo ""
# 2. Check symlink
echo "2. Checking symlink..."
ls -la /data/app/igny8/plugins/wordpress/dist/${PLUGIN_SLUG}-latest.zip | grep -q "^l" && echo " ✓ Symlink valid" || echo " ✗ Symlink missing"
echo ""
# 3. Test download endpoint
echo "3. Testing download endpoint..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://api.igny8.com/api/plugins/${PLUGIN_SLUG}/download/)
FILE_SIZE=$(curl -s -o /dev/null -w "%{size_download}" https://api.igny8.com/api/plugins/${PLUGIN_SLUG}/download/)
if [ "$HTTP_CODE" = "200" ]; then
echo " ✓ Download works: ${HTTP_CODE} - ${FILE_SIZE} bytes ($(( FILE_SIZE / 1024 )) KB)"
else
echo " ✗ Download failed: HTTP ${HTTP_CODE}"
fi
echo ""
# 4. Test check-update endpoint
echo "4. Testing check-update endpoint..."
UPDATE_RESPONSE=$(curl -s "https://api.igny8.com/api/plugins/${PLUGIN_SLUG}/check-update/?current_version=1.0.0")
LATEST_VERSION=$(echo "$UPDATE_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin).get('latest_version', 'N/A'))" 2>/dev/null)
if [ -n "$LATEST_VERSION" ] && [ "$LATEST_VERSION" != "N/A" ]; then
echo " ✓ Check-update works: Latest version = $LATEST_VERSION"
else
echo " ✗ Check-update failed"
fi
echo ""
# 5. Test info endpoint
echo "5. Testing info endpoint..."
INFO_RESPONSE=$(curl -s "https://api.igny8.com/api/plugins/${PLUGIN_SLUG}/info/")
PLUGIN_NAME=$(echo "$INFO_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin).get('name', 'N/A'))" 2>/dev/null)
if [ -n "$PLUGIN_NAME" ] && [ "$PLUGIN_NAME" != "N/A" ]; then
echo " ✓ Info endpoint works: $PLUGIN_NAME"
else
echo " ✗ Info endpoint failed"
fi
echo ""
# 6. Verify version in ZIP
echo "6. Verifying version in ZIP..."
if [ "$VERSION" != "latest" ]; then
ZIP_VERSION=$(unzip -p /data/app/igny8/plugins/wordpress/dist/${PLUGIN_SLUG}-v${VERSION}.zip ${PLUGIN_SLUG}/igny8-bridge.php 2>/dev/null | grep -E "Version:" | head -1 | awk '{print $3}')
if [ "$ZIP_VERSION" = "$VERSION" ]; then
echo " ✓ ZIP version matches: $ZIP_VERSION"
else
echo " ✗ ZIP version mismatch: expected $VERSION, got $ZIP_VERSION"
fi
else
echo " - Skipped (use specific version to check)"
fi
echo ""
# 7. Verify API URL
echo "7. Verifying API URL in ZIP..."
API_COUNT=$(unzip -p /data/app/igny8/plugins/wordpress/dist/${PLUGIN_SLUG}-latest.zip ${PLUGIN_SLUG}/igny8-bridge.php 2>/dev/null | grep -c "api.igny8.com")
if [ "$API_COUNT" -gt 0 ]; then
echo " ✓ API URL correct: api.igny8.com found (${API_COUNT} occurrences)"
else
echo " ✗ API URL incorrect: api.igny8.com not found"
fi
echo ""
# 8. Check database
echo "8. Checking database..."
docker exec igny8_backend python manage.py shell -c "
from igny8_core.plugins.models import Plugin, PluginVersion
try:
p = Plugin.objects.get(slug='${PLUGIN_SLUG}')
v = p.get_latest_version()
if v:
print(f' ✓ Latest version: {v.version}')
print(f' ✓ Status: {v.status}')
print(f' ✓ File: {v.file_path}')
print(f' ✓ Size: {v.file_size:,} bytes ({v.file_size/1024:.1f} KB)')
print(f' ✓ Checksum: {v.checksum[:32]}...')
else:
print(' ✗ No released version found')
except Exception as e:
print(f' ✗ Error: {e}')
" 2>/dev/null | grep -E "✓|✗"
echo ""
echo "=========================================="
echo "Verification Complete"
echo "=========================================="
```
**Quick run (copy-paste):**
```bash
# Verify latest version
curl -s "https://api.igny8.com/api/plugins/igny8-wp-bridge/info/" | python3 -m json.tool && \
curl -s "https://api.igny8.com/api/plugins/igny8-wp-bridge/check-update/?current_version=1.0.0" | python3 -m json.tool && \
ls -lh /data/app/igny8/plugins/wordpress/dist/igny8-wp-bridge-latest.zip && \
echo "✓ All endpoints working"
```
### Database Verification
```bash
# Check database has correct values
docker exec igny8_backend python manage.py shell -c "
from igny8_core.plugins.models import Plugin, PluginVersion
p = Plugin.objects.get(slug='igny8-wp-bridge')
v = p.get_latest_version()
print(f'Version: {v.version}')
print(f'Status: {v.status}')
print(f'File path: {v.file_path}')
print(f'File size: {v.file_size}')
print(f'Checksum: {v.checksum[:32]}...')
print(f'Released at: {v.released_at}')
"
```
### Expected Results
| Check | Expected |
|-------|----------|
| Download returns | 200 status, ~150-200KB |
| check-update shows | `"latest_version": "1.2.0"` |
| info shows | `"version": "1.2.0"` |
| ZIP version header | `Version: 1.2.0` |
| API URL | `api.igny8.com` (NOT app.igny8.com) |
### WordPress Verification (If Possible)
1. Go to a test WordPress site with plugin installed
2. Navigate to **Dashboard****Updates****Check Again**
3. Verify "IGNY8 WordPress Bridge" shows update available
4. Click "View version details" → verify changelog appears
5. Click "Update Now" → verify update completes successfully
---
## 6. Version Numbering
### Semantic Versioning
Format: `MAJOR.MINOR.PATCH` (e.g., 1.2.3)
| Part | When to Increment |
|------|-------------------|
| **MAJOR** | Breaking changes, incompatible API changes |
| **MINOR** | New features, backwards compatible |
| **PATCH** | Bug fixes, minor improvements |
### Version Code Calculation
Used for numeric comparison in database:
```
1.0.0 → 10000
1.0.1 → 10001
1.2.0 → 10200
1.2.3 → 10203
2.0.0 → 20000
```
Formula: `(MAJOR * 10000) + (MINOR * 100) + PATCH`
---
## 7. Rollback Procedure
### If Update Causes Issues
**Option 1: Quick Rollback via Database**
```bash
# Make old version the "latest" by changing status
docker exec igny8_backend python manage.py shell -c "
from igny8_core.plugins.models import PluginVersion
# Demote new version
new = PluginVersion.objects.get(plugin__slug='igny8-wp-bridge', version='1.2.0')
new.status = 'deprecated'
new.save()
# Promote old version back
old = PluginVersion.objects.get(plugin__slug='igny8-wp-bridge', version='1.1.1')
old.status = 'released'
old.save()
print('Rollback complete')
"
```
**Option 2: Keep Old ZIP Files**
Old ZIP files are preserved in `dist/`. To serve an old version:
```bash
# Update symlink to point to old version
cd /data/app/igny8/plugins/wordpress/dist/
ln -sf igny8-wp-bridge-v1.1.1.zip igny8-wp-bridge-latest.zip
```
### Retention Policy
Keep at least 3 previous version ZIPs for emergency rollback:
- Current release
- Previous release
- One before that
---
## 8. Emergency Updates
### For Critical Security Fixes
1. **Set `force_update = True`** on the new PluginVersion
2. This flag signals WordPress sites that update is mandatory
3. Sites with auto-updates enabled will update immediately
### Emergency Release Checklist
```bash
# 1. Make the fix in source
cd /data/app/igny8/plugins/wordpress/source/igny8-wp-bridge/
# ... make changes ...
# 2. Update version (use PATCH increment)
# Edit igny8-bridge.php
# 3. Create version in Django admin with:
# - Status: update_ready
# - Force Update: ✓ (checked)
# - Changelog: "SECURITY: [description]"
# 4. Verify immediately
curl https://api.igny8.com/api/plugins/igny8-wp-bridge/check-update/?current_version=1.0.0
# Response should include:
# "force_update": true
```
---
## 9. Recent Updates & Lessons Learned
### v1.3.3 Release (Jan 10, 2026)
**Changes:**
- Fixed square image grid layout (side-by-side display)
- Fixed landscape image positioning in sections 4+
- Removed card wrapper for images without captions
- Applied border-radius/shadow directly to images
- Landscape images now appear after first paragraph
**Process:**
- Source code updated in plugin templates and CSS
- Version bumped in `igny8-bridge.php` (header + constant)
- Django admin: Created v1.3.3 with changelog
- Status changed to "released" → ZIP auto-generated
- Verified download endpoint and file contents
- Template changes tested on staging WordPress site
**Lessons:**
- CSS changes require thorough cross-browser testing
- Image layout fixes need responsive design verification
- Template changes should be tested with multiple content types
### v1.3.2 Release (Jan 9, 2026)
**Changes:**
- Template rendering improvements
- Image layout enhancements
- Content section fixes
**Process:**
- Standard release workflow
- Auto-build successful
- No rollback needed
### v1.3.0 Release (Jan 8, 2026)
**Changes:**
- Initial distribution system implementation
- WordPress auto-update mechanism
- Base template system
**Process:**
- Major release with new infrastructure
- Extensive testing required
- First use of automated packaging
### Distribution System Milestones
**v1.7.0 Infrastructure:**
- ✅ Complete plugin distribution system
- ✅ Multi-platform support architecture
- ✅ Automated versioning and packaging
- ✅ Security features (checksums, signed URLs)
- ✅ Monitoring and analytics
**Best Practices Established:**
1. Always test download endpoint after release
2. Verify ZIP contents match source
3. Check version number in extracted files
4. Test update notification in WordPress
5. Monitor download analytics
**Common Issues Resolved:**
- ZIP generation timing → Now synchronous in signals
- Checksum mismatches → Auto-calculated reliably
- Version comparison → Semantic versioning logic fixed
- File size tracking → Automatic and accurate
---
## Quick Reference Card
### Release New Version (Simplified)
```
1. Edit source code in /plugins/wordpress/source/igny8-wp-bridge/
2. Update version in igny8-bridge.php (header + constant)
3. Django Admin → Add Plugin Version:
- Plugin: IGNY8 WordPress Bridge
- Version: 1.3.3 (or next version)
- Changelog: Describe changes
- Status: draft
- (All other fields auto-fill)
4. Change status to "released" (or use bulk action) → Auto-build triggers
5. Run verification checklist
6. Optionally: Mark old version as deprecated
```
**Admin Bulk Actions:**
- **✅ Release selected versions** - Builds ZIP and marks as released
- **📢 Mark as update ready** - Notifies WordPress sites
- **🗑️ Mark as deprecated** - Deprecates old versions
### Verification Commands
```bash
# Test all endpoints
curl -I https://api.igny8.com/api/plugins/igny8-wp-bridge/download/
curl https://api.igny8.com/api/plugins/igny8-wp-bridge/check-update/?current_version=1.0.0
curl https://api.igny8.com/api/plugins/igny8-wp-bridge/info/
# Check ZIP contents
unzip -l /data/app/igny8/plugins/wordpress/dist/igny8-wp-bridge-v*.zip | head -20
```
### Emergency Rollback
```bash
# Swap versions in database
docker exec igny8_backend python manage.py shell -c "
from igny8_core.plugins.models import PluginVersion
PluginVersion.objects.filter(version='NEW').update(status='deprecated')
PluginVersion.objects.filter(version='OLD').update(status='released')
"
```
---
## Related Documentation
- [WORDPRESS-INTEGRATION.md](WORDPRESS-INTEGRATION.md) - Full integration guide
- [docs/plans/PLUGIN-DISTRIBUTION-SYSTEM.md](/docs/plans/PLUGIN-DISTRIBUTION-SYSTEM.md) - Original system design

View File

@@ -0,0 +1,729 @@
# WordPress Integration Management
**Last Updated:** January 10, 2026
**Version:** 1.7.0
**Status:** Production
**Scope:** App-side management of WordPress plugin distribution and site integrations
---
## Table of Contents
1. [Overview](#1-overview)
2. [Architecture](#2-architecture)
3. [Plugin Distribution System](#3-plugin-distribution-system)
4. [Database Models](#4-database-models)
5. [API Endpoints](#5-api-endpoints)
6. [Django Admin Management](#6-django-admin-management)
7. [Frontend Integration](#7-frontend-integration)
8. [Site Integration Flow](#8-site-integration-flow)
9. [Recent Updates (v1.7.0)](#9-recent-updates-v170)
10. [Troubleshooting](#10-troubleshooting)
---
## 1. Overview
### What This Document Covers
This document covers the **app-side** management of WordPress integration:
- How the IGNY8 app distributes the WordPress plugin
- How site integrations are tracked in the database
- API endpoints that WordPress plugins call
- Admin interface for managing plugins and versions
- Frontend components for plugin download
- Plugin distribution infrastructure (v1.7.0)
- Automated versioning and packaging system
### What This Document Does NOT Cover
- Internal plugin PHP code and functions (see `/plugins/wordpress/source/igny8-wp-bridge/docs/`)
- WordPress-side setup and configuration
- Plugin installation on WordPress sites
### Current Status (v1.7.0)
- ✅ WordPress Plugin: v1.3.3 (Production)
- ✅ Distribution System: Fully operational
- ✅ Auto-update Mechanism: Active
- ✅ Template Design: Updated with image layout fixes
- ✅ API Endpoints: All operational
- ✅ Security Features: Checksums, signed URLs, rate limiting
---
## 2. Architecture
### System Components
```
┌─────────────────────────────────────────────────────────────────────────┐
│ IGNY8 App Server │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Django Backend │ │ PostgreSQL │ │ Plugin Files │ │
│ │ │ │ │ │ │ │
│ │ - API Views │◄──►│ - Plugin │ │ /plugins/ │ │
│ │ - Admin │ │ - PluginVersion │ │ └─wordpress/ │ │
│ │ - Signals │ │ - Installation │ │ ├─source/ │ │
│ │ │ │ - Downloads │ │ └─dist/ │ │
│ └────────┬─────────┘ └──────────────────┘ └────────┬─────────┘ │
│ │ │ │
│ │ API Routes: api.igny8.com │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ API Endpoints │ │
│ │ │ │
│ │ /api/plugins/{slug}/download/ → Serve ZIP file │ │
│ │ /api/plugins/{slug}/check-update/ → Return update info │ │
│ │ /api/plugins/{slug}/info/ → Return plugin metadata │ │
│ │ /api/plugins/{slug}/register/ → Register installation │ │
│ │ /api/plugins/{slug}/health-check/ → Report plugin status │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
│ HTTPS
┌─────────────────────────────────────────────────────────────────────────┐
│ WordPress Sites │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Site A │ │ Site B │ │ Site C │ │ Site N │ │
│ │ v1.1.1 │ │ v1.1.0 │ │ v1.1.1 │ │ v1.0.0 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### URL Routing
| Domain | Service | Purpose |
|--------|---------|---------|
| `app.igny8.com` | Frontend (Vite) | User interface |
| `api.igny8.com` | Backend (Django) | API endpoints |
**Important**: WordPress plugins must call `api.igny8.com`, not `app.igny8.com`.
---
## 3. Plugin Distribution System
### Directory Structure
```
/data/app/igny8/
├── plugins/
│ └── wordpress/
│ ├── source/ # Development source
│ │ └── igny8-wp-bridge/
│ │ ├── igny8-bridge.php # Main plugin file
│ │ ├── includes/ # PHP classes
│ │ ├── admin/ # Admin interface
│ │ ├── sync/ # Sync functionality
│ │ ├── templates/ # Frontend templates
│ │ └── docs/ # Plugin-internal docs
│ └── dist/ # Distribution files
│ ├── igny8-wp-bridge-v1.1.1.zip # Version ZIP
│ ├── igny8-wp-bridge-v1.1.1.sha256
│ └── igny8-wp-bridge-latest.zip # Symlink to latest
└── backend/
└── igny8_core/
└── plugins/ # Django app
├── models.py # Database models
├── views.py # API endpoints
├── signals.py # Auto-build triggers
├── utils.py # ZIP creation utilities
├── admin.py # Django admin config
└── urls.py # URL routing
```
### How ZIP Files Are Created
#### Automatic Build (Recommended)
When a `PluginVersion` status changes to `released` or `update_ready`, the system **automatically**:
1. Reads source files from `/plugins/wordpress/source/igny8-wp-bridge/`
2. Updates version numbers in PHP files
3. Removes development files (tests, .git, __pycache__)
4. Creates ZIP in `/plugins/wordpress/dist/`
5. Calculates SHA256 checksum
6. Updates database with `file_path`, `file_size`, `checksum`
7. Updates `*-latest.zip` symlink
This is handled by `signals.py`:
```python
# backend/igny8_core/plugins/signals.py
@receiver(pre_save, sender=PluginVersion)
def auto_build_plugin_on_release(sender, instance, **kwargs):
"""Automatically build ZIP when version is released."""
# Triggered on status change to 'released' or 'update_ready'
```
#### Manual Build (If Needed)
```bash
# From project root
cd /data/app/igny8/plugins/wordpress/source
# Create ZIP manually
zip -r ../dist/igny8-wp-bridge-v1.2.0.zip igny8-wp-bridge/ \
-x "*.git*" -x "*__pycache__*" -x "*.DS_Store" -x "*tester*"
# Update checksum
sha256sum ../dist/igny8-wp-bridge-v1.2.0.zip > ../dist/igny8-wp-bridge-v1.2.0.sha256
# Update symlink
ln -sf igny8-wp-bridge-v1.2.0.zip ../dist/igny8-wp-bridge-latest.zip
```
---
## 4. Database Models
### Plugin
Represents a plugin type (WordPress, Shopify, etc.).
```python
class Plugin(models.Model):
name = models.CharField(max_length=100) # "IGNY8 WordPress Bridge"
slug = models.SlugField(unique=True) # "igny8-wp-bridge"
platform = models.CharField(...) # "wordpress", "shopify", "custom"
description = models.TextField()
homepage_url = models.URLField()
is_active = models.BooleanField(default=True)
```
**Current Records:**
| ID | Name | Slug | Platform |
|----|------|------|----------|
| 1 | IGNY8 WordPress Bridge | igny8-wp-bridge | wordpress |
### PluginVersion
Tracks each version of a plugin.
```python
class PluginVersion(models.Model):
plugin = models.ForeignKey(Plugin, ...)
version = models.CharField(max_length=20) # "1.1.1"
version_code = models.IntegerField() # 10101 (for comparison)
status = models.CharField(...) # "draft", "released", "update_ready"
# File info (auto-populated on release)
file_path = models.CharField(...) # "igny8-wp-bridge-v1.1.1.zip"
file_size = models.IntegerField() # 167589 (bytes)
checksum = models.CharField(max_length=64) # SHA256
# Release info
changelog = models.TextField()
min_php_version = models.CharField(default='7.4')
released_at = models.DateTimeField(null=True)
force_update = models.BooleanField(default=False)
```
**Version Status Flow:**
```
draft → testing → staged → released → update_ready → deprecated
│ │
│ └─ Notifies WP sites
└─ Available for download
```
### PluginInstallation
Tracks where plugins are installed.
```python
class PluginInstallation(models.Model):
site = models.ForeignKey('Site', ...)
plugin = models.ForeignKey(Plugin, ...)
current_version = models.ForeignKey(PluginVersion, ...)
is_active = models.BooleanField(default=True)
last_health_check = models.DateTimeField(null=True)
health_status = models.CharField(...) # "healthy", "outdated", "error"
```
### PluginDownload
Tracks download analytics.
```python
class PluginDownload(models.Model):
plugin = models.ForeignKey(Plugin, ...)
version = models.ForeignKey(PluginVersion, ...)
ip_address = models.GenericIPAddressField()
user_agent = models.CharField(max_length=500)
download_type = models.CharField(...) # "manual", "auto_update"
created_at = models.DateTimeField(auto_now_add=True)
```
---
## 5. API Endpoints
### Public Endpoints (No Auth Required)
#### Download Plugin
```
GET /api/plugins/{slug}/download/
```
Returns the latest released ZIP file.
**Response:** Binary ZIP file download
**Example:**
```bash
curl -O https://api.igny8.com/api/plugins/igny8-wp-bridge/download/
```
#### Check for Updates
```
GET /api/plugins/{slug}/check-update/?current_version=1.0.0
```
Called by installed WordPress plugins to check for updates.
**Response:**
```json
{
"update_available": true,
"current_version": "1.0.0",
"latest_version": "1.1.1",
"latest_version_code": 10101,
"download_url": "https://api.igny8.com/api/plugins/igny8-wp-bridge/download/",
"changelog": "Bug fixes and improvements",
"info_url": "https://api.igny8.com/api/plugins/igny8-wp-bridge/info/",
"force_update": false,
"checksum": "6b9e231c07434df1dcfe81596b57f3571c30b6c2..."
}
```
#### Plugin Info
```
GET /api/plugins/{slug}/info/
```
Returns plugin metadata for WordPress update modal.
**Response:**
```json
{
"name": "IGNY8 WordPress Bridge",
"slug": "igny8-wp-bridge",
"version": "1.1.1",
"author": "IGNY8",
"homepage": "https://igny8.com",
"description": "Lightweight bridge plugin...",
"changelog": "Bug fixes and improvements",
"download_url": "https://api.igny8.com/api/plugins/igny8-wp-bridge/download/",
"file_size": 167589,
"requires_php": "7.4",
"tested_wp": "6.7"
}
```
### Authenticated Endpoints
#### Register Installation
```
POST /api/plugins/{slug}/register/
Headers: Authorization: Api-Key {site_api_key}
```
Called when plugin is activated on a WordPress site.
**Request Body:**
```json
{
"site_id": 123,
"version": "1.1.1",
"wp_version": "6.7",
"php_version": "8.2"
}
```
#### Health Check
```
POST /api/plugins/{slug}/health-check/
Headers: Authorization: Api-Key {site_api_key}
```
Periodic status report from installed plugins.
**Request Body:**
```json
{
"site_id": 123,
"version": "1.1.1",
"status": "active",
"last_sync": "2026-01-09T12:00:00Z"
}
```
---
## 6. Django Admin Management
### Accessing Plugin Admin
1. Go to: `https://api.igny8.com/backend/`
2. Login with admin credentials
3. Navigate to **Plugin Distribution** section
### Admin Sections
#### Plugins
View and manage plugin registry.
| Field | Description |
|-------|-------------|
| Name | Display name |
| Slug | URL identifier (cannot change after creation) |
| Platform | wordpress, shopify, custom |
| Is Active | Whether available for download |
#### Plugin Versions
Manage versions and trigger releases.
**Required Fields Only:**
- Plugin
- Version (e.g., 1.2.0)
- Changelog
- Status
**Auto-Filled Fields:**
- Min API/Platform/PHP versions (copied from previous version)
- File path, size, checksum (generated on release)
- Version code (calculated from version number)
**To Release a New Version:**
1. Click **Add Plugin Version**
2. Select plugin
3. Enter version number (e.g., 1.2.0)
4. Write changelog
5. Set status to `draft`
6. Save
7. Change status to `released` (or use bulk action "✅ Release selected versions")
8. ZIP is automatically built with all file info populated
**Bulk Actions:**
- **✅ Release selected versions** - Builds ZIP and releases
- **📢 Mark as update ready** - Notifies WordPress sites
- **🗑️ Mark as deprecated** - Deprecates old versions
#### Plugin Installations
View where plugins are installed.
| Field | Description |
|-------|-------------|
| Site | IGNY8 site record |
| Plugin | Which plugin |
| Current Version | Installed version |
| Health Status | healthy, outdated, error |
| Last Health Check | Timestamp |
#### Plugin Downloads
View download analytics.
| Field | Description |
|-------|-------------|
| Plugin | Which plugin |
| Version | Which version |
| Download Type | manual, auto_update |
| IP Address | Client IP |
| User Agent | Browser/client info |
| Created At | Timestamp |
---
## 7. Frontend Integration
### Download Button Location
**Page:** Site Settings → Integrations → WordPress
**Component:** `frontend/src/components/sites/WordPressIntegrationForm.tsx`
### How Download Works
```typescript
// WordPressIntegrationForm.tsx
import { API_BASE_URL } from '../../services/api';
const handleDownloadPlugin = () => {
// Uses API_BASE_URL which resolves to https://api.igny8.com/api
const pluginUrl = `${API_BASE_URL}/plugins/igny8-wp-bridge/download/`;
window.open(pluginUrl, '_blank');
toast.success('Plugin download started');
};
```
### Plugin Information Display
The frontend shows:
- Plugin name and version
- File size
- PHP requirements
- Download button
- Changelog (expandable)
---
## 8. Site Integration Flow
### Integration Creation Flow
```
┌─────────────────────────────────────────────────────────────────────────┐
│ WordPress Site Setup │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. User downloads plugin from IGNY8 app │
│ └─ GET /api/plugins/igny8-wp-bridge/download/ │
│ │
│ 2. User installs and activates plugin in WordPress │
│ │
│ 3. User enters Site ID and API Key in WP plugin settings │
│ └─ These are generated in IGNY8 app │
│ │
│ 4. WP Plugin calls IGNY8 to register │
│ └─ POST /api/plugins/igny8-wp-bridge/register/ │
│ │
│ 5. IGNY8 creates SiteIntegration record │
│ └─ Links WordPress site to IGNY8 site │
│ │
│ 6. Integration is now active │
│ └─ Content can be published from IGNY8 to WordPress │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### Integration Data Model
```python
# SiteIntegration model (in auth/models.py)
class SiteIntegration(models.Model):
site = models.ForeignKey(Site, ...) # IGNY8 site
platform = models.CharField(...) # "wordpress"
external_site_url = models.URLField() # WordPress URL
api_key = models.CharField(...) # Encrypted key
sync_enabled = models.BooleanField()
last_sync = models.DateTimeField()
connection_status = models.CharField(...) # "connected", "error"
```
---
## 9. Troubleshooting
### Common Issues
#### "404 Not Found" on Download
**Cause:** Using wrong domain (app.igny8.com instead of api.igny8.com)
**Solution:** Ensure URL is `https://api.igny8.com/api/plugins/igny8-wp-bridge/download/`
#### "Plugin file not found" Error
**Cause:** ZIP file doesn't exist in dist/ directory
**Solution:**
```bash
# Check if file exists
ls -la /data/app/igny8/plugins/wordpress/dist/
# If missing, rebuild manually or change version status to trigger auto-build
```
#### Updates Not Showing in WordPress
**Cause 1:** WordPress caches plugin update checks
**Solution:** In WordPress admin, go to Dashboard → Updates → Check Again
**Cause 2:** Version status is not `released` or `update_ready`
**Solution:** Check PluginVersion status in Django admin
#### Wrong File Size/Checksum
**Cause:** Database values don't match actual file
**Solution:**
```bash
# Get actual values
stat -c %s /data/app/igny8/plugins/wordpress/dist/igny8-wp-bridge-v1.1.1.zip
sha256sum /data/app/igny8/plugins/wordpress/dist/igny8-wp-bridge-v1.1.1.zip
# Update in Django admin or shell
```
### Verification Commands
```bash
# Test download endpoint
curl -I https://api.igny8.com/api/plugins/igny8-wp-bridge/download/
# Test check-update endpoint
curl "https://api.igny8.com/api/plugins/igny8-wp-bridge/check-update/?current_version=1.0.0"
# Test info endpoint
curl https://api.igny8.com/api/plugins/igny8-wp-bridge/info/
# Check ZIP contents
unzip -l /data/app/igny8/plugins/wordpress/dist/igny8-wp-bridge-v1.1.1.zip
# Verify API URL in plugin
unzip -p /data/app/igny8/plugins/wordpress/dist/igny8-wp-bridge-v1.1.1.zip \
igny8-wp-bridge/igny8-bridge.php | grep "api.igny8.com"
```
### Log Locations
| Log | Location |
|-----|----------|
| Django Logs | `/data/app/igny8/backend/logs/` |
| Plugin API Logs | Check Django logs for `/api/plugins/` requests |
| WordPress Plugin Logs | WP site's debug.log if WP_DEBUG enabled |
---
## 9. Recent Updates (v1.7.0)
### Plugin Distribution System Implemented
**Infrastructure:**
- ✅ Complete plugin distribution system operational
- ✅ Multi-platform support (WordPress, Shopify, Custom sites)
- ✅ Automated ZIP packaging with versioning
- ✅ Security features: checksums (MD5/SHA256), signed URLs, rate limiting
- ✅ Monitoring and analytics dashboard
**Database Models:**
- `Plugin` - Platform-agnostic plugin registry
- `PluginVersion` - Full version lifecycle management
- `PluginInstallation` - Per-site installation tracking
- `PluginDownload` - Download analytics and monitoring
**API Endpoints:**
- All 5 endpoints operational and tested
- `/download/` - Serve plugin ZIP files
- `/check-update/` - WordPress update mechanism
- `/info/` - Plugin metadata
- `/register/` - Installation registration
- `/health-check/` - Plugin health monitoring
### WordPress Plugin Updates (v1.3.0 → v1.3.3)
**v1.3.3 - Template Design Improvements:**
- Fixed square images displaying in 2 rows (now side-by-side)
- Fixed landscape images in sections 4+ displaying incorrectly
- Removed card wrapper for images without descriptions
- Applied border-radius and shadow directly to images
- Landscape images now appear after first paragraph
- Consistent CSS classes for all image types
**v1.3.2 - Template Fixes:**
- Template rendering improvements in app and plugin
- Image layout enhancements
- Content section fixes
**v1.3.1 - Plugin Updates:**
- Versioning system improvements
- WordPress plugin compatibility updates
**v1.3.0 - Initial Distribution System:**
- First release with automated distribution
- Update mechanism implementation
- Base template system
### Version Progression Timeline
```
v1.7.0 (Jan 10, 2026) - App infrastructure + plugin v1.3.3
├── Plugin distribution system complete
├── Template design overhaul
├── Image layout fixes
└── Pre-launch cleanup (Phases 1, 5, 6)
v1.6.2 (Jan 8, 2026) - Design refinements
└── Marketing site updates
v1.6.0 (Jan 8, 2026) - Payment system refactor
└── Stripe, PayPal, Bank Transfer
v1.4.0 (Jan 5, 2026) - AI model architecture overhaul
└── IntegrationProvider, AIModelConfig
```
### Infrastructure Improvements
**Security:**
- Signed URLs with expiration for downloads
- Checksum verification (MD5 + SHA256)
- Rate limiting per IP/site
- API authentication for sensitive operations
- Production environment protection
**Automation:**
- Auto-build on version status change
- Automatic checksum calculation
- File size tracking
- Version number updates in ZIP contents
- Released timestamp tracking
**Monitoring:**
- Installation tracking per site
- Download analytics
- Health check endpoints
- Version distribution monitoring
- Error logging and alerts
### Documentation Updates
- Added comprehensive plugin distribution plan
- Updated API endpoint documentation
- Enhanced troubleshooting guides
- Added version progression tracking
- Updated architecture diagrams
---
## 10. Troubleshooting
|-----|----------|
| Django logs | `/data/app/logs/django.log` |
| Plugin build logs | Check Django log for `"Created plugin ZIP"` messages |
| Download tracking | `PluginDownload` model in database |
---
## Related Documentation
- [PLUGIN-UPDATE-WORKFLOW.md](PLUGIN-UPDATE-WORKFLOW.md) - Post-update checklist
- `/plugins/wordpress/source/igny8-wp-bridge/docs/` - Plugin internal documentation
- [docs/plans/PLUGIN-DISTRIBUTION-SYSTEM.md](/docs/plans/PLUGIN-DISTRIBUTION-SYSTEM.md) - Original implementation plan

View File

@@ -0,0 +1,195 @@
# Global Keywords Database (SeedKeyword) - Import Guide
## Overview
The Global Keywords Database stores canonical keyword suggestions that can be imported into account-specific keywords. These are organized by Industry and Sector.
**Admin URL:** `https://api.igny8.com/admin/igny8_core_auth/seedkeyword/`
---
## Import Functionality
### CSV Format
The import expects a CSV file with the following columns:
| Column | Type | Required | Description | Example |
|--------|------|----------|-------------|---------|
| `keyword` | String | **Yes** | The keyword phrase | "best massage chairs" |
| `industry` | String | **Yes** | Industry name (must exist) | "Health & Wellness" |
| `sector` | String | **Yes** | Sector name (must exist) | "Massage Products" |
| `volume` | Integer | No | Monthly search volume | 5400 |
| `difficulty` | Integer | No | Keyword difficulty (0-100) | 45 |
| `country` | String | No | Country code (US, CA, GB, etc.) | "US" |
| `is_active` | Boolean | No | Active status | True |
### Sample CSV
```csv
keyword,industry,sector,volume,difficulty,country,is_active
best massage chairs,Health & Wellness,Massage Products,5400,45,US,True
deep tissue massage chair,Health & Wellness,Massage Products,720,52,US,True
shiatsu massage chair,Health & Wellness,Massage Products,1200,48,US,True
```
**Template file available:** `/data/app/igny8/backend/seed_keywords_import_template.csv`
---
## How to Import
### Step 1: Prepare Your CSV File
1. Download the template: `seed_keywords_import_template.csv`
2. Add your keywords (one per row)
3. Ensure Industry and Sector names **exactly match** existing records
4. Save as CSV (UTF-8 encoding)
### Step 2: Import via Django Admin
1. Go to: `https://api.igny8.com/admin/igny8_core_auth/seedkeyword/`
2. Click **"Import"** button (top right)
3. Click **"Choose File"** and select your CSV
4. Click **"Submit"**
5. Review the preview:
- ✅ Green = New records to be created
- 🔵 Blue = Existing records to be updated
- ❌ Red = Errors (fix and re-import)
6. If preview looks good, click **"Confirm import"**
### Step 3: Verify Import
- Check the list to see your imported keywords
- Use filters to find specific industries/sectors
- Edit any records if needed
---
## Data Validation
The import process automatically:
**Validates volume:** Ensures it's a positive integer (defaults to 0 if invalid)
**Validates difficulty:** Clamps to 0-100 range
**Validates country:** Must be one of: US, CA, GB, AE, AU, IN, PK (defaults to US)
**Handles duplicates:** Uses `(keyword, industry, sector)` as unique key
**Skip unchanged:** If keyword already exists with same data, it's skipped
---
## Bulk Delete
### How to Delete Keywords
1. Select keywords using checkboxes (or "Select all")
2. Choose **"Delete selected keywords"** from the action dropdown
3. Click **"Go"**
4. Review the confirmation page showing all related objects
5. Click **"Yes, I'm sure"** to confirm deletion
**Note:** Only superusers and developers can delete seed keywords.
---
## Export Functionality
### Export to CSV/Excel
1. Go to: `https://api.igny8.com/admin/igny8_core_auth/seedkeyword/`
2. (Optional) Use filters to narrow down results
3. Click **"Export"** button (top right)
4. Choose format: CSV, Excel, JSON, etc.
5. File downloads with all selected fields
**Export includes:**
- All keyword data
- Related industry/sector names
- SEO metrics (volume, difficulty)
- Metadata (created date, active status)
---
## Common Issues & Solutions
### Issue: "Industry not found" error during import
**Solution:**
- Ensure the industry name in your CSV **exactly matches** an existing Industry record
- Check spelling, capitalization, and spacing
- View existing industries: `/admin/igny8_core_auth/industry/`
### Issue: "Sector not found" error during import
**Solution:**
- Ensure the sector name in your CSV **exactly matches** an existing IndustrySector record
- The sector must belong to the specified industry
- View existing sectors: `/admin/igny8_core_auth/industrysector/`
### Issue: Import shows errors for all rows
**Solution:**
- Check CSV encoding (must be UTF-8)
- Ensure column headers match exactly: `keyword,industry,sector,volume,difficulty,country,is_active`
- Remove any extra columns or spaces in headers
- Verify there are no special characters causing parsing issues
### Issue: Duplicate keyword error
**Solution:**
- Keywords are unique per `(keyword, industry, sector)` combination
- If importing a keyword that already exists, it will be updated (not duplicated)
- Use `skip_unchanged = True` to avoid unnecessary updates
### Issue: Delete confirmation page has no "Delete" button
**Solution:****FIXED** - Custom bulk delete action now includes proper delete button on confirmation page
---
## Permissions
| Action | Permission Required |
|--------|-------------------|
| View | Staff users |
| Add | Superuser |
| Edit | Superuser |
| Delete | Superuser or Developer |
| Import | Superuser |
| Export | Staff users |
---
## Technical Details
### Model Location
- **Model:** `backend/igny8_core/auth/models.py` - `SeedKeyword`
- **Admin:** `backend/igny8_core/auth/admin.py` - `SeedKeywordAdmin`
- **Resource:** `backend/igny8_core/auth/admin.py` - `SeedKeywordResource`
### Database Table
- **Table name:** `igny8_seed_keywords`
- **Unique constraint:** `(keyword, industry, sector)`
- **Indexes:**
- `keyword`
- `industry, sector`
- `industry, sector, is_active`
- `country`
### API Access (Read-Only)
- **Endpoint:** `/api/v1/auth/seed-keywords/`
- **ViewSet:** `SeedKeywordViewSet` (ReadOnlyModelViewSet)
- **Filters:** industry, sector, country, is_active
---
## Related Documentation
- [Django Admin Guide](../../docs/90-REFERENCE/DJANGO-ADMIN-ACCESS-GUIDE.md)
- [Models Reference](../../docs/90-REFERENCE/MODELS.md)
- [Planner Module](../../docs/10-MODULES/PLANNER.md)
---
**Last Updated:** January 11, 2026
**Maintainer:** IGNY8 Team

View File

@@ -1,7 +1,7 @@
# IGNY8 Technical Documentation
**Version:** 1.6.2
**Last Updated:** January 8, 2026
**Version:** 1.7.1
**Last Updated:** January 11, 2026
**Purpose:** Complete technical reference for the IGNY8 AI content platform
---
@@ -19,6 +19,8 @@
| **Read design guide** | [30-FRONTEND/DESIGN-GUIDE.md](30-FRONTEND/DESIGN-GUIDE.md) |
| Understand frontend structure | [30-FRONTEND/PAGES.md](30-FRONTEND/PAGES.md) |
| Trace a workflow end-to-end | [40-WORKFLOWS/](#workflows) |
| **Manage WordPress plugin** | [60-PLUGINS/WORDPRESS-INTEGRATION.md](60-PLUGINS/WORDPRESS-INTEGRATION.md) |
| **Release plugin update** | [60-PLUGINS/PLUGIN-UPDATE-WORKFLOW.md](60-PLUGINS/PLUGIN-UPDATE-WORKFLOW.md) |
| Look up model fields | [90-REFERENCE/MODELS.md](90-REFERENCE/MODELS.md) |
| **Payment system (Stripe/PayPal/Bank)** | [90-REFERENCE/PAYMENT-SYSTEM.md](90-REFERENCE/PAYMENT-SYSTEM.md) |
| See prelaunch checklist | [plans/FINAL-PRELAUNCH.md](plans/FINAL-PRELAUNCH.md) |
@@ -133,6 +135,22 @@
---
## 60-PLUGINS - Plugin Distribution & Management
| Document | Purpose |
|----------|---------|
| [INDEX.md](60-PLUGINS/INDEX.md) | Plugin management overview |
| [WORDPRESS-INTEGRATION.md](60-PLUGINS/WORDPRESS-INTEGRATION.md) | Complete WordPress integration guide (app-side) |
| [PLUGIN-UPDATE-WORKFLOW.md](60-PLUGINS/PLUGIN-UPDATE-WORKFLOW.md) | How to release updates, verification checklist |
**Key Points:**
- Plugin source: `/plugins/wordpress/source/igny8-wp-bridge/`
- Distribution: `/plugins/wordpress/dist/`
- Django app: `/backend/igny8_core/plugins/`
- API domain: `api.igny8.com` (not app.igny8.com)
---
## 90-REFERENCE - Quick Lookup
| Document | Purpose |

View File

@@ -26,6 +26,195 @@
3.3 ✅ - Update the 2 image sizes to be landscape and 2 square, and update the template to use full width image on full section, and half width content section
---
# PRE-LAUNCH PHASE 1: Code Cleanup & Technical Debt ✅
> **Goal:** Clean, maintainable codebase before production lock
> **Status:** Completed January 9, 2026
> **Commits:** 4 commits, -3,218 lines removed, 24 files changed
## 1.1 - Legacy Code Cleanup ✅
### 1.1.1 - Identify Legacy Items ✅
**Action:** Audit and document all unused code
- [x] Unused pages in `frontend/src/pages/` - Removed 11 empty folders
- [x] Unused routes in `App.tsx` - Cleaned up
- [x] Unused components in `frontend/src/components/` - Removed empty folders
- [x] Unused API endpoints in backend - N/A (all in use)
- [x] Deprecated documentation references - Updated
### 1.1.2 - Remove Legacy Code ✅
- [x] Remove identified unused pages - Removed test files, empty folders
- [x] Remove orphaned routes - Cleaned up
- [x] Remove unused components - Removed 11 empty folders
- [x] Remove deprecated API endpoints - N/A
- [x] Update documentation to reflect removals - Updated
### 1.1.3 - Code Quality Verification ✅
- [x] Run ESLint with design system rules - Passed
- [x] Fix any design system violations - None found
- [x] Verify TypeScript strict mode compliance - Passed
- [x] Check for console.log/debug statements - Removed 17 instances
---
# PRE-LAUNCH PHASE 4: Email & Notifications QA ✅
> **Goal:** Reliable email delivery and notification system
> **Status:** Completed January 9, 2026
## 4.1 - Email Deliverability Testing ✅
### 4.1.1 - Spam Score Testing
- [x] Test Resend emails with mail-tester.com
- [x] Document spam score results
- [x] Address any deliverability issues
### 4.1.2 - Email Trigger Verification
| Trigger | Email Type | Service | Tested |
|---------|------------|---------|--------|
| User Registration | Welcome email | Resend | ✅ |
| Email Verification | Verification link | Resend | ✅ |
| Password Reset | Reset link | Resend | ✅ |
| Password Changed | Confirmation | Resend | ✅ |
| Plan Upgrade | Confirmation | Resend | ✅ |
| Payment Success | Receipt | Resend | ✅ |
| Payment Failed | Alert | Resend | ✅ |
| Automation Complete | Notification | Resend | ✅ |
| Content Ready | Notification | Resend | ✅ |
### 4.1.3 - Acceptable Deliverability Standards
- Target: 95%+ inbox placement ✅
- Bounce rate: <5% ✅
- Spam complaint rate: <0.1% ✅
## 4.2 - In-App Notifications ✅
- [x] Verify notification bell updates in real-time
- [x] Test notification read/unread states
- [x] Verify notification dropdown functionality
- [x] Check notifications page `/account/notifications`
---
# PRE-LAUNCH PHASE 5: UX Improvements ✅
> **Goal:** Polished user experience for production
> **Status:** Completed January 9, 2026
> **Focus:** Enhanced search modal with filters, suggestions, and help integration
## 5.1 - Search Modal Enhancement ✅
**Current:** Enhanced with comprehensive features
**Completed:** Full search experience with help integration
### Improvements: ✅
- [x] Add search filters (by type: keyword, content, site, etc.) - Implemented with category badges
- [x] Add recent searches history - Implemented (stored in localStorage)
- [x] Improve search results display with context - Added context snippets with highlighting
- [x] Add keyboard shortcuts (Cmd/Ctrl + K) - Already implemented
- [x] Quick actions from search results - Implemented with suggested questions
- [x] **Bonus:** Added help knowledge base with 25+ questions across 8 topics
- [x] **Bonus:** Added smart phrase matching (strips filler words, handles plurals)
- [x] **Bonus:** Added comprehensive keyword coverage (task, cluster, billing, invoice, etc.)
## 5.2 - Image Regeneration Feature ⏸️
> **Status:** Deferred to post-launch (Phase 9)
> **Reason:** Current image generation is stable; regeneration is enhancement not critical for launch
## 5.3 - User Flow Polish ✅
> **Status:** Verified working - Ready for Phase 7 user testing
### Signup to First Content Flow ✅
1. [x] User signs up → verify smooth flow - Working
2. [x] Onboarding wizard → verify all steps work - Functional
3. [x] Add site → verify WordPress integration - Stable
4. [x] Add keywords → verify import works - Working
5. [x] Run clustering → verify AI works - Functional
6. [ ] Generate content → verify output quality - (Pending Phase 7 testing)
7. [ ] Publish to WordPress → verify integration - (Pending Phase 7 testing)
---
# PRE-LAUNCH PHASE 6: Data Backup & Cleanup ✅
> **Goal:** Fresh database for production launch
> **Status:** Completed January 9, 2026
> **Deliverables:** Django management commands + comprehensive documentation
## 6.1 - System Configuration Backup ✅
### 6.1.1 - Export System Data
**Keep these (system configuration):**
| Model Group | Export Format | Location |
|-------------|---------------|----------|
| Plans | JSON | `/backups/config/plans.json` |
| AIModelConfig | JSON | `/backups/config/ai_models.json` |
| CreditCostConfig | JSON | `/backups/config/credit_costs.json` |
| GlobalIntegrationSettings | JSON | `/backups/config/integrations.json` |
| SystemSettings | JSON | `/backups/config/system.json` |
| Prompts | JSON | `/backups/config/prompts.json` |
| AuthorProfiles | JSON | `/backups/config/authors.json` |
| Industries & Sectors | JSON | `/backups/config/industries.json` |
| SeedKeywords | JSON | `/backups/config/seed_keywords.json` |
### 6.1.2 - Document Configuration Values ✅
- [x] Export all Plan configurations - Command: `export_system_config`
- [x] Export all AI model settings - Included in export
- [x] Export all prompt templates - Included in export
- [x] Export all system settings - Included in export
- [x] Store in version control - Ready to commit before V1.0
**Implementation:** `/backend/igny8_core/management/commands/export_system_config.py`
**Usage:** `python manage.py export_system_config --output=/backups/v1-config.json`
## 6.2 - User Data Cleanup ✅
### 6.2.1 - Clear User-Generated Data ✅
**Remove ALL user-specific data:**
- [x] All Sites (except internal test sites) - Command ready
- [x] All Keywords - Command ready
- [x] All Clusters - Command ready
- [x] All Content Ideas - Command ready
- [x] All Tasks - Command ready
- [x] All Content - Command ready
- [x] All Images - Command ready
- [x] All Automation Runs - Command ready
- [x] All Publishing Records - Command ready
- [x] All Sync Events - Command ready
- [x] All Credit Transactions (except system) - Command ready
- [x] All Credit Usage Logs - Command ready
- [x] All Notifications - Command ready
**Implementation:** `/backend/igny8_core/management/commands/cleanup_user_data.py`
**Usage:** `python manage.py cleanup_user_data --confirm`
**Safety:** Includes dry-run mode, confirmation prompts, atomic transactions
### 6.2.2 - Clear Logs ✅
- [x] Application logs - Manual cleanup script provided
- [x] Celery task logs - Manual cleanup script provided
- [x] Automation logs - Covered by cleanup command
- [x] Publishing sync logs - Covered by cleanup command
- [x] Error logs - Manual cleanup documented
### 6.2.3 - Clear Media Storage ✅
- [x] Remove all generated images - Included in cleanup command
- [x] Clear CDN cache if applicable - Documented
- [x] Verify storage is empty - Verification steps included
**Documentation:** `/docs/plans/PHASE-6-BACKUP-CLEANUP-GUIDE.md` (300+ lines)
## 6.3 - V1.0 Configuration Lock ⏳
> **Status:** Ready to execute before V1.0 deployment
> **Note:** Commands and documentation prepared, will run during Phase 8 deployment
---
# PHASE 1: App UI Quick Fixes
## 1.1 - Credits Display Fix ✅
@@ -505,4 +694,47 @@ Simple pricing page with three plans:
- Phase 2 (after Phase 1) + Phase 3 + Phase 4
**Must be sequential**:
- Phase 5 → Phase 6 → Phase 7 → Phase 8
- Phase 5 → Phase 6 → Phase 7 → Phase 8
---
# PRE-LAUNCH PHASE 2: Content & Template Optimization ✅
> **Goal:** Production-ready content generation and publishing
> **Status:** Completed January 11, 2026
## 2.1 - WordPress Post Template ✅
### 2.1.1 - Template Structure Review ✅
**Location:** Backend content generation + WordPress publishing
- [x] Review HTML output structure
- [x] Validate semantic HTML (proper H1-H6 hierarchy)
- [x] Verify responsive image handling
- [x] Check internal link formatting
### 2.1.2 - SEO Elements Optimization ✅
- [x] Meta title generation quality
- [x] Meta description generation quality
- [x] Open Graph tags implementation
- [x] Schema.org structured data
- [x] Canonical URL handling
### 2.1.3 - Clean HTML Output ✅
- [x] Remove unnecessary HTML attributes
- [x] Minimize inline styles
- [x] Ensure valid HTML5 output
- [x] Test in WordPress theme compatibility
## 2.2 - Header Section Cleanup ✅
**Current Issue:** Shows various tags including non-public/non-SEO-friendly ones
### Required Changes ✅:
- [x] Audit all tags currently shown in content headers
- [x] Keep ONLY publicly visible, SEO-friendly tags
- [x] Keep ONLY functional/working tags
- [x] Remove all internal/debug tags from user-facing content
- [x] Document final tag structure
---

View File

@@ -1,7 +1,7 @@
# IGNY8 Pre-Launch Pending Tasks
**Last Updated:** January 9, 2026
**Version:** 1.6.3
**Last Updated:** January 11, 2026
**Version:** 1.7.1
**Target:** Production Launch Ready
---
@@ -10,96 +10,24 @@
| Phase | Focus | Priority | Status |
|-------|-------|----------|--------|
| **1** | Code Cleanup & Technical Debt | 🔴 Critical | ✅ Completed (Jan 9) |
| **2** | Content & Template Optimization | 🔴 Critical | ⏳ Pending |
| **3** | Pipeline Verification & Testing | 🔴 Critical | ⏳ Pending |
| **4** | Email & Notifications QA | 🟡 High | ⏳ Pending |
| **5** | UX Improvements | 🟡 High | ✅ Completed (Jan 9) |
| **6** | Data Backup & Cleanup | 🔴 Critical | ✅ Completed (Jan 9) |
| **7** | User Testing & Verification | 🔴 Critical | ⏳ Pending |
| **8** | Production Deployment | 🔴 Critical | ⏳ Pending |
| **9** | Documentation & Media | 🟢 Post-Launch | ⏳ Pending |
| **10** | Future Products & Addons | 🟢 Post-Launch | ⏳ Pending |
| **1** | Pipeline Verification & Testing | 🔴 Critical | ⏳ Pending |
| **2** | User Testing & Verification | 🔴 Critical | ⏳ Pending |
| **3** | Production Deployment | 🔴 Critical | ⏳ Pending |
| **4** | Documentation & Media | 🟢 Post-Launch | ⏳ Pending |
| **5** | Future Products & Addons | 🟢 Post-Launch | ⏳ Pending |
| **6** | Content View - Image Regeneration | 🟡 Enhancement | ⏳ Pending |
---
# PHASE 1: Code Cleanup & Technical Debt ✅
> **Goal:** Clean, maintainable codebase before production lock
> **Status:** Completed January 9, 2026
> **Commits:** 4 commits, -3,218 lines removed, 24 files changed
## 1.1 - Legacy Code Cleanup ✅
### 1.1.1 - Identify Legacy Items ✅
**Action:** Audit and document all unused code
- [x] Unused pages in `frontend/src/pages/` - Removed 11 empty folders
- [x] Unused routes in `App.tsx` - Cleaned up
- [x] Unused components in `frontend/src/components/` - Removed empty folders
- [x] Unused API endpoints in backend - N/A (all in use)
- [x] Deprecated documentation references - Updated
### 1.1.2 - Remove Legacy Code ✅
- [x] Remove identified unused pages - Removed test files, empty folders
- [x] Remove orphaned routes - Cleaned up
- [x] Remove unused components - Removed 11 empty folders
- [x] Remove deprecated API endpoints - N/A
- [x] Update documentation to reflect removals - Updated
### 1.1.3 - Code Quality Verification ✅
- [x] Run ESLint with design system rules - Passed
- [x] Fix any design system violations - None found
- [x] Verify TypeScript strict mode compliance - Passed
- [x] Check for console.log/debug statements - Removed 17 instances
> **Note:** Completed phases (1, 2, 4, 5, 6) have been moved to [FINAL-PRELAUNCH-Completed.md](./FINAL-PRELAUNCH-Completed.md)
---
# PHASE 2: Content & Template Optimization 🔴
> **Goal:** Production-ready content generation and publishing
## 2.1 - WordPress Post Template ⏳
### 2.1.1 - Template Structure Review
**Location:** Backend content generation + WordPress publishing
- [ ] Review HTML output structure
- [ ] Validate semantic HTML (proper H1-H6 hierarchy)
- [ ] Verify responsive image handling
- [ ] Check internal link formatting
### 2.1.2 - SEO Elements Optimization
- [ ] Meta title generation quality
- [ ] Meta description generation quality
- [ ] Open Graph tags implementation
- [ ] Schema.org structured data
- [ ] Canonical URL handling
### 2.1.3 - Clean HTML Output
- [ ] Remove unnecessary HTML attributes
- [ ] Minimize inline styles
- [ ] Ensure valid HTML5 output
- [ ] Test in WordPress theme compatibility
## 2.2 - Header Section Cleanup ⏳
**Current Issue:** Shows various tags including non-public/non-SEO-friendly ones
### Required Changes:
- [ ] Audit all tags currently shown in content headers
- [ ] Keep ONLY publicly visible, SEO-friendly tags
- [ ] Keep ONLY functional/working tags
- [ ] Remove all internal/debug tags from user-facing content
- [ ] Document final tag structure
---
# PHASE 3: Pipeline Verification & Testing 🔴
# PHASE 1: Pipeline Verification & Testing 🔴
> **Goal:** Complete end-to-end functionality verified
## 3.1 - Manual Pipeline Test ⏳
## 1.1 - Manual Pipeline Test ⏳
**Workflow:** Keywords → Clusters → Ideas → Tasks → Content → Images → Review → Publish
@@ -114,7 +42,7 @@
| 7. Review | Review content in editor | Content editable, preview works |
| 8. Publish | Publish to WordPress | Content appears on WP site |
## 3.2 - Automated Pipeline Test ⏳
## 1.2 - Automated Pipeline Test ⏳
**Test 7-stage automation with real data:**
@@ -125,9 +53,9 @@
- [ ] Confirm content reaches review/publish queue
- [ ] Check automation logs for issues
## 3.3 - CRUD Operations Verification ⏳
## 1.3 - CRUD Operations Verification ⏳
### 3.3.1 - Per-Module CRUD Audit
### 1.3.1 - Per-Module CRUD Audit
| Module | Create | Read | Update | Delete | Bulk Actions |
|--------|--------|------|--------|--------|--------------|
@@ -139,14 +67,14 @@
| Images | [ ] | [ ] | [ ] | [ ] | [ ] |
| Sites | [ ] | [ ] | [ ] | [ ] | N/A |
### 3.3.2 - Document Any Issues
### 1.3.2 - Document Any Issues
- Record each issue with steps to reproduce
- Prioritize by severity (blocking, major, minor)
- Fix all blocking issues before proceeding
## 3.4 - Credits Accuracy Verification ⏳
## 1.4 - Credits Accuracy Verification ⏳
### 3.4.1 - Credit Consumption Tests
### 1.4.1 - Credit Consumption Tests
| Operation | Expected Credits | Actual | Pass/Fail |
|-----------|------------------|--------|-----------|
| Keyword Clustering | Per batch (token-based) | [ ] | [ ] |
@@ -156,7 +84,7 @@
| Image Generation (Quality) | 5 credits/image | [ ] | [ ] |
| Image Generation (Premium) | 15 credits/image | [ ] | [ ] |
### 3.4.2 - Edge Cases
### 1.4.2 - Edge Cases
- [ ] Test with zero credits (should block AI operations)
- [ ] Test insufficient credits scenario
- [ ] Verify balance never goes negative
@@ -164,222 +92,34 @@
---
# PHASE 4: Email & Notifications QA 🟡
> **Goal:** Reliable email delivery and notification system
## 4.1 - Email Deliverability Testing ⏳
### 4.1.1 - Spam Score Testing
- [ ] Test Resend emails with mail-tester.com
- [ ] Test Resend emails with mail-tester.com
- [ ] Document spam score results
- [ ] Address any deliverability issues
### 4.1.2 - Email Trigger Verification
| Trigger | Email Type | Service | Tested |
|---------|------------|---------|--------|
| User Registration | Welcome email | Resend | [ ] |
| Email Verification | Verification link | Resend | [ ] |
| Password Reset | Reset link | Resend | [ ] |
| Password Changed | Confirmation | Resend | [ ] |
| Plan Upgrade | Confirmation | Resend | [ ] |
| Payment Success | Receipt | Resend | [ ] |
| Payment Failed | Alert | Resend | [ ] |
| Automation Complete | Notification | Resend | [ ] |
| Content Ready | Notification | Resend | [ ] |
### 4.1.3 - Acceptable Deliverability Standards
- Target: 95%+ inbox placement
- Bounce rate: <5%
- Spam complaint rate: <0.1%
## 4.2 - In-App Notifications ⏳
- [ ] Verify notification bell updates in real-time
- [ ] Test notification read/unread states
- [ ] Verify notification dropdown functionality
- [ ] Check notifications page `/account/notifications`
---
# PHASE 5: UX Improvements ✅
> **Goal:** Polished user experience for production
> **Status:** Completed January 9, 2026
> **Focus:** Enhanced search modal with filters, suggestions, and help integration
## 5.1 - Search Modal Enhancement ✅
**Current:** Enhanced with comprehensive features
**Completed:** Full search experience with help integration
### Improvements: ✅
- [x] Add search filters (by type: keyword, content, site, etc.) - Implemented with category badges
- [x] Add recent searches history - Implemented (stored in localStorage)
- [x] Improve search results display with context - Added context snippets with highlighting
- [x] Add keyboard shortcuts (Cmd/Ctrl + K) - Already implemented
- [x] Quick actions from search results - Implemented with suggested questions
- [x] **Bonus:** Added help knowledge base with 25+ questions across 8 topics
- [x] **Bonus:** Added smart phrase matching (strips filler words, handles plurals)
- [x] **Bonus:** Added comprehensive keyword coverage (task, cluster, billing, invoice, etc.)
## 5.2 - Image Regeneration Feature ⏸️
> **Status:** Deferred to post-launch (Phase 9)
> **Reason:** Current image generation is stable; regeneration is enhancement not critical for launch
### 5.2.1 - Images Page Improvements
**Location:** `/writer/images`
- [ ] Add "Regenerate" button for each image
- [ ] Option to describe issue when regenerating
- [ ] Show regeneration status/history
### 5.2.2 - Content View Improvements
**Location:** `/writer/content/:id`
- [ ] Add regenerate option for featured image
- [ ] Admin/Editor role visibility only
- [ ] Include note field for regeneration reason
### 5.2.3 - Auto-Regeneration (Optional)
- [ ] Detect common image generation failures
- [ ] Auto-retry with modified prompt
- [ ] Log auto-regeneration attempts
## 5.3 - User Flow Polish ✅
> **Status:** Verified working - Ready for Phase 7 user testing
### Signup to First Content Flow ✅
1. [x] User signs up → verify smooth flow - Working
2. [x] Onboarding wizard → verify all steps work - Functional
3. [x] Add site → verify WordPress integration - Stable
4. [x] Add keywords → verify import works - Working
5. [x] Run clustering → verify AI works - Functional
6. [ ] Generate content → verify output quality
7. [ ] Publish to WordPress → verify integration
---
# PHASE 6: Data Backup & Cleanup ✅
> **Goal:** Fresh database for production launch
> **Status:** Completed January 9, 2026
> **Deliverables:** Django management commands + comprehensive documentation
## 6.1 - System Configuration Backup ✅
### 6.1.1 - Export System Data
**Keep these (system configuration):**
| Model Group | Export Format | Location |
|-------------|---------------|----------|
| Plans | JSON | `/backups/config/plans.json` |
| AIModelConfig | JSON | `/backups/config/ai_models.json` |
| CreditCostConfig | JSON | `/backups/config/credit_costs.json` |
| GlobalIntegrationSettings | JSON | `/backups/config/integrations.json` |
| SystemSettings | JSON | `/backups/config/system.json` |
| Prompts | JSON | `/backups/config/prompts.json` |
| AuthorProfiles | JSON | `/backups/config/authors.json` |
| Industries & Sectors | JSON | `/backups/config/industries.json` |
| SeedKeywords | JSON | `/backups/config/seed_keywords.json` |
### 6.1.2 - Document Configuration Values ✅
- [x] Export all Plan configurations - Command: `export_system_config`
- [x] Export all AI model settings - Included in export
- [x] Export all prompt templates - Included in export
- [x] Export all system settings - Included in export
- [x] Store in version control - Ready to commit before V1.0
**Implementation:** `/backend/igny8_core/management/commands/export_system_config.py`
**Usage:** `python manage.py export_system_config --output=/backups/v1-config.json`
## 6.2 - User Data Cleanup ✅
### 6.2.1 - Clear User-Generated Data ✅
**Remove ALL user-specific data:**
- [x] All Sites (except internal test sites) - Command ready
- [x] All Keywords - Command ready
- [x] All Clusters - Command ready
- [x] All Content Ideas - Command ready
- [x] All Tasks - Command ready
- [x] All Content - Command ready
- [x] All Images - Command ready
- [x] All Automation Runs - Command ready
- [x] All Publishing Records - Command ready
- [x] All Sync Events - Command ready
- [x] All Credit Transactions (except system) - Command ready
- [x] All Credit Usage Logs - Command ready
- [x] All Notifications - Command ready
**Implementation:** `/backend/igny8_core/management/commands/cleanup_user_data.py`
**Usage:** `python manage.py cleanup_user_data --confirm`
**Safety:** Includes dry-run mode, confirmation prompts, atomic transactions
### 6.2.2 - Clear Logs ✅
- [x] Application logs - Manual cleanup script provided
- [x] Celery task logs - Manual cleanup script provided
- [x] Automation logs - Covered by cleanup command
- [x] Publishing sync logs - Covered by cleanup command
- [x] Error logs - Manual cleanup documented
### 6.2.3 - Clear Media Storage ✅
- [x] Remove all generated images - Included in cleanup command
- [x] Clear CDN cache if applicable - Documented
- [x] Verify storage is empty - Verification steps included
**Documentation:** `/docs/plans/PHASE-6-BACKUP-CLEANUP-GUIDE.md` (300+ lines)
## 6.3 - V1.0 Configuration Lock ⏳
> **Status:** Ready to execute before V1.0 deployment
> **Note:** Commands and documentation prepared, will run during Phase 8 deployment
### 6.3.1 - Final Configuration Freeze
- [ ] Lock all Plan configurations
- [ ] Lock all credit costs
- [ ] Lock all AI model configs
- [ ] Lock all system settings
- [ ] Document final values in `docs/90-REFERENCE/V1-CONFIG.md`
### 6.3.2 - Version Tagging
- [ ] Tag codebase as `v1.0.0`
- [ ] Create release notes
- [ ] No config changes without version bump
---
# PHASE 7: User Testing & Verification 🔴
# PHASE 2: User Testing & Verification 🔴
> **Goal:** Real-world user flow validation
## 7.1 - New User Signup Tests ⏳
## 2.1 - New User Signup Tests ⏳
### 7.1.1 - Starter Plan (3 Users)
### 2.1.1 - Starter Plan (3 Users)
| User | Email | Signup | Onboarding | Site | Keywords | AI Run | Publish |
|------|-------|--------|------------|------|----------|--------|---------|
| 1 | test_starter_1@... | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] |
| 2 | test_starter_2@... | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] |
| 3 | test_starter_3@... | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] |
### 7.1.2 - Growth Plan (3 Users)
### 2.1.2 - Growth Plan (3 Users)
| User | Email | Signup | Payment | Onboarding | Multi-Site |
|------|-------|--------|---------|------------|------------|
| 1 | test_growth_1@... | [ ] | [ ] | [ ] | [ ] |
| 2 | test_growth_2@... | [ ] | [ ] | [ ] | [ ] |
| 3 | test_growth_3@... | [ ] | [ ] | [ ] | [ ] |
### 7.1.3 - Scale Plan (3 Users)
### 2.1.3 - Scale Plan (3 Users)
| User | Email | Signup | Payment | Team Invite | Full Pipeline |
|------|-------|--------|---------|-------------|---------------|
| 1 | test_scale_1@... | [ ] | [ ] | [ ] | [ ] |
| 2 | test_scale_2@... | [ ] | [ ] | [ ] | [ ] |
| 3 | test_scale_3@... | [ ] | [ ] | [ ] | [ ] |
## 7.2 - Personal Site Deployments ⏳
## 2.2 - Personal Site Deployments ⏳
### Test with Real Sites:
| Site | WP Integration | Keywords | Pipeline | Published |
@@ -392,13 +132,13 @@
---
# PHASE 8: Production Deployment 🔴
# PHASE 3: Production Deployment 🔴
> **Goal:** Stable production environment
## 8.1 - Pre-Deployment Checklist ⏳
## 3.1 - Pre-Deployment Checklist ⏳
### 8.1.1 - Environment Variables
### 3.1.1 - Environment Variables
- [ ] All API keys configured (OpenAI, Anthropic, Runware, Bria)
- [ ] Stripe/PayPal credentials set
- [ ] Email service credentials (Resend, Brevo)
@@ -406,20 +146,20 @@
- [ ] Redis connection
- [ ] CDN/Storage configuration
### 8.1.2 - Security Audit
### 3.1.2 - Security Audit
- [ ] All debug modes disabled
- [ ] CORS properly configured
- [ ] SSL certificates valid
- [ ] Rate limiting enabled
- [ ] API authentication verified
### 8.1.3 - Performance Check
### 3.1.3 - Performance Check
- [ ] Database indexes verified
- [ ] Static assets optimized
- [ ] CDN caching configured
- [ ] Celery workers scaled appropriately
## 8.2 - Go Live ⏳
## 3.2 - Go Live ⏳
- [ ] Deploy to production
- [ ] Run smoke tests
@@ -429,11 +169,11 @@
---
# PHASE 9: Documentation & Media 🟢 (Post-Launch)
# PHASE 4: Documentation & Media 🟢 (Post-Launch)
> **Goal:** Marketing and support materials
## 9.1 - Feature Screencasts ⏳
## 4.1 - Feature Screencasts ⏳
### Per-Page Screenshots/Recordings
| Page | Screenshot | Screencast | Notes |
@@ -448,7 +188,7 @@
**Important:** Use real/representative data, not empty states
## 9.2 - Explainer Videos ⏳
## 4.2 - Explainer Videos ⏳
| Video | Topic | Duration | Status |
|-------|-------|----------|--------|
@@ -458,7 +198,7 @@
| 4 | WordPress Integration | 3-5 min | [ ] |
| 5 | Credits & Billing | 2-3 min | [ ] |
## 9.3 - Tutorial Videos ⏳
## 4.3 - Tutorial Videos ⏳
| Tutorial | Workflow | Duration | Status |
|----------|----------|----------|--------|
@@ -469,13 +209,13 @@
---
# PHASE 10: Future Products & Addons 🟢 (Post-Launch)
# PHASE 5: Future Products & Addons 🟢 (Post-Launch)
> **Goal:** Revenue expansion options
## 10.1 - Service Packages ⏳
## 5.1 - Service Packages ⏳
### 10.1.1 - Initial Setup Package
### 5.1.1 - Initial Setup Package
**Price:** $149 (One-time)
**Includes:**
@@ -485,7 +225,7 @@
- Initial clustering configuration
- First automation run
### 10.1.2 - Managed Plan
### 5.1.2 - Managed Plan
**Price:** TBD (Monthly)
**Includes:**
@@ -495,7 +235,7 @@
- Performance monitoring
- Partner agency fulfillment
## 10.2 - Pricing Page Updates ⏳
## 5.2 - Pricing Page Updates ⏳
### Required Changes:
- [ ] Design clear product/addon presentation
@@ -506,15 +246,76 @@
---
# PHASE 6: Content View - Image Regeneration 🟡 (Enhancement)
### NEW FEATURE - Enhancement
**Problem:**
Need ability to regenerate images from the content view with:
- Custom prompt input
- Option to regenerate from original prompt
- Option to generate at higher quality tier
**Current State:**
- Backend: API endpoint documented but NOT implemented
- Frontend: No regenerate buttons exist
**Implementation Plan:**
### Backend:
Add `regenerate` action to `ImageViewSet`:
```python
# In modules/writer/views.py - ImageViewSet
@action(detail=True, methods=['post'])
def regenerate(self, request, pk=None):
image = self.get_object()
custom_prompt = request.data.get('custom_prompt', '')
quality_tier = request.data.get('quality_tier', image.quality_tier)
# Append custom prompt to original if provided
prompt = image.prompt
if custom_prompt:
prompt = f"{prompt}. {custom_prompt}"
# Check credits for quality tier
# Generate new image
# Update image record
# Return result
```
### Frontend:
Add regenerate button to content view:
```tsx
// In ContentViewTemplate or similar
<Button
variant="secondary"
size="sm"
onClick={() => setShowRegenerateModal(true)}
>
<RefreshIcon /> Regenerate Image
</Button>
// Modal with options:
// - Custom prompt textarea
// - Quality tier selector (Basic/Quality/Premium)
// - "Use original prompt" checkbox
```
**Credit Calculation:**
- Show credit cost before regeneration
- Different costs for different quality tiers
---
# Quick Reference: Execution Checklist
## Pre-Launch Critical Path
```
Week 1: Phase 1 (Cleanup) + Phase 2 (Templates)
Week 2: Phase 3 (Testing) + Phase 4 (Email)
Week 3: Phase 5 (UX) + Phase 6 (Backup/Cleanup)
Week 4: Phase 7 (User Testing) + Phase 8 (Deploy)
Phase 1: Pipeline Testing (1 week)
Phase 2: User Testing (1 week)
Phase 3: Production Deployment (3 days)
Post-Launch: Phase 4 (Documentation) + Phase 5 (Future Products)
```
## Daily Standup Questions

View File

@@ -0,0 +1,447 @@
# WordPress Integration Audit Report
## Overview
The WordPress plugin (IGNY8 WP Bridge v1.3.3) has been built with extensive features, many of which are either not fully implemented on the IGNY8 app side or are too complex for current needs.
---
## 1. CONTROLS PAGE (Plugin)
### What It Does:
The "Controls" page in the WordPress plugin lets users configure which content types and features should sync between WordPress and IGNY8.
### Current Components:
#### A) **Post Types to Sync**
- Shows: Posts, Pages, Products (if WooCommerce installed)
- **Purpose**: Determines which WordPress post types IGNY8 can publish to
- **What Actually Works**: Only "Posts" is fully functional. Pages and Products publishing is NOT implemented in IGNY8 app yet.
#### B) **Taxonomies to Sync**
- Shows: Categories, Tags, Product Categories, Product Tags, Product Shipping Classes, IGNY8 Sectors, IGNY8 Clusters, Brand, Brands, Expired
- **Purpose**: Determines which taxonomy terms sync between systems
- **What Actually Works**: Only Categories, Tags, IGNY8 Sectors, IGNY8 Clusters are used. The rest are either WooCommerce-specific (not supported) or custom taxonomies from the specific WordPress site.
#### C) **Control Mode** (Mirror vs Hybrid)
- **Mirror Mode**: IGNY8 is source of truth. WordPress reflects changes only. Content is read-only in WordPress.
- **Hybrid Mode**: Two-way sync. WordPress editors can edit content and changes sync back to IGNY8.
- **What Actually Works**: **NOTHING**. This is purely UI. The backend code doesn't check or enforce either mode. Content can always be edited in WordPress and it does NOT sync back to IGNY8. This is misleading UI.
#### D) **IGNY8 Modules**
- Shows: Sites (Data & Semantic Map), Planner (Keywords & Briefs), Writer (Tasks & Posts), Linker (Internal Links), Optimizer (Audits & Scores)
- **Purpose**: Allow users to disable specific IGNY8 features if they're not using them
- **What Actually Works**: **NOTHING**. This is purely UI. The plugin doesn't actually enable/disable any module functionality. These checkboxes have no effect on what gets synced or displayed.
#### E) **Default Post Status**
- Draft or Publish
- **Purpose**: When IGNY8 publishes content, should it be saved as Draft (for review) or Published immediately
- **What Actually Works**: **YES, this works**. The plugin checks this setting when receiving content from IGNY8.
#### F) **Sync WooCommerce Products**
- Checkbox (only shown if WooCommerce installed)
- **What Actually Works**: **NOT IMPLEMENTED** in IGNY8 app. WooCommerce product sync is planned but not built.
---
## 2. SYNC PAGE (Plugin)
### What It Does:
Shows sync status and history for different data types.
### Current Components:
#### A) **Sync Status Toggle**
- "Enable IGNY8 Sync" checkbox
- **Purpose**: Master switch to enable/disable all syncing
- **What Actually Works**: **YES, this works**. When disabled, the plugin rejects incoming content from IGNY8.
#### B) **Sync History**
- Shows: Site Data, Taxonomies, Keywords, Writers
- Shows last sync timestamp and status (Synced/Never)
- **What Actually Works**: **MISLEADING**.
- "Site Data" = metadata about WordPress site sent TO IGNY8 (one-way)
- "Taxonomies" = categories/tags metadata
- "Keywords" = NOT IMPLEMENTED (seed keywords feature)
- "Writers" = NOT IMPLEMENTED (author sync feature)
#### C) **Scheduled Syncs**
- Shows next scheduled site data sync
- **What Actually Works**: WordPress cron job that periodically sends site metadata to IGNY8. Works but rarely used.
---
## 3. DATA PAGE (Plugin)
### What It Does:
Shows internal link queue status.
### Current Components:
- Total Links, Pending, Processed counts
- Link Queue table showing posts, target URLs, anchors, status
### What Actually Works:
This is for the **LINKER** module - internal linking automation. When IGNY8 sends a post with internal link suggestions, they go into this queue and get processed.
**Current Status**: Linker module is partially implemented but not production-ready in IGNY8 app.
---
## 4. CONTENT TYPES PAGE (IGNY8 App - Site Settings)
### What It Does:
Shows WordPress site structure fetched from the plugin:
- Post Types (Pages, Posts, Products) with counts and "Enabled/Disabled" badges
- Taxonomies (Categories, Tags, etc.) with counts and "Enabled/Disabled" badges
- "Sync Structure" button to refresh data
### What Actually Works:
- **Sync Structure** = Calls WordPress plugin's `/igny8/v1/site-metadata/` endpoint to get counts
- **Enabled/Disabled** = Reflects what's checked in WordPress plugin's Controls page
- **Limit: 100** = Fetch limit per type (hardcoded, not configurable in app)
### Purpose:
This page is **informational only** - shows what's available on WordPress side. User cannot change anything here. They must change settings in WordPress plugin.
### Problem:
The "Enabled/Disabled" comes from WordPress but the IGNY8 app has NO way to enable/disable content types. It's display-only and confusing.
---
## 5. What Actually Gets Synced
### IGNY8 → WordPress (Working):
1. **Published Content**: When content is approved in IGNY8 and published, it gets sent to WordPress
2. **Categories**: IGNY8 categories sync as WordPress categories
3. **Tags**: IGNY8 tags sync as WordPress tags
4. **IGNY8 Sectors**: Custom taxonomy for content organization
5. **IGNY8 Clusters**: Custom taxonomy for content clustering
6. **Featured Images**: Images are uploaded to WordPress media library
### WordPress → IGNY8 (Working):
1. **Site Metadata**: Post counts, taxonomy counts (for display in app)
2. **Post Status Updates**: Webhooks notify IGNY8 when WordPress post status changes
### NOT Working / Not Implemented:
1. Pages publishing
2. Products publishing (WooCommerce)
3. Product Categories/Tags sync
4. Two-way content sync (Hybrid Mode)
5. Keywords sync
6. Writers sync
7. Module-based enable/disable
8. Internal linking automation (Linker)
9. Audits & Scores (Optimizer)
---
---
# IMPLEMENTATION PLAN
---
## Phase 1: WordPress Plugin Updates
### 1.1 Controls Page Redesign → Rename to "Settings"
#### New Structure:
**Section A: Post Types** (Top level toggles)
```
┌─────────────────────────────────────────────────────────────┐
│ POST TYPES │
├─────────────────────────────────────────────────────────────┤
│ ☑ Posts │
│ ☐ Pages [Coming Soon] │
│ ☐ Products (WooCommerce) [Coming Soon] │
│ ☐ {Other detected CPTs} [Coming Soon] │
└─────────────────────────────────────────────────────────────┘
```
- Auto-detect ALL registered post types in WordPress
- Show toggle for each post type
- "Coming Soon" badge on ALL post types except Posts
- Keep all toggles functional for now (testing) - will disable later
- Only show taxonomy cards below for ENABLED post types
**Section B: Post Type Cards** (Conditional - only shown if post type is enabled)
For each enabled post type, show a separate card with its taxonomies:
```
┌─────────────────────────────────────────────────────────────┐
│ 📝 POSTS │
├─────────────────────────────────────────────────────────────┤
│ Taxonomies: │
│ ☑ Categories │
│ ☑ Tags │
│ ☑ IGNY8 Sectors │
│ ☑ IGNY8 Clusters │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 📦 PRODUCTS [Coming Soon] │
├─────────────────────────────────────────────────────────────┤
│ Taxonomies: │
│ ☑ Product Categories │
│ ☑ Product Tags │
│ ☑ IGNY8 Sectors │
│ ☑ IGNY8 Clusters │
│ ☐ Product Shipping Classes │
│ ☐ Brands │
│ ☐ {Other product attributes/taxonomies} │
└─────────────────────────────────────────────────────────────┘
```
- Auto-detect ALL taxonomies registered for each post type
- For Products: auto-detect all WooCommerce attributes and taxonomies
- Default enabled: Categories, Tags, Product Categories, Product Tags, IGNY8 Sectors, IGNY8 Clusters
- Default disabled: All other detected taxonomies
- Remove "Expired" taxonomy (this was created by our plugin in error - delete it)
**Section C: Other Settings**
```
┌─────────────────────────────────────────────────────────────┐
│ DEFAULT SETTINGS │
├─────────────────────────────────────────────────────────────┤
│ Default Post Status: [Draft ▼] │
│ Enable IGNY8 Sync: ☑ ON │
└─────────────────────────────────────────────────────────────┘
```
### 1.2 Items to REMOVE from Plugin
1. **Control Mode** (Mirror/Hybrid) - doesn't do anything, misleading
2. **IGNY8 Modules checkboxes** - doesn't do anything, misleading
3. **Sync History for Keywords** - not implemented
4. **Sync History for Writers** - not implemented
5. **Scheduled Syncs section** - rarely used, confusing
6. **"Expired" taxonomy** - created in error by plugin, remove it
### 1.3 UI Terminology Changes
| Current | New |
|---------|-----|
| Controls | Settings |
| Sync | Connection |
| Sync History | Last Updated |
| Data | Link Queue (if Linker enabled) or hide |
### 1.4 Technical Implementation Notes
```php
// Auto-detect post types
$post_types = get_post_types(array('public' => true), 'objects');
// Auto-detect taxonomies for each post type
foreach ($post_types as $post_type) {
$taxonomies = get_object_taxonomies($post_type->name, 'objects');
}
// For WooCommerce Products, also get attributes
if (class_exists('WooCommerce')) {
$attributes = wc_get_attribute_taxonomies();
}
```
---
## Phase 2: IGNY8 App Updates
### 2.1 Site Content Page Updates (`/sites/{id}/content`)
#### A) Post Type Filters (Top Bar)
Add post type selector buttons above the content list:
- Only show if there are more than 1 post type enabled in WordPress plugin
- Show buttons like: `Posts` | `Products` | `Services` | etc.
- Default to "Posts"
- Filter content by selected post type
```
┌─────────────────────────────────────────────────────────────┐
│ Posts │ Products │ Services │
├─────────────────────────────────────────────────────────────┤
│ [Filters...] [Content Types] │
├─────────────────────────────────────────────────────────────┤
│ Content list... │
└─────────────────────────────────────────────────────────────┘
```
#### B) Content Types Button
Add "Content Types" button on the far right of the same row as post type filters:
- Opens a modal/drawer showing WordPress content structure
- This replaces the "Content Types" tab in Site Settings
### 2.2 New Content Structure Page (Full Page)
**Route:** `/sites/{id}/content/structure`
**Purpose:** Show site's content structure organized by Clusters - FULL PAGE, not modal
**Layout:**
```
┌─────────────────────────────────────────────────────────────┐
│ SITE > CONTENT > STRUCTURE │
├─────────────────────────────────────────────────────────────┤
│ Cluster: [Select Cluster ▼] │
├─────────────────────────────────────────────────────────────┤
│ (When cluster selected, show:) │
│ │
│ Keywords in this Cluster: │
│ ┌─────────────────────────────────────────────────────────┐
│ │ Keyword Name │ Content Count │
│ ├─────────────────────────────────────────────────────────┤
│ │ AI Content Writing │ 12 │
│ │ Blog Automation Tools │ 8 │
│ │ SEO Content Strategy │ 5 │
│ └─────────────────────────────────────────────────────────┘
│ │
│ Cluster Content: │
│ (REUSE existing component from /planner/clusters/{id}) │
│ - View Live link │
│ - View in App link │
│ - Edit link │
│ - Published/Pending/Draft counts │
│ - Content list with same styling │
└─────────────────────────────────────────────────────────────┘
```
**Key Features:**
- Full page with proper URL: `/sites/{id}/content/structure`
- Cluster selector dropdown at top
- Keywords list with content counts for selected cluster
- **REUSE** existing content list component from cluster detail page (`/planner/clusters/{id}`)
- Same functionality: View Live, View in App, Edit links
- Shows content stats and articles list
### 2.3 Remove from Site Settings Page
1. **Remove "Content Types" tab** from Site Settings
2. Only track content/terms that IGNY8 is publishing/using
### 2.4 Content & Taxonomy Tracking (KEEP & IMPROVE)
**IMPORTANT:** Do NOT break existing publishing and tracking systems.
**What We Track (KEEP):**
- Content published by IGNY8 against Clusters
- Content published by IGNY8 against Keywords
- IGNY8 Clusters taxonomy on WordPress (created/synced by plugin)
- IGNY8 Sectors taxonomy on WordPress (created/synced by plugin)
- Categories and Tags used by published content
**Improvements:**
- Validate IGNY8 Clusters taxonomy sync between app and WordPress
- Validate IGNY8 Sectors taxonomy sync between app and WordPress
- Ensure counts match on both local app and remote WordPress site
- Content tracking against clusters/keywords is critical for:
- Future optimization
- Content rewriting
- Content extension
- Performance analytics
### 2.5 Bulk Structure Syncing (SIMPLIFY, NOT REMOVE)
**KEEP:**
- Syncing of IGNY8 Clusters taxonomy
- Syncing of IGNY8 Sectors taxonomy
- Categories/Tags used by IGNY8 published content
- Content counts against clusters/keywords
**REMOVE:**
- Full WordPress site structure dump (all post types, all taxonomies)
- Counts of content not managed by IGNY8
- Periodic full re-sync of entire site structure
**Principle:** Only track what IGNY8 creates/publishes, not entire WordPress site.
---
## Phase 3: Summary of Changes
### WordPress Plugin Changes:
| Item | Action |
|------|--------|
| Controls page | Rename to "Settings", redesign |
| Post Types | Auto-detect all, show toggles, "Coming Soon" on all except Posts |
| Taxonomies | Group under post type cards, auto-detect all |
| Control Mode | REMOVE |
| IGNY8 Modules | REMOVE |
| Expired taxonomy | DELETE (was created in error) |
| Sync History (Keywords, Writers) | REMOVE |
| Scheduled Syncs | REMOVE |
| Data page | Keep for Linker queue only |
### IGNY8 App Changes:
| Item | Action |
|------|--------|
| `/sites/{id}/content` | Add post type filter buttons |
| Content Types button | Add to content page → links to `/sites/{id}/content/structure` |
| Content Structure page | New full page at `/sites/{id}/content/structure` |
| Site Settings > Content Types tab | REMOVE |
| Bulk structure syncing | SIMPLIFY - only track IGNY8-managed content |
| IGNY8 Clusters/Sectors sync | KEEP & VALIDATE - ensure app ↔ WordPress match |
| Content tracking (clusters/keywords) | KEEP - critical for optimization/rewriting |
### What Still Works (No Changes):
1. Publishing Posts to WordPress ✅
2. Categories sync ✅
3. Tags sync ✅
4. IGNY8 Sectors sync ✅
5. IGNY8 Clusters sync ✅
6. Featured images ✅
7. In-article images ✅
8. Post status webhooks ✅
9. Content tracking against Clusters ✅
10. Content tracking against Keywords ✅
---
## Implementation Checklist
### WordPress Plugin:
- [ ] Rename "Controls" to "Settings"
- [ ] Add post type auto-detection
- [ ] Add post type toggles with "Coming Soon" badges
- [ ] Create taxonomy cards for each enabled post type
- [ ] Auto-detect all taxonomies per post type
- [ ] Auto-detect WooCommerce attributes for Products
- [ ] Set default enabled taxonomies (Categories, Tags, Product Categories, Product Tags, Sectors, Clusters)
- [ ] Remove Control Mode section
- [ ] Remove IGNY8 Modules section
- [ ] Remove "Expired" taxonomy
- [ ] Remove Keywords/Writers from Sync History
- [ ] Remove Scheduled Syncs section
- [ ] Simplify Sync page → Connection page
### IGNY8 App:
- [ ] Add post type filter buttons to `/sites/{id}/content`
- [ ] Only show if >1 post type enabled
- [ ] Add "Structure" button (far right) → links to `/sites/{id}/content/structure`
- [ ] Create Content Structure page at `/sites/{id}/content/structure`
- [ ] Add cluster selector dropdown
- [ ] Show keywords with content counts
- [ ] REUSE content list component from `/planner/clusters/{id}`
- [ ] Remove Content Types tab from Site Settings
- [ ] Simplify bulk structure syncing (keep IGNY8 clusters/sectors only)
- [ ] Validate IGNY8 Clusters taxonomy sync (app ↔ WordPress)
- [ ] Validate IGNY8 Sectors taxonomy sync (app ↔ WordPress)
- [ ] Keep content tracking against clusters/keywords (DO NOT BREAK)
---
## Notes
- All post types except Posts show "Coming Soon" but remain configurable for testing
- Later: disable non-Posts post types after production testing
- Publishing flow MUST continue working throughout changes
- No changes to the actual publishing API endpoints
- **CRITICAL:** Content tracking against Clusters and Keywords must remain intact
- **CRITICAL:** IGNY8 Clusters and Sectors taxonomy sync must be validated and working
- Reuse existing components where possible (content list from cluster detail page)

View File

@@ -0,0 +1,215 @@
# WordPress Plugin & IGNY8 App Cleanup - Implementation Summary
**Date:** Implementation completed
**Related Plan:** [WP_PLUGIN_IGNY8_APP_CLEANUP.md](./WP_PLUGIN_IGNY8_APP_CLEANUP.md)
---
## Overview
This document summarizes the changes implemented to clean up and simplify the WordPress plugin (IGNY8 WP Bridge) and the IGNY8 app frontend based on the requirements discussed.
---
## WordPress Plugin Changes
### 1. Controls Page → Settings Page (RENAMED & REDESIGNED)
**File:** `plugins/wordpress/source/igny8-wp-bridge/admin/pages/settings.php`
**Changes:**
- Renamed "Controls" to "Settings" throughout the plugin
- Complete redesign with post type cards showing:
- Auto-detected post types using WordPress `get_post_types(['public' => true])`
- Each post type displayed in a card with its own taxonomies
- Enable/disable toggles for each post type
- **"Coming Soon" badges** on all post types except Posts
- Default settings section with Post Status and Sync Enable toggles
- Taxonomy cards shown only for enabled post types
**Key Features:**
- Auto-detects ALL public post types (Posts, Pages, Products, Custom Post Types)
- Shows custom taxonomies specific to each post type
- Products section auto-detects WooCommerce attributes/taxonomies
- Accordion/expandable cards for post type configuration
- Clean, scannable interface
### 2. Header Navigation Update
**File:** `plugins/wordpress/source/igny8-wp-bridge/admin/layout-header.php`
**Changes:**
- Changed "Controls" navigation link to "Settings"
- Updated icon to settings gear icon
### 3. Admin Class Updates
**File:** `plugins/wordpress/source/igny8-wp-bridge/admin/class-admin.php`
**Changes:**
- Changed menu slug from 'igny8-controls' to 'igny8-settings'
- Updated render_page() to load settings.php instead of controls.php
- Added new setting registration: `igny8_sync_enabled`
### 4. Dashboard Quick Links Update
**File:** `plugins/wordpress/source/igny8-wp-bridge/admin/pages/dashboard.php`
**Changes:**
- Updated "Controls" card to "Settings" with appropriate description and icon
### 5. Sync Page Simplified
**File:** `plugins/wordpress/source/igny8-wp-bridge/admin/pages/sync.php`
**Changes:**
- **Removed:** Keywords sync history section
- **Removed:** Writers sync history section
- **Removed:** Scheduled Syncs configuration section
- **Kept:** Connection status indicator
- **Kept:** Content stats (total synced count)
- **Kept:** Last updated timestamp
---
## IGNY8 App Frontend Changes
### 1. New Content Structure Page
**File:** `frontend/src/pages/Sites/ContentStructure.tsx`
**Route:** `/sites/:id/content/structure`
**Features:**
- **Full page** (not modal/drawer as originally planned)
- Cluster selector dropdown at top
- Keywords table showing:
- Keyword name
- Intent
- Volume
- Content count per keyword
- Content list reusing the ClusterDetail component style
- View/Edit buttons for each content item
- Back navigation to Content page
### 2. App Router Update
**File:** `frontend/src/App.tsx`
**Changes:**
- Added lazy import for `SiteContentStructure`
- Added route: `/sites/:id/content/structure`
### 3. Site Content Page Updates
**File:** `frontend/src/pages/Sites/Content.tsx`
**Changes:**
- Added `postTypeFilter` state for filtering by post type
- Added post type filter buttons (All, Posts, Pages, Products)
- Added "Structure" button linking to `/sites/{id}/content/structure`
- Filter buttons passed to API when fetching content
### 4. Site Settings - Content Types Tab Removed
**File:** `frontend/src/pages/Sites/Settings.tsx`
**Changes:**
- Removed 'content-types' from `TabType` union
- Removed Content Types tab button from navigation
- Removed Content Types tab content section
- Removed `loadContentTypes()` function and related state
- Added redirect: if tab is 'content-types', redirects to `/sites/{id}/content/structure`
---
## Files Created
| File | Purpose |
|------|---------|
| `plugins/wordpress/source/igny8-wp-bridge/admin/pages/settings.php` | New Settings page replacing Controls |
| `frontend/src/pages/Sites/ContentStructure.tsx` | New Content Structure full page |
---
## Files Modified
| File | Changes |
|------|---------|
| `plugins/wordpress/source/igny8-wp-bridge/admin/class-admin.php` | Menu slug change, page render update |
| `plugins/wordpress/source/igny8-wp-bridge/admin/layout-header.php` | Navigation link rename |
| `plugins/wordpress/source/igny8-wp-bridge/admin/pages/dashboard.php` | Quick links card rename |
| `plugins/wordpress/source/igny8-wp-bridge/admin/pages/sync.php` | Simplified (removed bulk sections) |
| `frontend/src/App.tsx` | Added content structure route |
| `frontend/src/pages/Sites/Content.tsx` | Added post type filters and Structure button |
| `frontend/src/pages/Sites/Settings.tsx` | Removed Content Types tab |
---
## What Remains Working (Unchanged)
As per requirements, the following features remain fully functional:
-**Single article publishing to WordPress** - Healthy and working
-**Article updates** - Still working
-**Taxonomies (categories/tags)** - Still applied during publish
-**Featured images** - Still uploaded and set
-**In-article images** - Still handled properly
-**Content tracking against clusters/keywords** - Intact
-**Cluster-based content view** - Now enhanced with new Content Structure page
---
## UI Indicator Summary
| Post Type | Status | UI Indicator |
|-----------|--------|--------------|
| Posts | Active | No badge (fully working) |
| Pages | Coming Soon | "Coming Soon" badge |
| Products | Coming Soon | "Coming Soon" badge |
| Custom Post Types | Coming Soon | "Coming Soon" badge |
---
## API Backend Requirements (Not Implemented)
The following API endpoints may need backend updates to fully support the new frontend features:
1. **Content Structure API** - Endpoint to fetch cluster-based content structure
- Clusters with their keywords
- Content counts per keyword
- Content list filterable by cluster/keyword
2. **Post Type Filter** - Backend support for `postTypeFilter` parameter in content listing API
---
## Testing Checklist
### WordPress Plugin
- [ ] Settings page loads correctly
- [ ] All public post types are detected
- [ ] Post type enable/disable toggles work
- [ ] Taxonomies display correctly per post type
- [ ] Coming Soon badges appear on non-Post types
- [ ] Sync page shows correct connection status
- [ ] Navigation links work correctly
### IGNY8 App
- [ ] Content Structure page loads at `/sites/{id}/content/structure`
- [ ] Cluster selector dropdown works
- [ ] Keywords table displays correctly
- [ ] Content list shows with View/Edit buttons
- [ ] Post type filter buttons work on Content page
- [ ] Structure button navigates correctly
- [ ] Site Settings no longer shows Content Types tab
- [ ] Publishing to WordPress still works as expected
---
## Notes
1. The "Expired" taxonomy mentioned in requirements should be removed from WordPress - this is a plugin configuration issue, not a code change
2. The Content Structure page reuses existing component styles for consistency
3. All TypeScript errors have been resolved
4. The implementation follows the existing design patterns and component library

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,334 @@
# Footer Widgets Audit - Complete Analysis
**Date:** January 10, 2026
**Purpose:** Document all footer widgets across Planner and Writer pages to identify data conflicts
---
## SUMMARY
All Planner and Writer pages use `StandardThreeWidgetFooter` component which displays:
1. **Widget 1 (Left)**: Page Progress - page-specific metrics
2. **Widget 2 (Middle)**: Module Stats - uses `StandardizedModuleWidget`
3. **Widget 3 (Right)**: Workflow Completion - uses `WorkflowCompletionWidget` via `useWorkflowStats` hook
---
## PLANNER MODULE PAGES
### Page 1: Keywords (`/planner/keywords`)
#### Widget 1: Page Progress
| Field | Value | Source | Filter/Criteria |
|-------|-------|--------|-----------------|
| Keywords | `totalCount` | Local state (line 49) | All keywords for site+sector on current page |
| Clustered | `totalClustered` | Local state (line 50) | Keywords with status='mapped' |
| Unmapped | `totalUnmapped` | Local state (line 51) | Keywords without cluster_id |
| Volume | `totalVolume` | Calculated from keywords | Sum of search volumes |
| Progress % | Calculated | `(totalClustered / totalCount) * 100` | - |
**Data Loading:** Lines 132-183
- Loads keywords via `fetchKeywords({ site_id, sector_id, page, page_size, ...filters })`
- **SECTOR FILTERED**: Yes - uses `activeSector.id`
- Calculates totals from loaded data
- **Issue**: Only calculates from CURRENT PAGE data, not all keywords
#### Widget 2: Module Stats
| Field | Value | Source |
|-------|-------|--------|
| Type | "planner" | Hardcoded prop |
| Component | StandardizedModuleWidget | Centralized component |
#### Widget 3: Workflow Completion
Uses `useWorkflowStats` hook
| Field | API Endpoint | Filter |
|-------|--------------|--------|
| Keywords Total | `/v1/planner/keywords/` | `site_id` only (NO sector) |
| Keywords Clustered | `/v1/planner/keywords/?status=mapped` | `site_id` only |
| Clusters Created | `/v1/planner/clusters/` | `site_id` only |
| Ideas Generated | `/v1/planner/ideas/` | `site_id` only |
| Content Drafts | `/v1/writer/content/?status=draft` | `site_id` only |
| Content Review | `/v1/writer/content/?status=review` | `site_id` only |
| Content Published | `/v1/writer/content/?status__in=approved,published` | `site_id` only |
| Images Created | `/v1/writer/images/` | `site_id` only |
**Source:** `frontend/src/hooks/useWorkflowStats.ts` (lines 144-234)
- **SECTOR FILTERED**: No - intentionally site-wide for consistency
- **Date Filtered**: Yes - supports Today, 7d, 30d, 90d, all
---
### Page 2: Clusters (`/planner/clusters`)
#### Widget 1: Page Progress
| Field | Value | Source | Filter/Criteria |
|-------|-------|--------|-----------------|
| Clusters | `totalCount` | Local state (line 46) | All clusters for site+sector |
| With Ideas | `totalWithIdeas` | Local state (line 47) | Clusters with ideas_count > 0 |
| Keywords | Calculated | Sum of all clusters' keywords_count | From loaded clusters |
| Ready | `totalReady` | Local state (line 48) | Clusters with ideas_count === 0 |
| Progress % | Calculated | `(totalWithIdeas / totalCount) * 100` | - |
**Data Loading:** Lines 94-127
- Loads clusters via `fetchClusters({ site_id, sector_id, page, page_size, ...filters })`
- **SECTOR FILTERED**: Yes - uses `activeSector.id`
- Calculates totals from loaded data
- **Issue**: Only calculates from CURRENT PAGE data
#### Widget 2 & 3: Same as Keywords page
---
### Page 3: Ideas (`/planner/ideas`)
#### Widget 1: Page Progress
| Field | Value | Source | Filter/Criteria |
|-------|-------|--------|-----------------|
| Ideas | `totalCount` | Local state (line 45) | All ideas for site+sector |
| In Tasks | `totalInTasks` | Local state (line 46) | Ideas with task_id not null |
| Pending | `totalPending` | Local state (line 47) | Ideas without task_id |
| From Clusters | `clusters.length` | Loaded clusters count | Unique clusters |
| Progress % | Calculated | `(totalInTasks / totalCount) * 100` | - |
**Data Loading:** Lines 87-133
- Loads ideas via `fetchContentIdeas({ site_id, sector_id, page, page_size, ...filters })`
- **SECTOR FILTERED**: Yes - uses `activeSector.id`
- Loads clusters separately
- **Issue**: Only calculates from CURRENT PAGE data
#### Widget 2 & 3: Same as above
---
## WRITER MODULE PAGES
### Page 4: Tasks (`/writer/tasks`)
#### Widget 1: Page Progress
| Field | Value | Source | Filter/Criteria |
|-------|-------|--------|-----------------|
| Tasks | `totalCount` | Local state (line 47) | All tasks for site+sector |
| Drafted | `totalDrafted` | Local state (line 48) | Tasks with content created |
| Pending | `totalPending` | Local state (line 49) | Tasks without content |
| Priority | `totalPriority` | Local state (line 50) | Tasks with priority=true |
| Progress % | Calculated | `(totalDrafted / totalCount) * 100` | - |
**Data Loading:** Lines 139-182
- Loads tasks via `fetchTasks({ site_id, sector_id, page, page_size, ...filters })`
- **SECTOR FILTERED**: Yes - uses `activeSector.id`
- Calculates totals from loaded data
- **Issue**: Only calculates from CURRENT PAGE data
#### Widget 2: Module Stats
| Field | Value | Source |
|-------|-------|--------|
| Type | "writer" | Hardcoded prop |
| Component | StandardizedModuleWidget | Centralized component |
#### Widget 3: Workflow Completion
Same as Planner pages - uses `useWorkflowStats` hook
---
### Page 5: Content/Drafts (`/writer/content`)
#### Widget 1: Page Progress
| Field | Value | Source | Filter/Criteria |
|-------|-------|--------|-----------------|
| Content | `totalCount` | Local state (line 43) | All content for site+sector |
| Published | `totalPublished` | Local state (line 44) | Content with site_status='published' |
| In Review | `totalInReview` | Local state (line 45) | Content with status='review' |
| Approved | `totalApproved` | Local state (line 46) | Content with status='approved' |
| Progress % | Calculated | `(totalPublished / totalCount) * 100` | - |
**Data Loading:** Lines 84-112
- Loads content via `fetchContent({ site_id, sector_id, page, page_size, ...filters })`
- **SECTOR FILTERED**: Yes - uses `activeSector.id`
- **Issue**: Only calculates from CURRENT PAGE data
#### Widget 2 & 3: Same as Tasks page
---
### Page 6: Review (`/writer/review`)
#### Widget 1: Page Progress
| Field | Value | Source | Filter/Criteria |
|-------|-------|--------|-----------------|
| In Review | `totalCount` | Local state (line 39) | Content with status='review' |
| Approved | `totalApproved` | Local state (line 40) | From review that moved to approved |
| Pending | `totalPending` | Local state (line 41) | Still in review status |
| Priority | `totalPriority` | Local state (line 42) | With priority flag |
| Progress % | Calculated | `(totalApproved / totalCount) * 100` | - |
**Data Loading:** Lines 77-105
- Loads review content via `fetchContent({ site_id, sector_id, status: 'review', page, page_size })`
- **SECTOR FILTERED**: Yes - uses `activeSector.id`
- **Pre-filtered**: Only loads status='review'
- **Issue**: Only calculates from CURRENT PAGE data
#### Widget 2 & 3: Same as other Writer pages
---
### Page 7: Approved (`/writer/approved`)
#### Widget 1: Page Progress
| Field | Value | Source | Filter/Criteria |
|-------|-------|--------|-----------------|
| Approved | `totalCount` | Local state (line 39) | Content with status='approved' |
| Published | `totalPublished` | Local state (line 40) | With site_status='published' |
| Scheduled | `totalScheduled` | Local state (line 41) | With site_status='scheduled' |
| Ready | `totalReady` | Local state (line 42) | With site_status='not_published' |
| Progress % | Calculated | `(totalPublished / totalCount) * 100` | - |
**Data Loading:** Lines 77-107
- Loads approved content via `fetchContent({ site_id, sector_id, status: 'approved', page, page_size })`
- **SECTOR FILTERED**: Yes - uses `activeSector.id`
- **Pre-filtered**: Only loads status='approved'
- **Issue**: Only calculates from CURRENT PAGE data
#### Widget 2 & 3: Same as other Writer pages
---
### Page 8: Images (`/writer/images`)
#### Widget 1: Page Progress
| Field | Value | Source | Filter/Criteria |
|-------|-------|--------|-----------------|
| Images | `totalCount` | Local state (line 44) | All images for site+sector |
| Featured | `totalFeatured` | Local state (line 45) | Images with image_type='featured' |
| In-Article | `totalInArticle` | Local state (line 46) | Images with image_type='in_article' |
| Linked | `totalLinked` | Local state (line 47) | Images with content_id not null |
| Progress % | Calculated | `(totalLinked / totalCount) * 100` | - |
**Data Loading:** Lines 98-144
- Loads images via `fetchImages({ site_id, sector_id, page, page_size, ...filters })`
- **SECTOR FILTERED**: Yes - uses `activeSector.id`
- **Issue**: Only calculates from CURRENT PAGE data
#### Widget 2 & 3: Same as other Writer pages
---
## ROOT CAUSES OF DATA CONFLICTS
### Problem 1: Page-Level vs Site-Wide Data
**Conflict:** Widget 1 (Page Progress) shows **page-filtered** counts, Widget 3 (Workflow) shows **site-wide** counts
| Widget | Scope | Sector Filter | Date Filter | Data Source |
|--------|-------|---------------|-------------|-------------|
| Widget 1 (Page Progress) | Current page only | YES | NO | Local state from paginated API |
| Widget 2 (Module Stats) | Site-wide | NO | NO | Centralized hook (StandardizedModuleWidget) |
| Widget 3 (Workflow) | Site-wide | NO | YES (optional) | useWorkflowStats hook |
**Example Conflict:**
- Keywords page shows "17 Keywords" in Page Progress (Widget 1) ← from current page
- Workflow widget shows "17 Keywords Clustered" (Widget 3) ← from ALL keywords site-wide
- If user is on page 2, Widget 1 shows page 2 keywords, but Widget 3 shows total site keywords
### Problem 2: Sector Filtering Inconsistency
**Conflict:** Widget 1 filters by sector, Widget 3 does NOT
| Component | Sector Filtered? | Reasoning |
|-----------|------------------|-----------|
| Page Progress (Widget 1) | ✅ YES | Shows current page data which is sector-filtered |
| Module Stats (Widget 2) | ❌ NO | Centralized module-level stats |
| Workflow Widget (Widget 3) | ❌ NO | Intentionally site-wide for consistency across pages |
**User's Point:** If only 1 sector exists, sector filter doesn't matter - but data STILL conflicts because Widget 1 shows PAGINATED data
### Problem 3: Pagination vs Total Counts
**Critical Issue:** Widget 1 calculates totals from **current page data only**, not all records
Example on Keywords page (lines 182-183):
```typescript
setTotalCount(keywords.length); // ← Only current page!
setTotalClustered(keywords.filter(k => k.status === 'mapped').length); // ← Only current page!
```
Should be:
```typescript
setTotalCount(response.count); // ← Total from API
setTotalClustered(/* need separate API call or response field */);
```
### Problem 4: Different Time Ranges
| Widget | Time Filtering |
|--------|----------------|
| Widget 1 | NO time filter - shows ALL data for site+sector |
| Widget 3 | YES time filter - supports Today, 7d, 30d, 90d buttons |
---
## RECOMMENDED FIXES
### Fix 1: Make Page Progress Show Site-Wide Totals
**Current:** Calculates from paginated data
**Should Be:** Use `response.count` from API for totals
**Files to Fix:**
- `frontend/src/pages/Planner/Keywords.tsx`
- `frontend/src/pages/Planner/Clusters.tsx`
- `frontend/src/pages/Planner/Ideas.tsx`
- `frontend/src/pages/Writer/Tasks.tsx`
- `frontend/src/pages/Writer/Content.tsx`
- `frontend/src/pages/Writer/Review.tsx`
- `frontend/src/pages/Writer/Approved.tsx`
- `frontend/src/pages/Writer/Images.tsx`
**Change Pattern:**
```typescript
// OLD (WRONG):
setTotalCount(items.length); // Only current page
// NEW (CORRECT):
setTotalCount(response.count); // Total count from API
```
### Fix 2: Document That Widgets Show Different Scopes
**Add tooltips/help text:**
- Widget 1: "Page statistics (current filters)"
- Widget 3: "Site-wide workflow progress (all sectors)"
### Fix 3: Consider Adding Sector Filter Option to Widget 3
**Alternative:** Add toggle in Workflow Widget to switch between:
- Site-wide (current behavior)
- Current sector only (match Widget 1)
---
## ADDITIONAL FINDINGS
### Publishing Tab Issues
**File:** `frontend/src/pages/Sites/Settings.tsx`
**Issue:** Day selection and time slot changes auto-save immediately instead of waiting for "Save Publishing Settings" button
**Lines with auto-save:**
- Line 1195: Publishing days button click calls `savePublishingSettings({ publish_days: newDays })`
- Line 1224: Remove time slot calls `savePublishingSettings({ publish_time_slots: newSlots })`
- Line 1236: Add time slot calls `savePublishingSettings({ publish_time_slots: newSlots })`
**Fix:** Remove `savePublishingSettings()` calls from these onChange handlers, let user click the Save button at line 1278
---
## SUMMARY TABLE: ALL PAGES
| Page | Widget 1 Scope | Widget 1 Sector Filter | Widget 3 Scope | Widget 3 Sector Filter | Conflict? |
|------|---------------|----------------------|---------------|----------------------|-----------|
| Keywords | Current page | YES | Site-wide | NO | ✅ YES |
| Clusters | Current page | YES | Site-wide | NO | ✅ YES |
| Ideas | Current page | YES | Site-wide | NO | ✅ YES |
| Tasks | Current page | YES | Site-wide | NO | ✅ YES |
| Content | Current page | YES | Site-wide | NO | ✅ YES |
| Review | Current page | YES | Site-wide | NO | ✅ YES |
| Approved | Current page | YES | Site-wide | NO | ✅ YES |
| Images | Current page | YES | Site-wide | NO | ✅ YES |
**Conclusion:** ALL pages have the pagination vs site-wide conflict. The sector filtering is actually a secondary issue.
---
## END OF AUDIT

View File

@@ -0,0 +1,373 @@
# Image Generation System - Comprehensive Gap Analysis
**Date:** January 2026
**Status:** Audit Complete
**Reviewer:** System Audit
## Executive Summary
This document provides a comprehensive audit of the image generation system, analyzing the flow from model configuration to image delivery, both for manual and automation workflows.
---
## 1. System Architecture Overview
### Current Flow
```
User Selects Quality Tier (basic/quality/quality_option2/premium)
AIModelConfig (database) → provider, model_name, landscape_size, square_size
process_image_generation_queue (Celery task)
ai_core.generate_image() → provider-specific handler
_generate_image_openai() / _generate_image_runware()
Downloaded to /frontend/public/images/ai-images/
Image record updated (Images model)
```
### Image Count Determination
```
User sets max_images (1-8) in Site Settings
AISettings.get_effective_max_images(account)
generate_image_prompts.py: 1 featured + max_images in_article
Images created with positions: featured(0) + in_article(0,1,2,3...)
```
---
## 2. RESOLVED Issues (Previously Fixed)
### ✅ 2.1 Quality Tier Fallback
- **Issue:** `get_effective_quality_tier()` was returning hardcoded 'basic' instead of default model's tier
- **Fixed in:** `ai_settings.py` lines 196-232
- **Solution:** Now falls back to `AIModelConfig.is_default=True` model's `quality_tier`
### ✅ 2.2 Image Sizes from Database
- **Issue:** `tasks.py` had hardcoded `MODEL_LANDSCAPE_SIZES` dict
- **Fixed in:** `tasks.py` lines 242-260
- **Solution:** Now loads `landscape_size` and `square_size` from `AIModelConfig`
### ✅ 2.3 Settings Endpoint Default Tier
- **Issue:** `settings_views.py` returned hardcoded 'basic' as default tier
- **Fixed in:** `settings_views.py` lines 616-640
- **Solution:** Gets `default_tier` from `default_image_model.quality_tier`
### ✅ 2.4 Integration Views Dynamic Sizes
- **Issue:** Two endpoints in `integration_views.py` had hardcoded size lookup
- **Fixed:** Both endpoints now load from `AIModelConfig`
---
## 3. REMAINING Gaps (Action Required)
### 🔴 GAP-1: Hardcoded Size Constants in global_settings_models.py
**Location:** `backend/igny8_core/modules/system/global_settings_models.py` lines 180-188
**Code:**
```python
# 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'
```
**Impact:** These constants are UNUSED but could cause confusion. They're legacy from before the AIModelConfig migration.
**Recommendation:**
- [ ] Remove unused `MODEL_LANDSCAPE_SIZES` dict
- [ ] Remove unused `DEFAULT_SQUARE_SIZE` constant
- [ ] Add deprecation comment if keeping for reference
---
### 🔴 GAP-2: Hardcoded Size Fallbacks in Tasks
**Location:** `backend/igny8_core/ai/tasks.py` lines 254-260
**Code:**
```python
else:
# Fallback sizes if no model config (should never happen)
model_landscape_size = '1792x1024'
model_square_size = '1024x1024'
logger.warning(f"[process_image_generation_queue] No model config, using fallback sizes")
```
**Impact:** LOW - Fallback only triggers if database is misconfigured. But the hardcoded sizes may not match actual model requirements.
**Recommendation:**
- [ ] Consider failing gracefully with clear error instead of using fallback
- [ ] Or: Load fallback from a system default in database
---
### 🔴 GAP-3: Frontend VALID_SIZES_BY_MODEL Hardcoded
**Location:** `frontend/src/components/common/ImageGenerationCard.tsx` lines 52-55
**Code:**
```tsx
const VALID_SIZES_BY_MODEL: Record<string, string[]> = {
'dall-e-3': ['1024x1024', '1024x1792', '1792x1024'],
'dall-e-2': ['256x256', '512x512', '1024x1024'],
};
```
**Impact:** MEDIUM - Test image generation card only shows OpenAI sizes, not Runware/Bytedance sizes.
**Recommendation:**
- [ ] Fetch valid_sizes from AIModelConfig via API
- [ ] Or: Pass sizes from backend settings endpoint
---
### 🔴 GAP-4: Backend VALID_SIZES_BY_MODEL Hardcoded
**Location:** `backend/igny8_core/ai/constants.py` lines 40-43
**Code:**
```python
VALID_SIZES_BY_MODEL = {
'dall-e-3': ['1024x1024', '1024x1792', '1792x1024'],
'dall-e-2': ['256x256', '512x512', '1024x1024'],
}
```
**Impact:** MEDIUM - Used for OpenAI validation only. Runware models bypass this validation.
**Status:** PARTIAL - Only affects OpenAI validation. Runware has its own validation via provider-specific code.
**Recommendation:**
- [ ] Move validation to AIModelConfig.valid_sizes field
- [ ] Validate against model's valid_sizes from database
---
### 🔴 GAP-5: Missing Runware Model Size Validation
**Location:** `backend/igny8_core/ai/ai_core.py` lines 943-1050
**Code:** The `_generate_image_runware()` method does NOT validate sizes against `valid_sizes` from database.
**Impact:** LOW - Runware API will reject invalid sizes anyway, but error message won't be clear.
**Recommendation:**
- [ ] Add validation: check `size` against `AIModelConfig.valid_sizes` before API call
- [ ] Return clear error: "Size X is not valid for model Y"
---
### 🔴 GAP-6: Seedream Minimum Pixel Validation Hardcoded
**Location:** `backend/igny8_core/ai/ai_core.py` lines 1018-1027
**Code:**
```python
elif runware_model.startswith('bytedance:'):
# Enforce minimum size for Seedream (min 3,686,400 pixels ~ 1920x1920)
current_pixels = width * height
if current_pixels < 3686400:
# Use default Seedream square size
inference_task['width'] = 2048
inference_task['height'] = 2048
```
**Impact:** LOW - This hardcoded check works, but should come from database.
**Recommendation:**
- [ ] Add `min_pixels` field to AIModelConfig
- [ ] Check model.min_pixels instead of hardcoded 3686400
---
### 🔴 GAP-7: Provider-Specific Steps/CFGScale Hardcoded
**Location:** `backend/igny8_core/ai/ai_core.py` lines 995-1040
**Code:**
```python
if runware_model.startswith('bria:'):
inference_task['steps'] = 20
# ...
elif runware_model.startswith('runware:'):
inference_task['steps'] = 20
inference_task['CFGScale'] = 7
```
**Impact:** LOW - Works correctly but adding new models requires code changes.
**Recommendation:**
- [ ] Add `generation_params` JSON field to AIModelConfig
- [ ] Store steps, CFGScale, etc. in database per model
---
### 🔴 GAP-8: Image Count Not Per-Content Configurable
**Location:** System-wide setting only
**Current Behavior:**
- `max_images` is a global setting (site-wide)
- All articles get the same number of in_article images
**Impact:** MEDIUM - Users cannot set different image counts per article/keyword.
**Recommendation:**
- [ ] Add `images_count` field to Content model (nullable, inherits from site default)
- [ ] Or: Add to Keywords model for keyword-level override
---
### 🟡 GAP-9: Legacy generate_images.py Function (Partially Dead Code)
**Location:** `backend/igny8_core/ai/functions/generate_images.py`
**Issue:** `generate_images_core()` function exists but appears to be legacy. Main flow uses `process_image_generation_queue()` in tasks.py.
**Impact:** LOW - Code duplication, potential maintenance burden.
**Recommendation:**
- [ ] Audit if `generate_images.py` is actually used anywhere
- [ ] If not: Add deprecation warning or remove
- [ ] If used: Ensure it uses same dynamic config loading
---
### 🟡 GAP-10: No Validation of quality_tier Values
**Location:** Multiple locations
**Issue:** When user selects a quality_tier, there's no validation that:
1. The tier exists in AIModelConfig
2. The tier has an active model
3. The user's plan allows that tier
**Impact:** MEDIUM - Could lead to runtime errors if tier doesn't exist.
**Recommendation:**
- [ ] Add validation in settings save endpoint
- [ ] Return error if selected tier has no active model
---
## 4. Image Count Flow (Working Correctly)
### How image count works:
1. **User configures:**
- `max_images` (1-8) in Site Settings → saved to AccountSettings
2. **Prompt generation:**
```python
# generate_image_prompts.py
max_images = AISettings.get_effective_max_images(account) # e.g., 4
# Creates: 1 featured + 4 in_article = 5 image prompts
```
3. **Image types:**
- `featured` - Always 1, position=0, landscape size
- `in_article` - Up to max_images, positions 0,1,2,3..., alternating square/landscape
4. **Size determination:**
```python
# tasks.py
if image.image_type == 'featured':
image_size = featured_image_size # landscape
elif image.image_type == 'in_article':
position = image.position or 0
if position % 2 == 0: # 0, 2
image_size = square_size
else: # 1, 3
image_size = landscape_size
```
**STATUS:** ✅ Working correctly. No gaps identified in image count logic.
---
## 5. Automation Flow (Working Correctly)
### Stage 6: Image Generation
**Location:** `automation_service.py` lines 1236-1400
**Flow:**
1. Query `Images.objects.filter(site=site, status='pending')`
2. For each image: `process_image_generation_queue.delay(image_ids=[image.id], ...)`
3. Monitor task completion
4. Update run progress
**STATUS:** ✅ Uses same task as manual generation. Consistent behavior.
---
## 6. Model Provider Support Matrix
| Provider | Models | Status | Gaps |
|----------|--------|--------|------|
| OpenAI | dall-e-3, dall-e-2 | ✅ Working | Valid sizes hardcoded |
| Runware | runware:97@1 | ✅ Working | No size validation |
| Runware | bria:10@1 | ✅ Working | Steps hardcoded |
| Runware | google:4@2 | ✅ Working | Resolution param hardcoded |
| Runware | bytedance:seedream@4.5 | ✅ Working | Min pixels hardcoded |
---
## 7. Priority Action Items
### High Priority
1. **GAP-4/5:** Implement database-driven size validation for all providers
2. **GAP-10:** Add quality_tier validation on save
### Medium Priority
3. **GAP-6/7:** Move provider-specific params to AIModelConfig.generation_params
4. **GAP-8:** Consider per-content image count override
### Low Priority
5. **GAP-1:** Clean up unused constants
6. **GAP-9:** Audit and deprecate legacy code
7. **GAP-3:** Fetch valid sizes from API in frontend
---
## 8. Recommendations Summary
### Short-term (Before Launch)
- Ensure all hardcoded fallbacks are clearly logged
- Test each model tier end-to-end
### Medium-term (Post-Launch)
- Migrate all hardcoded params to AIModelConfig fields
- Add model validation on quality_tier save
### Long-term (Future Enhancement)
- Per-content image count override
- Per-keyword image style override
- Image regeneration without deleting existing
---
## Appendix: Key Files Reference
| File | Purpose |
|------|---------|
| `ai/tasks.py` | Main image generation Celery task |
| `ai/ai_core.py` | Provider-specific generation methods |
| `ai/functions/generate_image_prompts.py` | Extract prompts from content |
| `modules/system/ai_settings.py` | System defaults + account overrides |
| `modules/system/settings_views.py` | Frontend settings API |
| `business/billing/models.py` | AIModelConfig model |
| `business/automation/services/automation_service.py` | Automation Stage 6 |

View File

@@ -2,7 +2,7 @@
**Created:** January 9, 2026
**Version:** 1.0
**Status:** Planning
**Status:** ✅ Phase 1 Implemented
**Scope:** WordPress, Shopify, Custom Site Integration Plugins
---
@@ -553,23 +553,23 @@ export function PluginDownloadSection({ platform = 'wordpress' }: { platform: st
## 8. Implementation Phases
### Phase 1: Basic Infrastructure (Week 1)
- [ ] Create `/plugins/` directory structure
- [ ] Create Django `plugins` app with models
- [ ] Migrate existing WP plugin to `/plugins/wordpress/source/`
- [ ] Create build script for ZIP generation
- [ ] Implement download API endpoint
### Phase 1: Basic Infrastructure (Week 1) ✅ COMPLETED
- [x] Create `/plugins/` directory structure
- [x] Create Django `plugins` app with models
- [x] Migrate existing WP plugin to `/plugins/wordpress/source/`
- [x] Create build script for ZIP generation
- [x] Implement download API endpoint
### Phase 2: Frontend Integration (Week 1)
- [ ] Update Site Settings > Integrations download button
- [ ] Create plugin info API endpoint
- [ ] Add version display to download section
### Phase 2: Frontend Integration (Week 1) ✅ COMPLETED
- [x] Update Site Settings > Integrations download button
- [x] Create plugin info API endpoint
- [x] Add version display to download section
### Phase 3: Update System (Week 2)
- [ ] Implement check-update API
- [ ] Add update hooks to WP plugin
- [ ] Create PluginInstallation tracking
- [ ] Build admin version management UI
### Phase 3: Update System (Week 2) ✅ COMPLETED
- [x] Implement check-update API
- [x] Add update hooks to WP plugin
- [x] Create PluginInstallation tracking
- [x] Build admin version management UI
### Phase 4: Advanced Features (Week 3)
- [ ] Signed download URLs

View File

@@ -0,0 +1,814 @@
---
## 🎨 DESIGN ANALYSIS & PLAN
### Current State Analysis
**WordPress Single Post Template:**
- Uses emojis (📅, 📝, ✍️, 📁, 🏷️) in header metadata
- Shows all metadata including internal/debug data (Content ID, Task ID, Sector ID, Cluster ID, etc.)
- SEO section shows Meta Title & Meta Description in header (should be hidden from users)
- "Section Spotlight" label is hardcoded
- Images not optimally distributed - one per section sequentially
- Container max-width: 1200px
**App ContentViewTemplate:**
- Uses icons properly via component imports
- Similar "Section Spotlight" label issue
- Better image handling with aspect ratio detection
- Shows extensive metadata in header (Meta Title, Meta Description, Primary/Secondary Keywords)
- Container max-width: 1440px
---
## 📐 DESIGN PLAN
### 1. CSS Container Width Update
```
.igny8-content-container {
max-width: 1280px; /* Default for screens <= 1600px */
}
@media (min-width: 1600px) {
.igny8-content-container {
max-width: 1530px; /* For screens > 1600px */
}
}
```
---
### 2. WordPress Header Redesign
**USER-FACING FIELDS (Keep in Header):**
| Field | Display | Icon | Notes |
|-------|---------|------|-------|
| Title | H1 | - | Post title |
| Status Badge | Published/Draft/etc | - | Post status |
| Posted Date | Formatted date | Calendar SVG | Publication date |
| Word Count | Formatted number | Document SVG | Content word count |
| Author | Author name | User SVG | Post author |
| Topic | Cluster name (clickable)| Compass SVG | Display cluster_name as "Topic" |
| Categories | Badge list (Parent > Child clicakble) | Folder SVG | WP Categories |
| Tags | Badge list (clickable)| Tag SVG | WP Tags |
**NON-USER FIELDS (Move to Metadata Section - Editor+ only):**
- Content ID, Task ID
- Content Type, Structure
- Cluster ID (keep cluster_name as Topic in header)
- Sector ID, Sector Name
- Primary Keyword, Secondary Keywords
- Meta Title, Meta Description
- Source, Last Synced
---
### 3. Section Label Redesign
**Current:** "Section Spotlight" (generic text)
**New Approach - Keyword/Tag Matching Algorithm:**
1. **Source Data:**
- Get all WordPress tags assigned to the post
- Get all WordPress categories assigned to the post
- Get primary keyword from post meta
- Get secondary keywords from post meta (if available)
2. **Matching Logic:**
- For each section heading (H2), perform case-insensitive partial matching
- Check if any tag name appears in the heading text
- Check if any category name appears in the heading text
- Check if primary/secondary keywords appear in the heading text
- Prioritize: Primary Keyword > Tags > Categories > Secondary Keywords
3. **Display Rules:**
- If matches found: Display up to 2 matched keywords/tags as badges
- If no matches: Display topic (cluster_name) or leave section without label badges
- Never display generic "Section Spotlight" text
4. **Badge Styling:**
```
[Primary Match] [Secondary Match] ← styled badges replacing "Section Spotlight"
```
**Colors:**
- Primary badge: `theme-color @ 15% opacity` background, `theme-color` text
- Secondary badge: `theme-color @ 8% opacity` background, `theme-color @ 80%` text
**Implementation Function (Pseudo-code):**
```php
function igny8_get_section_badges($heading, $post_id) {
$badges = [];
$heading_lower = strtolower($heading);
// Get post taxonomies and keywords
$tags = get_the_tags($post_id);
$categories = get_the_category($post_id);
$primary_kw = get_post_meta($post_id, '_igny8_primary_keyword', true);
$secondary_kws = get_post_meta($post_id, '_igny8_secondary_keywords', true);
// Priority 1: Primary keyword
if ($primary_kw && stripos($heading_lower, strtolower($primary_kw)) !== false) {
$badges[] = ['text' => $primary_kw, 'type' => 'primary'];
}
// Priority 2: Tags
if ($tags && count($badges) < 2) {
foreach ($tags as $tag) {
if (stripos($heading_lower, strtolower($tag->name)) !== false) {
$badges[] = ['text' => $tag->name, 'type' => 'tag'];
if (count($badges) >= 2) break;
}
}
}
// Priority 3: Categories
if ($categories && count($badges) < 2) {
foreach ($categories as $cat) {
if (stripos($heading_lower, strtolower($cat->name)) !== false) {
$badges[] = ['text' => $cat->name, 'type' => 'category'];
if (count($badges) >= 2) break;
}
}
}
// Priority 4: Secondary keywords
if ($secondary_kws && count($badges) < 2) {
$kw_array = is_array($secondary_kws) ? $secondary_kws : explode(',', $secondary_kws);
foreach ($kw_array as $kw) {
$kw = trim($kw);
if (stripos($heading_lower, strtolower($kw)) !== false) {
$badges[] = ['text' => $kw, 'type' => 'keyword'];
if (count($badges) >= 2) break;
}
}
}
return $badges;
}
```
---
### 4. Image Distribution Strategy
**Available Images (4 total):**
- Position 0: Square (1024×1024)
- Position 1: Landscape (1536×1024 or 1920×1080)
- Position 2: Square (1024×1024)
- Position 3: Landscape (1536×1024 or 1920×1080)
**Distribution Plan - First 4 Sections (with descriptions):**
| Section | Image Position | Type | Width | Alignment | Description |
|---------|---------------|------|-------|-----------|-------------|
| **Featured** | Position 1 | Landscape | 100% max 1024px | Center | Show prompt on first use |
| **Section 1** | Position 0 | Square | 50% | Right | With description + widget placeholder below |
| **Section 2** | Position 3 | Landscape | 100% max 1024px | Full width | With description |
| **Section 3** | Position 2 | Square | 50% | Left | With description + widget placeholder below |
| **Section 4** | Position 1 (reuse) | Landscape | 100% max 1024px | Full width | With description |
**Distribution Plan - Sections 5-7+ (reuse without descriptions):**
| Section | Reuse Image | Type | Width | Alignment | Description |
|---------|-------------|------|-------|-----------|-------------|
| **Section 5** | Featured (pos 1) | Landscape | 100% max 1024px | Full width | NO description |
| **Section 6** | Position 0 | Square | 50% | Right | NO description + widget placeholder |
| **Section 7** | Position 3 | Landscape | 100% max 1024px | Full width | NO description |
| **Section 8+** | Cycle through all 4 | Based on type | Based on type | Based on type | NO description |
**Special Case - Tables:**
- When section contains `<table>` element, always place full-width landscape image BEFORE table
- Use next available landscape image (Position 1 or 3)
- Max width: 1024px, centered
- Spacing: `margin-bottom: 2rem` before table
- Override normal section pattern when table detected
**Image Reuse Rules:**
- Images 1-4 used in first 4 sections WITH descriptions/prompts
- Sections 5+ reuse same images WITHOUT descriptions/prompts
- Use CSS classes: `.igny8-image-first-use` vs `.igny8-image-reuse`
- Maintain same layout pattern (square = 50%, landscape = 100%)
**Widget Placeholders:**
- Show only below square images (left/right aligned)
- Empty div with class `.igny8-widget-placeholder`
- Space reserved for future widget insertion
- Controlled via plugin settings (future implementation)
**Implementation Notes:**
```php
// Check for table in section content
function igny8_section_has_table($section_html) {
return (stripos($section_html, '<table') !== false);
}
// Get image aspect ratio from position
function igny8_get_image_aspect($position) {
// Even positions (0, 2) = square
// Odd positions (1, 3) = landscape
return ($position % 2 === 0) ? 'square' : 'landscape';
}
// Determine if image should show description
function igny8_show_image_description($section_index) {
// First 4 sections (0-3) show descriptions
return ($section_index < 4);
}
```
---
### 5. Image Alignment with Tables
When section contains a `<table>`:
- Place landscape image ABOVE the table
- Full width (max 800px)
- Proper spacing: `margin-bottom: 2rem`
- Table should not wrap around image
---
### 6. Responsive Image Width Rules
```css
/* Landscape images */
.igny8-image-landscape {
max-width: 1024px; /* Updated from 800px */
width: 100%;
margin: 0 auto;
display: block;
}
.igny8-image-landscape.igny8-image-reuse {
/* No description shown on reuse */
}
/* Single square image - Right aligned */
.igny8-image-square-right {
max-width: 50%;
margin-left: auto;
float: right;
margin-left: 2rem;
margin-bottom: 2rem;
}
/* Single square image - Left aligned */
.igny8-image-square-left {
max-width: 50%;
margin-right: auto;
float: left;
margin-right: 2rem;
margin-bottom: 2rem;
}
/* Widget placeholder below square images */
.igny8-widget-placeholder {
clear: both;
min-height: 200px;
padding: 1.5rem;
margin-top: 1rem;
background: rgba(0, 0, 0, 0.02);
border: 1px dashed rgba(0, 0, 0, 0.1);
border-radius: 12px;
display: none; /* Hidden by default, shown when widgets enabled */
}
.igny8-widget-placeholder.igny8-widgets-enabled {
display: block;
}
/* Table-specific image positioning */
.igny8-image-before-table {
max-width: 1024px;
width: 100%;
margin: 0 auto 2rem;
display: block;
}
```
---
### 7. Role-Based Visibility
**Metadata Section (Bottom):**
```php
<?php if (current_user_can('edit_posts')): ?>
<div class="igny8-metadata-footer igny8-editor-only">
<!-- All internal metadata here -->
</div>
<?php endif; ?>
```
**Visible only to:**
- Editor
- Administrator
- Author (for their own posts)
---
### 8. Header Icon Set (Replace Emojis)
Create inline SVG icons matching theme color:
```php
// Calendar Icon
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"/>
</svg>
// Document Icon (Word Count)
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z"/>
</svg>
// User Icon (Author)
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"/>
</svg>
// Compass Icon (Topic/Cluster)
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"/>
</svg>
// Folder Icon (Categories)
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
</svg>
// Tag Icon (Tags)
<svg class="igny8-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z"/>
</svg>
```
Icon styling:
```css
.igny8-icon {
width: 1rem;
height: 1rem;
color: var(--igny8-theme-color, currentColor);
opacity: 0.8;
display: inline-block;
vertical-align: middle;
}
```
---
### 9. Table of Contents
**Position:** Below featured image, before intro section
**Content:** List all H2 headings from content
**Features:**
- Clickable links with smooth scroll to sections
- Collapsible/expandable (optional)
- Numbered list matching section numbers
- Sticky positioning option (future setting)
**Implementation:**
```php
function igny8_generate_table_of_contents($content) {
$toc_items = [];
// Parse content for H2 headings
preg_match_all('/<h2[^>]*>(.*?)<\/h2>/i', $content, $matches);
if (!empty($matches[1])) {
foreach ($matches[1] as $index => $heading) {
$heading_text = strip_tags($heading);
$slug = sanitize_title($heading_text);
$toc_items[] = [
'number' => $index + 1,
'text' => $heading_text,
'id' => $slug
];
}
}
return $toc_items;
}
```
**HTML Structure:**
```html
<nav class="igny8-table-of-contents">
<div class="igny8-toc-header">
<span class="igny8-toc-icon">📑</span>
<h3>Table of Contents</h3>
</div>
<ol class="igny8-toc-list">
<?php foreach ($toc_items as $item): ?>
<li class="igny8-toc-item">
<a href="#<?php echo esc_attr($item['id']); ?>" class="igny8-toc-link">
<span class="igny8-toc-number"><?php echo $item['number']; ?>.</span>
<span class="igny8-toc-text"><?php echo esc_html($item['text']); ?></span>
</a>
</li>
<?php endforeach; ?>
</ol>
</nav>
```
**CSS:**
```css
.igny8-table-of-contents {
background: var(--wp--preset--color--base, #ffffff);
border: 2px solid rgba(0, 0, 0, 0.12);
border-radius: 16px;
padding: 1.5rem 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.igny8-toc-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.igny8-toc-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.igny8-toc-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.igny8-toc-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
text-decoration: none;
color: inherit;
border-radius: 8px;
transition: background-color 0.2s ease;
}
.igny8-toc-link:hover {
background: rgba(0, 0, 0, 0.04);
}
.igny8-toc-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
background: rgba(59, 130, 246, 0.1);
color: rgba(59, 130, 246, 1);
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
flex-shrink: 0;
}
.igny8-toc-text {
flex: 1;
font-size: 0.9375rem;
}
```
**Settings (Future Implementation):**
```php
// Plugin settings for TOC
$igny8_toc_settings = [
'enabled' => true,
'show_numbers' => true,
'collapsible' => false,
'sticky' => false,
'min_headings' => 3, // Only show if 3+ H2 headings
];
```
---
### 10. Widget System
**Widget Placeholders:**
Widgets appear below square images (left/right aligned) where there's natural space.
**Placeholder Function:**
```php
function igny8_render_widget_placeholder($position, $section_index) {
// Check if widgets are enabled in settings
$widgets_enabled = get_option('igny8_widgets_enabled', false);
if (!$widgets_enabled) {
return '';
}
$placeholder_class = 'igny8-widget-placeholder igny8-widgets-enabled';
$placeholder_class .= ' igny8-widget-' . $position; // left or right
$placeholder_class .= ' igny8-widget-section-' . $section_index;
?>
<div class="<?php echo esc_attr($placeholder_class); ?>"
data-widget-position="<?php echo esc_attr($position); ?>"
data-section-index="<?php echo esc_attr($section_index); ?>">
<!-- Widget content will be inserted here via settings -->
<?php do_action('igny8_widget_placeholder', $position, $section_index); ?>
</div>
<?php
}
```
**Widget Settings (Future Implementation):**
```php
// Plugin settings for widgets
$igny8_widget_settings = [
'enabled' => false,
'sections' => [
'section_1' => [
'position' => 'right',
'widget_type' => 'related_posts', // or 'custom_html', 'ad_unit', etc.
'content' => '',
],
'section_3' => [
'position' => 'left',
'widget_type' => 'newsletter_signup',
'content' => '',
],
],
];
```
**Widget Types (Future):**
- Related Posts
- Newsletter Signup
- Ad Units
- Custom HTML
- Social Share Buttons
- Author Bio
- Call-to-Action Boxes
---
### 11. Updated Structure Overview
**WordPress Single Post:**
```
┌─────────────────────────────────────────────┐
│ HEADER │
│ ← Back to Posts │
│ │
│ [H1 Title] [Status Badge] │
│ │
│ 📅 Posted: Date 📄 Words 👤 Author │
│ 🧭 Topic: Cluster Name │
│ 📁 [Category Badges] 🏷️ [Tag Badges] │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ FEATURED IMAGE (Landscape, max 1024px) │
│ │
│ [Image Prompt - first use only] │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ TABLE OF CONTENTS │
│ 📑 Table of Contents │
│ 1. Section Heading One │
│ 2. Section Heading Two │
│ 3. Section Heading Three │
│ ... (clickable, smooth scroll) │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ INTRO SECTION │
│ Opening Narrative │
│ [Content...] │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ SECTION 1 │
│ [Keyword Badge] [Tag Badge] │
│ 1 [H2 Heading] │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ │ │ │ │
│ │ Content │ │ Square Image (50%) │ │
│ │ │ │ Right Aligned │ │
│ │ │ │ [Image Description] │ │
│ └──────────────┘ └──────────────────────┘ │
│ [Widget Placeholder] │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ SECTION 2 │
│ [Keyword Badge] │
│ 2 [H2 Heading] │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ Landscape Image (100% max 1024px) │ │
│ │ [Image Description] │ │
│ └───────────────────────────────────────┘ │
│ │
│ [Content...] │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ SECTION 3 │
│ [Keyword Badge] [Tag Badge] │
│ 3 [H2 Heading] │
│ │
│ ┌──────────────────────┐ ┌──────────────┐ │
│ │ │ │ │ │
│ │ Square Image (50%) │ │ Content │ │
│ │ Left Aligned │ │ │ │
│ │ [Image Description] │ │ │ │
│ └──────────────────────┘ └──────────────┘ │
│ [Widget Placeholder] │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ SECTION 4 (with table example) │
│ [Keyword Badge] │
│ 4 [H2 Heading] │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ Landscape Image (100% max 1024px) │ │
│ │ [Image Description] │ │
│ └───────────────────────────────────────┘ │
│ │
│ [Content before table...] │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ TABLE │ │
│ │ [Data rows and columns] │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ SECTION 5 (reuse - no description) │
│ [Keyword Badge] │
│ 5 [H2 Heading] │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ Featured Image REUSED (no caption) │ │
│ └───────────────────────────────────────┘ │
│ │
│ [Content...] │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ SECTION 6 (reuse - no description) │
│ [Tag Badge] │
│ 6 [H2 Heading] │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Content │ │ Square Image REUSED │ │
│ │ │ │ (no caption) │ │
│ └──────────────┘ └──────────────────────┘ │
│ [Widget Placeholder] │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ METADATA FOOTER (Editor+ only) │
│ ▸ View IGNY8 Metadata │
│ - Content ID: 123 │
│ - Task ID: 456 │
│ - Meta Title: ... │
│ - Meta Description: ... │
│ - Primary Keyword: ... │
│ - Secondary Keywords: [list] │
│ - Cluster ID: 789 │
│ - Sector: Industry Name │
│ - Source: AI Generated │
│ - Last Synced: Date/Time │
└─────────────────────────────────────────────┘
```
---
### 12. App ContentViewTemplate Updates
**Changes to body section only (not header):**
1. **Remove "Section Spotlight" label** - Replace with keyword badge matching system
2. **Add Table of Contents** below featured image (matching WordPress implementation)
3. **Match image layout rules** from WordPress template:
- Section 1: Square right-aligned 50% (with description)
- Section 2: Landscape full width max 1024px (with description)
- Section 3: Square left-aligned 50% (with description)
- Section 4: Landscape full width max 1024px (with description)
- Sections 5+: Reuse images without descriptions
4. **Featured image** max 1024px centered
5. **Widget placeholders** below square images (empty for now)
6. **Table detection** - full-width image before tables
**Implementation Priority:**
- Phase 1: Update image sizing (1024px max)
- Phase 2: Implement keyword badge matching
- Phase 3: Add table of contents component
- Phase 4: Add widget placeholder divs
---
## Summary of Files to Update
| File | Changes | Priority |
|------|---------|----------|
| `igny8-content-template.css` | Container width breakpoints, image sizing classes, TOC styles, widget placeholder styles | 🔴 High |
| `igny8-header.php` | Remove emojis, add SVG icons, add Topic field, remove SEO/internal metadata | 🔴 High |
| `igny8-metadata.php` | Add role check (`current_user_can('edit_posts')`), include all moved metadata fields | 🔴 High |
| `igny8-content-sections.php` | Keyword badge matching logic, smart image distribution (Section 1-4 pattern), widget placeholders | 🔴 High |
| `igny8-featured-image.php` | Max 1024px, landscape priority | 🟡 Medium |
| `includes/template-functions.php` | Add helper functions: `igny8_get_section_badges()`, `igny8_section_has_table()`, `igny8_show_image_description()`, `igny8_generate_table_of_contents()` | 🔴 High |
| `ContentViewTemplate.tsx` | Match section labels, image layouts, add TOC component, widget placeholders | 🟡 Medium |
| **New File**: `parts/igny8-table-of-contents.php` | Table of contents component | 🟡 Medium |
| **New File**: `admin/settings-page.php` | Widget settings, TOC settings (future) | 🟢 Low |
---
## Configuration Settings (Future Implementation)
```php
// Plugin settings structure
$igny8_plugin_settings = [
'table_of_contents' => [
'enabled' => true,
'show_numbers' => true,
'collapsible' => false,
'sticky' => false,
'min_headings' => 3,
'position' => 'after_featured_image', // or 'before_content', 'floating'
],
'widgets' => [
'enabled' => false,
'sections' => [
'section_1' => [
'position' => 'right',
'widget_type' => 'none', // 'related_posts', 'custom_html', 'ad_unit', etc.
'content' => '',
],
'section_3' => [
'position' => 'left',
'widget_type' => 'none',
'content' => '',
],
],
],
'images' => [
'featured_max_width' => 1024,
'landscape_max_width' => 1024,
'square_width_percentage' => 50,
'show_descriptions_sections' => 4, // Show descriptions in first N sections
],
'badges' => [
'show_section_badges' => true,
'max_badges_per_section' => 2,
'badge_sources' => ['primary_keyword', 'tags', 'categories', 'secondary_keywords'], // Priority order
],
];
```
---
## Implementation Phases
### Phase 1: Core Template Updates (Week 1)
- ✅ Update CSS container widths and image sizing
- ✅ Replace emojis with SVG icons in header
- ✅ Add Topic field to header
- ✅ Move metadata to bottom with role check
- ✅ Implement keyword badge matching logic
### Phase 2: Advanced Features (Week 2)
- ✅ Table of contents component
- ✅ Table detection and image positioning
- ✅ Image reuse logic (sections 5+)
### Phase 3: App Sync (Week 3)
- ✅ Update ContentViewTemplate.tsx to match WordPress
- ✅ Add TOC component to React app
- ✅ Sync image layouts and sizing
### Phase 4: Settings & Configuration (Week 4)
- ⏳ Plugin settings page
- ⏳ TOC configuration options
- ⏳ Widget management interface
- ⏳ Badge display preferences
---
**Last Updated:** January 10, 2026
**Document Version:** 2.0
**Status:** Design Complete - Ready for Implementation

View File

@@ -1,7 +1,7 @@
{
"name": "tailadmin-react",
"private": true,
"version": "2.0.2",
"version": "1.7.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,53 +0,0 @@
<svg width="231" height="48" viewBox="0 0 231 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.425781 12.6316C0.425781 5.65535 6.08113 0 13.0574 0H35.7942C42.7704 0 48.4258 5.65535 48.4258 12.6316V35.3684C48.4258 42.3446 42.7704 48 35.7942 48H13.0574C6.08113 48 0.425781 42.3446 0.425781 35.3684V12.6316Z" fill="#465FFF"/>
<g filter="url(#filter0_d_3903_56743)">
<path d="M13.0615 12.6323C13.0615 11.237 14.1926 10.106 15.5878 10.106C16.9831 10.106 18.1142 11.237 18.1142 12.6323V35.3691C18.1142 36.7644 16.9831 37.8954 15.5878 37.8954C14.1926 37.8954 13.0615 36.7644 13.0615 35.3691V12.6323Z" fill="white"/>
</g>
<g filter="url(#filter1_d_3903_56743)">
<path d="M22.5391 22.7353C22.5391 21.3401 23.6701 20.209 25.0654 20.209C26.4606 20.209 27.5917 21.3401 27.5917 22.7353V35.3669C27.5917 36.7621 26.4606 37.8932 25.0654 37.8932C23.6701 37.8932 22.5391 36.7621 22.5391 35.3669V22.7353Z" fill="white" fill-opacity="0.9" shape-rendering="crispEdges"/>
</g>
<g filter="url(#filter2_d_3903_56743)">
<path d="M32.0078 16.4189C32.0078 15.0236 33.1389 13.8926 34.5341 13.8926C35.9294 13.8926 37.0604 15.0236 37.0604 16.4189V35.3663C37.0604 36.7615 35.9294 37.8926 34.5341 37.8926C33.1389 37.8926 32.0078 36.7615 32.0078 35.3663V16.4189Z" fill="white" fill-opacity="0.7" shape-rendering="crispEdges"/>
</g>
<path d="M66.4258 15.1724H74.0585V37.0363H78.6239V15.1724H86.2567V10.9637H66.4258V15.1724Z" fill="white"/>
<path d="M91.3521 37.5C94.0984 37.5 96.4881 36.2516 97.2371 34.4326L97.5581 37.0363H101.375V26.3362C101.375 21.4498 98.4498 18.8818 93.7061 18.8818C88.9267 18.8818 85.788 21.3785 85.788 25.1948H89.4974C89.4974 23.3402 90.9241 22.2701 93.4921 22.2701C95.7035 22.2701 97.1301 23.2332 97.1301 25.6229V26.0152L91.8514 26.4075C87.6784 26.7285 85.3243 28.7616 85.3243 32.0073C85.3243 35.3243 87.607 37.5 91.3521 37.5ZM92.7788 34.2186C90.8171 34.2186 89.747 33.4339 89.747 31.8289C89.747 30.4022 90.7814 29.5106 93.4921 29.2609L97.1658 28.9756V29.9029C97.1658 32.6136 95.4538 34.2186 92.7788 34.2186Z" fill="white"/>
<path d="M107.825 15.8857C109.252 15.8857 110.429 14.7087 110.429 13.2464C110.429 11.784 109.252 10.6427 107.825 10.6427C106.327 10.6427 105.15 11.784 105.15 13.2464C105.15 14.7087 106.327 15.8857 107.825 15.8857ZM105.649 37.0363H110.001V19.4168H105.649V37.0363Z" fill="white"/>
<path d="M118.883 37.0363V10.5H114.568V37.0363H118.883Z" fill="white"/>
<path d="M126.337 37.0363L128.441 31.0086H138.179L140.283 37.0363H145.098L135.682 10.9637H131.009L121.593 37.0363H126.337ZM132.757 18.7391C133.007 18.0258 133.221 17.2411 133.328 16.7417C133.399 17.2768 133.649 18.0614 133.863 18.7391L136.859 27.1565H129.797L132.757 18.7391Z" fill="white"/>
<path d="M154.165 37.5C156.84 37.5 159.122 36.323 160.192 34.29L160.478 37.0363H164.472V10.5H160.157V21.6638C159.051 19.9161 156.875 18.8818 154.414 18.8818C149.1 18.8818 145.89 22.8052 145.89 28.2979C145.89 33.755 149.064 37.5 154.165 37.5ZM155.128 33.5053C152.096 33.5053 150.241 31.2939 150.241 28.1552C150.241 25.0165 152.096 22.7695 155.128 22.7695C158.159 22.7695 160.121 24.9808 160.121 28.1552C160.121 31.3296 158.159 33.5053 155.128 33.5053Z" fill="white"/>
<path d="M173.359 37.0363V27.0495C173.359 24.1962 175.035 22.8408 177.104 22.8408C179.172 22.8408 180.492 24.1605 180.492 26.6215V37.0363H184.843V27.0495C184.843 24.1605 186.448 22.8052 188.553 22.8052C190.621 22.8052 191.977 24.1248 191.977 26.6572V37.0363H196.292V25.5159C196.292 21.4498 193.938 18.8818 189.658 18.8818C186.983 18.8818 184.915 20.2015 184.023 22.2345C183.096 20.2015 181.241 18.8818 178.566 18.8818C176.034 18.8818 174.25 20.0231 173.359 21.4855L173.002 19.4168H169.007V37.0363H173.359Z" fill="white"/>
<path d="M202.74 15.8857C204.167 15.8857 205.344 14.7087 205.344 13.2464C205.344 11.784 204.167 10.6427 202.74 10.6427C201.242 10.6427 200.065 11.784 200.065 13.2464C200.065 14.7087 201.242 15.8857 202.74 15.8857ZM200.564 37.0363H204.916V19.4168H200.564V37.0363Z" fill="white"/>
<path d="M213.763 37.0363V27.5489C213.763 24.6955 215.403 22.8408 218.078 22.8408C220.325 22.8408 221.788 24.2675 221.788 27.2279V37.0363H226.139V26.1935C226.139 21.6281 223.856 18.8818 219.434 18.8818C217.044 18.8818 214.904 19.9161 213.798 21.6995L213.442 19.4168H209.411V37.0363H213.763Z" fill="white"/>
<defs>
<filter id="filter0_d_3903_56743" x="12.0615" y="9.60596" width="7.05273" height="29.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_3903_56743"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3903_56743" result="shape"/>
</filter>
<filter id="filter1_d_3903_56743" x="21.5391" y="19.709" width="7.05273" height="19.6843" 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_3903_56743"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3903_56743" result="shape"/>
</filter>
<filter id="filter2_d_3903_56743" x="31.0078" y="13.3926" width="7.05273" height="26" 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_3903_56743"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3903_56743" result="shape"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -102,6 +102,7 @@ const Sites = lazy(() => import("./pages/Settings/Sites"));
const SiteList = lazy(() => import("./pages/Sites/List"));
const SiteDashboard = lazy(() => import("./pages/Sites/Dashboard"));
const SiteContent = lazy(() => import("./pages/Sites/Content"));
const SiteContentStructure = lazy(() => import("./pages/Sites/ContentStructure"));
const PageManager = lazy(() => import("./pages/Sites/PageManager"));
const PostEditor = lazy(() => import("./pages/Sites/PostEditor"));
const SiteSettings = lazy(() => import("./pages/Sites/Settings"));
@@ -288,6 +289,7 @@ export default function App() {
<Route path="/sites/:id/pages/new" element={<PageManager />} />
<Route path="/sites/:id/pages/:pageId/edit" element={<PageManager />} />
<Route path="/sites/:id/content" element={<SiteContent />} />
<Route path="/sites/:id/content/structure" element={<SiteContentStructure />} />
<Route path="/sites/:id/settings" element={<SiteSettings />} />
<Route path="/sites/:id/sync" element={<SyncDashboard />} />
<Route path="/sites/:id/deploy" element={<DeploymentPanel />} />

View File

@@ -276,7 +276,7 @@ const CurrentProcessingCard: React.FC<CurrentProcessingCardProps> = ({
<Button
onClick={handlePause}
disabled={isPausing}
variant="outline"
variant="primary"
tone="warning"
size="sm"
startIcon={<PauseIcon className="w-4 h-4" />}
@@ -299,8 +299,8 @@ const CurrentProcessingCard: React.FC<CurrentProcessingCardProps> = ({
<Button
onClick={handleCancel}
disabled={isCancelling}
variant="ghost"
tone="neutral"
variant="primary"
tone="danger"
size="sm"
>
{isCancelling ? 'Cancelling...' : 'Cancel'}

View File

@@ -14,6 +14,7 @@ interface CreditsUsageWidgetProps {
aiOperations?: {
total: number;
period: string;
siteName?: string;
};
loading?: boolean;
}
@@ -113,6 +114,11 @@ export default function CreditsUsageWidget({
<div>
<p className="text-base text-gray-500 dark:text-gray-400">
AI Operations ({aiOperations.period})
{aiOperations.siteName && aiOperations.total > 0 && (
<span className="ml-2 text-sm">
· Site: {aiOperations.siteName}
</span>
)}
</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{aiOperations.total.toLocaleString()}

View File

@@ -20,8 +20,9 @@ interface OperationStat {
interface OperationsCostsWidgetProps {
operations: OperationStat[];
period?: '7d' | '30d' | 'total';
period?: '7d' | '30d' | '90d';
loading?: boolean;
onPeriodChange?: (period: '7d' | '30d' | '90d') => void;
}
const operationConfig = {
@@ -54,9 +55,10 @@ const operationConfig = {
export default function OperationsCostsWidget({
operations,
period = '7d',
loading = false
loading = false,
onPeriodChange
}: OperationsCostsWidgetProps) {
const periodLabel = period === '7d' ? 'Last 7 Days' : period === '30d' ? 'Last 30 Days' : 'All Time';
const periodLabel = period === '7d' ? 'Last 7 Days' : period === '30d' ? 'Last 30 Days' : 'Last 90 Days';
const totalOps = operations.reduce((sum, op) => sum + op.count, 0);
const totalCredits = operations.reduce((sum, op) => sum + op.creditsUsed, 0);
@@ -68,7 +70,25 @@ export default function OperationsCostsWidget({
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
AI Operations
</h3>
<span className="text-xs text-gray-600 dark:text-gray-400">{periodLabel}</span>
{onPeriodChange ? (
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
{(['7d', '30d', '90d'] as const).map((p) => (
<button
key={p}
onClick={() => onPeriodChange(p)}
className={`px-2.5 py-1 text-xs font-medium rounded transition-colors ${
period === p
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{p === '7d' ? '7d' : p === '30d' ? '30d' : '90d'}
</button>
))}
</div>
) : (
<span className="text-xs text-gray-600 dark:text-gray-400">{periodLabel}</span>
)}
</div>
{/* Operations List */}

View File

@@ -146,9 +146,9 @@ export default function WorkflowCompletionWidget({
{ label: 'Pages Published', value: writer.contentPublished, barColor: `var(${WORKFLOW_COLORS.writer.pagesPublished})` },
];
// Calculate max value for proportional bars (across both columns)
const allValues = [...plannerItems, ...writerItems].map(i => i.value);
const maxValue = Math.max(...allValues, 1);
// Since these are totals (not percentages), show full-width bars
// The value itself indicates the metric, not the bar width
const maxValue = 1; // Always show 100% filled bars
return (
<Card className={`p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700 min-w-0 ${className}`}>

View File

@@ -12,7 +12,7 @@ import Checkbox from '../form/input/Checkbox';
import Switch from '../form/switch/Switch';
import { useToast } from '../ui/toast/ToastContainer';
import { integrationApi, SiteIntegration } from '../../services/integration.api';
import { fetchAPI } from '../../services/api';
import { fetchAPI, API_BASE_URL } from '../../services/api';
import {
CheckCircleIcon,
AlertIcon,
@@ -22,7 +22,8 @@ import {
TrashBinIcon,
GlobeIcon,
KeyIcon,
RefreshCwIcon
RefreshCwIcon,
InfoIcon
} from '../../icons';
interface WordPressIntegrationFormProps {
@@ -45,6 +46,8 @@ export default function WordPressIntegrationForm({
const [generatingKey, setGeneratingKey] = useState(false);
const [apiKey, setApiKey] = useState<string>('');
const [apiKeyVisible, setApiKeyVisible] = useState(false);
const [pluginInfo, setPluginInfo] = useState<any>(null);
const [loadingPlugin, setLoadingPlugin] = useState(false);
// Load API key from integration on mount or when integration changes
useEffect(() => {
@@ -55,6 +58,23 @@ export default function WordPressIntegrationForm({
}
}, [integration]);
// Fetch plugin information
useEffect(() => {
const fetchPluginInfo = async () => {
try {
setLoadingPlugin(true);
const response = await fetchAPI('/plugins/wordpress/latest/');
setPluginInfo(response);
} catch (error) {
console.error('Failed to fetch plugin info:', error);
} finally {
setLoadingPlugin(false);
}
};
fetchPluginInfo();
}, []);
const handleGenerateApiKey = async () => {
try {
setGeneratingKey(true);
@@ -151,7 +171,8 @@ export default function WordPressIntegrationForm({
};
const handleDownloadPlugin = () => {
const pluginUrl = `https://github.com/igny8/igny8-wp-bridge/releases/latest/download/igny8-wp-bridge.zip`;
// Use the backend API endpoint for plugin download (must use full API URL, not relative)
const pluginUrl = `${API_BASE_URL}/plugins/igny8-wp-bridge/download/`;
window.open(pluginUrl, '_blank');
toast.success('Plugin download started');
};
@@ -371,23 +392,93 @@ export default function WordPressIntegrationForm({
{/* Plugin Download Section */}
{apiKey && (
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<DownloadIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
IGNY8 WP Bridge Plugin
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Download and install the plugin on your WordPress site
</p>
<div className="space-y-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<DownloadIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
IGNY8 WP Bridge Plugin
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Download and install the plugin on your WordPress site
</p>
</div>
<Button
onClick={handleDownloadPlugin}
variant="solid"
startIcon={<DownloadIcon className="w-4 h-4" />}
disabled={loadingPlugin}
>
Download Plugin
</Button>
</div>
<Button
onClick={handleDownloadPlugin}
variant="solid"
startIcon={<DownloadIcon className="w-4 h-4" />}
>
Download Plugin
</Button>
{/* Plugin Details */}
{pluginInfo && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Version</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{pluginInfo.version}
</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">File Size</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{(pluginInfo.file_size / 1024).toFixed(1)} KB
</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">WordPress Version</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
5.0+
</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">PHP Version</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
7.4+
</p>
</div>
</div>
{/* Requirements & Instructions */}
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-start gap-2">
<InfoIcon className="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs font-medium text-blue-900 dark:text-blue-300 mb-1">
Installation Steps
</p>
<ol className="text-xs text-blue-800 dark:text-blue-400 space-y-1 list-decimal list-inside">
<li>Download the plugin ZIP file</li>
<li>Go to your WordPress admin Plugins Add New</li>
<li>Click "Upload Plugin" and select the ZIP file</li>
<li>Activate the plugin after installation</li>
<li>Configure plugin with your API key from above</li>
</ol>
</div>
</div>
</div>
{/* Changelog */}
{pluginInfo.changelog && (
<div className="mt-3 text-xs text-gray-600 dark:text-gray-400">
<p className="font-medium text-gray-900 dark:text-white mb-1">What's New:</p>
<p className="whitespace-pre-line">{pluginInfo.changelog}</p>
</div>
)}
</div>
)}
{loadingPlugin && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">
Loading plugin information...
</p>
</div>
)}
</div>
</Card>
)}

View File

@@ -247,6 +247,7 @@ export const WORKFLOW_COLORS = {
writer: {
tasksCreated: CSS_VAR_COLORS.grayBase, // Navy
contentPages: CSS_VAR_COLORS.primary, // Blue
imagesCreated: CSS_VAR_COLORS.purple, // Purple for images
pagesPublished: CSS_VAR_COLORS.success, // Green
},
} as const;

View File

@@ -296,24 +296,45 @@ export function createApprovedPageConfig(params: {
const headerMetrics: HeaderMetricConfig[] = [
{
label: 'Approved',
accentColor: 'green',
calculate: (data: { totalCount: number }) => data.totalCount,
tooltip: 'Total approved content ready for publishing.',
},
{
label: 'On Site',
label: 'Content',
accentColor: 'blue',
calculate: (data: { content: Content[] }) =>
data.content.filter(c => c.external_id).length,
tooltip: 'Content published to your website.',
calculate: (data: { totalCount: number }) => data.totalCount,
tooltip: 'Total content items tracked. Overall volume across all stages.',
},
{
label: 'Pending',
label: 'Draft',
accentColor: 'amber',
calculate: (data: { content: Content[] }) =>
data.content.filter(c => !c.external_id).length,
tooltip: 'Approved content not yet published to site.',
data.content.filter(c => c.status === 'draft').length,
tooltip: 'Content written, images not generated. Generate images to move to review.',
},
{
label: 'In Review',
accentColor: 'purple',
calculate: (data: { content: Content[] }) =>
data.content.filter(c => c.status === 'review').length,
tooltip: 'Images generated, awaiting approval. Review and approve to publish.',
},
{
label: 'Approved',
accentColor: 'green',
calculate: (data: { content: Content[] }) =>
data.content.filter(c => c.status === 'approved').length,
tooltip: 'Approved content awaiting publishing. Publish to site when ready.',
},
{
label: 'Published',
accentColor: 'green',
calculate: (data: { content: Content[] }) =>
data.content.filter(c => c.status === 'published').length,
tooltip: 'Live content on your website. Successfully published and accessible.',
},
{
label: 'Total Images',
accentColor: 'blue',
calculate: (data: { content: Content[] }) =>
data.content.filter(c => c.has_generated_images).length,
tooltip: 'Total images generated across all content. Tracks visual asset coverage.',
},
];

View File

@@ -456,28 +456,42 @@ export const createContentPageConfig = (
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
tooltip: 'Total content pieces generated. Includes drafts, review, and published content.',
tooltip: 'Total content items tracked. Overall volume across all stages.',
},
{
label: 'Draft',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.content.filter((c: Content) => c.status === 'draft').length,
tooltip: 'Content in draft stage. Edit and refine before moving to review.',
tooltip: 'Content written, images not generated. Generate images to move to review.',
},
{
label: 'In Review',
value: 0,
accentColor: 'blue' as const,
accentColor: 'purple' as const,
calculate: (data) => data.content.filter((c: Content) => c.status === 'review').length,
tooltip: 'Content awaiting review and approval. Review for quality before publishing.',
tooltip: 'Images generated, awaiting approval. Review and approve to publish.',
},
{
label: 'Approved',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.content.filter((c: Content) => c.status === 'approved').length,
tooltip: 'Approved content awaiting publishing. Publish to site when ready.',
},
{
label: 'Published',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.content.filter((c: Content) => c.status === 'published').length,
tooltip: 'Published content ready for WordPress sync. Track your published library.',
tooltip: 'Live content on your website. Successfully published and accessible.',
},
{
label: 'Total Images',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => 0,
tooltip: 'Total images generated across all content. Tracks visual asset coverage.',
},
],
};

View File

@@ -111,14 +111,17 @@ export const createImagesPageConfig = (
];
// Add in-article image columns dynamically
for (let i = 1; i <= maxImages; i++) {
// Backend uses 0-indexed positions (0, 1, 2, 3)
// Display uses 1-indexed labels (In-Article 1, 2, 3, 4)
for (let i = 0; i < maxImages; i++) {
const displayIndex = i + 1; // 1-indexed for display
columns.push({
key: `in_article_${i}`,
label: `In-Article ${i}`,
key: `in_article_${displayIndex}`,
label: `In-Article ${displayIndex}`,
sortable: false,
width: '150px',
render: (_value: any, row: ContentImagesGroup) => {
const image = row.in_article_images.find(img => img.position === i);
const image = row.in_article_images.find(img => img.position === i); // 0-indexed position
return (
<ContentImageCell
image={image || null}
@@ -204,28 +207,42 @@ export const createImagesPageConfig = (
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0,
tooltip: 'Total content pieces with image generation. Track image coverage across all content.',
tooltip: 'Total content items tracked. Overall volume across all stages.',
},
{
label: 'Complete',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'complete').length,
tooltip: 'Content with all images generated. Ready for publishing with full visual coverage.',
},
{
label: 'Partial',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'partial').length,
tooltip: 'Content with some images missing. Generate remaining images to complete visual assets.',
},
{
label: 'Pending',
label: 'Draft',
value: 0,
accentColor: 'amber' as const,
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'pending').length,
tooltip: 'Content waiting for image generation. Queue these to start creating visual assets.',
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.content_status === 'draft').length,
tooltip: 'Content written, images not generated. Generate images to move to review.',
},
{
label: 'In Review',
value: 0,
accentColor: 'purple' as const,
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.content_status === 'review').length,
tooltip: 'Images generated, awaiting approval. Review and approve to publish.',
},
{
label: 'Approved',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.content_status === 'approved').length,
tooltip: 'Approved content awaiting publishing. Publish to site when ready.',
},
{
label: 'Published',
value: 0,
accentColor: 'green' as const,
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.content_status === 'published').length,
tooltip: 'Live content on your website. Successfully published and accessible.',
},
{
label: 'Total Images',
value: 0,
accentColor: 'blue' as const,
calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'complete').length,
tooltip: 'Total images generated across all content. Tracks visual asset coverage.',
},
],
maxInArticleImages: maxImages,

Some files were not shown because too many files have changed in this diff Show More