Compare commits
31 Commits
cf8181d1f9
...
e9f02f5e9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9f02f5e9f | ||
|
|
b390e02aa5 | ||
|
|
368601f68c | ||
|
|
95d8ade942 | ||
|
|
90b9d6aadc | ||
|
|
3925ddf894 | ||
|
|
00ef985a5f | ||
|
|
75e5b148f5 | ||
|
|
e9369df151 | ||
|
|
747770ac58 | ||
|
|
6e15ffb49b | ||
|
|
854b3efd45 | ||
|
|
6fb0411f56 | ||
|
|
1246f8ac5d | ||
|
|
622e66b0fb | ||
|
|
60f981cafd | ||
|
|
b0c941dba5 | ||
|
|
b6b6ae7a84 | ||
|
|
067eb59344 | ||
|
|
af95454049 | ||
|
|
ceee9ba34d | ||
|
|
be2c190eca | ||
|
|
9e785f141c | ||
|
|
975eab46cf | ||
|
|
0c693dc1cc | ||
|
|
ce66dadc00 | ||
|
|
346d3f0531 | ||
|
|
a86524a6b1 | ||
|
|
0ea3a30909 | ||
|
|
4343f62140 | ||
|
|
80f1709a2e |
365
CHANGELOG.md
365
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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"""
|
||||
|
||||
1
backend/igny8_core/plugins/__init__.py
Normal file
1
backend/igny8_core/plugins/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# IGNY8 Plugin Distribution System
|
||||
239
backend/igny8_core/plugins/admin.py
Normal file
239
backend/igny8_core/plugins/admin.py
Normal 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']
|
||||
}),
|
||||
]
|
||||
16
backend/igny8_core/plugins/apps.py
Normal file
16
backend/igny8_core/plugins/apps.py
Normal 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
|
||||
1
backend/igny8_core/plugins/management/__init__.py
Normal file
1
backend/igny8_core/plugins/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Plugin management commands
|
||||
@@ -0,0 +1 @@
|
||||
# Plugin management commands
|
||||
164
backend/igny8_core/plugins/management/commands/build_plugin.py
Normal file
164
backend/igny8_core/plugins/management/commands/build_plugin.py
Normal 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."
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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/"
|
||||
)
|
||||
)
|
||||
107
backend/igny8_core/plugins/migrations/0001_initial.py
Normal file
107
backend/igny8_core/plugins/migrations/0001_initial.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
0
backend/igny8_core/plugins/migrations/__init__.py
Normal file
0
backend/igny8_core/plugins/migrations/__init__.py
Normal file
385
backend/igny8_core/plugins/models.py
Normal file
385
backend/igny8_core/plugins/models.py
Normal 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}"
|
||||
238
backend/igny8_core/plugins/serializers.py
Normal file
238
backend/igny8_core/plugins/serializers.py
Normal 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
|
||||
86
backend/igny8_core/plugins/signals.py
Normal file
86
backend/igny8_core/plugins/signals.py
Normal 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}")
|
||||
62
backend/igny8_core/plugins/urls.py
Normal file
62
backend/igny8_core/plugins/urls.py
Normal 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'),
|
||||
]
|
||||
324
backend/igny8_core/plugins/utils.py
Normal file
324
backend/igny8_core/plugins/utils.py
Normal 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
|
||||
658
backend/igny8_core/plugins/views.py
Normal file
658
backend/igny8_core/plugins/views.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
11
backend/seed_keywords_import_template.csv
Normal file
11
backend/seed_keywords_import_template.csv
Normal 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,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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
130
docs/60-PLUGINS/INDEX.md
Normal 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/`.
|
||||
690
docs/60-PLUGINS/PLUGIN-UPDATE-WORKFLOW.md
Normal file
690
docs/60-PLUGINS/PLUGIN-UPDATE-WORKFLOW.md
Normal 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
|
||||
729
docs/60-PLUGINS/WORDPRESS-INTEGRATION.md
Normal file
729
docs/60-PLUGINS/WORDPRESS-INTEGRATION.md
Normal 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
|
||||
195
docs/90-REFERENCE/SEED-KEYWORDS-IMPORT-GUIDE.md
Normal file
195
docs/90-REFERENCE/SEED-KEYWORDS-IMPORT-GUIDE.md
Normal 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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
@@ -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
|
||||
|
||||
447
docs/plans/WP_PLUGIN_IGNY8_APP_CLEANUP.md
Normal file
447
docs/plans/WP_PLUGIN_IGNY8_APP_CLEANUP.md
Normal 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)
|
||||
215
docs/plans/WP_PLUGIN_IGNY8_APP_CLEANUP_IMPLEMENTATION_SUMMARY.md
Normal file
215
docs/plans/WP_PLUGIN_IGNY8_APP_CLEANUP_IMPLEMENTATION_SUMMARY.md
Normal 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
|
||||
1156
docs/plans/implemented/COMPREHENSIVE-SYSTEM-FIX-PLAN-JAN-10-2026.md
Normal file
1156
docs/plans/implemented/COMPREHENSIVE-SYSTEM-FIX-PLAN-JAN-10-2026.md
Normal file
File diff suppressed because it is too large
Load Diff
334
docs/plans/implemented/FOOTER-WIDGETS-AUDIT.md
Normal file
334
docs/plans/implemented/FOOTER-WIDGETS-AUDIT.md
Normal 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
|
||||
373
docs/plans/implemented/IMAGE-GENERATION-GAPS.md
Normal file
373
docs/plans/implemented/IMAGE-GENERATION-GAPS.md
Normal 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 |
|
||||
@@ -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
|
||||
814
docs/plans/implemented/phase3-content-template-redesign.md
Normal file
814
docs/plans/implemented/phase3-content-template-redesign.md
Normal 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
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tailadmin-react",
|
||||
"private": true,
|
||||
"version": "2.0.2",
|
||||
"version": "1.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 |
@@ -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 />} />
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user