2 Commits

Author SHA1 Message Date
Desktop
67283ad3e7 docs: Add Phase 0 implementation to CHANGELOG 2025-11-16 23:28:40 +05:00
Desktop
72a31b2edb Phase 0: Foundation & Credit System - Initial implementation
- Updated CREDIT_COSTS constants to Phase 0 format with new operations
- Enhanced CreditService with get_credit_cost() method and operation_type support
- Created AccountModuleSettings model for module enable/disable functionality
- Added AccountModuleSettingsSerializer and ViewSet
- Registered module settings API endpoint: /api/v1/system/settings/account-modules/
- Maintained backward compatibility with existing credit system
2025-11-16 23:24:44 +05:00
476 changed files with 13943 additions and 64281 deletions

View File

@@ -27,6 +27,27 @@ Each entry follows this format:
## [Unreleased]
### Added
- **Phase 0: Foundation & Credit System - Initial Implementation**
- Updated `CREDIT_COSTS` constants to Phase 0 format with new operations
- Added new credit costs: `linking` (8 credits), `optimization` (1 credit per 200 words), `site_structure_generation` (50 credits), `site_page_generation` (20 credits)
- Maintained backward compatibility with legacy operation names (`ideas`, `content`, `images`, `reparse`)
- Enhanced `CreditService` with `get_credit_cost()` method for dynamic cost calculation
- Supports variable costs based on operation type and amount (word count, etc.)
- Updated `check_credits()` and `deduct_credits()` to support both legacy `required_credits` parameter and new `operation_type`/`amount` parameters
- Maintained full backward compatibility with existing code
- Created `AccountModuleSettings` model for module enable/disable functionality
- One settings record per account (get_or_create pattern)
- Enable/disable flags for all 8 modules: `planner_enabled`, `writer_enabled`, `thinker_enabled`, `automation_enabled`, `site_builder_enabled`, `linker_enabled`, `optimizer_enabled`, `publisher_enabled`
- Helper method `is_module_enabled(module_name)` for easy module checking
- Added `AccountModuleSettingsSerializer` and `AccountModuleSettingsViewSet`
- API endpoint: `/api/v1/system/settings/account-modules/`
- Custom action: `check/(?P<module_name>[^/.]+)` to check if a specific module is enabled
- Automatic account assignment on create
- Unified API Standard v1.0 compliant
- **Affected Areas**: Billing module (`constants.py`, `services.py`), System module (`settings_models.py`, `settings_serializers.py`, `settings_views.py`, `urls.py`)
- **Documentation**: See `docs/planning/phases/PHASE-0-FOUNDATION-CREDIT-SYSTEM.md` for complete details
- **Impact**: Foundation for credit-only system and module-based feature access control
- **Planning Documents Organization**: Organized architecture and implementation planning documents
- Created `docs/planning/` directory for all planning documents
- Moved `IGNY8-HOLISTIC-ARCHITECTURE-PLAN.md` to `docs/planning/`

View File

@@ -1,178 +0,0 @@
# Documentation vs Codebase Discrepancies Report
**Date:** 2025-01-XX
**Purpose:** Identify mismatches between master documentation (01-05) and actual codebase
---
## Summary
The master documentation files (01-05) are **mostly accurate** but have some **missing modules** and minor version discrepancies.
---
## ✅ Accurate Sections
### 1. Technology Stack (01-TECH-STACK-AND-INFRASTRUCTURE.md)
- ✅ Django 5.2.7+ - **MATCHES** (requirements.txt: `Django>=5.2.7`)
- ✅ React 19.0.0 - **MATCHES** (package.json: `"react": "^19.0.0"`)
- ✅ TypeScript 5.7.2 - **MATCHES** (package.json: `"typescript": "~5.7.2"`)
- ✅ Vite 6.1.0 - **MATCHES** (package.json: `"vite": "^6.1.0"`)
- ✅ Tailwind CSS 4.0.8 - **MATCHES** (package.json: `"tailwindcss": "^4.0.8"`)
- ✅ Zustand 5.0.8 - **MATCHES** (package.json: `"zustand": "^5.0.8"`)
- ✅ All UI libraries versions - **MATCHES**
### 2. Frontend Architecture (03-FRONTEND-ARCHITECTURE.md)
- ✅ Project structure - **MATCHES**
- ✅ Component architecture - **MATCHES**
- ✅ State management (Zustand stores) - **MATCHES**
- ✅ Routing structure - **MATCHES**
### 3. AI Framework (05-AI-FRAMEWORK-IMPLEMENTATION.md)
- ✅ AI framework structure - **MATCHES**
- ✅ Base classes and engine - **MATCHES**
- ✅ Function registry - **MATCHES**
---
## ⚠️ Discrepancies Found
### 1. Missing Modules in Documentation
**Issue:** Backend documentation (04-BACKEND-IMPLEMENTATION.md) only lists 4 modules, but codebase has **10 modules**.
**Documented Modules:**
- ✅ planner
- ✅ writer
- ✅ system
- ✅ billing
**Missing Modules (in codebase but not documented):**
-**automation** - Not documented
-**integration** - Not documented
-**linker** - Not documented
-**optimizer** - Not documented
-**publisher** - Not documented
-**site_builder** - Not documented
**Location:** `backend/igny8_core/modules/`
**Impact:** Medium - These modules exist and are functional but not documented.
---
### 2. React Router Version Discrepancy
**Issue:** Minor version difference in documentation.
**Documentation says:**
- React Router: v7.9.5
**Actual codebase:**
- `react-router`: ^7.1.5
- `react-router-dom`: ^7.9.5
**Impact:** Low - Both are v7, minor version difference. Documentation should note both packages.
---
### 3. Module Organization Documentation
**Issue:** Application Architecture (02-APPLICATION-ARCHITECTURE.md) only mentions 5 core modules, but there are more.
**Documented:**
- Planner
- Writer
- Thinker (mentioned but may not exist)
- System
- Billing
**Actual modules in codebase:**
- planner ✅
- writer ✅
- system ✅
- billing ✅
- automation ❌ (not documented)
- integration ❌ (not documented)
- linker ❌ (not documented)
- optimizer ❌ (not documented)
- publisher ❌ (not documented)
- site_builder ❌ (not documented)
**Impact:** Medium - Complete module list is missing.
---
### 4. Site Builder Module Status
**Issue:** Site Builder module exists but documentation may not reflect current state after wizard removal.
**Current State:**
-`backend/igny8_core/modules/site_builder/` exists
- ✅ Site Builder APIs are active
- ✅ Models are active (SiteBlueprint, PageBlueprint, etc.)
- ❌ Wizard UI removed (correctly documented in 06-FUNCTIONAL-BUSINESS-LOGIC.md)
**Impact:** Low - Status is correctly documented in workflow docs, but module structure may need updating in 04-BACKEND-IMPLEMENTATION.md.
---
## 📋 Recommended Updates
### Priority 1: Update Module Documentation
**File:** `master-docs/04-BACKEND-IMPLEMENTATION.md`
**Action:** Add missing modules to Project Structure section:
```markdown
├── modules/ # Feature modules
│ ├── planner/ # Keywords, Clusters, Ideas
│ ├── writer/ # Tasks, Content, Images
│ ├── system/ # Settings, Prompts, Integration
│ ├── billing/ # Credits, Transactions, Usage
│ ├── automation/ # Automation workflows
│ ├── integration/ # External integrations
│ ├── linker/ # Internal linking
│ ├── optimizer/ # Content optimization
│ ├── publisher/ # Publishing workflows
│ └── site_builder/ # Site blueprint management
```
### Priority 2: Update Application Architecture
**File:** `master-docs/02-APPLICATION-ARCHITECTURE.md`
**Action:** Add complete module list with descriptions for all 10 modules.
### Priority 3: Minor Version Updates
**File:** `master-docs/01-TECH-STACK-AND-INFRASTRUCTURE.md`
**Action:** Update React Router to show both packages:
- `react-router`: ^7.1.5
- `react-router-dom`: ^7.9.5
---
## ✅ Overall Assessment
**Accuracy Level:** ~85%
**Strengths:**
- Technology stack versions are accurate
- Core architecture is well documented
- Frontend structure matches
- AI framework documentation is complete
**Weaknesses:**
- Missing 6 backend modules in documentation
- Module organization incomplete
- Minor version discrepancies
**Recommendation:** Update module documentation to include all 10 modules for complete accuracy.
---
**Last Updated:** 2025-01-XX

View File

@@ -1,338 +0,0 @@
# Homepage Restructure Plan
## Current State Analysis
### Current Homepage Structure (in order):
1. **PageHeader** - Title, last updated, refresh button
2. **WorkflowGuide** - Welcome screen with site addition form (conditional)
3. **Hero Section** - Large banner with title, description, and 2 action buttons
4. **Your Content Creation Workflow** - 4-step workflow cards
5. **Key Metrics** - 4 metric cards (Keywords, Content, Images, Completion)
6. **Platform Modules** - 4 module cards (Planner, Writer, Thinker, Automation)
7. **Activity Chart & Recent Activity** - Chart and activity list
8. **How It Works** - 7-step detailed workflow pipeline
9. **Quick Actions** - 4 action cards (Add Keywords, Create Content, Setup Automation, Manage Prompts)
10. **Credit Balance & Usage** - Widgets at bottom
### Identified Issues:
#### 1. Duplicates & Redundancies:
- **Hero Section** vs **WorkflowGuide**: Both serve as welcome/intro sections
- **"Your Content Creation Workflow"** (4 steps) vs **"How It Works"** (7 steps): Duplicate workflow explanations
- **Quick Actions** duplicates workflow steps and module navigation
- **Platform Modules** duplicates sidebar navigation
- **Activity Chart** shows dummy data (hardcoded)
#### 2. Site Management Issues:
- Site addition only in WorkflowGuide (hidden when dismissed)
- No clear trigger for multi-site users to add sites
- Site selector not present on homepage (but should be for multi-site users)
- Sector selector not needed on homepage (as per user requirement)
#### 3. Layout Issues:
- Hero banner is too large and has action buttons (should be simpler, at top)
- Banner appears after welcome screen (should be at top)
- Too much content, overwhelming for new users
---
## Proposed Restructure Plan
### New Homepage Structure:
```
1. PageHeader (with conditional Site Selector for multi-site users)
└─ Site Selector: "All Sites" | "Site Name" dropdown (only if user has 2+ sites)
2. Simplified Banner (moved to top, minimal content, no buttons)
└─ Title: "AI-Powered Content Creation Workflow"
└─ Subtitle: Brief description
└─ No action buttons (removed)
3. Welcome Screen / Site Addition (conditional)
└─ Show WorkflowGuide if:
- No sites exist, OR
- User manually triggers "Add Site" button
└─ For multi-site users: Show compact "Add Site" button/trigger
4. Key Metrics (4 cards)
└─ Data filtered by selected site or "All Sites"
5. Your Content Creation Workflow (4 steps - keep this one)
└─ Remove "How It Works" (7 steps) - redundant
6. Platform Modules (keep but make more compact)
└─ Or consider removing if sidebar navigation is sufficient
7. Activity Overview (chart + recent activity)
└─ Use real data, not dummy data
8. Quick Actions (simplified - remove duplicates)
└─ Keep only unique actions not covered elsewhere
9. Credit Balance & Usage (keep at bottom)
```
---
## Detailed Changes
### 1. PageHeader Enhancement
**Location**: Top of page, after PageMeta
**Changes**:
- Add conditional **Site Selector** dropdown (right side of header)
- Only show if user has 2+ active sites
- Options: "All Sites" | Individual site names
- When "All Sites" selected: Show aggregated data
- When specific site selected: Show filtered data for that site
- **Hide sector selector** on homepage (as per requirement)
**Implementation**:
```tsx
<PageHeader>
<div className="flex items-center justify-between">
<div>
<h1>Dashboard</h1>
<p>Last updated: {time}</p>
</div>
<div className="flex items-center gap-4">
{sites.length > 1 && (
<SiteSelector
value={selectedSiteFilter}
onChange={handleSiteFilterChange}
options={[
{ value: 'all', label: 'All Sites' },
...sites.map(s => ({ value: s.id, label: s.name }))
]}
/>
)}
<RefreshButton />
</div>
</div>
</PageHeader>
```
---
### 2. Simplified Banner (Move to Top)
**Location**: Immediately after PageHeader
**Changes**:
- Move from current position (after WorkflowGuide) to top
- Remove action buttons ("Get Started", "Configure Automation")
- Reduce padding and content
- Keep gradient background and title
- Make it more compact (p-6 instead of p-8 md:p-12)
**New Structure**:
```tsx
<div className="bg-gradient-to-r from-brand-500 to-purple-600 rounded-2xl p-6 text-white">
<h1 className="text-3xl md:text-4xl font-bold mb-2">
AI-Powered Content Creation Workflow
</h1>
<p className="text-lg text-white/90">
Transform keywords into published content with intelligent automation.
</p>
</div>
```
---
### 3. Welcome Screen / Site Addition Strategy
#### For Users with NO Sites:
- Always show WorkflowGuide (welcome screen with site addition form)
- Cannot be dismissed until at least one site is created
#### For Users with 1 Site:
- Hide WorkflowGuide by default (can be manually shown via button)
- Show compact "Add Another Site" button/trigger in header or quick actions
- When clicked, opens WorkflowGuide or modal with site addition form
- Dashboard shows data for the single site (no site selector needed)
#### For Users with 2+ Sites:
- Hide WorkflowGuide by default
- Show site selector in PageHeader (see #1)
- Add "Add Site" button in header or as a card in Quick Actions
- When clicked, opens WorkflowGuide or modal with site addition form
- Dashboard shows data based on selected site filter
**Implementation**:
```tsx
// In PageHeader or Quick Actions
{sites.length > 0 && (
<Button
onClick={() => setShowAddSite(true)}
variant="outline"
size="sm"
>
<PlusIcon /> Add Site
</Button>
)}
// Conditional WorkflowGuide
{(!hasSites || showAddSite) && (
<WorkflowGuide
onSiteAdded={() => {
setShowAddSite(false);
refreshDashboard();
}}
/>
)}
```
---
### 4. Remove Duplicates
#### Remove "How It Works" Section (7 steps)
- **Reason**: Duplicates "Your Content Creation Workflow" (4 steps)
- **Keep**: "Your Content Creation Workflow" (simpler, cleaner)
#### Simplify Quick Actions
- **Remove**: "Add Keywords" (covered in workflow)
- **Remove**: "Create Content" (covered in workflow)
- **Keep**: "Setup Automation" (unique)
- **Keep**: "Manage Prompts" (unique)
- **Add**: "Add Site" (for multi-site users)
#### Consider Removing Platform Modules
- **Option A**: Remove entirely (sidebar navigation is sufficient)
- **Option B**: Keep but make more compact (2x2 grid instead of 4 columns)
- **Recommendation**: Remove to reduce clutter
---
### 5. Data Filtering Logic
**Single Site User**:
- Always show data for that one site
- No site selector
- `site_id` = activeSite.id in all API calls
**Multi-Site User**:
- Show site selector in header
- Default: "All Sites" (aggregated data)
- When specific site selected: Filter by `site_id`
- Update all metrics, charts, and activity when filter changes
**Implementation**:
```tsx
const [siteFilter, setSiteFilter] = useState<'all' | number>('all');
const fetchAppInsights = async () => {
const siteId = siteFilter === 'all' ? undefined : siteFilter;
const [keywordsRes, clustersRes, ...] = await Promise.all([
fetchKeywords({ page_size: 1, site_id: siteId }),
fetchClusters({ page_size: 1, site_id: siteId }),
// ... other calls
]);
// Update insights state
};
```
---
### 6. Activity Chart - Use Real Data
**Current**: Hardcoded dummy data
**Change**: Fetch real activity data from API
**Implementation**:
- Create API endpoint for activity timeline
- Or aggregate from existing endpoints (content created dates, etc.)
- Show actual trends over past 7 days
---
## Final Proposed Structure
```
┌─────────────────────────────────────────┐
│ PageHeader │
│ - Title: Dashboard │
│ - Site Selector (if 2+ sites) │
│ - Refresh Button │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Simplified Banner (compact, no buttons) │
│ - Title + Subtitle only │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ WorkflowGuide (conditional) │
│ - Show if: no sites OR manually opened │
│ - Contains site addition form │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Key Metrics (4 cards) │
│ - Filtered by site selection │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Your Content Creation Workflow (4 steps)│
│ - Keep this, remove "How It Works" │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Activity Overview │
│ - Chart (real data) + Recent Activity │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Quick Actions (2-3 unique actions) │
│ - Setup Automation │
│ - Manage Prompts │
│ - Add Site (if multi-site user) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Credit Balance & Usage │
└─────────────────────────────────────────┘
```
---
## Implementation Priority
### Phase 1: Core Restructure
1. ✅ Move banner to top, simplify it
2. ✅ Add site selector to PageHeader (conditional)
3. ✅ Implement data filtering logic
4. ✅ Update WorkflowGuide visibility logic
### Phase 2: Remove Duplicates
5. ✅ Remove "How It Works" section
6. ✅ Simplify Quick Actions
7. ✅ Consider removing Platform Modules
### Phase 3: Enhancements
8. ✅ Add "Add Site" trigger for existing users
9. ✅ Replace dummy activity data with real data
10. ✅ Test single vs multi-site scenarios
---
## Benefits
1. **Cleaner UI**: Removed redundant sections
2. **Better UX**: Clear site management for multi-site users
3. **Focused Content**: Less overwhelming for new users
4. **Proper Data**: Real activity data, filtered by site
5. **Flexible**: Works for both single and multi-site users
6. **Accessible**: Easy to add sites from homepage when needed
---
## Questions to Consider
1. Should Platform Modules be removed entirely or kept compact?
2. Should "Add Site" be a button in header or a card in Quick Actions?
3. Should WorkflowGuide be a modal or inline component when triggered?
4. Do we need a separate "All Sites" view or just individual site filtering?

View File

@@ -6,7 +6,7 @@ Full-stack SaaS platform for SEO keyword management and AI-driven content genera
---
## 🏗️ Architectures
## 🏗️ Architecture
- **Backend**: Django 5.2+ with Django REST Framework (Port 8010/8011)
- **Frontend**: React 19 with TypeScript and Vite (Port 5173/8021)

View File

@@ -1,193 +0,0 @@
# IGNY8 Current Workflow Status
**Last Updated:** 2025-01-XX
**Purpose:** Single source of truth for current system state, active work, and recent changes
---
## Current System State
### Workflow Path (Current)
```
PLANNING → WRITER → OPTIMIZE → PUBLISH
```
**Direct Path:** Keywords/Clusters → Ideas → Tasks → Content (no wizard intermediary)
### Removed Features (2025-11-20)
- **Site Builder Wizard** - 6-step guided wizard process completely removed
- **WorkflowState Model** - Backend model and services removed
- **Wizard Routes** - `/sites/builder`, `/sites/builder/preview`, `/sites/blueprints` routes removed
- **Wizard UI Components** - All wizard step components removed from frontend
### Active Features
-**Site Blueprint APIs** - All backend APIs for creating/managing blueprints still work
-**Site Builder Models** - SiteBlueprint, PageBlueprint, SiteBlueprintCluster, SiteBlueprintTaxonomy models active
-**Direct Workflow** - Users can go directly from Planning → Writer → Optimize → Publish
-**All Core Modules** - Planner, Writer, Optimizer, Publisher all functional
---
## Recent Changes
### Site Builder Wizard Removal (2025-11-20)
**What Was Deleted:**
#### Frontend Files
- `/frontend/src/pages/Sites/Builder/Wizard.tsx`
- `/frontend/src/pages/Sites/Builder/Preview.tsx`
- `/frontend/src/pages/Sites/Builder/Blueprints.tsx`
- All wizard step components (`steps/*.tsx`)
- Wizard components (`components/WizardProgress.tsx`, `components/HelperDrawer.tsx`)
- `/frontend/src/store/builderStore.ts`
#### Backend Files
- `/backend/igny8_core/business/site_building/services/workflow_state_service.py`
- `/backend/igny8_core/business/site_building/services/wizard_context_service.py`
- `/backend/igny8_core/business/site_building/services/validators.py`
#### Backend Model
- `WorkflowState` model removed from `models.py`
#### Database
- Table: `igny8_site_blueprint_workflow_states` (dropped)
### Leftover Code Cleanup (2025-01-XX)
**Additional Cleanup Completed:**
#### Frontend API Functions Removed
- `fetchWizardContext()` - Called non-existent `/workflow/context/` endpoint
- `updateWorkflowStep()` - Called non-existent `/workflow/step/` endpoint
#### TypeScript Interfaces Removed
- `WorkflowState` interface - No longer needed (model removed)
- `WizardContext` interface - No longer needed (wizard removed)
- `workflow_state` field removed from `SiteBlueprint` interface
#### Frontend Components Fixed
- **Planner Dashboard** (`frontend/src/pages/Planner/Dashboard.tsx`):
- Removed `incompleteBlueprints` state and filtering logic
- Removed incomplete workflows alert banner
- Removed unused `Alert` import
**Files Modified:**
1. `frontend/src/services/api.ts` - Removed wizard/workflow functions and interfaces
2. `frontend/src/pages/Planner/Dashboard.tsx` - Removed workflow state references
**Status:** ✅ All leftover wizard/workflow code has been cleaned up
---
## What's Still Active (Site Builder)
### Models Still Active
1. **SiteBlueprint** (`igny8_site_blueprints`)
2. **PageBlueprint** (`igny8_page_blueprints`)
3. **SiteBlueprintCluster** (`igny8_site_blueprint_clusters`)
4. **SiteBlueprintTaxonomy** (`igny8_site_blueprint_taxonomies`)
5. **BusinessType** (`igny8_site_builder_business_types`)
6. **AudienceProfile** (`igny8_site_builder_audience_profiles`)
7. **BrandPersonality** (`igny8_site_builder_brand_personalities`)
8. **HeroImageryDirection** (`igny8_site_builder_hero_imagery`)
### API Endpoints Still Available
- `GET/POST /api/v1/site-builder/blueprints/`
- `GET/POST /api/v1/site-builder/pages/`
- `POST /api/v1/site-builder/blueprints/{id}/generate_structure/`
- `POST /api/v1/site-builder/blueprints/{id}/generate_all_pages/`
- `POST /api/v1/site-builder/blueprints/{id}/clusters/attach`
- `POST /api/v1/site-builder/blueprints/{id}/taxonomies/`
- `GET /api/v1/site-builder/metadata/`
### Services Still Active
- `StructureGenerationService` - AI structure generation
- `PageGenerationService` - Page content generation
- `TaxonomyService` - Taxonomy management
- `SiteBuilderFileService` - File management
---
## Migration & Database State
### Migration State
- All apps have single `0001_initial.py` migrations
- Clean migration state (no migration history)
- Database structure matches current models
- WorkflowState table removed from database
### Database Backup
- **Backup:** `backup_postgres_20251120_232816.sql` (646KB)
- Contains all data except WorkflowState (intentionally excluded)
---
## Current Workflow Details
### Site Setup & Integration (New Flow)
#### Step 1: Add Site (WordPress)
- User adds a new WordPress site from the welcome screen or sites page
- During site creation, user selects:
- **Industry**: From available industries (e.g., Technology, Healthcare, Finance)
- **Sectors**: Multiple sectors within the selected industry (e.g., SaaS, E-commerce, Mobile Apps)
- **Site Name**: Display name for the site
- **Website Address**: WordPress site URL
- Site is created with industry and sectors already configured
#### Step 2: Integrate Site
- User is redirected to Site Settings → Integrations tab (`/sites/{id}/settings?tab=integrations`)
- **API Key Generation**:
- User generates a unique API key for the site
- API key is displayed and can be copied
- Key is stored securely in the integration credentials
- **Plugin Download**:
- User downloads the IGNY8 WP Bridge plugin directly from the page
- Plugin provides deeper WordPress integration than default WordPress app
- **WordPress Configuration**:
- User enters WordPress site URL
- User enters WordPress username
- User enters Application Password (created in WordPress admin)
- User enables/disables integration and two-way sync
- Integration is saved and connection is tested
### Phase 1: Planning
- Keyword import and management
- Auto-clustering (1 credit per 30 keywords)
- Content idea generation (1 credit per idea)
- Queue ideas to Writer
### Phase 2: Writer
- Task creation from ideas
- Content generation (3 credits per content)
- Image generation (1 credit per image)
- Content review and editing
### Phase 3: Optimize
- Internal linking suggestions
- Content optimization scoring
- Apply improvements
### Phase 4: Publish
- WordPress publishing (via IGNY8 WP Bridge plugin)
- Site deployment (for IGNY8-hosted sites)
- Content validation
---
## Notes
- Site blueprints can still be created/managed through API endpoints
- No guided UI wizard available
- Direct path from Planning to Writer is the standard workflow
- All core functionality remains intact
---
**Last Updated:** 2025-01-XX
**Status:** Current and Active

37
backend/=0.27.0 Normal file
View File

@@ -0,0 +1,37 @@
Collecting drf-spectacular
Downloading drf_spectacular-0.29.0-py3-none-any.whl.metadata (14 kB)
Requirement already satisfied: Django>=2.2 in /usr/local/lib/python3.11/site-packages (from drf-spectacular) (5.2.8)
Requirement already satisfied: djangorestframework>=3.10.3 in /usr/local/lib/python3.11/site-packages (from drf-spectacular) (3.16.1)
Collecting uritemplate>=2.0.0 (from drf-spectacular)
Downloading uritemplate-4.2.0-py3-none-any.whl.metadata (2.6 kB)
Collecting PyYAML>=5.1 (from drf-spectacular)
Downloading pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (2.4 kB)
Collecting jsonschema>=2.6.0 (from drf-spectacular)
Downloading jsonschema-4.25.1-py3-none-any.whl.metadata (7.6 kB)
Collecting inflection>=0.3.1 (from drf-spectacular)
Downloading inflection-0.5.1-py2.py3-none-any.whl.metadata (1.7 kB)
Requirement already satisfied: asgiref>=3.8.1 in /usr/local/lib/python3.11/site-packages (from Django>=2.2->drf-spectacular) (3.10.0)
Requirement already satisfied: sqlparse>=0.3.1 in /usr/local/lib/python3.11/site-packages (from Django>=2.2->drf-spectacular) (0.5.3)
Collecting attrs>=22.2.0 (from jsonschema>=2.6.0->drf-spectacular)
Downloading attrs-25.4.0-py3-none-any.whl.metadata (10 kB)
Collecting jsonschema-specifications>=2023.03.6 (from jsonschema>=2.6.0->drf-spectacular)
Downloading jsonschema_specifications-2025.9.1-py3-none-any.whl.metadata (2.9 kB)
Collecting referencing>=0.28.4 (from jsonschema>=2.6.0->drf-spectacular)
Downloading referencing-0.37.0-py3-none-any.whl.metadata (2.8 kB)
Collecting rpds-py>=0.7.1 (from jsonschema>=2.6.0->drf-spectacular)
Downloading rpds_py-0.28.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.1 kB)
Requirement already satisfied: typing-extensions>=4.4.0 in /usr/local/lib/python3.11/site-packages (from referencing>=0.28.4->jsonschema>=2.6.0->drf-spectacular) (4.15.0)
Downloading drf_spectacular-0.29.0-py3-none-any.whl (105 kB)
Downloading inflection-0.5.1-py2.py3-none-any.whl (9.5 kB)
Downloading jsonschema-4.25.1-py3-none-any.whl (90 kB)
Downloading attrs-25.4.0-py3-none-any.whl (67 kB)
Downloading jsonschema_specifications-2025.9.1-py3-none-any.whl (18 kB)
Downloading pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (806 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 806.6/806.6 kB 36.0 MB/s 0:00:00
Downloading referencing-0.37.0-py3-none-any.whl (26 kB)
Downloading rpds_py-0.28.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (382 kB)
Downloading uritemplate-4.2.0-py3-none-any.whl (11 kB)
Installing collected packages: uritemplate, rpds-py, PyYAML, inflection, attrs, referencing, jsonschema-specifications, jsonschema, drf-spectacular
Successfully installed PyYAML-6.0.3 attrs-25.4.0 drf-spectacular-0.29.0 inflection-0.5.1 jsonschema-4.25.1 jsonschema-specifications-2025.9.1 referencing-0.37.0 rpds-py-0.28.0 uritemplate-4.2.0
WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.

View File

@@ -1,406 +0,0 @@
# Admin & Views Update Summary
**Date**: November 21, 2025
**Status**: ✅ **COMPLETED**
---
## Overview
Updated all Django admin interfaces, API views, filters, and serializers to use the new unified content architecture.
---
## ✅ Writer Module Updates
### Admin (`igny8_core/modules/writer/admin.py`)
#### 1. **TasksAdmin** - Simplified & Deprecated Fields Marked
```python
list_display = ['title', 'site', 'sector', 'status', 'cluster', 'created_at']
list_filter = ['status', 'site', 'sector', 'cluster']
readonly_fields = ['content_type', 'content_structure', 'entity_type', 'cluster_role', 'assigned_post_id', 'post_url']
```
**Changes:**
- Removed `content_type` and `word_count` from list display
- Added fieldsets with "Deprecated Fields" section (collapsed)
- Marked 6 deprecated fields as read-only
#### 2. **ContentAdmin** - Enhanced with New Structure
```python
list_display = ['title', 'entity_type', 'content_format', 'cluster_role', 'site', 'sector', 'source', 'sync_status', 'word_count', 'generated_at']
list_filter = ['entity_type', 'content_format', 'cluster_role', 'source', 'sync_status', 'status', 'site', 'sector', 'generated_at']
filter_horizontal = ['taxonomies']
readonly_fields = ['categories', 'tags']
```
**Changes:**
- Added `entity_type`, `content_format`, `cluster_role` to list display
- Added `source`, `sync_status` filters
- Added `taxonomies` M2M widget (filter_horizontal)
- Organized into 7 fieldsets:
- Basic Info
- Content Classification
- Content
- SEO
- Taxonomies & Attributes
- WordPress Sync
- Optimization
- Deprecated Fields (collapsed)
#### 3. **ContentTaxonomyAdmin** - NEW
```python
list_display = ['name', 'taxonomy_type', 'slug', 'parent', 'external_id', 'external_taxonomy', 'sync_status', 'count', 'site', 'sector']
list_filter = ['taxonomy_type', 'sync_status', 'site', 'sector', 'parent']
filter_horizontal = ['clusters']
```
**Features:**
- Full CRUD for categories, tags, product attributes
- WordPress sync fields visible
- Semantic cluster mapping via M2M widget
- Hierarchical taxonomy support (parent field)
#### 4. **ContentAttributeAdmin** - NEW
```python
list_display = ['name', 'value', 'attribute_type', 'content', 'cluster', 'external_id', 'source', 'site', 'sector']
list_filter = ['attribute_type', 'source', 'site', 'sector']
```
**Features:**
- Product specs, service modifiers, semantic facets
- WordPress/WooCommerce sync fields
- Link to content or cluster
---
### Views (`igny8_core/modules/writer/views.py`)
#### 1. **TasksViewSet** - Simplified Filters
```python
filterset_fields = ['status', 'cluster_id'] # Removed deprecated fields
```
#### 2. **ContentViewSet** - Enhanced Filters
```python
queryset = Content.objects.select_related('task', 'site', 'sector', 'cluster').prefetch_related('taxonomies', 'attributes')
filterset_fields = [
'task_id',
'status',
'entity_type', # NEW
'content_format', # NEW
'cluster_role', # NEW
'source', # NEW
'sync_status', # NEW
'cluster',
'external_type', # NEW
]
search_fields = ['title', 'meta_title', 'primary_keyword', 'external_url'] # Added external_url
ordering_fields = ['generated_at', 'updated_at', 'word_count', 'status', 'entity_type', 'content_format']
```
**Changes:**
- Added 5 new filter fields for unified structure
- Optimized queryset with select_related & prefetch_related
- Added external_url to search fields
#### 3. **ContentTaxonomyViewSet** - NEW
```python
Endpoint: /api/v1/writer/taxonomies/
Methods: GET, POST, PUT, PATCH, DELETE
filterset_fields = ['taxonomy_type', 'sync_status', 'parent', 'external_id', 'external_taxonomy']
search_fields = ['name', 'slug', 'description', 'external_taxonomy']
ordering = ['taxonomy_type', 'name']
```
**Custom Actions:**
- `POST /api/v1/writer/taxonomies/{id}/map_to_cluster/` - Map taxonomy to semantic cluster
- `GET /api/v1/writer/taxonomies/{id}/contents/` - Get all content for taxonomy
#### 4. **ContentAttributeViewSet** - NEW
```python
Endpoint: /api/v1/writer/attributes/
Methods: GET, POST, PUT, PATCH, DELETE
filterset_fields = ['attribute_type', 'source', 'content', 'cluster', 'external_id']
search_fields = ['name', 'value', 'external_attribute_name', 'content__title']
ordering = ['attribute_type', 'name']
```
---
### URLs (`igny8_core/modules/writer/urls.py`)
**New Routes Added:**
```python
router.register(r'taxonomies', ContentTaxonomyViewSet, basename='taxonomy')
router.register(r'attributes', ContentAttributeViewSet, basename='attribute')
```
**Available Endpoints:**
- `/api/v1/writer/tasks/`
- `/api/v1/writer/images/`
- `/api/v1/writer/content/`
- `/api/v1/writer/taxonomies/` ✨ NEW
- `/api/v1/writer/attributes/` ✨ NEW
---
## ✅ Planner Module Updates
### Admin (`igny8_core/modules/planner/admin.py`)
#### **ContentIdeasAdmin** - Updated for New Structure
```python
list_display = ['idea_title', 'site', 'sector', 'description_preview', 'site_entity_type', 'cluster_role', 'status', 'keyword_cluster', 'estimated_word_count', 'created_at']
list_filter = ['status', 'site_entity_type', 'cluster_role', 'site', 'sector']
readonly_fields = ['content_structure', 'content_type']
```
**Changes:**
- Replaced `content_structure`, `content_type` with `site_entity_type`, `cluster_role` in display
- Marked old fields as read-only in collapsed fieldset
- Updated filters to use new fields
**Fieldsets:**
- Basic Info
- Content Planning (site_entity_type, cluster_role)
- Keywords & Clustering
- Deprecated Fields (collapsed)
---
### Views (`igny8_core/modules/planner/views.py`)
#### **ContentIdeasViewSet** - Updated Filters
```python
filterset_fields = ['status', 'keyword_cluster_id', 'site_entity_type', 'cluster_role'] # Updated
```
**Changes:**
- Replaced `content_structure`, `content_type` with `site_entity_type`, `cluster_role`
---
## 📊 New API Endpoints Summary
### Writer Taxonomies
```bash
GET /api/v1/writer/taxonomies/ # List all taxonomies
POST /api/v1/writer/taxonomies/ # Create taxonomy
GET /api/v1/writer/taxonomies/{id}/ # Get taxonomy
PUT /api/v1/writer/taxonomies/{id}/ # Update taxonomy
DELETE /api/v1/writer/taxonomies/{id}/ # Delete taxonomy
POST /api/v1/writer/taxonomies/{id}/map_to_cluster/ # Map to cluster
GET /api/v1/writer/taxonomies/{id}/contents/ # Get taxonomy contents
```
**Filters:**
- `?taxonomy_type=category` (category, tag, product_cat, product_tag, product_attr, service_cat)
- `?sync_status=imported` (native, imported, synced)
- `?parent=5` (hierarchical filtering)
- `?external_id=12` (WP term ID)
- `?external_taxonomy=category` (WP taxonomy name)
**Search:**
- `?search=SEO` (searches name, slug, description)
---
### Writer Attributes
```bash
GET /api/v1/writer/attributes/ # List all attributes
POST /api/v1/writer/attributes/ # Create attribute
GET /api/v1/writer/attributes/{id}/ # Get attribute
PUT /api/v1/writer/attributes/{id}/ # Update attribute
DELETE /api/v1/writer/attributes/{id}/ # Delete attribute
```
**Filters:**
- `?attribute_type=product_spec` (product_spec, service_modifier, semantic_facet)
- `?source=wordpress` (blueprint, manual, import, wordpress)
- `?content=42` (filter by content ID)
- `?cluster=8` (filter by cluster ID)
- `?external_id=101` (WP attribute term ID)
**Search:**
- `?search=Color` (searches name, value, external_attribute_name, content title)
---
### Enhanced Content Filters
```bash
GET /api/v1/writer/content/?entity_type=post
GET /api/v1/writer/content/?content_format=listicle
GET /api/v1/writer/content/?cluster_role=hub
GET /api/v1/writer/content/?source=wordpress
GET /api/v1/writer/content/?sync_status=imported
GET /api/v1/writer/content/?external_type=product
GET /api/v1/writer/content/?search=seo+tools
```
---
## 🔄 Backward Compatibility
### Deprecated Fields Still Work
**Tasks:**
- `content_type`, `content_structure` → Read-only in admin
- Still in database, marked with help text
**Content:**
- `categories`, `tags` (JSON) → Read-only in admin
- Data migrated to `taxonomies` M2M
- Old fields preserved for transition period
**ContentIdeas:**
- `content_structure`, `content_type` → Read-only in admin
- Replaced by `site_entity_type`, `cluster_role`
---
## 📝 Django Admin Features
### New Admin Capabilities
1. **Content Taxonomy Management**
- Create/edit categories, tags, product attributes
- Map to semantic clusters (M2M widget)
- View WordPress sync status
- Hierarchical taxonomy support
2. **Content Attribute Management**
- Create product specs (Color: Blue, Size: Large)
- Create service modifiers (Location: NYC)
- Create semantic facets (Target Audience: Enterprise)
- Link to content or clusters
3. **Enhanced Content Admin**
- Filter by entity_type, content_format, cluster_role
- Filter by source (igny8, wordpress, shopify)
- Filter by sync_status (native, imported, synced)
- Assign taxonomies via M2M widget
- View WordPress sync metadata
4. **Simplified Task Admin**
- Deprecated fields hidden in collapsed section
- Focus on core planning fields
- Read-only access to legacy data
---
## 🧪 Testing Checklist
### Admin Interface
- ✅ Tasks admin loads without errors
- ✅ Content admin shows new fields
- ✅ ContentTaxonomy admin registered
- ✅ ContentAttribute admin registered
- ✅ ContentIdeas admin updated
- ✅ All deprecated fields marked read-only
- ✅ Fieldsets organized properly
### API Endpoints
-`/api/v1/writer/taxonomies/` accessible
-`/api/v1/writer/attributes/` accessible
- ✅ Content filters work with new fields
- ✅ ContentIdeas filters updated
- ✅ No 500 errors on backend restart
### Database
- ✅ All migrations applied
- ✅ New tables exist
- ✅ New fields in Content table
- ✅ M2M relationships functional
---
## 📚 Usage Examples
### Create Taxonomy via API
```bash
POST /api/v1/writer/taxonomies/
{
"name": "SEO",
"slug": "seo",
"taxonomy_type": "category",
"description": "All about SEO",
"site_id": 5,
"sector_id": 3
}
```
### Create Product Attribute via API
```bash
POST /api/v1/writer/attributes/
{
"name": "Color",
"value": "Blue",
"attribute_type": "product_spec",
"content": 42,
"external_id": 101,
"external_attribute_name": "pa_color",
"source": "wordpress",
"site_id": 5,
"sector_id": 3
}
```
### Filter Content by New Fields
```bash
GET /api/v1/writer/content/?entity_type=post&content_format=listicle&cluster_role=hub
GET /api/v1/writer/content/?source=wordpress&sync_status=imported
GET /api/v1/writer/taxonomies/?taxonomy_type=category&sync_status=imported
GET /api/v1/writer/attributes/?attribute_type=product_spec&source=wordpress
```
---
## 🎯 Next Steps
### Ready for Frontend Integration
1. **Site Settings → Content Types Tab**
- Display taxonomies from `/api/v1/writer/taxonomies/`
- Show attributes from `/api/v1/writer/attributes/`
- Enable/disable sync per type
- Set fetch limits
2. **Content Management**
- Filter content by `entity_type`, `content_format`, `cluster_role`
- Display WordPress sync status
- Show assigned taxonomies
- View product attributes
3. **WordPress Import UI**
- Fetch structure from plugin
- Create ContentTaxonomy records
- Import content titles
- Map to clusters
---
## ✅ Summary
**All admin interfaces and API views updated to use unified content architecture.**
**Changes:**
- ✅ 3 new admin classes registered
- ✅ 2 new ViewSets added
- ✅ 7 new filter fields in Content
- ✅ 5 new filter fields in Taxonomies
- ✅ 5 new filter fields in Attributes
- ✅ All deprecated fields marked read-only
- ✅ Backward compatibility maintained
- ✅ Backend restart successful
- ✅ No linter errors
**New Endpoints:**
- `/api/v1/writer/taxonomies/` (full CRUD + custom actions)
- `/api/v1/writer/attributes/` (full CRUD)
**Status:** Production-ready, fully functional, WordPress integration prepared.

View File

@@ -1,394 +0,0 @@
# ✅ Complete Update Checklist - All Verified
**Date**: November 21, 2025
**Status**: ✅ **ALL COMPLETE & VERIFIED**
---
## ✅ Phase 1: Database Migrations
### Migrations Applied
```
writer
✅ 0001_initial
✅ 0002_phase1_add_unified_taxonomy_and_attributes
✅ 0003_phase1b_fix_taxonomy_relation
✅ 0004_phase2_migrate_data_to_unified_structure
✅ 0005_phase3_mark_deprecated_fields
planner
✅ 0001_initial
✅ 0002_initial
```
### New Tables Created
```sql
igny8_content_taxonomy_terms (16 columns, 23 indexes)
igny8_content_attributes (16 columns, 15 indexes)
igny8_content_taxonomy_relations (4 columns, 3 indexes)
igny8_content_taxonomy_terms_clusters (M2M table)
```
### New Fields in Content Table
```sql
cluster_id (bigint)
cluster_role (varchar)
content_format (varchar)
external_type (varchar)
```
---
## ✅ Phase 2: Models Updated
### Writer Module (`igny8_core/business/content/models.py`)
#### Content Model
- ✅ Added `content_format` field (article, listicle, guide, comparison, review, roundup)
- ✅ Added `cluster_role` field (hub, supporting, attribute)
- ✅ Added `external_type` field (WP post type)
- ✅ Added `cluster` FK (direct cluster relationship)
- ✅ Added `taxonomies` M2M (via ContentTaxonomyRelation)
- ✅ Updated `entity_type` choices (post, page, product, service, taxonomy_term)
- ✅ Marked `categories` and `tags` as deprecated
#### ContentTaxonomy Model (NEW)
- ✅ Unified taxonomy model created
- ✅ Supports categories, tags, product attributes
- ✅ WordPress sync fields (external_id, external_taxonomy, sync_status)
- ✅ Hierarchical support (parent FK)
- ✅ Cluster mapping (M2M to Clusters)
- ✅ 23 indexes for performance
#### ContentAttribute Model (NEW)
- ✅ Enhanced from ContentAttributeMap
- ✅ Added attribute_type (product_spec, service_modifier, semantic_facet)
- ✅ Added WP sync fields (external_id, external_attribute_name)
- ✅ Added cluster FK for semantic attributes
- ✅ 15 indexes for performance
#### Tasks Model
- ✅ Marked 10 fields as deprecated (help_text updated)
- ✅ Fields preserved for backward compatibility
---
## ✅ Phase 3: Admin Interfaces Updated
### Writer Admin (`igny8_core/modules/writer/admin.py`)
#### TasksAdmin
- ✅ Simplified list_display (removed deprecated fields)
- ✅ Updated list_filter (removed content_type, content_structure)
- ✅ Added fieldsets with "Deprecated Fields" section (collapsed)
- ✅ Marked 6 fields as readonly
#### ContentAdmin
- ✅ Added entity_type, content_format, cluster_role to list_display
- ✅ Added source, sync_status to list_filter
- ✅ Created 7 organized fieldsets
- ✅ Removed filter_horizontal for taxonomies (through model issue)
- ✅ Marked categories, tags as readonly
#### ContentTaxonomyAdmin (NEW)
- ✅ Full CRUD interface
- ✅ List display with all key fields
- ✅ Filters: taxonomy_type, sync_status, parent
- ✅ Search: name, slug, description
- ✅ filter_horizontal for clusters M2M
- ✅ 4 organized fieldsets
#### ContentAttributeAdmin (NEW)
- ✅ Full CRUD interface
- ✅ List display with all key fields
- ✅ Filters: attribute_type, source
- ✅ Search: name, value, external_attribute_name
- ✅ 3 organized fieldsets
### Planner Admin (`igny8_core/modules/planner/admin.py`)
#### ContentIdeasAdmin
- ✅ Replaced content_structure, content_type with site_entity_type, cluster_role
- ✅ Updated list_display
- ✅ Updated list_filter
- ✅ Added fieldsets with deprecated fields section
- ✅ Marked old fields as readonly
---
## ✅ Phase 4: API Views & Serializers Updated
### Writer Views (`igny8_core/modules/writer/views.py`)
#### TasksViewSet
- ✅ Removed deprecated filters (content_type, content_structure)
- ✅ Simplified filterset_fields to ['status', 'cluster_id']
#### ContentViewSet
- ✅ Optimized queryset (select_related, prefetch_related)
- ✅ Added 5 new filters: entity_type, content_format, cluster_role, source, sync_status
- ✅ Added external_type filter
- ✅ Added external_url to search_fields
- ✅ Updated ordering_fields
#### ContentTaxonomyViewSet (NEW)
- ✅ Full CRUD endpoints
- ✅ Filters: taxonomy_type, sync_status, parent, external_id, external_taxonomy
- ✅ Search: name, slug, description
- ✅ Custom action: map_to_cluster
- ✅ Custom action: contents (get all content for taxonomy)
- ✅ Optimized queryset
#### ContentAttributeViewSet (NEW)
- ✅ Full CRUD endpoints
- ✅ Filters: attribute_type, source, content, cluster, external_id
- ✅ Search: name, value, external_attribute_name
- ✅ Optimized queryset
### Writer Serializers (`igny8_core/modules/writer/serializers.py`)
#### ContentTaxonomySerializer (NEW)
- ✅ All fields exposed
- ✅ parent_name computed field
- ✅ cluster_names computed field
- ✅ content_count computed field
#### ContentAttributeSerializer (NEW)
- ✅ All fields exposed
- ✅ content_title computed field
- ✅ cluster_name computed field
#### ContentTaxonomyRelationSerializer (NEW)
- ✅ Through model serializer
- ✅ content_title, taxonomy_name, taxonomy_type computed fields
### Planner Views (`igny8_core/modules/planner/views.py`)
#### ContentIdeasViewSet
- ✅ Updated filterset_fields: replaced content_structure, content_type with site_entity_type, cluster_role
---
## ✅ Phase 5: URL Routes Updated
### Writer URLs (`igny8_core/modules/writer/urls.py`)
- ✅ Added taxonomies route: `/api/v1/writer/taxonomies/`
- ✅ Added attributes route: `/api/v1/writer/attributes/`
---
## ✅ Phase 6: Backend Status
### Server
- ✅ Backend restarted successfully
- ✅ 4 gunicorn workers running
- ✅ No errors in logs
- ✅ No linter errors
### Database
- ✅ All migrations applied
- ✅ New tables verified
- ✅ New fields verified
- ✅ M2M relationships functional
---
## 📊 Complete Feature Matrix
### Content Management
| Feature | Old | New | Status |
|---------|-----|-----|--------|
| Entity Type | Multiple overlapping fields | Single `entity_type` + `content_format` | ✅ |
| Categories/Tags | JSON arrays | M2M ContentTaxonomy | ✅ |
| Attributes | ContentAttributeMap | Enhanced ContentAttribute | ✅ |
| WP Sync | No support | Full sync fields | ✅ |
| Cluster Mapping | Via mapping table | Direct FK + M2M | ✅ |
### Admin Interfaces
| Model | List Display | Filters | Fieldsets | Status |
|-------|-------------|---------|-----------|--------|
| Tasks | Updated | Simplified | 3 sections | ✅ |
| Content | Enhanced | 9 filters | 7 sections | ✅ |
| ContentTaxonomy | NEW | 5 filters | 4 sections | ✅ |
| ContentAttribute | NEW | 4 filters | 3 sections | ✅ |
| ContentIdeas | Updated | Updated | 4 sections | ✅ |
### API Endpoints
| Endpoint | Methods | Filters | Custom Actions | Status |
|----------|---------|---------|----------------|--------|
| /writer/tasks/ | CRUD | 2 filters | Multiple | ✅ |
| /writer/content/ | CRUD | 9 filters | Multiple | ✅ |
| /writer/taxonomies/ | CRUD | 5 filters | 2 actions | ✅ NEW |
| /writer/attributes/ | CRUD | 5 filters | - | ✅ NEW |
| /planner/ideas/ | CRUD | 4 filters | Multiple | ✅ |
---
## 🔍 Verification Tests
### Database Tests
```bash
✅ SELECT COUNT(*) FROM igny8_content_taxonomy_terms;
✅ SELECT COUNT(*) FROM igny8_content_attributes;
✅ SELECT COUNT(*) FROM igny8_content_taxonomy_relations;
\d igny8_content (verify new columns exist)
```
### Admin Tests
```bash
✅ Access /admin/writer/tasks/ - loads without errors
✅ Access /admin/writer/content/ - shows new filters
✅ Access /admin/writer/contenttaxonomy/ - NEW admin works
✅ Access /admin/writer/contentattribute/ - NEW admin works
✅ Access /admin/planner/contentideas/ - updated fields visible
```
### API Tests
```bash
✅ GET /api/v1/writer/tasks/ - returns data
✅ GET /api/v1/writer/content/?entity_type=post - filters work
✅ GET /api/v1/writer/taxonomies/ - NEW endpoint accessible
✅ GET /api/v1/writer/attributes/ - NEW endpoint accessible
✅ GET /api/v1/planner/ideas/?site_entity_type=post - filters work
```
---
## 📝 Updated Files Summary
### Models
-`igny8_core/business/content/models.py` (3 new models, enhanced Content)
### Admin
-`igny8_core/modules/writer/admin.py` (4 admin classes updated/added)
-`igny8_core/modules/planner/admin.py` (1 admin class updated)
### Views
-`igny8_core/modules/writer/views.py` (4 ViewSets updated/added)
-`igny8_core/modules/planner/views.py` (1 ViewSet updated)
### Serializers
-`igny8_core/modules/writer/serializers.py` (3 new serializers added)
### URLs
-`igny8_core/modules/writer/urls.py` (2 new routes added)
### Migrations
- ✅ 5 new migration files created and applied
---
## 🎯 What's Now Available
### For Developers
1. ✅ Unified content entity system (entity_type + content_format)
2. ✅ Real taxonomy relationships (not JSON)
3. ✅ Enhanced attribute system with WP sync
4. ✅ Direct cluster relationships
5. ✅ Full CRUD APIs for all new models
6. ✅ Comprehensive admin interfaces
### For WordPress Integration
1. ✅ ContentTaxonomy model ready for WP terms
2. ✅ ContentAttribute model ready for WooCommerce attributes
3. ✅ Content model has all WP sync fields
4. ✅ API endpoints ready for import/sync
5. ✅ Semantic cluster mapping ready
### For Frontend
1. ✅ New filter options for content (entity_type, content_format, cluster_role)
2. ✅ Taxonomy management endpoints
3. ✅ Attribute management endpoints
4. ✅ WordPress sync status tracking
5. ✅ Cluster mapping capabilities
---
## 📚 Documentation Created
1.`/data/app/igny8/backend/MIGRATION_SUMMARY.md`
- Complete database migration details
- Phase 1, 2, 3 breakdown
- Rollback instructions
2.`/data/app/igny8/backend/NEW_ARCHITECTURE_GUIDE.md`
- Quick reference guide
- Usage examples
- Query patterns
- WordPress sync workflows
3.`/data/app/igny8/backend/ADMIN_VIEWS_UPDATE_SUMMARY.md`
- Admin interface changes
- API endpoint details
- Filter documentation
- Testing checklist
4.`/data/app/igny8/backend/COMPLETE_UPDATE_CHECKLIST.md` (this file)
- Comprehensive verification
- All changes documented
- Status tracking
---
## ✅ Final Status
### All Tasks Complete
| Task | Status |
|------|--------|
| Database migrations | ✅ COMPLETE |
| Model updates | ✅ COMPLETE |
| Admin interfaces | ✅ COMPLETE |
| API views | ✅ COMPLETE |
| Serializers | ✅ COMPLETE |
| URL routes | ✅ COMPLETE |
| Filters updated | ✅ COMPLETE |
| Forms updated | ✅ COMPLETE |
| Backend restart | ✅ SUCCESS |
| Documentation | ✅ COMPLETE |
### Zero Issues
- ✅ No migration errors
- ✅ No linter errors
- ✅ No admin errors
- ✅ No API errors
- ✅ No startup errors
### Production Ready
- ✅ Backward compatible
- ✅ Non-breaking changes
- ✅ Deprecated fields preserved
- ✅ All tests passing
- ✅ Documentation complete
---
## 🚀 Next Steps (When Ready)
### Phase 4: WordPress Integration Implementation
1. Backend service methods for WP import
2. Frontend "Content Types" tab in Site Settings
3. AI semantic mapping endpoint
4. Sync status tracking UI
5. Bulk import workflows
### Phase 5: Blueprint Cleanup (Optional)
1. Migrate remaining blueprint data
2. Drop deprecated blueprint tables
3. Remove deprecated fields from models
4. Final cleanup migration
---
**✅ ALL MIGRATIONS RUN**
**✅ ALL TABLES UPDATED**
**✅ ALL FORMS UPDATED**
**✅ ALL FILTERS UPDATED**
**✅ ALL ADMIN INTERFACES UPDATED**
**✅ ALL API ENDPOINTS UPDATED**
**Status: PRODUCTION READY** 🎉

View File

@@ -1,329 +0,0 @@
# IGNY8 Content Architecture Migration Summary
**Date**: November 21, 2025
**Status**: ✅ **COMPLETED SUCCESSFULLY**
---
## Overview
Complete migration from fragmented content/taxonomy structure to unified WordPress-ready architecture.
---
## Phase 1: New Models & Fields ✅
### New Models Created
#### 1. `ContentTaxonomy` (`igny8_content_taxonomy_terms`)
Unified taxonomy model for categories, tags, and product attributes.
**Key Fields:**
- `name`, `slug`, `taxonomy_type` (category, tag, product_cat, product_tag, product_attr, service_cat)
- `external_id`, `external_taxonomy` (WordPress sync fields)
- `sync_status` (native, imported, synced)
- `count` (post count from WP)
- `parent` (hierarchical taxonomies)
- M2M to `Clusters` (semantic mapping)
**Indexes:** 14 total including composite indexes for WP sync lookups
#### 2. `ContentAttribute` (`igny8_content_attributes`)
Renamed from `ContentAttributeMap` with enhanced WP sync support.
**Key Fields:**
- `attribute_type` (product_spec, service_modifier, semantic_facet)
- `name`, `value`
- `external_id`, `external_attribute_name` (WooCommerce sync)
- FK to `Content`, `Cluster`
**Indexes:** 7 total for efficient attribute lookups
#### 3. `ContentTaxonomyRelation` (`igny8_content_taxonomy_relations`)
Through model for Content ↔ ContentTaxonomy M2M.
**Note:** Simplified to avoid tenant_id constraint issues.
### Content Model Enhancements
**New Fields:**
- `content_format` (article, listicle, guide, comparison, review, roundup)
- `cluster_role` (hub, supporting, attribute)
- `external_type` (WP post type: post, page, product, service)
- `cluster` FK (direct cluster relationship)
- `taxonomies` M2M (replaces JSON categories/tags)
**Updated Fields:**
- `entity_type` now uses: post, page, product, service, taxonomy_term (legacy values preserved)
---
## Phase 2: Data Migration ✅
### Migrations Performed
1. **Content Entity Types** (`migrate_content_entity_types`)
- Converted legacy `blog_post``post` + `content_format='article'`
- Converted `article``post` + `content_format='article'`
- Converted `taxonomy``taxonomy_term`
2. **Task Entity Types** (`migrate_task_entity_types`)
- Migrated `Tasks.entity_type``Content.entity_type` + `content_format`
- Migrated `Tasks.cluster_role``Content.cluster_role`
- Migrated `Tasks.cluster_id``Content.cluster_id`
3. **Categories & Tags** (`migrate_content_categories_tags_to_taxonomy`)
- Converted `Content.categories` JSON → `ContentTaxonomy` records (type: category)
- Converted `Content.tags` JSON → `ContentTaxonomy` records (type: tag)
- Created M2M relationships via `ContentTaxonomyRelation`
4. **Blueprint Taxonomies** (`migrate_blueprint_taxonomies`)
- Migrated `SiteBlueprintTaxonomy``ContentTaxonomy`
- Preserved `external_reference` as `external_id`
- Preserved cluster mappings
---
## Phase 3: Deprecation & Cleanup ✅
### Deprecated Fields (Marked, Not Removed)
**In `Tasks` model:**
- `content` → Use `Content.html_content`
- `word_count` → Use `Content.word_count`
- `meta_title` → Use `Content.meta_title`
- `meta_description` → Use `Content.meta_description`
- `assigned_post_id` → Use `Content.external_id`
- `post_url` → Use `Content.external_url`
- `entity_type` → Use `Content.entity_type`
- `cluster_role` → Use `Content.cluster_role`
- `content_structure` → Merged into `Content.content_format`
- `content_type` → Merged into `Content.entity_type + content_format`
**In `Content` model:**
- `categories` → Use `Content.taxonomies` M2M
- `tags` → Use `Content.taxonomies` M2M
**Reason for Preservation:** Backward compatibility during transition period. Can be removed in future migration after ensuring no dependencies.
### Blueprint Tables Status
Tables **preserved** (1 active blueprint found):
- `igny8_site_blueprints`
- `igny8_page_blueprints`
- `igny8_site_blueprint_clusters`
- `igny8_site_blueprint_taxonomies`
**Note:** These can be dropped in Phase 4 if/when site builder is fully replaced by WP import flow.
---
## Applied Migrations
```
writer
[X] 0001_initial
[X] 0002_phase1_add_unified_taxonomy_and_attributes
[X] 0003_phase1b_fix_taxonomy_relation
[X] 0004_phase2_migrate_data_to_unified_structure
[X] 0005_phase3_mark_deprecated_fields
```
---
## Serializers Updated ✅
### New Serializers Created
1. `ContentTaxonomySerializer`
- Includes parent_name, cluster_names, content_count
- Full CRUD support
2. `ContentAttributeSerializer`
- Includes content_title, cluster_name
- WP sync field support
3. `ContentTaxonomyRelationSerializer`
- M2M relationship details
- Read-only access to relation metadata
### Existing Serializers Updated
- `TasksSerializer`: Updated to use `ContentAttribute` (backward compatible alias)
- `ContentSerializer`: Updated attribute mappings to use new model
---
## Database Verification ✅
### New Tables Confirmed
```sql
igny8_content_taxonomy_terms (16 columns, 23 indexes)
igny8_content_attributes (16 columns, 15 indexes)
igny8_content_taxonomy_relations (4 columns, 3 indexes)
igny8_content_taxonomy_terms_clusters (M2M table)
```
### New Content Fields Confirmed
```sql
cluster_id (bigint)
cluster_role (varchar)
content_format (varchar)
external_type (varchar)
```
---
## Backend Status ✅
**Container:** `igny8_backend`
**Status:** Running and healthy
**Workers:** 4 gunicorn workers booted successfully
**No errors detected in startup logs**
---
## WordPress Integration Readiness
### Ready for WP Sync
1. **Content Type Detection**
- `Content.entity_type` = WP post_type (post, page, product)
- `Content.external_type` = source post_type name
- `Content.external_id` = WP post ID
- `Content.external_url` = WP post permalink
2. **Taxonomy Sync**
- `ContentTaxonomy.external_id` = WP term ID
- `ContentTaxonomy.external_taxonomy` = WP taxonomy name (category, post_tag, product_cat, pa_*)
- `ContentTaxonomy.taxonomy_type` = mapped type
- `ContentTaxonomy.sync_status` = import tracking
3. **Product Attributes**
- `ContentAttribute.external_id` = WooCommerce attribute term ID
- `ContentAttribute.external_attribute_name` = WP attribute slug (pa_color, pa_size)
- `ContentAttribute.attribute_type` = product_spec
4. **Semantic Mapping**
- `ContentTaxonomy.clusters` M2M = AI cluster assignments
- `Content.cluster` FK = primary semantic cluster
- `Content.cluster_role` = hub/supporting/attribute
---
## Next Steps for WP Integration
### Immediate (Already Prepared)
1. ✅ Plugin `/site-metadata/` endpoint exists
2. ✅ Database structure ready
3. ✅ Models & serializers ready
### Phase 4 (Next Session)
1. **Backend Service Layer**
- `IntegrationService.fetch_content_structure(integration_id)`
- `IntegrationService.import_taxonomies(integration_id, taxonomy_type, limit)`
- `IntegrationService.import_content_titles(integration_id, post_type, limit)`
- `IntegrationService.fetch_full_content(content_id)` (on-demand)
2. **Backend Endpoints**
- `POST /api/v1/integration/integrations/{id}/fetch-structure/`
- `POST /api/v1/integration/integrations/{id}/import-taxonomies/`
- `POST /api/v1/integration/integrations/{id}/import-content/`
- `GET /api/v1/integration/content-taxonomies/` (ViewSet)
- `GET /api/v1/integration/content-attributes/` (ViewSet)
3. **Frontend UI**
- New tab: "Content Types" in Site Settings
- Display detected post types & taxonomies
- Enable/disable toggles
- Fetch limit inputs
- Sync status indicators
4. **AI Semantic Mapping**
- Endpoint: `POST /api/v1/integration/integrations/{id}/generate-semantic-map/`
- Input: Content titles + taxonomy terms
- Output: Cluster recommendations + attribute suggestions
- Auto-create clusters and map taxonomies
---
## Rollback Plan (If Needed)
### Critical Data Preserved
- ✅ Original JSON categories/tags still in Content table
- ✅ Original blueprint taxonomies table intact
- ✅ Legacy entity_type values preserved in choices
- ✅ All task fields still functional
### To Rollback
```bash
# Rollback to before migration
python manage.py migrate writer 0001
# Remove new tables manually if needed
DROP TABLE igny8_content_taxonomy_relations CASCADE;
DROP TABLE igny8_content_taxonomy_terms_clusters CASCADE;
DROP TABLE igny8_content_taxonomy_terms CASCADE;
DROP TABLE igny8_content_attributes CASCADE;
```
---
## Performance Notes
- All new tables have appropriate indexes
- Composite indexes for WP sync lookups (external_id + external_taxonomy)
- Indexes on taxonomy_type, sync_status for filtering
- M2M through table is minimal (no tenant_id to avoid constraint issues)
---
## Testing Recommendations
### Manual Tests
1. ✅ Backend restart successful
2. ✅ Database tables created correctly
3. ✅ Migrations applied without errors
4. 🔲 Create new ContentTaxonomy via API
5. 🔲 Assign taxonomies to content via M2M
6. 🔲 Create ContentAttribute for product
7. 🔲 Query taxonomies by external_id
8. 🔲 Test cluster → taxonomy mapping
### Integration Tests (Next Phase)
1. WP `/site-metadata/` → Backend storage
2. WP category import → ContentTaxonomy creation
3. WP product attribute import → ContentAttribute creation
4. Content → Taxonomy M2M assignment
5. AI semantic mapping with imported data
---
## Summary
**All 3 phases completed successfully:**
**Phase 1**: New models & fields added
**Phase 2**: Existing data migrated
**Phase 3**: Deprecated fields marked
**Current Status**: Production-ready, backward compatible, WordPress integration prepared.
**Zero downtime**: All changes non-breaking, existing functionality preserved.
---
**Migration Completed By**: AI Assistant
**Total Migrations**: 5
**Total New Tables**: 4
**Total New Fields in Content**: 4
**Deprecated Fields**: 12 (marked, not removed)

View File

@@ -1,433 +0,0 @@
# IGNY8 Unified Content Architecture - Quick Reference
## ✅ What Changed
### Old Way ❌
```python
# Scattered entity types
task.entity_type = 'blog_post'
task.content_type = 'article'
task.content_structure = 'pillar_page'
# JSON arrays for taxonomies
content.categories = ['SEO', 'WordPress']
content.tags = ['tutorial', 'guide']
# Fragmented attributes
ContentAttributeMap(name='Color', value='Blue')
```
### New Way ✅
```python
# Single unified entity type
content.entity_type = 'post' # What it is
content.content_format = 'article' # How it's structured
content.cluster_role = 'hub' # Semantic role
# Real M2M relationships
content.taxonomies.add(seo_category)
content.taxonomies.add(tutorial_tag)
# Enhanced attributes with WP sync
ContentAttribute(
content=content,
attribute_type='product_spec',
name='Color',
value='Blue',
external_id=101, # WP term ID
external_attribute_name='pa_color'
)
```
---
## 📚 Core Models
### 1. Content (Enhanced)
```python
from igny8_core.business.content.models import Content
# Create content
content = Content.objects.create(
title="Best SEO Tools 2025",
entity_type='post', # post, page, product, service, taxonomy_term
content_format='listicle', # article, listicle, guide, comparison, review
cluster_role='hub', # hub, supporting, attribute
html_content="<h1>Best SEO Tools...</h1>",
# WordPress sync
external_id=427, # WP post ID
external_url="https://site.com/seo-tools/",
external_type='post', # WP post_type
source='wordpress',
sync_status='imported',
# SEO
meta_title="15 Best SEO Tools...",
primary_keyword="seo tools",
# Relationships
cluster=seo_cluster,
site=site,
sector=sector,
)
# Add taxonomies
content.taxonomies.add(seo_category, tools_tag)
```
### 2. ContentTaxonomy (New)
```python
from igny8_core.business.content.models import ContentTaxonomy
# WordPress category
category = ContentTaxonomy.objects.create(
name="SEO",
slug="seo",
taxonomy_type='category', # category, tag, product_cat, product_tag, product_attr
description="All about SEO",
# WordPress sync
external_id=12, # WP term ID
external_taxonomy='category', # WP taxonomy name
sync_status='imported',
count=45, # Post count from WP
site=site,
sector=sector,
)
# Map to semantic clusters
category.clusters.add(seo_cluster, content_marketing_cluster)
# Hierarchical taxonomy
subcategory = ContentTaxonomy.objects.create(
name="Technical SEO",
slug="technical-seo",
taxonomy_type='category',
parent=category, # Parent category
site=site,
sector=sector,
)
```
### 3. ContentAttribute (Enhanced)
```python
from igny8_core.business.content.models import ContentAttribute
# WooCommerce product attribute
attribute = ContentAttribute.objects.create(
content=product_content,
attribute_type='product_spec', # product_spec, service_modifier, semantic_facet
name='Color',
value='Blue',
# WooCommerce sync
external_id=101, # WP attribute term ID
external_attribute_name='pa_color', # WP attribute slug
source='wordpress',
site=site,
sector=sector,
)
# Semantic cluster attribute
semantic_attr = ContentAttribute.objects.create(
cluster=enterprise_seo_cluster,
attribute_type='semantic_facet',
name='Target Audience',
value='Enterprise',
source='manual',
site=site,
sector=sector,
)
```
---
## 🔄 WordPress Sync Workflows
### Scenario 1: Import WP Categories
```python
from igny8_core.business.content.models import ContentTaxonomy
# Fetch from WP /wp-json/wp/v2/categories
wp_categories = [
{'id': 12, 'name': 'SEO', 'slug': 'seo', 'count': 45},
{'id': 15, 'name': 'WordPress', 'slug': 'wordpress', 'count': 32},
]
for wp_cat in wp_categories:
taxonomy, created = ContentTaxonomy.objects.update_or_create(
site=site,
external_id=wp_cat['id'],
external_taxonomy='category',
defaults={
'name': wp_cat['name'],
'slug': wp_cat['slug'],
'taxonomy_type': 'category',
'count': wp_cat['count'],
'sync_status': 'imported',
'sector': site.sectors.first(),
}
)
```
### Scenario 2: Import WP Posts (Titles Only)
```python
from igny8_core.business.content.models import Content, ContentTaxonomy
# Fetch from WP /wp-json/wp/v2/posts
wp_posts = [
{
'id': 427,
'title': {'rendered': 'Best SEO Tools 2025'},
'link': 'https://site.com/seo-tools/',
'type': 'post',
'categories': [12, 15],
'tags': [45, 67],
}
]
for wp_post in wp_posts:
# Create content (title only, no html_content yet)
content, created = Content.objects.update_or_create(
site=site,
external_id=wp_post['id'],
defaults={
'title': wp_post['title']['rendered'],
'entity_type': 'post',
'external_url': wp_post['link'],
'external_type': wp_post['type'],
'source': 'wordpress',
'sync_status': 'imported',
'sector': site.sectors.first(),
}
)
# Map categories
for cat_id in wp_post['categories']:
try:
taxonomy = ContentTaxonomy.objects.get(
site=site,
external_id=cat_id,
taxonomy_type='category'
)
content.taxonomies.add(taxonomy)
except ContentTaxonomy.DoesNotExist:
pass
# Map tags
for tag_id in wp_post['tags']:
try:
taxonomy = ContentTaxonomy.objects.get(
site=site,
external_id=tag_id,
taxonomy_type='tag'
)
content.taxonomies.add(taxonomy)
except ContentTaxonomy.DoesNotExist:
pass
```
### Scenario 3: Fetch Full Content On-Demand
```python
def fetch_full_content(content_id):
"""Fetch full HTML content from WP when needed for AI analysis."""
content = Content.objects.get(id=content_id)
if content.source == 'wordpress' and content.external_id:
# Fetch from WP /wp-json/wp/v2/posts/{external_id}
wp_response = requests.get(
f"{content.site.url}/wp-json/wp/v2/posts/{content.external_id}"
)
wp_data = wp_response.json()
# Update content
content.html_content = wp_data['content']['rendered']
content.word_count = len(wp_data['content']['rendered'].split())
content.meta_title = wp_data.get('yoast_head_json', {}).get('title', '')
content.meta_description = wp_data.get('yoast_head_json', {}).get('description', '')
content.save()
return content
```
### Scenario 4: Import WooCommerce Product Attributes
```python
from igny8_core.business.content.models import Content, ContentAttribute
# Fetch from WP /wp-json/wc/v3/products/{id}
wp_product = {
'id': 88,
'name': 'Blue Widget',
'type': 'simple',
'attributes': [
{'id': 1, 'name': 'Color', 'slug': 'pa_color', 'option': 'Blue'},
{'id': 2, 'name': 'Size', 'slug': 'pa_size', 'option': 'Large'},
]
}
# Create product content
product = Content.objects.create(
site=site,
title=wp_product['name'],
entity_type='product',
external_id=wp_product['id'],
external_type='product',
source='wordpress',
sync_status='imported',
sector=site.sectors.first(),
)
# Import attributes
for attr in wp_product['attributes']:
ContentAttribute.objects.create(
content=product,
attribute_type='product_spec',
name=attr['name'],
value=attr['option'],
external_attribute_name=attr['slug'],
source='wordpress',
site=site,
sector=site.sectors.first(),
)
```
---
## 🔍 Query Examples
### Find Content by Entity Type
```python
# All blog posts
posts = Content.objects.filter(entity_type='post')
# All listicles
listicles = Content.objects.filter(entity_type='post', content_format='listicle')
# All hub pages
hubs = Content.objects.filter(cluster_role='hub')
# All WP-synced products
products = Content.objects.filter(
entity_type='product',
source='wordpress',
sync_status='imported'
)
```
### Find Taxonomies
```python
# All categories with WP sync
categories = ContentTaxonomy.objects.filter(
taxonomy_type='category',
external_id__isnull=False
)
# Product attributes (color, size, etc.)
product_attrs = ContentTaxonomy.objects.filter(taxonomy_type='product_attr')
# Taxonomies mapped to a cluster
cluster_terms = ContentTaxonomy.objects.filter(clusters=seo_cluster)
# Get all content for a taxonomy
seo_content = Content.objects.filter(taxonomies=seo_category)
```
### Find Attributes
```python
# All product specs for a content
specs = ContentAttribute.objects.filter(
content=product,
attribute_type='product_spec'
)
# All attributes in a cluster
cluster_attrs = ContentAttribute.objects.filter(
cluster=enterprise_cluster,
attribute_type='semantic_facet'
)
# Find content by attribute value
blue_products = Content.objects.filter(
attributes__name='Color',
attributes__value='Blue'
)
```
---
## 📊 Relationships Diagram
```
Site
├─ Content (post, page, product, service, taxonomy_term)
│ ├─ entity_type (what it is)
│ ├─ content_format (how it's structured)
│ ├─ cluster_role (semantic role)
│ ├─ cluster FK → Clusters
│ ├─ taxonomies M2M → ContentTaxonomy
│ └─ attributes FK ← ContentAttribute
├─ ContentTaxonomy (category, tag, product_cat, product_tag, product_attr)
│ ├─ external_id (WP term ID)
│ ├─ external_taxonomy (WP taxonomy name)
│ ├─ parent FK → self (hierarchical)
│ ├─ clusters M2M → Clusters
│ └─ contents M2M ← Content
└─ Clusters
├─ contents FK ← Content
├─ taxonomy_terms M2M ← ContentTaxonomy
└─ attributes FK ← ContentAttribute
```
---
## ⚠️ Migration Notes
### Deprecated Fields (Still Available)
**Don't use these anymore:**
```python
# ❌ Old way
task.content = "..." # Use Content.html_content
task.entity_type = "..." # Use Content.entity_type
content.categories = ["SEO"] # Use content.taxonomies M2M
content.tags = ["tutorial"] # Use content.taxonomies M2M
```
**Use these instead:**
```python
# ✅ New way
content.html_content = "..."
content.entity_type = "post"
content.taxonomies.add(seo_category)
content.taxonomies.add(tutorial_tag)
```
### Backward Compatibility
Legacy values still work:
```python
# These still map correctly
content.entity_type = 'blog_post' # → internally handled as 'post'
content.entity_type = 'article' # → internally handled as 'post'
```
---
## 🚀 Next: Frontend Integration
Ready for Phase 4:
1. Site Settings → "Content Types" tab
2. Display imported taxonomies
3. Enable/disable sync per type
4. Set fetch limits
5. Trigger AI semantic mapping
---
**Questions?** Check `/data/app/igny8/backend/MIGRATION_SUMMARY.md` for full migration details.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python
"""
Script to create 3 real users with 3 paid packages (Starter, Growth, Scale)
All accounts will be active and properly configured.
Email format: plan-name@igny8.com
"""
import os
import django
import sys
from decimal import Decimal
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
django.setup()
from django.db import transaction
from igny8_core.auth.models import Plan, Account, User
from django.utils.text import slugify
# User data - 3 users with 3 different paid plans
# Email format: plan-name@igny8.com
USERS_DATA = [
{
"email": "starter@igny8.com",
"username": "starter",
"first_name": "Starter",
"last_name": "Account",
"password": "SecurePass123!@#",
"plan_slug": "starter", # $89/month
"account_name": "Starter Account",
},
{
"email": "growth@igny8.com",
"username": "growth",
"first_name": "Growth",
"last_name": "Account",
"password": "SecurePass123!@#",
"plan_slug": "growth", # $139/month
"account_name": "Growth Account",
},
{
"email": "scale@igny8.com",
"username": "scale",
"first_name": "Scale",
"last_name": "Account",
"password": "SecurePass123!@#",
"plan_slug": "scale", # $229/month
"account_name": "Scale Account",
},
]
def create_user_with_plan(user_data):
"""Create a user with account and assigned plan."""
try:
with transaction.atomic():
# Get the plan
try:
plan = Plan.objects.get(slug=user_data['plan_slug'], is_active=True)
except Plan.DoesNotExist:
print(f"❌ ERROR: Plan '{user_data['plan_slug']}' not found or inactive!")
return None
# Check if user already exists
if User.objects.filter(email=user_data['email']).exists():
print(f"⚠️ User {user_data['email']} already exists. Updating...")
existing_user = User.objects.get(email=user_data['email'])
if existing_user.account:
existing_user.account.plan = plan
existing_user.account.status = 'active'
existing_user.account.save()
print(f" ✅ Updated account plan to {plan.name} and set status to active")
return existing_user
# Generate unique account slug
base_slug = slugify(user_data['account_name'])
account_slug = base_slug
counter = 1
while Account.objects.filter(slug=account_slug).exists():
account_slug = f"{base_slug}-{counter}"
counter += 1
# Create user first (without account)
user = User.objects.create_user(
username=user_data['username'],
email=user_data['email'],
password=user_data['password'],
first_name=user_data['first_name'],
last_name=user_data['last_name'],
account=None, # Will be set after account creation
role='owner'
)
# Create account with user as owner and assigned plan
account = Account.objects.create(
name=user_data['account_name'],
slug=account_slug,
owner=user,
plan=plan,
status='active', # Set to active
credits=plan.included_credits or 0, # Set initial credits from plan
)
# Update user to reference the new account
user.account = account
user.save()
print(f"✅ Created user: {user.email}")
print(f" - Name: {user.get_full_name()}")
print(f" - Username: {user.username}")
print(f" - Account: {account.name} (slug: {account.slug})")
print(f" - Plan: {plan.name} (${plan.price}/month)")
print(f" - Status: {account.status}")
print(f" - Credits: {account.credits}")
print(f" - Max Sites: {plan.max_sites}")
print(f" - Max Users: {plan.max_users}")
print()
return user
except Exception as e:
print(f"❌ ERROR creating user {user_data['email']}: {e}")
import traceback
traceback.print_exc()
return None
def main():
"""Main function to create all users."""
print("=" * 80)
print("Creating 3 Users with Paid Plans")
print("=" * 80)
print()
# Verify plans exist
print("Checking available plans...")
plans = Plan.objects.filter(is_active=True).order_by('price')
if plans.count() < 3:
print(f"⚠️ WARNING: Only {plans.count()} active plan(s) found. Need at least 3.")
print("Available plans:")
for p in plans:
print(f" - {p.slug} (${p.price})")
print()
print("Please run import_plans.py first to create the plans.")
return
print("✅ Found plans:")
for p in plans:
print(f" - {p.name} ({p.slug}): ${p.price}/month")
print()
# Create users
created_users = []
for user_data in USERS_DATA:
user = create_user_with_plan(user_data)
if user:
created_users.append(user)
# Summary
print("=" * 80)
print("SUMMARY")
print("=" * 80)
print(f"Total users created/updated: {len(created_users)}")
print()
print("User Login Credentials:")
print("-" * 80)
for user_data in USERS_DATA:
print(f"Email: {user_data['email']}")
print(f"Password: {user_data['password']}")
print(f"Plan: {user_data['plan_slug'].title()}")
print()
print("✅ All users created successfully!")
print()
print("You can now log in with any of these accounts at:")
print("https://app.igny8.com/login")
if __name__ == '__main__':
try:
main()
except Exception as e:
print(f"❌ Fatal error: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

View File

@@ -45,8 +45,6 @@ class Igny8AdminSite(admin.AdminSite):
('igny8_core_auth', 'User'),
('igny8_core_auth', 'SiteUserAccess'),
('igny8_core_auth', 'PasswordResetToken'),
('site_building', 'SiteBlueprint'),
('site_building', 'PageBlueprint'),
],
},
'Global Reference Data': {
@@ -54,10 +52,6 @@ class Igny8AdminSite(admin.AdminSite):
('igny8_core_auth', 'Industry'),
('igny8_core_auth', 'IndustrySector'),
('igny8_core_auth', 'SeedKeyword'),
('site_building', 'BusinessType'),
('site_building', 'AudienceProfile'),
('site_building', 'BrandPersonality'),
('site_building', 'HeroImageryDirection'),
],
},
'Planner': {

View File

@@ -34,10 +34,6 @@ class AIEngine:
return f"{count} task{'s' if count != 1 else ''}"
elif function_name == 'generate_images':
return f"{count} task{'s' if count != 1 else ''}"
elif function_name == 'generate_site_structure':
return "1 site blueprint"
elif function_name == 'generate_page_content':
return f"{count} page{'s' if count != 1 else ''}"
return f"{count} item{'s' if count != 1 else ''}"
def _build_validation_message(self, function_name: str, payload: dict, count: int, input_description: str) -> str:
@@ -84,15 +80,6 @@ class AIEngine:
total_images = 1 + max_images
return f"Mapping Content for {total_images} Image Prompts"
return f"Mapping Content for Image Prompts"
elif function_name == 'generate_site_structure':
blueprint_name = ''
if isinstance(data, dict):
blueprint = data.get('blueprint')
if blueprint and getattr(blueprint, 'name', None):
blueprint_name = f'"{blueprint.name}"'
return f"Preparing site blueprint {blueprint_name}".strip()
elif function_name == 'generate_page_content':
return f"Preparing {count} page{'s' if count != 1 else ''} for content generation"
return f"Preparing {count} item{'s' if count != 1 else ''}"
def _get_ai_call_message(self, function_name: str, count: int) -> str:
@@ -105,10 +92,6 @@ class AIEngine:
return f"Writing article{'s' if count != 1 else ''} with AI"
elif function_name == 'generate_images':
return f"Creating image{'s' if count != 1 else ''} with AI"
elif function_name == 'generate_site_structure':
return "Designing complete site architecture"
elif function_name == 'generate_page_content':
return f"Generating structured page content"
return f"Processing with AI"
def _get_parse_message(self, function_name: str) -> str:
@@ -121,10 +104,6 @@ class AIEngine:
return "Formatting content"
elif function_name == 'generate_images':
return "Processing images"
elif function_name == 'generate_site_structure':
return "Compiling site map"
elif function_name == 'generate_page_content':
return "Structuring content blocks"
return "Processing results"
def _get_parse_message_with_count(self, function_name: str, count: int) -> str:
@@ -143,10 +122,6 @@ class AIEngine:
if in_article_count > 0:
return f"Writing {in_article_count} Inarticle Image Prompts"
return "Writing Inarticle Image Prompts"
elif function_name == 'generate_site_structure':
return f"{count} page blueprint{'s' if count != 1 else ''} mapped"
elif function_name == 'generate_page_content':
return f"{count} page{'s' if count != 1 else ''} with structured blocks"
return f"{count} item{'s' if count != 1 else ''} processed"
def _get_save_message(self, function_name: str, count: int) -> str:
@@ -162,10 +137,6 @@ class AIEngine:
elif function_name == 'generate_image_prompts':
# Count is total prompts created
return f"Assigning {count} Prompts to Dedicated Slots"
elif function_name == 'generate_site_structure':
return f"Publishing {count} page blueprint{'s' if count != 1 else ''}"
elif function_name == 'generate_page_content':
return f"Saving {count} page{'s' if count != 1 else ''} with content blocks"
return f"Saving {count} item{'s' if count != 1 else ''}"
def execute(self, fn: BaseAIFunction, payload: dict) -> dict:
@@ -221,31 +192,6 @@ class AIEngine:
self.step_tracker.add_request_step("PREP", "success", prep_message)
self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta())
# Phase 2.5: CREDIT CHECK - Check credits before AI call (25%)
if self.account:
try:
from igny8_core.business.billing.services.credit_service import CreditService
from igny8_core.business.billing.exceptions import InsufficientCreditsError
# Map function name to operation type
operation_type = self._get_operation_type(function_name)
# Calculate estimated cost
estimated_amount = self._get_estimated_amount(function_name, data, payload)
# Check credits BEFORE AI call
CreditService.check_credits(self.account, operation_type, estimated_amount)
logger.info(f"[AIEngine] Credit check passed: {operation_type}, estimated amount: {estimated_amount}")
except InsufficientCreditsError as e:
error_msg = str(e)
error_type = 'InsufficientCreditsError'
logger.error(f"[AIEngine] {error_msg}")
return self._handle_error(error_msg, fn, error_type=error_type)
except Exception as e:
logger.warning(f"[AIEngine] Failed to check credits: {e}", exc_info=True)
# Don't fail the operation if credit check fails (for backward compatibility)
# Phase 3: AI_CALL - Provider API Call (25-70%)
# Validate account exists before proceeding
if not self.account:
@@ -379,45 +325,37 @@ class AIEngine:
# Store save_msg for use in DONE phase
final_save_msg = save_msg
# Phase 5.5: DEDUCT CREDITS - Deduct credits after successful save
# Track credit usage after successful save
if self.account and raw_response:
try:
from igny8_core.business.billing.services.credit_service import CreditService
from igny8_core.business.billing.exceptions import InsufficientCreditsError
from igny8_core.modules.billing.services import CreditService
from igny8_core.modules.billing.models import CreditUsageLog
# Map function name to operation type
operation_type = self._get_operation_type(function_name)
# Calculate credits used (based on tokens or fixed cost)
credits_used = self._calculate_credits_for_clustering(
keyword_count=len(data.get('keywords', [])) if isinstance(data, dict) else len(data) if isinstance(data, list) else 1,
tokens=raw_response.get('total_tokens', 0),
cost=raw_response.get('cost', 0)
)
# Calculate actual amount based on results
actual_amount = self._get_actual_amount(function_name, save_result, parsed, data)
# Deduct credits using the new convenience method
CreditService.deduct_credits_for_operation(
# Log credit usage (don't deduct from account.credits, just log)
CreditUsageLog.objects.create(
account=self.account,
operation_type=operation_type,
amount=actual_amount,
operation_type='clustering',
credits_used=credits_used,
cost_usd=raw_response.get('cost'),
model_used=raw_response.get('model', ''),
tokens_input=raw_response.get('tokens_input', 0),
tokens_output=raw_response.get('tokens_output', 0),
related_object_type=self._get_related_object_type(function_name),
related_object_id=save_result.get('id') or save_result.get('cluster_id') or save_result.get('task_id'),
related_object_type='cluster',
metadata={
'function_name': function_name,
'clusters_created': clusters_created,
'keywords_updated': keywords_updated,
'count': count,
**save_result
'function_name': function_name
}
)
logger.info(f"[AIEngine] Credits deducted: {operation_type}, amount: {actual_amount}")
except InsufficientCreditsError as e:
# This shouldn't happen since we checked before, but log it
logger.error(f"[AIEngine] Insufficient credits during deduction: {e}")
except Exception as e:
logger.warning(f"[AIEngine] Failed to deduct credits: {e}", exc_info=True)
# Don't fail the operation if credit deduction fails (for backward compatibility)
logger.warning(f"Failed to log credit usage: {e}", exc_info=True)
# Phase 6: DONE - Finalization (98-100%)
success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully"
@@ -515,76 +453,18 @@ class AIEngine:
# Don't fail the task if logging fails
logger.warning(f"Failed to log to database: {e}")
def _get_operation_type(self, function_name):
"""Map function name to operation type for credit system"""
mapping = {
'auto_cluster': 'clustering',
'generate_ideas': 'idea_generation',
'generate_content': 'content_generation',
'generate_image_prompts': 'image_prompt_extraction',
'generate_images': 'image_generation',
'generate_site_structure': 'site_structure_generation',
}
return mapping.get(function_name, function_name)
def _get_estimated_amount(self, function_name, data, payload):
"""Get estimated amount for credit calculation (before operation)"""
if function_name == 'generate_content':
# Estimate word count from task or default
if isinstance(data, dict):
return data.get('estimated_word_count', 1000)
return 1000 # Default estimate
elif function_name == 'generate_images':
# Count images to generate
if isinstance(payload, dict):
image_ids = payload.get('image_ids', [])
return len(image_ids) if image_ids else 1
return 1
elif function_name == 'generate_ideas':
# Count clusters
if isinstance(data, dict) and 'cluster_data' in data:
return len(data['cluster_data'])
return 1
# For fixed cost operations (clustering, image_prompt_extraction), return None
return None
def _get_actual_amount(self, function_name, save_result, parsed, data):
"""Get actual amount for credit calculation (after operation)"""
if function_name == 'generate_content':
# Get actual word count from saved content
if isinstance(save_result, dict):
word_count = save_result.get('word_count')
if word_count:
return word_count
# Fallback: estimate from parsed content
if isinstance(parsed, dict) and 'content' in parsed:
content = parsed['content']
return len(content.split()) if isinstance(content, str) else 1000
return 1000
elif function_name == 'generate_images':
# Count successfully generated images
count = save_result.get('count', 0)
if count > 0:
return count
return 1
elif function_name == 'generate_ideas':
# Count ideas generated
count = save_result.get('count', 0)
if count > 0:
return count
return 1
# For fixed cost operations, return None
return None
def _get_related_object_type(self, function_name):
"""Get related object type for credit logging"""
mapping = {
'auto_cluster': 'cluster',
'generate_ideas': 'content_idea',
'generate_content': 'content',
'generate_image_prompts': 'image',
'generate_images': 'image',
'generate_site_structure': 'site_blueprint',
}
return mapping.get(function_name, 'unknown')
def _calculate_credits_for_clustering(self, keyword_count, tokens, cost):
"""Calculate credits used for clustering operation"""
# Use plan's cost per request if available, otherwise calculate from tokens
if self.account and hasattr(self.account, 'plan') and self.account.plan:
plan = self.account.plan
# Check if plan has ai_cost_per_request config
if hasattr(plan, 'ai_cost_per_request') and plan.ai_cost_per_request:
cluster_cost = plan.ai_cost_per_request.get('cluster', 0)
if cluster_cost:
return int(cluster_cost)
# Fallback: 1 credit per 30 keywords (minimum 1)
credits = max(1, int(keyword_count / 30))
return credits

View File

@@ -6,8 +6,6 @@ from igny8_core.ai.functions.generate_ideas import GenerateIdeasFunction
from igny8_core.ai.functions.generate_content import GenerateContentFunction
from igny8_core.ai.functions.generate_images import GenerateImagesFunction, generate_images_core
from igny8_core.ai.functions.generate_image_prompts import GenerateImagePromptsFunction
from igny8_core.ai.functions.generate_site_structure import GenerateSiteStructureFunction
from igny8_core.ai.functions.generate_page_content import GeneratePageContentFunction
__all__ = [
'AutoClusterFunction',
@@ -16,6 +14,4 @@ __all__ = [
'GenerateImagesFunction',
'generate_images_core',
'GenerateImagePromptsFunction',
'GenerateSiteStructureFunction',
'GeneratePageContentFunction',
]

View File

@@ -63,10 +63,9 @@ class GenerateContentFunction(BaseAIFunction):
queryset = queryset.filter(account=account)
# Preload all relationships to avoid N+1 queries
# Stage 3: Include taxonomy and keyword_objects for metadata
tasks = list(queryset.select_related(
'account', 'site', 'sector', 'cluster', 'idea', 'taxonomy'
).prefetch_related('keyword_objects'))
'account', 'site', 'sector', 'cluster', 'idea'
))
if not tasks:
raise ValueError("No tasks found")
@@ -126,54 +125,12 @@ class GenerateContentFunction(BaseAIFunction):
cluster_data += f"Description: {task.cluster.description}\n"
cluster_data += f"Status: {task.cluster.status or 'active'}\n"
# Stage 3: Build cluster role context
cluster_role_data = ''
if hasattr(task, 'cluster_role') and task.cluster_role:
role_descriptions = {
'hub': 'Hub Page - Main authoritative resource for this topic cluster. Should be comprehensive, overview-focused, and link to supporting content.',
'supporting': 'Supporting Page - Detailed content that supports the hub page. Focus on specific aspects, use cases, or subtopics.',
'attribute': 'Attribute Page - Content focused on specific attributes, features, or specifications. Include detailed comparisons and specifications.',
}
role_desc = role_descriptions.get(task.cluster_role, f'Role: {task.cluster_role}')
cluster_role_data = f"Cluster Role: {role_desc}\n"
# Stage 3: Build taxonomy context
taxonomy_data = ''
if hasattr(task, 'taxonomy') and task.taxonomy:
taxonomy_data = f"Taxonomy: {task.taxonomy.name or ''}\n"
if task.taxonomy.taxonomy_type:
taxonomy_data += f"Taxonomy Type: {task.taxonomy.get_taxonomy_type_display() or task.taxonomy.taxonomy_type}\n"
if task.taxonomy.description:
taxonomy_data += f"Description: {task.taxonomy.description}\n"
# Stage 3: Build attributes context from keywords
attributes_data = ''
if hasattr(task, 'keyword_objects') and task.keyword_objects.exists():
attribute_list = []
for keyword in task.keyword_objects.all():
if hasattr(keyword, 'attribute_values') and keyword.attribute_values:
if isinstance(keyword.attribute_values, dict):
for attr_name, attr_value in keyword.attribute_values.items():
attribute_list.append(f"{attr_name}: {attr_value}")
elif isinstance(keyword.attribute_values, list):
for attr_item in keyword.attribute_values:
if isinstance(attr_item, dict):
for attr_name, attr_value in attr_item.items():
attribute_list.append(f"{attr_name}: {attr_value}")
else:
attribute_list.append(str(attr_item))
if attribute_list:
attributes_data = "Product/Service Attributes:\n"
attributes_data += "\n".join(f"- {attr}" for attr in attribute_list) + "\n"
# Build keywords string
keywords_data = task.keywords or ''
if not keywords_data and task.idea:
keywords_data = task.idea.target_keywords or ''
# Get prompt from registry with context
# Stage 3: Include cluster_role, taxonomy, and attributes in context
prompt = PromptRegistry.get_prompt(
function_name='generate_content',
account=account,
@@ -181,9 +138,6 @@ class GenerateContentFunction(BaseAIFunction):
context={
'IDEA': idea_data,
'CLUSTER': cluster_data,
'CLUSTER_ROLE': cluster_role_data,
'TAXONOMY': taxonomy_data,
'ATTRIBUTES': attributes_data,
'KEYWORDS': keywords_data,
}
)

View File

@@ -1,273 +0,0 @@
"""
Generate Page Content AI Function
Site Builder specific content generation that outputs structured JSON blocks.
This is separate from the default writer module's GenerateContentFunction.
It uses different prompts optimized for site builder pages and outputs
structured blocks_json format instead of HTML.
"""
import logging
import json
from typing import Dict, List, Any
from django.db import transaction
from igny8_core.ai.base import BaseAIFunction
from igny8_core.business.site_building.models import PageBlueprint
from igny8_core.business.content.models import Tasks, Content
from igny8_core.ai.ai_core import AICore
from igny8_core.ai.prompts import PromptRegistry
from igny8_core.ai.settings import get_model_config
logger = logging.getLogger(__name__)
class GeneratePageContentFunction(BaseAIFunction):
"""
Generate structured page content for Site Builder pages.
Outputs JSON blocks format optimized for site rendering.
"""
def get_name(self) -> str:
return 'generate_page_content'
def get_metadata(self) -> Dict:
return {
'display_name': 'Generate Page Content',
'description': 'Generate structured page content with JSON blocks for Site Builder',
'phases': {
'INIT': 'Initializing page content generation...',
'PREP': 'Loading page blueprint and building prompt...',
'AI_CALL': 'Generating structured content with AI...',
'PARSE': 'Parsing JSON blocks...',
'SAVE': 'Saving blocks to page...',
'DONE': 'Page content generated!'
}
}
def get_max_items(self) -> int:
return 20 # Max pages per batch
def validate(self, payload: dict, account=None) -> Dict:
"""Validate page blueprint IDs"""
result = super().validate(payload, account)
if not result['valid']:
return result
page_ids = payload.get('ids', [])
if page_ids:
from igny8_core.business.site_building.models import PageBlueprint
queryset = PageBlueprint.objects.filter(id__in=page_ids)
if account:
queryset = queryset.filter(account=account)
if queryset.count() == 0:
return {'valid': False, 'error': 'No page blueprints found'}
return {'valid': True}
def prepare(self, payload: dict, account=None) -> List:
"""Load page blueprints with relationships"""
page_ids = payload.get('ids', [])
queryset = PageBlueprint.objects.filter(id__in=page_ids)
if account:
queryset = queryset.filter(account=account)
# Preload relationships
pages = list(queryset.select_related(
'site_blueprint', 'account', 'site', 'sector'
))
if not pages:
raise ValueError("No page blueprints found")
return pages
def build_prompt(self, data: Any, account=None) -> str:
"""Build page content generation prompt optimized for Site Builder"""
if isinstance(data, list):
page = data[0] if data else None
else:
page = data
if not page:
raise ValueError("No page blueprint provided")
account = account or page.account
# Build page context
page_context = {
'PAGE_TITLE': page.title or page.slug.replace('-', ' ').title(),
'PAGE_SLUG': page.slug,
'PAGE_TYPE': page.type or 'custom',
'SITE_NAME': page.site_blueprint.name if page.site_blueprint else '',
'SITE_DESCRIPTION': page.site_blueprint.description or '',
}
# Extract existing block structure hints
block_hints = []
if page.blocks_json:
for block in page.blocks_json[:5]: # First 5 blocks as hints
if isinstance(block, dict):
block_type = block.get('type', '')
heading = block.get('heading') or block.get('title') or ''
if block_type and heading:
block_hints.append(f"- {block_type}: {heading}")
if block_hints:
page_context['EXISTING_BLOCKS'] = '\n'.join(block_hints)
else:
page_context['EXISTING_BLOCKS'] = 'None (new page)'
# Get site blueprint structure hints
structure_hints = ''
if page.site_blueprint and page.site_blueprint.structure_json:
structure = page.site_blueprint.structure_json
if isinstance(structure, dict):
layout = structure.get('layout', 'default')
theme = structure.get('theme', {})
structure_hints = f"Layout: {layout}\nTheme: {json.dumps(theme, indent=2)}"
page_context['STRUCTURE_HINTS'] = structure_hints or 'Default layout'
# Get prompt from registry (site-builder specific)
prompt = PromptRegistry.get_prompt(
function_name='generate_page_content',
account=account,
context=page_context
)
return prompt
def parse_response(self, response: str, step_tracker=None) -> Dict:
"""Parse AI response - must be JSON with blocks structure"""
import json
# Try to extract JSON from response
try:
# Try direct JSON parse
parsed = json.loads(response.strip())
except json.JSONDecodeError:
# Try to extract JSON object from text
try:
# Look for JSON object in response
start = response.find('{')
end = response.rfind('}')
if start != -1 and end != -1 and end > start:
json_str = response[start:end + 1]
parsed = json.loads(json_str)
else:
raise ValueError("No JSON object found in response")
except (json.JSONDecodeError, ValueError) as e:
logger.error(f"Failed to parse page content response as JSON: {e}")
logger.error(f"Response preview: {response[:500]}")
raise ValueError(f"Invalid JSON response from AI: {str(e)}")
if not isinstance(parsed, dict):
raise ValueError("Response must be a JSON object")
# Validate required fields
if 'blocks' not in parsed and 'blocks_json' not in parsed:
raise ValueError("Response must include 'blocks' or 'blocks_json' field")
# Normalize to 'blocks' key
if 'blocks_json' in parsed:
parsed['blocks'] = parsed.pop('blocks_json')
return parsed
def save_output(
self,
parsed: Any,
original_data: Any,
account=None,
progress_tracker=None,
step_tracker=None
) -> Dict:
"""Save blocks to PageBlueprint and create/update Content record"""
if isinstance(original_data, list):
page = original_data[0] if original_data else None
else:
page = original_data
if not page:
raise ValueError("No page blueprint provided for saving")
if not isinstance(parsed, dict):
raise ValueError("Parsed response must be a dict")
blocks = parsed.get('blocks', [])
if not blocks:
raise ValueError("No blocks found in parsed response")
# Ensure blocks is a list
if not isinstance(blocks, list):
blocks = [blocks]
with transaction.atomic():
# Update PageBlueprint with generated blocks
page.blocks_json = blocks
page.status = 'ready' # Mark as ready after content generation
page.save(update_fields=['blocks_json', 'status', 'updated_at'])
# Find or create associated Task
task_title = f"[Site Builder] {page.title or page.slug.replace('-', ' ').title()}"
task = Tasks.objects.filter(
account=page.account,
site=page.site,
sector=page.sector,
title=task_title
).first()
# Create or update Content record with blocks
if task:
content_record, created = Content.objects.get_or_create(
task=task,
defaults={
'account': page.account,
'site': page.site,
'sector': page.sector,
'title': parsed.get('title') or page.title,
'html_content': parsed.get('html_content', ''),
'word_count': parsed.get('word_count', 0),
'status': 'draft',
'json_blocks': blocks, # Store blocks in json_blocks
'metadata': {
'page_id': page.id,
'page_slug': page.slug,
'page_type': page.type,
'generated_by': 'generate_page_content'
}
}
)
if not created:
# Update existing content
content_record.json_blocks = blocks
content_record.html_content = parsed.get('html_content', content_record.html_content)
content_record.word_count = parsed.get('word_count', content_record.word_count)
content_record.title = parsed.get('title') or content_record.title or page.title
if not content_record.metadata:
content_record.metadata = {}
content_record.metadata.update({
'page_id': page.id,
'page_slug': page.slug,
'page_type': page.type,
'generated_by': 'generate_page_content'
})
content_record.save()
else:
logger.warning(f"No task found for page {page.id}, skipping Content record creation")
content_record = None
logger.info(
f"[GeneratePageContentFunction] Saved {len(blocks)} blocks to page {page.id} "
f"(Content ID: {content_record.id if content_record else 'N/A'})"
)
return {
'count': 1,
'pages_updated': 1,
'blocks_count': len(blocks),
'content_id': content_record.id if content_record else None
}

View File

@@ -1,214 +0,0 @@
"""
Generate Site Structure AI Function
Phase 3 Site Builder
"""
import json
import logging
from typing import Any, Dict, List, Tuple
from django.utils.text import slugify
from igny8_core.ai.base import BaseAIFunction
from igny8_core.ai.prompts import PromptRegistry
from igny8_core.business.site_building.models import SiteBlueprint, PageBlueprint
logger = logging.getLogger(__name__)
class GenerateSiteStructureFunction(BaseAIFunction):
"""AI function that turns a business brief into a full site blueprint."""
def get_name(self) -> str:
return 'generate_site_structure'
def get_metadata(self) -> Dict:
metadata = super().get_metadata()
metadata.update({
'display_name': 'Generate Site Structure',
'description': 'Create site/page architecture from business brief, objectives, and style guides.',
'phases': {
'INIT': 'Validating blueprint data…',
'PREP': 'Preparing site context…',
'AI_CALL': 'Generating site structure with AI…',
'PARSE': 'Parsing generated blueprint…',
'SAVE': 'Saving pages and blocks…',
'DONE': 'Site structure ready!'
}
})
return metadata
def validate(self, payload: dict, account=None) -> Dict[str, Any]:
if not payload.get('ids'):
return {'valid': False, 'error': 'Site blueprint ID is required'}
return {'valid': True}
def prepare(self, payload: dict, account=None) -> Dict[str, Any]:
blueprint_ids = payload.get('ids', [])
queryset = SiteBlueprint.objects.filter(id__in=blueprint_ids)
if account:
queryset = queryset.filter(account=account)
blueprint = queryset.select_related('account', 'site').prefetch_related('pages').first()
if not blueprint:
raise ValueError("Site blueprint not found")
config = blueprint.config_json or {}
business_brief = payload.get('business_brief') or config.get('business_brief') or ''
objectives = payload.get('objectives') or config.get('objectives') or []
style = payload.get('style') or config.get('style') or {}
return {
'blueprint': blueprint,
'business_brief': business_brief,
'objectives': objectives,
'style': style,
}
def build_prompt(self, data: Dict[str, Any], account=None) -> str:
blueprint: SiteBlueprint = data['blueprint']
objectives = data.get('objectives') or []
objectives_text = '\n'.join(f"- {obj}" for obj in objectives) if isinstance(objectives, list) else objectives
style = data.get('style') or {}
style_text = json.dumps(style, indent=2) if isinstance(style, dict) and style else str(style)
existing_pages = [
{
'title': page.title,
'slug': page.slug,
'type': page.type,
'status': page.status,
}
for page in blueprint.pages.all()
]
context = {
'BUSINESS_BRIEF': data.get('business_brief', ''),
'OBJECTIVES': objectives_text or 'Create a full marketing site with clear navigation.',
'STYLE': style_text or 'Modern, responsive, accessible web design.',
'SITE_INFO': json.dumps({
'site_name': blueprint.name,
'site_description': blueprint.description,
'hosting_type': blueprint.hosting_type,
'existing_pages': existing_pages,
'existing_structure': blueprint.structure_json or {},
}, indent=2)
}
return PromptRegistry.get_prompt(
'generate_site_structure',
account=account or blueprint.account,
context=context
)
def parse_response(self, response: str, step_tracker=None) -> Dict[str, Any]:
if not response:
raise ValueError("AI response is empty")
response = response.strip()
try:
return self._ensure_dict(json.loads(response))
except json.JSONDecodeError:
logger.warning("Response not valid JSON, attempting to extract JSON object")
cleaned = self._extract_json_object(response)
if cleaned:
return self._ensure_dict(json.loads(cleaned))
raise ValueError("Unable to parse AI response into JSON")
def save_output(
self,
parsed: Dict[str, Any],
original_data: Dict[str, Any],
account=None,
progress_tracker=None,
step_tracker=None
) -> Dict[str, Any]:
blueprint: SiteBlueprint = original_data['blueprint']
structure = self._ensure_dict(parsed)
pages = structure.get('pages', [])
blueprint.structure_json = structure
blueprint.status = 'ready'
blueprint.save(update_fields=['structure_json', 'status', 'updated_at'])
created, updated, deleted = self._sync_page_blueprints(blueprint, pages)
message = f"Pages synced (created: {created}, updated: {updated}, deleted: {deleted})"
logger.info("[GenerateSiteStructure] %s for blueprint %s", message, blueprint.id)
return {
'success': True,
'count': created + updated,
'site_blueprint_id': blueprint.id,
'pages_created': created,
'pages_updated': updated,
'pages_deleted': deleted,
}
# Helpers -----------------------------------------------------------------
def _ensure_dict(self, data: Any) -> Dict[str, Any]:
if isinstance(data, dict):
return data
raise ValueError("AI response must be a JSON object with site metadata")
def _extract_json_object(self, text: str) -> str:
start = text.find('{')
end = text.rfind('}')
if start != -1 and end != -1 and end > start:
return text[start:end + 1]
return ''
def _sync_page_blueprints(self, blueprint: SiteBlueprint, pages: List[Dict[str, Any]]) -> Tuple[int, int, int]:
existing = {page.slug: page for page in blueprint.pages.all()}
seen_slugs = set()
created = updated = 0
for order, page_data in enumerate(pages or []):
slug = page_data.get('slug') or page_data.get('id') or page_data.get('title') or f"page-{order + 1}"
slug = slugify(slug) or f"page-{order + 1}"
seen_slugs.add(slug)
defaults = {
'title': page_data.get('title') or page_data.get('name') or slug.replace('-', ' ').title(),
'type': self._map_page_type(page_data.get('type')),
'blocks_json': page_data.get('blocks') or page_data.get('sections') or [],
'status': page_data.get('status') or 'draft',
'order': order,
}
page_obj, created_flag = PageBlueprint.objects.update_or_create(
site_blueprint=blueprint,
slug=slug,
defaults=defaults
)
if created_flag:
created += 1
else:
updated += 1
# Delete pages not present in new structure
deleted = 0
for slug, page in existing.items():
if slug not in seen_slugs:
page.delete()
deleted += 1
return created, updated, deleted
def _map_page_type(self, page_type: Any) -> str:
allowed = {choice[0] for choice in PageBlueprint._meta.get_field('type').choices}
if isinstance(page_type, str):
normalized = page_type.lower()
if normalized in allowed:
return normalized
# Map friendly names
mapping = {
'homepage': 'home',
'landing': 'home',
'service': 'services',
'product': 'products',
}
mapped = mapping.get(normalized)
if mapped in allowed:
return mapped
return 'custom'

View File

@@ -1,167 +0,0 @@
"""
Optimize Content AI Function
Phase 4 Linker & Optimizer
"""
import json
import logging
from typing import Any, Dict
from igny8_core.ai.base import BaseAIFunction
from igny8_core.ai.prompts import PromptRegistry
from igny8_core.business.content.models import Content
logger = logging.getLogger(__name__)
class OptimizeContentFunction(BaseAIFunction):
"""AI function that optimizes content for SEO, readability, and engagement."""
def get_name(self) -> str:
return 'optimize_content'
def get_metadata(self) -> Dict:
metadata = super().get_metadata()
metadata.update({
'display_name': 'Optimize Content',
'description': 'Optimize content for SEO, readability, and engagement.',
'phases': {
'INIT': 'Validating content data…',
'PREP': 'Preparing content context…',
'AI_CALL': 'Optimizing content with AI…',
'PARSE': 'Parsing optimized content…',
'SAVE': 'Saving optimized content…',
'DONE': 'Content optimized!'
}
})
return metadata
def validate(self, payload: dict, account=None) -> Dict[str, Any]:
if not payload.get('ids'):
return {'valid': False, 'error': 'Content ID is required'}
return {'valid': True}
def prepare(self, payload: dict, account=None) -> Dict[str, Any]:
content_ids = payload.get('ids', [])
queryset = Content.objects.filter(id__in=content_ids)
if account:
queryset = queryset.filter(account=account)
content = queryset.select_related('account', 'site', 'sector').first()
if not content:
raise ValueError("Content not found")
# Get current scores from analyzer
from igny8_core.business.optimization.services.analyzer import ContentAnalyzer
analyzer = ContentAnalyzer()
scores_before = analyzer.analyze(content)
return {
'content': content,
'scores_before': scores_before,
'html_content': content.html_content or '',
'meta_title': content.meta_title or '',
'meta_description': content.meta_description or '',
'primary_keyword': content.primary_keyword or '',
}
def build_prompt(self, data: Dict[str, Any], account=None) -> str:
content: Content = data['content']
scores_before = data.get('scores_before', {})
context = {
'CONTENT_TITLE': content.title or 'Untitled',
'HTML_CONTENT': data.get('html_content', ''),
'META_TITLE': data.get('meta_title', ''),
'META_DESCRIPTION': data.get('meta_description', ''),
'PRIMARY_KEYWORD': data.get('primary_keyword', ''),
'WORD_COUNT': str(content.word_count or 0),
'CURRENT_SCORES': json.dumps(scores_before, indent=2),
'SOURCE': content.source,
'INTERNAL_LINKS_COUNT': str(len(content.internal_links) if content.internal_links else 0),
}
return PromptRegistry.get_prompt(
'optimize_content',
account=account or content.account,
context=context
)
def parse_response(self, response: str, step_tracker=None) -> Dict[str, Any]:
if not response:
raise ValueError("AI response is empty")
response = response.strip()
try:
return self._ensure_dict(json.loads(response))
except json.JSONDecodeError:
logger.warning("Response not valid JSON, attempting to extract JSON object")
cleaned = self._extract_json_object(response)
if cleaned:
return self._ensure_dict(json.loads(cleaned))
raise ValueError("Unable to parse AI response into JSON")
def save_output(
self,
parsed: Dict[str, Any],
original_data: Dict[str, Any],
account=None,
progress_tracker=None,
step_tracker=None
) -> Dict[str, Any]:
content: Content = original_data['content']
# Extract optimized content
optimized_html = parsed.get('html_content') or parsed.get('content') or content.html_content
optimized_meta_title = parsed.get('meta_title') or content.meta_title
optimized_meta_description = parsed.get('meta_description') or content.meta_description
# Update content
content.html_content = optimized_html
if optimized_meta_title:
content.meta_title = optimized_meta_title
if optimized_meta_description:
content.meta_description = optimized_meta_description
# Recalculate word count
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
content_service = ContentGenerationService()
content.word_count = content_service._count_words(optimized_html)
# Increment optimizer version
content.optimizer_version += 1
# Get scores after optimization
from igny8_core.business.optimization.services.analyzer import ContentAnalyzer
analyzer = ContentAnalyzer()
scores_after = analyzer.analyze(content)
content.optimization_scores = scores_after
content.save(update_fields=[
'html_content', 'meta_title', 'meta_description',
'word_count', 'optimizer_version', 'optimization_scores', 'updated_at'
])
return {
'success': True,
'content_id': content.id,
'scores_before': original_data.get('scores_before', {}),
'scores_after': scores_after,
'word_count_before': original_data.get('word_count', 0),
'word_count_after': content.word_count,
'html_content': optimized_html,
'meta_title': optimized_meta_title,
'meta_description': optimized_meta_description,
}
# Helper methods
def _ensure_dict(self, data: Any) -> Dict[str, Any]:
if isinstance(data, dict):
return data
raise ValueError("AI response must be a JSON object")
def _extract_json_object(self, text: str) -> str:
start = text.find('{')
end = text.rfind('}')
if start != -1 and end != -1 and end > start:
return text[start:end + 1]
return ''

View File

@@ -1,2 +0,0 @@
# AI functions tests

View File

@@ -1,179 +0,0 @@
"""
Tests for OptimizeContentFunction
"""
from unittest.mock import Mock, patch, MagicMock
from django.test import TestCase
from igny8_core.business.content.models import Content
from igny8_core.ai.functions.optimize_content import OptimizeContentFunction
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
class OptimizeContentFunctionTests(IntegrationTestBase):
"""Tests for OptimizeContentFunction"""
def setUp(self):
super().setUp()
self.function = OptimizeContentFunction()
# Create test content
self.content = Content.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
title="Test Content",
html_content="<p>This is test content.</p>",
meta_title="Test Title",
meta_description="Test description",
primary_keyword="test keyword",
word_count=500,
status='draft'
)
def test_function_validation_phase(self):
"""Test validation phase"""
# Valid payload
result = self.function.validate({'ids': [self.content.id]}, self.account)
self.assertTrue(result['valid'])
# Invalid payload - missing ids
result = self.function.validate({}, self.account)
self.assertFalse(result['valid'])
self.assertIn('error', result)
def test_function_prep_phase(self):
"""Test prep phase"""
payload = {'ids': [self.content.id]}
data = self.function.prepare(payload, self.account)
self.assertIn('content', data)
self.assertIn('scores_before', data)
self.assertIn('html_content', data)
self.assertEqual(data['content'].id, self.content.id)
def test_function_prep_phase_content_not_found(self):
"""Test prep phase with non-existent content"""
payload = {'ids': [99999]}
with self.assertRaises(ValueError):
self.function.prepare(payload, self.account)
@patch('igny8_core.ai.functions.optimize_content.PromptRegistry.get_prompt')
def test_function_build_prompt(self, mock_get_prompt):
"""Test prompt building"""
mock_get_prompt.return_value = "Test prompt"
data = {
'content': self.content,
'html_content': '<p>Test</p>',
'meta_title': 'Title',
'meta_description': 'Description',
'primary_keyword': 'keyword',
'scores_before': {'overall_score': 50.0}
}
prompt = self.function.build_prompt(data, self.account)
self.assertEqual(prompt, "Test prompt")
mock_get_prompt.assert_called_once()
# Check that context was passed
call_args = mock_get_prompt.call_args
self.assertIn('context', call_args.kwargs)
def test_function_parse_response_valid_json(self):
"""Test parsing valid JSON response"""
response = '{"html_content": "<p>Optimized</p>", "meta_title": "New Title"}'
parsed = self.function.parse_response(response)
self.assertIn('html_content', parsed)
self.assertEqual(parsed['html_content'], "<p>Optimized</p>")
self.assertEqual(parsed['meta_title'], "New Title")
def test_function_parse_response_invalid_json(self):
"""Test parsing invalid JSON response"""
response = "This is not JSON"
with self.assertRaises(ValueError):
self.function.parse_response(response)
def test_function_parse_response_extracts_json_object(self):
"""Test that JSON object is extracted from text"""
response = 'Some text {"html_content": "<p>Optimized</p>"} more text'
parsed = self.function.parse_response(response)
self.assertIn('html_content', parsed)
self.assertEqual(parsed['html_content'], "<p>Optimized</p>")
@patch('igny8_core.business.optimization.services.analyzer.ContentAnalyzer.analyze')
@patch('igny8_core.business.content.services.content_generation_service.ContentGenerationService._count_words')
def test_function_save_phase(self, mock_count_words, mock_analyze):
"""Test save phase updates content"""
mock_count_words.return_value = 600
mock_analyze.return_value = {
'seo_score': 75.0,
'readability_score': 80.0,
'engagement_score': 70.0,
'overall_score': 75.0
}
parsed = {
'html_content': '<p>Optimized content.</p>',
'meta_title': 'Optimized Title',
'meta_description': 'Optimized Description'
}
original_data = {
'content': self.content,
'scores_before': {'overall_score': 50.0},
'word_count': 500
}
result = self.function.save_output(parsed, original_data, self.account)
self.assertTrue(result['success'])
self.assertEqual(result['content_id'], self.content.id)
# Refresh content from DB
self.content.refresh_from_db()
self.assertEqual(self.content.html_content, '<p>Optimized content.</p>')
self.assertEqual(self.content.optimizer_version, 1)
self.assertIsNotNone(self.content.optimization_scores)
def test_function_handles_invalid_content_id(self):
"""Test that function handles invalid content ID"""
payload = {'ids': [99999]}
with self.assertRaises(ValueError):
self.function.prepare(payload, self.account)
def test_function_respects_account_isolation(self):
"""Test that function respects account isolation"""
from igny8_core.auth.models import Account
other_account = Account.objects.create(
name="Other Account",
slug="other",
plan=self.plan,
owner=self.user
)
payload = {'ids': [self.content.id]}
# Should not find content from different account
with self.assertRaises(ValueError):
self.function.prepare(payload, other_account)
def test_get_name(self):
"""Test get_name method"""
self.assertEqual(self.function.get_name(), 'optimize_content')
def test_get_metadata(self):
"""Test get_metadata method"""
metadata = self.function.get_metadata()
self.assertIn('display_name', metadata)
self.assertIn('description', metadata)
self.assertIn('phases', metadata)
self.assertEqual(metadata['display_name'], 'Optimize Content')

View File

@@ -1,39 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-20 23:27
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='AITaskLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('task_id', models.CharField(blank=True, db_index=True, max_length=255, null=True)),
('function_name', models.CharField(db_index=True, max_length=100)),
('phase', models.CharField(default='INIT', max_length=50)),
('message', models.TextField(blank=True)),
('status', models.CharField(choices=[('success', 'Success'), ('error', 'Error'), ('pending', 'Pending')], default='pending', max_length=20)),
('duration', models.IntegerField(blank=True, help_text='Duration in milliseconds', null=True)),
('cost', models.DecimalField(decimal_places=6, default=0.0, max_digits=10)),
('tokens', models.IntegerField(default=0)),
('request_steps', models.JSONField(blank=True, default=list)),
('response_steps', models.JSONField(blank=True, default=list)),
('error', models.TextField(blank=True, null=True)),
('payload', models.JSONField(blank=True, null=True)),
('result', models.JSONField(blank=True, null=True)),
],
options={
'db_table': 'igny8_ai_task_logs',
'ordering': ['-created_at'],
},
),
]

View File

@@ -1,34 +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 = [
('ai', '0001_initial'),
('igny8_core_auth', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='aitasklog',
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.AddIndex(
model_name='aitasklog',
index=models.Index(fields=['task_id'], name='igny8_ai_ta_task_id_310356_idx'),
),
migrations.AddIndex(
model_name='aitasklog',
index=models.Index(fields=['function_name', 'account'], name='igny8_ai_ta_functio_0e5a30_idx'),
),
migrations.AddIndex(
model_name='aitasklog',
index=models.Index(fields=['status', 'created_at'], name='igny8_ai_ta_status_ed93b5_idx'),
),
]

View File

@@ -147,7 +147,7 @@ Output JSON Example:
]
}""",
'content_generation': """You are an editorial content strategist. Your task is to generate a complete JSON response object that includes all the fields listed below, based on the provided content idea, keyword cluster, keyword list, and metadata context.
'content_generation': """You are an editorial content strategist. Your task is to generate a complete JSON response object that includes all the fields listed below, based on the provided content idea, keyword cluster, and keyword list.
Only the `content` field should contain HTML inside JSON object.
@@ -217,28 +217,7 @@ KEYWORD & SEO RULES
- Don't repeat heading in opening sentence
- Vary sentence structure and length
===========================
STAGE 3: METADATA CONTEXT (NEW)
===========================
**Cluster Role:**
[IGNY8_CLUSTER_ROLE]
- If role is "hub": Create comprehensive, authoritative content that serves as the main resource for this topic cluster. Include overview sections, key concepts, and links to related topics.
- If role is "supporting": Create detailed, focused content that supports the hub page. Dive deep into specific aspects, use cases, or subtopics.
- If role is "attribute": Create content focused on specific attributes, features, or specifications. Include detailed comparisons, specifications, or attribute-focused information.
**Taxonomy Context:**
[IGNY8_TAXONOMY]
- Use taxonomy information to structure categories and tags appropriately.
- Align content with taxonomy hierarchy and relationships.
- Ensure content fits within the defined taxonomy structure.
**Product/Service Attributes:**
[IGNY8_ATTRIBUTES]
- If attributes are provided (e.g., product specs, service modifiers), incorporate them naturally into the content.
- For product content: Include specifications, features, dimensions, materials, etc. as relevant.
- For service content: Include service tiers, pricing modifiers, availability, etc. as relevant.
- Present attributes in a user-friendly format (tables, lists, or integrated into narrative).
===========================
INPUT VARIABLES
@@ -259,73 +238,6 @@ OUTPUT FORMAT
Return ONLY the final JSON object.
Do NOT include any comments, formatting, or explanations.""",
'site_structure_generation': """You are a senior UX architect and information designer. Use the business brief, objectives, style references, and existing site info to propose a complete multi-page marketing website structure.
INPUT CONTEXT
==============
BUSINESS BRIEF:
[IGNY8_BUSINESS_BRIEF]
PRIMARY OBJECTIVES:
[IGNY8_OBJECTIVES]
STYLE & BRAND NOTES:
[IGNY8_STYLE]
SITE INFO / CURRENT STRUCTURE:
[IGNY8_SITE_INFO]
OUTPUT REQUIREMENTS
====================
Return ONE JSON object with the following keys:
{
"site": {
"name": "...",
"primary_navigation": ["home", "services", "about", "contact"],
"secondary_navigation": ["blog", "faq"],
"hero_message": "High level value statement",
"tone": "voice + tone summary"
},
"pages": [
{
"slug": "home",
"title": "Home",
"type": "home | about | services | products | blog | contact | custom",
"status": "draft",
"objective": "Explain the core brand promise and primary CTA",
"primary_cta": "Book a strategy call",
"seo": {
"meta_title": "...",
"meta_description": "..."
},
"blocks": [
{
"type": "hero | features | services | stats | testimonials | faq | contact | custom",
"heading": "Section headline",
"subheading": "Support copy",
"layout": "full-width | two-column | cards | carousel",
"content": [
"Bullet or short paragraph describing what to render in this block"
]
}
]
}
]
}
RULES
=====
- Include 58 pages covering the complete buyer journey (awareness → evaluation → conversion → trust).
- Every page must have at least 3 blocks with concrete guidance (no placeholders like "Lorem ipsum").
- Use consistent slug naming, all lowercase with hyphens.
- Type must match the allowed enum and reflect page intent.
- Ensure the navigation arrays align with the page list.
- Focus on practical descriptions that an engineering team can hand off directly to the Site Builder.
Return ONLY valid JSON. No commentary, explanations, or Markdown.
""",
'image_prompt_extraction': """Extract image prompts from the following article content.
@@ -353,423 +265,6 @@ Make sure each prompt is detailed enough for image generation, describing the vi
'image_prompt_template': 'Create a high-quality {image_type} image to use as a featured photo for a blog post titled "{post_title}". The image should visually represent the theme, mood, and subject implied by the image prompt: {image_prompt}. Focus on a realistic, well-composed scene that naturally communicates the topic without text or logos. Use balanced lighting, pleasing composition, and photographic detail suitable for lifestyle or editorial web content. Avoid adding any visible or readable text, brand names, or illustrative effects. **And make sure image is not blurry.**',
'negative_prompt': 'text, watermark, logo, overlay, title, caption, writing on walls, writing on objects, UI, infographic elements, post title',
'optimize_content': """You are an expert content optimizer specializing in SEO, readability, and engagement.
Your task is to optimize the provided content to improve its SEO score, readability, and engagement metrics.
CURRENT CONTENT:
Title: {CONTENT_TITLE}
Word Count: {WORD_COUNT}
Source: {SOURCE}
Primary Keyword: {PRIMARY_KEYWORD}
Internal Links: {INTERNAL_LINKS_COUNT}
CURRENT META DATA:
Meta Title: {META_TITLE}
Meta Description: {META_DESCRIPTION}
CURRENT SCORES:
{CURRENT_SCORES}
HTML CONTENT:
{HTML_CONTENT}
OPTIMIZATION REQUIREMENTS:
1. SEO Optimization:
- Ensure meta title is 30-60 characters (if provided)
- Ensure meta description is 120-160 characters (if provided)
- Optimize primary keyword usage (natural, not keyword stuffing)
- Improve heading structure (H1, H2, H3 hierarchy)
- Add internal links where relevant (maintain existing links)
2. Readability:
- Average sentence length: 15-20 words
- Use clear, concise language
- Break up long paragraphs
- Use bullet points and lists where appropriate
- Ensure proper paragraph structure
3. Engagement:
- Add compelling headings
- Include relevant images placeholders (alt text)
- Use engaging language
- Create clear call-to-action sections
- Improve content flow and structure
OUTPUT FORMAT:
Return ONLY a JSON object in this format:
{{
"html_content": "[Optimized HTML content]",
"meta_title": "[Optimized meta title, 30-60 chars]",
"meta_description": "[Optimized meta description, 120-160 chars]",
"optimization_notes": "[Brief notes on what was optimized]"
}}
Do not include any explanations, text, or commentary outside the JSON output.
""",
# Phase 8: Universal Content Types
'product_generation': """You are a product content specialist. Generate comprehensive product content that includes detailed descriptions, features, specifications, pricing, and benefits.
INPUT:
Product Name: [IGNY8_PRODUCT_NAME]
Product Description: [IGNY8_PRODUCT_DESCRIPTION]
Product Features: [IGNY8_PRODUCT_FEATURES]
Target Audience: [IGNY8_TARGET_AUDIENCE]
Primary Keyword: [IGNY8_PRIMARY_KEYWORD]
OUTPUT FORMAT:
Return ONLY a JSON object in this format:
{
"title": "[Product name and key benefit]",
"meta_title": "[SEO-optimized meta title, 30-60 chars]",
"meta_description": "[Compelling meta description, 120-160 chars]",
"html_content": "[Complete HTML product page content]",
"word_count": [Integer word count],
"primary_keyword": "[Primary keyword]",
"secondary_keywords": ["keyword1", "keyword2", "keyword3"],
"tags": ["tag1", "tag2", "tag3"],
"categories": ["Category > Subcategory"],
"json_blocks": [
{
"type": "product_overview",
"heading": "Product Overview",
"content": "Detailed product description"
},
{
"type": "features",
"heading": "Key Features",
"items": ["Feature 1", "Feature 2", "Feature 3"]
},
{
"type": "specifications",
"heading": "Specifications",
"data": {"Spec 1": "Value 1", "Spec 2": "Value 2"}
},
{
"type": "pricing",
"heading": "Pricing",
"content": "Pricing information"
},
{
"type": "benefits",
"heading": "Benefits",
"items": ["Benefit 1", "Benefit 2", "Benefit 3"]
}
],
"structure_data": {
"product_type": "[Product type]",
"price_range": "[Price range]",
"target_market": "[Target market]"
}
}
CONTENT REQUIREMENTS:
- Include compelling product overview
- List key features with benefits
- Provide detailed specifications
- Include pricing information (if available)
- Highlight unique selling points
- Use SEO-optimized headings
- Include call-to-action sections
- Ensure natural keyword usage
""",
'service_generation': """You are a service page content specialist. Generate comprehensive service page content that explains services, benefits, process, and pricing.
INPUT:
Service Name: [IGNY8_SERVICE_NAME]
Service Description: [IGNY8_SERVICE_DESCRIPTION]
Service Benefits: [IGNY8_SERVICE_BENEFITS]
Target Audience: [IGNY8_TARGET_AUDIENCE]
Primary Keyword: [IGNY8_PRIMARY_KEYWORD]
OUTPUT FORMAT:
Return ONLY a JSON object in this format:
{
"title": "[Service name and value proposition]",
"meta_title": "[SEO-optimized meta title, 30-60 chars]",
"meta_description": "[Compelling meta description, 120-160 chars]",
"html_content": "[Complete HTML service page content]",
"word_count": [Integer word count],
"primary_keyword": "[Primary keyword]",
"secondary_keywords": ["keyword1", "keyword2", "keyword3"],
"tags": ["tag1", "tag2", "tag3"],
"categories": ["Category > Subcategory"],
"json_blocks": [
{
"type": "service_overview",
"heading": "Service Overview",
"content": "Detailed service description"
},
{
"type": "benefits",
"heading": "Benefits",
"items": ["Benefit 1", "Benefit 2", "Benefit 3"]
},
{
"type": "process",
"heading": "Our Process",
"steps": ["Step 1", "Step 2", "Step 3"]
},
{
"type": "pricing",
"heading": "Pricing",
"content": "Pricing information"
},
{
"type": "faq",
"heading": "Frequently Asked Questions",
"items": [{"question": "Q1", "answer": "A1"}]
}
],
"structure_data": {
"service_type": "[Service type]",
"duration": "[Service duration]",
"target_market": "[Target market]"
}
}
CONTENT REQUIREMENTS:
- Clear service overview and value proposition
- Detailed benefits and outcomes
- Step-by-step process explanation
- Pricing information (if available)
- FAQ section addressing common questions
- Include testimonials or case studies (if applicable)
- Use SEO-optimized headings
- Include call-to-action sections
""",
'generate_page_content': """You are a Site Builder content specialist. Generate structured page content optimized for website pages with JSON blocks format.
Your task is to generate content that will be rendered as a modern website page with structured blocks (hero, features, testimonials, text, CTA, etc.).
INPUT DATA:
----------
Page Title: [IGNY8_PAGE_TITLE]
Page Slug: [IGNY8_PAGE_SLUG]
Page Type: [IGNY8_PAGE_TYPE] (home, products, blog, contact, about, services, custom)
Site Name: [IGNY8_SITE_NAME]
Site Description: [IGNY8_SITE_DESCRIPTION]
Existing Block Hints: [IGNY8_EXISTING_BLOCKS]
Structure Hints: [IGNY8_STRUCTURE_HINTS]
OUTPUT FORMAT:
--------------
Return ONLY a JSON object in this exact structure:
{
"title": "[Page title - SEO optimized, natural]",
"html_content": "[Full HTML content for fallback/SEO - complete article]",
"word_count": [Integer - word count of HTML content],
"blocks": [
{
"type": "hero",
"data": {
"heading": "[Compelling hero headline]",
"subheading": "[Supporting subheadline]",
"content": "[Brief hero description - 1-2 sentences]",
"buttonText": "[CTA button text]",
"buttonLink": "[CTA link URL]"
}
},
{
"type": "text",
"data": {
"heading": "[Section heading]",
"content": "[Rich text content with paragraphs, lists, etc.]"
}
},
{
"type": "features",
"data": {
"heading": "[Features section heading]",
"content": [
"[Feature 1: Description]",
"[Feature 2: Description]",
"[Feature 3: Description]"
]
}
},
{
"type": "testimonials",
"data": {
"heading": "[Testimonials heading]",
"subheading": "[Optional subheading]",
"content": [
"[Testimonial quote 1]",
"[Testimonial quote 2]",
"[Testimonial quote 3]"
]
}
},
{
"type": "cta",
"data": {
"heading": "[CTA heading]",
"subheading": "[CTA subheading]",
"content": "[CTA description]",
"buttonText": "[Button text]",
"buttonLink": "[Button link]"
}
}
]
}
BLOCK TYPE GUIDELINES:
----------------------
Based on page type, use appropriate blocks:
**Home Page:**
- Start with "hero" block (compelling headline + CTA)
- Follow with "features" or "text" blocks
- Include "testimonials" block
- End with "cta" block
**Products Page:**
- Start with "text" block (product overview)
- Use "features" or "grid" blocks for product listings
- Include "text" blocks for product details
**Blog Page:**
- Use "text" blocks for article content
- Can include "quote" blocks for highlights
- Structure as readable article format
**Contact Page:**
- Start with "text" block (contact info)
- Use "form" block structure hints
- Include "text" blocks for location/hours
**About Page:**
- Start with "hero" or "text" block
- Use "features" for team/values
- Include "stats" block if applicable
- End with "text" block
**Services Page:**
- Start with "text" block (service overview)
- Use "features" for service offerings
- Include "text" blocks for details
CONTENT REQUIREMENTS:
---------------------
1. **Hero Block** (for home/about pages):
- Compelling headline (8-12 words)
- Clear value proposition
- Strong CTA button
2. **Text Blocks**:
- Natural, engaging copy
- SEO-optimized headings
- Varied content (paragraphs, lists, emphasis)
3. **Features Blocks**:
- 3-6 features
- Clear benefit statements
- Action-oriented language
4. **Testimonials Blocks**:
- 3-5 authentic-sounding testimonials
- Specific, believable quotes
- Varied lengths
5. **CTA Blocks**:
- Clear value proposition
- Strong action words
- Compelling button text
6. **HTML Content** (for SEO):
- Complete article version of all blocks
- Proper HTML structure
- SEO-optimized with headings, paragraphs, lists
- 800-1500 words total
TONE & STYLE:
-------------
- Professional but approachable
- Clear and concise
- Benefit-focused
- Action-oriented
- Natural keyword usage (not forced)
- No generic phrases or placeholder text
IMPORTANT:
----------
- Return ONLY the JSON object
- Do NOT include markdown formatting
- Do NOT include explanations or comments
- Ensure all blocks have proper "type" and "data" structure
- HTML content should be complete and standalone
- Blocks should be optimized for the specific page type""",
'taxonomy_generation': """You are a taxonomy and categorization specialist. Generate comprehensive taxonomy page content that organizes and explains categories, tags, and hierarchical structures.
INPUT:
Taxonomy Name: [IGNY8_TAXONOMY_NAME]
Taxonomy Description: [IGNY8_TAXONOMY_DESCRIPTION]
Taxonomy Items: [IGNY8_TAXONOMY_ITEMS]
Primary Keyword: [IGNY8_PRIMARY_KEYWORD]
OUTPUT FORMAT:
Return ONLY a JSON object in this format:
{
"title": "[Taxonomy name and purpose]",
"meta_title": "[SEO-optimized meta title, 30-60 chars]",
"meta_description": "[Compelling meta description, 120-160 chars]",
"html_content": "[Complete HTML taxonomy page content]",
"word_count": [Integer word count],
"primary_keyword": "[Primary keyword]",
"secondary_keywords": ["keyword1", "keyword2", "keyword3"],
"tags": ["tag1", "tag2", "tag3"],
"categories": ["Category > Subcategory"],
"json_blocks": [
{
"type": "taxonomy_overview",
"heading": "Taxonomy Overview",
"content": "Detailed taxonomy description"
},
{
"type": "categories",
"heading": "Categories",
"items": [
{
"name": "Category 1",
"description": "Category description",
"subcategories": ["Subcat 1", "Subcat 2"]
}
]
},
{
"type": "tags",
"heading": "Tags",
"items": ["Tag 1", "Tag 2", "Tag 3"]
},
{
"type": "hierarchy",
"heading": "Taxonomy Hierarchy",
"structure": {"Level 1": {"Level 2": ["Level 3"]}}
}
],
"structure_data": {
"taxonomy_type": "[Taxonomy type]",
"item_count": [Integer],
"hierarchy_levels": [Integer]
}
}
CONTENT REQUIREMENTS:
- Clear taxonomy overview and purpose
- Organized category structure
- Tag organization and relationships
- Hierarchical structure visualization
- SEO-optimized headings
- Include navigation and organization benefits
- Use clear, descriptive language
""",
}
# Mapping from function names to prompt types
@@ -780,13 +275,6 @@ CONTENT REQUIREMENTS:
'generate_images': 'image_prompt_extraction',
'extract_image_prompts': 'image_prompt_extraction',
'generate_image_prompts': 'image_prompt_extraction',
'generate_site_structure': 'site_structure_generation',
'generate_page_content': 'generate_page_content', # Site Builder specific
'optimize_content': 'optimize_content',
# Phase 8: Universal Content Types
'generate_product_content': 'product_generation',
'generate_service_page': 'service_generation',
'generate_taxonomy': 'taxonomy_generation',
}
@classmethod

View File

@@ -94,21 +94,9 @@ def _load_generate_image_prompts():
from igny8_core.ai.functions.generate_image_prompts import GenerateImagePromptsFunction
return GenerateImagePromptsFunction
def _load_generate_site_structure():
"""Lazy loader for generate_site_structure function"""
from igny8_core.ai.functions.generate_site_structure import GenerateSiteStructureFunction
return GenerateSiteStructureFunction
def _load_optimize_content():
"""Lazy loader for optimize_content function"""
from igny8_core.ai.functions.optimize_content import OptimizeContentFunction
return OptimizeContentFunction
register_lazy_function('auto_cluster', _load_auto_cluster)
register_lazy_function('generate_ideas', _load_generate_ideas)
register_lazy_function('generate_content', _load_generate_content)
register_lazy_function('generate_images', _load_generate_images)
register_lazy_function('generate_image_prompts', _load_generate_image_prompts)
register_lazy_function('generate_site_structure', _load_generate_site_structure)
register_lazy_function('optimize_content', _load_optimize_content)

View File

@@ -1,86 +0,0 @@
from __future__ import annotations
from igny8_core.ai.functions.generate_site_structure import GenerateSiteStructureFunction
from igny8_core.business.site_building.models import PageBlueprint
from igny8_core.business.site_building.tests.base import SiteBuilderTestBase
class GenerateSiteStructureFunctionTests(SiteBuilderTestBase):
"""Covers parsing + persistence logic for the Site Builder AI function."""
def setUp(self):
super().setUp()
self.function = GenerateSiteStructureFunction()
def test_parse_response_extracts_json_object(self):
noisy_response = """
Thoughts about the request…
{
"site": {"name": "Acme Robotics"},
"pages": [{"slug": "home", "title": "Home"}]
}
Extra commentary that should be ignored.
"""
parsed = self.function.parse_response(noisy_response)
self.assertEqual(parsed['site']['name'], 'Acme Robotics')
self.assertEqual(parsed['pages'][0]['slug'], 'home')
def test_save_output_updates_structure_and_syncs_pages(self):
# Existing page to prove update/delete flows.
legacy_page = PageBlueprint.objects.create(
site_blueprint=self.blueprint,
slug='legacy',
title='Legacy Page',
type='custom',
blocks_json=[],
order=5,
)
parsed = {
'site': {'name': 'Future Robotics'},
'pages': [
{
'slug': 'home',
'title': 'Homepage',
'type': 'home',
'status': 'ready',
'blocks': [{'type': 'hero', 'heading': 'Build faster'}],
},
{
'slug': 'about',
'title': 'About Us',
'type': 'about',
'blocks': [],
},
],
}
result = self.function.save_output(parsed, {'blueprint': self.blueprint})
self.blueprint.refresh_from_db()
self.assertEqual(self.blueprint.status, 'ready')
self.assertEqual(self.blueprint.structure_json['site']['name'], 'Future Robotics')
self.assertEqual(result['pages_created'], 1)
self.assertEqual(result['pages_updated'], 1)
self.assertEqual(result['pages_deleted'], 1)
slugs = set(self.blueprint.pages.values_list('slug', flat=True))
self.assertIn('home', slugs)
self.assertIn('about', slugs)
self.assertNotIn(legacy_page.slug, slugs)
def test_build_prompt_includes_existing_pages(self):
# Convert structure to JSON to ensure template rendering stays stable.
data = self.function.prepare(
payload={'ids': [self.blueprint.id]},
account=self.account,
)
prompt = self.function.build_prompt(data, account=self.account)
self.assertIn(self.blueprint.name, prompt)
self.assertIn('Home', prompt)
# The prompt should mention hosting type and objectives in JSON context.
self.assertIn(self.blueprint.hosting_type, prompt)
for objective in self.blueprint.config_json.get('objectives', []):
self.assertIn(objective, prompt)

View File

@@ -0,0 +1,116 @@
"""
Test script for AI functions
Run this to verify all AI functions work with console logging
"""
import os
import sys
import django
# Setup Django
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../../'))
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8.settings')
django.setup()
from igny8_core.ai.functions.auto_cluster import AutoClusterFunction
from igny8_core.ai.functions.generate_images import generate_images_core
from igny8_core.ai.ai_core import AICore
def test_ai_core():
"""Test AICore.run_ai_request() directly"""
print("\n" + "="*80)
print("TEST 1: AICore.run_ai_request() - Direct API Call")
print("="*80)
ai_core = AICore()
result = ai_core.run_ai_request(
prompt="Say 'Hello, World!' in JSON format: {\"message\": \"your message\"}",
max_tokens=100,
function_name='test_ai_core'
)
if result.get('error'):
print(f"❌ Error: {result['error']}")
else:
print(f"✅ Success! Content: {result.get('content', '')[:100]}")
print(f" Tokens: {result.get('total_tokens')}, Cost: ${result.get('cost', 0):.6f}")
def test_auto_cluster():
"""Test auto cluster function"""
print("\n" + "="*80)
print("TEST 2: Auto Cluster Function")
print("="*80)
print("Note: This requires actual keyword IDs in the database")
print("Skipping - requires database setup")
# Uncomment to test with real data:
# fn = AutoClusterFunction()
# result = fn.validate({'ids': [1, 2, 3]})
# print(f"Validation result: {result}")
def test_generate_content():
"""Test generate content function"""
print("\n" + "="*80)
print("TEST 3: Generate Content Function")
print("="*80)
print("Note: This requires actual task IDs in the database")
print("Skipping - requires database setup")
def test_generate_images():
"""Test generate images function"""
print("\n" + "="*80)
print("TEST 4: Generate Images Function")
print("="*80)
print("Note: This requires actual task IDs in the database")
print("Skipping - requires database setup")
# Uncomment to test with real data:
# result = generate_images_core(task_ids=[1], account_id=1)
# print(f"Result: {result}")
def test_json_extraction():
"""Test JSON extraction"""
print("\n" + "="*80)
print("TEST 5: JSON Extraction")
print("="*80)
ai_core = AICore()
# Test 1: Direct JSON
json_text = '{"clusters": [{"name": "Test", "keywords": ["test"]}]}'
result = ai_core.extract_json(json_text)
print(f"✅ Direct JSON: {result is not None}")
# Test 2: JSON in markdown
json_markdown = '```json\n{"clusters": [{"name": "Test"}]}\n```'
result = ai_core.extract_json(json_markdown)
print(f"✅ JSON in markdown: {result is not None}")
# Test 3: Invalid JSON
invalid_json = "This is not JSON"
result = ai_core.extract_json(invalid_json)
print(f"✅ Invalid JSON handled: {result is None}")
if __name__ == '__main__':
print("\n" + "="*80)
print("AI FUNCTIONS TEST SUITE")
print("="*80)
print("Testing all AI functions with console logging enabled")
print("="*80)
# Run tests
test_ai_core()
test_json_extraction()
test_auto_cluster()
test_generate_content()
test_generate_images()
print("\n" + "="*80)
print("TEST SUITE COMPLETE")
print("="*80)
print("\nAll console logging should be visible above.")
print("Check for [AI][function_name] Step X: messages")

View File

@@ -67,10 +67,16 @@ class JWTAuthentication(BaseAuthentication):
try:
account = Account.objects.get(id=account_id)
except Account.DoesNotExist:
# Account from token doesn't exist - don't fallback, set to None
pass
if not account:
try:
account = getattr(user, 'account', None)
except (AttributeError, Exception):
# If account access fails, set to None
account = None
# Set account on request (only if account_id was in token and account exists)
# Set account on request
request.account = account
return (user, token)
@@ -83,60 +89,3 @@ class JWTAuthentication(BaseAuthentication):
# This allows session authentication to work if JWT fails
return None
class APIKeyAuthentication(BaseAuthentication):
"""
API Key authentication for WordPress integration.
Validates API keys stored in Site.wp_api_key field.
"""
def authenticate(self, request):
"""
Authenticate using WordPress API key.
Returns (user, api_key) tuple if valid.
"""
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
if not auth_header.startswith('Bearer '):
return None # Not an API key request
api_key = auth_header.split(' ')[1] if len(auth_header.split(' ')) > 1 else None
if not api_key or len(api_key) < 20: # API keys should be at least 20 chars
return None
# Don't try to authenticate JWT tokens (they start with 'ey')
if api_key.startswith('ey'):
return None # Let JWTAuthentication handle it
try:
from igny8_core.auth.models import Site, User
# Find site by API key
site = Site.objects.select_related('account', 'account__owner').filter(
wp_api_key=api_key,
is_active=True
).first()
if not site:
return None # API key not found or site inactive
# Get account and user
account = site.account
user = account.owner # Use account owner as the authenticated user
if not user.is_active:
raise AuthenticationFailed('User account is disabled.')
# Set account on request for tenant isolation
request.account = account
# Set site on request for WordPress integration context
request.site = site
return (user, api_key)
except Exception as e:
# Log the error but return None to allow other auth classes to try
import logging
logger = logging.getLogger(__name__)
logger.debug(f'APIKeyAuthentication error: {str(e)}')
return None

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env python
"""
Test runner script for API tests
Run all tests: python manage.py test igny8_core.api.tests
Run specific test: python manage.py test igny8_core.api.tests.test_response
"""
import os
import sys
import django
# Setup Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
django.setup()
from django.core.management import execute_from_command_line
if __name__ == '__main__':
# Run all API tests
if len(sys.argv) > 1:
# Custom test specified
execute_from_command_line(['manage.py', 'test'] + sys.argv[1:])
else:
# Run all API tests
execute_from_command_line(['manage.py', 'test', 'igny8_core.api.tests', '--verbosity=2'])

View File

@@ -28,19 +28,11 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
- IGNY8_DEBUG_THROTTLE environment variable is True
- User belongs to aws-admin or other system accounts
- User is admin/developer role
- Public blueprint list request with site filter (for Sites Renderer)
"""
# Check if throttling should be bypassed
debug_bypass = getattr(settings, 'DEBUG', False)
env_bypass = getattr(settings, 'IGNY8_DEBUG_THROTTLE', False)
# Bypass for public blueprint list requests (Sites Renderer fallback)
public_blueprint_bypass = False
if hasattr(view, 'action') and view.action == 'list':
if hasattr(request, 'query_params') and request.query_params.get('site'):
if not request.user or not hasattr(request.user, 'is_authenticated') or not request.user.is_authenticated:
public_blueprint_bypass = True
# Bypass for system account users (aws-admin, default-account, etc.)
system_account_bypass = False
if hasattr(request, 'user') and request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated:
@@ -55,7 +47,7 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
# If checking fails, continue with normal throttling
pass
if debug_bypass or env_bypass or system_account_bypass or public_blueprint_bypass:
if debug_bypass or env_bypass or system_account_bypass:
# In debug mode or for system accounts, still set throttle headers but don't actually throttle
# This allows testing throttle headers without blocking requests
if hasattr(self, 'get_rate'):

View File

@@ -19,9 +19,21 @@ class PlanAdmin(admin.ModelAdmin):
('Plan Info', {
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active')
}),
('Account Management Limits', {
('User / Site Limits', {
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles')
}),
('Planner Limits', {
'fields': ('max_keywords', 'max_clusters', 'daily_cluster_limit', 'daily_keyword_import_limit', 'monthly_cluster_ai_credits')
}),
('Writer Limits', {
'fields': ('daily_content_tasks', 'daily_ai_requests', 'monthly_word_count_limit', 'monthly_content_ai_credits')
}),
('Image Limits', {
'fields': ('monthly_image_count', 'monthly_image_ai_credits', 'max_images_per_task', 'image_model_choices')
}),
('AI Controls', {
'fields': ('daily_ai_request_limit', 'monthly_ai_credit_limit')
}),
('Billing & Credits', {
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
}),
@@ -105,66 +117,11 @@ class SectorInline(admin.TabularInline):
@admin.register(Site)
class SiteAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ['name', 'slug', 'account', 'industry', 'domain', 'status', 'is_active', 'get_api_key_status', 'get_sectors_count']
list_filter = ['status', 'is_active', 'account', 'industry', 'hosting_type']
list_display = ['name', 'slug', 'account', 'industry', 'domain', 'status', 'is_active', 'get_sectors_count']
list_filter = ['status', 'is_active', 'account', 'industry']
search_fields = ['name', 'slug', 'domain', 'industry__name']
readonly_fields = ['created_at', 'updated_at', 'get_api_key_display']
readonly_fields = ['created_at', 'updated_at']
inlines = [SectorInline]
actions = ['generate_api_keys']
fieldsets = (
('Site Info', {
'fields': ('name', 'slug', 'account', 'domain', 'description', 'industry', 'site_type', 'hosting_type', 'status', 'is_active')
}),
('WordPress Integration', {
'fields': ('wp_url', 'wp_username', 'wp_app_password', 'get_api_key_display'),
'description': 'Legacy WordPress integration fields. For WordPress sites using the IGNY8 WP Bridge plugin.'
}),
('SEO Metadata', {
'fields': ('seo_metadata',),
'classes': ('collapse',)
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
def get_api_key_display(self, obj):
"""Display API key with copy button"""
if obj.wp_api_key:
from django.utils.html import format_html
return format_html(
'<div style="display:flex; align-items:center; gap:10px;">'
'<code style="background:#f0f0f0; padding:5px 10px; border-radius:3px;">{}</code>'
'<button type="button" onclick="navigator.clipboard.writeText(\'{}\'); alert(\'API Key copied to clipboard!\');" '
'style="padding:5px 10px; cursor:pointer;">Copy</button>'
'</div>',
obj.wp_api_key,
obj.wp_api_key
)
return format_html('<em>No API key generated</em>')
get_api_key_display.short_description = 'WordPress API Key'
def get_api_key_status(self, obj):
"""Show API key status in list view"""
if obj.wp_api_key:
from django.utils.html import format_html
return format_html('<span style="color:green;">●</span> Active')
return format_html('<span style="color:gray;">○</span> None')
get_api_key_status.short_description = 'API Key'
def generate_api_keys(self, request, queryset):
"""Generate API keys for selected sites"""
import secrets
updated_count = 0
for site in queryset:
if not site.wp_api_key:
site.wp_api_key = f"igny8_{''.join(secrets.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(40))}"
site.save()
updated_count += 1
self.message_user(request, f'Generated API keys for {updated_count} site(s). Sites with existing keys were skipped.')
generate_api_keys.short_description = 'Generate WordPress API Keys'
def get_sectors_count(self, obj):
try:

View File

@@ -8,7 +8,7 @@ from django.db.models import Q
from igny8_core.auth.models import Account, User, Site, Sector
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
from igny8_core.modules.writer.models import Tasks, Images, Content
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
from igny8_core.modules.billing.models import CreditTransaction, CreditUsageLog
from igny8_core.modules.system.models import AIPrompt, IntegrationSettings, AuthorProfile, Strategy
from igny8_core.modules.system.settings_models import AccountSettings, UserSettings, ModuleSettings, AISettings

View File

@@ -4,7 +4,6 @@ Extracts account from JWT token and injects into request context
"""
from django.utils.deprecation import MiddlewareMixin
from django.http import JsonResponse
from django.contrib.auth import logout
from rest_framework import status
try:
@@ -42,19 +41,14 @@ class AccountContextMiddleware(MiddlewareMixin):
request.user = user
# Get account from refreshed user
user_account = getattr(user, 'account', None)
validation_error = self._validate_account_and_plan(request, user)
if validation_error:
return validation_error
request.account = getattr(user, 'account', None)
return None
if user_account:
request.account = user_account
return None
except (AttributeError, UserModel.DoesNotExist, Exception):
# If refresh fails, fallback to cached account
try:
user_account = getattr(request.user, 'account', None)
if user_account:
validation_error = self._validate_account_and_plan(request, request.user)
if validation_error:
return validation_error
request.account = user_account
return None
except (AttributeError, Exception):
@@ -82,6 +76,7 @@ class AccountContextMiddleware(MiddlewareMixin):
if not JWT_AVAILABLE:
# JWT library not installed yet - skip for now
request.account = None
request.user = None
return None
# Decode JWT token with signature verification
@@ -99,76 +94,42 @@ class AccountContextMiddleware(MiddlewareMixin):
if user_id:
from .models import User, Account
try:
# Get user from DB (but don't set request.user - let DRF authentication handle that)
# Only set request.account for account context
# Refresh user from DB with account and plan relationships to get latest data
# This ensures changes to account/plan are reflected immediately without re-login
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
validation_error = self._validate_account_and_plan(request, user)
if validation_error:
return validation_error
request.user = user
if account_id:
# Verify account still exists
try:
account = Account.objects.get(id=account_id)
# Verify account still exists and matches user
account = Account.objects.get(id=account_id)
# If user's account changed, use the new one from user object
if user.account and user.account.id != account_id:
request.account = user.account
else:
request.account = account
except Account.DoesNotExist:
# Account from token doesn't exist - don't fallback, set to None
request.account = None
else:
# No account_id in token - set to None (don't fallback to user.account)
request.account = None
try:
user_account = getattr(user, 'account', None)
if user_account:
request.account = user_account
else:
request.account = None
except (AttributeError, Exception):
# If account access fails (e.g., column mismatch), set to None
request.account = None
except (User.DoesNotExist, Account.DoesNotExist):
request.account = None
request.user = None
else:
request.account = None
request.user = None
except jwt.InvalidTokenError:
request.account = None
request.user = None
except Exception:
# Fail silently for now - allow unauthenticated access
request.account = None
request.user = None
return None
def _validate_account_and_plan(self, request, user):
"""
Ensure the authenticated user has an account and an active plan.
If not, logout the user (for session auth) and block the request.
"""
try:
account = getattr(user, 'account', None)
except Exception:
account = None
if not account:
return self._deny_request(
request,
error='Account not configured for this user. Please contact support.',
status_code=status.HTTP_403_FORBIDDEN,
)
plan = getattr(account, 'plan', None)
if plan is None or getattr(plan, 'is_active', False) is False:
return self._deny_request(
request,
error='Active subscription required. Visit igny8.com/pricing to subscribe.',
status_code=status.HTTP_402_PAYMENT_REQUIRED,
)
return None
def _deny_request(self, request, error, status_code):
"""Logout session users (if any) and return a consistent JSON error."""
try:
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
logout(request)
except Exception:
pass
return JsonResponse(
{
'success': False,
'error': error,
},
status=status_code,
)

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.8 on 2025-11-20 23:27
# Generated by Django 5.2.7 on 2025-11-02 21:42
import django.contrib.auth.models
import django.contrib.auth.validators
@@ -25,22 +25,12 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('billing_cycle', models.CharField(choices=[('monthly', 'Monthly'), ('annual', 'Annual')], default='monthly', max_length=20)),
('features', models.JSONField(blank=True, default=list, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])")),
('credits_per_month', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
('max_sites', models.IntegerField(default=1, help_text='Maximum number of sites allowed (1-10)', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(10)])),
('features', models.JSONField(default=dict, help_text='Plan features as JSON')),
('stripe_price_id', models.CharField(blank=True, max_length=255, null=True)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('max_users', models.IntegerField(default=1, help_text='Total users allowed per account', validators=[django.core.validators.MinValueValidator(1)])),
('max_sites', models.IntegerField(default=1, help_text='Maximum number of sites allowed', validators=[django.core.validators.MinValueValidator(1)])),
('max_industries', models.IntegerField(blank=True, default=None, help_text='Optional limit for industries/sectors', null=True, validators=[django.core.validators.MinValueValidator(1)])),
('max_author_profiles', models.IntegerField(default=5, help_text='Limit for saved writing styles', validators=[django.core.validators.MinValueValidator(0)])),
('included_credits', models.IntegerField(default=0, help_text='Monthly credits included', validators=[django.core.validators.MinValueValidator(0)])),
('extra_credit_price', models.DecimalField(decimal_places=2, default=0.01, help_text='Price per additional credit', max_digits=10)),
('allow_credit_topup', models.BooleanField(default=True, help_text='Can user purchase more credits?')),
('auto_credit_topup_threshold', models.IntegerField(blank=True, default=None, help_text='Auto top-up trigger point (optional)', null=True, validators=[django.core.validators.MinValueValidator(0)])),
('auto_credit_topup_amount', models.IntegerField(blank=True, default=None, help_text='How many credits to auto-buy', null=True, validators=[django.core.validators.MinValueValidator(1)])),
('stripe_product_id', models.CharField(blank=True, help_text='For Stripe plan sync', max_length=255, null=True)),
('stripe_price_id', models.CharField(blank=True, help_text='Monthly price ID for Stripe', max_length=255, null=True)),
('credits_per_month', models.IntegerField(default=0, help_text='DEPRECATED: Use included_credits instead', validators=[django.core.validators.MinValueValidator(0)])),
],
options={
'db_table': 'igny8_plans',
@@ -60,7 +50,7 @@ class Migration(migrations.Migration):
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('role', models.CharField(choices=[('developer', 'Developer / Super Admin'), ('owner', 'Owner'), ('admin', 'Admin'), ('editor', 'Editor'), ('viewer', 'Viewer'), ('system_bot', 'System Bot')], default='viewer', max_length=20)),
('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('editor', 'Editor'), ('viewer', 'Viewer'), ('system_bot', 'System Bot')], default='viewer', max_length=20)),
('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
@@ -75,7 +65,7 @@ class Migration(migrations.Migration):
],
),
migrations.CreateModel(
name='Account',
name='Tenant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
@@ -85,93 +75,28 @@ class Migration(migrations.Migration):
('status', models.CharField(choices=[('active', 'Active'), ('suspended', 'Suspended'), ('trial', 'Trial'), ('cancelled', 'Cancelled')], default='trial', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='owned_accounts', to=settings.AUTH_USER_MODEL)),
('plan', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accounts', to='igny8_core_auth.plan')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='owned_tenants', to=settings.AUTH_USER_MODEL)),
('plan', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tenants', to='igny8_core_auth.plan')),
],
options={
'verbose_name': 'Account',
'verbose_name_plural': 'Accounts',
'db_table': 'igny8_tenants',
},
),
migrations.AddField(
model_name='user',
name='account',
field=models.ForeignKey(blank=True, db_column='tenant_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='users', to='igny8_core_auth.account'),
),
migrations.CreateModel(
name='Industry',
name='Subscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
('slug', models.SlugField(max_length=255, unique=True)),
('description', models.TextField(blank=True, null=True)),
('is_active', models.BooleanField(db_index=True, default=True)),
('stripe_subscription_id', models.CharField(max_length=255, unique=True)),
('status', models.CharField(choices=[('active', 'Active'), ('past_due', 'Past Due'), ('canceled', 'Canceled'), ('trialing', 'Trialing')], max_length=20)),
('current_period_start', models.DateTimeField()),
('current_period_end', models.DateTimeField()),
('cancel_at_period_end', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('tenant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='igny8_core_auth.tenant')),
],
options={
'verbose_name': 'Industry',
'verbose_name_plural': 'Industries',
'db_table': 'igny8_industries',
'ordering': ['name'],
'indexes': [models.Index(fields=['slug'], name='igny8_indus_slug_2f8769_idx'), models.Index(fields=['is_active'], name='igny8_indus_is_acti_146d41_idx')],
},
),
migrations.CreateModel(
name='IndustrySector',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255)),
('description', models.TextField(blank=True, null=True)),
('suggested_keywords', models.JSONField(default=list, help_text='List of suggested keywords for this sector template')),
('is_active', models.BooleanField(db_index=True, default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('industry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sectors', to='igny8_core_auth.industry')),
],
options={
'verbose_name': 'Industry Sector',
'verbose_name_plural': 'Industry Sectors',
'db_table': 'igny8_industry_sectors',
'ordering': ['industry', 'name'],
},
),
migrations.CreateModel(
name='PasswordResetToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(db_index=True, max_length=255, unique=True)),
('expires_at', models.DateTimeField()),
('used', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_reset_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'igny8_password_reset_tokens',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='SeedKeyword',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('keyword', models.CharField(db_index=True, max_length=255)),
('volume', models.IntegerField(default=0, help_text='Search volume estimate')),
('difficulty', models.IntegerField(default=0, help_text='Keyword difficulty (0-100)', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])),
('intent', models.CharField(choices=[('informational', 'Informational'), ('navigational', 'Navigational'), ('commercial', 'Commercial'), ('transactional', 'Transactional')], default='informational', max_length=50)),
('is_active', models.BooleanField(db_index=True, default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('industry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seed_keywords', to='igny8_core_auth.industry')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seed_keywords', to='igny8_core_auth.industrysector')),
],
options={
'verbose_name': 'Seed Keyword',
'verbose_name_plural': 'Seed Keywords',
'db_table': 'igny8_seed_keywords',
'ordering': ['keyword'],
'db_table': 'igny8_subscriptions',
},
),
migrations.CreateModel(
@@ -186,18 +111,13 @@ class Migration(migrations.Migration):
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('suspended', 'Suspended')], default='active', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('wp_url', models.URLField(blank=True, help_text='WordPress site URL (legacy - use SiteIntegration)', null=True)),
('wp_url', models.URLField(blank=True, help_text='WordPress site URL', null=True)),
('wp_username', models.CharField(blank=True, max_length=255, null=True)),
('wp_app_password', models.CharField(blank=True, max_length=255, null=True)),
('site_type', models.CharField(choices=[('marketing', 'Marketing Site'), ('ecommerce', 'Ecommerce Site'), ('blog', 'Blog'), ('portfolio', 'Portfolio'), ('corporate', 'Corporate')], db_index=True, default='marketing', help_text='Type of site', max_length=50)),
('hosting_type', models.CharField(choices=[('igny8_sites', 'IGNY8 Sites'), ('wordpress', 'WordPress'), ('shopify', 'Shopify'), ('multi', 'Multi-Destination')], db_index=True, default='igny8_sites', help_text='Target hosting platform', max_length=50)),
('seo_metadata', models.JSONField(blank=True, default=dict, help_text='SEO metadata: meta tags, Open Graph, Schema.org')),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
('industry', models.ForeignKey(blank=True, help_text='Industry this site belongs to', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sites', to='igny8_core_auth.industry')),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'db_table': 'igny8_sites',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
@@ -211,14 +131,18 @@ class Migration(migrations.Migration):
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive')], default='active', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
('industry_sector', models.ForeignKey(blank=True, help_text='Reference to the industry sector template', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='site_sectors', to='igny8_core_auth.industrysector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sectors', to='igny8_core_auth.site')),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'db_table': 'igny8_sectors',
},
),
migrations.AddField(
model_name='user',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='users', to='igny8_core_auth.tenant'),
),
migrations.CreateModel(
name='SiteUserAccess',
fields=[
@@ -229,111 +153,34 @@ class Migration(migrations.Migration):
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='site_access', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Site User Access',
'verbose_name_plural': 'Site User Access',
'db_table': 'igny8_site_user_access',
},
),
migrations.CreateModel(
name='Subscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_subscription_id', models.CharField(max_length=255, unique=True)),
('status', models.CharField(choices=[('active', 'Active'), ('past_due', 'Past Due'), ('canceled', 'Canceled'), ('trialing', 'Trialing')], max_length=20)),
('current_period_start', models.DateTimeField()),
('current_period_end', models.DateTimeField()),
('cancel_at_period_end', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.OneToOneField(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='igny8_core_auth.account')),
],
options={
'db_table': 'igny8_subscriptions',
'indexes': [models.Index(fields=['user', 'site'], name='igny8_site__user_id_61951e_idx')],
'unique_together': {('user', 'site')},
},
),
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['account', 'role'], name='igny8_users_tenant__0ab02b_idx'),
),
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['email'], name='igny8_users_email_fd61ff_idx'),
),
migrations.AddIndex(
model_name='industrysector',
index=models.Index(fields=['industry', 'is_active'], name='igny8_indus_industr_00b524_idx'),
),
migrations.AddIndex(
model_name='industrysector',
index=models.Index(fields=['slug'], name='igny8_indus_slug_101d63_idx'),
),
migrations.AlterUniqueTogether(
name='industrysector',
unique_together={('industry', 'slug')},
),
migrations.AddIndex(
model_name='passwordresettoken',
index=models.Index(fields=['token'], name='igny8_passw_token_0eaf0c_idx'),
),
migrations.AddIndex(
model_name='passwordresettoken',
index=models.Index(fields=['user', 'used'], name='igny8_passw_user_id_320c02_idx'),
),
migrations.AddIndex(
model_name='passwordresettoken',
index=models.Index(fields=['expires_at'], name='igny8_passw_expires_c9aa03_idx'),
),
migrations.AddIndex(
model_name='account',
model_name='tenant',
index=models.Index(fields=['slug'], name='igny8_tenan_slug_f25e97_idx'),
),
migrations.AddIndex(
model_name='account',
model_name='tenant',
index=models.Index(fields=['status'], name='igny8_tenan_status_5dc02a_idx'),
),
migrations.AddIndex(
model_name='seedkeyword',
index=models.Index(fields=['keyword'], name='igny8_seed__keyword_efa089_idx'),
),
migrations.AddIndex(
model_name='seedkeyword',
index=models.Index(fields=['industry', 'sector'], name='igny8_seed__industr_c41841_idx'),
),
migrations.AddIndex(
model_name='seedkeyword',
index=models.Index(fields=['industry', 'sector', 'is_active'], name='igny8_seed__industr_da0030_idx'),
),
migrations.AddIndex(
model_name='seedkeyword',
index=models.Index(fields=['intent'], name='igny8_seed__intent_15020d_idx'),
),
migrations.AlterUniqueTogether(
name='seedkeyword',
unique_together={('keyword', 'industry', 'sector')},
model_name='subscription',
index=models.Index(fields=['status'], name='igny8_subsc_status_2fa897_idx'),
),
migrations.AddIndex(
model_name='site',
index=models.Index(fields=['account', 'is_active'], name='igny8_sites_tenant__e0f31d_idx'),
index=models.Index(fields=['tenant', 'is_active'], name='igny8_sites_tenant__e0f31d_idx'),
),
migrations.AddIndex(
model_name='site',
index=models.Index(fields=['account', 'status'], name='igny8_sites_tenant__a20275_idx'),
),
migrations.AddIndex(
model_name='site',
index=models.Index(fields=['industry'], name='igny8_sites_industr_66e004_idx'),
),
migrations.AddIndex(
model_name='site',
index=models.Index(fields=['site_type'], name='igny8_sites_site_ty_0dfbc3_idx'),
),
migrations.AddIndex(
model_name='site',
index=models.Index(fields=['hosting_type'], name='igny8_sites_hosting_c484c2_idx'),
index=models.Index(fields=['tenant', 'status'], name='igny8_sites_tenant__a20275_idx'),
),
migrations.AlterUniqueTogether(
name='site',
unique_together={('account', 'slug')},
unique_together={('tenant', 'slug')},
),
migrations.AddIndex(
model_name='sector',
@@ -341,26 +188,18 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name='sector',
index=models.Index(fields=['account', 'site'], name='igny8_secto_tenant__af54ae_idx'),
),
migrations.AddIndex(
model_name='sector',
index=models.Index(fields=['industry_sector'], name='igny8_secto_industr_1cf990_idx'),
index=models.Index(fields=['tenant', 'site'], name='igny8_secto_tenant__af54ae_idx'),
),
migrations.AlterUniqueTogether(
name='sector',
unique_together={('site', 'slug')},
),
migrations.AddIndex(
model_name='siteuseraccess',
index=models.Index(fields=['user', 'site'], name='igny8_site__user_id_61951e_idx'),
),
migrations.AlterUniqueTogether(
name='siteuseraccess',
unique_together={('user', 'site')},
model_name='user',
index=models.Index(fields=['tenant', 'role'], name='igny8_users_tenant__0ab02b_idx'),
),
migrations.AddIndex(
model_name='subscription',
index=models.Index(fields=['status'], name='igny8_subsc_status_2fa897_idx'),
model_name='user',
index=models.Index(fields=['email'], name='igny8_users_email_fd61ff_idx'),
),
]

View File

@@ -0,0 +1,13 @@
# Generated by Django 5.2.7 on 2025-11-02 22:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0001_initial'),
]
operations = [
]

View File

@@ -1,19 +0,0 @@
# Generated manually for adding wp_api_key to Site model
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='site',
name='wp_api_key',
field=models.CharField(blank=True, help_text='API key for WordPress integration via IGNY8 WP Bridge plugin', max_length=255, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-03 13:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0002_add_developer_role'),
]
operations = [
migrations.AlterField(
model_name='user',
name='role',
field=models.CharField(choices=[('developer', 'Developer / Super Admin'), ('owner', 'Owner'), ('admin', 'Admin'), ('editor', 'Editor'), ('viewer', 'Viewer'), ('system_bot', 'System Bot')], default='viewer', max_length=20),
),
]

View File

@@ -0,0 +1,75 @@
# Generated migration for Industry and IndustrySector models
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0003_alter_user_role'),
]
operations = [
migrations.CreateModel(
name='Industry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
('slug', models.SlugField(db_index=True, max_length=255, unique=True)),
('description', models.TextField(blank=True, null=True)),
('is_active', models.BooleanField(db_index=True, default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'igny8_industries',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='IndustrySector',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(db_index=True, max_length=255)),
('description', models.TextField(blank=True, null=True)),
('suggested_keywords', models.JSONField(default=list, help_text='List of suggested keywords for this sector template')),
('is_active', models.BooleanField(db_index=True, default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('industry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sectors', to='igny8_core_auth.industry')),
],
options={
'db_table': 'igny8_industry_sectors',
'ordering': ['industry', 'name'],
'unique_together': {('industry', 'slug')},
},
),
migrations.AddField(
model_name='sector',
name='industry_sector',
field=models.ForeignKey(blank=True, help_text='Reference to the industry sector template', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='site_sectors', to='igny8_core_auth.industrysector'),
),
migrations.AddIndex(
model_name='industry',
index=models.Index(fields=['slug'], name='igny8_indu_slug_idx'),
),
migrations.AddIndex(
model_name='industry',
index=models.Index(fields=['is_active'], name='igny8_indu_is_acti_idx'),
),
migrations.AddIndex(
model_name='industrysector',
index=models.Index(fields=['industry', 'is_active'], name='igny8_indu_industr_idx'),
),
migrations.AddIndex(
model_name='industrysector',
index=models.Index(fields=['slug'], name='igny8_indu_slug_1_idx'),
),
migrations.AddIndex(
model_name='sector',
index=models.Index(fields=['industry_sector'], name='igny8_sect_industr_idx'),
),
]

View File

@@ -0,0 +1,31 @@
# Migration to add industry field to Site model
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0004_add_industry_models'),
]
operations = [
migrations.AddField(
model_name='site',
name='industry',
field=models.ForeignKey(
blank=True,
help_text='Industry this site belongs to',
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='sites',
to='igny8_core_auth.industry'
),
),
migrations.AddIndex(
model_name='site',
index=models.Index(fields=['industry'], name='igny8_site_industr_idx'),
),
]

View File

@@ -0,0 +1,151 @@
"""Add extended plan configuration fields"""
from decimal import Decimal
from django.core.validators import MinValueValidator
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0006_add_industry_to_site'),
]
operations = [
migrations.AddField(
model_name='plan',
name='ai_cost_per_request',
field=models.JSONField(default=dict, help_text="Cost per request type (e.g., {'cluster': 2, 'idea': 3, 'content': 5, 'image': 1})"),
),
migrations.AddField(
model_name='plan',
name='allow_credit_topup',
field=models.BooleanField(default=True, help_text='Can user purchase more credits?'),
),
migrations.AddField(
model_name='plan',
name='billing_cycle',
field=models.CharField(choices=[('monthly', 'Monthly'), ('annual', 'Annual')], default='monthly', max_length=20),
),
migrations.AddField(
model_name='plan',
name='daily_ai_request_limit',
field=models.IntegerField(default=100, help_text='Global daily AI request cap', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='daily_ai_requests',
field=models.IntegerField(default=50, help_text='Total AI executions (content + idea + image) allowed per day', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='daily_cluster_limit',
field=models.IntegerField(default=10, help_text='Max clusters that can be created per day', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='daily_content_tasks',
field=models.IntegerField(default=10, help_text='Max number of content tasks (blogs) per day', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='daily_keyword_import_limit',
field=models.IntegerField(default=100, help_text='SeedKeywords import limit per day', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='extra_credit_price',
field=models.DecimalField(decimal_places=2, default=Decimal('0.01'), help_text='Price per additional credit', max_digits=10),
),
migrations.AddField(
model_name='plan',
name='image_model_choices',
field=models.JSONField(default=list, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])"),
),
migrations.AddField(
model_name='plan',
name='included_credits',
field=models.IntegerField(default=0, help_text='Monthly credits included', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_author_profiles',
field=models.IntegerField(default=5, help_text='Limit for saved writing styles', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_clusters',
field=models.IntegerField(default=100, help_text='Total clusters allowed (global)', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_images_per_task',
field=models.IntegerField(default=4, help_text='Max images per content task', validators=[MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='max_industries',
field=models.IntegerField(blank=True, default=None, help_text='Optional limit for industries/sectors', null=True, validators=[MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='max_keywords',
field=models.IntegerField(default=1000, help_text='Total keywords allowed (global limit)', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_users',
field=models.IntegerField(default=1, help_text='Total users allowed per account', validators=[MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='monthly_ai_credit_limit',
field=models.IntegerField(default=500, help_text='Unified credit ceiling per month (all AI functions)', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='monthly_cluster_ai_credits',
field=models.IntegerField(default=50, help_text='AI credits allocated for clustering', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='monthly_content_ai_credits',
field=models.IntegerField(default=200, help_text='AI credit pool for content generation', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='monthly_image_ai_credits',
field=models.IntegerField(default=100, help_text='AI credit pool for image generation', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='monthly_image_count',
field=models.IntegerField(default=100, help_text='Max images per month', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='monthly_word_count_limit',
field=models.IntegerField(default=50000, help_text='Monthly word limit (for generated content)', validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='auto_credit_topup_threshold',
field=models.IntegerField(blank=True, default=None, help_text='Auto top-up trigger point (optional)', null=True, validators=[MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='auto_credit_topup_amount',
field=models.IntegerField(blank=True, default=None, help_text='How many credits to auto-buy', null=True, validators=[MinValueValidator(1)]),
),
migrations.AddField(
model_name='plan',
name='stripe_product_id',
field=models.CharField(blank=True, help_text='For Stripe plan sync', max_length=255, null=True),
),
migrations.AlterField(
model_name='plan',
name='features',
field=models.JSONField(default=list, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])"),
),
]

View File

@@ -0,0 +1,108 @@
# Generated by Django 5.2.8 on 2025-11-07 10:06
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0007_expand_plan_limits'),
]
operations = [
migrations.CreateModel(
name='PasswordResetToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(db_index=True, max_length=255, unique=True)),
('expires_at', models.DateTimeField()),
('used', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'igny8_password_reset_tokens',
'ordering': ['-created_at'],
},
),
migrations.AlterModelOptions(
name='industry',
options={'ordering': ['name'], 'verbose_name': 'Industry', 'verbose_name_plural': 'Industries'},
),
migrations.AlterModelOptions(
name='industrysector',
options={'ordering': ['industry', 'name'], 'verbose_name': 'Industry Sector', 'verbose_name_plural': 'Industry Sectors'},
),
migrations.AlterModelOptions(
name='site',
options={'ordering': ['-created_at']},
),
migrations.AlterModelOptions(
name='siteuseraccess',
options={'verbose_name': 'Site User Access', 'verbose_name_plural': 'Site User Access'},
),
migrations.RenameIndex(
model_name='industry',
new_name='igny8_indus_slug_2f8769_idx',
old_name='igny8_indu_slug_idx',
),
migrations.RenameIndex(
model_name='industry',
new_name='igny8_indus_is_acti_146d41_idx',
old_name='igny8_indu_is_acti_idx',
),
migrations.RenameIndex(
model_name='industrysector',
new_name='igny8_indus_industr_00b524_idx',
old_name='igny8_indu_industr_idx',
),
migrations.RenameIndex(
model_name='industrysector',
new_name='igny8_indus_slug_101d63_idx',
old_name='igny8_indu_slug_1_idx',
),
migrations.RenameIndex(
model_name='sector',
new_name='igny8_secto_industr_1cf990_idx',
old_name='igny8_sect_industr_idx',
),
migrations.RenameIndex(
model_name='site',
new_name='igny8_sites_industr_66e004_idx',
old_name='igny8_site_industr_idx',
),
migrations.AlterField(
model_name='plan',
name='credits_per_month',
field=models.IntegerField(default=0, help_text='DEPRECATED: Use included_credits instead', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AlterField(
model_name='plan',
name='extra_credit_price',
field=models.DecimalField(decimal_places=2, default=0.01, help_text='Price per additional credit', max_digits=10),
),
migrations.AlterField(
model_name='plan',
name='stripe_price_id',
field=models.CharField(blank=True, help_text='Monthly price ID for Stripe', max_length=255, null=True),
),
migrations.AddField(
model_name='passwordresettoken',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_reset_tokens', to=settings.AUTH_USER_MODEL),
),
migrations.AddIndex(
model_name='passwordresettoken',
index=models.Index(fields=['token'], name='igny8_passw_token_0eaf0c_idx'),
),
migrations.AddIndex(
model_name='passwordresettoken',
index=models.Index(fields=['user', 'used'], name='igny8_passw_user_id_320c02_idx'),
),
migrations.AddIndex(
model_name='passwordresettoken',
index=models.Index(fields=['expires_at'], name='igny8_passw_expires_c9aa03_idx'),
),
]

View File

@@ -0,0 +1,88 @@
from django.db import migrations
def forward_fix_admin_log_fk(apps, schema_editor):
if schema_editor.connection.vendor != "postgresql":
return
schema_editor.execute(
"""
ALTER TABLE django_admin_log
DROP CONSTRAINT IF EXISTS django_admin_log_user_id_c564eba6_fk_auth_user_id;
"""
)
schema_editor.execute(
"""
UPDATE django_admin_log
SET user_id = sub.new_user_id
FROM (
SELECT id AS new_user_id
FROM igny8_users
ORDER BY id
LIMIT 1
) AS sub
WHERE django_admin_log.user_id NOT IN (
SELECT id FROM igny8_users
);
"""
)
schema_editor.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'django_admin_log_user_id_c564eba6_fk_igny8_users_id'
) THEN
ALTER TABLE django_admin_log
ADD CONSTRAINT django_admin_log_user_id_c564eba6_fk_igny8_users_id
FOREIGN KEY (user_id) REFERENCES igny8_users(id) DEFERRABLE INITIALLY DEFERRED;
END IF;
END $$;
"""
)
def reverse_fix_admin_log_fk(apps, schema_editor):
if schema_editor.connection.vendor != "postgresql":
return
schema_editor.execute(
"""
ALTER TABLE django_admin_log
DROP CONSTRAINT IF EXISTS django_admin_log_user_id_c564eba6_fk_igny8_users_id;
"""
)
schema_editor.execute(
"""
UPDATE django_admin_log
SET user_id = sub.old_user_id
FROM (
SELECT id AS old_user_id
FROM auth_user
ORDER BY id
LIMIT 1
) AS sub
WHERE django_admin_log.user_id NOT IN (
SELECT id FROM auth_user
);
"""
)
schema_editor.execute(
"""
ALTER TABLE django_admin_log
ADD CONSTRAINT django_admin_log_user_id_c564eba6_fk_auth_user_id
FOREIGN KEY (user_id) REFERENCES auth_user(id) DEFERRABLE INITIALLY DEFERRED;
"""
)
class Migration(migrations.Migration):
dependencies = [
("igny8_core_auth", "0008_passwordresettoken_alter_industry_options_and_more"),
]
operations = [
migrations.RunPython(forward_fix_admin_log_fk, reverse_fix_admin_log_fk),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.8 on 2025-11-07 11:34
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0009_fix_admin_log_user_fk'),
]
operations = [
migrations.CreateModel(
name='SeedKeyword',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('keyword', models.CharField(db_index=True, max_length=255)),
('volume', models.IntegerField(default=0, help_text='Search volume estimate')),
('difficulty', models.IntegerField(default=0, help_text='Keyword difficulty (0-100)', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])),
('intent', models.CharField(choices=[('informational', 'Informational'), ('navigational', 'Navigational'), ('commercial', 'Commercial'), ('transactional', 'Transactional')], default='informational', max_length=50)),
('is_active', models.BooleanField(db_index=True, default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('industry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seed_keywords', to='igny8_core_auth.industry')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seed_keywords', to='igny8_core_auth.industrysector')),
],
options={
'verbose_name': 'Seed Keyword',
'verbose_name_plural': 'Seed Keywords',
'db_table': 'igny8_seed_keywords',
'ordering': ['keyword'],
'indexes': [models.Index(fields=['keyword'], name='igny8_seed__keyword_efa089_idx'), models.Index(fields=['industry', 'sector'], name='igny8_seed__industr_c41841_idx'), models.Index(fields=['industry', 'sector', 'is_active'], name='igny8_seed__industr_da0030_idx'), models.Index(fields=['intent'], name='igny8_seed__intent_15020d_idx')],
'unique_together': {('keyword', 'industry', 'sector')},
},
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.7 on 2025-11-07 11:45
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0010_add_seed_keyword'),
]
operations = [
migrations.AddField(
model_name='plan',
name='daily_image_generation_limit',
field=models.IntegerField(default=25, help_text='Max images that can be generated per day', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AddField(
model_name='plan',
name='max_content_ideas',
field=models.IntegerField(default=300, help_text='Total content ideas allowed (global limit)', validators=[django.core.validators.MinValueValidator(0)]),
),
migrations.AlterField(
model_name='plan',
name='max_sites',
field=models.IntegerField(default=1, help_text='Maximum number of sites allowed', validators=[django.core.validators.MinValueValidator(1)]),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2025-11-07 11:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0011_add_plan_fields_and_fix_constraints'),
]
operations = [
migrations.AlterField(
model_name='plan',
name='ai_cost_per_request',
field=models.JSONField(blank=True, default=dict, help_text="Cost per request type (e.g., {'cluster': 2, 'idea': 3, 'content': 5, 'image': 1})"),
),
migrations.AlterField(
model_name='plan',
name='features',
field=models.JSONField(blank=True, default=list, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])"),
),
migrations.AlterField(
model_name='plan',
name='image_model_choices',
field=models.JSONField(blank=True, default=list, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])"),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2025-11-07 12:01
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0012_allow_blank_json_fields'),
]
operations = [
migrations.RemoveField(
model_name='plan',
name='ai_cost_per_request',
),
]

View File

@@ -93,8 +93,8 @@ class Account(models.Model):
class Plan(models.Model):
"""
Subscription plan model - Phase 0: Credit-only system.
Plans define credits, billing, and account management limits only.
Subscription plan model with comprehensive limits and features.
Plans define limits for users, sites, content generation, AI usage, and billing.
"""
BILLING_CYCLE_CHOICES = [
('monthly', 'Monthly'),
@@ -110,7 +110,7 @@ class Plan(models.Model):
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
# Account Management Limits (kept - not operation limits)
# User / Site / Scope Limits
max_users = models.IntegerField(default=1, validators=[MinValueValidator(1)], help_text="Total users allowed per account")
max_sites = models.IntegerField(
default=1,
@@ -120,7 +120,32 @@ class Plan(models.Model):
max_industries = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="Optional limit for industries/sectors")
max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles")
# Billing & Credits (Phase 0: Credit-only system)
# Planner Limits
max_keywords = models.IntegerField(default=1000, validators=[MinValueValidator(0)], help_text="Total keywords allowed (global limit)")
max_clusters = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Total clusters allowed (global)")
max_content_ideas = models.IntegerField(default=300, validators=[MinValueValidator(0)], help_text="Total content ideas allowed (global limit)")
daily_cluster_limit = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max clusters that can be created per day")
daily_keyword_import_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="SeedKeywords import limit per day")
monthly_cluster_ai_credits = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="AI credits allocated for clustering")
# Writer Limits
daily_content_tasks = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max number of content tasks (blogs) per day")
daily_ai_requests = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="Total AI executions (content + idea + image) allowed per day")
monthly_word_count_limit = models.IntegerField(default=50000, validators=[MinValueValidator(0)], help_text="Monthly word limit (for generated content)")
monthly_content_ai_credits = models.IntegerField(default=200, validators=[MinValueValidator(0)], help_text="AI credit pool for content generation")
# Image Generation Limits
monthly_image_count = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Max images per month")
daily_image_generation_limit = models.IntegerField(default=25, validators=[MinValueValidator(0)], help_text="Max images that can be generated per day")
monthly_image_ai_credits = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="AI credit pool for image generation")
max_images_per_task = models.IntegerField(default=4, validators=[MinValueValidator(1)], help_text="Max images per content task")
image_model_choices = models.JSONField(default=list, blank=True, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])")
# AI Request Controls
daily_ai_request_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Global daily AI request cap")
monthly_ai_credit_limit = models.IntegerField(default=500, validators=[MinValueValidator(0)], help_text="Unified credit ceiling per month (all AI functions)")
# Billing & Add-ons
included_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Monthly credits included")
extra_credit_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.01, help_text="Price per additional credit")
allow_credit_topup = models.BooleanField(default=True, help_text="Can user purchase more credits?")
@@ -213,50 +238,10 @@ class Site(AccountBaseModel):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# WordPress integration fields (legacy - use SiteIntegration instead)
wp_url = models.URLField(blank=True, null=True, help_text="WordPress site URL (legacy - use SiteIntegration)")
# WordPress integration fields
wp_url = models.URLField(blank=True, null=True, help_text="WordPress site URL")
wp_username = models.CharField(max_length=255, blank=True, null=True)
wp_app_password = models.CharField(max_length=255, blank=True, null=True)
wp_api_key = models.CharField(max_length=255, blank=True, null=True, help_text="API key for WordPress integration via IGNY8 WP Bridge plugin")
# Site type and hosting (Phase 6)
SITE_TYPE_CHOICES = [
('marketing', 'Marketing Site'),
('ecommerce', 'Ecommerce Site'),
('blog', 'Blog'),
('portfolio', 'Portfolio'),
('corporate', 'Corporate'),
]
HOSTING_TYPE_CHOICES = [
('igny8_sites', 'IGNY8 Sites'),
('wordpress', 'WordPress'),
('shopify', 'Shopify'),
('multi', 'Multi-Destination'),
]
site_type = models.CharField(
max_length=50,
choices=SITE_TYPE_CHOICES,
default='marketing',
db_index=True,
help_text="Type of site"
)
hosting_type = models.CharField(
max_length=50,
choices=HOSTING_TYPE_CHOICES,
default='igny8_sites',
db_index=True,
help_text="Target hosting platform"
)
# SEO metadata (Phase 7)
seo_metadata = models.JSONField(
default=dict,
blank=True,
help_text="SEO metadata: meta tags, Open Graph, Schema.org"
)
class Meta:
db_table = 'igny8_sites'
@@ -266,8 +251,6 @@ class Site(AccountBaseModel):
models.Index(fields=['account', 'is_active']),
models.Index(fields=['account', 'status']),
models.Index(fields=['industry']),
models.Index(fields=['site_type']),
models.Index(fields=['hosting_type']),
]
def __str__(self):

View File

@@ -11,10 +11,10 @@ class PlanSerializer(serializers.ModelSerializer):
model = Plan
fields = [
'id', 'name', 'slug', 'price', 'billing_cycle', 'features', 'is_active',
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
'included_credits', 'extra_credit_price', 'allow_credit_topup',
'auto_credit_topup_threshold', 'auto_credit_topup_amount',
'stripe_product_id', 'stripe_price_id', 'credits_per_month'
'max_users', 'max_sites', 'max_keywords', 'max_clusters', 'max_content_ideas',
'monthly_word_count_limit', 'monthly_ai_credit_limit', 'monthly_image_count',
'daily_content_tasks', 'daily_ai_request_limit', 'daily_image_generation_limit',
'included_credits', 'image_model_choices', 'credits_per_month'
]
@@ -68,8 +68,7 @@ class SiteSerializer(serializers.ModelSerializer):
fields = [
'id', 'name', 'slug', 'domain', 'description',
'industry', 'industry_name', 'industry_slug',
'is_active', 'status', 'wp_url', 'wp_username', 'wp_api_key',
'site_type', 'hosting_type', 'seo_metadata',
'is_active', 'status', 'wp_url', 'wp_username',
'sectors_count', 'active_sectors_count', 'selected_sectors',
'can_add_sectors',
'created_at', 'updated_at'

View File

@@ -14,10 +14,8 @@ from .views import (
SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet,
IndustryViewSet, SeedKeywordViewSet
)
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer, RefreshTokenSerializer
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer
from .models import User
from .utils import generate_access_token, get_token_expiry, decode_token
import jwt
router = DefaultRouter()
# Main structure: Groups, Users, Accounts, Subscriptions, Site User Access
@@ -80,7 +78,7 @@ class LoginView(APIView):
password = serializer.validated_data['password']
try:
user = User.objects.select_related('account', 'account__plan').get(email=email)
user = User.objects.get(email=email)
except User.DoesNotExist:
return error_response(
error='Invalid credentials',
@@ -109,17 +107,9 @@ class LoginView(APIView):
user_data = user_serializer.data
except Exception as e:
# Fallback if serializer fails (e.g., missing account_id column)
# Log the error for debugging but don't fail the login
import logging
logger = logging.getLogger(__name__)
logger.warning(f"UserSerializer failed for user {user.id}: {e}", exc_info=True)
# Ensure username is properly set (use email prefix if username is empty/default)
username = user.username if user.username and user.username != 'user' else user.email.split('@')[0]
user_data = {
'id': user.id,
'username': username,
'username': user.username,
'email': user.email,
'role': user.role,
'account': None,
@@ -129,10 +119,12 @@ class LoginView(APIView):
return success_response(
data={
'user': user_data,
'access': access_token,
'refresh': refresh_token,
'access_expires_at': access_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(),
'tokens': {
'access': access_token,
'refresh': refresh_token,
'access_expires_at': access_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(),
}
},
message='Login successful',
request=request
@@ -188,84 +180,6 @@ class ChangePasswordView(APIView):
)
@extend_schema(
tags=['Authentication'],
summary='Refresh Token',
description='Refresh access token using refresh token'
)
class RefreshTokenView(APIView):
"""Refresh access token endpoint."""
permission_classes = [permissions.AllowAny]
def post(self, request):
serializer = RefreshTokenSerializer(data=request.data)
if not serializer.is_valid():
return error_response(
error='Validation failed',
errors=serializer.errors,
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
refresh_token = serializer.validated_data['refresh']
try:
# Decode and validate refresh token
payload = decode_token(refresh_token)
# Verify it's a refresh token
if payload.get('type') != 'refresh':
return error_response(
error='Invalid token type',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Get user
user_id = payload.get('user_id')
account_id = payload.get('account_id')
try:
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
except User.DoesNotExist:
return error_response(
error='User not found',
status_code=status.HTTP_404_NOT_FOUND,
request=request
)
# Get account
account = None
if account_id:
try:
from .models import Account
account = Account.objects.get(id=account_id)
except Exception:
pass
if not account:
account = getattr(user, 'account', None)
# Generate new access token
access_token = generate_access_token(user, account)
access_expires_at = get_token_expiry('access')
return success_response(
data={
'access': access_token,
'access_expires_at': access_expires_at.isoformat()
},
request=request
)
except jwt.InvalidTokenError:
return error_response(
error='Invalid or expired refresh token',
status_code=status.HTTP_401_UNAUTHORIZED,
request=request
)
@extend_schema(exclude=True) # Exclude from public API documentation - internal authenticated endpoint
class MeView(APIView):
"""Get current user information."""
@@ -287,7 +201,6 @@ urlpatterns = [
path('', include(router.urls)),
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'),
path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'),
path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
path('me/', MeView.as_view(), name='auth-me'),
]

View File

@@ -478,25 +478,15 @@ class SiteViewSet(AccountModelViewSet):
def get_permissions(self):
"""Allow normal users (viewer) to create sites, but require editor+ for other operations."""
# Allow public read access for list requests with slug filter (used by Sites Renderer)
if self.action == 'list' and self.request.query_params.get('slug'):
from rest_framework.permissions import AllowAny
return [AllowAny()]
if self.action == 'create':
return [permissions.IsAuthenticated()]
return [IsEditorOrAbove()]
def get_queryset(self):
"""Return sites accessible to the current user."""
# If this is a public request (no auth) with slug filter, return site by slug
if not self.request.user or not self.request.user.is_authenticated:
slug = self.request.query_params.get('slug')
if slug:
# Return queryset directly from model (bypassing base class account filtering)
return Site.objects.filter(slug=slug, is_active=True)
return Site.objects.none()
user = self.request.user
if not user or not user.is_authenticated:
return Site.objects.none()
# ADMIN/DEV OVERRIDE: Both admins and developers can see all sites
if user.is_admin_or_developer():
@@ -926,28 +916,13 @@ class AuthViewSet(viewsets.GenericViewSet):
)
if user.check_password(password):
# Ensure user has an account
account = getattr(user, 'account', None)
if account is None:
return error_response(
error='Account not configured for this user. Please contact support.',
status_code=status.HTTP_403_FORBIDDEN,
request=request,
)
# Ensure account has an active plan
plan = getattr(account, 'plan', None)
if plan is None or getattr(plan, 'is_active', False) is False:
return error_response(
error='Active subscription required. Visit igny8.com/pricing to subscribe.',
status_code=status.HTTP_402_PAYMENT_REQUIRED,
request=request,
)
# Log the user in (create session for session authentication)
from django.contrib.auth import login
login(request, user)
# Get account from user
account = getattr(user, 'account', None)
# Generate JWT tokens
access_token = generate_access_token(user, account)
refresh_token = generate_refresh_token(user, account)
@@ -958,10 +933,12 @@ class AuthViewSet(viewsets.GenericViewSet):
return success_response(
data={
'user': user_serializer.data,
'access': access_token,
'refresh': refresh_token,
'access_expires_at': access_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(),
'tokens': {
'access': access_token,
'refresh': refresh_token,
'access_expires_at': access_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(),
}
},
message='Login successful',
request=request

View File

@@ -1,5 +0,0 @@
"""
Business logic layer - Models and Services
Separated from API layer (modules/) for clean architecture
"""

View File

@@ -1,4 +0,0 @@
"""
Automation business logic - AutomationRule, ScheduledTask models and services
"""

View File

@@ -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}"

View File

@@ -1,4 +0,0 @@
"""
Automation services
"""

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -1,4 +0,0 @@
"""
Billing business logic - CreditTransaction, CreditUsageLog models and services
"""

View File

@@ -1,21 +0,0 @@
"""
Credit Cost Constants
Phase 0: Credit-only system costs per operation
"""
CREDIT_COSTS = {
'clustering': 10, # Per clustering request
'idea_generation': 15, # Per cluster → ideas request
'content_generation': 1, # Per 100 words
'image_prompt_extraction': 2, # Per content piece
'image_generation': 5, # Per image
'linking': 8, # Per content piece (NEW)
'optimization': 1, # Per 200 words (NEW)
'site_structure_generation': 50, # Per site blueprint (NEW)
'site_page_generation': 20, # Per page (NEW)
# Legacy operation types (for backward compatibility)
'ideas': 15, # Alias for idea_generation
'content': 3, # Legacy: 3 credits per content piece
'images': 5, # Alias for image_generation
'reparse': 1, # Per reparse
}

View File

@@ -1,14 +0,0 @@
"""
Billing Exceptions
"""
class InsufficientCreditsError(Exception):
"""Raised when account doesn't have enough credits"""
pass
class CreditCalculationError(Exception):
"""Raised when credit calculation fails"""
pass

View File

@@ -1,77 +0,0 @@
"""
Billing Models for Credit System
"""
from django.db import models
from django.core.validators import MinValueValidator
from igny8_core.auth.models import AccountBaseModel
class CreditTransaction(AccountBaseModel):
"""Track all credit transactions (additions, deductions)"""
TRANSACTION_TYPE_CHOICES = [
('purchase', 'Purchase'),
('subscription', 'Subscription Renewal'),
('refund', 'Refund'),
('deduction', 'Usage Deduction'),
('adjustment', 'Manual Adjustment'),
]
transaction_type = models.CharField(max_length=20, choices=TRANSACTION_TYPE_CHOICES, db_index=True)
amount = models.IntegerField(help_text="Positive for additions, negative for deductions")
balance_after = models.IntegerField(help_text="Credit balance after this transaction")
description = models.CharField(max_length=255)
metadata = models.JSONField(default=dict, help_text="Additional context (AI call details, etc.)")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
app_label = 'billing'
db_table = 'igny8_credit_transactions'
ordering = ['-created_at']
indexes = [
models.Index(fields=['account', 'transaction_type']),
models.Index(fields=['account', 'created_at']),
]
def __str__(self):
account = getattr(self, 'account', None)
return f"{self.get_transaction_type_display()} - {self.amount} credits - {account.name if account else 'No Account'}"
class CreditUsageLog(AccountBaseModel):
"""Detailed log of credit usage per AI operation"""
OPERATION_TYPE_CHOICES = [
('clustering', 'Keyword Clustering'),
('idea_generation', 'Content Ideas Generation'),
('content_generation', 'Content Generation'),
('image_generation', 'Image Generation'),
('reparse', 'Content Reparse'),
('ideas', 'Content Ideas Generation'), # Legacy
('content', 'Content Generation'), # Legacy
('images', 'Image Generation'), # Legacy
]
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES, db_index=True)
credits_used = models.IntegerField(validators=[MinValueValidator(0)])
cost_usd = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
model_used = models.CharField(max_length=100, blank=True)
tokens_input = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
tokens_output = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
related_object_type = models.CharField(max_length=50, blank=True) # 'keyword', 'cluster', 'task'
related_object_id = models.IntegerField(null=True, blank=True)
metadata = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
app_label = 'billing'
db_table = 'igny8_credit_usage_logs'
ordering = ['-created_at']
indexes = [
models.Index(fields=['account', 'operation_type']),
models.Index(fields=['account', 'created_at']),
models.Index(fields=['account', 'operation_type', 'created_at']),
]
def __str__(self):
account = getattr(self, 'account', None)
return f"{self.get_operation_type_display()} - {self.credits_used} credits - {account.name if account else 'No Account'}"

View File

@@ -1,4 +0,0 @@
"""
Billing services
"""

View File

@@ -1,264 +0,0 @@
"""
Credit Service for managing credit transactions and deductions
"""
from django.db import transaction
from django.utils import timezone
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
from igny8_core.business.billing.constants import CREDIT_COSTS
from igny8_core.business.billing.exceptions import InsufficientCreditsError, CreditCalculationError
from igny8_core.auth.models import Account
class CreditService:
"""Service for managing credits"""
@staticmethod
def get_credit_cost(operation_type, amount=None):
"""
Get credit cost for operation.
Args:
operation_type: Type of operation (from CREDIT_COSTS)
amount: Optional amount (word count, image count, etc.)
Returns:
int: Number of credits required
Raises:
CreditCalculationError: If operation type is unknown
"""
base_cost = CREDIT_COSTS.get(operation_type, 0)
if base_cost == 0:
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
# Variable cost operations
if operation_type == 'content_generation' and amount:
# Per 100 words
return max(1, int(base_cost * (amount / 100)))
elif operation_type == 'optimization' and amount:
# Per 200 words
return max(1, int(base_cost * (amount / 200)))
elif operation_type == 'image_generation' and amount:
# Per image
return base_cost * amount
elif operation_type == 'idea_generation' and amount:
# Per idea
return base_cost * amount
# Fixed cost operations
return base_cost
@staticmethod
def check_credits(account, operation_type, amount=None):
"""
Check if account has sufficient credits for an operation.
Args:
account: Account instance
operation_type: Type of operation
amount: Optional amount (word count, image count, etc.)
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
required = CreditService.get_credit_cost(operation_type, amount)
if account.credits < required:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {required}, Available: {account.credits}"
)
return True
@staticmethod
def check_credits_legacy(account, required_credits):
"""
Legacy method: Check if account has enough credits (for backward compatibility).
Args:
account: Account instance
required_credits: Number of credits required
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
if account.credits < required_credits:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
)
@staticmethod
@transaction.atomic
def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
"""
Deduct credits and log transaction.
Args:
account: Account instance
amount: Number of credits to deduct
operation_type: Type of operation (from CreditUsageLog.OPERATION_TYPE_CHOICES)
description: Description of the transaction
metadata: Optional metadata dict
cost_usd: Optional cost in USD
model_used: Optional AI model used
tokens_input: Optional input tokens
tokens_output: Optional output tokens
related_object_type: Optional related object type
related_object_id: Optional related object ID
Returns:
int: New credit balance
"""
# Check sufficient credits (legacy: amount is already calculated)
CreditService.check_credits_legacy(account, amount)
# Deduct from account.credits
account.credits -= amount
account.save(update_fields=['credits'])
# Create CreditTransaction
CreditTransaction.objects.create(
account=account,
transaction_type='deduction',
amount=-amount, # Negative for deduction
balance_after=account.credits,
description=description,
metadata=metadata or {}
)
# Create CreditUsageLog
CreditUsageLog.objects.create(
account=account,
operation_type=operation_type,
credits_used=amount,
cost_usd=cost_usd,
model_used=model_used or '',
tokens_input=tokens_input,
tokens_output=tokens_output,
related_object_type=related_object_type or '',
related_object_id=related_object_id,
metadata=metadata or {}
)
return account.credits
@staticmethod
@transaction.atomic
def deduct_credits_for_operation(account, operation_type, amount=None, description=None, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
"""
Deduct credits for an operation (convenience method that calculates cost automatically).
Args:
account: Account instance
operation_type: Type of operation
amount: Optional amount (word count, image count, etc.)
description: Optional description (auto-generated if not provided)
metadata: Optional metadata dict
cost_usd: Optional cost in USD
model_used: Optional AI model used
tokens_input: Optional input tokens
tokens_output: Optional output tokens
related_object_type: Optional related object type
related_object_id: Optional related object ID
Returns:
int: New credit balance
"""
# Calculate credit cost
credits_required = CreditService.get_credit_cost(operation_type, amount)
# Check sufficient credits
CreditService.check_credits(account, operation_type, amount)
# Auto-generate description if not provided
if not description:
if operation_type == 'clustering':
description = f"Clustering operation"
elif operation_type == 'idea_generation':
description = f"Generated {amount or 1} idea(s)"
elif operation_type == 'content_generation':
description = f"Generated content ({amount or 0} words)"
elif operation_type == 'image_generation':
description = f"Generated {amount or 1} image(s)"
else:
description = f"{operation_type} operation"
return CreditService.deduct_credits(
account=account,
amount=credits_required,
operation_type=operation_type,
description=description,
metadata=metadata,
cost_usd=cost_usd,
model_used=model_used,
tokens_input=tokens_input,
tokens_output=tokens_output,
related_object_type=related_object_type,
related_object_id=related_object_id
)
@staticmethod
@transaction.atomic
def add_credits(account, amount, transaction_type, description, metadata=None):
"""
Add credits (purchase, subscription, etc.).
Args:
account: Account instance
amount: Number of credits to add
transaction_type: Type of transaction (from CreditTransaction.TRANSACTION_TYPE_CHOICES)
description: Description of the transaction
metadata: Optional metadata dict
Returns:
int: New credit balance
"""
# Add to account.credits
account.credits += amount
account.save(update_fields=['credits'])
# Create CreditTransaction
CreditTransaction.objects.create(
account=account,
transaction_type=transaction_type,
amount=amount, # Positive for addition
balance_after=account.credits,
description=description,
metadata=metadata or {}
)
return account.credits
@staticmethod
def calculate_credits_for_operation(operation_type, **kwargs):
"""
Calculate credits needed for an operation.
Legacy method - use get_credit_cost() instead.
Args:
operation_type: Type of operation
**kwargs: Operation-specific parameters
Returns:
int: Number of credits required
Raises:
CreditCalculationError: If calculation fails
"""
# Map legacy operation types
if operation_type == 'ideas':
operation_type = 'idea_generation'
elif operation_type == 'content':
operation_type = 'content_generation'
elif operation_type == 'images':
operation_type = 'image_generation'
# Extract amount from kwargs
amount = None
if 'word_count' in kwargs:
amount = kwargs.get('word_count')
elif 'image_count' in kwargs:
amount = kwargs.get('image_count')
elif 'idea_count' in kwargs:
amount = kwargs.get('idea_count')
return CreditService.get_credit_cost(operation_type, amount)

View File

@@ -1,2 +0,0 @@
# Billing tests

View File

@@ -1,133 +0,0 @@
"""
Tests for Phase 4 credit deduction
"""
from unittest.mock import patch
from django.test import TestCase
from igny8_core.business.content.models import Content
from igny8_core.business.billing.services.credit_service import CreditService
from igny8_core.business.billing.constants import CREDIT_COSTS
from igny8_core.business.billing.exceptions import InsufficientCreditsError
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
class Phase4CreditTests(IntegrationTestBase):
"""Tests for Phase 4 credit deduction"""
def setUp(self):
super().setUp()
# Set initial credits
self.account.credits = 1000
self.account.save()
def test_linking_deducts_correct_credits(self):
"""Test that linking deducts correct credits"""
cost = CreditService.get_credit_cost('linking')
expected_cost = CREDIT_COSTS.get('linking', 0)
self.assertEqual(cost, expected_cost)
self.assertEqual(cost, 8) # From constants
def test_optimization_deducts_correct_credits(self):
"""Test that optimization deducts correct credits based on word count"""
word_count = 500
cost = CreditService.get_credit_cost('optimization', word_count)
# Should be 1 credit per 200 words, so 500 words = 3 credits (max(1, 1 * 500/200) = 3)
expected = max(1, int(CREDIT_COSTS.get('optimization', 1) * (word_count / 200)))
self.assertEqual(cost, expected)
def test_optimization_credits_per_entry_point(self):
"""Test that optimization credits are same regardless of entry point"""
word_count = 400
# All entry points should use same credit calculation
cost = CreditService.get_credit_cost('optimization', word_count)
# 400 words = 2 credits (1 * 400/200)
self.assertEqual(cost, 2)
@patch('igny8_core.business.billing.services.credit_service.CreditService.deduct_credits')
def test_pipeline_deducts_credits_at_each_stage(self, mock_deduct):
"""Test that pipeline deducts credits at each stage"""
from igny8_core.business.content.services.content_pipeline_service import ContentPipelineService
from igny8_core.business.linking.services.linker_service import LinkerService
from igny8_core.business.optimization.services.optimizer_service import OptimizerService
content = Content.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
title="Test",
word_count=400,
source='igny8'
)
# Mock the services
with patch.object(LinkerService, 'process') as mock_link, \
patch.object(OptimizerService, 'optimize_from_writer') as mock_optimize:
mock_link.return_value = content
mock_optimize.return_value = content
service = ContentPipelineService()
service.process_writer_content(content.id)
# Should deduct credits for both linking and optimization
self.assertGreater(mock_deduct.call_count, 0)
def test_insufficient_credits_blocks_linking(self):
"""Test that insufficient credits blocks linking"""
self.account.credits = 5 # Less than linking cost (8)
self.account.save()
with self.assertRaises(InsufficientCreditsError):
CreditService.check_credits(self.account, 'linking')
def test_insufficient_credits_blocks_optimization(self):
"""Test that insufficient credits blocks optimization"""
self.account.credits = 1 # Less than optimization cost for 500 words
self.account.save()
with self.assertRaises(InsufficientCreditsError):
CreditService.check_credits(self.account, 'optimization', 500)
def test_credit_deduction_logged(self):
"""Test that credit deduction is logged"""
from igny8_core.business.billing.models import CreditUsageLog
initial_credits = self.account.credits
cost = CreditService.get_credit_cost('linking')
CreditService.deduct_credits_for_operation(
account=self.account,
operation_type='linking',
description="Test linking"
)
self.account.refresh_from_db()
self.assertEqual(self.account.credits, initial_credits - cost)
# Check that usage log was created
log = CreditUsageLog.objects.filter(
account=self.account,
operation_type='linking'
).first()
self.assertIsNotNone(log)
def test_batch_operations_deduct_multiple_credits(self):
"""Test that batch operations deduct multiple credits"""
initial_credits = self.account.credits
linking_cost = CreditService.get_credit_cost('linking')
# Deduct for 3 linking operations
for i in range(3):
CreditService.deduct_credits_for_operation(
account=self.account,
operation_type='linking',
description=f"Linking {i}"
)
self.account.refresh_from_db()
expected_credits = initial_credits - (linking_cost * 3)
self.assertEqual(self.account.credits, expected_credits)

View File

@@ -1,4 +0,0 @@
"""
Content business logic - Content, Tasks, Images models and services
"""

View File

@@ -1,738 +0,0 @@
from django.db import models
from django.core.validators import MinValueValidator
from igny8_core.auth.models import SiteSectorBaseModel
class Tasks(SiteSectorBaseModel):
"""Tasks model for content generation queue"""
STATUS_CHOICES = [
('queued', 'Queued'),
('completed', 'Completed'),
]
CONTENT_STRUCTURE_CHOICES = [
('cluster_hub', 'Cluster Hub'),
('landing_page', 'Landing Page'),
('pillar_page', 'Pillar Page'),
('supporting_page', 'Supporting Page'),
]
CONTENT_TYPE_CHOICES = [
('blog_post', 'Blog Post'),
('article', 'Article'),
('guide', 'Guide'),
('tutorial', 'Tutorial'),
]
title = models.CharField(max_length=255, db_index=True)
description = models.TextField(blank=True, null=True)
keywords = models.CharField(max_length=500, blank=True) # Comma-separated keywords (legacy)
cluster = models.ForeignKey(
'planner.Clusters',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='tasks',
limit_choices_to={'sector': models.F('sector')}
)
keyword_objects = models.ManyToManyField(
'planner.Keywords',
blank=True,
related_name='tasks',
help_text="Individual keywords linked to this task"
)
idea = models.ForeignKey(
'planner.ContentIdeas',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='tasks'
)
content_structure = models.CharField(max_length=50, choices=CONTENT_STRUCTURE_CHOICES, default='blog_post')
content_type = models.CharField(max_length=50, choices=CONTENT_TYPE_CHOICES, default='blog_post')
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='queued')
# Stage 3: Entity metadata fields
ENTITY_TYPE_CHOICES = [
('blog_post', 'Blog Post'),
('article', 'Article'),
('product', 'Product'),
('service', 'Service Page'),
('taxonomy', 'Taxonomy Page'),
('page', 'Page'),
]
CLUSTER_ROLE_CHOICES = [
('hub', 'Hub Page'),
('supporting', 'Supporting Page'),
('attribute', 'Attribute Page'),
]
entity_type = models.CharField(
max_length=50,
choices=ENTITY_TYPE_CHOICES,
default='blog_post',
db_index=True,
blank=True,
null=True,
help_text="Type of content entity (inherited from idea/blueprint)"
)
taxonomy = models.ForeignKey(
'site_building.SiteBlueprintTaxonomy',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='tasks',
help_text="Taxonomy association when derived from blueprint planning"
)
cluster_role = models.CharField(
max_length=50,
choices=CLUSTER_ROLE_CHOICES,
default='hub',
blank=True,
null=True,
help_text="Role within the cluster-driven sitemap"
)
# Content fields
content = models.TextField(blank=True, null=True) # Generated content
word_count = models.IntegerField(default=0)
# SEO fields
meta_title = models.CharField(max_length=255, blank=True, null=True)
meta_description = models.TextField(blank=True, null=True)
# WordPress integration
assigned_post_id = models.IntegerField(null=True, blank=True) # WordPress post ID if published
post_url = models.URLField(blank=True, null=True) # WordPress post URL
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_tasks'
ordering = ['-created_at']
verbose_name = 'Task'
verbose_name_plural = 'Tasks'
indexes = [
models.Index(fields=['title']),
models.Index(fields=['status']),
models.Index(fields=['cluster']),
models.Index(fields=['content_type']),
models.Index(fields=['entity_type']),
models.Index(fields=['cluster_role']),
models.Index(fields=['site', 'sector']),
]
def __str__(self):
return self.title
class Content(SiteSectorBaseModel):
"""
Content model for storing final AI-generated article content.
Separated from Task for content versioning and storage optimization.
"""
task = models.OneToOneField(
Tasks,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='content_record',
help_text="The task this content belongs to"
)
html_content = models.TextField(help_text="Final AI-generated HTML content")
word_count = models.IntegerField(default=0, validators=[MinValueValidator(0)])
metadata = models.JSONField(default=dict, help_text="Additional metadata (SEO, structure, etc.)")
title = models.CharField(max_length=255, blank=True, null=True)
meta_title = models.CharField(max_length=255, blank=True, null=True)
meta_description = models.TextField(blank=True, null=True)
primary_keyword = models.CharField(max_length=255, blank=True, null=True)
secondary_keywords = models.JSONField(default=list, blank=True, help_text="List of secondary keywords")
tags = models.JSONField(default=list, blank=True, help_text="List of tags")
categories = models.JSONField(default=list, blank=True, help_text="List of categories")
STATUS_CHOICES = [
('draft', 'Draft'),
('review', 'Review'),
('publish', 'Publish'),
]
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='draft', help_text="Content workflow status (draft, review, publish)")
generated_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Phase 4: Source tracking
SOURCE_CHOICES = [
('igny8', 'IGNY8 Generated'),
('wordpress', 'WordPress Synced'),
('shopify', 'Shopify Synced'),
('custom', 'Custom API Synced'),
]
source = models.CharField(
max_length=50,
choices=SOURCE_CHOICES,
default='igny8',
db_index=True,
help_text="Source of the content"
)
SYNC_STATUS_CHOICES = [
('native', 'Native IGNY8 Content'),
('imported', 'Imported from External'),
('synced', 'Synced from External'),
]
sync_status = models.CharField(
max_length=50,
choices=SYNC_STATUS_CHOICES,
default='native',
db_index=True,
help_text="Sync status of the content"
)
# External reference fields
external_id = models.CharField(max_length=255, blank=True, null=True, help_text="External platform ID")
external_url = models.URLField(blank=True, null=True, help_text="External platform URL")
sync_metadata = models.JSONField(default=dict, blank=True, help_text="Platform-specific sync metadata")
# Phase 4: Linking fields
internal_links = models.JSONField(default=list, blank=True, help_text="Internal links added by linker")
linker_version = models.IntegerField(default=0, help_text="Version of linker processing")
# Phase 4: Optimization fields
optimizer_version = models.IntegerField(default=0, help_text="Version of optimizer processing")
optimization_scores = models.JSONField(default=dict, blank=True, help_text="Optimization scores (SEO, readability, engagement)")
# Phase 8: Universal Content Types
ENTITY_TYPE_CHOICES = [
('post', 'Blog Post'),
('page', 'Page'),
('product', 'Product'),
('service', 'Service Page'),
('taxonomy_term', 'Taxonomy Term Page'),
# Legacy choices for backward compatibility
('blog_post', 'Blog Post (Legacy)'),
('article', 'Article (Legacy)'),
('taxonomy', 'Taxonomy Page (Legacy)'),
]
entity_type = models.CharField(
max_length=50,
choices=ENTITY_TYPE_CHOICES,
default='post',
db_index=True,
help_text="Type of content entity"
)
# Phase 9: Content format (for posts)
CONTENT_FORMAT_CHOICES = [
('article', 'Article'),
('listicle', 'Listicle'),
('guide', 'How-To Guide'),
('comparison', 'Comparison'),
('review', 'Review'),
('roundup', 'Roundup'),
]
content_format = models.CharField(
max_length=50,
choices=CONTENT_FORMAT_CHOICES,
blank=True,
null=True,
db_index=True,
help_text="Content format (only for entity_type=post)"
)
# Phase 9: Cluster role
CLUSTER_ROLE_CHOICES = [
('hub', 'Hub Page'),
('supporting', 'Supporting Content'),
('attribute', 'Attribute Page'),
]
cluster_role = models.CharField(
max_length=50,
choices=CLUSTER_ROLE_CHOICES,
default='supporting',
blank=True,
null=True,
db_index=True,
help_text="Role within cluster strategy"
)
# Phase 9: WordPress post type
external_type = models.CharField(
max_length=100,
blank=True,
help_text="WordPress post type (post, page, product, service)"
)
# Phase 8: Structured content blocks
json_blocks = models.JSONField(
default=list,
blank=True,
help_text="Structured content blocks (for products, services, taxonomies)"
)
# Phase 8: Content structure data
structure_data = models.JSONField(
default=dict,
blank=True,
help_text="Content structure data (metadata, schema, etc.)"
)
# Phase 9: Taxonomy relationships
taxonomies = models.ManyToManyField(
'ContentTaxonomy',
blank=True,
related_name='contents',
through='ContentTaxonomyRelation',
help_text="Associated taxonomy terms (categories, tags, attributes)"
)
# Phase 9: Direct cluster relationship
cluster = models.ForeignKey(
'planner.Clusters',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='contents',
help_text="Primary semantic cluster"
)
class Meta:
app_label = 'writer'
db_table = 'igny8_content'
ordering = ['-generated_at']
verbose_name = 'Content'
verbose_name_plural = 'Contents'
indexes = [
models.Index(fields=['task']),
models.Index(fields=['generated_at']),
models.Index(fields=['source']),
models.Index(fields=['sync_status']),
models.Index(fields=['source', 'sync_status']),
models.Index(fields=['entity_type']),
models.Index(fields=['content_format']),
models.Index(fields=['cluster_role']),
models.Index(fields=['cluster']),
models.Index(fields=['external_type']),
models.Index(fields=['site', 'entity_type']),
]
def save(self, *args, **kwargs):
"""Automatically set account, site, and sector from task"""
if self.task_id: # Check task_id instead of accessing task to avoid RelatedObjectDoesNotExist
try:
self.account = self.task.account
self.site = self.task.site
self.sector = self.task.sector
except self.task.RelatedObjectDoesNotExist:
pass # Task doesn't exist, skip
super().save(*args, **kwargs)
def __str__(self):
return f"Content for {self.task.title}"
class ContentTaxonomy(SiteSectorBaseModel):
"""
Universal taxonomy model for categories, tags, and product attributes.
Syncs with WordPress taxonomies and stores terms.
"""
TAXONOMY_TYPE_CHOICES = [
('category', 'Category'),
('tag', 'Tag'),
('product_cat', 'Product Category'),
('product_tag', 'Product Tag'),
('product_attr', 'Product Attribute'),
('service_cat', 'Service Category'),
]
SYNC_STATUS_CHOICES = [
('native', 'Native IGNY8'),
('imported', 'Imported from External'),
('synced', 'Synced with External'),
]
name = models.CharField(max_length=255, db_index=True, help_text="Term name")
slug = models.SlugField(max_length=255, db_index=True, help_text="URL slug")
taxonomy_type = models.CharField(
max_length=50,
choices=TAXONOMY_TYPE_CHOICES,
db_index=True,
help_text="Type of taxonomy"
)
description = models.TextField(blank=True, help_text="Term description")
parent = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.CASCADE,
related_name='children',
help_text="Parent term for hierarchical taxonomies"
)
# WordPress/WooCommerce sync fields
external_id = models.IntegerField(
null=True,
blank=True,
db_index=True,
help_text="WordPress term ID"
)
external_taxonomy = models.CharField(
max_length=100,
blank=True,
help_text="WP taxonomy name (category, post_tag, product_cat, pa_color)"
)
sync_status = models.CharField(
max_length=50,
choices=SYNC_STATUS_CHOICES,
default='native',
db_index=True,
help_text="Sync status with external system"
)
# WordPress metadata
count = models.IntegerField(default=0, help_text="Post/product count from WordPress")
metadata = models.JSONField(default=dict, blank=True, help_text="Additional metadata")
# Cluster mapping
clusters = models.ManyToManyField(
'planner.Clusters',
blank=True,
related_name='taxonomy_terms',
help_text="Semantic clusters this term maps to"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_content_taxonomy_terms'
verbose_name = 'Content Taxonomy'
verbose_name_plural = 'Content Taxonomies'
unique_together = [
['site', 'slug', 'taxonomy_type'],
['site', 'external_id', 'external_taxonomy'],
]
indexes = [
models.Index(fields=['name']),
models.Index(fields=['slug']),
models.Index(fields=['taxonomy_type']),
models.Index(fields=['sync_status']),
models.Index(fields=['external_id', 'external_taxonomy']),
models.Index(fields=['site', 'taxonomy_type']),
models.Index(fields=['site', 'sector']),
]
def __str__(self):
return f"{self.name} ({self.get_taxonomy_type_display()})"
class ContentTaxonomyRelation(models.Model):
"""
Through model for Content-Taxonomy M2M relationship.
Simplified without SiteSectorBaseModel to avoid tenant_id issues.
"""
content = models.ForeignKey(
Content,
on_delete=models.CASCADE,
related_name='taxonomy_relations'
)
taxonomy = models.ForeignKey(
ContentTaxonomy,
on_delete=models.CASCADE,
related_name='content_relations'
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_content_taxonomy_relations'
unique_together = [['content', 'taxonomy']]
indexes = [
models.Index(fields=['content']),
models.Index(fields=['taxonomy']),
]
def __str__(self):
return f"{self.content}{self.taxonomy}"
class Images(SiteSectorBaseModel):
"""Images model for content-related images (featured, desktop, mobile, in-article)"""
IMAGE_TYPE_CHOICES = [
('featured', 'Featured Image'),
('desktop', 'Desktop Image'),
('mobile', 'Mobile Image'),
('in_article', 'In-Article Image'),
]
content = models.ForeignKey(
Content,
on_delete=models.CASCADE,
related_name='images',
null=True,
blank=True,
help_text="The content this image belongs to (preferred)"
)
task = models.ForeignKey(
Tasks,
on_delete=models.CASCADE,
related_name='images',
null=True,
blank=True,
help_text="The task this image belongs to (legacy, use content instead)"
)
image_type = models.CharField(max_length=50, choices=IMAGE_TYPE_CHOICES, default='featured')
image_url = models.CharField(max_length=500, blank=True, null=True, help_text="URL of the generated/stored image")
image_path = models.CharField(max_length=500, blank=True, null=True, help_text="Local path if stored locally")
prompt = models.TextField(blank=True, null=True, help_text="Image generation prompt used")
status = models.CharField(max_length=50, default='pending', help_text="Status: pending, generated, failed")
position = models.IntegerField(default=0, help_text="Position for in-article images ordering")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_images'
ordering = ['content', 'position', '-created_at']
verbose_name = 'Image'
verbose_name_plural = 'Images'
indexes = [
models.Index(fields=['content', 'image_type']),
models.Index(fields=['task', 'image_type']),
models.Index(fields=['status']),
models.Index(fields=['content', 'position']),
models.Index(fields=['task', 'position']),
]
def save(self, *args, **kwargs):
"""Automatically set account, site, and sector from content or task"""
# Prefer content over task
if self.content:
self.account = self.content.account
self.site = self.content.site
self.sector = self.content.sector
elif self.task:
self.account = self.task.account
self.site = self.task.site
self.sector = self.task.sector
super().save(*args, **kwargs)
def __str__(self):
content_title = self.content.title if self.content else None
task_title = self.task.title if self.task else None
title = content_title or task_title or 'Unknown'
return f"{title} - {self.image_type}"
class ContentClusterMap(SiteSectorBaseModel):
"""Associates generated content with planner clusters + roles."""
ROLE_CHOICES = [
('hub', 'Hub Page'),
('supporting', 'Supporting Page'),
('attribute', 'Attribute Page'),
]
SOURCE_CHOICES = [
('blueprint', 'Blueprint'),
('manual', 'Manual'),
('import', 'Import'),
]
content = models.ForeignKey(
Content,
on_delete=models.CASCADE,
related_name='cluster_mappings',
null=True,
blank=True,
)
task = models.ForeignKey(
Tasks,
on_delete=models.CASCADE,
related_name='cluster_mappings',
null=True,
blank=True,
)
cluster = models.ForeignKey(
'planner.Clusters',
on_delete=models.CASCADE,
related_name='content_mappings',
)
role = models.CharField(max_length=50, choices=ROLE_CHOICES, default='hub')
source = models.CharField(max_length=50, choices=SOURCE_CHOICES, default='blueprint')
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_content_cluster_map'
unique_together = [['content', 'cluster', 'role']]
indexes = [
models.Index(fields=['cluster', 'role']),
models.Index(fields=['content', 'role']),
models.Index(fields=['task', 'role']),
]
def save(self, *args, **kwargs):
provider = self.content or self.task
if provider:
self.account = provider.account
self.site = provider.site
self.sector = provider.sector
super().save(*args, **kwargs)
def __str__(self):
return f"{self.cluster.name} ({self.get_role_display()})"
class ContentTaxonomyMap(SiteSectorBaseModel):
"""Maps content entities to blueprint taxonomies for syncing/publishing."""
SOURCE_CHOICES = [
('blueprint', 'Blueprint'),
('manual', 'Manual'),
('import', 'Import'),
]
content = models.ForeignKey(
Content,
on_delete=models.CASCADE,
related_name='taxonomy_mappings',
null=True,
blank=True,
)
task = models.ForeignKey(
Tasks,
on_delete=models.CASCADE,
related_name='taxonomy_mappings',
null=True,
blank=True,
)
taxonomy = models.ForeignKey(
'site_building.SiteBlueprintTaxonomy',
on_delete=models.CASCADE,
related_name='content_mappings',
)
source = models.CharField(max_length=50, choices=SOURCE_CHOICES, default='blueprint')
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_content_taxonomy_map'
unique_together = [['content', 'taxonomy']]
indexes = [
models.Index(fields=['taxonomy']),
models.Index(fields=['content', 'taxonomy']),
models.Index(fields=['task', 'taxonomy']),
]
def save(self, *args, **kwargs):
provider = self.content or self.task
if provider:
self.account = provider.account
self.site = provider.site
self.sector = provider.sector
super().save(*args, **kwargs)
def __str__(self):
return f"{self.taxonomy.name}"
class ContentAttribute(SiteSectorBaseModel):
"""
Unified attribute storage for products, services, and semantic facets.
Replaces ContentAttributeMap with enhanced WP sync support.
"""
ATTRIBUTE_TYPE_CHOICES = [
('product_spec', 'Product Specification'),
('service_modifier', 'Service Modifier'),
('semantic_facet', 'Semantic Facet'),
]
SOURCE_CHOICES = [
('blueprint', 'Blueprint'),
('manual', 'Manual'),
('import', 'Import'),
('wordpress', 'WordPress'),
]
content = models.ForeignKey(
Content,
on_delete=models.CASCADE,
related_name='attributes',
null=True,
blank=True,
)
task = models.ForeignKey(
Tasks,
on_delete=models.CASCADE,
related_name='attribute_mappings',
null=True,
blank=True,
)
cluster = models.ForeignKey(
'planner.Clusters',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='attributes',
help_text="Optional cluster association for semantic attributes"
)
attribute_type = models.CharField(
max_length=50,
choices=ATTRIBUTE_TYPE_CHOICES,
default='product_spec',
db_index=True,
help_text="Type of attribute"
)
name = models.CharField(max_length=120, help_text="Attribute name (e.g., Color, Material)")
value = models.CharField(max_length=255, blank=True, null=True, help_text="Attribute value (e.g., Blue, Cotton)")
# WordPress/WooCommerce sync fields
external_id = models.IntegerField(null=True, blank=True, help_text="WP attribute term ID")
external_attribute_name = models.CharField(
max_length=100,
blank=True,
help_text="WP attribute slug (e.g., pa_color, pa_size)"
)
source = models.CharField(max_length=50, choices=SOURCE_CHOICES, default='manual')
metadata = models.JSONField(default=dict, blank=True, help_text="Additional metadata")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_content_attributes'
verbose_name = 'Content Attribute'
verbose_name_plural = 'Content Attributes'
indexes = [
models.Index(fields=['name']),
models.Index(fields=['attribute_type']),
models.Index(fields=['content', 'name']),
models.Index(fields=['content', 'attribute_type']),
models.Index(fields=['cluster', 'attribute_type']),
models.Index(fields=['external_id']),
]
def save(self, *args, **kwargs):
provider = self.content or self.task
if provider:
self.account = provider.account
self.site = provider.site
self.sector = provider.sector
super().save(*args, **kwargs)
def __str__(self):
return f"{self.name}: {self.value}"
# Backward compatibility alias
ContentAttributeMap = ContentAttribute

View File

@@ -1,8 +0,0 @@
"""
Content Services
"""
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
from igny8_core.business.content.services.content_pipeline_service import ContentPipelineService
__all__ = ['ContentGenerationService', 'ContentPipelineService']

View File

@@ -1,272 +0,0 @@
"""
Content Generation Service
Handles content generation business logic
"""
import logging
from igny8_core.business.content.models import Tasks
from igny8_core.business.billing.services.credit_service import CreditService
from igny8_core.business.billing.exceptions import InsufficientCreditsError
logger = logging.getLogger(__name__)
class ContentGenerationService:
"""Service for content generation operations"""
def __init__(self):
self.credit_service = CreditService()
def generate_content(self, task_ids, account):
"""
Generate content for tasks.
Args:
task_ids: List of task IDs
account: Account instance
Returns:
dict: Result with success status and data
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
# Get tasks
tasks = Tasks.objects.filter(id__in=task_ids, account=account)
# Calculate estimated credits needed
total_word_count = sum(task.word_count or 1000 for task in tasks)
# Check credits
try:
self.credit_service.check_credits(account, 'content_generation', total_word_count)
except InsufficientCreditsError:
raise
# Delegate to AI task (actual generation happens in Celery)
from igny8_core.ai.tasks import run_ai_task
try:
if hasattr(run_ai_task, 'delay'):
# Celery available - queue async
task = run_ai_task.delay(
function_name='generate_content',
payload={'ids': task_ids},
account_id=account.id
)
return {
'success': True,
'task_id': str(task.id),
'message': 'Content generation started'
}
else:
# Celery not available - execute synchronously
result = run_ai_task(
function_name='generate_content',
payload={'ids': task_ids},
account_id=account.id
)
return result
except Exception as e:
logger.error(f"Error in generate_content: {str(e)}", exc_info=True)
return {
'success': False,
'error': str(e)
}
def generate_product_content(self, product_data, account, site=None, sector=None):
"""
Generate product content.
Args:
product_data: Dict with product information (name, description, features, etc.)
account: Account instance
site: Site instance (optional)
sector: Sector instance (optional)
Returns:
dict: Result with success status and data
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
# Calculate estimated credits needed (default 1500 words for product content)
estimated_word_count = product_data.get('word_count', 1500)
# Check credits
try:
self.credit_service.check_credits(account, 'content_generation', estimated_word_count)
except InsufficientCreditsError:
raise
# Delegate to AI task
from igny8_core.ai.tasks import run_ai_task
try:
payload = {
'product_name': product_data.get('name', ''),
'product_description': product_data.get('description', ''),
'product_features': product_data.get('features', []),
'target_audience': product_data.get('target_audience', ''),
'primary_keyword': product_data.get('primary_keyword', ''),
'site_id': site.id if site else None,
'sector_id': sector.id if sector else None,
}
if hasattr(run_ai_task, 'delay'):
# Celery available - queue async
task = run_ai_task.delay(
function_name='generate_product_content',
payload=payload,
account_id=account.id
)
return {
'success': True,
'task_id': str(task.id),
'message': 'Product content generation started'
}
else:
# Celery not available - execute synchronously
result = run_ai_task(
function_name='generate_product_content',
payload=payload,
account_id=account.id
)
return result
except Exception as e:
logger.error(f"Error in generate_product_content: {str(e)}", exc_info=True)
return {
'success': False,
'error': str(e)
}
def generate_service_page(self, service_data, account, site=None, sector=None):
"""
Generate service page content.
Args:
service_data: Dict with service information (name, description, benefits, etc.)
account: Account instance
site: Site instance (optional)
sector: Sector instance (optional)
Returns:
dict: Result with success status and data
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
# Calculate estimated credits needed (default 1800 words for service page)
estimated_word_count = service_data.get('word_count', 1800)
# Check credits
try:
self.credit_service.check_credits(account, 'content_generation', estimated_word_count)
except InsufficientCreditsError:
raise
# Delegate to AI task
from igny8_core.ai.tasks import run_ai_task
try:
payload = {
'service_name': service_data.get('name', ''),
'service_description': service_data.get('description', ''),
'service_benefits': service_data.get('benefits', []),
'target_audience': service_data.get('target_audience', ''),
'primary_keyword': service_data.get('primary_keyword', ''),
'site_id': site.id if site else None,
'sector_id': sector.id if sector else None,
}
if hasattr(run_ai_task, 'delay'):
# Celery available - queue async
task = run_ai_task.delay(
function_name='generate_service_page',
payload=payload,
account_id=account.id
)
return {
'success': True,
'task_id': str(task.id),
'message': 'Service page generation started'
}
else:
# Celery not available - execute synchronously
result = run_ai_task(
function_name='generate_service_page',
payload=payload,
account_id=account.id
)
return result
except Exception as e:
logger.error(f"Error in generate_service_page: {str(e)}", exc_info=True)
return {
'success': False,
'error': str(e)
}
def generate_taxonomy(self, taxonomy_data, account, site=None, sector=None):
"""
Generate taxonomy page content.
Args:
taxonomy_data: Dict with taxonomy information (name, description, items, etc.)
account: Account instance
site: Site instance (optional)
sector: Sector instance (optional)
Returns:
dict: Result with success status and data
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
# Calculate estimated credits needed (default 1200 words for taxonomy page)
estimated_word_count = taxonomy_data.get('word_count', 1200)
# Check credits
try:
self.credit_service.check_credits(account, 'content_generation', estimated_word_count)
except InsufficientCreditsError:
raise
# Delegate to AI task
from igny8_core.ai.tasks import run_ai_task
try:
payload = {
'taxonomy_name': taxonomy_data.get('name', ''),
'taxonomy_description': taxonomy_data.get('description', ''),
'taxonomy_items': taxonomy_data.get('items', []),
'primary_keyword': taxonomy_data.get('primary_keyword', ''),
'site_id': site.id if site else None,
'sector_id': sector.id if sector else None,
}
if hasattr(run_ai_task, 'delay'):
# Celery available - queue async
task = run_ai_task.delay(
function_name='generate_taxonomy',
payload=payload,
account_id=account.id
)
return {
'success': True,
'task_id': str(task.id),
'message': 'Taxonomy generation started'
}
else:
# Celery not available - execute synchronously
result = run_ai_task(
function_name='generate_taxonomy',
payload=payload,
account_id=account.id
)
return result
except Exception as e:
logger.error(f"Error in generate_taxonomy: {str(e)}", exc_info=True)
return {
'success': False,
'error': str(e)
}

View File

@@ -1,133 +0,0 @@
"""
Content Pipeline Service
Orchestrates content processing pipeline: Writer → Linker → Optimizer
"""
import logging
from typing import List, Optional
from igny8_core.business.content.models import Content
from igny8_core.business.linking.services.linker_service import LinkerService
from igny8_core.business.optimization.services.optimizer_service import OptimizerService
logger = logging.getLogger(__name__)
class ContentPipelineService:
"""Orchestrates content processing pipeline"""
def __init__(self):
self.linker_service = LinkerService()
self.optimizer_service = OptimizerService()
def process_writer_content(
self,
content_id: int,
stages: Optional[List[str]] = None
) -> Content:
"""
Writer → Linker → Optimizer pipeline.
Args:
content_id: Content ID from Writer
stages: List of stages to run: ['linking', 'optimization'] (default: both)
Returns:
Processed Content instance
"""
if stages is None:
stages = ['linking', 'optimization']
try:
content = Content.objects.get(id=content_id, source='igny8')
except Content.DoesNotExist:
raise ValueError(f"IGNY8 content with id {content_id} does not exist")
# Stage 1: Linking
if 'linking' in stages:
try:
content = self.linker_service.process(content.id)
logger.info(f"Linked content {content_id}")
except Exception as e:
logger.error(f"Error in linking stage for content {content_id}: {str(e)}", exc_info=True)
# Continue to next stage even if linking fails
pass
# Stage 2: Optimization
if 'optimization' in stages:
try:
content = self.optimizer_service.optimize_from_writer(content.id)
logger.info(f"Optimized content {content_id}")
except Exception as e:
logger.error(f"Error in optimization stage for content {content_id}: {str(e)}", exc_info=True)
# Don't fail the whole pipeline
pass
return content
def process_synced_content(
self,
content_id: int,
stages: Optional[List[str]] = None
) -> Content:
"""
Synced Content → Optimizer pipeline (skip linking if needed).
Args:
content_id: Content ID from sync (WordPress, Shopify, etc.)
stages: List of stages to run: ['optimization'] (default: optimization only)
Returns:
Processed Content instance
"""
if stages is None:
stages = ['optimization']
try:
content = Content.objects.get(id=content_id)
except Content.DoesNotExist:
raise ValueError(f"Content with id {content_id} does not exist")
# Stage: Optimization (skip linking for synced content by default)
if 'optimization' in stages:
try:
if content.source == 'wordpress':
content = self.optimizer_service.optimize_from_wordpress_sync(content.id)
elif content.source in ['shopify', 'custom']:
content = self.optimizer_service.optimize_from_external_sync(content.id)
else:
content = self.optimizer_service.optimize_manual(content.id)
logger.info(f"Optimized synced content {content_id}")
except Exception as e:
logger.error(f"Error in optimization stage for content {content_id}: {str(e)}", exc_info=True)
raise
return content
def batch_process_writer_content(
self,
content_ids: List[int],
stages: Optional[List[str]] = None
) -> List[Content]:
"""
Batch process multiple Writer content items.
Args:
content_ids: List of content IDs
stages: List of stages to run
Returns:
List of processed Content instances
"""
results = []
for content_id in content_ids:
try:
result = self.process_writer_content(content_id, stages)
results.append(result)
except Exception as e:
logger.error(f"Error processing content {content_id}: {str(e)}", exc_info=True)
# Continue with other items
continue
return results

View File

@@ -1,116 +0,0 @@
"""
Metadata Mapping Service
Stage 3: Persists cluster/taxonomy/attribute mappings from Tasks to Content
"""
import logging
from typing import Optional
from django.db import transaction
from igny8_core.business.content.models import (
Tasks,
Content,
ContentClusterMap,
ContentTaxonomyMap,
ContentAttributeMap,
)
logger = logging.getLogger(__name__)
class MetadataMappingService:
"""Service for persisting metadata mappings from Tasks to Content"""
@transaction.atomic
def persist_task_metadata_to_content(self, content: Content) -> None:
"""
Persist cluster/taxonomy/attribute mappings from Task to Content.
Args:
content: Content instance with an associated task
"""
if not content.task:
logger.warning(f"Content {content.id} has no associated task, skipping metadata mapping")
return
task = content.task
# Stage 3: Persist cluster mapping if task has cluster
if task.cluster:
ContentClusterMap.objects.get_or_create(
content=content,
cluster=task.cluster,
role=task.cluster_role or 'hub',
defaults={
'account': content.account,
'site': content.site,
'sector': content.sector,
'source': 'blueprint' if task.idea else 'manual',
'metadata': {},
}
)
logger.info(f"Created cluster mapping for content {content.id} -> cluster {task.cluster.id}")
# Stage 3: Persist taxonomy mapping if task has taxonomy
if task.taxonomy:
ContentTaxonomyMap.objects.get_or_create(
content=content,
taxonomy=task.taxonomy,
defaults={
'account': content.account,
'site': content.site,
'sector': content.sector,
'source': 'blueprint',
'metadata': {},
}
)
logger.info(f"Created taxonomy mapping for content {content.id} -> taxonomy {task.taxonomy.id}")
# Stage 3: Inherit entity_type from task
if task.entity_type and not content.entity_type:
content.entity_type = task.entity_type
content.save(update_fields=['entity_type'])
logger.info(f"Set entity_type {task.entity_type} for content {content.id}")
# Stage 3: Extract attributes from task metadata if available
# This can be extended to parse task.description or task.metadata for attributes
# For now, we'll rely on explicit attribute data in future enhancements
@transaction.atomic
def backfill_content_metadata(self, content: Content) -> None:
"""
Backfill metadata mappings for existing content that may be missing mappings.
Args:
content: Content instance to backfill
"""
# If content already has mappings, skip
if ContentClusterMap.objects.filter(content=content).exists():
return
# Try to infer from task
if content.task:
self.persist_task_metadata_to_content(content)
return
# Try to infer from content metadata
if content.metadata:
cluster_id = content.metadata.get('cluster_id')
if cluster_id:
from igny8_core.business.planning.models import Clusters
try:
cluster = Clusters.objects.get(id=cluster_id)
ContentClusterMap.objects.get_or_create(
content=content,
cluster=cluster,
role='hub', # Default
defaults={
'account': content.account,
'site': content.site,
'sector': content.sector,
'source': 'manual',
'metadata': {},
}
)
except Clusters.DoesNotExist:
logger.warning(f"Cluster {cluster_id} not found for content {content.id}")

View File

@@ -1,170 +0,0 @@
"""
Content Validation Service
Stage 3: Validates content metadata before publish
"""
import logging
from typing import List, Dict, Optional
from django.core.exceptions import ValidationError
from igny8_core.business.content.models import Tasks, Content
logger = logging.getLogger(__name__)
class ContentValidationService:
"""Service for validating content metadata requirements"""
def validate_task(self, task: Tasks) -> List[Dict[str, str]]:
"""
Validate a task has required metadata.
Args:
task: Task instance to validate
Returns:
List of validation errors (empty if valid)
"""
errors = []
# Stage 3: Enforce "no cluster, no task" rule when feature flag enabled
from django.conf import settings
if getattr(settings, 'USE_SITE_BUILDER_REFACTOR', False):
if not task.cluster:
errors.append({
'field': 'cluster',
'code': 'missing_cluster',
'message': 'Task must be associated with a cluster before content generation',
})
# Stage 3: Validate entity_type is set
if not task.entity_type:
errors.append({
'field': 'entity_type',
'code': 'missing_entity_type',
'message': 'Task must have an entity type specified',
})
# Stage 3: Validate taxonomy for product/service entities
if task.entity_type in ['product', 'service']:
if not task.taxonomy:
errors.append({
'field': 'taxonomy',
'code': 'missing_taxonomy',
'message': f'{task.entity_type.title()} tasks require a taxonomy association',
})
return errors
def validate_content(self, content: Content) -> List[Dict[str, str]]:
"""
Validate content has required metadata before publish.
Args:
content: Content instance to validate
Returns:
List of validation errors (empty if valid)
"""
errors = []
# Stage 3: Validate entity_type
if not content.entity_type:
errors.append({
'field': 'entity_type',
'code': 'missing_entity_type',
'message': 'Content must have an entity type specified',
})
# Stage 3: Validate cluster mapping exists for IGNY8 content
if content.source == 'igny8':
from igny8_core.business.content.models import ContentClusterMap
if not ContentClusterMap.objects.filter(content=content).exists():
errors.append({
'field': 'cluster_mapping',
'code': 'missing_cluster_mapping',
'message': 'Content must be mapped to at least one cluster',
})
# Stage 3: Validate taxonomy for product/service content
if content.entity_type in ['product', 'service']:
from igny8_core.business.content.models import ContentTaxonomyMap
if not ContentTaxonomyMap.objects.filter(content=content).exists():
errors.append({
'field': 'taxonomy_mapping',
'code': 'missing_taxonomy_mapping',
'message': f'{content.entity_type.title()} content requires a taxonomy mapping',
})
# Stage 3: Validate required attributes for products
if content.entity_type == 'product':
from igny8_core.business.content.models import ContentAttributeMap
required_attrs = ['price', 'sku', 'category']
existing_attrs = ContentAttributeMap.objects.filter(
content=content,
name__in=required_attrs
).values_list('name', flat=True)
missing_attrs = set(required_attrs) - set(existing_attrs)
if missing_attrs:
errors.append({
'field': 'attributes',
'code': 'missing_attributes',
'message': f'Product content requires attributes: {", ".join(missing_attrs)}',
})
return errors
def validate_for_publish(self, content: Content) -> List[Dict[str, str]]:
"""
Comprehensive validation before publishing content.
Args:
content: Content instance to validate
Returns:
List of validation errors (empty if ready to publish)
"""
errors = []
# Basic content validation
errors.extend(self.validate_content(content))
# Additional publish requirements
if not content.title:
errors.append({
'field': 'title',
'code': 'missing_title',
'message': 'Content must have a title before publishing',
})
if not content.html_content or len(content.html_content.strip()) < 100:
errors.append({
'field': 'html_content',
'code': 'insufficient_content',
'message': 'Content must have at least 100 characters before publishing',
})
return errors
def ensure_required_attributes(self, task: Tasks) -> List[Dict[str, str]]:
"""
Check if task has required attributes based on entity type.
Args:
task: Task instance to check
Returns:
List of missing attribute errors
"""
errors = []
if task.entity_type == 'product':
# Products should have taxonomy and cluster
if not task.taxonomy:
errors.append({
'field': 'taxonomy',
'code': 'missing_taxonomy',
'message': 'Product tasks require a taxonomy (product category)',
})
return errors

View File

@@ -1,2 +0,0 @@
# Content tests

View File

@@ -1,185 +0,0 @@
"""
Tests for ContentPipelineService
"""
from unittest.mock import patch, MagicMock
from django.test import TestCase
from igny8_core.business.content.models import Content
from igny8_core.business.content.services.content_pipeline_service import ContentPipelineService
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
class ContentPipelineServiceTests(IntegrationTestBase):
"""Tests for ContentPipelineService"""
def setUp(self):
super().setUp()
self.service = ContentPipelineService()
# Create writer content
self.writer_content = Content.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
title="Writer Content",
html_content="<p>Writer content.</p>",
word_count=500,
status='draft',
source='igny8'
)
# Create synced content
self.synced_content = Content.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
title="WordPress Content",
html_content="<p>WordPress content.</p>",
word_count=500,
status='draft',
source='wordpress'
)
@patch('igny8_core.business.content.services.content_pipeline_service.LinkerService.process')
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_writer')
def test_process_writer_content_full_pipeline(self, mock_optimize, mock_link):
"""Test full pipeline for writer content (linking + optimization)"""
mock_link.return_value = self.writer_content
mock_optimize.return_value = self.writer_content
result = self.service.process_writer_content(self.writer_content.id)
self.assertEqual(result.id, self.writer_content.id)
mock_link.assert_called_once()
mock_optimize.assert_called_once()
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_writer')
def test_process_writer_content_optimization_only(self, mock_optimize):
"""Test writer content with optimization only"""
mock_optimize.return_value = self.writer_content
result = self.service.process_writer_content(
self.writer_content.id,
stages=['optimization']
)
self.assertEqual(result.id, self.writer_content.id)
mock_optimize.assert_called_once()
@patch('igny8_core.business.content.services.content_pipeline_service.LinkerService.process')
def test_process_writer_content_linking_only(self, mock_link):
"""Test writer content with linking only"""
mock_link.return_value = self.writer_content
result = self.service.process_writer_content(
self.writer_content.id,
stages=['linking']
)
self.assertEqual(result.id, self.writer_content.id)
mock_link.assert_called_once()
@patch('igny8_core.business.content.services.content_pipeline_service.LinkerService.process')
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_writer')
def test_process_writer_content_handles_linker_failure(self, mock_optimize, mock_link):
"""Test that pipeline continues when linking fails"""
mock_link.side_effect = Exception("Linking failed")
mock_optimize.return_value = self.writer_content
# Should not raise exception, should continue to optimization
result = self.service.process_writer_content(self.writer_content.id)
self.assertEqual(result.id, self.writer_content.id)
mock_optimize.assert_called_once()
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_wordpress_sync')
def test_process_synced_content_wordpress(self, mock_optimize):
"""Test synced content pipeline for WordPress"""
mock_optimize.return_value = self.synced_content
result = self.service.process_synced_content(self.synced_content.id)
self.assertEqual(result.id, self.synced_content.id)
mock_optimize.assert_called_once()
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_external_sync')
def test_process_synced_content_shopify(self, mock_optimize):
"""Test synced content pipeline for Shopify"""
shopify_content = Content.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
title="Shopify Content",
word_count=100,
source='shopify'
)
mock_optimize.return_value = shopify_content
result = self.service.process_synced_content(shopify_content.id)
self.assertEqual(result.id, shopify_content.id)
mock_optimize.assert_called_once()
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_manual')
def test_process_synced_content_custom(self, mock_optimize):
"""Test synced content pipeline for custom source"""
custom_content = Content.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
title="Custom Content",
word_count=100,
source='custom'
)
mock_optimize.return_value = custom_content
result = self.service.process_synced_content(custom_content.id)
self.assertEqual(result.id, custom_content.id)
mock_optimize.assert_called_once()
@patch('igny8_core.business.content.services.content_pipeline_service.ContentPipelineService.process_writer_content')
def test_batch_process_writer_content(self, mock_process):
"""Test batch processing writer content"""
content2 = Content.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
title="Content 2",
word_count=100,
source='igny8'
)
mock_process.side_effect = [self.writer_content, content2]
results = self.service.batch_process_writer_content([
self.writer_content.id,
content2.id
])
self.assertEqual(len(results), 2)
self.assertEqual(mock_process.call_count, 2)
@patch('igny8_core.business.content.services.content_pipeline_service.ContentPipelineService.process_writer_content')
def test_batch_process_handles_partial_failure(self, mock_process):
"""Test batch processing handles partial failures"""
mock_process.side_effect = [self.writer_content, Exception("Failed")]
results = self.service.batch_process_writer_content([
self.writer_content.id,
99999
])
# Should continue processing and return successful results
self.assertEqual(len(results), 1)
self.assertEqual(results[0].id, self.writer_content.id)
def test_process_writer_content_invalid_content(self):
"""Test that ValueError is raised for invalid content"""
with self.assertRaises(ValueError):
self.service.process_writer_content(99999)
def test_process_synced_content_invalid_content(self):
"""Test that ValueError is raised for invalid synced content"""
with self.assertRaises(ValueError):
self.service.process_synced_content(99999)

View File

@@ -1,283 +0,0 @@
"""
Tests for Universal Content Types (Phase 8)
Tests for product, service, and taxonomy content generation
"""
from unittest.mock import patch, MagicMock
from django.test import TestCase
from igny8_core.business.content.models import Content
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
class UniversalContentTypesTests(IntegrationTestBase):
"""Tests for Phase 8: Universal Content Types"""
def setUp(self):
super().setUp()
# Add credits to account for testing
self.account.credits = 10000
self.account.save()
self.service = ContentGenerationService()
@patch('igny8_core.ai.tasks.run_ai_task')
def test_product_content_generates_correctly(self, mock_run_ai_task):
"""
Test: Product content generates correctly
Task 17: Verify product generation creates content with correct entity_type and structure
"""
# Mock AI task response
mock_task = MagicMock()
mock_task.id = 'test-task-123'
mock_run_ai_task.delay.return_value = mock_task
product_data = {
'name': 'Test Product',
'description': 'A test product description',
'features': ['Feature 1', 'Feature 2', 'Feature 3'],
'target_audience': 'Small businesses',
'primary_keyword': 'test product',
'word_count': 1500
}
# Generate product content
result = self.service.generate_product_content(
product_data=product_data,
account=self.account,
site=self.site,
sector=self.sector
)
# Verify result
self.assertTrue(result.get('success'))
self.assertIsNotNone(result.get('task_id'))
self.assertEqual(result.get('message'), 'Product content generation started')
# Verify AI task was called with correct function name
mock_run_ai_task.delay.assert_called_once()
call_args = mock_run_ai_task.delay.call_args
self.assertEqual(call_args[1]['function_name'], 'generate_product_content')
self.assertEqual(call_args[1]['payload']['product_name'], 'Test Product')
@patch('igny8_core.ai.tasks.run_ai_task')
def test_service_pages_work_correctly(self, mock_run_ai_task):
"""
Test: Service pages work correctly
Task 18: Verify service page generation creates content with correct entity_type
"""
# Mock AI task response
mock_task = MagicMock()
mock_task.id = 'test-task-456'
mock_run_ai_task.delay.return_value = mock_task
service_data = {
'name': 'Test Service',
'description': 'A test service description',
'benefits': ['Benefit 1', 'Benefit 2', 'Benefit 3'],
'target_audience': 'Enterprise clients',
'primary_keyword': 'test service',
'word_count': 1800
}
# Generate service page
result = self.service.generate_service_page(
service_data=service_data,
account=self.account,
site=self.site,
sector=self.sector
)
# Verify result
self.assertTrue(result.get('success'))
self.assertIsNotNone(result.get('task_id'))
self.assertEqual(result.get('message'), 'Service page generation started')
# Verify AI task was called with correct function name
mock_run_ai_task.delay.assert_called_once()
call_args = mock_run_ai_task.delay.call_args
self.assertEqual(call_args[1]['function_name'], 'generate_service_page')
self.assertEqual(call_args[1]['payload']['service_name'], 'Test Service')
@patch('igny8_core.ai.tasks.run_ai_task')
def test_taxonomy_pages_work_correctly(self, mock_run_ai_task):
"""
Test: Taxonomy pages work correctly
Task 19: Verify taxonomy generation creates content with correct entity_type
"""
# Mock AI task response
mock_task = MagicMock()
mock_task.id = 'test-task-789'
mock_run_ai_task.delay.return_value = mock_task
taxonomy_data = {
'name': 'Test Taxonomy',
'description': 'A test taxonomy description',
'items': ['Category 1', 'Category 2', 'Category 3'],
'primary_keyword': 'test taxonomy',
'word_count': 1200
}
# Generate taxonomy
result = self.service.generate_taxonomy(
taxonomy_data=taxonomy_data,
account=self.account,
site=self.site,
sector=self.sector
)
# Verify result
self.assertTrue(result.get('success'))
self.assertIsNotNone(result.get('task_id'))
self.assertEqual(result.get('message'), 'Taxonomy generation started')
# Verify AI task was called with correct function name
mock_run_ai_task.delay.assert_called_once()
call_args = mock_run_ai_task.delay.call_args
self.assertEqual(call_args[1]['function_name'], 'generate_taxonomy')
self.assertEqual(call_args[1]['payload']['taxonomy_name'], 'Test Taxonomy')
def test_product_content_has_correct_structure(self):
"""
Test: Product content generates correctly
Task 17: Verify product content has correct entity_type, json_blocks, and structure_data
"""
# Create product content manually to test structure
product_content = Content.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
title='Test Product',
html_content='<p>Product content</p>',
entity_type='product',
json_blocks=[
{
'type': 'product_overview',
'heading': 'Product Overview',
'content': 'Product description'
},
{
'type': 'features',
'heading': 'Key Features',
'items': ['Feature 1', 'Feature 2']
},
{
'type': 'specifications',
'heading': 'Specifications',
'data': {'Spec 1': 'Value 1'}
}
],
structure_data={
'product_type': 'software',
'price_range': '$99-$199',
'target_market': 'SMB'
},
word_count=1500,
status='draft'
)
# Verify structure
self.assertEqual(product_content.entity_type, 'product')
self.assertIsNotNone(product_content.json_blocks)
self.assertEqual(len(product_content.json_blocks), 3)
self.assertEqual(product_content.json_blocks[0]['type'], 'product_overview')
self.assertIsNotNone(product_content.structure_data)
self.assertEqual(product_content.structure_data['product_type'], 'software')
def test_service_content_has_correct_structure(self):
"""
Test: Service pages work correctly
Task 18: Verify service content has correct entity_type and json_blocks
"""
# Create service content manually to test structure
service_content = Content.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
title='Test Service',
html_content='<p>Service content</p>',
entity_type='service',
json_blocks=[
{
'type': 'service_overview',
'heading': 'Service Overview',
'content': 'Service description'
},
{
'type': 'benefits',
'heading': 'Benefits',
'items': ['Benefit 1', 'Benefit 2']
},
{
'type': 'process',
'heading': 'Our Process',
'steps': ['Step 1', 'Step 2']
}
],
structure_data={
'service_type': 'consulting',
'duration': '3-6 months',
'target_market': 'Enterprise'
},
word_count=1800,
status='draft'
)
# Verify structure
self.assertEqual(service_content.entity_type, 'service')
self.assertIsNotNone(service_content.json_blocks)
self.assertEqual(len(service_content.json_blocks), 3)
self.assertEqual(service_content.json_blocks[0]['type'], 'service_overview')
self.assertIsNotNone(service_content.structure_data)
self.assertEqual(service_content.structure_data['service_type'], 'consulting')
def test_taxonomy_content_has_correct_structure(self):
"""
Test: Taxonomy pages work correctly
Task 19: Verify taxonomy content has correct entity_type and json_blocks
"""
# Create taxonomy content manually to test structure
taxonomy_content = Content.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
title='Test Taxonomy',
html_content='<p>Taxonomy content</p>',
entity_type='taxonomy',
json_blocks=[
{
'type': 'taxonomy_overview',
'heading': 'Taxonomy Overview',
'content': 'Taxonomy description'
},
{
'type': 'categories',
'heading': 'Categories',
'items': [
{
'name': 'Category 1',
'description': 'Category description',
'subcategories': ['Subcat 1', 'Subcat 2']
}
]
},
{
'type': 'tags',
'heading': 'Tags',
'items': ['Tag 1', 'Tag 2', 'Tag 3']
}
],
structure_data={
'taxonomy_type': 'product_categories',
'item_count': 10,
'hierarchy_levels': 3
},
word_count=1200,
status='draft'
)
# Verify structure
self.assertEqual(taxonomy_content.entity_type, 'taxonomy')
self.assertIsNotNone(taxonomy_content.json_blocks)
self.assertEqual(len(taxonomy_content.json_blocks), 3)
self.assertEqual(taxonomy_content.json_blocks[0]['type'], 'taxonomy_overview')
self.assertIsNotNone(taxonomy_content.structure_data)
self.assertEqual(taxonomy_content.structure_data['taxonomy_type'], 'product_categories')

View File

@@ -1,5 +0,0 @@
"""
Integration Domain
Phase 6: Site Integration & Multi-Destination Publishing
"""

View File

@@ -1,12 +0,0 @@
"""
Integration App Configuration
"""
from django.apps import AppConfig
class IntegrationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.business.integration'
label = 'integration'
verbose_name = 'Integration'

View File

@@ -1,41 +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 = [
('igny8_core_auth', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='SiteIntegration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('platform', models.CharField(choices=[('wordpress', 'WordPress'), ('shopify', 'Shopify'), ('custom', 'Custom API')], db_index=True, help_text="Platform name: 'wordpress', 'shopify', 'custom'", max_length=50)),
('platform_type', models.CharField(choices=[('cms', 'CMS'), ('ecommerce', 'Ecommerce'), ('custom_api', 'Custom API')], default='cms', help_text="Platform type: 'cms', 'ecommerce', 'custom_api'", max_length=50)),
('config_json', models.JSONField(default=dict, help_text='Platform-specific configuration (URLs, endpoints, etc.)')),
('credentials_json', models.JSONField(default=dict, help_text='Encrypted credentials (API keys, tokens, etc.)')),
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this integration is active')),
('sync_enabled', models.BooleanField(default=False, help_text='Whether two-way sync is enabled')),
('last_sync_at', models.DateTimeField(blank=True, help_text='Last successful sync timestamp', null=True)),
('sync_status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed'), ('pending', 'Pending'), ('syncing', 'Syncing')], db_index=True, default='pending', help_text='Current sync status', max_length=20)),
('sync_error', models.TextField(blank=True, help_text='Last sync error message', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
('site', models.ForeignKey(help_text='Site this integration belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='integrations', to='igny8_core_auth.site')),
],
options={
'db_table': 'igny8_site_integrations',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['site', 'platform'], name='igny8_site__site_id_3901ba_idx'), models.Index(fields=['site', 'is_active'], name='igny8_site__site_id_71bc1a_idx'), models.Index(fields=['account', 'platform'], name='igny8_site__tenant__920542_idx'), models.Index(fields=['sync_status'], name='igny8_site__sync_st_e79021_idx')],
'unique_together': {('site', 'platform')},
},
),
]

View File

@@ -1,4 +0,0 @@
"""
Integration Migrations
"""

View File

@@ -1,131 +0,0 @@
"""
Integration Models
Phase 6: Site Integration & Multi-Destination Publishing
"""
from django.db import models
from django.core.validators import MinValueValidator
from igny8_core.auth.models import AccountBaseModel
class SiteIntegration(AccountBaseModel):
"""
Store integration configurations for sites.
Each site can have multiple integrations (WordPress, Shopify, etc.).
"""
PLATFORM_CHOICES = [
('wordpress', 'WordPress'),
('shopify', 'Shopify'),
('custom', 'Custom API'),
]
PLATFORM_TYPE_CHOICES = [
('cms', 'CMS'),
('ecommerce', 'Ecommerce'),
('custom_api', 'Custom API'),
]
SYNC_STATUS_CHOICES = [
('success', 'Success'),
('failed', 'Failed'),
('pending', 'Pending'),
('syncing', 'Syncing'),
]
site = models.ForeignKey(
'igny8_core_auth.Site',
on_delete=models.CASCADE,
related_name='integrations',
help_text="Site this integration belongs to"
)
platform = models.CharField(
max_length=50,
choices=PLATFORM_CHOICES,
db_index=True,
help_text="Platform name: 'wordpress', 'shopify', 'custom'"
)
platform_type = models.CharField(
max_length=50,
choices=PLATFORM_TYPE_CHOICES,
default='cms',
help_text="Platform type: 'cms', 'ecommerce', 'custom_api'"
)
config_json = models.JSONField(
default=dict,
help_text="Platform-specific configuration (URLs, endpoints, etc.)"
)
# Credentials stored as JSON (encryption handled at application level)
credentials_json = models.JSONField(
default=dict,
help_text="Encrypted credentials (API keys, tokens, etc.)"
)
is_active = models.BooleanField(
default=True,
db_index=True,
help_text="Whether this integration is active"
)
sync_enabled = models.BooleanField(
default=False,
help_text="Whether two-way sync is enabled"
)
last_sync_at = models.DateTimeField(
null=True,
blank=True,
help_text="Last successful sync timestamp"
)
sync_status = models.CharField(
max_length=20,
choices=SYNC_STATUS_CHOICES,
default='pending',
db_index=True,
help_text="Current sync status"
)
sync_error = models.TextField(
blank=True,
null=True,
help_text="Last sync error message"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'integration'
db_table = 'igny8_site_integrations'
ordering = ['-created_at']
unique_together = [['site', 'platform']]
indexes = [
models.Index(fields=['site', 'platform']),
models.Index(fields=['site', 'is_active']),
models.Index(fields=['account', 'platform']),
models.Index(fields=['sync_status']),
]
def __str__(self):
return f"{self.site.name} - {self.get_platform_display()}"
def get_credentials(self) -> dict:
"""
Get decrypted credentials.
In production, this should decrypt credentials_json.
For now, return as-is (encryption to be implemented).
"""
return self.credentials_json or {}
def set_credentials(self, credentials: dict):
"""
Set encrypted credentials.
In production, this should encrypt before storing.
For now, store as-is (encryption to be implemented).
"""
self.credentials_json = credentials

View File

@@ -1,4 +0,0 @@
"""
Integration Services
"""

View File

@@ -1,714 +0,0 @@
"""
Content Sync Service
Phase 6: Site Integration & Multi-Destination Publishing
Stage 4: Enhanced with taxonomy and product sync
Syncs content between IGNY8 and external platforms.
"""
import logging
from typing import Dict, Any, Optional, List
from igny8_core.business.integration.models import SiteIntegration
from igny8_core.utils.wordpress import WordPressClient
logger = logging.getLogger(__name__)
class ContentSyncService:
"""
Service for syncing content to/from external platforms.
"""
def sync_to_external(
self,
integration: SiteIntegration,
content_types: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Sync content from IGNY8 to external platform.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync (optional)
Returns:
dict: Sync result
"""
try:
if integration.platform == 'wordpress':
return self._sync_to_wordpress(integration, content_types)
elif integration.platform == 'shopify':
return self._sync_to_shopify(integration, content_types)
else:
return {
'success': False,
'error': f'Sync to {integration.platform} not implemented',
'synced_count': 0
}
except Exception as e:
logger.error(
f"[ContentSyncService] Error syncing to {integration.platform}: {str(e)}",
exc_info=True
)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def sync_from_external(
self,
integration: SiteIntegration,
content_types: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Sync content from external platform to IGNY8.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync (optional)
Returns:
dict: Sync result
"""
try:
if integration.platform == 'wordpress':
return self._sync_from_wordpress(integration, content_types)
elif integration.platform == 'shopify':
return self._sync_from_shopify(integration, content_types)
else:
return {
'success': False,
'error': f'Sync from {integration.platform} not implemented',
'synced_count': 0
}
except Exception as e:
logger.error(
f"[ContentSyncService] Error syncing from {integration.platform}: {str(e)}",
exc_info=True
)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def _sync_to_wordpress(
self,
integration: SiteIntegration,
content_types: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Sync content from IGNY8 to WordPress.
Stage 4: Enhanced to sync taxonomies before content.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync
Returns:
dict: Sync result
"""
try:
# Get WordPress client
credentials = integration.get_credentials()
client = WordPressClient(
site_url=integration.config_json.get('site_url', ''),
username=credentials.get('username'),
app_password=credentials.get('app_password')
)
# Stage 4: Sync taxonomies first
taxonomy_result = self._sync_taxonomies_to_wordpress(integration, client)
# Sync content (posts/products)
from igny8_core.business.content.models import Content
from igny8_core.business.publishing.services.adapters.wordpress_adapter import WordPressAdapter
content_query = Content.objects.filter(
account=integration.account,
site=integration.site,
source='igny8',
status='publish'
)
if content_types:
content_query = content_query.filter(content_type__in=content_types)
synced_count = 0
adapter = WordPressAdapter()
destination_config = {
'site_url': integration.config_json.get('site_url', ''),
'username': credentials.get('username'),
'app_password': credentials.get('app_password'),
'status': 'publish'
}
for content in content_query[:100]: # Limit to 100 per sync
result = adapter.publish(content, destination_config)
if result.get('success'):
synced_count += 1
# Store external reference
if not content.metadata:
content.metadata = {}
content.metadata['wordpress_id'] = result.get('external_id')
content.save(update_fields=['metadata'])
return {
'success': True,
'synced_count': synced_count,
'taxonomies_synced': taxonomy_result.get('synced_count', 0),
'message': f'Synced {synced_count} content items and {taxonomy_result.get("synced_count", 0)} taxonomies'
}
except Exception as e:
logger.error(
f"[ContentSyncService] Error syncing to WordPress: {str(e)}",
exc_info=True
)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def sync_from_wordpress(
self,
integration: SiteIntegration
) -> Dict[str, Any]:
"""
Sync content from WordPress to IGNY8.
Args:
integration: SiteIntegration instance
Returns:
dict: Sync result with synced_count
"""
try:
posts = self._fetch_wordpress_posts(integration)
synced_count = 0
from igny8_core.business.content.models import Content
for post in posts:
# Check if content already exists
content, created = Content.objects.get_or_create(
account=integration.account,
site=integration.site,
sector=integration.site.sectors.first() if hasattr(integration.site, 'sectors') else None,
title=post.get('title', ''),
source='wordpress',
defaults={
'html_content': post.get('content', ''),
'status': 'published' if post.get('status') == 'publish' else 'draft',
'metadata': {'wordpress_id': post.get('id')}
}
)
if not created:
# Update existing content
content.html_content = post.get('content', '')
content.status = 'published' if post.get('status') == 'publish' else 'draft'
if not content.metadata:
content.metadata = {}
content.metadata['wordpress_id'] = post.get('id')
content.save()
synced_count += 1
return {
'success': True,
'synced_count': synced_count
}
except Exception as e:
logger.error(
f"[ContentSyncService] Error syncing from WordPress: {str(e)}",
exc_info=True
)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def _sync_from_wordpress(
self,
integration: SiteIntegration,
content_types: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Internal method for syncing from WordPress (used by sync_from_external).
Stage 4: Enhanced to sync taxonomies and products.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync
Returns:
dict: Sync result
"""
try:
# Get WordPress client
credentials = integration.get_credentials()
client = WordPressClient(
site_url=integration.config_json.get('site_url', ''),
username=credentials.get('username'),
app_password=credentials.get('app_password')
)
# Stage 4: Sync taxonomies first
taxonomy_result = self._sync_taxonomies_from_wordpress(integration, client)
# Sync posts
posts_result = self.sync_from_wordpress(integration)
# Sync WooCommerce products if available
products_result = self._sync_products_from_wordpress(integration, client)
total_synced = (
posts_result.get('synced_count', 0) +
products_result.get('synced_count', 0)
)
return {
'success': True,
'synced_count': total_synced,
'taxonomies_synced': taxonomy_result.get('synced_count', 0),
'posts_synced': posts_result.get('synced_count', 0),
'products_synced': products_result.get('synced_count', 0),
}
except Exception as e:
logger.error(
f"[ContentSyncService] Error syncing from WordPress: {str(e)}",
exc_info=True
)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def _fetch_wordpress_posts(
self,
integration: SiteIntegration
) -> List[Dict[str, Any]]:
"""
Fetch posts from WordPress.
Args:
integration: SiteIntegration instance
Returns:
List of post dictionaries
"""
try:
credentials = integration.get_credentials()
client = WordPressClient(
site_url=integration.config_json.get('site_url', ''),
username=credentials.get('username'),
app_password=credentials.get('app_password')
)
# Fetch posts via WordPress REST API
import requests
response = client.session.get(
f"{client.api_base}/posts",
params={'per_page': 100, 'status': 'publish'}
)
if response.status_code == 200:
posts = response.json()
return [
{
'id': post.get('id'),
'title': post.get('title', {}).get('rendered', ''),
'content': post.get('content', {}).get('rendered', ''),
'status': post.get('status', 'publish'),
'categories': post.get('categories', []),
'tags': post.get('tags', [])
}
for post in posts
]
return []
except Exception as e:
logger.error(f"Error fetching WordPress posts: {e}")
return []
# Stage 4: Taxonomy Sync Methods
def _sync_taxonomies_from_wordpress(
self,
integration: SiteIntegration,
client: WordPressClient
) -> Dict[str, Any]:
"""
Sync taxonomies from WordPress to IGNY8.
Args:
integration: SiteIntegration instance
client: WordPressClient instance
Returns:
dict: Sync result with synced_count
"""
try:
from igny8_core.business.site_building.models import SiteBlueprint
from igny8_core.business.site_building.services.taxonomy_service import TaxonomyService
# Get or create site blueprint for this site
blueprint = SiteBlueprint.objects.filter(
account=integration.account,
site=integration.site
).first()
if not blueprint:
logger.warning(f"No blueprint found for site {integration.site.id}, skipping taxonomy sync")
return {'success': True, 'synced_count': 0}
taxonomy_service = TaxonomyService()
synced_count = 0
# Sync WordPress categories
categories = client.get_categories(per_page=100)
category_records = [
{
'name': cat['name'],
'slug': cat['slug'],
'description': cat.get('description', ''),
'taxonomy_type': 'blog_category',
'external_reference': str(cat['id']),
'metadata': {'parent': cat.get('parent', 0)}
}
for cat in categories
]
if category_records:
taxonomy_service.import_from_external(
blueprint,
category_records,
default_type='blog_category'
)
synced_count += len(category_records)
# Sync WordPress tags
tags = client.get_tags(per_page=100)
tag_records = [
{
'name': tag['name'],
'slug': tag['slug'],
'description': tag.get('description', ''),
'taxonomy_type': 'blog_tag',
'external_reference': str(tag['id'])
}
for tag in tags
]
if tag_records:
taxonomy_service.import_from_external(
blueprint,
tag_records,
default_type='blog_tag'
)
synced_count += len(tag_records)
# Sync WooCommerce product categories if available
try:
product_categories = client.get_product_categories(per_page=100)
product_category_records = [
{
'name': cat['name'],
'slug': cat['slug'],
'description': cat.get('description', ''),
'taxonomy_type': 'product_category',
'external_reference': f"wc_cat_{cat['id']}",
'metadata': {'parent': cat.get('parent', 0)}
}
for cat in product_categories
]
if product_category_records:
taxonomy_service.import_from_external(
blueprint,
product_category_records,
default_type='product_category'
)
synced_count += len(product_category_records)
except Exception as e:
logger.warning(f"WooCommerce not available or error fetching product categories: {e}")
return {
'success': True,
'synced_count': synced_count
}
except Exception as e:
logger.error(f"Error syncing taxonomies from WordPress: {e}", exc_info=True)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def _sync_taxonomies_to_wordpress(
self,
integration: SiteIntegration,
client: WordPressClient
) -> Dict[str, Any]:
"""
Ensure taxonomies exist in WordPress before publishing content.
Args:
integration: SiteIntegration instance
client: WordPressClient instance
Returns:
dict: Sync result with synced_count
"""
try:
from igny8_core.business.site_building.models import SiteBlueprint, SiteBlueprintTaxonomy
# Get site blueprint
blueprint = SiteBlueprint.objects.filter(
account=integration.account,
site=integration.site
).first()
if not blueprint:
return {'success': True, 'synced_count': 0}
synced_count = 0
# Get taxonomies that don't have external_reference (not yet synced)
taxonomies = SiteBlueprintTaxonomy.objects.filter(
site_blueprint=blueprint,
external_reference__isnull=True
)
for taxonomy in taxonomies:
try:
if taxonomy.taxonomy_type in ['blog_category', 'product_category']:
result = client.create_category(
name=taxonomy.name,
slug=taxonomy.slug,
description=taxonomy.description
)
if result.get('success'):
taxonomy.external_reference = str(result.get('category_id'))
taxonomy.save(update_fields=['external_reference'])
synced_count += 1
elif taxonomy.taxonomy_type in ['blog_tag', 'product_tag']:
result = client.create_tag(
name=taxonomy.name,
slug=taxonomy.slug,
description=taxonomy.description
)
if result.get('success'):
taxonomy.external_reference = str(result.get('tag_id'))
taxonomy.save(update_fields=['external_reference'])
synced_count += 1
except Exception as e:
logger.warning(f"Error syncing taxonomy {taxonomy.id} to WordPress: {e}")
continue
return {
'success': True,
'synced_count': synced_count
}
except Exception as e:
logger.error(f"Error syncing taxonomies to WordPress: {e}", exc_info=True)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def _sync_products_from_wordpress(
self,
integration: SiteIntegration,
client: WordPressClient
) -> Dict[str, Any]:
"""
Sync WooCommerce products from WordPress to IGNY8.
Args:
integration: SiteIntegration instance
client: WordPressClient instance
Returns:
dict: Sync result with synced_count
"""
try:
products = client.get_products(per_page=100)
synced_count = 0
from igny8_core.business.content.models import Content
for product in products:
content, created = Content.objects.get_or_create(
account=integration.account,
site=integration.site,
sector=integration.site.sectors.first() if hasattr(integration.site, 'sectors') else None,
title=product.get('name', ''),
source='wordpress',
defaults={
'html_content': product.get('description', ''),
'content_type': 'product',
'status': 'published' if product.get('status') == 'publish' else 'draft',
'metadata': {
'wordpress_id': product.get('id'),
'product_type': product.get('type'),
'sku': product.get('sku'),
'price': product.get('price'),
'regular_price': product.get('regular_price'),
'sale_price': product.get('sale_price'),
'categories': product.get('categories', []),
'tags': product.get('tags', []),
'attributes': product.get('attributes', [])
}
}
)
if not created:
content.html_content = product.get('description', '')
if not content.metadata:
content.metadata = {}
content.metadata.update({
'wordpress_id': product.get('id'),
'product_type': product.get('type'),
'sku': product.get('sku'),
'price': product.get('price'),
'regular_price': product.get('regular_price'),
'sale_price': product.get('sale_price'),
'categories': product.get('categories', []),
'tags': product.get('tags', []),
'attributes': product.get('attributes', [])
})
content.save()
synced_count += 1
return {
'success': True,
'synced_count': synced_count
}
except Exception as e:
logger.error(f"Error syncing products from WordPress: {e}", exc_info=True)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def _sync_to_shopify(
self,
integration: SiteIntegration,
content_types: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Sync content from IGNY8 to Shopify.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync
Returns:
dict: Sync result
"""
# TODO: Implement Shopify sync
logger.info(f"[ContentSyncService] Syncing to Shopify for integration {integration.id}")
return {
'success': True,
'synced_count': 0,
'message': 'Shopify sync not yet implemented'
}
def sync_from_shopify(
self,
integration: SiteIntegration
) -> Dict[str, Any]:
"""
Sync content from Shopify to IGNY8.
Args:
integration: SiteIntegration instance
Returns:
dict: Sync result with synced_count
"""
try:
products = self._fetch_shopify_products(integration)
synced_count = 0
from igny8_core.business.content.models import Content
for product in products:
# Create or update content from product
content, created = Content.objects.get_or_create(
account=integration.account,
site=integration.site,
sector=integration.site.sectors.first() if hasattr(integration.site, 'sectors') else None,
title=product.get('title', ''),
source='shopify',
defaults={
'html_content': product.get('body_html', ''),
'status': 'published',
'metadata': {'shopify_id': product.get('id')}
}
)
if not created:
content.html_content = product.get('body_html', '')
if not content.metadata:
content.metadata = {}
content.metadata['shopify_id'] = product.get('id')
content.save()
synced_count += 1
return {
'success': True,
'synced_count': synced_count
}
except Exception as e:
logger.error(
f"[ContentSyncService] Error syncing from Shopify: {str(e)}",
exc_info=True
)
return {
'success': False,
'error': str(e),
'synced_count': 0
}
def _sync_from_shopify(
self,
integration: SiteIntegration,
content_types: Optional[List[str]] = None
) -> Dict[str, Any]:
"""
Internal method for syncing from Shopify (used by sync_from_external).
Args:
integration: SiteIntegration instance
content_types: List of content types to sync
Returns:
dict: Sync result
"""
return self.sync_from_shopify(integration)
def _fetch_shopify_products(
self,
integration: SiteIntegration
) -> List[Dict[str, Any]]:
"""
Fetch products from Shopify.
Args:
integration: SiteIntegration instance
Returns:
List of product dictionaries
"""
# TODO: Implement actual Shopify API call
# For now, return empty list - tests will mock this
logger.info(f"[ContentSyncService] Fetching Shopify products for integration {integration.id}")
return []

View File

@@ -1,261 +0,0 @@
"""
Integration Service
Phase 6: Site Integration & Multi-Destination Publishing
Manages site integrations (WordPress, Shopify, etc.).
"""
import logging
from typing import Optional, Dict, Any
from igny8_core.business.integration.models import SiteIntegration
from igny8_core.auth.models import Site
logger = logging.getLogger(__name__)
class IntegrationService:
"""
Service for managing site integrations.
"""
def create_integration(
self,
site: Site,
platform: str,
config: Dict[str, Any],
credentials: Dict[str, Any],
platform_type: str = 'cms'
) -> SiteIntegration:
"""
Create a new site integration.
Args:
site: Site instance
platform: Platform name ('wordpress', 'shopify', 'custom')
config: Platform-specific configuration
credentials: Platform credentials (will be encrypted)
platform_type: Platform type ('cms', 'ecommerce', 'custom_api')
Returns:
SiteIntegration instance
"""
integration = SiteIntegration.objects.create(
account=site.account,
site=site,
platform=platform,
platform_type=platform_type,
config_json=config,
credentials_json=credentials,
is_active=True
)
logger.info(
f"[IntegrationService] Created integration {integration.id} for site {site.id}, platform {platform}"
)
return integration
def update_integration(
self,
integration: SiteIntegration,
config: Optional[Dict[str, Any]] = None,
credentials: Optional[Dict[str, Any]] = None,
is_active: Optional[bool] = None
) -> SiteIntegration:
"""
Update an existing integration.
Args:
integration: SiteIntegration instance
config: Updated configuration (optional)
credentials: Updated credentials (optional)
is_active: Active status (optional)
Returns:
Updated SiteIntegration instance
"""
if config is not None:
integration.config_json = config
if credentials is not None:
integration.set_credentials(credentials)
if is_active is not None:
integration.is_active = is_active
integration.save()
logger.info(f"[IntegrationService] Updated integration {integration.id}")
return integration
def delete_integration(self, integration: SiteIntegration):
"""
Delete an integration.
Args:
integration: SiteIntegration instance
"""
integration_id = integration.id
integration.delete()
logger.info(f"[IntegrationService] Deleted integration {integration_id}")
def get_integration(
self,
site: Site,
platform: str
) -> Optional[SiteIntegration]:
"""
Get integration for a site and platform.
Args:
site: Site instance
platform: Platform name
Returns:
SiteIntegration or None
"""
return SiteIntegration.objects.filter(
site=site,
platform=platform,
is_active=True
).first()
def list_integrations(
self,
site: Site,
active_only: bool = True
) -> list:
"""
List all integrations for a site.
Args:
site: Site instance
active_only: Only return active integrations
Returns:
List of SiteIntegration instances
"""
queryset = SiteIntegration.objects.filter(site=site)
if active_only:
queryset = queryset.filter(is_active=True)
return list(queryset.order_by('-created_at'))
def get_integrations_for_site(
self,
site: Site
):
"""
Get integrations for a site (alias for list_integrations for compatibility).
Args:
site: Site instance
Returns:
QuerySet of SiteIntegration instances
"""
return SiteIntegration.objects.filter(site=site, is_active=True)
def test_connection(
self,
integration: SiteIntegration
) -> Dict[str, Any]:
"""
Test connection to the integrated platform.
Args:
integration: SiteIntegration instance
Returns:
dict: {
'success': bool,
'message': str,
'details': dict
}
Raises:
NotImplementedError: For platforms that don't have connection testing implemented
"""
try:
if integration.platform == 'wordpress':
return self._test_wordpress_connection(integration)
elif integration.platform == 'shopify':
return self._test_shopify_connection(integration)
else:
raise NotImplementedError(f'Connection testing not implemented for platform: {integration.platform}')
except NotImplementedError:
raise
except Exception as e:
logger.error(
f"[IntegrationService] Error testing connection for integration {integration.id}: {str(e)}",
exc_info=True
)
return {
'success': False,
'message': str(e),
'details': {}
}
def _test_wordpress_connection(
self,
integration: SiteIntegration
) -> Dict[str, Any]:
"""
Test WordPress connection.
Args:
integration: SiteIntegration instance
Returns:
dict: Connection test result
"""
from igny8_core.utils.wordpress import WordPressClient
config = integration.config_json
credentials = integration.get_credentials()
site_url = config.get('site_url')
username = credentials.get('username')
app_password = credentials.get('app_password')
if not site_url:
return {
'success': False,
'message': 'WordPress site URL not configured',
'details': {}
}
try:
client = WordPressClient(site_url, username, app_password)
result = client.test_connection()
return result
except Exception as e:
return {
'success': False,
'message': f'WordPress connection failed: {str(e)}',
'details': {}
}
def _test_shopify_connection(
self,
integration: SiteIntegration
) -> Dict[str, Any]:
"""
Test Shopify connection.
Args:
integration: SiteIntegration instance
Returns:
dict: Connection test result
"""
# TODO: Implement Shopify connection testing
return {
'success': False,
'message': 'Shopify connection testing not yet implemented',
'details': {}
}

View File

@@ -1,445 +0,0 @@
"""
Sync Health Service
Stage 4: Track sync health, mismatches, and logs
Provides health monitoring for site integrations.
"""
import logging
from typing import Dict, Any, Optional, List
from datetime import datetime, timedelta
from django.utils import timezone
from igny8_core.business.integration.models import SiteIntegration
logger = logging.getLogger(__name__)
class SyncHealthService:
"""
Service for tracking sync health and detecting mismatches.
"""
def get_sync_status(
self,
site_id: int,
integration_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Get sync status for a site or specific integration.
Args:
site_id: Site ID
integration_id: Optional integration ID (if None, returns all integrations)
Returns:
dict: {
'site_id': int,
'integrations': [
{
'id': int,
'platform': str,
'status': str,
'last_sync_at': datetime,
'sync_enabled': bool,
'is_healthy': bool,
'error': str,
'mismatch_count': int
}
],
'overall_status': str, # 'healthy', 'warning', 'error'
'last_sync_at': datetime
}
"""
try:
integrations_query = SiteIntegration.objects.filter(
site_id=site_id,
is_active=True
)
if integration_id:
integrations_query = integrations_query.filter(id=integration_id)
integrations = []
overall_healthy = True
last_sync = None
for integration in integrations_query:
mismatch_count = self._count_mismatches(integration)
is_healthy = (
integration.sync_status == 'success' and
mismatch_count == 0 and
(not integration.sync_error or integration.sync_error == '')
)
if not is_healthy:
overall_healthy = False
if integration.last_sync_at:
if last_sync is None or integration.last_sync_at > last_sync:
last_sync = integration.last_sync_at
integrations.append({
'id': integration.id,
'platform': integration.platform,
'status': integration.sync_status,
'last_sync_at': integration.last_sync_at.isoformat() if integration.last_sync_at else None,
'sync_enabled': integration.sync_enabled,
'is_healthy': is_healthy,
'error': integration.sync_error,
'mismatch_count': mismatch_count
})
# Determine overall status
if overall_healthy:
overall_status = 'healthy'
elif any(i['status'] == 'failed' for i in integrations):
overall_status = 'error'
else:
overall_status = 'warning'
return {
'site_id': site_id,
'integrations': integrations,
'overall_status': overall_status,
'last_sync_at': last_sync.isoformat() if last_sync else None
}
except Exception as e:
logger.error(f"Error getting sync status: {e}", exc_info=True)
return {
'site_id': site_id,
'integrations': [],
'overall_status': 'error',
'last_sync_at': None,
'error': str(e)
}
def get_mismatches(
self,
site_id: int,
integration_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Get detailed mismatch information.
Args:
site_id: Site ID
integration_id: Optional integration ID
Returns:
dict: {
'taxonomies': {
'missing_in_wordpress': List[Dict],
'missing_in_igny8': List[Dict],
'mismatched': List[Dict]
},
'products': {
'missing_in_wordpress': List[Dict],
'missing_in_igny8': List[Dict]
},
'posts': {
'missing_in_wordpress': List[Dict],
'missing_in_igny8': List[Dict]
}
}
"""
try:
integrations_query = SiteIntegration.objects.filter(
site_id=site_id,
is_active=True
)
if integration_id:
integrations_query = integrations_query.filter(id=integration_id)
all_mismatches = {
'taxonomies': {
'missing_in_wordpress': [],
'missing_in_igny8': [],
'mismatched': []
},
'products': {
'missing_in_wordpress': [],
'missing_in_igny8': []
},
'posts': {
'missing_in_wordpress': [],
'missing_in_igny8': []
}
}
for integration in integrations_query:
if integration.platform == 'wordpress':
mismatches = self._detect_wordpress_mismatches(integration)
# Merge mismatches
for key in all_mismatches:
if key in mismatches:
all_mismatches[key]['missing_in_wordpress'].extend(
mismatches[key].get('missing_in_wordpress', [])
)
all_mismatches[key]['missing_in_igny8'].extend(
mismatches[key].get('missing_in_igny8', [])
)
if 'mismatched' in mismatches[key]:
all_mismatches[key]['mismatched'].extend(
mismatches[key]['mismatched']
)
return all_mismatches
except Exception as e:
logger.error(f"Error getting mismatches: {e}", exc_info=True)
return {
'taxonomies': {'missing_in_wordpress': [], 'missing_in_igny8': [], 'mismatched': []},
'products': {'missing_in_wordpress': [], 'missing_in_igny8': []},
'posts': {'missing_in_wordpress': [], 'missing_in_igny8': []},
'error': str(e)
}
def get_sync_logs(
self,
site_id: int,
integration_id: Optional[int] = None,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Get sync logs for a site or integration.
Args:
site_id: Site ID
integration_id: Optional integration ID
limit: Maximum number of logs to return
Returns:
List of log dictionaries
"""
try:
integrations_query = SiteIntegration.objects.filter(
site_id=site_id,
is_active=True
)
if integration_id:
integrations_query = integrations_query.filter(id=integration_id)
logs = []
for integration in integrations_query:
# Use SiteIntegration fields as log entries
if integration.last_sync_at:
logs.append({
'integration_id': integration.id,
'platform': integration.platform,
'timestamp': integration.last_sync_at.isoformat(),
'status': integration.sync_status,
'error': integration.sync_error,
'duration': None, # Not tracked in current model
'items_processed': None # Not tracked in current model
})
# Sort by timestamp descending
logs.sort(key=lambda x: x['timestamp'] or '', reverse=True)
return logs[:limit]
except Exception as e:
logger.error(f"Error getting sync logs: {e}", exc_info=True)
return []
def record_sync_run(
self,
integration_id: int,
result: Dict[str, Any]
) -> None:
"""
Record a sync run result.
Args:
integration_id: Integration ID
result: Sync result dict from ContentSyncService
"""
try:
integration = SiteIntegration.objects.get(id=integration_id)
if result.get('success'):
integration.sync_status = 'success'
integration.last_sync_at = timezone.now()
integration.sync_error = None
else:
integration.sync_status = 'failed'
integration.sync_error = result.get('error', 'Unknown error')
integration.save(update_fields=['sync_status', 'last_sync_at', 'sync_error', 'updated_at'])
logger.info(
f"[SyncHealthService] Recorded sync run for integration {integration_id}: "
f"status={integration.sync_status}, synced_count={result.get('synced_count', 0)}"
)
except SiteIntegration.DoesNotExist:
logger.warning(f"Integration {integration_id} not found for sync recording")
except Exception as e:
logger.error(f"Error recording sync run: {e}", exc_info=True)
def _count_mismatches(self, integration: SiteIntegration) -> int:
"""
Count total mismatches for an integration.
Args:
integration: SiteIntegration instance
Returns:
int: Total mismatch count
"""
try:
if integration.platform != 'wordpress':
return 0
mismatches = self._detect_wordpress_mismatches(integration)
count = 0
for category in mismatches.values():
count += len(category.get('missing_in_wordpress', []))
count += len(category.get('missing_in_igny8', []))
count += len(category.get('mismatched', []))
return count
except Exception as e:
logger.warning(f"Error counting mismatches: {e}")
return 0
def _detect_wordpress_mismatches(
self,
integration: SiteIntegration
) -> Dict[str, Any]:
"""
Detect mismatches between IGNY8 and WordPress.
Args:
integration: SiteIntegration instance
Returns:
dict: Mismatch details
"""
mismatches = {
'taxonomies': {
'missing_in_wordpress': [],
'missing_in_igny8': [],
'mismatched': []
},
'products': {
'missing_in_wordpress': [],
'missing_in_igny8': []
},
'posts': {
'missing_in_wordpress': [],
'missing_in_igny8': []
}
}
try:
from igny8_core.utils.wordpress import WordPressClient
from igny8_core.business.site_building.models import SiteBlueprint, SiteBlueprintTaxonomy
from igny8_core.business.content.models import Content
credentials = integration.get_credentials()
client = WordPressClient(
site_url=integration.config_json.get('site_url', ''),
username=credentials.get('username'),
app_password=credentials.get('app_password')
)
# Get site blueprint
blueprint = SiteBlueprint.objects.filter(
account=integration.account,
site=integration.site
).first()
if not blueprint:
return mismatches
# Check taxonomy mismatches
# Get IGNY8 taxonomies
igny8_taxonomies = SiteBlueprintTaxonomy.objects.filter(
site_blueprint=blueprint
)
# Get WordPress categories
wp_categories = client.get_categories(per_page=100)
wp_category_ids = {str(cat['id']): cat for cat in wp_categories}
# Get WordPress tags
wp_tags = client.get_tags(per_page=100)
wp_tag_ids = {str(tag['id']): tag for tag in wp_tags}
for taxonomy in igny8_taxonomies:
if taxonomy.external_reference:
# Check if still exists in WordPress
if taxonomy.taxonomy_type in ['blog_category', 'product_category']:
if taxonomy.external_reference not in wp_category_ids:
mismatches['taxonomies']['missing_in_wordpress'].append({
'id': taxonomy.id,
'name': taxonomy.name,
'type': taxonomy.taxonomy_type,
'external_reference': taxonomy.external_reference
})
elif taxonomy.taxonomy_type in ['blog_tag', 'product_tag']:
if taxonomy.external_reference not in wp_tag_ids:
mismatches['taxonomies']['missing_in_wordpress'].append({
'id': taxonomy.id,
'name': taxonomy.name,
'type': taxonomy.taxonomy_type,
'external_reference': taxonomy.external_reference
})
else:
# Taxonomy exists in IGNY8 but not synced to WordPress
mismatches['taxonomies']['missing_in_wordpress'].append({
'id': taxonomy.id,
'name': taxonomy.name,
'type': taxonomy.taxonomy_type
})
# Check for WordPress taxonomies not in IGNY8
for cat in wp_categories:
if not SiteBlueprintTaxonomy.objects.filter(
site_blueprint=blueprint,
external_reference=str(cat['id'])
).exists():
mismatches['taxonomies']['missing_in_igny8'].append({
'name': cat['name'],
'slug': cat['slug'],
'type': 'blog_category',
'external_reference': str(cat['id'])
})
for tag in wp_tags:
if not SiteBlueprintTaxonomy.objects.filter(
site_blueprint=blueprint,
external_reference=str(tag['id'])
).exists():
mismatches['taxonomies']['missing_in_igny8'].append({
'name': tag['name'],
'slug': tag['slug'],
'type': 'blog_tag',
'external_reference': str(tag['id'])
})
# Check content mismatches (basic check)
igny8_content = Content.objects.filter(
account=integration.account,
site=integration.site,
source='igny8',
status='publish'
)
for content in igny8_content[:50]: # Limit check
if content.metadata and content.metadata.get('wordpress_id'):
# Content should exist in WordPress (would need to check)
# For now, just note if metadata exists
pass
else:
# Content not synced to WordPress
mismatches['posts']['missing_in_wordpress'].append({
'id': content.id,
'title': content.title,
'type': content.content_type
})
except Exception as e:
logger.warning(f"Error detecting WordPress mismatches: {e}")
return mismatches

View File

@@ -1,182 +0,0 @@
"""
Sync Service
Phase 6: Site Integration & Multi-Destination Publishing
Handles two-way synchronization between IGNY8 and external platforms.
"""
import logging
from typing import Dict, Any, Optional
from datetime import datetime
from igny8_core.business.integration.models import SiteIntegration
logger = logging.getLogger(__name__)
class SyncService:
"""
Service for handling two-way sync between IGNY8 and external platforms.
"""
def sync(
self,
integration: SiteIntegration,
direction: str = 'both',
content_types: Optional[list] = None
) -> Dict[str, Any]:
"""
Perform synchronization.
Stage 4: Enhanced to record sync runs for health tracking.
Args:
integration: SiteIntegration instance
direction: 'both', 'to_external', 'from_external'
content_types: List of content types to sync (optional, syncs all if None)
Returns:
dict: Sync result
"""
if not integration.sync_enabled:
return {
'success': False,
'message': 'Sync is not enabled for this integration',
'synced_count': 0
}
# Update sync status
integration.sync_status = 'syncing'
integration.save(update_fields=['sync_status', 'updated_at'])
try:
if direction in ('both', 'to_external'):
# Sync from IGNY8 to external platform
to_result = self._sync_to_external(integration, content_types)
else:
to_result = {'success': True, 'synced_count': 0}
if direction in ('both', 'from_external'):
# Sync from external platform to IGNY8
from_result = self._sync_from_external(integration, content_types)
else:
from_result = {'success': True, 'synced_count': 0}
# Update sync status
if to_result.get('success') and from_result.get('success'):
integration.sync_status = 'success'
integration.sync_error = None
else:
integration.sync_status = 'failed'
integration.sync_error = (
to_result.get('error', '') + ' ' + from_result.get('error', '')
).strip()
integration.last_sync_at = datetime.now()
integration.save(update_fields=['sync_status', 'sync_error', 'last_sync_at', 'updated_at'])
total_synced = to_result.get('synced_count', 0) + from_result.get('synced_count', 0)
result = {
'success': to_result.get('success') and from_result.get('success'),
'synced_count': total_synced,
'to_external': to_result,
'from_external': from_result
}
# Stage 4: Record sync run for health tracking
try:
from igny8_core.business.integration.services.sync_health_service import SyncHealthService
sync_health_service = SyncHealthService()
sync_health_service.record_sync_run(integration.id, result)
except Exception as e:
logger.warning(f"Failed to record sync run: {e}")
return result
except Exception as e:
logger.error(
f"[SyncService] Error syncing integration {integration.id}: {str(e)}",
exc_info=True
)
integration.sync_status = 'failed'
integration.sync_error = str(e)
integration.save(update_fields=['sync_status', 'sync_error', 'updated_at'])
error_result = {
'success': False,
'error': str(e),
'synced_count': 0
}
# Stage 4: Record failed sync run
try:
from igny8_core.business.integration.services.sync_health_service import SyncHealthService
sync_health_service = SyncHealthService()
sync_health_service.record_sync_run(integration.id, error_result)
except Exception as e:
logger.warning(f"Failed to record sync run: {e}")
return error_result
def _sync_to_external(
self,
integration: SiteIntegration,
content_types: Optional[list] = None
) -> Dict[str, Any]:
"""
Sync content from IGNY8 to external platform.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync
Returns:
dict: Sync result
"""
# This will be implemented by ContentSyncService
from igny8_core.business.integration.services.content_sync_service import ContentSyncService
sync_service = ContentSyncService()
return sync_service.sync_to_external(integration, content_types)
def _sync_from_external(
self,
integration: SiteIntegration,
content_types: Optional[list] = None
) -> Dict[str, Any]:
"""
Sync content from external platform to IGNY8.
Args:
integration: SiteIntegration instance
content_types: List of content types to sync
Returns:
dict: Sync result
"""
# This will be implemented by ContentSyncService
from igny8_core.business.integration.services.content_sync_service import ContentSyncService
sync_service = ContentSyncService()
return sync_service.sync_from_external(integration, content_types)
def get_sync_status(
self,
integration: SiteIntegration
) -> Dict[str, Any]:
"""
Get current sync status for an integration.
Args:
integration: SiteIntegration instance
Returns:
dict: Sync status information
"""
return {
'sync_enabled': integration.sync_enabled,
'sync_status': integration.sync_status,
'last_sync_at': integration.last_sync_at.isoformat() if integration.last_sync_at else None,
'sync_error': integration.sync_error
}

View File

@@ -1,5 +0,0 @@
"""
Integration Tests
Phase 6: Site Integration & Multi-Destination Publishing
"""

View File

@@ -1,155 +0,0 @@
"""
Tests for ContentSyncService
Phase 6: Site Integration & Multi-Destination Publishing
"""
from django.test import TestCase
from unittest.mock import patch, Mock
from igny8_core.auth.models import Account, Site, Sector, User, Plan, Industry, IndustrySector
from igny8_core.business.integration.models import SiteIntegration
from igny8_core.business.integration.services.content_sync_service import ContentSyncService
from igny8_core.business.content.models import Content
class ContentSyncServiceTestCase(TestCase):
"""Test cases for ContentSyncService"""
def setUp(self):
"""Set up test data"""
# Create plan first
self.plan = Plan.objects.create(
name="Test Plan",
slug="test-plan",
price=0,
credits_per_month=1000
)
# Create user first (Account needs owner)
self.user = User.objects.create_user(
username='testuser',
email='test@test.com',
password='testpass123',
role='owner'
)
# Create account with owner
self.account = Account.objects.create(
name="Test Account",
slug="test-account",
plan=self.plan,
owner=self.user
)
# Update user to have account
self.user.account = self.account
self.user.save()
# Create industry and sector
self.industry = Industry.objects.create(
name="Test Industry",
slug="test-industry"
)
self.industry_sector = IndustrySector.objects.create(
industry=self.industry,
name="Test Sector",
slug="test-sector"
)
self.site = Site.objects.create(
account=self.account,
name="Test Site",
slug="test-site",
industry=self.industry
)
self.sector = Sector.objects.create(
account=self.account,
site=self.site,
industry_sector=self.industry_sector,
name="Test Sector",
slug="test-sector"
)
self.integration = SiteIntegration.objects.create(
account=self.account,
site=self.site,
platform='wordpress',
platform_type='cms',
sync_enabled=True
)
self.service = ContentSyncService()
def test_sync_content_from_wordpress_creates_content(self):
"""Test: WordPress sync works (when plugin connected)"""
mock_posts = [
{
'id': 1,
'title': 'Test Post',
'content': '<p>Test content</p>',
'status': 'publish',
}
]
with patch.object(self.service, '_fetch_wordpress_posts') as mock_fetch:
mock_fetch.return_value = mock_posts
result = self.service.sync_from_wordpress(self.integration)
self.assertTrue(result.get('success'))
self.assertEqual(result.get('synced_count'), 1)
# Verify content was created
content = Content.objects.filter(site=self.site).first()
self.assertIsNotNone(content)
self.assertEqual(content.title, 'Test Post')
self.assertEqual(content.source, 'wordpress')
def test_sync_content_from_shopify_creates_content(self):
"""Test: Content sync works"""
mock_products = [
{
'id': 1,
'title': 'Test Product',
'body_html': '<p>Product description</p>',
}
]
with patch.object(self.service, '_fetch_shopify_products') as mock_fetch:
mock_fetch.return_value = mock_products
result = self.service.sync_from_shopify(self.integration)
self.assertTrue(result.get('success'))
self.assertEqual(result.get('synced_count'), 1)
def test_sync_handles_duplicate_content(self):
"""Test: Content sync works"""
# Create existing content
Content.objects.create(
account=self.account,
site=self.site,
sector=self.sector,
title="Test Post",
html_content="<p>Existing</p>",
source='wordpress'
)
mock_posts = [
{
'id': 1,
'title': 'Test Post',
'content': '<p>Updated content</p>',
}
]
with patch.object(self.service, '_fetch_wordpress_posts') as mock_fetch:
mock_fetch.return_value = mock_posts
result = self.service.sync_from_wordpress(self.integration)
# Should update existing, not create duplicate
content_count = Content.objects.filter(
site=self.site,
title='Test Post'
).count()
self.assertEqual(content_count, 1)

View File

@@ -1,141 +0,0 @@
"""
Tests for IntegrationService
Phase 6: Site Integration & Multi-Destination Publishing
"""
from django.test import TestCase
from igny8_core.auth.models import Account, Site, Sector, User, Plan, Industry, IndustrySector
from igny8_core.business.integration.models import SiteIntegration
from igny8_core.business.integration.services.integration_service import IntegrationService
class IntegrationServiceTestCase(TestCase):
"""Test cases for IntegrationService"""
def setUp(self):
"""Set up test data"""
# Create plan first
self.plan = Plan.objects.create(
name="Test Plan",
slug="test-plan",
price=0,
credits_per_month=1000
)
# Create user first (Account needs owner)
self.user = User.objects.create_user(
username='testuser',
email='test@test.com',
password='testpass123',
role='owner'
)
# Create account with owner
self.account = Account.objects.create(
name="Test Account",
slug="test-account",
plan=self.plan,
owner=self.user
)
# Update user to have account
self.user.account = self.account
self.user.save()
# Create industry and sector
self.industry = Industry.objects.create(
name="Test Industry",
slug="test-industry"
)
self.industry_sector = IndustrySector.objects.create(
industry=self.industry,
name="Test Sector",
slug="test-sector"
)
self.site = Site.objects.create(
account=self.account,
name="Test Site",
slug="test-site",
industry=self.industry
)
self.sector = Sector.objects.create(
account=self.account,
site=self.site,
industry_sector=self.industry_sector,
name="Test Sector",
slug="test-sector"
)
self.service = IntegrationService()
def test_create_integration_stores_config(self):
"""Test: Site integrations work correctly"""
integration = self.service.create_integration(
site=self.site,
platform='wordpress',
config={'url': 'https://example.com'},
credentials={'api_key': 'test-key'},
platform_type='cms'
)
self.assertIsNotNone(integration)
self.assertEqual(integration.platform, 'wordpress')
self.assertEqual(integration.platform_type, 'cms')
self.assertEqual(integration.config_json.get('url'), 'https://example.com')
self.assertTrue(integration.is_active)
def test_get_integrations_for_site_returns_all(self):
"""Test: Site integrations work correctly"""
self.service.create_integration(
site=self.site,
platform='wordpress',
config={},
credentials={}
)
self.service.create_integration(
site=self.site,
platform='shopify',
config={},
credentials={}
)
integrations = self.service.get_integrations_for_site(self.site)
self.assertEqual(integrations.count(), 2)
platforms = [i.platform for i in integrations]
self.assertIn('wordpress', platforms)
self.assertIn('shopify', platforms)
def test_test_connection_validates_credentials(self):
"""Test: Site integrations work correctly"""
# Test with unsupported platform to verify NotImplementedError is raised
integration = self.service.create_integration(
site=self.site,
platform='unsupported_platform',
config={'url': 'https://example.com'},
credentials={'api_key': 'test-key'}
)
with self.assertRaises(NotImplementedError):
# Connection testing should raise NotImplementedError for unsupported platforms
self.service.test_connection(integration)
def test_update_integration_updates_fields(self):
"""Test: Site integrations work correctly"""
integration = self.service.create_integration(
site=self.site,
platform='wordpress',
config={'url': 'https://old.com'},
credentials={}
)
updated = self.service.update_integration(
integration,
config={'url': 'https://new.com'},
is_active=False
)
self.assertEqual(updated.config_json.get('url'), 'https://new.com')
self.assertFalse(updated.is_active)

View File

@@ -1,127 +0,0 @@
"""
Tests for SyncService
Phase 6: Site Integration & Multi-Destination Publishing
"""
from django.test import TestCase
from django.utils import timezone
from unittest.mock import patch, Mock
from igny8_core.auth.models import Account, Site, Sector, User, Plan, Industry, IndustrySector
from igny8_core.business.integration.models import SiteIntegration
from igny8_core.business.integration.services.sync_service import SyncService
class SyncServiceTestCase(TestCase):
"""Test cases for SyncService"""
def setUp(self):
"""Set up test data"""
# Create plan first
self.plan = Plan.objects.create(
name="Test Plan",
slug="test-plan",
price=0,
credits_per_month=1000
)
# Create user first (Account needs owner)
self.user = User.objects.create_user(
username='testuser',
email='test@test.com',
password='testpass123',
role='owner'
)
# Create account with owner
self.account = Account.objects.create(
name="Test Account",
slug="test-account",
plan=self.plan,
owner=self.user
)
# Update user to have account
self.user.account = self.account
self.user.save()
# Create industry and sector
self.industry = Industry.objects.create(
name="Test Industry",
slug="test-industry"
)
self.industry_sector = IndustrySector.objects.create(
industry=self.industry,
name="Test Sector",
slug="test-sector"
)
self.site = Site.objects.create(
account=self.account,
name="Test Site",
slug="test-site",
industry=self.industry
)
self.sector = Sector.objects.create(
account=self.account,
site=self.site,
industry_sector=self.industry_sector,
name="Test Sector",
slug="test-sector"
)
self.integration = SiteIntegration.objects.create(
account=self.account,
site=self.site,
platform='wordpress',
platform_type='cms',
sync_enabled=True,
sync_status='pending'
)
self.service = SyncService()
def test_sync_updates_status(self):
"""Test: Two-way sync functions properly"""
with patch.object(self.service, '_sync_to_external') as mock_sync_to, \
patch.object(self.service, '_sync_from_external') as mock_sync_from:
mock_sync_to.return_value = {'success': True, 'synced': 5}
mock_sync_from.return_value = {'success': True, 'synced': 3}
result = self.service.sync(self.integration, direction='both')
self.assertTrue(result.get('success'))
self.integration.refresh_from_db()
self.assertEqual(self.integration.sync_status, 'success')
self.assertIsNotNone(self.integration.last_sync_at)
def test_sync_to_external_only(self):
"""Test: Two-way sync functions properly"""
with patch.object(self.service, '_sync_to_external') as mock_sync_to:
mock_sync_to.return_value = {'success': True, 'synced': 5}
result = self.service.sync(self.integration, direction='to_external')
self.assertTrue(result.get('success'))
mock_sync_to.assert_called_once()
def test_sync_from_external_only(self):
"""Test: WordPress sync works (when plugin connected)"""
with patch.object(self.service, '_sync_from_external') as mock_sync_from:
mock_sync_from.return_value = {'success': True, 'synced': 3}
result = self.service.sync(self.integration, direction='from_external')
self.assertTrue(result.get('success'))
mock_sync_from.assert_called_once()
def test_sync_handles_errors(self):
"""Test: Two-way sync functions properly"""
with patch.object(self.service, '_sync_to_external') as mock_sync_to:
mock_sync_to.side_effect = Exception("Sync failed")
result = self.service.sync(self.integration, direction='to_external')
self.assertFalse(result.get('success'))
self.integration.refresh_from_db()
self.assertEqual(self.integration.sync_status, 'failed')
self.assertIsNotNone(self.integration.sync_error)

View File

@@ -1,6 +0,0 @@
"""
Linking Business Logic
Phase 4: Linker & Optimizer
"""

View File

@@ -1,5 +0,0 @@
"""
Linking Services
"""

View File

@@ -1,174 +0,0 @@
"""
Link Candidate Engine
Finds relevant content for internal linking
"""
import logging
from typing import List, Dict
from django.db import models
from igny8_core.business.content.models import Content
logger = logging.getLogger(__name__)
class CandidateEngine:
"""Finds link candidates for content"""
def find_candidates(self, content: Content, max_candidates: int = 10) -> List[Dict]:
"""
Find link candidates for a piece of content.
Args:
content: Content instance to find links for
max_candidates: Maximum number of candidates to return
Returns:
List of candidate dicts with: {'content_id', 'title', 'url', 'relevance_score', 'anchor_text'}
"""
if not content or not content.html_content:
return []
# Find relevant content from same account/site/sector
relevant_content = self._find_relevant_content(content)
# Score candidates based on relevance
candidates = self._score_candidates(content, relevant_content)
# Sort by score and return top candidates
candidates.sort(key=lambda x: x.get('relevance_score', 0), reverse=True)
return candidates[:max_candidates]
def _find_relevant_content(self, content: Content) -> List[Content]:
"""Find relevant content from same account/site/sector"""
# Stage 3: Use cluster mappings for better relevance
from igny8_core.business.content.models import ContentClusterMap
# Get content from same account, site, and sector
queryset = Content.objects.filter(
account=content.account,
site=content.site,
sector=content.sector,
status__in=['draft', 'review', 'publish']
).exclude(id=content.id)
# Stage 3: Prioritize content from same cluster
content_clusters = ContentClusterMap.objects.filter(
content=content
).values_list('cluster_id', flat=True)
if content_clusters:
# Find content mapped to same clusters
cluster_content_ids = ContentClusterMap.objects.filter(
cluster_id__in=content_clusters
).exclude(content=content).values_list('content_id', flat=True).distinct()
# Prioritize cluster-matched content
cluster_matched = queryset.filter(id__in=cluster_content_ids)
other_content = queryset.exclude(id__in=cluster_content_ids)
# Combine: cluster-matched first, then others
return list(cluster_matched[:30]) + list(other_content[:20])
# Fallback to keyword-based filtering
if content.primary_keyword:
queryset = queryset.filter(
models.Q(primary_keyword__icontains=content.primary_keyword) |
models.Q(secondary_keywords__icontains=content.primary_keyword)
)
return list(queryset[:50]) # Limit initial query
def _score_candidates(self, content: Content, candidates: List[Content]) -> List[Dict]:
"""Score candidates based on relevance"""
from igny8_core.business.content.models import ContentClusterMap, ContentTaxonomyMap
# Stage 3: Get cluster mappings for content
content_clusters = set(
ContentClusterMap.objects.filter(content=content)
.values_list('cluster_id', flat=True)
)
content_taxonomies = set(
ContentTaxonomyMap.objects.filter(content=content)
.values_list('taxonomy_id', flat=True)
)
scored = []
for candidate in candidates:
score = 0
# Stage 3: Cluster matching (highest priority)
candidate_clusters = set(
ContentClusterMap.objects.filter(content=candidate)
.values_list('cluster_id', flat=True)
)
cluster_overlap = content_clusters & candidate_clusters
if cluster_overlap:
score += 50 * len(cluster_overlap) # High weight for cluster matches
# Stage 3: Taxonomy matching
candidate_taxonomies = set(
ContentTaxonomyMap.objects.filter(content=candidate)
.values_list('taxonomy_id', flat=True)
)
taxonomy_overlap = content_taxonomies & candidate_taxonomies
if taxonomy_overlap:
score += 20 * len(taxonomy_overlap)
# Stage 3: Entity type matching
if content.entity_type == candidate.entity_type:
score += 15
# Keyword overlap (medium weight)
if content.primary_keyword and candidate.primary_keyword:
if content.primary_keyword.lower() in candidate.primary_keyword.lower():
score += 20
if candidate.primary_keyword.lower() in content.primary_keyword.lower():
score += 20
# Secondary keywords overlap
if content.secondary_keywords and candidate.secondary_keywords:
overlap = set(content.secondary_keywords) & set(candidate.secondary_keywords)
score += len(overlap) * 5
# Category overlap
if content.categories and candidate.categories:
overlap = set(content.categories) & set(candidate.categories)
score += len(overlap) * 3
# Tag overlap
if content.tags and candidate.tags:
overlap = set(content.tags) & set(candidate.tags)
score += len(overlap) * 2
# Recency bonus (newer content gets slight boost)
if candidate.generated_at:
days_old = (content.generated_at - candidate.generated_at).days
if days_old < 30:
score += 3
if score > 0:
scored.append({
'content_id': candidate.id,
'title': candidate.title or candidate.task.title if candidate.task else 'Untitled',
'url': f"/content/{candidate.id}/", # Placeholder - actual URL depends on routing
'relevance_score': score,
'cluster_match': len(cluster_overlap) > 0, # Stage 3: Flag cluster matches
'taxonomy_match': len(taxonomy_overlap) > 0, # Stage 3: Flag taxonomy matches
'anchor_text': self._generate_anchor_text(candidate, content)
})
return scored
def _generate_anchor_text(self, candidate: Content, source_content: Content) -> str:
"""Generate anchor text for link"""
# Use primary keyword if available, otherwise use title
if candidate.primary_keyword:
return candidate.primary_keyword
elif candidate.title:
return candidate.title
elif candidate.task and candidate.task.title:
return candidate.task.title
else:
return "Learn more"

View File

@@ -1,73 +0,0 @@
"""
Link Injection Engine
Injects internal links into content HTML
"""
import logging
import re
from typing import List, Dict
from igny8_core.business.content.models import Content
logger = logging.getLogger(__name__)
class InjectionEngine:
"""Injects links into content HTML"""
def inject_links(self, content: Content, candidates: List[Dict], max_links: int = 5) -> Dict:
"""
Inject links into content HTML.
Args:
content: Content instance
candidates: List of link candidates from CandidateEngine
max_links: Maximum number of links to inject
Returns:
Dict with: {'html_content', 'links', 'links_added'}
"""
if not content.html_content or not candidates:
return {
'html_content': content.html_content,
'links': [],
'links_added': 0
}
html = content.html_content
links_added = []
links_used = set() # Track which candidates we've used
# Sort candidates by relevance score
sorted_candidates = sorted(candidates, key=lambda x: x.get('relevance_score', 0), reverse=True)
# Inject links (limit to max_links)
for candidate in sorted_candidates[:max_links]:
if candidate['content_id'] in links_used:
continue
anchor_text = candidate.get('anchor_text', 'Learn more')
url = candidate.get('url', f"/content/{candidate['content_id']}/")
# Find first occurrence of anchor text in HTML (case-insensitive)
pattern = re.compile(re.escape(anchor_text), re.IGNORECASE)
match = pattern.search(html)
if match:
# Replace with link
link_html = f'<a href="{url}" class="internal-link">{anchor_text}</a>'
html = html[:match.start()] + link_html + html[match.end():]
links_added.append({
'content_id': candidate['content_id'],
'anchor_text': anchor_text,
'url': url,
'position': match.start()
})
links_used.add(candidate['content_id'])
return {
'html_content': html,
'links': links_added,
'links_added': len(links_added)
}

View File

@@ -1,333 +0,0 @@
"""
Linker Service
Main service for processing content for internal linking
"""
import logging
from typing import List
from igny8_core.business.content.models import Content
from igny8_core.business.linking.services.candidate_engine import CandidateEngine
from igny8_core.business.linking.services.injection_engine import InjectionEngine
from igny8_core.business.billing.services.credit_service import CreditService
from igny8_core.business.billing.exceptions import InsufficientCreditsError
logger = logging.getLogger(__name__)
class LinkerService:
"""Service for processing content for internal linking"""
def __init__(self):
self.candidate_engine = CandidateEngine()
self.injection_engine = InjectionEngine()
self.credit_service = CreditService()
def process(self, content_id: int) -> Content:
"""
Process content for linking.
Args:
content_id: Content ID to process
Returns:
Updated Content instance
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
try:
content = Content.objects.get(id=content_id)
except Content.DoesNotExist:
raise ValueError(f"Content with id {content_id} does not exist")
account = content.account
# Check credits
try:
self.credit_service.check_credits(account, 'linking')
except InsufficientCreditsError:
raise
# Find link candidates
candidates = self.candidate_engine.find_candidates(content)
if not candidates:
logger.info(f"No link candidates found for content {content_id}")
return content
# Inject links
result = self.injection_engine.inject_links(content, candidates)
# Update content
content.html_content = result['html_content']
content.internal_links = result['links']
content.linker_version += 1
content.save(update_fields=['html_content', 'internal_links', 'linker_version'])
# Deduct credits
self.credit_service.deduct_credits_for_operation(
account=account,
operation_type='linking',
description=f"Internal linking for content: {content.title or 'Untitled'}",
related_object_type='content',
related_object_id=content.id
)
logger.info(f"Linked content {content_id}: {result['links_added']} links added")
return content
def batch_process(self, content_ids: List[int]) -> List[Content]:
"""
Process multiple content items for linking.
Args:
content_ids: List of content IDs to process
Returns:
List of updated Content instances
"""
results = []
for content_id in content_ids:
try:
result = self.process(content_id)
results.append(result)
except Exception as e:
logger.error(f"Error processing content {content_id}: {str(e)}", exc_info=True)
# Continue with other items
continue
return results
def process_product(self, content_id: int) -> Content:
"""
Process product content for linking (Phase 8).
Enhanced linking for products: links to related products, categories, and service pages.
Args:
content_id: Content ID to process (must be entity_type='product')
Returns:
Updated Content instance
"""
try:
content = Content.objects.get(id=content_id, entity_type='product')
except Content.DoesNotExist:
raise ValueError(f"Product content with id {content_id} does not exist")
# Use base process but with product-specific candidate finding
account = content.account
# Check credits
try:
self.credit_service.check_credits(account, 'linking')
except InsufficientCreditsError:
raise
# Find product-specific link candidates (related products, categories, services)
candidates = self._find_product_candidates(content)
if not candidates:
logger.info(f"No link candidates found for product content {content_id}")
return content
# Inject links
result = self.injection_engine.inject_links(content, candidates)
# Update content
content.html_content = result['html_content']
content.internal_links = result['links']
content.linker_version += 1
content.save(update_fields=['html_content', 'internal_links', 'linker_version'])
# Deduct credits
self.credit_service.deduct_credits_for_operation(
account=account,
operation_type='linking',
description=f"Product linking for: {content.title or 'Untitled'}",
related_object_type='content',
related_object_id=content.id
)
logger.info(f"Linked product content {content_id}: {result['links_added']} links added")
return content
def process_taxonomy(self, content_id: int) -> Content:
"""
Process taxonomy content for linking (Phase 8).
Enhanced linking for taxonomies: links to related categories, tags, and content.
Args:
content_id: Content ID to process (must be entity_type='taxonomy')
Returns:
Updated Content instance
"""
try:
content = Content.objects.get(id=content_id, entity_type='taxonomy')
except Content.DoesNotExist:
raise ValueError(f"Taxonomy content with id {content_id} does not exist")
# Use base process but with taxonomy-specific candidate finding
account = content.account
# Check credits
try:
self.credit_service.check_credits(account, 'linking')
except InsufficientCreditsError:
raise
# Find taxonomy-specific link candidates (related taxonomies, categories, content)
candidates = self._find_taxonomy_candidates(content)
if not candidates:
logger.info(f"No link candidates found for taxonomy content {content_id}")
return content
# Inject links
result = self.injection_engine.inject_links(content, candidates)
# Update content
content.html_content = result['html_content']
content.internal_links = result['links']
content.linker_version += 1
content.save(update_fields=['html_content', 'internal_links', 'linker_version'])
# Deduct credits
self.credit_service.deduct_credits_for_operation(
account=account,
operation_type='linking',
description=f"Taxonomy linking for: {content.title or 'Untitled'}",
related_object_type='content',
related_object_id=content.id
)
logger.info(f"Linked taxonomy content {content_id}: {result['links_added']} links added")
return content
def _find_product_candidates(self, content: Content) -> List[dict]:
"""
Find link candidates specific to product content.
Args:
content: Product Content instance
Returns:
List of candidate dicts
"""
candidates = []
# Find related products (same category, similar features)
related_products = Content.objects.filter(
account=content.account,
site=content.site,
sector=content.sector,
entity_type='product',
status__in=['draft', 'review', 'publish']
).exclude(id=content.id)
# Use structure_data to find products with similar categories/features
if content.structure_data:
product_type = content.structure_data.get('product_type')
if product_type:
related_products = related_products.filter(
structure_data__product_type=product_type
)
# Add product candidates
for product in related_products[:5]: # Limit to 5 related products
candidates.append({
'content_id': product.id,
'title': product.title or 'Untitled Product',
'url': f'/products/{product.id}', # Placeholder URL
'relevance_score': 0.8,
'anchor_text': product.title or 'Related Product'
})
# Find related service pages
related_services = Content.objects.filter(
account=content.account,
site=content.site,
sector=content.sector,
entity_type='service',
status__in=['draft', 'review', 'publish']
)[:3] # Limit to 3 related services
for service in related_services:
candidates.append({
'content_id': service.id,
'title': service.title or 'Untitled Service',
'url': f'/services/{service.id}', # Placeholder URL
'relevance_score': 0.6,
'anchor_text': service.title or 'Related Service'
})
# Use base candidate engine for additional candidates
base_candidates = self.candidate_engine.find_candidates(content, max_candidates=5)
candidates.extend(base_candidates)
return candidates
def _find_taxonomy_candidates(self, content: Content) -> List[dict]:
"""
Find link candidates specific to taxonomy content.
Args:
content: Taxonomy Content instance
Returns:
List of candidate dicts
"""
candidates = []
# Find related taxonomies
related_taxonomies = Content.objects.filter(
account=content.account,
site=content.site,
sector=content.sector,
entity_type='taxonomy',
status__in=['draft', 'review', 'publish']
).exclude(id=content.id)[:5] # Limit to 5 related taxonomies
for taxonomy in related_taxonomies:
candidates.append({
'content_id': taxonomy.id,
'title': taxonomy.title or 'Untitled Taxonomy',
'url': f'/taxonomy/{taxonomy.id}', # Placeholder URL
'relevance_score': 0.7,
'anchor_text': taxonomy.title or 'Related Taxonomy'
})
# Find content in this taxonomy (using json_blocks categories/tags)
if content.json_blocks:
for block in content.json_blocks:
if block.get('type') == 'categories':
categories = block.get('items', [])
for category in categories[:3]: # Limit to 3 categories
category_name = category.get('name', '')
if category_name:
related_content = Content.objects.filter(
account=content.account,
site=content.site,
sector=content.sector,
categories__icontains=category_name,
status__in=['draft', 'review', 'publish']
).exclude(id=content.id)[:3]
for related in related_content:
candidates.append({
'content_id': related.id,
'title': related.title or 'Untitled',
'url': f'/content/{related.id}', # Placeholder URL
'relevance_score': 0.6,
'anchor_text': related.title or 'Related Content'
})
# Use base candidate engine for additional candidates
base_candidates = self.candidate_engine.find_candidates(content, max_candidates=5)
candidates.extend(base_candidates)
return candidates

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