old automation cleanup adn status feilds of planner udpate
This commit is contained in:
263
KEYWORDS-CLUSTERS-IDEAS-COMPLETE-MAPPING.md
Normal file
263
KEYWORDS-CLUSTERS-IDEAS-COMPLETE-MAPPING.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# Keywords, Clusters & Ideas - Complete Field Mapping
|
||||||
|
|
||||||
|
**Date:** December 3, 2025
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 KEYWORDS - Complete Field Reference
|
||||||
|
|
||||||
|
### Backend Model Fields
|
||||||
|
**File:** `backend/igny8_core/business/planning/models.py`
|
||||||
|
|
||||||
|
| Field Name | Type | Default | Required | Choices | Notes |
|
||||||
|
|------------|------|---------|----------|---------|-------|
|
||||||
|
| `id` | Integer PK | Auto | ✅ | - | Primary key |
|
||||||
|
| `seed_keyword` | ForeignKey | - | ✅ | - | Links to SeedKeyword (PROTECT on delete) |
|
||||||
|
| `volume_override` | Integer | NULL | ❌ | - | Site-specific override (uses seed_keyword.volume if NULL) |
|
||||||
|
| `difficulty_override` | Integer | NULL | ❌ | - | Site-specific override (uses seed_keyword.difficulty if NULL) |
|
||||||
|
| `attribute_values` | JSONField | [] | ❌ | - | Optional metadata (product specs, modifiers) |
|
||||||
|
| `cluster` | ForeignKey | NULL | ❌ | - | Parent cluster (SET_NULL on delete) |
|
||||||
|
| `status` | CharField(50) | `pending` | ✅ | `pending`, `active`, `archived` | Keyword status |
|
||||||
|
| `site` | ForeignKey | - | ✅ | - | Owner site (inherited) |
|
||||||
|
| `sector` | ForeignKey | - | ✅ | - | Owner sector (inherited) |
|
||||||
|
| `account` | ForeignKey | - | ✅ | - | Owner account (inherited) |
|
||||||
|
| `created_at` | DateTime | Auto | ✅ | - | Auto-generated |
|
||||||
|
| `updated_at` | DateTime | Auto | ✅ | - | Auto-updated |
|
||||||
|
|
||||||
|
### Frontend Table Columns
|
||||||
|
**File:** `frontend/src/config/pages/keywords.config.tsx`
|
||||||
|
|
||||||
|
| Column Key | Label | Visible Default | Sortable | Render Notes |
|
||||||
|
|----------|--------|-----------------|----------|--------------|
|
||||||
|
| `keyword` | Keyword | ✅ Yes | ✅ Yes | From seed_keyword.keyword (links to Keywords page) |
|
||||||
|
| `sector_name` | Sector | Conditional* | ✅ Yes | Badge (blue) - shown when viewing all sectors |
|
||||||
|
| `volume` | Volume | ✅ Yes | ✅ Yes | Formatted as number (e.g., 1,250) |
|
||||||
|
| `cluster_name` | Cluster | ✅ Yes | ✅ Yes | Parent cluster name or "-" |
|
||||||
|
| `difficulty` | Difficulty | ✅ Yes | ✅ Yes | Badge (1-5): 1-2=green, 3=amber, 4-5=red |
|
||||||
|
| `intent` | Intent | ✅ Yes | ✅ Yes | Badge colors: Transactional/Commercial=green, Navigational=amber |
|
||||||
|
| `status` | Status | ✅ Yes | ✅ Yes | Badge: pending=amber, active=green, archived=red |
|
||||||
|
| `created_at` | Created | ✅ Yes | ✅ Yes | Relative date (e.g., "2 hours ago") |
|
||||||
|
| (Hidden by default) | | | | |
|
||||||
|
| `updated_at` | Updated | ❌ No | ✅ Yes | Relative date |
|
||||||
|
|
||||||
|
### Frontend Filter Dropdown
|
||||||
|
**File:** `frontend/src/config/pages/keywords.config.tsx` (Lines 310-360)
|
||||||
|
|
||||||
|
| Filter Key | Label | Type | Options | Dynamic |
|
||||||
|
|-----------|-------|------|---------|---------|
|
||||||
|
| `search` | Search | Text | N/A | - |
|
||||||
|
| `status` | Status | Select | `pending`, `active`, `archived` | ❌ No |
|
||||||
|
| `intent` | Intent | Select | `informational`, `navigational`, `transactional`, `commercial` | ❌ No |
|
||||||
|
| `difficulty` | Difficulty | Select | `1-5` with labels | ❌ No |
|
||||||
|
| `cluster` | Cluster | Select | Dynamic from database | ✅ Yes (loads clusters) |
|
||||||
|
| `volume` | Volume Range | Custom | Min/Max number inputs | ❌ No (range picker) |
|
||||||
|
|
||||||
|
### Frontend Create/Edit Form
|
||||||
|
**File:** `frontend/src/config/pages/keywords.config.tsx` (Lines 560-586)
|
||||||
|
|
||||||
|
| Field Key | Label | Type | Required | Default | Options |
|
||||||
|
|-----------|-------|------|----------|---------|---------|
|
||||||
|
| `seed_keyword_id` | Seed Keyword | Select | ✅ Yes | - | Dynamic from availableSeedKeywords |
|
||||||
|
| `volume_override` | Volume Override | Number | ❌ No | NULL | Numeric input (optional override) |
|
||||||
|
| `difficulty_override` | Difficulty Override | Number | ❌ No | NULL | Select 1-5 |
|
||||||
|
| `cluster_id` | Cluster | Select | ❌ No | NULL | Dynamic from clusters array |
|
||||||
|
| `status` | Status | Select | ✅ Yes | `pending` | `pending`, `active`, `archived` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 CLUSTERS - Complete Field Reference
|
||||||
|
|
||||||
|
### Backend Model Fields
|
||||||
|
**File:** `backend/igny8_core/business/planning/models.py`
|
||||||
|
|
||||||
|
| Field Name | Type | Default | Required | Choices | Notes |
|
||||||
|
|------------|------|---------|----------|---------|-------|
|
||||||
|
| `id` | Integer PK | Auto | ✅ | - | Primary key |
|
||||||
|
| `name` | CharField(255) | - | ✅ | - | Unique cluster name (unique=True) |
|
||||||
|
| `description` | TextField | NULL | ❌ | - | Optional cluster description |
|
||||||
|
| `keywords_count` | Integer | 0 | ✅ | - | Cached count of linked keywords |
|
||||||
|
| `ideas_count` | Integer | 0 | ✅ | - | Cached count of linked ideas |
|
||||||
|
| `volume` | Integer | 0 | ✅ | - | Cached total volume from keywords |
|
||||||
|
| `mapped_pages` | Integer | 0 | ✅ | - | Number of mapped pages |
|
||||||
|
| `status` | CharField(50) | `new` | ✅ | `new`, `idea`, `mapped` | Cluster status |
|
||||||
|
| `site` | ForeignKey | - | ✅ | - | Owner site (inherited) |
|
||||||
|
| `sector` | ForeignKey | - | ✅ | - | Owner sector (inherited) |
|
||||||
|
| `account` | ForeignKey | - | ✅ | - | Owner account (inherited) |
|
||||||
|
| `created_at` | DateTime | Auto | ✅ | - | Auto-generated |
|
||||||
|
| `updated_at` | DateTime | Auto | ✅ | - | Auto-updated |
|
||||||
|
|
||||||
|
### Frontend Table Columns
|
||||||
|
**File:** `frontend/src/config/pages/clusters.config.tsx`
|
||||||
|
|
||||||
|
| Column Key | Label | Visible Default | Sortable | Render Notes |
|
||||||
|
|----------|--------|-----------------|----------|--------------|
|
||||||
|
| `name` | Cluster Name | ✅ Yes | ✅ Yes | Link to cluster detail page |
|
||||||
|
| `sector_name` | Sector | Conditional* | ✅ Yes | Badge (blue) - shown when viewing all sectors |
|
||||||
|
| `keywords_count` | Keywords | ✅ Yes | ✅ Yes | Formatted as number (e.g., 45) |
|
||||||
|
| `ideas_count` | Ideas | ✅ Yes | ✅ Yes | Formatted as number (e.g., 12) |
|
||||||
|
| `volume` | Volume | ✅ Yes | ✅ Yes | Formatted as number (e.g., 5,280) |
|
||||||
|
| `difficulty` | Difficulty | ✅ Yes | ✅ Yes | Badge (1-5): 1-2=green, 3=amber, 4-5=red |
|
||||||
|
| `content_count` | Content | ✅ Yes | ✅ Yes | Formatted as number |
|
||||||
|
| `status` | Status | ✅ Yes | ✅ Yes | Badge: new=amber, idea=blue, mapped=green |
|
||||||
|
| `created_at` | Created | ✅ Yes | ✅ Yes | Relative date |
|
||||||
|
| (Hidden by default) | | | | |
|
||||||
|
| `description` | Description | ❌ No | ❌ No | Text truncated to 250px |
|
||||||
|
| `mapped_pages` | Mapped Pages | ❌ No | ✅ Yes | Formatted number |
|
||||||
|
| `updated_at` | Updated | ❌ No | ✅ Yes | Relative date |
|
||||||
|
|
||||||
|
### Frontend Filter Dropdown
|
||||||
|
**File:** `frontend/src/config/pages/clusters.config.tsx` (Lines 240-290)
|
||||||
|
|
||||||
|
| Filter Key | Label | Type | Options | Dynamic |
|
||||||
|
|-----------|-------|------|---------|---------|
|
||||||
|
| `search` | Search | Text | N/A | - |
|
||||||
|
| `status` | Status | Select | `new`, `idea`, `mapped` | ❌ No |
|
||||||
|
| `difficulty` | Difficulty | Select | `1-5` with labels | ❌ No |
|
||||||
|
| `volume` | Volume Range | Custom | Min/Max number inputs | ❌ No (range picker) |
|
||||||
|
|
||||||
|
### Frontend Create/Edit Form
|
||||||
|
**File:** `frontend/src/config/pages/clusters.config.tsx` (Lines 405-418)
|
||||||
|
|
||||||
|
| Field Key | Label | Type | Required | Default | Options |
|
||||||
|
|-----------|-------|------|----------|---------|---------|
|
||||||
|
| `name` | Cluster Name | Text | ✅ Yes | - | Text input (placeholder: "Enter cluster name") |
|
||||||
|
| `description` | Description | Textarea | ❌ No | NULL | Textarea (placeholder: "Enter cluster description") |
|
||||||
|
| `status` | Status | Select | ✅ Yes | `new` | `new`, `idea`, `mapped` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 CONTENT IDEAS - Complete Field Reference
|
||||||
|
|
||||||
|
### Backend Model Fields
|
||||||
|
**File:** `backend/igny8_core/business/planning/models.py`
|
||||||
|
|
||||||
|
| Field Name | Type | Default | Required | Choices | Notes |
|
||||||
|
|------------|------|---------|----------|---------|-------|
|
||||||
|
| `id` | Integer PK | Auto | ✅ | - | Primary key |
|
||||||
|
| `idea_title` | CharField(255) | - | ✅ | - | Content idea title |
|
||||||
|
| `description` | TextField | NULL | ❌ | - | Content outline/description |
|
||||||
|
| `target_keywords` | CharField(500) | '' | ❌ | - | Comma-separated keywords (legacy) |
|
||||||
|
| `keyword_objects` | M2M(Keywords) | - | ❌ | - | Individual keywords linked to idea |
|
||||||
|
| `keyword_cluster` | ForeignKey(Clusters) | NULL | ❌ | - | Parent cluster (SET_NULL on delete) |
|
||||||
|
| `status` | CharField(50) | `new` | ✅ | `new`, `scheduled`, `completed`, `published` | Idea workflow status |
|
||||||
|
| `estimated_word_count` | Integer | 1000 | ✅ | - | Target article length |
|
||||||
|
| `content_type` | CharField(50) | `post` | ✅ | `post`, `page`, `product`, `taxonomy` | Content type |
|
||||||
|
| `content_structure` | CharField(50) | `article` | ✅ | See structures below | Content format/structure |
|
||||||
|
| `site` | ForeignKey | - | ✅ | - | Owner site (inherited) |
|
||||||
|
| `sector` | ForeignKey | - | ✅ | - | Owner sector (inherited) |
|
||||||
|
| `account` | ForeignKey | - | ✅ | - | Owner account (inherited) |
|
||||||
|
| `created_at` | DateTime | Auto | ✅ | - | Auto-generated |
|
||||||
|
| `updated_at` | DateTime | Auto | ✅ | - | Auto-updated |
|
||||||
|
|
||||||
|
**Content Structure Choices (based on content_type):**
|
||||||
|
- **Post:** `article`, `guide`, `comparison`, `review`, `listicle`
|
||||||
|
- **Page:** `landing_page`, `business_page`, `service_page`, `general`, `cluster_hub`
|
||||||
|
- **Product:** `product_page`
|
||||||
|
- **Taxonomy:** `category_archive`, `tag_archive`, `attribute_archive`
|
||||||
|
|
||||||
|
### Frontend Table Columns
|
||||||
|
**File:** `frontend/src/config/pages/ideas.config.tsx`
|
||||||
|
|
||||||
|
| Column Key | Label | Visible Default | Sortable | Render Notes |
|
||||||
|
|----------|--------|-----------------|----------|--------------|
|
||||||
|
| `idea_title` | Title | ✅ Yes | ✅ Yes | Expandable (shows description) |
|
||||||
|
| `sector_name` | Sector | Conditional* | ✅ Yes | Badge (blue) - shown when viewing all sectors |
|
||||||
|
| `content_structure` | Structure | ✅ Yes | ✅ Yes | Badge (purple): article, guide, guide, etc. |
|
||||||
|
| `content_type` | Type | ✅ Yes | ✅ Yes | Badge (blue): post, page, product, taxonomy |
|
||||||
|
| `target_keywords` | Target Keywords | ✅ Yes | ❌ No | Text truncated to 250px |
|
||||||
|
| `keyword_cluster_name` | Cluster | ✅ Yes | ✅ Yes | Parent cluster name or "-" |
|
||||||
|
| `status` | Status | ✅ Yes | ✅ Yes | Badge: new=amber, scheduled=blue, completed=blue, published=green |
|
||||||
|
| `estimated_word_count` | Words | ✅ Yes | ✅ Yes | Formatted as number (e.g., 1,500) |
|
||||||
|
| `created_at` | Created | ✅ Yes | ✅ Yes | Relative date |
|
||||||
|
| (Hidden by default) | | | | |
|
||||||
|
| `updated_at` | Updated | ❌ No | ✅ Yes | Relative date |
|
||||||
|
|
||||||
|
### Frontend Filter Dropdown
|
||||||
|
**File:** `frontend/src/config/pages/ideas.config.tsx` (Lines 218-270)
|
||||||
|
|
||||||
|
| Filter Key | Label | Type | Options | Dynamic |
|
||||||
|
|-----------|-------|------|---------|---------|
|
||||||
|
| `search` | Search | Text | N/A | - |
|
||||||
|
| `status` | Status | Select | `new`, `scheduled`, `completed`, `published` | ❌ No |
|
||||||
|
| `content_structure` | Structure | Select | article, guide, comparison, etc. (all 13 options) | ❌ No |
|
||||||
|
| `content_type` | Type | Select | `post`, `page`, `product`, `taxonomy` | ❌ No |
|
||||||
|
| `cluster` | Cluster | Select | Dynamic from database | ✅ Yes (loads clusters) |
|
||||||
|
|
||||||
|
### Frontend Create/Edit Form
|
||||||
|
**File:** `frontend/src/config/pages/ideas.config.tsx` (Lines 372-417)
|
||||||
|
|
||||||
|
| Field Key | Label | Type | Required | Default | Options |
|
||||||
|
|-----------|-------|------|----------|---------|---------|
|
||||||
|
| `idea_title` | Title | Text | ✅ Yes | - | Text input (placeholder: "Enter idea title") |
|
||||||
|
| `description` | Description | Textarea | ❌ No | NULL | Textarea (placeholder: "Enter content outline") |
|
||||||
|
| `content_type` | Content Type | Select | ✅ Yes | `post` | `post`, `page`, `product`, `taxonomy` |
|
||||||
|
| `content_structure` | Content Structure | Select | ✅ Yes | `article` | 13 structure options (depends on content_type) |
|
||||||
|
| `target_keywords` | Target Keywords | Text | ❌ No | NULL | Text input (comma-separated) |
|
||||||
|
| `keyword_cluster_id` | Cluster | Select | ❌ No | NULL | Dynamic from clusters array |
|
||||||
|
| `status` | Status | Select | ✅ Yes | `new` | `new`, `scheduled`, `completed`, `published` |
|
||||||
|
| `estimated_word_count` | Word Count | Number | ❌ No | 1000 | Numeric input |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 SUMMARY COMPARISON
|
||||||
|
|
||||||
|
### Status Fields
|
||||||
|
| Module | Backend Default | Backend Choices | Frontend Form Default | Frontend Form Choices |
|
||||||
|
|--------|-----------------|-----------------|----------------------|----------------------|
|
||||||
|
| **Keywords** | `pending` | pending, active, archived | `pending` | pending, active, archived |
|
||||||
|
| **Clusters** | `new` | new, idea, mapped | `new` | new, idea, mapped |
|
||||||
|
| **Ideas** | `new` | new, scheduled, completed, published | `new` | new, scheduled, completed, published |
|
||||||
|
|
||||||
|
### Required Fields (Must be filled)
|
||||||
|
| Module | Required Fields |
|
||||||
|
|--------|-----------------|
|
||||||
|
| **Keywords** | seed_keyword_id, status, site, sector, account |
|
||||||
|
| **Clusters** | name, status, site, sector, account |
|
||||||
|
| **Ideas** | idea_title, status, content_type, content_structure, estimated_word_count, site, sector, account |
|
||||||
|
|
||||||
|
### Optional Fields
|
||||||
|
| Module | Optional Fields |
|
||||||
|
|--------|-----------------|
|
||||||
|
| **Keywords** | volume_override, difficulty_override, attribute_values, cluster_id |
|
||||||
|
| **Clusters** | description |
|
||||||
|
| **Ideas** | description, target_keywords, keyword_objects, keyword_cluster_id |
|
||||||
|
|
||||||
|
### Dynamic Dropdowns (Loaded from DB)
|
||||||
|
| Module | Filter | Form |
|
||||||
|
|--------|--------|------|
|
||||||
|
| **Keywords** | cluster (dropdown) | seed_keyword_id (all available), cluster_id (all clusters for sector) |
|
||||||
|
| **Clusters** | - | - |
|
||||||
|
| **Ideas** | cluster (dropdown) | keyword_cluster_id (all clusters for sector) |
|
||||||
|
|
||||||
|
### Visible-by-Default Table Columns
|
||||||
|
| Module | Count | Primary Columns |
|
||||||
|
|--------|-------|-----------------|
|
||||||
|
| **Keywords** | 9 | keyword, volume, cluster, difficulty, intent, status, created_at |
|
||||||
|
| **Clusters** | 11 | name, keywords_count, ideas_count, volume, difficulty, content_count, status, created_at |
|
||||||
|
| **Ideas** | 10 | idea_title, content_structure, content_type, target_keywords, cluster, status, word_count, created_at |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 Key Differences
|
||||||
|
|
||||||
|
### Keywords
|
||||||
|
- **Uses:** SeedKeyword (global pool) - one keyword per site/sector
|
||||||
|
- **Overrideable:** volume, difficulty (site-specific)
|
||||||
|
- **Links to:** Clusters (via cluster FK)
|
||||||
|
- **Status:** pending (awaiting cluster), active (clustered), archived
|
||||||
|
|
||||||
|
### Clusters
|
||||||
|
- **Type:** Pure topic clusters (semantic groupings)
|
||||||
|
- **Auto-updated:** keywords_count, ideas_count, volume (cached from keywords)
|
||||||
|
- **Status:** new (no ideas), idea (has ideas), mapped (has content)
|
||||||
|
- **No overrides:** All values are cached/calculated
|
||||||
|
|
||||||
|
### Ideas
|
||||||
|
- **Type:** Content concepts ready for production
|
||||||
|
- **Links:** Cluster (required for workflow), Keywords (optional M2M)
|
||||||
|
- **Customizable:** content_type, content_structure, word_count
|
||||||
|
- **Status:** new → scheduled (queued to writer) → completed (content generated) → published
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**END OF COMPLETE FIELD MAPPING**
|
||||||
335
STATUS-IMPLEMENTATION-TABLES.md
Normal file
335
STATUS-IMPLEMENTATION-TABLES.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# Status Implementation Tables - Complete Reference
|
||||||
|
|
||||||
|
**Date:** December 3, 2025
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 KEYWORDS MODULE
|
||||||
|
|
||||||
|
### Backend Model
|
||||||
|
**File:** `/backend/igny8_core/business/planning/models.py`
|
||||||
|
|
||||||
|
| Field | Type | Choices | Default | Required | Description |
|
||||||
|
|-------|------|---------|---------|----------|-------------|
|
||||||
|
| `status` | CharField(50) | `new`, `mapped` | `new` | ✅ Yes | Workflow status |
|
||||||
|
| `disabled` | BooleanField | - | `False` | ✅ Yes | Filter control (exclude from processes) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Table Column
|
||||||
|
**File:** `/frontend/src/config/pages/keywords.config.tsx` (Lines ~230-248)
|
||||||
|
|
||||||
|
| Status Value | Badge Color | Badge Label | Display When |
|
||||||
|
|--------------|-------------|-------------|--------------|
|
||||||
|
| `new` | Amber/Yellow | New | Keyword not yet clustered |
|
||||||
|
| `mapped` | Green | Mapped | Keyword assigned to cluster |
|
||||||
|
| *(disabled=true)* | Red/Gray | Disabled | User manually disabled (optional display) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Filter Dropdown
|
||||||
|
**File:** `/frontend/src/config/pages/keywords.config.tsx` (Lines ~310-318)
|
||||||
|
|
||||||
|
| Filter Type | Options | Default | Description |
|
||||||
|
|-------------|---------|---------|-------------|
|
||||||
|
| Status Select | `new`, `mapped` | All | Workflow status filter |
|
||||||
|
| Disabled Checkbox | Show/Hide disabled | Hide disabled | Filter control |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Form Fields
|
||||||
|
**File:** `/frontend/src/config/pages/keywords.config.tsx` (Lines ~560-570)
|
||||||
|
|
||||||
|
| Field | Type | Options | Default | Required | Editable |
|
||||||
|
|-------|------|---------|---------|----------|----------|
|
||||||
|
| Status | Select | `new`, `mapped` | `new` | ✅ Yes | ✅ Yes |
|
||||||
|
| Disabled | Checkbox | true/false | `false` | ❌ No | ✅ Yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 CLUSTERS MODULE
|
||||||
|
|
||||||
|
### Backend Model
|
||||||
|
**File:** `/backend/igny8_core/business/planning/models.py`
|
||||||
|
|
||||||
|
| Field | Type | Choices | Default | Required | Description |
|
||||||
|
|-------|------|---------|---------|----------|-------------|
|
||||||
|
| `status` | CharField(50) | `new`, `mapped` | `new` | ✅ Yes | Workflow status |
|
||||||
|
| `disabled` | BooleanField | - | `False` | ✅ Yes | Filter control (exclude from processes) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Table Column
|
||||||
|
**File:** `/frontend/src/config/pages/clusters.config.tsx` (Lines ~190-200)
|
||||||
|
|
||||||
|
| Status Value | Badge Color | Badge Label | Display When |
|
||||||
|
|--------------|-------------|-------------|--------------|
|
||||||
|
| `new` | Amber/Yellow | New | Cluster created, no ideas generated yet |
|
||||||
|
| `mapped` | Green | Mapped | Ideas generated from cluster |
|
||||||
|
| *(disabled=true)* | Red/Gray | Disabled | User manually disabled (optional display) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Filter Dropdown
|
||||||
|
**File:** `/frontend/src/config/pages/clusters.config.tsx` (Lines ~240-253)
|
||||||
|
|
||||||
|
| Filter Type | Options | Default | Description |
|
||||||
|
|-------------|---------|---------|-------------|
|
||||||
|
| Status Select | `new`, `mapped` | All | Workflow status filter |
|
||||||
|
| Disabled Checkbox | Show/Hide disabled | Hide disabled | Filter control |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Form Fields
|
||||||
|
**File:** `/frontend/src/config/pages/clusters.config.tsx` (Lines ~405-418)
|
||||||
|
|
||||||
|
| Field | Type | Options | Default | Required | Editable |
|
||||||
|
|-------|------|---------|---------|----------|----------|
|
||||||
|
| Status | Select | `new`, `mapped` | `new` | ✅ Yes | ✅ Yes |
|
||||||
|
| Disabled | Checkbox | true/false | `false` | ❌ No | ✅ Yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 IDEAS MODULE
|
||||||
|
|
||||||
|
### Backend Model
|
||||||
|
**File:** `/backend/igny8_core/business/planning/models.py`
|
||||||
|
|
||||||
|
| Field | Type | Choices | Default | Required | Description |
|
||||||
|
|-------|------|---------|---------|----------|-------------|
|
||||||
|
| `status` | CharField(50) | `new`, `queued`, `completed` | `new` | ✅ Yes | Workflow status |
|
||||||
|
| `disabled` | BooleanField | - | `False` | ✅ Yes | Filter control (exclude from processes) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Table Column
|
||||||
|
**File:** `/frontend/src/config/pages/ideas.config.tsx` (Lines ~170-185)
|
||||||
|
|
||||||
|
| Status Value | Badge Color | Badge Label | Display When |
|
||||||
|
|--------------|-------------|-------------|--------------|
|
||||||
|
| `new` | Amber/Yellow | New | Idea generated, not queued yet |
|
||||||
|
| `queued` | Blue | Queued | Task created in Writer module |
|
||||||
|
| `completed` | Green | Completed | Content generated (Task completed) |
|
||||||
|
| *(disabled=true)* | Red/Gray | Disabled | User manually disabled (optional display) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Filter Dropdown
|
||||||
|
**File:** `/frontend/src/config/pages/ideas.config.tsx` (Lines ~218-228)
|
||||||
|
|
||||||
|
| Filter Type | Options | Default | Description |
|
||||||
|
|-------------|---------|---------|-------------|
|
||||||
|
| Status Select | `new`, `queued`, `completed` | All | Workflow status filter |
|
||||||
|
| Disabled Checkbox | Show/Hide disabled | Hide disabled | Filter control |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Form Fields
|
||||||
|
**File:** `/frontend/src/config/pages/ideas.config.tsx` (Lines ~372-385)
|
||||||
|
|
||||||
|
| Field | Type | Options | Default | Required | Editable |
|
||||||
|
|-------|------|---------|---------|----------|----------|
|
||||||
|
| Status | Select | `new`, `queued`, `completed` | `new` | ✅ Yes | ✅ Yes |
|
||||||
|
| Disabled | Checkbox | true/false | `false` | ❌ No | ✅ Yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔄 STATUS TRANSITION TABLES
|
||||||
|
|
||||||
|
## KEYWORDS Status Transitions
|
||||||
|
|
||||||
|
| Current Status | Trigger/Action | Next Status | Auto/Manual | Updated By |
|
||||||
|
|----------------|----------------|-------------|-------------|------------|
|
||||||
|
| `new` | AI auto_cluster runs | `mapped` | 🤖 Auto | `auto_cluster.py` Line 297 |
|
||||||
|
| `new` | User manually assigns to cluster | `mapped` | 👤 Manual | User form edit |
|
||||||
|
| `new` | User toggles disabled | *(stays new)* + `disabled=true` | 👤 Manual | User form edit |
|
||||||
|
| `mapped` | User toggles disabled | *(stays mapped)* + `disabled=true` | 👤 Manual | User form edit |
|
||||||
|
| *(any)* | User re-enables | *(stays same)* + `disabled=false` | 👤 Manual | User form edit |
|
||||||
|
|
||||||
|
**Workflow Path:**
|
||||||
|
```
|
||||||
|
new ──[auto_cluster AI]──> mapped
|
||||||
|
│
|
||||||
|
└──[user toggle disabled]──> (status unchanged, disabled flag set)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLUSTERS Status Transitions
|
||||||
|
|
||||||
|
| Current Status | Trigger/Action | Next Status | Auto/Manual | Updated By |
|
||||||
|
|----------------|----------------|-------------|-------------|------------|
|
||||||
|
| `new` | AI generate_ideas runs | `mapped` | 🤖 Auto | `generate_ideas.py` (new code) |
|
||||||
|
| `new` | User manually creates ideas | `mapped` | 👤 Manual | User workflow |
|
||||||
|
| `new` | User toggles disabled | *(stays new)* + `disabled=true` | 👤 Manual | User form edit |
|
||||||
|
| `mapped` | User toggles disabled | *(stays mapped)* + `disabled=true` | 👤 Manual | User form edit |
|
||||||
|
| *(any)* | User re-enables | *(stays same)* + `disabled=false` | 👤 Manual | User form edit |
|
||||||
|
|
||||||
|
**Workflow Path:**
|
||||||
|
```
|
||||||
|
new ──[generate_ideas AI]──> mapped
|
||||||
|
│
|
||||||
|
└──[user toggle disabled]──> (status unchanged, disabled flag set)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IDEAS Status Transitions
|
||||||
|
|
||||||
|
| Current Status | Trigger/Action | Next Status | Auto/Manual | Updated By |
|
||||||
|
|----------------|----------------|-------------|-------------|------------|
|
||||||
|
| `new` | User bulk-queues ideas to writer | `queued` | 👤 Manual | `views.py` Line 1084 |
|
||||||
|
| `queued` | Writer AI generates content | `completed` | 🤖 Auto | `generate_content.py` Line 318 (syncs from Task) |
|
||||||
|
| `queued` | Task status becomes 'completed' | `completed` | 🤖 Auto | Auto-sync from Task.status |
|
||||||
|
| `new` | User toggles disabled | *(stays new)* + `disabled=true` | 👤 Manual | User form edit |
|
||||||
|
| `queued` | User toggles disabled | *(stays queued)* + `disabled=true` | 👤 Manual | User form edit |
|
||||||
|
| `completed` | User toggles disabled | *(stays completed)* + `disabled=true` | 👤 Manual | User form edit |
|
||||||
|
| *(any)* | User re-enables | *(stays same)* + `disabled=false` | 👤 Manual | User form edit |
|
||||||
|
|
||||||
|
**Workflow Path:**
|
||||||
|
```
|
||||||
|
new ──[user bulk_queue]──> queued ──[generate_content AI]──> completed
|
||||||
|
│ │ │
|
||||||
|
└──[user toggle disabled]────┴───────────[user toggle]──────────┘
|
||||||
|
(status unchanged, disabled flag set)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📊 COMBINED STATUS OVERVIEW
|
||||||
|
|
||||||
|
## All Modules - Status Values
|
||||||
|
|
||||||
|
| Module | Workflow Statuses | Filter Status | Total Unique Values |
|
||||||
|
|--------|-------------------|---------------|---------------------|
|
||||||
|
| **Keywords** | `new`, `mapped` | `disabled` (boolean) | 2 status + 1 flag |
|
||||||
|
| **Clusters** | `new`, `mapped` | `disabled` (boolean) | 2 status + 1 flag |
|
||||||
|
| **Ideas** | `new`, `queued`, `completed` | `disabled` (boolean) | 3 status + 1 flag |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Process Inclusion Matrix
|
||||||
|
|
||||||
|
| Status | Auto-Cluster AI | Generate-Ideas AI | Bulk-Queue | Generate-Content AI | Dashboard Metrics |
|
||||||
|
|--------|-----------------|-------------------|------------|---------------------|-------------------|
|
||||||
|
| Keywords: `new` | ✅ Included | ❌ N/A | ❌ N/A | ❌ N/A | ✅ Counted |
|
||||||
|
| Keywords: `mapped` | ❌ Already clustered | ✅ Used for ideas | ❌ N/A | ❌ N/A | ✅ Counted |
|
||||||
|
| Keywords: `disabled=true` | ❌ Excluded | ❌ Excluded | ❌ N/A | ❌ N/A | ❌ Excluded |
|
||||||
|
| Clusters: `new` | ❌ N/A | ✅ Included | ❌ N/A | ❌ N/A | ✅ Counted |
|
||||||
|
| Clusters: `mapped` | ❌ N/A | ❌ Already has ideas | ✅ Ideas can queue | ❌ N/A | ✅ Counted |
|
||||||
|
| Clusters: `disabled=true` | ❌ N/A | ❌ Excluded | ❌ Excluded | ❌ N/A | ❌ Excluded |
|
||||||
|
| Ideas: `new` | ❌ N/A | ❌ N/A | ✅ Included | ❌ N/A | ✅ Counted |
|
||||||
|
| Ideas: `queued` | ❌ N/A | ❌ N/A | ❌ Already queued | ✅ Included | ✅ Counted |
|
||||||
|
| Ideas: `completed` | ❌ N/A | ❌ N/A | ❌ Already done | ❌ Already done | ✅ Counted |
|
||||||
|
| Ideas: `disabled=true` | ❌ N/A | ❌ N/A | ❌ Excluded | ❌ Excluded | ❌ Excluded |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔄 DATA MIGRATION TRANSITION TABLE
|
||||||
|
|
||||||
|
## Keywords Migration
|
||||||
|
|
||||||
|
| Old Status | New Status | New Disabled Flag | Logic |
|
||||||
|
|------------|------------|-------------------|-------|
|
||||||
|
| `pending` | `new` | `false` | Direct mapping |
|
||||||
|
| `active` | `mapped` | `false` | Keyword was clustered |
|
||||||
|
| `archived` | `mapped` | `true` | Preserve data, mark as disabled |
|
||||||
|
|
||||||
|
**SQL Preview:**
|
||||||
|
```sql
|
||||||
|
-- Keywords migration
|
||||||
|
UPDATE igny8_keywords
|
||||||
|
SET status = 'new', disabled = false
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
UPDATE igny8_keywords
|
||||||
|
SET status = 'mapped', disabled = false
|
||||||
|
WHERE status = 'active';
|
||||||
|
|
||||||
|
UPDATE igny8_keywords
|
||||||
|
SET status = 'mapped', disabled = true
|
||||||
|
WHERE status = 'archived';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Clusters Migration
|
||||||
|
|
||||||
|
| Old Status | New Status | New Disabled Flag | Logic |
|
||||||
|
|------------|------------|-------------------|-------|
|
||||||
|
| `active` (with ideas_count > 0) | `mapped` | `false` | Cluster has ideas |
|
||||||
|
| `active` (with ideas_count = 0) | `new` | `false` | Cluster has no ideas yet |
|
||||||
|
| *(no archived status exists)* | - | - | - |
|
||||||
|
|
||||||
|
**SQL Preview:**
|
||||||
|
```sql
|
||||||
|
-- Clusters migration
|
||||||
|
UPDATE igny8_clusters
|
||||||
|
SET status = 'mapped', disabled = false
|
||||||
|
WHERE ideas_count > 0;
|
||||||
|
|
||||||
|
UPDATE igny8_clusters
|
||||||
|
SET status = 'new', disabled = false
|
||||||
|
WHERE ideas_count = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ideas Migration
|
||||||
|
|
||||||
|
| Old Status | New Status | New Disabled Flag | Logic |
|
||||||
|
|------------|------------|-------------------|-------|
|
||||||
|
| `new` | `new` | `false` | No change |
|
||||||
|
| `scheduled` | `queued` | `false` | Rename to match writer workflow |
|
||||||
|
| `published` | `completed` | `false` | Publishing is separate deployment |
|
||||||
|
| *(no archived status)* | - | - | - |
|
||||||
|
|
||||||
|
**SQL Preview:**
|
||||||
|
```sql
|
||||||
|
-- Ideas migration
|
||||||
|
UPDATE igny8_content_ideas
|
||||||
|
SET status = 'new', disabled = false
|
||||||
|
WHERE status = 'new';
|
||||||
|
|
||||||
|
UPDATE igny8_content_ideas
|
||||||
|
SET status = 'queued', disabled = false
|
||||||
|
WHERE status = 'scheduled';
|
||||||
|
|
||||||
|
UPDATE igny8_content_ideas
|
||||||
|
SET status = 'completed', disabled = false
|
||||||
|
WHERE status = 'published';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📋 SUMMARY COMPARISON TABLE
|
||||||
|
|
||||||
|
## Before vs After
|
||||||
|
|
||||||
|
| Module | Current Status Values | New Status Values | Current Filter | New Filter |
|
||||||
|
|--------|----------------------|-------------------|----------------|------------|
|
||||||
|
| **Keywords** | `pending`, `active`, `archived` | `new`, `mapped` | Status dropdown | Status dropdown + disabled checkbox |
|
||||||
|
| **Clusters** | `active` (hardcoded, no choices) | `new`, `mapped` | Status dropdown | Status dropdown + disabled checkbox |
|
||||||
|
| **Ideas** | `new`, `scheduled`, `published` | `new`, `queued`, `completed` | Status dropdown | Status dropdown + disabled checkbox |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status Count Changes
|
||||||
|
|
||||||
|
| Module | Before | After | Change |
|
||||||
|
|--------|--------|-------|--------|
|
||||||
|
| **Keywords** | 3 status values | 2 workflow + 1 boolean flag | -1 status (simplified) |
|
||||||
|
| **Clusters** | 1 hardcoded value | 2 workflow + 1 boolean flag | +1 status (proper choices) |
|
||||||
|
| **Ideas** | 3 status values | 3 workflow + 1 boolean flag | Same count (renamed) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**END OF IMPLEMENTATION TABLES**
|
||||||
541
STATUS-UNIFIED-STRUCTURE-PLAN-v2.md
Normal file
541
STATUS-UNIFIED-STRUCTURE-PLAN-v2.md
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
# Unified Status Structure Plan - Keywords, Clusters, Ideas (CORRECTED)
|
||||||
|
|
||||||
|
**Date:** December 3, 2025
|
||||||
|
**Objective:** Implement unified, workflow-driven status values across all three modules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Key Concept: Status vs Workflow
|
||||||
|
|
||||||
|
### ✅ WORKFLOW STATUS (Auto-updated by AI/System)
|
||||||
|
These statuses represent the workflow progression:
|
||||||
|
- Keywords: `new` → `mapped`
|
||||||
|
- Clusters: `new` → `mapped`
|
||||||
|
- Ideas: `new` → `queued` → `completed`
|
||||||
|
|
||||||
|
### 🔘 OPTIONAL MANUAL STATUS (User control only)
|
||||||
|
`disabled` = **NOT a workflow step**, but a **filter status**
|
||||||
|
- Does NOT affect workflow
|
||||||
|
- Manually set by user only
|
||||||
|
- Excludes items from all automated processes
|
||||||
|
- Excludes from dashboard metrics/suggestions
|
||||||
|
- Can be toggled on/off anytime
|
||||||
|
|
||||||
|
**Important:** `disabled` is a **data attribute**, not a workflow state. Items with `disabled=true` are simply ignored by all processes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Module Status Definitions
|
||||||
|
|
||||||
|
### KEYWORDS Module
|
||||||
|
**Workflow Statuses:** `new` → `mapped`
|
||||||
|
**Optional Filter Status:** `disabled` (manual user control)
|
||||||
|
|
||||||
|
| Status | Type | Meaning | When Set | Process Behavior |
|
||||||
|
|--------|------|---------|----------|-----------------|
|
||||||
|
| **new** | Workflow | Keyword attached to site, awaiting cluster assignment | User adds keyword via import/UI | Included in auto-cluster AI suggestions |
|
||||||
|
| **mapped** | Workflow | Keyword assigned to a cluster | AI auto_cluster completes or manual cluster assignment | Can be used for idea generation; included in cluster-based workflows |
|
||||||
|
| **disabled** | Filter | Manually excluded from processes | User manually sets | Excluded from auto-cluster, idea generation, dashboard (optional) |
|
||||||
|
|
||||||
|
**Workflow Transition:**
|
||||||
|
```
|
||||||
|
new ──[AI auto_cluster]──> mapped
|
||||||
|
│
|
||||||
|
└──[User toggle]──> disabled (any time, no workflow impact)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CLUSTERS Module
|
||||||
|
**Workflow Statuses:** `new` → `mapped`
|
||||||
|
**Optional Filter Status:** `disabled` (manual user control)
|
||||||
|
|
||||||
|
| Status | Type | Meaning | When Set | Process Behavior |
|
||||||
|
|--------|------|---------|----------|-----------------|
|
||||||
|
| **new** | Workflow | Cluster created; ready for idea generation | AI clustering creates OR user manually creates | Included in auto-generate-ideas suggestions |
|
||||||
|
| **mapped** | Workflow | Ideas generated from this cluster | AI generate_ideas completes | Ideas are ready; can be queued to writer |
|
||||||
|
| **disabled** | Filter | Manually excluded from processes | User manually sets | Excluded from idea generation, suggestion lists, dashboard (optional) |
|
||||||
|
|
||||||
|
**Workflow Transition:**
|
||||||
|
```
|
||||||
|
new ──[AI generate_ideas]──> mapped
|
||||||
|
│
|
||||||
|
└──[User toggle]──> disabled (any time, no workflow impact)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### IDEAS Module
|
||||||
|
**Workflow Statuses:** `new` → `queued` → `completed`
|
||||||
|
**Optional Filter Status:** `disabled` (manual user control)
|
||||||
|
|
||||||
|
| Status | Type | Meaning | When Set | Process Behavior |
|
||||||
|
|--------|------|---------|----------|-----------------|
|
||||||
|
| **new** | Workflow | Idea generated by AI; awaiting queue to writer | AI generate_ideas creates | Included in bulk-queue suggestions |
|
||||||
|
| **queued** | Workflow | Queued to writer; Task record created in Writer module | User bulk-queues ideas to writer | Task is assigned; waiting for content generation |
|
||||||
|
| **completed** | Workflow | Content generated from task (tracked via Task.status='completed') | generate_content AI completes; Content record created | Final automated state; content ready for publishing/deployment |
|
||||||
|
| **disabled** | Filter | Manually excluded from processes | User manually sets | Excluded from queue suggestions, bulk operations, dashboard (optional) |
|
||||||
|
|
||||||
|
**Workflow Transition:**
|
||||||
|
```
|
||||||
|
new ──[User bulk_queue]──> queued ──[Writer AI: generate_content]──> completed
|
||||||
|
│
|
||||||
|
└──[User toggle]──> disabled (any time, no workflow impact)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note on `completed`:**
|
||||||
|
- When a Task's status becomes `completed`, the related Idea automatically becomes `completed`
|
||||||
|
- No separate `published` status needed; publishing is a separate content deployment action
|
||||||
|
- One idea = one content piece through task tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Process Exclusion Rules
|
||||||
|
|
||||||
|
### When `disabled=true` (filter status):
|
||||||
|
Items are **excluded from**:
|
||||||
|
- ✗ AI process suggestions (auto-cluster, generate-ideas)
|
||||||
|
- ✗ Bulk operation selections (queue to writer, generate content)
|
||||||
|
- ✗ Dashboard workflow metrics (unless explicitly shown)
|
||||||
|
- ✗ Progress calculations (% mapped, % queued, etc.)
|
||||||
|
|
||||||
|
### When workflow status applies:
|
||||||
|
- ✅ Included in relevant processes
|
||||||
|
- ✅ Included in dashboard metrics
|
||||||
|
- ✅ Included in AI suggestions
|
||||||
|
- ✅ Can be bulk-operated on
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Status Update Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
═══ KEYWORDS WORKFLOW ═══
|
||||||
|
1. User imports SeedKeywords
|
||||||
|
└─> Keywords created with status='new'
|
||||||
|
|
||||||
|
2. Auto-cluster AI runs
|
||||||
|
└─> Keywords assigned to clusters
|
||||||
|
└─> Keywords status changes to 'mapped'
|
||||||
|
|
||||||
|
3. [Optional] User manually disables keyword
|
||||||
|
└─> Keywords.disabled = true
|
||||||
|
└─> Excluded from all processes
|
||||||
|
|
||||||
|
|
||||||
|
═══ CLUSTERS WORKFLOW ═══
|
||||||
|
1. Auto-cluster AI creates/updates clusters
|
||||||
|
└─> Clusters created with status='new'
|
||||||
|
|
||||||
|
2. AI generate-ideas runs on 'new' clusters
|
||||||
|
└─> Ideas created for cluster
|
||||||
|
└─> Clusters status changes to 'mapped'
|
||||||
|
|
||||||
|
3. [Optional] User manually disables cluster
|
||||||
|
└─> Clusters.disabled = true
|
||||||
|
└─> Excluded from all processes
|
||||||
|
|
||||||
|
|
||||||
|
═══ IDEAS WORKFLOW ═══
|
||||||
|
1. AI generate-ideas creates ideas for clusters
|
||||||
|
└─> Ideas created with status='new'
|
||||||
|
|
||||||
|
2. User bulk-queues ideas to writer
|
||||||
|
└─> Task created in Writer module
|
||||||
|
└─> Ideas status changes to 'queued'
|
||||||
|
|
||||||
|
3. Writer AI (generate-content) creates content
|
||||||
|
└─> Content record created
|
||||||
|
└─> Task.status = 'completed'
|
||||||
|
└─> Ideas.status = 'completed' [Auto-sync from Task]
|
||||||
|
|
||||||
|
4. [Optional] User manually disables idea
|
||||||
|
└─> Ideas.disabled = true
|
||||||
|
└─> Excluded from all processes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Implementation Changes
|
||||||
|
|
||||||
|
### Backend Model Changes
|
||||||
|
|
||||||
|
#### 1. `/backend/igny8_core/business/planning/models.py`
|
||||||
|
|
||||||
|
**Keywords Model:**
|
||||||
|
```python
|
||||||
|
# REMOVE old STATUS_CHOICES
|
||||||
|
# OLD:
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('active', 'Active'),
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('archived', 'Archived'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# NEW:
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('new', 'New'),
|
||||||
|
('mapped', 'Mapped'),
|
||||||
|
]
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='new'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ADD new filter field:
|
||||||
|
disabled = models.BooleanField(default=False, help_text="Exclude from processes")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Clusters Model:**
|
||||||
|
```python
|
||||||
|
# ADD STATUS_CHOICES (currently hardcoded as default='active', no choices)
|
||||||
|
# CURRENT: status = models.CharField(max_length=50, default='active')
|
||||||
|
|
||||||
|
# NEW:
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('new', 'New'),
|
||||||
|
('mapped', 'Mapped'),
|
||||||
|
]
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='new'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ADD new filter field:
|
||||||
|
disabled = models.BooleanField(default=False, help_text="Exclude from processes")
|
||||||
|
```
|
||||||
|
|
||||||
|
**ContentIdeas Model:**
|
||||||
|
```python
|
||||||
|
# UPDATE STATUS_CHOICES
|
||||||
|
# REMOVE 'scheduled', 'published'
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('new', 'New'),
|
||||||
|
('queued', 'Queued'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
]
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='new'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ADD new filter field:
|
||||||
|
disabled = models.BooleanField(default=False, help_text="Exclude from processes")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Backend AI Function Updates
|
||||||
|
|
||||||
|
#### 2. `/backend/igny8_core/ai/functions/auto_cluster.py`
|
||||||
|
|
||||||
|
**Location: Line ~297 in save_output()**
|
||||||
|
```python
|
||||||
|
# CURRENT:
|
||||||
|
status='active'
|
||||||
|
|
||||||
|
# NEW:
|
||||||
|
status='mapped' # When keywords are assigned to cluster
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. `/backend/igny8_core/ai/functions/generate_ideas.py`
|
||||||
|
|
||||||
|
**Location: After ideas created in save_output()**
|
||||||
|
```python
|
||||||
|
# NEW: Add after creating ideas
|
||||||
|
# Update cluster status from 'new' to 'mapped' after ideas generated
|
||||||
|
for cluster in clusters_used:
|
||||||
|
if cluster.status == 'new':
|
||||||
|
cluster.status = 'mapped'
|
||||||
|
cluster.save()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. `/backend/igny8_core/ai/functions/generate_content.py`
|
||||||
|
|
||||||
|
**Location: Line ~318 in save_output()**
|
||||||
|
```python
|
||||||
|
# CURRENT:
|
||||||
|
task.status = 'completed'
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
# NEW: Auto-sync idea status from task
|
||||||
|
task.status = 'completed'
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
# NEW: Update related idea to 'completed'
|
||||||
|
if hasattr(task, 'idea') and task.idea:
|
||||||
|
idea = task.idea
|
||||||
|
idea.status = 'completed'
|
||||||
|
idea.save()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Backend API Updates
|
||||||
|
|
||||||
|
#### 5. `/backend/igny8_core/modules/planner/views.py`
|
||||||
|
|
||||||
|
**Queue ideas to writer (Line ~1084):**
|
||||||
|
```python
|
||||||
|
# CURRENT:
|
||||||
|
idea.status = 'scheduled'
|
||||||
|
|
||||||
|
# NEW:
|
||||||
|
idea.status = 'queued'
|
||||||
|
```
|
||||||
|
|
||||||
|
**CSV import defaults (Line ~563):**
|
||||||
|
```python
|
||||||
|
# CURRENT:
|
||||||
|
status=row.get('status', 'pending') or 'pending'
|
||||||
|
|
||||||
|
# NEW:
|
||||||
|
status=row.get('status', 'new') or 'new'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter out disabled items in list views:**
|
||||||
|
```python
|
||||||
|
# NEW: When returning lists, exclude disabled=true (optional, configurable)
|
||||||
|
queryset = queryset.filter(disabled=False) # Or show both with filter option
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Configuration Updates
|
||||||
|
|
||||||
|
#### 6. `/frontend/src/config/pages/keywords.config.tsx`
|
||||||
|
|
||||||
|
**Table Column - Status Badge (Lines ~230-248):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
new: 'amber', active: 'green', archived: 'red'
|
||||||
|
|
||||||
|
// NEW:
|
||||||
|
new: 'amber', mapped: 'green'
|
||||||
|
|
||||||
|
// ADD: Display 'disabled' badge if disabled=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter Dropdown (Lines ~310-318):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
options: ['pending', 'active', 'archived']
|
||||||
|
|
||||||
|
// NEW:
|
||||||
|
options: ['new', 'mapped'],
|
||||||
|
additionalFilters: [
|
||||||
|
{ key: 'disabled', label: 'Show Disabled', type: 'checkbox', default: false }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Form Field Default (Lines ~560-570):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
default: 'pending'
|
||||||
|
|
||||||
|
// NEW:
|
||||||
|
default: 'new'
|
||||||
|
|
||||||
|
// ADD: disabled checkbox in form
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 7. `/frontend/src/config/pages/clusters.config.tsx`
|
||||||
|
|
||||||
|
**Table Column - Status Badge (Lines ~190-200):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
(missing or wrong values)
|
||||||
|
|
||||||
|
// NEW:
|
||||||
|
new: 'amber', mapped: 'green'
|
||||||
|
|
||||||
|
// ADD: Display 'disabled' badge if disabled=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter Dropdown (Lines ~240-253):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
options: ['new', 'idea', 'mapped']
|
||||||
|
|
||||||
|
// NEW:
|
||||||
|
options: ['new', 'mapped'],
|
||||||
|
additionalFilters: [
|
||||||
|
{ key: 'disabled', label: 'Show Disabled', type: 'checkbox', default: false }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Form Field (Lines ~405-418):**
|
||||||
|
```typescript
|
||||||
|
// ADD: disabled checkbox in form
|
||||||
|
// Keep status default as 'new'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 8. `/frontend/src/config/pages/ideas.config.tsx`
|
||||||
|
|
||||||
|
**Table Column - Status Badge (Lines ~170-185):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
new: 'amber', scheduled: 'blue', completed: 'blue', published: 'green'
|
||||||
|
|
||||||
|
// NEW:
|
||||||
|
new: 'amber', queued: 'blue', completed: 'green'
|
||||||
|
|
||||||
|
// ADD: Display 'disabled' badge if disabled=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter Dropdown (Lines ~218-228):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
options: ['new', 'scheduled', 'completed', 'published']
|
||||||
|
|
||||||
|
// NEW:
|
||||||
|
options: ['new', 'queued', 'completed'],
|
||||||
|
additionalFilters: [
|
||||||
|
{ key: 'disabled', label: 'Show Disabled', type: 'checkbox', default: false }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Form Field (Lines ~372-385):**
|
||||||
|
```typescript
|
||||||
|
// REMOVE: 'published' option
|
||||||
|
// ADD: disabled checkbox in form
|
||||||
|
// Keep status default as 'new'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Dashboard
|
||||||
|
|
||||||
|
#### 9. `/frontend/src/pages/Planner/Dashboard.tsx`
|
||||||
|
|
||||||
|
**Status Metrics:**
|
||||||
|
```typescript
|
||||||
|
// Update calculations:
|
||||||
|
// - Keywords: new vs mapped
|
||||||
|
// - Clusters: new vs mapped
|
||||||
|
// - Ideas: new vs queued vs completed
|
||||||
|
|
||||||
|
// Optional: Add disabled count metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📍 Change Summary
|
||||||
|
|
||||||
|
### Total Files: 9
|
||||||
|
|
||||||
|
| File | Changes | Type |
|
||||||
|
|------|---------|------|
|
||||||
|
| **models.py** | Add STATUS_CHOICES for Clusters; Update Keywords/Ideas; Add `disabled` field to all 3 | Backend |
|
||||||
|
| **auto_cluster.py** | Line 297: `status='active'` → `status='mapped'` | Backend AI |
|
||||||
|
| **generate_ideas.py** | After ideas created: Set cluster `status='mapped'` | Backend AI |
|
||||||
|
| **generate_content.py** | Line 318: Also sync `idea.status='completed'` from task | Backend AI |
|
||||||
|
| **views.py** | Queue ideas (line 1084): `'scheduled'` → `'queued'`; CSV import: `'pending'` → `'new'` | Backend API |
|
||||||
|
| **keywords.config.tsx** | Update badge/filter/form for new/mapped; Add disabled checkbox | Frontend Config |
|
||||||
|
| **clusters.config.tsx** | Update badge/filter/form for new/mapped; Add disabled checkbox | Frontend Config |
|
||||||
|
| **ideas.config.tsx** | Update badge/filter/form for new/queued/completed; Add disabled checkbox | Frontend Config |
|
||||||
|
| **Dashboard.tsx** | Update metrics calculations for new status values | Frontend Page |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Workflow Validations
|
||||||
|
|
||||||
|
### Keywords
|
||||||
|
```
|
||||||
|
✓ new → mapped (via auto_cluster)
|
||||||
|
✓ Can toggle disabled at any time (no workflow impact)
|
||||||
|
✓ Disabled items excluded from auto-cluster suggestions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clusters
|
||||||
|
```
|
||||||
|
✓ new → mapped (via generate_ideas)
|
||||||
|
✓ Can toggle disabled at any time (no workflow impact)
|
||||||
|
✓ Disabled items excluded from idea generation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ideas
|
||||||
|
```
|
||||||
|
✓ new → queued (via bulk_queue_to_writer)
|
||||||
|
✓ queued → completed (via generate_content, tracked via Task.status)
|
||||||
|
✓ Can toggle disabled at any time (no workflow impact)
|
||||||
|
✓ Disabled items excluded from queue suggestions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗑️ Data Migration Strategy
|
||||||
|
|
||||||
|
### Django Migration File
|
||||||
|
**File:** `backend/igny8_core/business/planning/migrations/NNNN_unified_status_fields.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Forward migration:
|
||||||
|
# 1. Add disabled=BooleanField(default=False) to all 3 models
|
||||||
|
# 2. Add STATUS_CHOICES to Clusters model
|
||||||
|
# 3. Update Keywords.status data:
|
||||||
|
# - pending → new
|
||||||
|
# - active → mapped
|
||||||
|
# - archived → mapped + set disabled=True
|
||||||
|
# 4. Update Clusters.status data:
|
||||||
|
# - active (with ideas_count > 0) → mapped
|
||||||
|
# - active (with ideas_count = 0) → new
|
||||||
|
# 5. Update ContentIdeas.status data:
|
||||||
|
# - scheduled → queued
|
||||||
|
# - published → completed
|
||||||
|
# - new → new (no change)
|
||||||
|
|
||||||
|
# Reverse migration:
|
||||||
|
# 1. Remove disabled field from all 3 models
|
||||||
|
# 2. Restore old STATUS_CHOICES
|
||||||
|
# 3. Reverse data transformations:
|
||||||
|
# - Keywords: new→pending, mapped→active, disabled=True→archived
|
||||||
|
# - Clusters: Remove STATUS_CHOICES, set all to 'active'
|
||||||
|
# - Ideas: queued→scheduled, completed→published
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Keywords: status='new' by default
|
||||||
|
- [ ] Keywords: auto_cluster sets status='mapped'
|
||||||
|
- [ ] Clusters: status='new' by default
|
||||||
|
- [ ] Clusters: generate_ideas sets status='mapped'
|
||||||
|
- [ ] Ideas: status='new' by default
|
||||||
|
- [ ] Ideas: bulk_queue_to_writer sets status='queued'
|
||||||
|
- [ ] Ideas: generate_content sets status='completed' (via task sync)
|
||||||
|
- [ ] All modules: disabled=true excludes from processes
|
||||||
|
- [ ] All modules: disabled checkbox toggles correctly
|
||||||
|
- [ ] Dashboard: metrics use new status values
|
||||||
|
- [ ] Filters: disabled checkbox hides disabled items by default
|
||||||
|
- [ ] Migration: old data transforms correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Key Differences from Previous Plan
|
||||||
|
|
||||||
|
| Previous | Now Corrected |
|
||||||
|
|----------|---------------|
|
||||||
|
| `disabled` was workflow step | `disabled` is filter status only |
|
||||||
|
| 3 workflows statuses per module | 2 workflow statuses per module |
|
||||||
|
| Published status for Ideas | Completed is final; publish is separate action |
|
||||||
|
| Ideas tracked separately | Ideas auto-sync from Task status |
|
||||||
|
| Unclear disabled behavior | Clear: disabled excluded from all processes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status:** ✅ Plan Complete & Corrected
|
||||||
|
**Ready for:** Implementation Phase 1 (Backend Models)
|
||||||
|
|
||||||
526
STATUS-UNIFIED-STRUCTURE-PLAN.md
Normal file
526
STATUS-UNIFIED-STRUCTURE-PLAN.md
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
# Unified Status Structure Plan - Keywords, Clusters, Ideas
|
||||||
|
|
||||||
|
**Date:** December 3, 2025
|
||||||
|
**Objective:** Implement unified, workflow-driven status values across all three modules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Proposed Unified Status Structure
|
||||||
|
|
||||||
|
### Module-Specific Status Values
|
||||||
|
|
||||||
|
#### KEYWORDS Module
|
||||||
|
**Current:** `pending`, `active`, `archived`
|
||||||
|
**Proposed:** `new`, `mapped`, `disabled`
|
||||||
|
|
||||||
|
| Status | Meaning | When Used | Next Step |
|
||||||
|
|--------|---------|-----------|-----------|
|
||||||
|
| **new** | Keyword just attached to site, not yet assigned to cluster | User adds keyword | AI auto-cluster runs → `mapped` |
|
||||||
|
| **mapped** | Keyword is assigned to a cluster | AI clustering completes OR manual assignment | Can generate ideas from cluster |
|
||||||
|
| **disabled** | Keyword is archived/inactive | User manually disables | No workflow activity |
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
```
|
||||||
|
new → mapped → (can be used for ideas)
|
||||||
|
↓
|
||||||
|
disabled (any time)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### CLUSTERS Module
|
||||||
|
**Current:** `active`, `pending`, `archived`
|
||||||
|
**Proposed:** `new`, `mapped`, `disabled`
|
||||||
|
|
||||||
|
| Status | Meaning | When Used | Next Step |
|
||||||
|
|--------|---------|-----------|-----------|
|
||||||
|
| **new** | Cluster created but no content ideas generated yet | AI clustering creates cluster OR manual creation | AI generate_ideas runs → `mapped` |
|
||||||
|
| **mapped** | Cluster has ideas generated from it | AI generate_ideas completes | Ideas ready to queue to writer |
|
||||||
|
| **disabled** | Cluster is archived/inactive | User manually disables | No workflow activity |
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
```
|
||||||
|
new → mapped → (ideas queue to writer)
|
||||||
|
↓
|
||||||
|
disabled (any time)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### IDEAS Module
|
||||||
|
**Current:** `new`, `scheduled`, `completed`, `published`
|
||||||
|
**Proposed:** `new`, `queued`, `completed`, `published`
|
||||||
|
|
||||||
|
| Status | Meaning | When Used | Next Step |
|
||||||
|
|--------|---------|-----------|-----------|
|
||||||
|
| **new** | Idea generated by AI but not queued to writer yet | AI generate_ideas creates idea | User queues to writer → `queued` |
|
||||||
|
| **queued** | Idea queued to writer, task created in Writer module | Bulk queue to writer API called | Content generated by writer → `completed` |
|
||||||
|
| **completed** | Content generated from idea (Task completed with content) | generate_content AI function creates Content record | Manual publish → `published` |
|
||||||
|
| **published** | Content published to site | User publishes OR auto-publish | Final state |
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
```
|
||||||
|
new → queued → completed → published
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Status Update Flow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
KEYWORDS WORKFLOW:
|
||||||
|
1. User adds SeedKeyword → Keywords status='new'
|
||||||
|
2. AI auto_cluster runs → Keywords status='mapped' (assigned to cluster)
|
||||||
|
3. User can disable → Keywords status='disabled'
|
||||||
|
|
||||||
|
CLUSTERS WORKFLOW:
|
||||||
|
1. AI creates cluster → Clusters status='new'
|
||||||
|
2. AI generate_ideas runs → Clusters status='mapped' (has ideas)
|
||||||
|
3. User can disable → Clusters status='disabled'
|
||||||
|
|
||||||
|
IDEAS WORKFLOW:
|
||||||
|
1. AI creates idea → Ideas status='new'
|
||||||
|
2. User bulk queues ideas → Ideas status='queued' (task created in Writer)
|
||||||
|
3. Writer AI generates content → Ideas status='completed' (Content record created)
|
||||||
|
4. User publishes content → Ideas status='published'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Change Locations & Impact
|
||||||
|
|
||||||
|
### Backend Model Files to Update
|
||||||
|
|
||||||
|
#### 1. `/backend/igny8_core/business/planning/models.py`
|
||||||
|
|
||||||
|
**Keywords Model (Lines 42-46):**
|
||||||
|
```python
|
||||||
|
# CURRENT:
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('active', 'Active'),
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('archived', 'Archived'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# PROPOSED:
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('new', 'New'),
|
||||||
|
('mapped', 'Mapped'),
|
||||||
|
('disabled', 'Disabled'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# DEFAULT: Change from 'pending' → 'new'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Clusters Model (Line 12):**
|
||||||
|
```python
|
||||||
|
# CURRENT:
|
||||||
|
status = models.CharField(max_length=50, default='active')
|
||||||
|
|
||||||
|
# PROPOSED:
|
||||||
|
status = models.CharField(max_length=50, default='new',
|
||||||
|
choices=[('new', 'New'), ('mapped', 'Mapped'), ('disabled', 'Disabled')])
|
||||||
|
```
|
||||||
|
|
||||||
|
**ContentIdeas Model (Lines 150-157):**
|
||||||
|
```python
|
||||||
|
# CURRENT:
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('new', 'New'),
|
||||||
|
('scheduled', 'Scheduled'),
|
||||||
|
('published', 'Published'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# PROPOSED:
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('new', 'New'),
|
||||||
|
('queued', 'Queued'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('published', 'Published'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# DEFAULT: Keep 'new'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Backend AI Function Files to Update
|
||||||
|
|
||||||
|
#### 2. `/backend/igny8_core/ai/functions/auto_cluster.py` (Line 297)
|
||||||
|
|
||||||
|
**When keywords are assigned to clusters:**
|
||||||
|
```python
|
||||||
|
# CURRENT:
|
||||||
|
status='active'
|
||||||
|
|
||||||
|
# PROPOSED:
|
||||||
|
status='mapped'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Location:** In `save_output()` method when updating Keywords
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. `/backend/igny8_core/ai/functions/generate_ideas.py` (Line 224)
|
||||||
|
|
||||||
|
**When clusters change status after ideas generated:**
|
||||||
|
```python
|
||||||
|
# CURRENT: No cluster status update
|
||||||
|
|
||||||
|
# PROPOSED: After creating ideas, update cluster status:
|
||||||
|
# In save_output() method, after creating ideas:
|
||||||
|
cluster.status = 'mapped'
|
||||||
|
cluster.save()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Additional:** Ideas default stays `status='new'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. `/backend/igny8_core/ai/functions/generate_content.py` (Line 318)
|
||||||
|
|
||||||
|
**When content is generated from a task:**
|
||||||
|
```python
|
||||||
|
# CURRENT:
|
||||||
|
task.status = 'completed'
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
# PROPOSED: Also update the related Idea status:
|
||||||
|
task.status = 'completed'
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
# NEW: Update idea to completed
|
||||||
|
if hasattr(task, 'idea') and task.idea:
|
||||||
|
idea = task.idea
|
||||||
|
idea.status = 'completed'
|
||||||
|
idea.save()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Location:** In `save_output()` method after content is created
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Backend API Endpoint Files to Update
|
||||||
|
|
||||||
|
#### 5. `/backend/igny8_core/modules/planner/views.py`
|
||||||
|
|
||||||
|
**Line 1029 - Queue ideas to writer filter:**
|
||||||
|
```python
|
||||||
|
# CURRENT:
|
||||||
|
queueable_ideas = all_ideas.filter(status='new')
|
||||||
|
|
||||||
|
# NO CHANGE NEEDED (stays 'new')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Line 1084 - Update idea status when queued:**
|
||||||
|
```python
|
||||||
|
# CURRENT:
|
||||||
|
idea.status = 'scheduled'
|
||||||
|
idea.save()
|
||||||
|
|
||||||
|
# PROPOSED:
|
||||||
|
idea.status = 'queued'
|
||||||
|
idea.save()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Line 563 - Bulk create keywords from CSV:**
|
||||||
|
```python
|
||||||
|
# CURRENT:
|
||||||
|
status=row.get('status', 'pending') or 'pending'
|
||||||
|
|
||||||
|
# PROPOSED:
|
||||||
|
status=row.get('status', 'new') or 'new'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Configuration Files to Update
|
||||||
|
|
||||||
|
#### 6. `/frontend/src/config/pages/keywords.config.tsx`
|
||||||
|
|
||||||
|
**Table Column Status Badge (Lines 230-248):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
pending: 'amber', active: 'green', archived: 'red'
|
||||||
|
|
||||||
|
// PROPOSED:
|
||||||
|
new: 'amber', mapped: 'green', disabled: 'red'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter Dropdown (Lines 310-318):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
options: ['pending', 'active', 'archived']
|
||||||
|
|
||||||
|
// PROPOSED:
|
||||||
|
options: ['new', 'mapped', 'disabled']
|
||||||
|
```
|
||||||
|
|
||||||
|
**Form Field Default (Lines 560-570):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
default: 'pending'
|
||||||
|
|
||||||
|
// PROPOSED:
|
||||||
|
default: 'new'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 7. `/frontend/src/config/pages/clusters.config.tsx`
|
||||||
|
|
||||||
|
**Table Column Status Badge (Lines 190-200):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
active: 'green', archived: 'red' (inconsistent from backend)
|
||||||
|
|
||||||
|
// PROPOSED:
|
||||||
|
new: 'amber', mapped: 'green', disabled: 'red'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter Dropdown (Lines 240-253):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
options: ['active', 'archived'] (missing 'new')
|
||||||
|
|
||||||
|
// PROPOSED:
|
||||||
|
options: ['new', 'mapped', 'disabled']
|
||||||
|
```
|
||||||
|
|
||||||
|
**Form Field Default (Lines 405-418):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
default: 'new'
|
||||||
|
|
||||||
|
// PROPOSED:
|
||||||
|
default: 'new' (no change, already correct)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 8. `/frontend/src/config/pages/ideas.config.tsx`
|
||||||
|
|
||||||
|
**Table Column Status Badge (Lines 170-185):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
new: 'amber', scheduled: 'blue', completed: 'blue', published: 'green'
|
||||||
|
|
||||||
|
// PROPOSED:
|
||||||
|
new: 'amber', queued: 'blue', completed: 'blue', published: 'green'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter Dropdown (Lines 218-228):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
options: ['new', 'scheduled', 'completed', 'published']
|
||||||
|
// NOTE: missing 'completed' in some places
|
||||||
|
|
||||||
|
// PROPOSED:
|
||||||
|
options: ['new', 'queued', 'completed', 'published']
|
||||||
|
```
|
||||||
|
|
||||||
|
**Form Field Default (Lines 372-385):**
|
||||||
|
```typescript
|
||||||
|
// CURRENT:
|
||||||
|
default: 'new'
|
||||||
|
|
||||||
|
// PROPOSED:
|
||||||
|
default: 'new' (no change)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Dashboard & State Files
|
||||||
|
|
||||||
|
#### 9. `/frontend/src/pages/Planner/Dashboard.tsx`
|
||||||
|
|
||||||
|
**Status Metrics (Lines 121-155):**
|
||||||
|
```typescript
|
||||||
|
// Updates status counting logic
|
||||||
|
// From: keywordsByStatus, clustersByStatus, ideaByStatus
|
||||||
|
// To: Use new status values
|
||||||
|
|
||||||
|
// Example refactor:
|
||||||
|
// OLD: keywordsByStatus['active'] vs keywordsByStatus['pending']
|
||||||
|
// NEW: keywordsByStatus['mapped'] vs keywordsByStatus['new']
|
||||||
|
```
|
||||||
|
|
||||||
|
**No API call changes needed** - API returns correct values
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 10. `/frontend/src/services/api.ts`
|
||||||
|
|
||||||
|
**No changes needed** - Only returns what backend provides
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Component Files
|
||||||
|
|
||||||
|
#### 11. `/frontend/src/pages/Planner/Keywords.tsx`
|
||||||
|
|
||||||
|
**Status Filter Logic (if hardcoded):**
|
||||||
|
- Search for status comparisons
|
||||||
|
- Update from old values → new values
|
||||||
|
|
||||||
|
#### 12. `/frontend/src/pages/Planner/Clusters.tsx`
|
||||||
|
|
||||||
|
**Status Filter Logic (if hardcoded):**
|
||||||
|
- Search for status comparisons
|
||||||
|
- Update from old values → new values
|
||||||
|
|
||||||
|
#### 13. `/frontend/src/pages/Planner/Ideas.tsx`
|
||||||
|
|
||||||
|
**Status Filter Logic (if hardcoded):**
|
||||||
|
- Update from `scheduled` → `queued`
|
||||||
|
- Keep `new`, `completed`, `published`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Database Migration Files
|
||||||
|
|
||||||
|
#### 14. New Django Migration Required
|
||||||
|
|
||||||
|
**File to create:** `/backend/igny8_core/business/planning/migrations/NNNN_update_status_choices.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Migration tasks:
|
||||||
|
# 1. Update Keywords STATUS_CHOICES in model
|
||||||
|
# 2. Data migration: pending → new, active → mapped, archived → disabled
|
||||||
|
# 3. Update Clusters status field with choices
|
||||||
|
# 4. Data migration: active → new (or map by count of ideas)
|
||||||
|
# 5. Update ContentIdeas STATUS_CHOICES
|
||||||
|
# 6. Data migration: scheduled → queued
|
||||||
|
```
|
||||||
|
|
||||||
|
**Changes needed:**
|
||||||
|
- Keywords: pending→new, active→mapped, archived→disabled
|
||||||
|
- Clusters: active→new or mapped (based on ideas_count), archived→disabled
|
||||||
|
- ContentIdeas: scheduled→queued
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📍 Location Summary
|
||||||
|
|
||||||
|
### Total Files to Update: 13
|
||||||
|
|
||||||
|
| Category | Count | Files |
|
||||||
|
|----------|-------|-------|
|
||||||
|
| Backend Models | 1 | models.py |
|
||||||
|
| Backend AI Functions | 3 | auto_cluster.py, generate_ideas.py, generate_content.py |
|
||||||
|
| Backend API Views | 1 | views.py |
|
||||||
|
| Frontend Configs | 3 | keywords.config.tsx, clusters.config.tsx, ideas.config.tsx |
|
||||||
|
| Frontend Pages | 3 | Dashboard.tsx, Keywords.tsx, Clusters.tsx, Ideas.tsx |
|
||||||
|
| Database Migrations | 1 | New migration file |
|
||||||
|
| **TOTAL** | **13** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Dependency Order
|
||||||
|
|
||||||
|
### Phase 1: Backend Models & Data Migration (BLOCKER)
|
||||||
|
1. Update `models.py` STATUS_CHOICES for all 3 models
|
||||||
|
2. Create Django migration with data transformation
|
||||||
|
3. Run migration on database
|
||||||
|
4. Test: Backend API returns new status values
|
||||||
|
|
||||||
|
### Phase 2: AI Functions (Status Update Logic)
|
||||||
|
5. Update `auto_cluster.py` line 297: keywords status → 'mapped'
|
||||||
|
6. Add to `generate_ideas.py`: clusters status → 'mapped' after ideas created
|
||||||
|
7. Update `generate_content.py` line 318: ideas status → 'completed' after content created
|
||||||
|
8. Test: AI workflows set correct status values
|
||||||
|
|
||||||
|
### Phase 3: Backend API (Response Values)
|
||||||
|
9. Update `views.py` line 563: CSV import defaults to 'new'
|
||||||
|
10. Update `views.py` line 1084: queue to writer uses 'queued'
|
||||||
|
11. Test: API responses have new status values
|
||||||
|
|
||||||
|
### Phase 4: Frontend Configuration (Display Logic)
|
||||||
|
12. Update `keywords.config.tsx` (3 locations: badge, filter, form)
|
||||||
|
13. Update `clusters.config.tsx` (3 locations: badge, filter, form)
|
||||||
|
14. Update `ideas.config.tsx` (3 locations: badge, filter, form)
|
||||||
|
15. Test: Filters and forms display new status values
|
||||||
|
|
||||||
|
### Phase 5: Frontend Pages (State Management)
|
||||||
|
16. Update `Dashboard.tsx` status metrics calculations
|
||||||
|
17. Update `Keywords.tsx` status filters (if hardcoded)
|
||||||
|
18. Update `Clusters.tsx` status filters (if hardcoded)
|
||||||
|
19. Update `Ideas.tsx` status filters and comparisons
|
||||||
|
20. Test: Dashboard shows correct counts, filters work
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing Checklist
|
||||||
|
|
||||||
|
### Backend Tests
|
||||||
|
- [ ] Models accept new status values only (no old values allowed)
|
||||||
|
- [ ] Migration transforms data correctly
|
||||||
|
- [ ] Auto-cluster: Keywords change from new → mapped
|
||||||
|
- [ ] Generate ideas: Clusters change from new → mapped
|
||||||
|
- [ ] Generate content: Ideas change from queued → completed
|
||||||
|
- [ ] API returns new status values
|
||||||
|
|
||||||
|
### Frontend Tests
|
||||||
|
- [ ] Keywords: Table shows new/mapped/disabled badges with correct colors
|
||||||
|
- [ ] Keywords: Filter dropdown shows new/mapped/disabled options
|
||||||
|
- [ ] Keywords: Create form defaults to 'new'
|
||||||
|
- [ ] Clusters: Table shows new/mapped/disabled badges
|
||||||
|
- [ ] Clusters: Filter shows new/mapped/disabled options
|
||||||
|
- [ ] Clusters: Create form defaults to 'new'
|
||||||
|
- [ ] Ideas: Table shows new/queued/completed/published badges
|
||||||
|
- [ ] Ideas: Filter shows all 4 status options
|
||||||
|
- [ ] Ideas: Queue to writer changes status to 'queued'
|
||||||
|
- [ ] Dashboard: Metrics count correctly with new status values
|
||||||
|
- [ ] Dashboard: Workflow percentage bars calculate correctly
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- [ ] Full workflow: keyword → cluster → idea → queued → completed → published
|
||||||
|
- [ ] Status transitions are unidirectional (new→mapped, queued→completed, etc.)
|
||||||
|
- [ ] Disabled items don't appear in workflow suggestions
|
||||||
|
- [ ] Bulk operations update status correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Benefits of Unified Structure
|
||||||
|
|
||||||
|
1. **Consistency:** All modules follow same pattern (new → mapped → disabled)
|
||||||
|
2. **Clarity:** Status names reflect actual workflow state, not just data state
|
||||||
|
3. **Scalability:** Easy to add new statuses without confusion
|
||||||
|
4. **Maintainability:** Single place to understand entire status flow
|
||||||
|
5. **User Understanding:** Users immediately understand workflow progression
|
||||||
|
6. **AI Integration:** AI functions have clear status update points
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Breaking Changes Alert
|
||||||
|
|
||||||
|
### For API Consumers
|
||||||
|
- Old status values `pending`, `active`, `archived`, `scheduled` will no longer be accepted
|
||||||
|
- API will return only `new`, `mapped`, `disabled`, `queued`, `completed`, `published`
|
||||||
|
- Clients must update filtering logic
|
||||||
|
|
||||||
|
### For Database
|
||||||
|
- Existing records will be migrated with data transformation
|
||||||
|
- No data loss, but status values will change
|
||||||
|
- Recommended: Backup database before running migration
|
||||||
|
|
||||||
|
### For Frontend
|
||||||
|
- Old hardcoded status comparisons must be updated
|
||||||
|
- Filter options in dropdowns must match new values
|
||||||
|
- Status badges must use new color mappings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Rollback Plan
|
||||||
|
|
||||||
|
If issues arise:
|
||||||
|
1. Reverse Django migration (restores old status values and data)
|
||||||
|
2. Revert backend code to previous version
|
||||||
|
3. Revert frontend configs to previous version
|
||||||
|
4. Test API and UI return to old behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status:** Plan Complete - Ready for Implementation
|
||||||
|
**Next Step:** Execute Phase 1 (Backend Models & Migration)
|
||||||
|
|
||||||
@@ -318,6 +318,12 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
task.status = 'completed'
|
task.status = 'completed'
|
||||||
task.save(update_fields=['status', 'updated_at'])
|
task.save(update_fields=['status', 'updated_at'])
|
||||||
|
|
||||||
|
# NEW: Auto-sync idea status from task status
|
||||||
|
if hasattr(task, 'idea') and task.idea:
|
||||||
|
task.idea.status = 'completed'
|
||||||
|
task.idea.save(update_fields=['status', 'updated_at'])
|
||||||
|
logger.info(f"Updated related idea ID {task.idea.id} to completed")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'count': 1,
|
'count': 1,
|
||||||
'content_id': content_record.id,
|
'content_id': content_record.id,
|
||||||
|
|||||||
@@ -228,6 +228,11 @@ class GenerateIdeasFunction(BaseAIFunction):
|
|||||||
)
|
)
|
||||||
ideas_created += 1
|
ideas_created += 1
|
||||||
|
|
||||||
|
# Update cluster status to 'mapped' after ideas are generated
|
||||||
|
if cluster and cluster.status == 'new':
|
||||||
|
cluster.status = 'mapped'
|
||||||
|
cluster.save()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'count': ideas_created,
|
'count': ideas_created,
|
||||||
'ideas_created': ideas_created
|
'ideas_created': ideas_created
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
"""
|
|
||||||
Automation business logic - AutomationRule, ScheduledTask models and services
|
|
||||||
"""
|
|
||||||
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
"""
|
|
||||||
Automation Models
|
|
||||||
Phase 2: Automation System
|
|
||||||
"""
|
|
||||||
from django.db import models
|
|
||||||
from django.core.validators import MinValueValidator
|
|
||||||
from igny8_core.auth.models import SiteSectorBaseModel, AccountBaseModel
|
|
||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
class AutomationRule(SiteSectorBaseModel):
|
|
||||||
"""
|
|
||||||
Automation Rule model for defining automated workflows.
|
|
||||||
|
|
||||||
Rules can be triggered by:
|
|
||||||
- schedule: Time-based triggers (cron-like)
|
|
||||||
- event: Event-based triggers (content created, keyword added, etc.)
|
|
||||||
- manual: Manual execution only
|
|
||||||
"""
|
|
||||||
|
|
||||||
TRIGGER_CHOICES = [
|
|
||||||
('schedule', 'Schedule'),
|
|
||||||
('event', 'Event'),
|
|
||||||
('manual', 'Manual'),
|
|
||||||
]
|
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
('active', 'Active'),
|
|
||||||
('inactive', 'Inactive'),
|
|
||||||
('paused', 'Paused'),
|
|
||||||
]
|
|
||||||
|
|
||||||
name = models.CharField(max_length=255, help_text="Rule name")
|
|
||||||
description = models.TextField(blank=True, null=True, help_text="Rule description")
|
|
||||||
|
|
||||||
# Trigger configuration
|
|
||||||
trigger = models.CharField(max_length=50, choices=TRIGGER_CHOICES, default='manual')
|
|
||||||
|
|
||||||
# Schedule configuration (for schedule triggers)
|
|
||||||
# Stored as cron-like string: "0 0 * * *" (daily at midnight)
|
|
||||||
schedule = models.CharField(
|
|
||||||
max_length=100,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
help_text="Cron-like schedule string (e.g., '0 0 * * *' for daily at midnight)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Conditions (JSON field)
|
|
||||||
# Format: [{"field": "content.status", "operator": "equals", "value": "draft"}, ...]
|
|
||||||
conditions = models.JSONField(
|
|
||||||
default=list,
|
|
||||||
help_text="List of conditions that must be met for rule to execute"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Actions (JSON field)
|
|
||||||
# Format: [{"type": "generate_content", "params": {...}}, ...]
|
|
||||||
actions = models.JSONField(
|
|
||||||
default=list,
|
|
||||||
help_text="List of actions to execute when rule triggers"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Status
|
|
||||||
is_active = models.BooleanField(default=True, help_text="Whether rule is active")
|
|
||||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='active')
|
|
||||||
|
|
||||||
# Execution tracking
|
|
||||||
last_executed_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
execution_count = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
|
||||||
|
|
||||||
# Metadata
|
|
||||||
metadata = models.JSONField(default=dict, help_text="Additional metadata")
|
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
app_label = 'automation'
|
|
||||||
db_table = 'igny8_automation_rules'
|
|
||||||
ordering = ['-created_at']
|
|
||||||
verbose_name = 'Automation Rule'
|
|
||||||
verbose_name_plural = 'Automation Rules'
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['trigger', 'is_active']),
|
|
||||||
models.Index(fields=['status']),
|
|
||||||
models.Index(fields=['site', 'sector']),
|
|
||||||
models.Index(fields=['trigger', 'is_active', 'status']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.name} ({self.get_trigger_display()})"
|
|
||||||
|
|
||||||
|
|
||||||
class ScheduledTask(AccountBaseModel):
|
|
||||||
"""
|
|
||||||
Scheduled Task model for tracking scheduled automation rule executions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
('pending', 'Pending'),
|
|
||||||
('running', 'Running'),
|
|
||||||
('completed', 'Completed'),
|
|
||||||
('failed', 'Failed'),
|
|
||||||
('cancelled', 'Cancelled'),
|
|
||||||
]
|
|
||||||
|
|
||||||
automation_rule = models.ForeignKey(
|
|
||||||
AutomationRule,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='scheduled_tasks',
|
|
||||||
help_text="The automation rule this task belongs to"
|
|
||||||
)
|
|
||||||
|
|
||||||
scheduled_at = models.DateTimeField(help_text="When the task is scheduled to run")
|
|
||||||
executed_at = models.DateTimeField(null=True, blank=True, help_text="When the task was actually executed")
|
|
||||||
|
|
||||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='pending')
|
|
||||||
|
|
||||||
# Execution results
|
|
||||||
result = models.JSONField(default=dict, help_text="Execution result data")
|
|
||||||
error_message = models.TextField(blank=True, null=True, help_text="Error message if execution failed")
|
|
||||||
|
|
||||||
# Metadata
|
|
||||||
metadata = models.JSONField(default=dict, help_text="Additional metadata")
|
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
app_label = 'automation'
|
|
||||||
db_table = 'igny8_scheduled_tasks'
|
|
||||||
ordering = ['-scheduled_at']
|
|
||||||
verbose_name = 'Scheduled Task'
|
|
||||||
verbose_name_plural = 'Scheduled Tasks'
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['automation_rule', 'status']),
|
|
||||||
models.Index(fields=['scheduled_at', 'status']),
|
|
||||||
models.Index(fields=['account', 'status']),
|
|
||||||
models.Index(fields=['status', 'scheduled_at']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Scheduled task for {self.automation_rule.name} at {self.scheduled_at}"
|
|
||||||
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
"""
|
|
||||||
Automation services
|
|
||||||
"""
|
|
||||||
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"""
|
|
||||||
Action Executor
|
|
||||||
Executes rule actions
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from igny8_core.business.planning.services.clustering_service import ClusteringService
|
|
||||||
from igny8_core.business.planning.services.ideas_service import IdeasService
|
|
||||||
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ActionExecutor:
|
|
||||||
"""Executes rule actions"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.clustering_service = ClusteringService()
|
|
||||||
self.ideas_service = IdeasService()
|
|
||||||
self.content_service = ContentGenerationService()
|
|
||||||
|
|
||||||
def execute(self, action, context, rule):
|
|
||||||
"""
|
|
||||||
Execute a single action.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
action: Action dict with 'type' and 'params'
|
|
||||||
context: Context dict
|
|
||||||
rule: AutomationRule instance
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Action execution result
|
|
||||||
"""
|
|
||||||
action_type = action.get('type')
|
|
||||||
params = action.get('params', {})
|
|
||||||
|
|
||||||
if action_type == 'cluster_keywords':
|
|
||||||
return self._execute_cluster_keywords(params, rule)
|
|
||||||
elif action_type == 'generate_ideas':
|
|
||||||
return self._execute_generate_ideas(params, rule)
|
|
||||||
elif action_type == 'generate_content':
|
|
||||||
return self._execute_generate_content(params, rule)
|
|
||||||
else:
|
|
||||||
logger.warning(f"Unknown action type: {action_type}")
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'error': f'Unknown action type: {action_type}'
|
|
||||||
}
|
|
||||||
|
|
||||||
def _execute_cluster_keywords(self, params, rule):
|
|
||||||
"""Execute cluster keywords action"""
|
|
||||||
keyword_ids = params.get('keyword_ids', [])
|
|
||||||
sector_id = params.get('sector_id') or (rule.sector.id if rule.sector else None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = self.clustering_service.cluster_keywords(
|
|
||||||
keyword_ids=keyword_ids,
|
|
||||||
account=rule.account,
|
|
||||||
sector_id=sector_id
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error clustering keywords: {str(e)}", exc_info=True)
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def _execute_generate_ideas(self, params, rule):
|
|
||||||
"""Execute generate ideas action"""
|
|
||||||
cluster_ids = params.get('cluster_ids', [])
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = self.ideas_service.generate_ideas(
|
|
||||||
cluster_ids=cluster_ids,
|
|
||||||
account=rule.account
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error generating ideas: {str(e)}", exc_info=True)
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def _execute_generate_content(self, params, rule):
|
|
||||||
"""Execute generate content action"""
|
|
||||||
task_ids = params.get('task_ids', [])
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = self.content_service.generate_content(
|
|
||||||
task_ids=task_ids,
|
|
||||||
account=rule.account
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error generating content: {str(e)}", exc_info=True)
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
"""
|
|
||||||
Automation Service
|
|
||||||
Main service for executing automation rules
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from django.utils import timezone
|
|
||||||
from igny8_core.business.automation.models import AutomationRule, ScheduledTask
|
|
||||||
from igny8_core.business.automation.services.rule_engine import RuleEngine
|
|
||||||
from igny8_core.business.billing.services.credit_service import CreditService
|
|
||||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class AutomationService:
|
|
||||||
"""Service for executing automation rules"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.rule_engine = RuleEngine()
|
|
||||||
self.credit_service = CreditService()
|
|
||||||
|
|
||||||
def execute_rule(self, rule, context=None):
|
|
||||||
"""
|
|
||||||
Execute an automation rule.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
rule: AutomationRule instance
|
|
||||||
context: Optional context dict for condition evaluation
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Execution result with status and data
|
|
||||||
"""
|
|
||||||
if not rule.is_active or rule.status != 'active':
|
|
||||||
return {
|
|
||||||
'status': 'skipped',
|
|
||||||
'reason': 'Rule is inactive',
|
|
||||||
'rule_id': rule.id
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check credits (estimate based on actions)
|
|
||||||
estimated_credits = self._estimate_credits(rule)
|
|
||||||
try:
|
|
||||||
self.credit_service.check_credits_legacy(rule.account, estimated_credits)
|
|
||||||
except InsufficientCreditsError as e:
|
|
||||||
logger.warning(f"Rule {rule.id} skipped: {str(e)}")
|
|
||||||
return {
|
|
||||||
'status': 'skipped',
|
|
||||||
'reason': f'Insufficient credits: {str(e)}',
|
|
||||||
'rule_id': rule.id
|
|
||||||
}
|
|
||||||
|
|
||||||
# Execute via rule engine
|
|
||||||
try:
|
|
||||||
result = self.rule_engine.execute(rule, context or {})
|
|
||||||
|
|
||||||
# Update rule tracking
|
|
||||||
rule.last_executed_at = timezone.now()
|
|
||||||
rule.execution_count += 1
|
|
||||||
rule.save(update_fields=['last_executed_at', 'execution_count'])
|
|
||||||
|
|
||||||
return {
|
|
||||||
'status': 'completed',
|
|
||||||
'rule_id': rule.id,
|
|
||||||
'result': result
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error executing rule {rule.id}: {str(e)}", exc_info=True)
|
|
||||||
return {
|
|
||||||
'status': 'failed',
|
|
||||||
'reason': str(e),
|
|
||||||
'rule_id': rule.id
|
|
||||||
}
|
|
||||||
|
|
||||||
def _estimate_credits(self, rule):
|
|
||||||
"""Estimate credits needed for rule execution"""
|
|
||||||
# Simple estimation based on action types
|
|
||||||
estimated = 0
|
|
||||||
for action in rule.actions:
|
|
||||||
action_type = action.get('type', '')
|
|
||||||
if 'cluster' in action_type:
|
|
||||||
estimated += 10
|
|
||||||
elif 'idea' in action_type:
|
|
||||||
estimated += 15
|
|
||||||
elif 'content' in action_type:
|
|
||||||
estimated += 50 # Conservative estimate
|
|
||||||
else:
|
|
||||||
estimated += 5 # Default
|
|
||||||
return max(estimated, 10) # Minimum 10 credits
|
|
||||||
|
|
||||||
def execute_scheduled_rules(self):
|
|
||||||
"""
|
|
||||||
Execute all scheduled rules that are due.
|
|
||||||
Called by Celery Beat task.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Summary of executions
|
|
||||||
"""
|
|
||||||
from django.utils import timezone
|
|
||||||
now = timezone.now()
|
|
||||||
|
|
||||||
# Get active scheduled rules
|
|
||||||
rules = AutomationRule.objects.filter(
|
|
||||||
trigger='schedule',
|
|
||||||
is_active=True,
|
|
||||||
status='active'
|
|
||||||
)
|
|
||||||
|
|
||||||
executed = 0
|
|
||||||
skipped = 0
|
|
||||||
failed = 0
|
|
||||||
|
|
||||||
for rule in rules:
|
|
||||||
# Check if rule should execute based on schedule
|
|
||||||
if self._should_execute_schedule(rule, now):
|
|
||||||
result = self.execute_rule(rule)
|
|
||||||
if result['status'] == 'completed':
|
|
||||||
executed += 1
|
|
||||||
elif result['status'] == 'skipped':
|
|
||||||
skipped += 1
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
'executed': executed,
|
|
||||||
'skipped': skipped,
|
|
||||||
'failed': failed,
|
|
||||||
'total': len(rules)
|
|
||||||
}
|
|
||||||
|
|
||||||
def _should_execute_schedule(self, rule, now):
|
|
||||||
"""
|
|
||||||
Check if a scheduled rule should execute now.
|
|
||||||
Simple implementation - can be enhanced with proper cron parsing.
|
|
||||||
"""
|
|
||||||
if not rule.schedule:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# For now, simple check - can be enhanced with cron parser
|
|
||||||
# This is a placeholder - proper implementation would parse cron string
|
|
||||||
return True # Simplified for now
|
|
||||||
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
"""
|
|
||||||
Condition Evaluator
|
|
||||||
Evaluates rule conditions
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ConditionEvaluator:
|
|
||||||
"""Evaluates rule conditions"""
|
|
||||||
|
|
||||||
OPERATORS = {
|
|
||||||
'equals': lambda a, b: a == b,
|
|
||||||
'not_equals': lambda a, b: a != b,
|
|
||||||
'greater_than': lambda a, b: a > b,
|
|
||||||
'greater_than_or_equal': lambda a, b: a >= b,
|
|
||||||
'less_than': lambda a, b: a < b,
|
|
||||||
'less_than_or_equal': lambda a, b: a <= b,
|
|
||||||
'in': lambda a, b: a in b,
|
|
||||||
'contains': lambda a, b: b in a if isinstance(a, str) else a in b,
|
|
||||||
'is_empty': lambda a, b: not a or (isinstance(a, str) and not a.strip()),
|
|
||||||
'is_not_empty': lambda a, b: a and (not isinstance(a, str) or a.strip()),
|
|
||||||
}
|
|
||||||
|
|
||||||
def evaluate(self, conditions, context):
|
|
||||||
"""
|
|
||||||
Evaluate a list of conditions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conditions: List of condition dicts
|
|
||||||
context: Context dict for field resolution
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if all conditions are met
|
|
||||||
"""
|
|
||||||
if not conditions:
|
|
||||||
return True
|
|
||||||
|
|
||||||
for condition in conditions:
|
|
||||||
if not self._evaluate_condition(condition, context):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _evaluate_condition(self, condition, context):
|
|
||||||
"""
|
|
||||||
Evaluate a single condition.
|
|
||||||
|
|
||||||
Condition format:
|
|
||||||
{
|
|
||||||
"field": "content.status",
|
|
||||||
"operator": "equals",
|
|
||||||
"value": "draft"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
field_path = condition.get('field')
|
|
||||||
operator = condition.get('operator', 'equals')
|
|
||||||
expected_value = condition.get('value')
|
|
||||||
|
|
||||||
if not field_path:
|
|
||||||
logger.warning("Condition missing 'field'")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Resolve field value from context
|
|
||||||
actual_value = self._resolve_field(field_path, context)
|
|
||||||
|
|
||||||
# Get operator function
|
|
||||||
op_func = self.OPERATORS.get(operator)
|
|
||||||
if not op_func:
|
|
||||||
logger.warning(f"Unknown operator: {operator}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Evaluate
|
|
||||||
try:
|
|
||||||
return op_func(actual_value, expected_value)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error evaluating condition: {str(e)}", exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _resolve_field(self, field_path, context):
|
|
||||||
"""
|
|
||||||
Resolve a field path from context.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- "content.status" -> context['content']['status']
|
|
||||||
- "count" -> context['count']
|
|
||||||
"""
|
|
||||||
parts = field_path.split('.')
|
|
||||||
value = context
|
|
||||||
|
|
||||||
for part in parts:
|
|
||||||
if isinstance(value, dict):
|
|
||||||
value = value.get(part)
|
|
||||||
elif hasattr(value, part):
|
|
||||||
value = getattr(value, part)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"""
|
|
||||||
Rule Engine
|
|
||||||
Orchestrates rule execution
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from igny8_core.business.automation.services.condition_evaluator import ConditionEvaluator
|
|
||||||
from igny8_core.business.automation.services.action_executor import ActionExecutor
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class RuleEngine:
|
|
||||||
"""Orchestrates rule execution"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.condition_evaluator = ConditionEvaluator()
|
|
||||||
self.action_executor = ActionExecutor()
|
|
||||||
|
|
||||||
def execute(self, rule, context):
|
|
||||||
"""
|
|
||||||
Execute a rule by evaluating conditions and executing actions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
rule: AutomationRule instance
|
|
||||||
context: Context dict for evaluation
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Execution results
|
|
||||||
"""
|
|
||||||
# Evaluate conditions
|
|
||||||
if rule.conditions:
|
|
||||||
conditions_met = self.condition_evaluator.evaluate(rule.conditions, context)
|
|
||||||
if not conditions_met:
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'reason': 'Conditions not met'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Execute actions
|
|
||||||
action_results = []
|
|
||||||
for action in rule.actions:
|
|
||||||
try:
|
|
||||||
result = self.action_executor.execute(action, context, rule)
|
|
||||||
action_results.append({
|
|
||||||
'action': action,
|
|
||||||
'success': True,
|
|
||||||
'result': result
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Action execution failed: {str(e)}", exc_info=True)
|
|
||||||
action_results.append({
|
|
||||||
'action': action,
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
'success': True,
|
|
||||||
'actions': action_results
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""
|
|
||||||
Automation Celery Tasks
|
|
||||||
"""
|
|
||||||
from celery import shared_task
|
|
||||||
import logging
|
|
||||||
from igny8_core.business.automation.services.automation_service import AutomationService
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name='igny8_core.business.automation.tasks.execute_scheduled_automation_rules')
|
|
||||||
def execute_scheduled_automation_rules():
|
|
||||||
"""
|
|
||||||
Execute all scheduled automation rules.
|
|
||||||
Called by Celery Beat.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
service = AutomationService()
|
|
||||||
result = service.execute_scheduled_rules()
|
|
||||||
logger.info(f"Executed scheduled automation rules: {result}")
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error executing scheduled automation rules: {str(e)}", exc_info=True)
|
|
||||||
return {
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -5,12 +5,18 @@ from igny8_core.auth.models import SiteSectorBaseModel, SeedKeyword
|
|||||||
class Clusters(SiteSectorBaseModel):
|
class Clusters(SiteSectorBaseModel):
|
||||||
"""Clusters model for keyword grouping - pure topic clusters"""
|
"""Clusters model for keyword grouping - pure topic clusters"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('new', 'New'),
|
||||||
|
('mapped', 'Mapped'),
|
||||||
|
]
|
||||||
|
|
||||||
name = models.CharField(max_length=255, unique=True, db_index=True)
|
name = models.CharField(max_length=255, unique=True, db_index=True)
|
||||||
description = models.TextField(blank=True, null=True)
|
description = models.TextField(blank=True, null=True)
|
||||||
keywords_count = models.IntegerField(default=0)
|
keywords_count = models.IntegerField(default=0)
|
||||||
volume = models.IntegerField(default=0)
|
volume = models.IntegerField(default=0)
|
||||||
mapped_pages = models.IntegerField(default=0)
|
mapped_pages = models.IntegerField(default=0)
|
||||||
status = models.CharField(max_length=50, default='active')
|
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='new')
|
||||||
|
disabled = models.BooleanField(default=False, help_text="Exclude from processes")
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -37,9 +43,8 @@ class Keywords(SiteSectorBaseModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('active', 'Active'),
|
('new', 'New'),
|
||||||
('pending', 'Pending'),
|
('mapped', 'Mapped'),
|
||||||
('archived', 'Archived'),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Required: Link to global SeedKeyword
|
# Required: Link to global SeedKeyword
|
||||||
@@ -75,7 +80,8 @@ class Keywords(SiteSectorBaseModel):
|
|||||||
related_name='keywords',
|
related_name='keywords',
|
||||||
limit_choices_to={'sector': models.F('sector')} # Cluster must be in same sector
|
limit_choices_to={'sector': models.F('sector')} # Cluster must be in same sector
|
||||||
)
|
)
|
||||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='pending')
|
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='new')
|
||||||
|
disabled = models.BooleanField(default=False, help_text="Exclude from processes")
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -142,8 +148,8 @@ class ContentIdeas(SiteSectorBaseModel):
|
|||||||
|
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('new', 'New'),
|
('new', 'New'),
|
||||||
('scheduled', 'Scheduled'),
|
('queued', 'Queued'),
|
||||||
('published', 'Published'),
|
('completed', 'Completed'),
|
||||||
]
|
]
|
||||||
|
|
||||||
CONTENT_TYPE_CHOICES = [
|
CONTENT_TYPE_CHOICES = [
|
||||||
@@ -193,6 +199,7 @@ class ContentIdeas(SiteSectorBaseModel):
|
|||||||
)
|
)
|
||||||
# REMOVED: taxonomy FK to SiteBlueprintTaxonomy (legacy blueprint functionality)
|
# REMOVED: taxonomy FK to SiteBlueprintTaxonomy (legacy blueprint functionality)
|
||||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='new')
|
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='new')
|
||||||
|
disabled = models.BooleanField(default=False, help_text="Exclude from processes")
|
||||||
estimated_word_count = models.IntegerField(default=1000)
|
estimated_word_count = models.IntegerField(default=1000)
|
||||||
content_type = models.CharField(
|
content_type = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
|
|||||||
@@ -25,10 +25,6 @@ app.conf.beat_schedule = {
|
|||||||
'task': 'igny8_core.modules.billing.tasks.replenish_monthly_credits',
|
'task': 'igny8_core.modules.billing.tasks.replenish_monthly_credits',
|
||||||
'schedule': crontab(hour=0, minute=0, day_of_month=1), # First day of month at midnight
|
'schedule': crontab(hour=0, minute=0, day_of_month=1), # First day of month at midnight
|
||||||
},
|
},
|
||||||
'execute-scheduled-automation-rules': {
|
|
||||||
'task': 'igny8_core.business.automation.tasks.execute_scheduled_automation_rules',
|
|
||||||
'schedule': crontab(minute='*/5'), # Every 5 minutes
|
|
||||||
},
|
|
||||||
# WordPress Publishing Tasks
|
# WordPress Publishing Tasks
|
||||||
'process-pending-wordpress-publications': {
|
'process-pending-wordpress-publications': {
|
||||||
'task': 'igny8_core.tasks.wordpress_publishing.process_pending_wordpress_publications',
|
'task': 'igny8_core.tasks.wordpress_publishing.process_pending_wordpress_publications',
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""
|
|
||||||
Automation Module - API Layer
|
|
||||||
Business logic is in business/automation/
|
|
||||||
"""
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
"""
|
|
||||||
Automation App Configuration
|
|
||||||
"""
|
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class AutomationConfig(AppConfig):
|
|
||||||
"""Configuration for automation module"""
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'igny8_core.modules.automation'
|
|
||||||
label = 'automation'
|
|
||||||
verbose_name = 'Automation'
|
|
||||||
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Generated by Django 5.2.8 on 2025-11-20 23:27
|
|
||||||
|
|
||||||
import django.core.validators
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='AutomationRule',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(help_text='Rule name', max_length=255)),
|
|
||||||
('description', models.TextField(blank=True, help_text='Rule description', null=True)),
|
|
||||||
('trigger', models.CharField(choices=[('schedule', 'Schedule'), ('event', 'Event'), ('manual', 'Manual')], default='manual', max_length=50)),
|
|
||||||
('schedule', models.CharField(blank=True, help_text="Cron-like schedule string (e.g., '0 0 * * *' for daily at midnight)", max_length=100, null=True)),
|
|
||||||
('conditions', models.JSONField(default=list, help_text='List of conditions that must be met for rule to execute')),
|
|
||||||
('actions', models.JSONField(default=list, help_text='List of actions to execute when rule triggers')),
|
|
||||||
('is_active', models.BooleanField(default=True, help_text='Whether rule is active')),
|
|
||||||
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('paused', 'Paused')], default='active', max_length=50)),
|
|
||||||
('last_executed_at', models.DateTimeField(blank=True, null=True)),
|
|
||||||
('execution_count', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
|
|
||||||
('metadata', models.JSONField(default=dict, help_text='Additional metadata')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Automation Rule',
|
|
||||||
'verbose_name_plural': 'Automation Rules',
|
|
||||||
'db_table': 'igny8_automation_rules',
|
|
||||||
'ordering': ['-created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ScheduledTask',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('scheduled_at', models.DateTimeField(help_text='When the task is scheduled to run')),
|
|
||||||
('executed_at', models.DateTimeField(blank=True, help_text='When the task was actually executed', null=True)),
|
|
||||||
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=50)),
|
|
||||||
('result', models.JSONField(default=dict, help_text='Execution result data')),
|
|
||||||
('error_message', models.TextField(blank=True, help_text='Error message if execution failed', null=True)),
|
|
||||||
('metadata', models.JSONField(default=dict, help_text='Additional metadata')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Scheduled Task',
|
|
||||||
'verbose_name_plural': 'Scheduled Tasks',
|
|
||||||
'db_table': 'igny8_scheduled_tasks',
|
|
||||||
'ordering': ['-scheduled_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# Generated by Django 5.2.8 on 2025-11-20 23:27
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('automation', '0001_initial'),
|
|
||||||
('igny8_core_auth', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='automationrule',
|
|
||||||
name='account',
|
|
||||||
field=models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='automationrule',
|
|
||||||
name='sector',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.sector'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='automationrule',
|
|
||||||
name='site',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.site'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='scheduledtask',
|
|
||||||
name='account',
|
|
||||||
field=models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='scheduledtask',
|
|
||||||
name='automation_rule',
|
|
||||||
field=models.ForeignKey(help_text='The automation rule this task belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_tasks', to='automation.automationrule'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='automationrule',
|
|
||||||
index=models.Index(fields=['trigger', 'is_active'], name='igny8_autom_trigger_32979f_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='automationrule',
|
|
||||||
index=models.Index(fields=['status'], name='igny8_autom_status_827c0d_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='automationrule',
|
|
||||||
index=models.Index(fields=['site', 'sector'], name='igny8_autom_site_id_d0a51d_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='automationrule',
|
|
||||||
index=models.Index(fields=['trigger', 'is_active', 'status'], name='igny8_autom_trigger_f3f3e2_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='scheduledtask',
|
|
||||||
index=models.Index(fields=['automation_rule', 'status'], name='igny8_sched_automat_da6c85_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='scheduledtask',
|
|
||||||
index=models.Index(fields=['scheduled_at', 'status'], name='igny8_sched_schedul_1e3342_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='scheduledtask',
|
|
||||||
index=models.Index(fields=['account', 'status'], name='igny8_sched_tenant__7244a8_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='scheduledtask',
|
|
||||||
index=models.Index(fields=['status', 'scheduled_at'], name='igny8_sched_status_21f32f_idx'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Backward compatibility alias - models moved to business/automation/
|
|
||||||
from igny8_core.business.automation.models import AutomationRule, ScheduledTask
|
|
||||||
|
|
||||||
__all__ = ['AutomationRule', 'ScheduledTask']
|
|
||||||
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""
|
|
||||||
Serializers for Automation Models
|
|
||||||
"""
|
|
||||||
from rest_framework import serializers
|
|
||||||
from igny8_core.business.automation.models import AutomationRule, ScheduledTask
|
|
||||||
|
|
||||||
|
|
||||||
class AutomationRuleSerializer(serializers.ModelSerializer):
|
|
||||||
"""Serializer for AutomationRule model"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = AutomationRule
|
|
||||||
fields = [
|
|
||||||
'id', 'name', 'description', 'trigger', 'schedule',
|
|
||||||
'conditions', 'actions', 'is_active', 'status',
|
|
||||||
'last_executed_at', 'execution_count',
|
|
||||||
'metadata', 'created_at', 'updated_at',
|
|
||||||
'account', 'site', 'sector'
|
|
||||||
]
|
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at', 'last_executed_at', 'execution_count']
|
|
||||||
|
|
||||||
|
|
||||||
class ScheduledTaskSerializer(serializers.ModelSerializer):
|
|
||||||
"""Serializer for ScheduledTask model"""
|
|
||||||
automation_rule_name = serializers.CharField(source='automation_rule.name', read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ScheduledTask
|
|
||||||
fields = [
|
|
||||||
'id', 'automation_rule', 'automation_rule_name',
|
|
||||||
'scheduled_at', 'executed_at', 'status',
|
|
||||||
'result', 'error_message', 'metadata',
|
|
||||||
'created_at', 'updated_at', 'account'
|
|
||||||
]
|
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at', 'executed_at']
|
|
||||||
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""
|
|
||||||
URL patterns for automation module.
|
|
||||||
"""
|
|
||||||
from django.urls import path, include
|
|
||||||
from rest_framework.routers import DefaultRouter
|
|
||||||
from .views import AutomationRuleViewSet, ScheduledTaskViewSet
|
|
||||||
|
|
||||||
router = DefaultRouter()
|
|
||||||
router.register(r'rules', AutomationRuleViewSet, basename='automation-rule')
|
|
||||||
router.register(r'scheduled-tasks', ScheduledTaskViewSet, basename='scheduled-task')
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('', include(router.urls)),
|
|
||||||
]
|
|
||||||
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""
|
|
||||||
ViewSets for Automation Models
|
|
||||||
Unified API Standard v1.0 compliant
|
|
||||||
"""
|
|
||||||
from rest_framework import viewsets, status
|
|
||||||
from rest_framework.decorators import action
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
|
||||||
from rest_framework import filters
|
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
|
||||||
from igny8_core.api.base import SiteSectorModelViewSet, AccountModelViewSet
|
|
||||||
from igny8_core.api.pagination import CustomPageNumberPagination
|
|
||||||
from igny8_core.api.response import success_response, error_response
|
|
||||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
|
||||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsViewerOrAbove
|
|
||||||
from igny8_core.business.automation.models import AutomationRule, ScheduledTask
|
|
||||||
from igny8_core.business.automation.services.automation_service import AutomationService
|
|
||||||
from .serializers import AutomationRuleSerializer, ScheduledTaskSerializer
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
|
||||||
list=extend_schema(tags=['Automation']),
|
|
||||||
create=extend_schema(tags=['Automation']),
|
|
||||||
retrieve=extend_schema(tags=['Automation']),
|
|
||||||
update=extend_schema(tags=['Automation']),
|
|
||||||
partial_update=extend_schema(tags=['Automation']),
|
|
||||||
destroy=extend_schema(tags=['Automation']),
|
|
||||||
)
|
|
||||||
class AutomationRuleViewSet(SiteSectorModelViewSet):
|
|
||||||
"""
|
|
||||||
ViewSet for managing automation rules
|
|
||||||
Unified API Standard v1.0 compliant
|
|
||||||
"""
|
|
||||||
queryset = AutomationRule.objects.all()
|
|
||||||
serializer_class = AutomationRuleSerializer
|
|
||||||
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
|
|
||||||
pagination_class = CustomPageNumberPagination
|
|
||||||
throttle_scope = 'automation'
|
|
||||||
throttle_classes = [DebugScopedRateThrottle]
|
|
||||||
|
|
||||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
|
||||||
search_fields = ['name', 'description']
|
|
||||||
ordering_fields = ['name', 'created_at', 'last_executed_at', 'execution_count']
|
|
||||||
ordering = ['-created_at']
|
|
||||||
filterset_fields = ['trigger', 'is_active', 'status']
|
|
||||||
|
|
||||||
@action(detail=True, methods=['post'], url_path='execute', url_name='execute')
|
|
||||||
def execute(self, request, pk=None):
|
|
||||||
"""Manually execute an automation rule"""
|
|
||||||
rule = self.get_object()
|
|
||||||
service = AutomationService()
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = service.execute_rule(rule, context=request.data.get('context', {}))
|
|
||||||
return success_response(
|
|
||||||
data=result,
|
|
||||||
message='Rule executed successfully',
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return error_response(
|
|
||||||
error=str(e),
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
|
||||||
list=extend_schema(tags=['Automation']),
|
|
||||||
create=extend_schema(tags=['Automation']),
|
|
||||||
retrieve=extend_schema(tags=['Automation']),
|
|
||||||
update=extend_schema(tags=['Automation']),
|
|
||||||
partial_update=extend_schema(tags=['Automation']),
|
|
||||||
destroy=extend_schema(tags=['Automation']),
|
|
||||||
)
|
|
||||||
class ScheduledTaskViewSet(AccountModelViewSet):
|
|
||||||
"""
|
|
||||||
ViewSet for managing scheduled tasks
|
|
||||||
Unified API Standard v1.0 compliant
|
|
||||||
"""
|
|
||||||
queryset = ScheduledTask.objects.select_related('automation_rule')
|
|
||||||
serializer_class = ScheduledTaskSerializer
|
|
||||||
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
|
|
||||||
pagination_class = CustomPageNumberPagination
|
|
||||||
throttle_scope = 'automation'
|
|
||||||
throttle_classes = [DebugScopedRateThrottle]
|
|
||||||
|
|
||||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
|
||||||
ordering_fields = ['scheduled_at', 'executed_at', 'status', 'created_at']
|
|
||||||
ordering = ['-scheduled_at']
|
|
||||||
filterset_fields = ['automation_rule', 'status']
|
|
||||||
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Generated migration for unified status refactor
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_status_data(apps, schema_editor):
|
||||||
|
"""Transform existing status data to new values"""
|
||||||
|
Keywords = apps.get_model('planner', 'Keywords')
|
||||||
|
Clusters = apps.get_model('planner', 'Clusters')
|
||||||
|
ContentIdeas = apps.get_model('planner', 'ContentIdeas')
|
||||||
|
|
||||||
|
# Keywords: pending→new, active→mapped, archived→mapped+disabled=true
|
||||||
|
Keywords.objects.filter(status='pending').update(status='new')
|
||||||
|
Keywords.objects.filter(status='active').update(status='mapped')
|
||||||
|
# Handle archived: set to mapped and mark as disabled
|
||||||
|
archived_keywords = Keywords.objects.filter(status='archived')
|
||||||
|
for kw in archived_keywords:
|
||||||
|
kw.status = 'mapped'
|
||||||
|
kw.disabled = True
|
||||||
|
kw.save()
|
||||||
|
|
||||||
|
# Clusters: active (with ideas)→mapped, active (no ideas)→new
|
||||||
|
# Check if cluster has any related ideas using the reverse relationship
|
||||||
|
for cluster in Clusters.objects.all():
|
||||||
|
if cluster.ideas.exists():
|
||||||
|
cluster.status = 'mapped'
|
||||||
|
else:
|
||||||
|
cluster.status = 'new'
|
||||||
|
cluster.save()
|
||||||
|
|
||||||
|
# ContentIdeas: scheduled→queued, published→completed, new stays new
|
||||||
|
ContentIdeas.objects.filter(status='scheduled').update(status='queued')
|
||||||
|
ContentIdeas.objects.filter(status='published').update(status='completed')
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_status_data(apps, schema_editor):
|
||||||
|
"""Reverse migration: restore old status values"""
|
||||||
|
Keywords = apps.get_model('planner', 'Keywords')
|
||||||
|
Clusters = apps.get_model('planner', 'Clusters')
|
||||||
|
ContentIdeas = apps.get_model('planner', 'ContentIdeas')
|
||||||
|
|
||||||
|
# Keywords: new→pending, mapped→active (or archived if disabled)
|
||||||
|
Keywords.objects.filter(status='new').update(status='pending')
|
||||||
|
Keywords.objects.filter(status='mapped', disabled=False).update(status='active')
|
||||||
|
Keywords.objects.filter(status='mapped', disabled=True).update(status='archived', disabled=False)
|
||||||
|
|
||||||
|
# Clusters: all back to 'active'
|
||||||
|
Clusters.objects.all().update(status='active')
|
||||||
|
|
||||||
|
# ContentIdeas: queued→scheduled, completed→published
|
||||||
|
ContentIdeas.objects.filter(status='queued').update(status='scheduled')
|
||||||
|
ContentIdeas.objects.filter(status='completed').update(status='published')
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('planner', '0005_field_rename_implementation'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Step 1: Add disabled field to all models (with default=False)
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='clusters',
|
||||||
|
name='disabled',
|
||||||
|
field=models.BooleanField(default=False, help_text='Exclude from processes'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='keywords',
|
||||||
|
name='disabled',
|
||||||
|
field=models.BooleanField(default=False, help_text='Exclude from processes'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contentideas',
|
||||||
|
name='disabled',
|
||||||
|
field=models.BooleanField(default=False, help_text='Exclude from processes'),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Step 2: Alter Keywords status field choices
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='keywords',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[('new', 'New'), ('mapped', 'Mapped')],
|
||||||
|
default='new',
|
||||||
|
max_length=50
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Step 3: Alter Clusters status field (add choices, change default)
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='clusters',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[('new', 'New'), ('mapped', 'Mapped')],
|
||||||
|
default='new',
|
||||||
|
max_length=50
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Step 4: Alter ContentIdeas status field choices
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='contentideas',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[('new', 'New'), ('queued', 'Queued'), ('completed', 'Completed')],
|
||||||
|
default='new',
|
||||||
|
max_length=50
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# Step 5: Data migration - transform existing records
|
||||||
|
migrations.RunPython(migrate_status_data, reverse_status_data),
|
||||||
|
]
|
||||||
@@ -560,7 +560,7 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
volume=int(row.get('volume', 0) or 0),
|
volume=int(row.get('volume', 0) or 0),
|
||||||
difficulty=int(row.get('difficulty', 0) or 0),
|
difficulty=int(row.get('difficulty', 0) or 0),
|
||||||
intent=row.get('intent', 'informational') or 'informational',
|
intent=row.get('intent', 'informational') or 'informational',
|
||||||
status=row.get('status', 'pending') or 'pending',
|
status=row.get('status', 'new') or 'new',
|
||||||
site=site,
|
site=site,
|
||||||
sector=sector,
|
sector=sector,
|
||||||
account=account
|
account=account
|
||||||
@@ -1080,8 +1080,8 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
created_tasks.append(task.id)
|
created_tasks.append(task.id)
|
||||||
|
|
||||||
# Update idea status
|
# Update idea status to queued
|
||||||
idea.status = 'scheduled'
|
idea.status = 'queued'
|
||||||
idea.save()
|
idea.save()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append({
|
errors.append({
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ INSTALLED_APPS = [
|
|||||||
'igny8_core.modules.writer.apps.WriterConfig',
|
'igny8_core.modules.writer.apps.WriterConfig',
|
||||||
'igny8_core.modules.system.apps.SystemConfig',
|
'igny8_core.modules.system.apps.SystemConfig',
|
||||||
'igny8_core.modules.billing.apps.BillingConfig',
|
'igny8_core.modules.billing.apps.BillingConfig',
|
||||||
'igny8_core.modules.automation.apps.AutomationConfig',
|
# 'igny8_core.modules.automation.apps.AutomationConfig', # Removed - automation module disabled
|
||||||
# 'igny8_core.business.site_building.apps.SiteBuildingConfig', # REMOVED: SiteBuilder/Blueprint deprecated
|
# 'igny8_core.business.site_building.apps.SiteBuildingConfig', # REMOVED: SiteBuilder/Blueprint deprecated
|
||||||
'igny8_core.business.optimization.apps.OptimizationConfig',
|
'igny8_core.business.optimization.apps.OptimizationConfig',
|
||||||
'igny8_core.business.publishing.apps.PublishingConfig',
|
'igny8_core.business.publishing.apps.PublishingConfig',
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ urlpatterns = [
|
|||||||
# Site Builder module removed - legacy blueprint functionality deprecated
|
# Site Builder module removed - legacy blueprint functionality deprecated
|
||||||
path('api/v1/system/', include('igny8_core.modules.system.urls')),
|
path('api/v1/system/', include('igny8_core.modules.system.urls')),
|
||||||
path('api/v1/billing/', include('igny8_core.modules.billing.urls')), # Billing endpoints
|
path('api/v1/billing/', include('igny8_core.modules.billing.urls')), # Billing endpoints
|
||||||
path('api/v1/automation/', include('igny8_core.modules.automation.urls')), # Automation endpoints
|
# path('api/v1/automation/', include('igny8_core.modules.automation.urls')), # Automation endpoints - REMOVED
|
||||||
path('api/v1/linker/', include('igny8_core.modules.linker.urls')), # Linker endpoints
|
path('api/v1/linker/', include('igny8_core.modules.linker.urls')), # Linker endpoints
|
||||||
path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints
|
path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints
|
||||||
path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints
|
path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints
|
||||||
|
|||||||
@@ -64,11 +64,6 @@ const ReferenceIndustries = lazy(() => import("./pages/Reference/Industries"));
|
|||||||
// Setup Pages - Lazy loaded
|
// Setup Pages - Lazy loaded
|
||||||
const IndustriesSectorsKeywords = lazy(() => import("./pages/Setup/IndustriesSectorsKeywords"));
|
const IndustriesSectorsKeywords = lazy(() => import("./pages/Setup/IndustriesSectorsKeywords"));
|
||||||
|
|
||||||
// Other Pages - Lazy loaded
|
|
||||||
const AutomationDashboard = lazy(() => import("./pages/Automation/Dashboard"));
|
|
||||||
const AutomationRules = lazy(() => import("./pages/Automation/Rules"));
|
|
||||||
const AutomationTasks = lazy(() => import("./pages/Automation/Tasks"));
|
|
||||||
|
|
||||||
// Settings - Lazy loaded
|
// Settings - Lazy loaded
|
||||||
const GeneralSettings = lazy(() => import("./pages/Settings/General"));
|
const GeneralSettings = lazy(() => import("./pages/Settings/General"));
|
||||||
const Users = lazy(() => import("./pages/Settings/Users"));
|
const Users = lazy(() => import("./pages/Settings/Users"));
|
||||||
@@ -364,23 +359,6 @@ export default function App() {
|
|||||||
{/* Legacy redirect */}
|
{/* Legacy redirect */}
|
||||||
<Route path="/setup/industries-sectors-keywords" element={<Navigate to="/setup/add-keywords" replace />} />
|
<Route path="/setup/industries-sectors-keywords" element={<Navigate to="/setup/add-keywords" replace />} />
|
||||||
|
|
||||||
{/* Automation Module - Redirect dashboard to rules */}
|
|
||||||
<Route path="/automation" element={<Navigate to="/automation/rules" replace />} />
|
|
||||||
<Route path="/automation/rules" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="automation">
|
|
||||||
<AutomationRules />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/automation/tasks" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="automation">
|
|
||||||
<AutomationTasks />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<Route path="/settings" element={
|
<Route path="/settings" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
import { fetchAPI } from '../services/api';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Automation API Client
|
|
||||||
* Functions for automation rules and scheduled tasks
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface AutomationRule {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
trigger: 'schedule' | 'event' | 'manual';
|
|
||||||
schedule?: string; // Cron-like string
|
|
||||||
conditions: Array<{
|
|
||||||
field: string;
|
|
||||||
operator: string;
|
|
||||||
value: any;
|
|
||||||
}>;
|
|
||||||
actions: Array<{
|
|
||||||
type: string;
|
|
||||||
params: Record<string, any>;
|
|
||||||
}>;
|
|
||||||
is_active: boolean;
|
|
||||||
status: 'active' | 'inactive' | 'paused';
|
|
||||||
execution_count: number;
|
|
||||||
last_executed_at?: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduledTask {
|
|
||||||
id: number;
|
|
||||||
rule_id?: number;
|
|
||||||
rule_name?: string;
|
|
||||||
task_type: string;
|
|
||||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
|
||||||
scheduled_at: string;
|
|
||||||
started_at?: string;
|
|
||||||
completed_at?: string;
|
|
||||||
result?: Record<string, any>;
|
|
||||||
error?: string;
|
|
||||||
retry_count: number;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AutomationRuleCreateData {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
trigger: 'schedule' | 'event' | 'manual';
|
|
||||||
schedule?: string;
|
|
||||||
conditions?: Array<{
|
|
||||||
field: string;
|
|
||||||
operator: string;
|
|
||||||
value: any;
|
|
||||||
}>;
|
|
||||||
actions: Array<{
|
|
||||||
type: string;
|
|
||||||
params: Record<string, any>;
|
|
||||||
}>;
|
|
||||||
is_active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AutomationRuleUpdateData extends Partial<AutomationRuleCreateData> {}
|
|
||||||
|
|
||||||
export const automationApi = {
|
|
||||||
/**
|
|
||||||
* List automation rules
|
|
||||||
*/
|
|
||||||
listRules: async (filters?: {
|
|
||||||
search?: string;
|
|
||||||
trigger?: string;
|
|
||||||
is_active?: boolean;
|
|
||||||
status?: string;
|
|
||||||
ordering?: string;
|
|
||||||
page?: number;
|
|
||||||
page_size?: number;
|
|
||||||
}) => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (filters?.search) params.append('search', filters.search);
|
|
||||||
if (filters?.trigger) params.append('trigger', filters.trigger);
|
|
||||||
if (filters?.is_active !== undefined) params.append('is_active', String(filters.is_active));
|
|
||||||
if (filters?.status) params.append('status', filters.status);
|
|
||||||
if (filters?.ordering) params.append('ordering', filters.ordering);
|
|
||||||
if (filters?.page) params.append('page', String(filters.page));
|
|
||||||
if (filters?.page_size) params.append('page_size', String(filters.page_size));
|
|
||||||
|
|
||||||
const query = params.toString();
|
|
||||||
return await fetchAPI(`/v1/automation/rules/${query ? `?${query}` : ''}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single automation rule
|
|
||||||
*/
|
|
||||||
getRule: async (id: number) => {
|
|
||||||
return await fetchAPI(`/v1/automation/rules/${id}/`) as AutomationRule;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new automation rule
|
|
||||||
*/
|
|
||||||
createRule: async (data: AutomationRuleCreateData) => {
|
|
||||||
return await fetchAPI('/v1/automation/rules/', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}) as AutomationRule;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an automation rule
|
|
||||||
*/
|
|
||||||
updateRule: async (id: number, data: AutomationRuleUpdateData) => {
|
|
||||||
return await fetchAPI(`/v1/automation/rules/${id}/`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}) as AutomationRule;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete an automation rule
|
|
||||||
*/
|
|
||||||
deleteRule: async (id: number) => {
|
|
||||||
return await fetchAPI(`/v1/automation/rules/${id}/`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute an automation rule manually
|
|
||||||
*/
|
|
||||||
executeRule: async (id: number, context?: Record<string, any>) => {
|
|
||||||
return await fetchAPI(`/v1/automation/rules/${id}/execute/`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ context: context || {} }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List scheduled tasks
|
|
||||||
*/
|
|
||||||
listTasks: async (filters?: {
|
|
||||||
rule_id?: number;
|
|
||||||
status?: string;
|
|
||||||
task_type?: string;
|
|
||||||
ordering?: string;
|
|
||||||
page?: number;
|
|
||||||
page_size?: number;
|
|
||||||
}) => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (filters?.rule_id) params.append('rule_id', String(filters.rule_id));
|
|
||||||
if (filters?.status) params.append('status', filters.status);
|
|
||||||
if (filters?.task_type) params.append('task_type', filters.task_type);
|
|
||||||
if (filters?.ordering) params.append('ordering', filters.ordering);
|
|
||||||
if (filters?.page) params.append('page', String(filters.page));
|
|
||||||
if (filters?.page_size) params.append('page_size', String(filters.page_size));
|
|
||||||
|
|
||||||
const query = params.toString();
|
|
||||||
return await fetchAPI(`/v1/automation/scheduled-tasks/${query ? `?${query}` : ''}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single scheduled task
|
|
||||||
*/
|
|
||||||
getTask: async (id: number) => {
|
|
||||||
return await fetchAPI(`/v1/automation/scheduled-tasks/${id}/`) as ScheduledTask;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry a failed scheduled task
|
|
||||||
*/
|
|
||||||
retryTask: async (id: number) => {
|
|
||||||
return await fetchAPI(`/v1/automation/scheduled-tasks/${id}/retry/`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ export const createClustersPageConfig = (
|
|||||||
render: (value: string) => {
|
render: (value: string) => {
|
||||||
const properCase = value ? value.charAt(0).toUpperCase() + value.slice(1) : '-';
|
const properCase = value ? value.charAt(0).toUpperCase() + value.slice(1) : '-';
|
||||||
return (
|
return (
|
||||||
<Badge color={value === 'active' ? 'success' : 'warning'} size="xs" variant="soft">
|
<Badge color={value === 'mapped' ? 'success' : 'amber'} size="xs" variant="soft">
|
||||||
<span className="text-[11px] font-normal">{properCase}</span>
|
<span className="text-[11px] font-normal">{properCase}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
@@ -248,8 +248,8 @@ export const createClustersPageConfig = (
|
|||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: [
|
||||||
{ value: '', label: 'All Status' },
|
{ value: '', label: 'All Status' },
|
||||||
{ value: 'active', label: 'Active' },
|
{ value: 'new', label: 'New' },
|
||||||
{ value: 'archived', label: 'Archived' },
|
{ value: 'mapped', label: 'Mapped' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -409,12 +409,12 @@ export const createClustersPageConfig = (
|
|||||||
key: 'status',
|
key: 'status',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
value: handlers.formData.status || 'active',
|
value: handlers.formData.status || 'new',
|
||||||
onChange: (value: any) =>
|
onChange: (value: any) =>
|
||||||
handlers.setFormData({ ...handlers.formData, status: value }),
|
handlers.setFormData({ ...handlers.formData, status: value }),
|
||||||
options: [
|
options: [
|
||||||
{ value: 'active', label: 'Active' },
|
{ value: 'new', label: 'New' },
|
||||||
{ value: 'archived', label: 'Archived' },
|
{ value: 'mapped', label: 'Mapped' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -173,8 +173,8 @@ export const createIdeasPageConfig = (
|
|||||||
render: (value: string) => {
|
render: (value: string) => {
|
||||||
const statusColors: Record<string, 'success' | 'amber' | 'info'> = {
|
const statusColors: Record<string, 'success' | 'amber' | 'info'> = {
|
||||||
'new': 'amber',
|
'new': 'amber',
|
||||||
'scheduled': 'info',
|
'queued': 'info',
|
||||||
'published': 'success',
|
'completed': 'success',
|
||||||
};
|
};
|
||||||
const properCase = value ? value.charAt(0).toUpperCase() + value.slice(1) : '-';
|
const properCase = value ? value.charAt(0).toUpperCase() + value.slice(1) : '-';
|
||||||
return (
|
return (
|
||||||
@@ -222,8 +222,8 @@ export const createIdeasPageConfig = (
|
|||||||
options: [
|
options: [
|
||||||
{ value: '', label: 'All Status' },
|
{ value: '', label: 'All Status' },
|
||||||
{ value: 'new', label: 'New' },
|
{ value: 'new', label: 'New' },
|
||||||
{ value: 'scheduled', label: 'Scheduled' },
|
{ value: 'queued', label: 'Queued' },
|
||||||
{ value: 'published', label: 'Published' },
|
{ value: 'completed', label: 'Completed' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -380,8 +380,8 @@ export const createIdeasPageConfig = (
|
|||||||
handlers.setFormData({ ...handlers.formData, status: value }),
|
handlers.setFormData({ ...handlers.formData, status: value }),
|
||||||
options: [
|
options: [
|
||||||
{ value: 'new', label: 'New' },
|
{ value: 'new', label: 'New' },
|
||||||
{ value: 'scheduled', label: 'Scheduled' },
|
{ value: 'queued', label: 'Queued' },
|
||||||
{ value: 'published', label: 'Published' },
|
{ value: 'completed', label: 'Completed' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -399,13 +399,13 @@ export const createIdeasPageConfig = (
|
|||||||
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'new').length,
|
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'new').length,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Scheduled',
|
label: 'Queued',
|
||||||
value: 0,
|
value: 0,
|
||||||
accentColor: 'blue' as const,
|
accentColor: 'blue' as const,
|
||||||
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'scheduled').length,
|
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'queued').length,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Published',
|
label: 'Completed',
|
||||||
value: 0,
|
value: 0,
|
||||||
accentColor: 'green' as const,
|
accentColor: 'green' as const,
|
||||||
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'published').length,
|
calculate: (data) => data.ideas.filter((i: ContentIdea) => i.status === 'published').length,
|
||||||
|
|||||||
@@ -232,9 +232,9 @@ export const createKeywordsPageConfig = (
|
|||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
color={
|
color={
|
||||||
value === 'active'
|
value === 'mapped'
|
||||||
? 'success'
|
? 'success'
|
||||||
: value === 'pending'
|
: value === 'new'
|
||||||
? 'amber'
|
? 'amber'
|
||||||
: 'error'
|
: 'error'
|
||||||
}
|
}
|
||||||
@@ -312,9 +312,8 @@ export const createKeywordsPageConfig = (
|
|||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: [
|
||||||
{ value: '', label: 'All Status' },
|
{ value: '', label: 'All Status' },
|
||||||
{ value: 'active', label: 'Active' },
|
{ value: 'new', label: 'New' },
|
||||||
{ value: 'pending', label: 'Pending' },
|
{ value: 'mapped', label: 'Mapped' },
|
||||||
{ value: 'archived', label: 'Archived' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -560,13 +559,12 @@ export const createKeywordsPageConfig = (
|
|||||||
key: 'status',
|
key: 'status',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
value: handlers.formData.status || 'pending',
|
value: handlers.formData.status || 'new',
|
||||||
onChange: (value: any) =>
|
onChange: (value: any) =>
|
||||||
handlers.setFormData({ ...handlers.formData, status: value }),
|
handlers.setFormData({ ...handlers.formData, status: value }),
|
||||||
options: [
|
options: [
|
||||||
{ value: 'pending', label: 'Pending' },
|
{ value: 'new', label: 'New' },
|
||||||
{ value: 'active', label: 'Active' },
|
{ value: 'mapped', label: 'Mapped' },
|
||||||
{ value: 'archived', label: 'Archived' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -95,15 +95,6 @@ const AppSidebar: React.FC = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add Automation if enabled (single item, no dropdown)
|
|
||||||
if (moduleEnabled('automation')) {
|
|
||||||
setupItems.push({
|
|
||||||
icon: <BoltIcon />,
|
|
||||||
name: "Automation",
|
|
||||||
path: "/automation/rules", // Default to rules, submenus shown as in-page navigation
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Thinker if enabled (single item, no dropdown)
|
// Add Thinker if enabled (single item, no dropdown)
|
||||||
if (moduleEnabled('thinker')) {
|
if (moduleEnabled('thinker')) {
|
||||||
setupItems.push({
|
setupItems.push({
|
||||||
|
|||||||
@@ -1,480 +0,0 @@
|
|||||||
import { useEffect, useState, lazy, Suspense } from "react";
|
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
|
||||||
import PageMeta from "../../components/common/PageMeta";
|
|
||||||
import ComponentCard from "../../components/common/ComponentCard";
|
|
||||||
import { ProgressBar } from "../../components/ui/progress";
|
|
||||||
import { ApexOptions } from "apexcharts";
|
|
||||||
import EnhancedMetricCard from "../../components/dashboard/EnhancedMetricCard";
|
|
||||||
import PageHeader from "../../components/common/PageHeader";
|
|
||||||
|
|
||||||
const Chart = lazy(() => import("react-apexcharts").then((mod) => ({ default: mod.default })));
|
|
||||||
|
|
||||||
import {
|
|
||||||
BoltIcon,
|
|
||||||
ClockIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
ArrowRightIcon,
|
|
||||||
CalendarIcon,
|
|
||||||
ListIcon,
|
|
||||||
GroupIcon,
|
|
||||||
FileTextIcon,
|
|
||||||
ArrowUpIcon,
|
|
||||||
ArrowDownIcon,
|
|
||||||
PaperPlaneIcon,
|
|
||||||
CloseIcon,
|
|
||||||
FileIcon,
|
|
||||||
} from "../../icons";
|
|
||||||
import { useSiteStore } from "../../store/siteStore";
|
|
||||||
import { useSectorStore } from "../../store/sectorStore";
|
|
||||||
|
|
||||||
interface AutomationStats {
|
|
||||||
activeWorkflows: number;
|
|
||||||
scheduledTasks: number;
|
|
||||||
completedToday: number;
|
|
||||||
successRate: number;
|
|
||||||
automationCoverage: {
|
|
||||||
keywords: boolean;
|
|
||||||
clustering: boolean;
|
|
||||||
ideas: boolean;
|
|
||||||
tasks: boolean;
|
|
||||||
content: boolean;
|
|
||||||
images: boolean;
|
|
||||||
publishing: boolean;
|
|
||||||
};
|
|
||||||
recentActivity: Array<{
|
|
||||||
id: number;
|
|
||||||
type: string;
|
|
||||||
status: string;
|
|
||||||
timestamp: Date;
|
|
||||||
itemsProcessed: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AutomationDashboard() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { activeSite } = useSiteStore();
|
|
||||||
const { activeSector } = useSectorStore();
|
|
||||||
|
|
||||||
const [stats, setStats] = useState<AutomationStats | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
|
|
||||||
|
|
||||||
// Mock data for now - will be replaced with real API calls
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
// Simulate API call
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
|
|
||||||
setStats({
|
|
||||||
activeWorkflows: 3,
|
|
||||||
scheduledTasks: 12,
|
|
||||||
completedToday: 47,
|
|
||||||
successRate: 94.5,
|
|
||||||
automationCoverage: {
|
|
||||||
keywords: true,
|
|
||||||
clustering: true,
|
|
||||||
ideas: true,
|
|
||||||
tasks: false,
|
|
||||||
content: true,
|
|
||||||
images: true,
|
|
||||||
publishing: false,
|
|
||||||
},
|
|
||||||
recentActivity: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
type: "Content Generation",
|
|
||||||
status: "completed",
|
|
||||||
timestamp: new Date(Date.now() - 15 * 60 * 1000),
|
|
||||||
itemsProcessed: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
type: "Image Generation",
|
|
||||||
status: "completed",
|
|
||||||
timestamp: new Date(Date.now() - 45 * 60 * 1000),
|
|
||||||
itemsProcessed: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
type: "Keyword Clustering",
|
|
||||||
status: "completed",
|
|
||||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
|
||||||
itemsProcessed: 12,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
setLastUpdated(new Date());
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [activeSite, activeSector]);
|
|
||||||
|
|
||||||
const automationWorkflows = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "Full Pipeline Automation",
|
|
||||||
description: "Keywords → Clusters → Ideas → Tasks → Content → Images → Publish",
|
|
||||||
status: "active",
|
|
||||||
schedule: "Every 6 hours",
|
|
||||||
lastRun: "2 hours ago",
|
|
||||||
nextRun: "4 hours",
|
|
||||||
coverage: 85,
|
|
||||||
icon: PaperPlaneIcon,
|
|
||||||
color: "from-[var(--color-purple)] to-[var(--color-purple-dark)]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "Writer Workflow",
|
|
||||||
description: "Tasks → Content → Images → Publishing",
|
|
||||||
status: "active",
|
|
||||||
schedule: "Every 3 hours",
|
|
||||||
lastRun: "1 hour ago",
|
|
||||||
nextRun: "2 hours",
|
|
||||||
coverage: 92,
|
|
||||||
icon: FileTextIcon,
|
|
||||||
color: "from-[var(--color-success)] to-[var(--color-success-dark)]",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "Planner Workflow",
|
|
||||||
description: "Keywords → Clusters → Ideas",
|
|
||||||
status: "active",
|
|
||||||
schedule: "Every 6 hours",
|
|
||||||
lastRun: "3 hours ago",
|
|
||||||
nextRun: "3 hours",
|
|
||||||
coverage: 78,
|
|
||||||
icon: ListIcon,
|
|
||||||
color: "from-[var(--color-primary)] to-[var(--color-primary-dark)]",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const automationSteps = [
|
|
||||||
{
|
|
||||||
step: "Keywords",
|
|
||||||
enabled: true,
|
|
||||||
description: "Auto-add keywords from opportunities",
|
|
||||||
path: "/planner/keyword-opportunities",
|
|
||||||
icon: ListIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: "Clustering",
|
|
||||||
enabled: true,
|
|
||||||
description: "Auto-cluster keywords into groups",
|
|
||||||
path: "/planner/clusters",
|
|
||||||
icon: GroupIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: "Ideas",
|
|
||||||
enabled: true,
|
|
||||||
description: "Auto-generate content ideas from clusters",
|
|
||||||
path: "/planner/ideas",
|
|
||||||
icon: BoltIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: "Tasks",
|
|
||||||
enabled: false,
|
|
||||||
description: "Auto-create tasks from ideas",
|
|
||||||
path: "/writer/tasks",
|
|
||||||
icon: CheckCircleIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: "Content",
|
|
||||||
enabled: true,
|
|
||||||
description: "Auto-generate content from tasks",
|
|
||||||
path: "/writer/content",
|
|
||||||
icon: FileTextIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: "Images",
|
|
||||||
enabled: true,
|
|
||||||
description: "Auto-generate images for content",
|
|
||||||
path: "/writer/images",
|
|
||||||
icon: FileIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: "Publishing",
|
|
||||||
enabled: false,
|
|
||||||
description: "Auto-publish content to WordPress",
|
|
||||||
path: "/writer/published",
|
|
||||||
icon: PaperPlaneIcon,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const chartOptions: ApexOptions = {
|
|
||||||
chart: {
|
|
||||||
type: "line",
|
|
||||||
height: 300,
|
|
||||||
toolbar: { show: false },
|
|
||||||
zoom: { enabled: false },
|
|
||||||
},
|
|
||||||
stroke: {
|
|
||||||
curve: "smooth",
|
|
||||||
width: 3,
|
|
||||||
},
|
|
||||||
xaxis: {
|
|
||||||
categories: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
|
||||||
labels: { style: { colors: "#6b7280" } },
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
labels: { style: { colors: "#6b7280" } },
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
position: "top",
|
|
||||||
labels: { colors: "#6b7280" },
|
|
||||||
},
|
|
||||||
colors: ["var(--color-primary)", "var(--color-success)", "var(--color-purple)"],
|
|
||||||
grid: {
|
|
||||||
borderColor: "#e5e7eb",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartSeries = [
|
|
||||||
{
|
|
||||||
name: "Automated",
|
|
||||||
data: [12, 19, 15, 25, 22, 18, 24],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Manual",
|
|
||||||
data: [5, 8, 6, 10, 9, 7, 11],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Failed",
|
|
||||||
data: [1, 2, 1, 2, 1, 2, 1],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageMeta title="Automation Dashboard - IGNY8" description="Manage and monitor automation workflows" />
|
|
||||||
<PageHeader title="Automation Dashboard" />
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Key Metrics */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
<EnhancedMetricCard
|
|
||||||
title="Active Workflows"
|
|
||||||
value={stats?.activeWorkflows || 0}
|
|
||||||
icon={<BoltIcon className="size-6" />}
|
|
||||||
trend={0}
|
|
||||||
accentColor="purple"
|
|
||||||
/>
|
|
||||||
<EnhancedMetricCard
|
|
||||||
title="Scheduled Tasks"
|
|
||||||
value={stats?.scheduledTasks || 0}
|
|
||||||
icon={<CalendarIcon className="size-6" />}
|
|
||||||
trend={0}
|
|
||||||
accentColor="blue"
|
|
||||||
/>
|
|
||||||
<EnhancedMetricCard
|
|
||||||
title="Completed Today"
|
|
||||||
value={stats?.completedToday || 0}
|
|
||||||
icon={<CheckCircleIcon className="size-6" />}
|
|
||||||
trend={0}
|
|
||||||
accentColor="green"
|
|
||||||
/>
|
|
||||||
<EnhancedMetricCard
|
|
||||||
title="Success Rate"
|
|
||||||
value={`${stats?.successRate || 0}%`}
|
|
||||||
icon={<PaperPlaneIcon className="size-6" />}
|
|
||||||
trend={0}
|
|
||||||
accentColor="orange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Automation Workflows */}
|
|
||||||
<ComponentCard title="Automation Workflows" desc="Manage your automated content pipelines">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
{automationWorkflows.map((workflow) => {
|
|
||||||
const Icon = workflow.icon;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={workflow.id}
|
|
||||||
className="rounded-2xl border-2 border-slate-200 bg-white p-6 hover:shadow-lg transition-all"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className={`inline-flex size-12 rounded-xl bg-gradient-to-br ${workflow.color} items-center justify-center text-white shadow-lg`}>
|
|
||||||
<Icon className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold ${
|
|
||||||
workflow.status === "active"
|
|
||||||
? "bg-green-100 text-green-700"
|
|
||||||
: "bg-gray-100 text-gray-700"
|
|
||||||
}`}>
|
|
||||||
{workflow.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-bold text-slate-900 mb-2">{workflow.name}</h3>
|
|
||||||
<p className="text-sm text-slate-600 mb-4">{workflow.description}</p>
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
<div className="flex items-center justify-between text-xs text-slate-600">
|
|
||||||
<span>Schedule:</span>
|
|
||||||
<span className="font-semibold">{workflow.schedule}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-xs text-slate-600">
|
|
||||||
<span>Last Run:</span>
|
|
||||||
<span>{workflow.lastRun}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-xs text-slate-600">
|
|
||||||
<span>Next Run:</span>
|
|
||||||
<span className="font-semibold text-[var(--color-primary)]">{workflow.nextRun}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex items-center justify-between text-xs text-slate-600 mb-1">
|
|
||||||
<span>Coverage</span>
|
|
||||||
<span className="font-semibold">{workflow.coverage}%</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar value={workflow.coverage} className="h-2" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-slate-100 text-slate-700 px-4 py-2 text-sm font-semibold hover:bg-slate-200 transition">
|
|
||||||
<CloseIcon className="h-4 w-4" />
|
|
||||||
Pause
|
|
||||||
</button>
|
|
||||||
<button className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] text-white px-4 py-2 text-sm font-semibold hover:shadow-lg transition">
|
|
||||||
<PaperPlaneIcon className="h-4 w-4" />
|
|
||||||
Run Now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
|
|
||||||
{/* Automation Steps Configuration */}
|
|
||||||
<ComponentCard title="Automation Steps" desc="Configure which steps are automated">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{automationSteps.map((step, index) => {
|
|
||||||
const Icon = step.icon;
|
|
||||||
const isEnabled = stats?.automationCoverage[step.step.toLowerCase() as keyof typeof stats.automationCoverage] || false;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={step.step}
|
|
||||||
to={step.path}
|
|
||||||
className="rounded-xl border-2 border-slate-200 bg-white p-5 hover:shadow-lg transition-all group"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className={`inline-flex size-10 rounded-lg bg-gradient-to-br ${
|
|
||||||
isEnabled
|
|
||||||
? "from-[var(--color-success)] to-[var(--color-success-dark)]"
|
|
||||||
: "from-slate-300 to-slate-400"
|
|
||||||
} items-center justify-center text-white shadow-md`}>
|
|
||||||
<Icon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div className={`size-5 rounded-full border-2 flex items-center justify-center ${
|
|
||||||
isEnabled
|
|
||||||
? "border-[var(--color-success)] bg-[var(--color-success)]"
|
|
||||||
: "border-slate-300 bg-white"
|
|
||||||
}`}>
|
|
||||||
{isEnabled && <CheckCircleIcon className="h-3 w-3 text-white" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-slate-900 mb-1">{step.step}</h4>
|
|
||||||
<p className="text-xs text-slate-600">{step.description}</p>
|
|
||||||
<div className="mt-3 flex items-center gap-1 text-xs text-[var(--color-primary)] opacity-0 group-hover:opacity-100 transition">
|
|
||||||
<span>Configure</span>
|
|
||||||
<ArrowRightIcon className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
|
|
||||||
{/* Activity Chart */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<ComponentCard title="Automation Activity" desc="Last 7 days of automation activity">
|
|
||||||
<Suspense fallback={<div className="h-[300px] flex items-center justify-center">Loading chart...</div>}>
|
|
||||||
<Chart options={chartOptions} series={chartSeries} type="line" height={300} />
|
|
||||||
</Suspense>
|
|
||||||
</ComponentCard>
|
|
||||||
|
|
||||||
{/* Recent Activity */}
|
|
||||||
<ComponentCard title="Recent Activity" desc="Latest automation executions">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{stats?.recentActivity.map((activity) => (
|
|
||||||
<div
|
|
||||||
key={activity.id}
|
|
||||||
className="flex items-center gap-4 p-4 rounded-lg border border-slate-200 bg-white"
|
|
||||||
>
|
|
||||||
<div className="size-10 rounded-lg bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] flex items-center justify-center text-white shadow-md">
|
|
||||||
<BoltIcon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<h4 className="font-semibold text-slate-900">{activity.type}</h4>
|
|
||||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
|
||||||
activity.status === "completed"
|
|
||||||
? "bg-green-100 text-green-700"
|
|
||||||
: "bg-yellow-100 text-yellow-700"
|
|
||||||
}`}>
|
|
||||||
{activity.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-slate-600">
|
|
||||||
<span>{activity.itemsProcessed} items processed</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{new Date(activity.timestamp).toLocaleTimeString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<ComponentCard title="Quick Actions" desc="Manually trigger automation workflows">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/planner/keyword-opportunities")}
|
|
||||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-slate-200 bg-white hover:border-[#0693e3] hover:shadow-lg transition-all group"
|
|
||||||
>
|
|
||||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] flex items-center justify-center text-white shadow-lg">
|
|
||||||
<ListIcon className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<h4 className="font-semibold text-slate-900 mb-1">Run Planner Workflow</h4>
|
|
||||||
<p className="text-sm text-slate-600">Keywords → Clusters → Ideas</p>
|
|
||||||
</div>
|
|
||||||
<ArrowRightIcon className="h-5 w-5 text-slate-400 group-hover:text-[var(--color-primary)] transition" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/writer/tasks")}
|
|
||||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-slate-200 bg-white hover:border-[#0bbf87] hover:shadow-lg transition-all group"
|
|
||||||
>
|
|
||||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-success)] to-[var(--color-success-dark)] flex items-center justify-center text-white shadow-lg">
|
|
||||||
<FileTextIcon className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<h4 className="font-semibold text-slate-900 mb-1">Run Writer Workflow</h4>
|
|
||||||
<p className="text-sm text-slate-600">Tasks → Content → Images</p>
|
|
||||||
</div>
|
|
||||||
<ArrowRightIcon className="h-5 w-5 text-slate-400 group-hover:text-[#0bbf87] transition" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/writer/published")}
|
|
||||||
className="flex items-center gap-4 p-6 rounded-xl border-2 border-slate-200 bg-white hover:border-[#5d4ae3] hover:shadow-lg transition-all group"
|
|
||||||
>
|
|
||||||
<div className="size-12 rounded-xl bg-gradient-to-br from-[var(--color-purple)] to-[var(--color-purple-dark)] flex items-center justify-center text-white shadow-lg">
|
|
||||||
<PaperPlaneIcon className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<h4 className="font-semibold text-slate-900 mb-1">Run Full Pipeline</h4>
|
|
||||||
<p className="text-sm text-slate-600">Complete end-to-end automation</p>
|
|
||||||
</div>
|
|
||||||
<ArrowRightIcon className="h-5 w-5 text-slate-400 group-hover:text-[#5d4ae3] transition" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
|
||||||
import ComponentCard from '../../components/common/ComponentCard';
|
|
||||||
import { automationApi, AutomationRule } from '../../api/automation.api';
|
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
|
||||||
import { BoltIcon, PlusIcon, TrashBinIcon, PencilIcon, PaperPlaneIcon, CloseIcon, TaskIcon, ClockIcon } from '../../icons';
|
|
||||||
import { useSiteStore } from '../../store/siteStore';
|
|
||||||
import { useSectorStore } from '../../store/sectorStore';
|
|
||||||
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
|
||||||
|
|
||||||
export default function AutomationRules() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const toast = useToast();
|
|
||||||
const { activeSite } = useSiteStore();
|
|
||||||
const { activeSector } = useSectorStore();
|
|
||||||
|
|
||||||
const [rules, setRules] = useState<AutomationRule[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [selectedRule, setSelectedRule] = useState<AutomationRule | null>(null);
|
|
||||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
|
||||||
|
|
||||||
const loadRules = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await automationApi.listRules({
|
|
||||||
page_size: 100,
|
|
||||||
});
|
|
||||||
setRules(response.results || []);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error loading rules:', error);
|
|
||||||
toast.error(`Failed to load rules: ${error.message}`);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [toast]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadRules();
|
|
||||||
}, [loadRules]);
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
setSelectedRule(null);
|
|
||||||
setIsEditMode(false);
|
|
||||||
setIsWizardOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (rule: AutomationRule) => {
|
|
||||||
setSelectedRule(rule);
|
|
||||||
setIsEditMode(true);
|
|
||||||
setIsWizardOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
if (!confirm('Are you sure you want to delete this rule?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await automationApi.deleteRule(id);
|
|
||||||
toast.success('Rule deleted successfully');
|
|
||||||
loadRules();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(`Failed to delete rule: ${error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleActive = async (rule: AutomationRule) => {
|
|
||||||
try {
|
|
||||||
await automationApi.updateRule(rule.id, {
|
|
||||||
is_active: !rule.is_active,
|
|
||||||
});
|
|
||||||
toast.success(`Rule ${rule.is_active ? 'deactivated' : 'activated'}`);
|
|
||||||
loadRules();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(`Failed to update rule: ${error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExecute = async (id: number) => {
|
|
||||||
try {
|
|
||||||
await automationApi.executeRule(id);
|
|
||||||
toast.success('Rule executed successfully');
|
|
||||||
loadRules();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(`Failed to execute rule: ${error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (rule: AutomationRule) => {
|
|
||||||
if (!rule.is_active) {
|
|
||||||
return <span className="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">Inactive</span>;
|
|
||||||
}
|
|
||||||
if (rule.status === 'paused') {
|
|
||||||
return <span className="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300">Paused</span>;
|
|
||||||
}
|
|
||||||
return <span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">Active</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTriggerBadge = (trigger: string) => {
|
|
||||||
const colors = {
|
|
||||||
schedule: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
|
||||||
event: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
|
||||||
manual: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${colors[trigger as keyof typeof colors] || colors.manual}`}>
|
|
||||||
{trigger.charAt(0).toUpperCase() + trigger.slice(1)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Automation navigation tabs
|
|
||||||
const automationTabs = [
|
|
||||||
{ label: 'Rules', path: '/automation/rules', icon: <BoltIcon /> },
|
|
||||||
{ label: 'Tasks', path: '/automation/tasks', icon: <ClockIcon /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageMeta title="Automation Rules" />
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Automation Rules"
|
|
||||||
lastUpdated={new Date()}
|
|
||||||
badge={{
|
|
||||||
icon: <BoltIcon />,
|
|
||||||
color: 'purple',
|
|
||||||
}}
|
|
||||||
navigation={<ModuleNavigationTabs tabs={automationTabs} />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Create and manage automation rules to automate your workflows
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleCreate}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors"
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
Create Rule
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-gray-500">Loading rules...</div>
|
|
||||||
</div>
|
|
||||||
) : rules.length === 0 ? (
|
|
||||||
<ComponentCard title="No Rules" desc="Create your first automation rule to get started">
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
|
||||||
You haven't created any automation rules yet.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleCreate}
|
|
||||||
className="px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors"
|
|
||||||
>
|
|
||||||
Create Your First Rule
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{rules.map((rule) => (
|
|
||||||
<ComponentCard
|
|
||||||
key={rule.id}
|
|
||||||
title={rule.name}
|
|
||||||
desc={rule.description || 'No description'}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{getStatusBadge(rule)}
|
|
||||||
{getTriggerBadge(rule.trigger)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rule.schedule && (
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<strong>Schedule:</strong> {rule.schedule}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<strong>Actions:</strong> {rule.actions.length}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rule.execution_count > 0 && (
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<strong>Executions:</strong> {rule.execution_count}
|
|
||||||
{rule.last_executed_at && (
|
|
||||||
<span className="ml-2">
|
|
||||||
(Last: {new Date(rule.last_executed_at).toLocaleDateString()})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggleActive(rule)}
|
|
||||||
className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors flex items-center justify-center"
|
|
||||||
title={rule.is_active ? 'Deactivate' : 'Activate'}
|
|
||||||
>
|
|
||||||
{rule.is_active ? <CloseIcon className="w-4 h-4" /> : <PaperPlaneIcon className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
{rule.trigger === 'manual' && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleExecute(rule.id)}
|
|
||||||
className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors flex items-center justify-center"
|
|
||||||
title="Execute Now"
|
|
||||||
>
|
|
||||||
<PaperPlaneIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleEdit(rule)}
|
|
||||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors flex items-center justify-center"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<PencilIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(rule.id)}
|
|
||||||
className="px-3 py-1.5 text-sm rounded-lg border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors flex items-center justify-center"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<TrashBinIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rule Creation/Edit Wizard Modal - TODO: Implement full wizard */}
|
|
||||||
{isWizardOpen && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4">
|
|
||||||
<h3 className="text-xl font-bold mb-4">
|
|
||||||
{isEditMode ? 'Edit Rule' : 'Create Rule'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
||||||
Rule wizard coming soon. For now, use the API directly or create rules programmatically.
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsWizardOpen(false)}
|
|
||||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
|
||||||
import ComponentCard from '../../components/common/ComponentCard';
|
|
||||||
import { automationApi, ScheduledTask } from '../../api/automation.api';
|
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
|
||||||
import { ClockIcon, CheckCircleIcon, XCircleIcon, ArrowRightIcon, BoltIcon } from '../../icons';
|
|
||||||
import { useSiteStore } from '../../store/siteStore';
|
|
||||||
import { useSectorStore } from '../../store/sectorStore';
|
|
||||||
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
|
||||||
|
|
||||||
export default function AutomationTasks() {
|
|
||||||
const toast = useToast();
|
|
||||||
const { activeSite } = useSiteStore();
|
|
||||||
const { activeSector } = useSectorStore();
|
|
||||||
|
|
||||||
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
|
||||||
const [ruleFilter, setRuleFilter] = useState<number | null>(null);
|
|
||||||
const [rules, setRules] = useState<Array<{ id: number; name: string }>>([]);
|
|
||||||
|
|
||||||
const loadTasks = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const filters: any = {
|
|
||||||
page_size: 100,
|
|
||||||
ordering: '-scheduled_at',
|
|
||||||
};
|
|
||||||
if (statusFilter !== 'all') {
|
|
||||||
filters.status = statusFilter;
|
|
||||||
}
|
|
||||||
if (ruleFilter) {
|
|
||||||
filters.rule_id = ruleFilter;
|
|
||||||
}
|
|
||||||
const response = await automationApi.listTasks(filters);
|
|
||||||
setTasks(response.results || []);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error loading tasks:', error);
|
|
||||||
toast.error(`Failed to load tasks: ${error.message}`);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [statusFilter, ruleFilter, toast]);
|
|
||||||
|
|
||||||
const loadRules = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await automationApi.listRules({ page_size: 100 });
|
|
||||||
setRules((response.results || []).map(r => ({ id: r.id, name: r.name })));
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error loading rules:', error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadRules();
|
|
||||||
}, [loadRules]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadTasks();
|
|
||||||
}, [loadTasks]);
|
|
||||||
|
|
||||||
const handleRetry = async (id: number) => {
|
|
||||||
try {
|
|
||||||
await automationApi.retryTask(id);
|
|
||||||
toast.success('Task retry initiated');
|
|
||||||
loadTasks();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(`Failed to retry task: ${error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
const badges = {
|
|
||||||
pending: { bg: 'bg-yellow-100', text: 'text-yellow-700', darkBg: 'dark:bg-yellow-900', darkText: 'dark:text-yellow-300', label: 'Pending' },
|
|
||||||
running: { bg: 'bg-blue-100', text: 'text-blue-700', darkBg: 'dark:bg-blue-900', darkText: 'dark:text-blue-300', label: 'Running' },
|
|
||||||
completed: { bg: 'bg-green-100', text: 'text-green-700', darkBg: 'dark:bg-green-900', darkText: 'dark:text-green-300', label: 'Completed' },
|
|
||||||
failed: { bg: 'bg-red-100', text: 'text-red-700', darkBg: 'dark:bg-red-900', darkText: 'dark:text-red-300', label: 'Failed' },
|
|
||||||
cancelled: { bg: 'bg-gray-100', text: 'text-gray-700', darkBg: 'dark:bg-gray-700', darkText: 'dark:text-gray-300', label: 'Cancelled' },
|
|
||||||
};
|
|
||||||
const badge = badges[status as keyof typeof badges] || badges.pending;
|
|
||||||
return (
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${badge.bg} ${badge.text} ${badge.darkBg} ${badge.darkText}`}>
|
|
||||||
{badge.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredTasks = tasks.filter(task => {
|
|
||||||
if (statusFilter !== 'all' && task.status !== statusFilter) return false;
|
|
||||||
if (ruleFilter && task.rule_id !== ruleFilter) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Automation navigation tabs
|
|
||||||
const automationTabs = [
|
|
||||||
{ label: 'Rules', path: '/automation/rules', icon: <BoltIcon /> },
|
|
||||||
{ label: 'Tasks', path: '/automation/tasks', icon: <ClockIcon /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageMeta title="Scheduled Tasks" />
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Scheduled Tasks"
|
|
||||||
lastUpdated={new Date()}
|
|
||||||
badge={{
|
|
||||||
icon: <ClockIcon />,
|
|
||||||
color: 'blue',
|
|
||||||
}}
|
|
||||||
navigation={<ModuleNavigationTabs tabs={automationTabs} />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Filter by Status
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
|
|
||||||
>
|
|
||||||
<option value="all">All Statuses</option>
|
|
||||||
<option value="pending">Pending</option>
|
|
||||||
<option value="running">Running</option>
|
|
||||||
<option value="completed">Completed</option>
|
|
||||||
<option value="failed">Failed</option>
|
|
||||||
<option value="cancelled">Cancelled</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Filter by Rule
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={ruleFilter || ''}
|
|
||||||
onChange={(e) => setRuleFilter(e.target.value ? Number(e.target.value) : null)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
|
|
||||||
>
|
|
||||||
<option value="">All Rules</option>
|
|
||||||
{rules.map(rule => (
|
|
||||||
<option key={rule.id} value={rule.id}>{rule.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-gray-500">Loading tasks...</div>
|
|
||||||
</div>
|
|
||||||
) : filteredTasks.length === 0 ? (
|
|
||||||
<ComponentCard title="No Tasks" desc="No scheduled tasks found">
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
|
||||||
{tasks.length === 0
|
|
||||||
? 'No scheduled tasks have been created yet.'
|
|
||||||
: 'No tasks match the current filters.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{filteredTasks.map((task) => (
|
|
||||||
<ComponentCard
|
|
||||||
key={task.id}
|
|
||||||
title={`Task #${task.id} - ${task.task_type}`}
|
|
||||||
desc={task.rule_name ? `Rule: ${task.rule_name}` : 'Manual task'}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{getStatusBadge(task.status)}
|
|
||||||
{task.retry_count > 0 && (
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Retries: {task.retry_count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<strong className="text-gray-700 dark:text-gray-300">Scheduled:</strong>
|
|
||||||
<div className="text-gray-600 dark:text-gray-400">
|
|
||||||
{formatDate(task.scheduled_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{task.started_at && (
|
|
||||||
<div>
|
|
||||||
<strong className="text-gray-700 dark:text-gray-300">Started:</strong>
|
|
||||||
<div className="text-gray-600 dark:text-gray-400">
|
|
||||||
{formatDate(task.started_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{task.completed_at && (
|
|
||||||
<div>
|
|
||||||
<strong className="text-gray-700 dark:text-gray-300">Completed:</strong>
|
|
||||||
<div className="text-gray-600 dark:text-gray-400">
|
|
||||||
{formatDate(task.completed_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{task.error && (
|
|
||||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<XCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<strong className="text-red-800 dark:text-red-300">Error:</strong>
|
|
||||||
<p className="text-red-700 dark:text-red-400 text-sm mt-1">{task.error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.result && task.status === 'completed' && (
|
|
||||||
<div className="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<strong className="text-green-800 dark:text-green-300">Result:</strong>
|
|
||||||
<pre className="text-green-700 dark:text-green-400 text-xs mt-1 overflow-auto">
|
|
||||||
{JSON.stringify(task.result, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.status === 'failed' && (
|
|
||||||
<div className="flex justify-end pt-2 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={() => handleRetry(task.id)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowRightIcon className="w-4 h-4" />
|
|
||||||
Retry Task
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -485,7 +485,7 @@ export default function PlannerDashboard() {
|
|||||||
<EnhancedMetricCard
|
<EnhancedMetricCard
|
||||||
title="Ideas Generated"
|
title="Ideas Generated"
|
||||||
value={stats.ideas.total}
|
value={stats.ideas.total}
|
||||||
subtitle={`${stats.ideas.queued} queued • ${stats.ideas.notQueued} pending`}
|
subtitle={`${stats.ideas.queued} queued • ${stats.ideas.notQueued} not queued`}
|
||||||
icon={<BoltIcon className="size-6" />}
|
icon={<BoltIcon className="size-6" />}
|
||||||
accentColor="orange"
|
accentColor="orange"
|
||||||
trend={0}
|
trend={0}
|
||||||
|
|||||||
Reference in New Issue
Block a user