From affa783a4f5b519ea0304493f169f5530f53ac1a Mon Sep 17 00:00:00 2001 From: alorig <220087330+alorig@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:57:26 +0500 Subject: [PATCH] docs --- FIxes-and-refactor | 784 ------- TEST_ENDPOINTS.md | 190 -- ...00-SYSTEM-ARCHITECTURE-MASTER-REFERENCE.md | 1984 ----------------- .../01-IGNY8-REST-API-COMPLETE-REFERENCE.md | 1707 -------------- ...PLANNER-WRITER-WORKFLOW-TECHNICAL-GUIDE.md | 1467 ------------ .../05-WRITER-IMAGES-PAGE-SYSTEM-DESIGN.md | 1228 ---------- ...06-FEATURE-MODIFICATION-DEVELOPER-GUIDE.md | 1121 ---------- .../AI-FUNCTIONS-COMPLETE-REFERENCE.md | 1155 ---------- .../API-COMPLETE-REFERENCE-LATEST.md | 1516 ------------- ...EYWORDS-CLUSTERS-IDEAS-COMPLETE-MAPPING.md | 263 --- .../TAXONOMY-RELATIONSHIP-DIAGRAM.md | 258 --- approved-docs/app-packaging-backaup-plan.md | 237 -- approved-docs/user-flow-plan-in-progress-1.md | 46 - approved-docs/user-flow-plan-in-progress-2.md | 13 - approved-docs/user-flow-plan.md | 200 -- .../CURRENT-STATE-CONTEXT.md | 0 .../FINAL-IMPLEMENTATION-PLAN-COMPLETE.md | 0 .../FINAL-IMPLEMENTATION-REQUIREMENTS.md | 0 .../FREE-TRIAL-SIGNUP-FIX.md | 0 .../IMPLEMENTATION-COMPLETE-SUMMARY.md | 0 .../IMPLEMENTATION-SUMMARY.md | 0 .../PRICING-TO-PAID-SIGNUP-GAP.md | 0 .../README-START-HERE.md | 0 tenant-temp/backend/igny8_core/ai/ai_core.py | 868 -------- tenant-temp/backend/igny8_core/api/base.py | 401 ---- .../backend/igny8_core/api/permissions.py | 131 -- .../backend/igny8_core/api/throttles.py | 135 -- .../backend/igny8_core/auth/middleware.py | 174 -- tenant-temp/backend/igny8_core/auth/models.py | 634 ------ .../backend/igny8_core/auth/permissions.py | 77 - tenant-temp/backend/igny8_core/auth/views.py | 1523 ------------- .../igny8_core/middleware/request_id.py | 43 - .../igny8_core/middleware/resource_tracker.py | 135 -- .../igny8_core/modules/billing/views.py | 581 ----- .../igny8_core/modules/planner/views.py | 1182 ---------- .../modules/system/integration_views.py | 1392 ------------ .../igny8_core/modules/system/views.py | 782 ------- .../igny8_core/modules/writer/views.py | 1710 -------------- tenant-temp/backend/igny8_core/settings.py | 616 ----- tenant-temp/frontend/src/App.tsx | 843 ------- .../src/components/auth/AdminGuard.tsx | 25 - .../src/components/auth/ProtectedRoute.tsx | 183 -- .../src/components/common/ModuleGuard.tsx | 41 - .../frontend/src/layout/AppSidebar.tsx | 636 ------ .../07-MULTITENANCY-ACCESS-REFERENCE.md | 104 - 45 files changed, 24385 deletions(-) delete mode 100644 FIxes-and-refactor delete mode 100644 TEST_ENDPOINTS.md delete mode 100644 approved-docs/00-SYSTEM-ARCHITECTURE-MASTER-REFERENCE.md delete mode 100644 approved-docs/01-IGNY8-REST-API-COMPLETE-REFERENCE.md delete mode 100644 approved-docs/02-PLANNER-WRITER-WORKFLOW-TECHNICAL-GUIDE.md delete mode 100644 approved-docs/05-WRITER-IMAGES-PAGE-SYSTEM-DESIGN.md delete mode 100644 approved-docs/06-FEATURE-MODIFICATION-DEVELOPER-GUIDE.md delete mode 100644 approved-docs/AI-FUNCTIONS-COMPLETE-REFERENCE.md delete mode 100644 approved-docs/API-COMPLETE-REFERENCE-LATEST.md delete mode 100644 approved-docs/KEYWORDS-CLUSTERS-IDEAS-COMPLETE-MAPPING.md delete mode 100644 approved-docs/TAXONOMY-RELATIONSHIP-DIAGRAM.md delete mode 100644 approved-docs/app-packaging-backaup-plan.md delete mode 100644 approved-docs/user-flow-plan-in-progress-1.md delete mode 100644 approved-docs/user-flow-plan-in-progress-2.md delete mode 100644 approved-docs/user-flow-plan.md rename {final-tenancy-accounts-payments => tenancy-accounts-payments-still-have issues}/CURRENT-STATE-CONTEXT.md (100%) rename {final-tenancy-accounts-payments => tenancy-accounts-payments-still-have issues}/FINAL-IMPLEMENTATION-PLAN-COMPLETE.md (100%) rename {final-tenancy-accounts-payments => tenancy-accounts-payments-still-have issues}/FINAL-IMPLEMENTATION-REQUIREMENTS.md (100%) rename {final-tenancy-accounts-payments => tenancy-accounts-payments-still-have issues}/FREE-TRIAL-SIGNUP-FIX.md (100%) rename {final-tenancy-accounts-payments => tenancy-accounts-payments-still-have issues}/IMPLEMENTATION-COMPLETE-SUMMARY.md (100%) rename {final-tenancy-accounts-payments => tenancy-accounts-payments-still-have issues}/IMPLEMENTATION-SUMMARY.md (100%) rename {final-tenancy-accounts-payments => tenancy-accounts-payments-still-have issues}/PRICING-TO-PAID-SIGNUP-GAP.md (100%) rename {final-tenancy-accounts-payments => tenancy-accounts-payments-still-have issues}/README-START-HERE.md (100%) delete mode 100644 tenant-temp/backend/igny8_core/ai/ai_core.py delete mode 100644 tenant-temp/backend/igny8_core/api/base.py delete mode 100644 tenant-temp/backend/igny8_core/api/permissions.py delete mode 100644 tenant-temp/backend/igny8_core/api/throttles.py delete mode 100644 tenant-temp/backend/igny8_core/auth/middleware.py delete mode 100644 tenant-temp/backend/igny8_core/auth/models.py delete mode 100644 tenant-temp/backend/igny8_core/auth/permissions.py delete mode 100644 tenant-temp/backend/igny8_core/auth/views.py delete mode 100644 tenant-temp/backend/igny8_core/middleware/request_id.py delete mode 100644 tenant-temp/backend/igny8_core/middleware/resource_tracker.py delete mode 100644 tenant-temp/backend/igny8_core/modules/billing/views.py delete mode 100644 tenant-temp/backend/igny8_core/modules/planner/views.py delete mode 100644 tenant-temp/backend/igny8_core/modules/system/integration_views.py delete mode 100644 tenant-temp/backend/igny8_core/modules/system/views.py delete mode 100644 tenant-temp/backend/igny8_core/modules/writer/views.py delete mode 100644 tenant-temp/backend/igny8_core/settings.py delete mode 100644 tenant-temp/frontend/src/App.tsx delete mode 100644 tenant-temp/frontend/src/components/auth/AdminGuard.tsx delete mode 100644 tenant-temp/frontend/src/components/auth/ProtectedRoute.tsx delete mode 100644 tenant-temp/frontend/src/components/common/ModuleGuard.tsx delete mode 100644 tenant-temp/frontend/src/layout/AppSidebar.tsx delete mode 100644 tenant-temp/master-docs/00-system/07-MULTITENANCY-ACCESS-REFERENCE.md diff --git a/FIxes-and-refactor b/FIxes-and-refactor deleted file mode 100644 index 641fb9ec..00000000 --- a/FIxes-and-refactor +++ /dev/null @@ -1,784 +0,0 @@ -# IGNY8 Automation System - Detailed Task List for AI Agent - -## CRITICAL ANALYSIS - -Based on the documentation and current implementation status, I've identified significant issues with the automation system and legacy SiteBuilder references that need systematic resolution. - ---- -## SECTION 1: udapte correct status, adn assoitae keywrods ot lcuster properly -Auto cluster AI function is currently setting status of clusters to active, and keywords are not mapped to clusters when run with automation. Update the original auto cluster AI function to use status new instead of active, and identify whether the keyword-to-cluster mapping issue is in the AI function or in the automation. - -Actualy the orringal ai fucntion has both of this issue, once fixed tehn ai fucntion wil lwork fine and autoamtion also, will run better, - - - -## SECTION 2: LEGACY SITEBUILDER/BLUEPRINT REMOVAL - -### Task 2.1: Database Models Cleanup -**Files to Remove Completely:** -1. `backend/igny8_core/business/site_building/models.py` - Already stubbed, remove entirely -2. Migration already exists: `0002_remove_blueprint_models.py` - Verify it ran successfully - -**Database Verification:** -1. Connect to production database -2. Run SQL: `SELECT tablename FROM pg_tables WHERE tablename LIKE '%blueprint%' OR tablename LIKE '%site_building%';` -3. Expected result: No tables (already dropped) -4. If tables exist, manually run DROP TABLE commands from migration - -**Foreign Key Cleanup:** -1. Check `igny8_deployment_records` table - verify `site_blueprint_id` column removed -2. Check `igny8_publishing_records` table - verify `site_blueprint_id` column removed -3. Confirm indexes dropped: `igny8_publishing_recor_site_blueprint_id_des_b7c4e5f8_idx` - ---- - -### Task 2.2: Backend Code References Removal - -**Phase 2.2.1: Remove Stub Models** -- **File:** `backend/igny8_core/business/site_building/models.py` -- **Action:** Delete entire file -- **Reason:** Contains only stub classes (`SiteBlueprint`, `PageBlueprint`, `SiteBlueprintCluster`, `SiteBlueprintTaxonomy`) with no functionality - -**Phase 2.2.2: Remove Entire site_building App** -- **Directory:** `backend/igny8_core/business/site_building/` -- **Action:** Delete entire directory -- **Reason:** All functionality deprecated, no active code - -**Files to Delete:** -1. `services/structure_generation_service.py` - Calls deprecated AI function -2. `services/page_generation_service.py` - References PageBlueprint -3. `services/taxonomy_service.py` - Uses SiteBlueprintTaxonomy -4. `services/file_management_service.py` - SiteBuilder file management -5. `tests/` - All test files reference removed models -6. `admin.py` - Already commented out -7. `migrations/` - Keep for database history, but app removal makes them inert - -**Phase 2.2.3: Remove site_builder Module** -- **Directory:** `backend/igny8_core/modules/site_builder.backup/` -- **Action:** Delete entire directory (already marked `.backup`) -- **Contains:** Deprecated API endpoints, serializers, views for blueprint management - ---- - -### Task 2.3: Settings Configuration Cleanup - -**File:** `backend/igny8_core/settings.py` - -**Changes:** -1. Line 56: Already commented out - Remove comment entirely -2. Line 61: Already commented out - Remove comment entirely -3. Verify `INSTALLED_APPS` list is clean - -**Verification:** -- Run `python manage.py check` - Should pass -- Run `python manage.py migrate --plan` - Should show no pending site_building migrations - ---- - -### Task 2.4: URL Routing Cleanup - -**File:** `backend/igny8_core/urls.py` - -**Changes:** -1. Line 42: Comment already exists - Remove comment entirely -2. Verify no routing to `site-builder/` endpoints exists - -**Verification:** -- Run Django server -- Attempt to access `/api/v1/site-builder/blueprints/` - Should return 404 -- Check API root `/api/v1/` - Should not list site-builder endpoints - ---- - -### Task 2.5: AI Function Removal - -**File:** `backend/igny8_core/ai/functions/generate_page_content.py` - -**Problem:** This AI function depends on `PageBlueprint` model which no longer exists. - -**Action Required:** -1. **DELETE FILE:** `generate_page_content.py` (21 references to PageBlueprint) -2. **UPDATE:** `backend/igny8_core/ai/registry.py` - Remove lazy loader registration -3. **UPDATE:** `backend/igny8_core/ai/engine.py` - Remove from operation type mappings (line 599) - -**Verification:** -- Search codebase for `generate_page_content` function calls -- Ensure no active code relies on this function -- Confirm AI function registry no longer lists it - ---- - -### Task 2.6: Backend Import Statement Cleanup - -**Files with Import Statements to Update:** - -1. **backend/igny8_core/business/integration/services/content_sync_service.py** - - Lines 378, 488: `from igny8_core.business.site_building.models import SiteBlueprint` - - **Action:** Remove import, remove dependent code blocks (lines 382-388, 491-496) - - **Alternative:** Service should use `ContentTaxonomy` directly (post-migration model) - -2. **backend/igny8_core/business/integration/services/sync_health_service.py** - - Line 335: `from igny8_core.business.site_building.models import SiteBlueprint, SiteBlueprintTaxonomy` - - **Action:** Remove import, refactor taxonomy checks to use `ContentTaxonomy` - -3. **backend/igny8_core/business/publishing/services/adapters/sites_renderer_adapter.py** - - Line 15: `from igny8_core.business.site_building.models import SiteBlueprint` - - **Action:** Entire adapter is deprecated - DELETE FILE - - **Reason:** Designed to deploy SiteBlueprint instances, no longer applicable - -4. **backend/igny8_core/business/publishing/services/deployment_readiness_service.py** - - Line 10: `from igny8_core.business.site_building.models import SiteBlueprint` - - **Action:** DELETE FILE or refactor to remove blueprint checks - - **Reason:** Service checks blueprint readiness for deployment - -5. **backend/igny8_core/business/publishing/services/deployment_service.py** - - Line 10: `from igny8_core.business.site_building.models import SiteBlueprint` - - **Action:** Remove blueprint-specific deployment methods - ---- - -### Task 2.7: Frontend Files Removal - -**Phase 2.7.1: Remove Type Definitions** -- **File:** `frontend/src/types/siteBuilder.ts` -- **Action:** Delete file entirely -- **References:** Used in store and components - -**Phase 2.7.2: Remove API Service** -- **File:** `frontend/src/services/siteBuilder.api.ts` -- **Action:** Delete file -- **Contains:** API methods for blueprint CRUD operations - -**Phase 2.7.3: Remove Pages** -- **Directory:** `frontend/src/pages/Sites/` -- **Files to Review:** - - `Editor.tsx` - Uses PageBlueprint, SiteBlueprint types (lines 15-36) - - `PageManager.tsx` - Fetches blueprints (lines 126-137) - - `DeploymentPanel.tsx` - Blueprint deployment UI (46 references) - -**Action for Pages:** -1. If pages ONLY deal with blueprints - DELETE -2. If pages have mixed functionality - REFACTOR to remove blueprint code -3. Likely DELETE: `Editor.tsx`, `DeploymentPanel.tsx` -4. Likely REFACTOR: `Dashboard.tsx` (remove blueprint widget) - -**Phase 2.7.4: Remove Store** -- **File:** `frontend/src/store/siteDefinitionStore.ts` -- **Action:** Review dependencies, likely DELETE -- **Alternative:** If used for non-blueprint purposes, refactor to remove PageBlueprint types - -**Phase 2.7.5: Remove Components** -- **File:** `frontend/src/components/sites/SiteProgressWidget.tsx` -- **Action:** DELETE if blueprint-specific -- **Uses:** `blueprintId` prop, calls `fetchSiteProgress(blueprintId)` - ---- - -### Task 2.8: Frontend Import and Reference Cleanup - -**Files Requiring Updates:** - -1. **frontend/src/services/api.ts** - - Lines 2302-2532: Multiple blueprint-related functions - - **Action:** Remove these function exports: - - `fetchDeploymentReadiness` - - `createSiteBlueprint`, `updateSiteBlueprint` - - `attachClustersToBlueprint`, `detachClustersFromBlueprint` - - `fetchBlueprintsTaxonomies`, `createBlueprintTaxonomy` - - `importBlueprintsTaxonomies` - - `updatePageBlueprint`, `regeneratePageBlueprint` - -2. **frontend/src/pages/Planner/Dashboard.tsx** - - Lines 30-31: Commented imports - - **Action:** Remove commented lines entirely - -3. **frontend/src/config/pages/tasks.config.tsx** - - Lines 110-111: Logic for `[Site Builder]` task title formatting - - **Action:** Remove special handling, update title display logic - ---- - -### Task 2.9: Sites Renderer Cleanup - -**File:** `sites/src/loaders/loadSiteDefinition.ts` - -**Current Behavior (Lines 103-159):** -- API load fails → Falls back to blueprint endpoint -- Transforms blueprint to site definition format - -**Required Changes:** -1. Remove fallback to blueprint endpoint (lines 116-127) -2. Remove `transformBlueprintToSiteDefinition` function (lines 137-159) -3. If API fails, return proper error instead of fallback -4. Update error messages to remove blueprint references - ---- - -### Task 2.10: Documentation Cleanup - -**Files to Remove:** -1. `docs/igny8-pp/TAXONOMY/QUICK-REFERENCE-TAXONOMY.md` - References SiteBuilder removal -2. Update `docs/tech-stack/00-SYSTEM-ARCHITECTURE-MASTER-REFERENCE.md`: - - Remove "Site Blueprints" from feature list (line 45) - - Remove `site_builder/` from architecture diagrams (lines 179-180) - - Remove SiteBuilder from system overview (line 1517) - -**Files to Update:** -1. `docs/igny8-pp/01-IGNY8-REST-API-COMPLETE-REFERENCE.md`: - - Remove entire section: "Site Blueprints" (lines 1201-1230) - - Remove "Page Blueprints" section (lines 1230-1247) - - Update deployment endpoints to remove blueprint references - -2. `docs/igny8-pp/02-PLANNER-WRITER-WORKFLOW-TECHNICAL-GUIDE.md`: - - Remove SiteBlueprintTaxonomy references (lines 114, 151) - ---- - -### Task 2.11: Test Files Cleanup - -**Backend Tests:** -1. DELETE: `backend/igny8_core/ai/tests/test_generate_site_structure_function.py` -2. DELETE: `backend/igny8_core/business/site_building/tests/` (entire directory) -3. DELETE: `backend/igny8_core/business/publishing/tests/test_deployment_service.py` -4. DELETE: `backend/igny8_core/business/publishing/tests/test_publisher_service.py` -5. DELETE: `backend/igny8_core/business/publishing/tests/test_adapters.py` - -**Frontend Tests:** -1. DELETE: `frontend/src/__tests__/sites/BulkGeneration.test.tsx` -2. UPDATE: `frontend/src/__tests__/sites/PromptManagement.test.tsx`: - - Remove site_structure_generation prompt type checks (lines 27-28) -3. UPDATE: `frontend/src/__tests__/sites/SiteManagement.test.tsx`: - - Remove `[Site Builder]` task filtering logic (lines 50-51) - ---- - -### Task 2.12: Database Migration Verification - -**Critical Checks:** -1. Verify `0002_remove_blueprint_models.py` migration applied in all environments -2. Check for orphaned data: - - Query for any `Tasks` with `taxonomy_id` pointing to deleted SiteBlueprintTaxonomy - - Query for any `ContentIdeas` with old taxonomy foreign keys -3. If orphaned data found, create data migration to: - - Set taxonomy foreign keys to NULL - - Or migrate to ContentTaxonomy if mapping exists - -**SQL Verification Queries:** -```sql --- Check for blueprint tables (should return empty) -SELECT tablename FROM pg_tables -WHERE tablename LIKE '%blueprint%' OR tablename LIKE '%site_building%'; - --- Check for foreign key constraints (should return empty) -SELECT constraint_name FROM information_schema.table_constraints -WHERE constraint_name LIKE '%blueprint%'; - --- Check for orphaned taxonomy references -SELECT COUNT(*) FROM igny8_tasks WHERE taxonomy_id IS NOT NULL; -SELECT COUNT(*) FROM igny8_content_ideas WHERE taxonomy_id IS NOT NULL; -``` - ---- - -## SECTION 3: AUTOMATION PAGE UI IMPROVEMENTS - -### Task 3.1: Stage Card Visual Redesign - -**Current Issues:** -- Icons too large, taking excessive space -- Stage names not clearly associated with stage numbers -- Inconsistent visual hierarchy - -**Required Changes:** -1. **Reduce Icon Size:** - - Current: Large colored square icons - - New: Smaller icons (32x32px instead of current size) - - Position: Top-left of card, not centered - -2. **Restructure Stage Header:** - - Move stage name directly below "Stage N" text - - Format: "Stage 1" (bold) / "Keywords → Clusters" (regular weight, smaller font) - - Remove redundant text repetition - -3. **Status Badge Positioning:** - - Move from separate row to same line as stage number - - Right-align badge next to stage number - -**Layout Example (No Code):** -``` -┌─────────────────────────────┐ -│ [Icon] Stage 1 [Ready] │ -│ Keywords → Clusters │ -│ │ -│ Total Queue: 7 │ -│ Processed: 0 │ -│ Remaining: 7 │ -└─────────────────────────────┘ -``` - ---- - -### Task 3.2: Add Progress Bars to Stage Cards - -**Implementation Requirements:** -1. **Individual Stage Progress Bar:** - - Display below queue metrics - - Calculate: `(Processed / Total Queue) * 100` - - Visual: Colored bar matching stage color - - Show percentage label - -2. **Overall Pipeline Progress Bar:** - - Large bar above all stage cards - - Calculate: `(Sum of Processed Items Across All Stages) / (Sum of Total Queue Across All Stages) * 100` - - Display current stage indicator: "Stage 4/7" - - Show estimated completion time - -3. **Progress Bar States:** - - Empty (0%): Gray/outline only - - In Progress (1-99%): Animated gradient - - Complete (100%): Solid color, checkmark icon - ---- - -### Task 3.3: Add Total Metrics Cards Above Pipeline - -**New Component: MetricsSummary Cards** - -**Cards to Display (Row above pipeline overview):** -1. **Keywords Card:** - - Total: Count from database - - Processed: Keywords with `status='mapped'` - - Pending: Keywords with `status='new'` - -2. **Clusters Card:** - - Total: All clusters for site - - Processed: Clusters with ideas generated - - Pending: Clusters without ideas - -3. **Ideas Card:** - - Total: All ideas for site - - Processed: Ideas converted to tasks (`status='in_progress'`) - - Pending: Ideas with `status='new'` - -4. **Content Card:** - - Total: All content for site - - Processed: Content with `status='draft'` + all images generated - - Pending: Content without images or in generation - -5. **Images Card:** - - Total: All image records for site content - - Processed: Images with `status='generated'` - - Pending: Images with `status='pending'` - -**Card Layout:** -- Width: Equal distribution across row -- Display: Icon, Title, Total/Processed/Pending counts -- Color: Match stage colors for visual consistency - ---- - -### Task 3.4: Pipeline Status Card Redesign - -**Current:** Wide row with text "Pipeline Status - Ready to run | 22 items pending" - -**New Design Requirements:** -1. **Convert to Centered Card:** - - Position: Above stage cards, below metrics summary - - Width: Narrower than full width, centered - - Style: Elevated/shadowed for emphasis - -2. **Content Structure:** - - Large status indicator (icon + text) - - Prominent pending items count - - Quick action buttons (Run Now, Pause, Configure) - -3. **Status Visual States:** - - Ready: Green pulse animation - - Running: Blue animated progress - - Paused: Yellow warning icon - - Failed: Red alert icon - ---- - -### Task 3.5: Remove/Compact Header Elements - -**Changes to Automation Page Header:** - -1. **Remove "Pipeline Overview" Section:** - - Delete heading: "📊 Pipeline Overview" - - Delete subtitle: "Complete view of automation pipeline status and pending items" - - Reason: Redundant with visible pipeline cards - -2. **Compact Schedule Panel:** - - Current: Large panel with heading, status row, action buttons - - New: Single compact row - - Layout: `[Status Badge] | [Schedule Text] | [Last Run] | [Estimated Credits] | [Configure Button] | [Run Now Button]` - - Remove empty space and excessive padding - ---- - -### Task 3.6: AI Request Delays Implementation - -**Problem:** Rapid sequential AI requests may hit rate limits or overload AI service. - -**Required Changes:** - -1. **Within-Stage Delay (between batches):** - - Location: `AutomationService` class methods - - Add delay after each batch completion before processing next batch - - Configurable: 3-5 seconds (default 3 seconds) - - Implementation point: After each AI function call completes in stage loop - -2. **Between-Stage Delay:** - - Add delay after stage completion before triggering next stage - - Configurable: 5-10 seconds (default 5 seconds) - - Implementation point: After `_execute_stage()` returns before incrementing `current_stage` - -3. **Configuration:** - - Add to `AutomationConfig` model: `within_stage_delay` (integer, seconds) - - Add to `AutomationConfig` model: `between_stage_delay` (integer, seconds) - - Expose in Configure modal for user adjustment - -4. **Logging:** - - Log delay start: "Waiting 3 seconds before next batch..." - - Log delay end: "Delay complete, resuming processing" - ---- - -## SECTION 4: AUTOMATION STAGE PROCESSING FIXES - -### Task 4.1: Verify Stage Sequential Processing Logic - -**Problem:** Pipeline not following strict sequential stage completion before moving to next stage. - -**Analysis Required:** -1. Review `AutomationService.start_automation()` method -2. Verify stage loop waits for 100% completion before `current_stage += 1` -3. Check for any parallel execution logic that bypasses sequential flow - -**Verification Steps:** -1. Each stage method (`run_stage_1()` to `run_stage_7()`) must return ONLY after ALL batches processed -2. Stage N+1 should NOT start if Stage N has `pending > 0` -3. Add explicit completion check before stage transition - -**Required Fixes:** -- Add validation: Before starting Stage N, verify Stage N-1 has 0 pending items -- If pending items found, log warning and halt automation -- Return error status with message: "Stage N-1 incomplete, cannot proceed to Stage N" - ---- - -### Task 4.2: Fix Batch Size Configuration Reading - -**Problem:** Manual "Run Now" only processes 5 keywords instead of respecting configured batch size (20). - -**Root Cause Analysis:** -1. Check if `run_stage_1()` reads from `AutomationConfig.stage_1_batch_size` -2. Verify query limit: `Keywords.objects.filter(...)[:batch_size]` uses correct variable -3. Confirm configuration loaded at automation start: `config = AutomationConfig.objects.get(site=self.site)` - -**Expected Behavior:** -- If queue has 7 keywords and batch_size = 20: Process all 7 (not limit to 5) -- If queue has 47 keywords and batch_size = 20: Process 20, then next batch of 20, then final 7 -- Batch size should be dynamic based on queue size: `min(queue_count, batch_size)` - -**Fix Implementation:** -1. Ensure configuration loaded once at automation start -2. Pass batch_size to each stage method -3. Update query to use: `[:min(pending_count, batch_size)]` -4. Log batch selection: "Processing batch 1/3: 20 keywords" - ---- - -### Task 4.3: Fix Stage 4 Processing Not Completing Full Queue - -**Problem:** Stage 4 (Tasks → Content) not processing all tasks before moving to Stage 5. - -**Investigation Steps:** -1. Check `run_stage_4()` implementation in `AutomationService` -2. Verify loop structure: Does it process tasks one-by-one until queue empty? -3. Look for premature loop exit conditions - -**Expected Logic:** -``` -While tasks with status='pending' exist: - 1. Get next task - 2. Call generate_content AI function - 3. Wait for completion - 4. Verify Content created - 5. Check if more pending tasks exist - 6. If yes, continue loop - 7. If no, return stage complete -``` - -**Common Issues to Check:** -- Loop exits after first task instead of continuing -- No loop at all - only processes one batch -- Error handling breaks loop prematurely - ---- - -### Task 4.4: Fix Stage 5 Not Triggering (Image Prompts Generation) - -**Problem:** Automation exits after Stage 4 without generating image prompts. - -**Analysis Required:** -1. Verify Stage 4 completion status set correctly -2. Check if Stage 5 start condition is met -3. Review database query in `run_stage_5()`: - - Query: Content with `status='draft'` AND `images_count=0` - - Verify Content records created in Stage 4 have correct status - -**Potential Issues:** -1. Content created with status other than 'draft' -2. Images count annotation incorrect (should use `annotate(images_count=Count('images'))`) -3. Stage handover logic doesn't trigger Stage 5 - -**Fix Steps:** -1. Verify Content model save in Stage 4 sets `status='draft'` -2. Ensure Stage 5 query matches Content records from Stage 4 -3. Add logging: "Stage 5: Found X content pieces without images" -4. If X > 0, process; if X = 0, skip stage gracefully - ---- - -### Task 4.5: Add Stage Handover Validation - -**New Logic Required Between Each Stage:** - -1. **Pre-Stage Validation:** - - Before starting Stage N, check Stage N-1 completion: - - Query pending items for Stage N-1 - - If pending > 0: Log error, halt automation - - If pending = 0: Log success, proceed - -2. **Post-Stage Validation:** - - After Stage N completes, verify: - - All input items processed - - Expected output items created - - No errors in stage result - - Log validation result before moving to Stage N+1 - -3. **Validation Logging:** - - Stage 1 → Stage 2: "Verified: 0 keywords pending, 8 clusters created" - - Stage 2 → Stage 3: "Verified: 0 clusters pending, 56 ideas created" - - Stage 3 → Stage 4: "Verified: 0 ideas pending, 56 tasks created" - - Stage 4 → Stage 5: "Verified: 0 tasks pending, 56 content pieces created" - - Stage 5 → Stage 6: "Verified: 0 content without images, 224 prompts created" - - Stage 6 → Stage 7: "Verified: 0 pending images, 224 images generated" - ---- - -### Task 4.6: Implement Dynamic Batch Size Logic - -**Problem:** Fixed batch sizes don't adapt to actual queue sizes. - -**Required Smart Batch Logic:** - -1. **For Stages 1, 2, 3, 5:** - - If `queue_count <= batch_size`: Process ALL items in one batch - - If `queue_count > batch_size`: Split into batches - -2. **For Stage 4 (Tasks → Content):** - - Always process one task at a time (sequential) - - Reason: Content generation is expensive, better control - - Batch size config for Stage 4 can be deprecated - -3. **For Stage 6 (Images):** - - Process one image at a time (current behavior) - - Reason: Image generation has rate limits - -**Configuration Update:** -- Stage 1-3, 5: Batch size applies -- Stage 4, 6: Batch size ignored (always 1) -- Update Configure modal to clarify batch size usage per stage - ---- - -## SECTION 5: STAGE CARD LAYOUT RESTRUCTURE - -### Task 5.1: Add Missing Stage 5 Card (Content → Image Prompts) - -**Problem:** Current UI combines Stages 3 & 4 into one card, Stage 5 missing. - -**Required Change:** -- Create separate card for Stage 5 -- Display: "Content → Image Prompts" -- Queue metrics: Content without images (not total content) -- Show progress bar for prompt extraction - ---- - -### Task 5.2: Separate Stages 3 & 4 into Individual Cards - -**Current:** One card shows "Ideas → Tasks → Content" with nested metrics. - -**New Structure:** -1. **Stage 3 Card:** "Ideas → Tasks" - - Total Queue: Ideas with `status='new'` - - Processed: Ideas converted to tasks - - Progress: Task creation count - -2. **Stage 4 Card:** "Tasks → Content" - - Total Queue: Tasks with `status='pending'` - - Processed: Tasks with `status='completed'` - - Progress: Content generation count - ---- - -### Task 5.3: Restructure Stage Card Rows - -**New Layout Requirements:** - -**Row 1 (Stages 1-4):** -- Stage 1: Keywords → Clusters -- Stage 2: Clusters → Ideas -- Stage 3: Ideas → Tasks -- Stage 4: Tasks → Content - -**Row 2 (Stages 5-8):** -- Stage 5: Content → Image Prompts -- Stage 6: Image Prompts → Images -- Stage 7: Review Gate (with action buttons) -- Stage 8: Status Summary (new informational card) - -**Responsive Behavior:** -- Desktop: 4 cards per row -- Tablet: 2 cards per row -- Mobile: 1 card per row (vertical stack) - ---- - -### Task 5.4: Design Stage 7 Card (Review Gate) - -**Unique Requirements:** -1. **Visual Distinction:** - - Different color scheme (amber/orange warning color) - - Icon: Stop sign or review icon - - Border: Dashed or highlighted - -2. **Content:** - - Title: "Manual Review Gate" - - Status: "Automation Stops Here" - - Count: Number of content pieces ready for review - - Two buttons: - - "Go to Review Page" (navigates to Writer Content page filtered by status='review') - - "Publish Without Review" (disabled initially, placeholder for future feature) - -3. **Button Behavior:** - - Review button: Active when count > 0 - - Publish button: Disabled with tooltip "Coming soon" - ---- - -### Task 5.5: Design Stage 8 Card (Status Summary) - -**New Informational Card:** - -**Purpose:** Display current automation run status without queue processing. - -**Content:** -1. **Title:** "Current Status" -2. **Large Status Icon:** Based on run status - - Running: Animated spinner - - Completed: Checkmark - - Failed: X icon - - Paused: Pause icon - -3. **Metrics Display:** - - Run ID - - Started at timestamp - - Current stage indicator - - Total credits used - - Completion percentage - -4. **Visual Style:** - - No queue metrics - - No action buttons - - Read-only information display - - Distinct styling (different background color, no hover effects) - ---- - -### Task 5.6: Adjust Card Width for New Layout - -**Current:** Stage cards likely using equal width across full viewport. - -**New Requirements:** -- Row 1 (4 cards): Each card 23% width (with 2% gap) -- Row 2 (4 cards): Same width distribution -- Stage 8 card: Can be wider or styled differently as summary card - -**Implementation Considerations:** -- Use CSS Grid or Flexbox for responsive layout -- Ensure consistent spacing between cards -- Maintain card aspect ratio for visual balance - ---- - -## SECTION 6: ADDITIONAL ENHANCEMENTS - -### Task 6.1: Add Credit Usage Tracking per Stage - -**Value Addition:** Real-time visibility into credit consumption. - -**Implementation:** -1. Track credits used at end of each stage in `stage_N_result` JSON field -2. Display in stage card: "Credits Used: X" -3. Add running total in overall pipeline progress bar -4. Compare estimated vs actual credits used -5. Alert if actual exceeds estimated by >20% - ---- - -### Task 6.2: Add Estimated Completion Time per Stage - -**Value Addition:** Predictable automation runtime for planning. - -**Implementation:** -1. Calculate average time per item based on historical runs -2. Estimate: `Remaining Items * Average Time per Item` -3. Display in stage card: "ETA: 45 minutes" -4. Update dynamically as items process -5. Store metrics in database for accuracy improvement over time - ---- - - -### Task 6.3: Add Error Rate Monitoring - -**Value Addition:** Proactive issue detection. - -**Implementation:** -1. Track error count per stage -2. Display: "Errors: X (Y%)" -3. Highlight stages with >5% error rate -4. Add "View Errors" button to navigate to error log -5. Set up alerts for error rate spikes - ---- - -### Task 6.4: Add Stage Completion Percentage - -**Value Addition:** Clear progress visualization. - -**Implementation:** -1. Calculate: `(Completed Items / Total Items) * 100` -2. Display as progress bar in stage card -3. Color code: - - Green: >75% - - Yellow: 25-75% - - Red: <25% -4. Animate progress bar during active stages -5. Show exact percentage in text format - ---- - -### Task 6.5: Add Stage Start/End Timestamps - -**Value Addition:** Audit trail for automation runs. - -**Implementation:** -1. Store start/end timestamps in `stage_N_result` -2. Display in stage card: "Started: 10:30 AM | Ended: 11:15 AM" -3 diff --git a/TEST_ENDPOINTS.md b/TEST_ENDPOINTS.md deleted file mode 100644 index f9217776..00000000 --- a/TEST_ENDPOINTS.md +++ /dev/null @@ -1,190 +0,0 @@ -# Backend API Endpoints - Test Results - -**Test Date:** December 5, 2025 -**Backend URL:** http://localhost:8011 - -## ✅ WORKING ENDPOINTS - -### Billing API Endpoints - -| Endpoint | Method | Status | Notes | -|----------|--------|--------|-------| -| `/api/v1/billing/invoices/` | GET | ✅ 401 | Auth required (correct) | -| `/api/v1/billing/payments/` | GET | ✅ 401 | Auth required (correct) | -| `/api/v1/billing/credit-packages/` | GET | ✅ 401 | Auth required (correct) | -| `/api/v1/billing/transactions/` | GET | ✅ 401 | Auth required (correct) | -| `/api/v1/billing/transactions/balance/` | GET | ✅ 401 | Auth required (correct) | -| `/api/v1/billing/admin/stats/` | GET | ✅ 401 | Auth required (correct) | - -### Account Endpoints - -| Endpoint | Method | Status | Notes | -|----------|--------|--------|-------| -| `/api/v1/account/settings/` | GET | ✅ 401 | Auth required (correct) | -| `/api/v1/account/settings/` | PATCH | ✅ 401 | Auth required (correct) | -| `/api/v1/account/team/` | GET | ✅ 401 | Auth required (correct) | -| `/api/v1/account/usage/analytics/` | GET | ✅ 401 | Auth required (correct) | - -## ❌ ISSUES FIXED - -### Frontend API Path Alignment -**Problem:** Frontend must always call the canonical `/api/v1/billing/...` endpoints (no `/v2` alias). - -**Files Fixed:** -- `frontend/src/services/billing.api.ts` – ensured all billing calls use `/v1/billing/...` - -**Changes:** -```typescript -// Before: -fetchAPI('/billing/invoices/') - -// After: -fetchAPI('/v1/billing/invoices/') -``` - -### Component Export Issues -**Problem:** `PricingPlan` type export conflict - -**File Fixed:** -- `frontend/src/components/ui/pricing-table/index.tsx` - -**Change:** -```typescript -// Before: -export { PricingPlan }; - -// After: -export type { PricingPlan }; -``` - -### Missing Function Issues -**Problem:** `submitManualPayment` doesn't exist, should be `createManualPayment` - -**File Fixed:** -- `frontend/src/pages/account/PurchaseCreditsPage.tsx` - -**Change:** -```typescript -// Import changed: -import { submitManualPayment } from '...' // ❌ -import { createManualPayment } from '...' // ✅ - -// Usage changed: -await submitManualPayment({...}) // ❌ -await createManualPayment({...}) // ✅ -``` - -## 📝 PAGES STATUS - -### Account Pages -| Page | Route | Status | Backend API | -|------|-------|--------|-------------| -| Account Settings | `/account/settings` | ✅ Ready | `/v1/account/settings/` | -| Team Management | `/account/team` | ✅ Ready | `/v1/account/team/` | -| Usage Analytics | `/account/usage` | ✅ Ready | `/v1/account/usage/analytics/` | -| Purchase Credits | `/account/purchase-credits` | ✅ Ready | `/v1/billing/credit-packages/` | - -### Billing Pages -| Page | Route | Status | Backend API | -|------|-------|--------|-------------| -| Credits Overview | `/billing/credits` | ✅ Ready | `/v1/billing/transactions/balance/` | -| Transactions | `/billing/transactions` | ✅ Ready | `/v1/billing/transactions/` | -| Usage | `/billing/usage` | ✅ Ready | `/v1/billing/transactions/` | -| Plans | `/settings/plans` | ✅ Ready | `/v1/auth/plans/` | - -### Admin Pages -| Page | Route | Status | Backend API | -|------|-------|--------|-------------| -| Admin Dashboard | `/admin/billing` | ⏳ Partial | `/v1/billing/admin/stats/` | -| Billing Management | `/admin/billing` | ⏳ Partial | Multiple endpoints | - -## 🔧 URL STRUCTURE - -### Correct URL Pattern -``` -Frontend calls: /v1/billing/invoices/ - ↓ -API Base URL: https://api.igny8.com/api - ↓ -Full URL: https://api.igny8.com/api/v1/billing/invoices/ - ↓ -Backend route: /api/v1/billing/ → igny8_core.business.billing.urls -``` - -### API Base URL Detection -```typescript -// frontend/src/services/api.ts -const API_BASE_URL = getApiBaseUrl(); - -// Returns: -// - localhost:3000 → http://localhost:8011/api -// - Production → https://api.igny8.com/api -``` - -## ✅ BUILD STATUS - -```bash -cd /data/app/igny8/frontend -npm run build -# ✅ built in 10.87s -``` - -## 🧪 TESTING CHECKLIST - -### Backend Tests -- [x] Invoices endpoint exists (401 auth required) -- [x] Payments endpoint exists (401 auth required) -- [x] Credit packages endpoint exists (401 auth required) -- [x] Transactions endpoint exists (401 auth required) -- [x] Balance endpoint exists (401 auth required) -- [x] Account settings endpoint exists (401 auth required) -- [x] Team management endpoint exists (401 auth required) -- [x] Usage analytics endpoint exists (401 auth required) - -### Frontend Tests -- [x] Build completes without errors -- [x] All API imports resolve correctly -- [x] Component exports work correctly -- [ ] Pages load in browser (requires authentication) -- [ ] API calls work with auth token -- [ ] Data displays correctly - -## 🚀 NEXT STEPS - -1. **Test with Authentication** - - Login to app - - Navigate to each page - - Verify data loads correctly - -2. **Test User Flows** - - Purchase credits flow - - View transactions - - Manage team members - - Update account settings - -3. **Test Admin Features** - - View billing stats - - Approve/reject payments - - Configure credit costs - -4. **Missing Features** - - Stripe payment integration (webhook handlers exist, UI integration pending) - - PDF invoice generation - - Email notifications - - Subscription management UI - -## 📚 DOCUMENTATION - -### For Users -- All account and billing pages accessible from sidebar -- Credit balance visible on Credits page -- Purchase credits via credit packages -- View transaction history -- Manage team members - -### For Developers -- Backend: Django REST Framework ViewSets -- Frontend: React + TypeScript + Vite -- API calls: Centralized in `services/billing.api.ts` -- Auth: JWT tokens in localStorage -- Multi-tenancy: Account-based access control diff --git a/approved-docs/00-SYSTEM-ARCHITECTURE-MASTER-REFERENCE.md b/approved-docs/00-SYSTEM-ARCHITECTURE-MASTER-REFERENCE.md deleted file mode 100644 index d3f06040..00000000 --- a/approved-docs/00-SYSTEM-ARCHITECTURE-MASTER-REFERENCE.md +++ /dev/null @@ -1,1984 +0,0 @@ -# IGNY8 System Master Reference - -**Version:** 1.0.0 -**Last Updated:** November 24, 2025 -**Purpose:** Complete system architecture and navigation reference for the IGNY8 SaaS platform and WordPress integration plugin. - ---- - -## Table of Contents - -1. [System Overview](#system-overview) -2. [Architecture](#architecture) -3. [Backend Deep Dive](#backend-deep-dive) -4. [Frontend Deep Dive](#frontend-deep-dive) -5. [WordPress Plugin Deep Dive](#wordpress-plugin-deep-dive) -6. [Integration Architecture](#integration-architecture) -7. [AI Framework](#ai-framework) -8. [Data Models](#data-models) -9. [API Reference](#api-reference) -10. [Workflows](#workflows) -11. [Developer Navigation Map](#developer-navigation-map) -12. [Troubleshooting Map](#troubleshooting-map) - ---- - -## System Overview - -IGNY8 is a full-stack AI-powered SEO content management platform consisting of three main components: - -1. **IGNY8 SaaS Application** - Multi-tenant Django + React application -2. **WordPress Bridge Plugin** - Bidirectional sync between WordPress and IGNY8 -3. **Sites Renderer** - Public-facing site deployment system - -### Core Capabilities - -| Capability | Implementation | Status | -|------------|----------------|--------| -| **Multi-Tenancy** | Account → Site → Sector hierarchy | ✅ Live | -| **Keyword Management** | Planner module (Keywords, Clusters, Ideas) | ✅ Live | -| **AI Content Generation** | Writer module with OpenAI integration | ✅ Live | -| **Image Generation** | AI-powered via DALL-E and Runware | ✅ Live | -| **WordPress Publishing** | Bidirectional sync via REST API | ✅ Live | -| **Internal Linking** | Linker module for SEO optimization | ✅ Live | -| **Content Optimization** | Optimizer module for scoring | ✅ Live | -| **Automation** | Scheduled tasks and rules | ✅ Live | -| **Credit System** | Usage-based billing | ✅ Live | - ---- - -## Architecture - -### High-Level System Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ User Layer │ -├─────────────────────────────────────────────────────────────────┤ -│ React Frontend (app.igny8.com) │ WordPress Site (client) │ -│ - Vite 6.1.0 + React 19 │ - WP Bridge Plugin │ -│ - Zustand State Management │ - REST API Endpoints │ -│ - React Router v7 │ - Bidirectional Sync │ -└─────────────────────┬───────────────────────────┬───────────────┘ - │ │ - ▼ ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ API Gateway (Django) │ -├─────────────────────────────────────────────────────────────────┤ -│ - JWT Authentication │ -│ - Unified Response Format (success, data, message, request_id) │ -│ - Rate Limiting & Throttling │ -│ - Account Context Middleware │ -└─────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Business Logic Layer │ -├─────────────────────────────────────────────────────────────────┤ -│ Modules (ViewSets) │ Business Logic (Services) │ -│ ─────────────────────────── │ ──────────────────────────── │ -│ • Planner │ • Planning Services │ -│ • Writer │ • Content Services │ -│ • Linker │ • Linking Services │ -│ • Optimizer │ • Optimization Services │ -│ • Publisher │ • Publishing Services │ -│ • Automation │ • Automation Services │ -│ • Integration │ • Integration Services │ -│ • System │ • System Settings │ -│ • Billing │ • Credit Services │ -└─────────────────────┬───────────────────┬───────────────────────┘ - │ │ - ▼ ▼ -┌──────────────────────────────┐ ┌──────────────────────────────┐ -│ AI Framework (Celery) │ │ Database (PostgreSQL) │ -├──────────────────────────────┤ ├──────────────────────────────┤ -│ • AIEngine │ │ • Account Isolation │ -│ • AICore │ │ • Site/Sector Scoping │ -│ • AI Functions Registry │ │ • Relational Models │ -│ • Progress Tracking │ │ • JSON Fields for Config │ -│ • Cost Tracking │ │ • Indexed Queries │ -└──────────────────────────────┘ └──────────────────────────────┘ -``` - -### Data Hierarchy - -``` -Account (Tenant) -├── Users (with roles) -├── Plan & Subscription -├── Credit Balance -└── Sites (1-N based on plan) - ├── Industry (optional) - ├── WordPress Integration (SiteIntegration) - └── Sectors (1-5 per site) - ├── Keywords - ├── Clusters - ├── Content Ideas - ├── Tasks - ├── Content - └── Images -``` - ---- - -## Backend Deep Dive - -### Technology Stack - -- **Framework:** Django 5.2+ -- **API:** Django REST Framework (DRF) with drf-spectacular -- **Database:** PostgreSQL -- **Task Queue:** Celery 5.3+ with Redis broker -- **Authentication:** JWT (PyJWT 2.8+) -- **AI:** OpenAI API, Runware API - -### Project Structure - -``` -backend/ -├── igny8_core/ # Django project root -│ ├── settings.py # Django configuration -│ ├── urls.py # Root URL routing -│ ├── celery.py # Celery configuration -│ ├── wsgi.py # WSGI entry point -│ ├── asgi.py # ASGI entry point (future) -│ │ -│ ├── auth/ # Multi-tenancy & authentication -│ │ ├── models.py # Account, User, Plan, Site, Sector -│ │ ├── views.py # Auth ViewSets -│ │ ├── serializers.py # Auth serializers -│ │ ├── middleware.py # AccountContextMiddleware -│ │ └── urls.py # /api/v1/auth/ routes -│ │ -│ ├── modules/ # Feature modules (ViewSets) -│ │ ├── planner/ # Keywords, Clusters, Ideas -│ │ │ ├── views.py # KeywordViewSet, ClusterViewSet, ContentIdeasViewSet -│ │ │ ├── serializers.py # Serializers -│ │ │ ├── urls.py # /api/v1/planner/ routes -│ │ │ └── apps.py # App config -│ │ │ -│ │ ├── writer/ # Tasks, Content, Images -│ │ │ ├── views.py # TasksViewSet, ContentViewSet, ImagesViewSet -│ │ │ ├── serializers.py # Serializers -│ │ │ └── urls.py # /api/v1/writer/ routes -│ │ │ -│ │ ├── linker/ # Internal linking -│ │ │ ├── views.py # LinkerViewSet -│ │ │ └── urls.py # /api/v1/linker/ routes -│ │ │ -│ │ ├── optimizer/ # Content optimization -│ │ │ ├── views.py # OptimizerViewSet -│ │ │ └── urls.py # /api/v1/optimizer/ routes -│ │ │ -│ │ ├── publisher/ # Publishing & deployment -│ │ │ ├── views.py # PublisherViewSet, PublishingRecordViewSet -│ │ │ └── urls.py # /api/v1/publisher/ routes -│ │ │ -│ │ ├── automation/ # Automation rules & tasks -│ │ │ ├── views.py # AutomationRuleViewSet, ScheduledTaskViewSet -│ │ │ └── urls.py # /api/v1/automation/ routes -│ │ │ -│ │ ├── integration/ # External platform integrations -│ │ │ ├── views.py # IntegrationViewSet -│ │ │ └── urls.py # /api/v1/integration/ routes -│ │ │ -│ │ ├── system/ # Settings, prompts, integrations -│ │ │ ├── models.py # AIPrompt, IntegrationSettings, AuthorProfile -│ │ │ ├── views.py # AIPromptViewSet, AuthorProfileViewSet -│ │ │ ├── settings_views.py # SystemSettingsViewSet -│ │ │ └── urls.py # /api/v1/system/ routes -│ │ │ -│ │ └── billing/ # Credits & transactions -│ │ ├── views.py # CreditBalanceViewSet, CreditTransactionViewSet -│ │ ├── services.py # CreditService -│ │ └── urls.py # /api/v1/billing/ routes -│ │ -│ ├── business/ # Business logic & models -│ │ ├── planning/ # Planner models -│ │ │ └── models.py # Keywords, Clusters, ContentIdeas -│ │ ├── content/ # Writer models -│ │ │ └── models.py # Tasks, Content, Images -│ │ ├── linking/ # Linker services -│ │ ├── optimization/ # Optimizer services -│ │ ├── publishing/ # Publisher models -│ │ ├── automation/ # Automation models -│ │ ├── integration/ # Integration models -│ │ │ ├── models.py # SiteIntegration -│ │ │ └── services/ # IntegrationService, SyncService -│ │ └── billing/ # Billing models -│ │ -│ ├── ai/ # AI Framework -│ │ ├── engine.py # AIEngine - Central orchestrator -│ │ ├── ai_core.py # AICore - API calls & model selection -│ │ ├── base.py # BaseAIFunction - Function interface -│ │ ├── registry.py # AI function registry -│ │ ├── prompts.py # PromptRegistry -│ │ ├── tracker.py # Progress & cost tracking -│ │ ├── tasks.py # run_ai_task Celery wrapper -│ │ ├── settings.py # Model configuration -│ │ ├── validators.py # Validation functions -│ │ ├── constants.py # AI constants -│ │ └── functions/ # AI function implementations -│ │ ├── auto_cluster.py # Keyword clustering -│ │ ├── generate_ideas.py # Content idea generation -│ │ ├── generate_content.py # Blog post generation -│ │ ├── generate_image_prompts.py # Image prompt extraction -│ │ └── generate_images.py # Image generation (DALL-E/Runware) -│ │ -│ ├── api/ # API base classes -│ │ ├── base.py # AccountModelViewSet, SiteSectorModelViewSet -│ │ ├── response.py # success_response, error_response -│ │ ├── pagination.py # CustomPageNumberPagination -│ │ ├── permissions.py # Custom permissions -│ │ ├── throttles.py # Rate limiting -│ │ └── exception_handlers.py # Unified error handling -│ │ -│ ├── middleware/ # Custom middleware -│ │ ├── request_id.py # RequestIDMiddleware -│ │ ├── resource_tracker.py # ResourceTrackingMiddleware -│ │ └── __init__.py -│ │ -│ ├── utils/ # Shared utilities -│ │ ├── wordpress.py # WordPress helpers -│ │ ├── content_normalizer.py # Content processing -│ │ ├── queue_manager.py # Queue utilities -│ │ └── ai_processor.py # Legacy AI utilities -│ │ -│ └── admin/ # Django admin customization -│ ├── base.py # BaseAdmin -│ └── site.py # Admin site configuration -│ -├── manage.py # Django management CLI -├── requirements.txt # Python dependencies -└── Dockerfile # Docker configuration -``` - -### Core Base Classes - -#### AccountModelViewSet -**Location:** `igny8_core/api/base.py` - -Automatic account filtering for all resources. Used by modules that don't need site/sector filtering. - -```python -class AccountModelViewSet(ModelViewSet): - """Filters queryset by request.account automatically""" - - def get_queryset(self): - return super().get_queryset().filter(account=self.request.account) -``` - -**Used by:** System, Billing, Auth modules - -#### SiteSectorModelViewSet -**Location:** `igny8_core/api/base.py` - -Automatic filtering by account, site, and sector. Used by content-related modules. - -```python -class SiteSectorModelViewSet(ModelViewSet): - """Filters by account, site, and sector from query params""" - - def get_queryset(self): - queryset = super().get_queryset() - site_id = self.request.query_params.get('site_id') - sector_id = self.request.query_params.get('sector_id') - # ... filtering logic -``` - -**Used by:** Planner, Writer, Publisher, Automation, Integration modules - -### Middleware Stack - -1. **RequestIDMiddleware** - Assigns unique ID to each request -2. **AccountContextMiddleware** - Sets `request.account` from JWT token -3. **ResourceTrackingMiddleware** - Tracks API usage metrics - ---- - -## Frontend Deep Dive - -### Technology Stack - -- **Framework:** React 19.0.0 -- **Build Tool:** Vite 6.1.0 -- **Routing:** React Router v7.9.5 -- **State:** Zustand 5.0.8 -- **Styling:** Tailwind CSS 4.0.8 -- **Charts:** ApexCharts 4.1.0 -- **Icons:** Heroicons 2.2.0 - -### Project Structure - -``` -frontend/ -├── src/ -│ ├── main.tsx # Application entry point -│ ├── App.tsx # Main app with routing -│ ├── index.css # Global styles -│ │ -│ ├── api/ # API client layer -│ │ ├── client.ts # Axios instance with interceptors -│ │ ├── auth.ts # Auth API calls -│ │ ├── planner.ts # Planner API calls -│ │ ├── writer.ts # Writer API calls -│ │ └── [module].ts # Other module APIs -│ │ -│ ├── store/ # Zustand state management -│ │ ├── authStore.ts # Auth state (user, tokens) -│ │ ├── siteStore.ts # Site context -│ │ ├── moduleStore.ts # Module-specific state -│ │ └── globalStore.ts # Global app state -│ │ -│ ├── pages/ # Page components -│ │ ├── AuthPages/ -│ │ │ ├── SignIn.tsx -│ │ │ └── SignUp.tsx -│ │ ├── Dashboard/ -│ │ │ └── Home.tsx -│ │ ├── Planner/ -│ │ │ ├── Dashboard.tsx -│ │ │ ├── Keywords.tsx -│ │ │ ├── Clusters.tsx -│ │ │ └── Ideas.tsx -│ │ ├── Writer/ -│ │ │ ├── Dashboard.tsx -│ │ │ ├── Tasks.tsx -│ │ │ ├── Content.tsx -│ │ │ ├── ContentView.tsx -│ │ │ ├── Drafts.tsx -│ │ │ ├── Images.tsx -│ │ │ └── Published.tsx -│ │ ├── Linker/ -│ │ │ ├── Dashboard.tsx -│ │ │ └── ContentList.tsx -│ │ ├── Optimizer/ -│ │ │ ├── Dashboard.tsx -│ │ │ ├── ContentSelector.tsx -│ │ │ └── AnalysisPreview.tsx -│ │ ├── Thinker/ # System settings -│ │ │ ├── Dashboard.tsx -│ │ │ ├── Prompts.tsx -│ │ │ ├── AuthorProfiles.tsx -│ │ │ └── Strategies.tsx -│ │ ├── Billing/ -│ │ │ ├── Credits.tsx -│ │ │ ├── Transactions.tsx -│ │ │ └── Usage.tsx -│ │ ├── Automation/ -│ │ │ ├── Dashboard.tsx -│ │ │ ├── Rules.tsx -│ │ │ └── Tasks.tsx -│ │ └── Settings/ -│ │ ├── General.tsx -│ │ ├── Users.tsx -│ │ ├── Subscriptions.tsx -│ │ ├── System.tsx -│ │ └── AI.tsx -│ │ -│ ├── components/ # Reusable components -│ │ ├── common/ # Common UI components -│ │ ├── auth/ # Auth components -│ │ ├── forms/ # Form components -│ │ └── [module]/ # Module-specific components -│ │ -│ ├── layout/ # Layout components -│ │ ├── AppLayout.tsx # Main app layout -│ │ ├── Sidebar.tsx # Navigation sidebar -│ │ ├── Header.tsx # Top header -│ │ └── Footer.tsx # Footer -│ │ -│ ├── hooks/ # Custom React hooks -│ │ ├── useAuth.ts # Auth hook -│ │ ├── useApi.ts # API call hook -│ │ └── useSite.ts # Site context hook -│ │ -│ ├── services/ # Business logic -│ │ ├── authService.ts # Auth logic -│ │ └── apiService.ts # API utilities -│ │ -│ ├── config/ # Configuration -│ │ └── constants.ts # App constants -│ │ -│ └── types/ # TypeScript types -│ ├── api.ts # API types -│ └── models.ts # Data model types -│ -├── public/ # Static assets -├── package.json # Node dependencies -├── vite.config.ts # Vite configuration -├── tsconfig.json # TypeScript configuration -└── tailwind.config.js # Tailwind configuration -``` - -### State Management (Zustand) - -**Auth Store** (`store/authStore.ts`) -```typescript -interface AuthState { - user: User | null; - token: string | null; - refreshToken: string | null; - isAuthenticated: boolean; - login: (email: string, password: string) => Promise; - logout: () => void; - refreshAccessToken: () => Promise; -} -``` - -**Site Store** (`store/siteStore.ts`) -```typescript -interface SiteState { - currentSite: Site | null; - currentSector: Sector | null; - sites: Site[]; - setSite: (site: Site) => void; - setSector: (sector: Sector) => void; -} -``` - ---- - -## WordPress Plugin Deep Dive - -### Plugin Structure - -``` -igny8-wp-integration/ -├── igny8-bridge.php # Main plugin file & bootstrap -├── uninstall.php # Cleanup on uninstall -│ -├── includes/ # Core classes -│ ├── class-igny8-api.php # API client (authentication, requests) -│ ├── class-igny8-rest-api.php # WP REST endpoints (/wp-json/igny8/v1/) -│ ├── class-igny8-site.php # Site data collection -│ ├── class-igny8-webhooks.php # Webhook handlers (incoming from IGNY8) -│ ├── class-igny8-webhook-logs.php # Webhook logging -│ ├── class-igny8-link-queue.php # Link processing queue -│ └── functions.php # Helper functions -│ -├── admin/ # WordPress admin UI -│ ├── class-admin.php # Settings page controller -│ ├── settings.php # Settings page UI -│ ├── class-admin-columns.php # Custom post columns -│ ├── class-post-meta-boxes.php # Post editor meta boxes -│ └── assets/ -│ ├── css/admin.css # Admin styles -│ └── js/ -│ ├── admin.js # Admin scripts -│ └── post-editor.js # Post editor enhancements -│ -├── sync/ # Synchronization logic -│ ├── hooks.php # WordPress action/filter hooks -│ ├── post-sync.php # WP → IGNY8 post sync -│ ├── taxonomy-sync.php # WP → IGNY8 taxonomy sync -│ └── igny8-to-wp.php # IGNY8 → WP content sync -│ -├── data/ # Data collection & mapping -│ ├── site-collection.php # Full site data collection -│ ├── semantic-mapping.php # Semantic metadata extraction -│ ├── link-graph.php # Internal link graph builder -│ └── woocommerce.php # WooCommerce integration -│ -├── docs/ # Plugin documentation -│ ├── WORDPRESS-PLUGIN-INTEGRATION.md -│ ├── AUTHENTICATION-AUDIT.md -│ └── SYNC-DATA-FLOW-DIAGRAM.md -│ -└── tests/ # Test scripts - ├── test-api-authentication.php - ├── test-site-metadata.php - └── test-sync-structure.php -``` - -### Core Classes - -#### Igny8API -**File:** `includes/class-igny8-api.php` - -Handles all API communication with IGNY8 backend. - -**Key Methods:** -- `connect($api_key, $site_id)` - Establish connection using API key -- `get($endpoint)` - GET request -- `post($endpoint, $data)` - POST request -- `put($endpoint, $data)` - PUT request -- `delete($endpoint)` - DELETE request -- `parse_response($response)` - Normalize API responses - -**Authentication:** -- Primary: API key (stored in `igny8_api_key` option) -- Sends as `X-IGNY8-API-Key` header -- Automatic retry on 401 errors - -#### Igny8RestAPI -**File:** `includes/class-igny8-rest-api.php` - -Exposes WordPress data via REST API for IGNY8 to consume. - -**Endpoints:** -- `GET /wp-json/igny8/v1/site-metadata/` - Site structure and metadata -- `GET /wp-json/igny8/v1/post-by-task-id/{task_id}` - Find post by IGNY8 task ID -- `GET /wp-json/igny8/v1/post-by-content-id/{content_id}` - Find post by content ID -- `POST /wp-json/igny8/v1/posts/` - Create post from IGNY8 content -- `PUT /wp-json/igny8/v1/posts/{id}` - Update post from IGNY8 - -**Authentication:** -- Accepts `X-IGNY8-API-Key` header -- Validates against stored API key - -#### Igny8Site -**File:** `includes/class-igny8-site.php` - -Collects and formats site data for IGNY8. - -**Key Methods:** -- `get_site_metadata()` - Collect post types, taxonomies, counts -- `get_posts_data($post_type, $limit)` - Export posts -- `get_taxonomy_data($taxonomy, $limit)` - Export taxonomy terms -- `send_to_igny8()` - Push site data to IGNY8 API - -### Sync Flow - -#### WP → IGNY8 (Post Status Updates) - -1. User edits/publishes post in WordPress -2. `save_post` hook triggers → `sync/post-sync.php` -3. Check if post has `_igny8_task_id` meta -4. Map WP status to IGNY8 status -5. Send PUT request to `/api/v1/writer/tasks/{task_id}/` -6. Update `_igny8_last_synced` meta - -#### IGNY8 → WP (Content Publishing) - -1. IGNY8 creates content via AI Writer -2. IGNY8 sends POST to `/wp-json/igny8/v1/posts/` -3. Plugin validates API key -4. Create WP post with content -5. Store `_igny8_task_id` and `_igny8_content_id` meta -6. Return post ID and URL to IGNY8 - -### Post Meta Fields - -All IGNY8-managed posts use prefixed meta keys: - -- `_igny8_task_id` - IGNY8 task ID -- `_igny8_content_id` - IGNY8 content ID -- `_igny8_wordpress_status` - Current WP status -- `_igny8_last_synced` - Last sync timestamp -- `_igny8_managed` - Flag for IGNY8-managed posts - ---- - -## Integration Architecture - -### WordPress ↔ IGNY8 Integration - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ WordPress Site │ -├─────────────────────────────────────────────────────────────────┤ -│ IGNY8 Bridge Plugin │ -│ ───────────────────────────────────────────────────────────── │ -│ 1. REST API Endpoints (/wp-json/igny8/v1/) │ -│ • site-metadata (expose WP structure) │ -│ • posts (CRUD operations) │ -│ • post-by-task-id (lookup) │ -│ │ -│ 2. Sync Handlers (sync/) │ -│ • post-sync.php (WP → IGNY8) │ -│ • igny8-to-wp.php (IGNY8 → WP) │ -│ │ -│ 3. API Client (Igny8API class) │ -│ • Authentication (API key) │ -│ • Request/Response handling │ -└─────────────────────┬───────────────────────────────────────────┘ - │ - │ HTTPS REST API - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ IGNY8 Backend (Django) │ -├─────────────────────────────────────────────────────────────────┤ -│ Integration Module (/api/v1/integration/) │ -│ ───────────────────────────────────────────────────────────── │ -│ 1. IntegrationViewSet │ -│ • test-connection (validate WP connection) │ -│ • update-structure (receive WP metadata) │ -│ • sync (bidirectional sync trigger) │ -│ │ -│ 2. SiteIntegration Model │ -│ • platform: 'wordpress' │ -│ • credentials_json: {api_key, username, app_password} │ -│ • config_json: {site_url, content_types, sync_settings} │ -│ │ -│ 3. Integration Services │ -│ • IntegrationService (connection testing) │ -│ • SyncService (bidirectional sync) │ -│ • ContentSyncService (content mapping) │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### Authentication Flow - -**Plugin → Backend:** -1. Plugin stores API key in WP options (`igny8_api_key`) -2. All API requests include `X-IGNY8-API-Key` header -3. Backend validates key against `Site.wp_api_key` field -4. Backend sets account context from API key - -**Backend → Plugin:** -1. Backend sends requests to WP REST endpoints -2. Includes `X-IGNY8-API-Key` header -3. Plugin validates key matches stored value -4. Plugin processes request and returns unified response - ---- - -## AI Framework - -### Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ AI Framework Entry Point │ -├─────────────────────────────────────────────────────────────────┤ -│ run_ai_task(function_name, payload, account, celery_task) │ -│ • Called from ViewSets for async AI operations │ -│ • Wraps AI execution in Celery task │ -└─────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ AIEngine │ -│ Location: igny8_core/ai/engine.py │ -├─────────────────────────────────────────────────────────────────┤ -│ • Central orchestrator for all AI functions │ -│ • Progress tracking (Celery task updates) │ -│ • Cost tracking (token usage) │ -│ • Retry logic │ -│ • Error handling │ -│ │ -│ Key Methods: │ -│ • execute(function_name, payload) → result │ -│ • _execute_function(func, data) → processes function │ -│ • _update_progress(step, status, message) → UI updates │ -└─────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ AI Function Registry │ -│ Location: igny8_core/ai/registry.py │ -├─────────────────────────────────────────────────────────────────┤ -│ Registered Functions: │ -│ • auto_cluster (keywords → clusters) │ -│ • generate_ideas (clusters → content ideas) │ -│ • generate_content (ideas → blog posts) │ -│ • generate_image_prompts (content → image prompts) │ -│ • generate_images (prompts → images) │ -└─────────────────────┬───────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ AICore │ -│ Location: igny8_core/ai/ai_core.py │ -├─────────────────────────────────────────────────────────────────┤ -│ • Handles API calls to OpenAI/Runware │ -│ • Model selection and configuration │ -│ • Response parsing (JSON/text) │ -│ • Console logging │ -│ • Token counting and cost calculation │ -│ │ -│ Key Methods: │ -│ • run_ai_request(prompt, model, ...) → AI response │ -│ • generate_image(prompt, model, ...) → image URL │ -│ • _call_openai_api(messages, model, ...) → raw response │ -│ • _parse_json_response(text) → parsed JSON │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### AI Functions - -#### auto_cluster -**File:** `ai/functions/auto_cluster.py` -**Input:** List of keyword IDs -**Output:** Clusters with assigned keywords -**Model:** GPT-4 or configured model -**Process:** -1. Load keywords from database -2. Build prompt with keyword list -3. Call AI to group semantically similar keywords -4. Parse JSON response -5. Create Cluster records -6. Assign keywords to clusters - -#### generate_ideas -**File:** `ai/functions/generate_ideas.py` -**Input:** List of cluster IDs -**Output:** Content ideas for each cluster -**Model:** GPT-4 or configured model -**Process:** -1. Load clusters and keywords -2. Build prompt for content ideation -3. Call AI to generate ideas -4. Parse response -5. Create ContentIdea records - -#### generate_content -**File:** `ai/functions/generate_content.py` -**Input:** List of task IDs -**Output:** Full blog posts with HTML -**Model:** GPT-4 or configured model -**Process:** -1. Load tasks and content ideas -2. Build detailed prompt with brief -3. Call AI to generate article -4. Parse HTML content -5. Create/update Content records -6. Update task status - -#### generate_images -**File:** `ai/functions/generate_images.py` -**Input:** List of task IDs with image prompts -**Output:** Generated image URLs -**Models:** DALL-E 3 or Runware -**Process:** -1. Load tasks with image prompts -2. For each prompt, call image API -3. Upload images to storage -4. Create Image records -5. Link images to content - ---- - -## Data Models - -### Account & Auth Models - -**Account** (`auth/models.py`) -```python -Account -├── name: str -├── slug: str (unique) -├── owner: User (FK) -├── plan: Plan (FK) -├── credits: int -├── status: str (active, suspended, trial, cancelled) -└── created_at, updated_at -``` - -**User** (`auth/models.py`) -```python -User (extends AbstractUser) -├── email: str (unique) -├── username: str (unique) -├── account: Account (FK) -├── role: str (developer, owner, admin, editor, viewer, system_bot) -└── is_active, is_staff, is_superuser -``` - -**Site** (`auth/models.py`) -```python -Site -├── account: Account (FK) -├── name: str -├── slug: str -├── domain: URL -├── industry: Industry (FK, nullable) -├── wordpress_url: URL -├── wordpress_username: str -├── wordpress_password: str (encrypted) -├── wp_api_key: str (for bridge auth) -├── status: str (active, inactive, suspended) -└── created_at, updated_at -``` - -**Sector** (`auth/models.py`) -```python -Sector -├── account: Account (FK) -├── site: Site (FK) -├── name: str -├── industry_sector: IndustrySector (FK, nullable) -├── status: str (active, inactive) -└── created_at, updated_at -``` - -### Planner Models - -**Keywords** (`business/planning/models.py`) -```python -Keywords (inherits SiteSectorBaseModel) -├── account, site, sector (from base) -├── keyword: str -├── search_volume: int -├── difficulty: int -├── intent: str (informational, commercial, transactional, navigational) -├── status: str (active, inactive, used) -├── source: str (manual, csv_import, api_import) -└── created_at, updated_at -``` - -**Clusters** (`business/planning/models.py`) -```python -Clusters (inherits SiteSectorBaseModel) -├── account, site, sector (from base) -├── name: str -├── description: text -├── keywords: ManyToMany(Keywords) -├── status: str (active, archived) -└── created_at, updated_at - -# Stage 1 Changes (Nov 2025): -# REMOVED: context_type, dimension_meta -# Now pure topic-based clustering -``` - -**ContentIdeas** (`business/planning/models.py`) -```python -ContentIdeas (inherits SiteSectorBaseModel) -├── account, site, sector (from base) -├── cluster: Cluster (FK) -├── title: str -├── description: text -├── suggested_keywords: JSON -├── status: str (draft, approved, in_progress, completed) -└── created_at, updated_at -``` - -### Writer Models - -**Tasks** (`business/content/models.py`) -```python -Tasks (inherits SiteSectorBaseModel) -├── account, site, sector (from base) -├── cluster: Cluster (FK, nullable) -├── title: str -├── description: text (brief) -├── keywords: JSON (target keywords) -├── content_type: str (post, page, product, etc.) -├── content_structure: JSON (template for content generation) -├── taxonomy_term_id: int (optional categorization) -├── status: str (queued, completed) # Simplified in Stage 1 -├── assigned_post_id: int (WP post ID) -├── post_url: URL -└── created_at, updated_at - -# Stage 1 Changes (Nov 2025): -# REMOVED: content_idea FK, cluster_role, entity_type, cluster_context, dimension_roles, metadata, content_record -# ADDED: content_type, content_structure, taxonomy_term_id -# CHANGED: status simplified to (queued, completed) -``` - -**Content** (`business/content/models.py`) -```python -Content (inherits SiteSectorBaseModel) -├── account, site, sector (from base) -├── cluster_id: int (cluster reference) -├── title: str -├── content_html: text (generated HTML content) -├── content_type: str (post, page, product, etc.) -├── content_structure: JSON (structure template used) -├── taxonomy_terms: ManyToMany(ContentTaxonomy) # Direct M2M -├── external_id: str (WP post ID) -├── external_url: URL (published URL) -├── source: str (igny8, wordpress, import) -├── status: str (draft, published) # Simplified in Stage 1 -└── created_at, updated_at - -# Stage 1 Changes (Nov 2025): -# REMOVED: task FK, html_content, word_count, metadata, meta_title, meta_description, -# primary_keyword, secondary_keywords, entity_type, json_blocks, structure_data, -# content_format, cluster_role, sync_status, external_type, external_status, -# sync_data, last_synced_at, validation_errors, is_validated -# ADDED: cluster_id, title, content_html, content_type, content_structure -# CHANGED: status simplified to (draft, published) -# CHANGED: taxonomy relationship from through model to direct M2M -``` - -**Images** (`business/content/models.py`) -```python -Images (inherits SiteSectorBaseModel) -├── account, site, sector (from base) -├── task: Tasks (FK) -├── image_url: URL -├── prompt: text -├── is_featured: bool -├── position: int -├── alt_text: str -├── caption: str -└── created_at, updated_at -``` - -**ContentTaxonomy** (`business/content/models.py`) -```python -ContentTaxonomy (inherits SiteSectorBaseModel) -├── account, site, sector (from base) -├── name: str -├── slug: str -├── taxonomy_type: str (category, post_tag, cluster) -├── external_id: str (WordPress taxonomy term ID) -├── external_taxonomy: str (WordPress taxonomy name) -└── created_at, updated_at - -# Stage 1 Changes (Nov 2025): -# REMOVED: description, parent FK, sync_status, count, metadata, clusters M2M -# ADDED: 'cluster' as taxonomy_type option -# Simplified to essential fields for WP sync -``` - -### Integration Models - -**SiteIntegration** (`business/integration/models.py`) -```python -SiteIntegration -├── account: Account (FK) -├── site: Site (FK) -├── platform: str (wordpress, shopify, wix) -├── platform_type: str (cms, ecommerce, builder) -├── config_json: JSON (site_url, content_types, sync_settings) -├── credentials_json: JSON (api_key, username, app_password) -├── is_active: bool -├── sync_enabled: bool -├── last_sync_at: datetime -└── created_at, updated_at -``` - ---- - -## API Reference - -### Base URL - -``` -Production: https://api.igny8.com/api/v1/ -Development: http://localhost:8000/api/v1/ -``` - -### Unified Response Format - -All endpoints return: - -```json -{ - "success": true, - "data": { ... }, - "message": "Success message", - "request_id": "uuid", - "count": 100, // For paginated endpoints - "next": "url", // Pagination - "previous": "url", // Pagination - "results": [...] // Paginated data -} -``` - -Error response: -```json -{ - "success": false, - "error": "Error message", - "errors": {...}, // Field-level errors - "request_id": "uuid" -} -``` - -### Authentication - -**Login:** -```http -POST /api/v1/auth/login/ -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "password" -} - -Response: -{ - "success": true, - "data": { - "access": "jwt_token", - "refresh": "refresh_token", - "user": {...} - } -} -``` - -**Use Token:** -```http -GET /api/v1/planner/keywords/ -Authorization: Bearer {access_token} -``` - -### Key Endpoints by Module - -#### Planner Module - -``` -GET /api/v1/planner/keywords/ # List keywords -POST /api/v1/planner/keywords/ # Create keyword -GET /api/v1/planner/keywords/{id}/ # Get keyword -PUT /api/v1/planner/keywords/{id}/ # Update keyword -DELETE /api/v1/planner/keywords/{id}/ # Delete keyword -POST /api/v1/planner/keywords/bulk_create/ # Bulk import -POST /api/v1/planner/keywords/auto_cluster/ # AI clustering - -GET /api/v1/planner/clusters/ # List clusters -POST /api/v1/planner/clusters/ # Create cluster -POST /api/v1/planner/clusters/generate_ideas/ # AI idea generation - -GET /api/v1/planner/ideas/ # List ideas -POST /api/v1/planner/ideas/ # Create idea -``` - -#### Writer Module - -``` -GET /api/v1/writer/tasks/ # List tasks -POST /api/v1/writer/tasks/ # Create task -GET /api/v1/writer/tasks/{id}/ # Get task -PUT /api/v1/writer/tasks/{id}/ # Update task -POST /api/v1/writer/tasks/generate_content/ # AI content generation -POST /api/v1/writer/tasks/generate_images/ # AI image generation - -GET /api/v1/writer/content/ # List content -GET /api/v1/writer/content/{id}/ # Get content - -GET /api/v1/writer/images/ # List images -``` - -#### Integration Module - -``` -GET /api/v1/integration/integrations/ # List integrations -POST /api/v1/integration/integrations/ # Create integration -POST /api/v1/integration/integrations/test-connection/ # Test WP connection -POST /api/v1/integration/integrations/{id}/update-structure/ # Update WP metadata -GET /api/v1/integration/integrations/{id}/content-types/ # Get content types -POST /api/v1/integration/integrations/{id}/sync/ # Trigger sync -``` - -#### System Module - -``` -GET /api/v1/system/prompts/ # List AI prompts -POST /api/v1/system/prompts/ # Create prompt -GET /api/v1/system/author-profiles/ # List author profiles -GET /api/v1/system/settings/ # System settings -``` - -#### Billing Module - -``` -GET /api/v1/billing/balance/ # Get credit balance -GET /api/v1/billing/transactions/ # List transactions -GET /api/v1/billing/usage/ # Usage analytics -``` - ---- - -## Workflows - -### 1. Keyword to Published Post (Full Workflow) - -``` -1. Import Keywords - ↓ - POST /api/v1/planner/keywords/bulk_create/ - • Upload CSV or manual entry - • Keywords stored in database - -2. AI Clustering - ↓ - POST /api/v1/planner/keywords/auto_cluster/ - • Celery task: auto_cluster - • AI groups keywords → Clusters - • Keywords assigned to clusters - -3. Generate Content Ideas - ↓ - POST /api/v1/planner/clusters/generate_ideas/ - • Celery task: generate_ideas - • AI creates ContentIdeas from clusters - -4. Create Writer Task - ↓ - POST /api/v1/writer/tasks/ - • Link task to ContentIdea - • Set brief and target keywords - -5. Generate Content - ↓ - POST /api/v1/writer/tasks/generate_content/ - • Celery task: generate_content - • AI writes full blog post → Content record - • Task status: completed - -6. Generate Images (optional) - ↓ - POST /api/v1/writer/tasks/generate_images/ - • Celery task: generate_images - • AI creates images → Image records - -7. Publish to WordPress - ↓ - POST /wp-json/igny8/v1/posts/ - • IGNY8 sends content to WP plugin - • Plugin creates WP post - • Stores _igny8_task_id meta - -8. Status Sync (WP → IGNY8) - ↓ - PUT /api/v1/writer/tasks/{id}/ - • User publishes in WP - • Plugin syncs status back to IGNY8 -``` - -### 2. WordPress Integration Setup - -``` -1. Install Plugin - ↓ - • Upload igny8-bridge.zip to WordPress - • Activate plugin - -2. Generate API Key (IGNY8 App) - ↓ - • Login to app.igny8.com - • Navigate to Settings → Sites - • Generate API key for site - -3. Connect Plugin - ↓ - • In WP admin: Settings → IGNY8 API - • Enter email, password, API key - • Click "Test Connection" - -4. Initial Sync - ↓ - • Plugin sends site metadata to IGNY8 - • POST /api/v1/integration/integrations/test-connection/ - • IGNY8 creates SiteIntegration record - -5. Ongoing Sync - ↓ - • WP → IGNY8: Post status updates (save_post hook) - • IGNY8 → WP: Content publishing (REST API) -``` - ---- - -## Developer Navigation Map - -### "I need to..." - -#### Add a new API endpoint -1. Create method in ViewSet (`modules/[module]/views.py`) -2. Use `@action` decorator for custom endpoints -3. Add route to `urls.py` if needed -4. Return `success_response()` or `error_response()` - -#### Add a new model field -1. Edit model in `business/[domain]/models.py` -2. Run `python manage.py makemigrations` -3. Run `python manage.py migrate` -4. Update serializers in `modules/[module]/serializers.py` - -#### Add AI functionality -1. Create function class in `ai/functions/[name].py` -2. Inherit from `BaseAIFunction` -3. Implement `validate()`, `prepare()`, `execute()`, `save()` -4. Register in `ai/registry.py` -5. Call via `run_ai_task()` from ViewSet - -#### Debug API call -1. Check request ID in response -2. Search logs: `grep {request_id} logs/` -3. Check middleware: `RequestIDMiddleware` -4. Check account context: `AccountContextMiddleware` - -#### Add WordPress sync logic -1. Add hook in `sync/hooks.php` -2. Implement handler in `sync/post-sync.php` or `sync/taxonomy-sync.php` -3. Use `Igny8API` class for API calls -4. Update post meta with `_igny8_*` keys - -#### Add frontend page -1. Create component in `frontend/src/pages/[Module]/[Page].tsx` -2. Add route in `App.tsx` -3. Add to sidebar navigation in `layout/Sidebar.tsx` -4. Create API calls in `api/[module].ts` -5. Use Zustand store for state - ---- - -## Troubleshooting Map - -### Common Issues and Where to Look - -#### "Authentication failed / 401 errors" -**Check:** -- `igny8_core/auth/middleware.py` - AccountContextMiddleware -- JWT token expiry - tokens expire in 15 minutes -- API key validation - `Site.wp_api_key` field -- Plugin: `includes/class-igny8-api.php` - connect() method - -#### "Posts not syncing to IGNY8" -**Check:** -- `sync/hooks.php` - save_post hook registered? -- `sync/post-sync.php` - igny8_sync_post_status_to_igny8() -- Post meta: `_igny8_task_id` exists? -- `igny8_is_connection_enabled()` returns true? -- API key stored: `get_option('igny8_api_key')` - -#### "AI task stuck/failed" -**Check:** -- Celery worker running: `celery -A igny8_core worker -l info` -- Task logs: Check Celery console output -- `ai/engine.py` - AIEngine.execute() -- `ai/ai_core.py` - APICore.run_ai_request() -- Credit balance: Check `Account.credits` -- Model configuration: `system/models.py` - IntegrationSettings - -#### "Frontend can't fetch data" -**Check:** -- CORS settings: `igny8_core/settings.py` - CORS_ALLOWED_ORIGINS -- API base URL: `frontend/src/api/client.ts` -- Auth token: Check `authStore` in browser DevTools -- Network tab: Check request/response -- Backend logs: Check for errors - -#### "WordPress plugin can't connect" -**Check:** -- API key matches: WP option vs `Site.wp_api_key` -- Site URL correct: WP site URL vs `SiteIntegration.config_json.site_url` -- REST API enabled: Test `/wp-json/` endpoint -- PHP errors: Check WP debug log -- Connection test: `includes/class-igny8-api.php` - connect() - -#### "Rate limiting errors" -**Check:** -- `api/throttles.py` - DebugScopedRateThrottle -- `settings.py` - IGNY8_DEBUG_THROTTLE flag -- User role has sufficient permissions -- Check throttle scope in ViewSet - -#### "Account isolation not working" -**Check:** -- `auth/middleware.py` - AccountContextMiddleware sets request.account? -- ViewSet inherits from `AccountModelViewSet` or `SiteSectorModelViewSet`? -- JWT token contains `account_id`? -- Query filtering: `get_queryset()` filters by account? - ---- - -## File Quick Reference - -### Most Important Files - -| File | Purpose | When to Check | -|------|---------|---------------| -| `backend/igny8_core/settings.py` | Django configuration | Environment issues, installed apps | -| `backend/igny8_core/urls.py` | URL routing | Adding endpoints, 404 errors | -| `backend/igny8_core/celery.py` | Celery config | Task queue issues | -| `backend/igny8_core/auth/models.py` | Account, User, Site models | Multi-tenancy, auth issues | -| `backend/igny8_core/auth/middleware.py` | Account context | Request.account issues | -| `backend/igny8_core/api/base.py` | Base ViewSets | Queryset filtering | -| `backend/igny8_core/ai/engine.py` | AI orchestrator | AI task failures | -| `backend/igny8_core/ai/ai_core.py` | AI API calls | OpenAI errors | -| `frontend/src/App.tsx` | Frontend routing | Navigation issues | -| `frontend/src/store/authStore.ts` | Auth state | Login/logout issues | -| `frontend/src/api/client.ts` | API client | API call failures | -| `igny8-wp-integration/igny8-bridge.php` | Plugin bootstrap | Plugin initialization | -| `igny8-wp-integration/includes/class-igny8-api.php` | WP API client | API communication | -| `igny8-wp-integration/includes/class-igny8-rest-api.php` | WP REST endpoints | Endpoint errors | -| `igny8-wp-integration/sync/post-sync.php` | WP → IGNY8 sync | Sync failures | - ---- - -## Technology Dependencies - -### Backend - -``` -Django==5.2.7 -djangorestframework -psycopg2-binary # PostgreSQL -redis # Celery broker -celery==5.3.0 -PyJWT==2.8.0 -requests==2.31.0 -beautifulsoup4==4.12.0 -drf-spectacular==0.27.0 -django-filter -django-cors-headers -whitenoise -gunicorn -``` - -### Frontend - -``` -react==19.0.0 -react-dom==19.0.0 -react-router==7.1.5 -react-router-dom==7.9.5 -zustand==5.0.8 -vite==6.1.0 -typescript==5.7.2 -tailwindcss==4.0.8 -apexcharts==4.1.0 -@heroicons/react==2.2.0 -``` - -### WordPress Plugin - -``` -PHP >= 7.4 -WordPress >= 5.0 -``` - ---- - -## Portable Package Structure - -This section defines the **essential files and folders** needed to deploy IGNY8 to a new infrastructure with the same tech stack. These are IGNY8-specific application files (excluding standard framework boilerplate that gets installed via package managers). - -### Backend Essential Package Tree - -**Package Only These Files** - Excludes Django-generated boilerplate and dependencies installed via pip: - -``` -backend/ -├── manage.py # Django entry point (IGNY8-configured) -├── requirements.txt # Python dependencies (essential) -├── Dockerfile # Container configuration (essential) -│ -└── igny8_core/ # Main Django project - ├── __init__.py # Project marker - ├── settings.py # ⭐ IGNY8 configuration (critical) - ├── test_settings.py # Test configuration - ├── urls.py # Main URL routing (IGNY8-specific) - ├── asgi.py # ASGI entry point - ├── wsgi.py # WSGI entry point - ├── celery.py # ⭐ Celery configuration (critical) - │ - ├── admin/ # Custom Django admin - │ ├── __init__.py - │ ├── apps.py - │ ├── base.py # Admin base classes - │ └── site.py # Custom admin site - │ - ├── auth/ # ⭐ Authentication module (critical) - │ ├── __init__.py - │ ├── apps.py - │ ├── admin.py - │ ├── models.py # User, Account, Site models - │ ├── serializers.py - │ ├── views.py - │ ├── urls.py - │ ├── permissions.py - │ ├── middleware.py # Account context middleware - │ ├── utils.py - │ └── management/ - │ └── commands/ # Custom management commands - │ - ├── ai/ # ⭐ AI Framework (critical) - │ ├── __init__.py - │ ├── apps.py - │ ├── admin.py - │ ├── models.py # AIJob, AITaskResult - │ ├── base.py # Base AI classes - │ ├── ai_core.py # Core AI logic - │ ├── engine.py # AI execution engine - │ ├── registry.py # AI function registry - │ ├── prompts.py # AI prompt templates - │ ├── validators.py - │ ├── tracker.py # Progress tracking - │ ├── tasks.py # Celery tasks - │ ├── settings.py # AI configuration - │ ├── constants.py - │ ├── functions/ # AI function implementations - │ │ ├── __init__.py - │ │ ├── base.py - │ │ ├── content_generation.py - │ │ ├── image_generation.py - │ │ └── ... - │ ├── migrations/ # Database migrations (critical) - │ └── tests/ - │ - ├── api/ # ⭐ API Infrastructure (critical) - │ ├── __init__.py - │ ├── base.py # Base ViewSet classes - │ ├── response.py # Unified response format - │ ├── pagination.py # Pagination classes - │ ├── permissions.py # API permissions - │ ├── authentication.py # JWT authentication - │ ├── throttles.py # Rate limiting - │ ├── exception_handlers.py # Error handling - │ ├── schema_extensions.py # OpenAPI schema - │ ├── wordpress_publishing.py # WordPress integration - │ └── tests/ - │ - ├── business/ # ⭐ Business Logic Layer (critical) - │ ├── __init__.py - │ ├── planning/ - │ │ ├── __init__.py - │ │ ├── cluster_service.py - │ │ ├── keyword_service.py - │ │ └── idea_service.py - │ ├── content/ - │ │ ├── __init__.py - │ │ ├── generation_service.py - │ │ └── image_service.py - │ ├── linking/ - │ │ ├── __init__.py - │ │ └── linking_service.py - │ ├── optimization/ - │ │ ├── __init__.py - │ │ └── optimization_service.py - │ ├── publishing/ - │ │ ├── __init__.py - │ │ └── wordpress_service.py - │ ├── site_building/ - │ │ ├── __init__.py - │ │ └── builder_service.py - │ ├── automation/ - │ │ ├── __init__.py - │ │ └── automation_service.py - │ ├── integration/ - │ │ ├── __init__.py - │ │ └── integration_service.py - │ └── billing/ - │ ├── __init__.py - │ └── credit_service.py - │ - ├── modules/ # ⭐ Feature Modules (critical) - │ ├── __init__.py - │ │ - │ ├── planner/ # Keyword planning - │ │ ├── __init__.py - │ │ ├── apps.py - │ │ ├── admin.py - │ │ ├── models.py - │ │ ├── serializers.py - │ │ ├── cluster_serializers.py - │ │ ├── views.py - │ │ ├── urls.py - │ │ ├── migrations/ - │ │ └── management/ - │ │ - │ ├── writer/ # Content generation - │ │ ├── __init__.py - │ │ ├── apps.py - │ │ ├── admin.py - │ │ ├── models.py - │ │ ├── serializers.py - │ │ ├── views.py - │ │ ├── urls.py - │ │ ├── migrations/ - │ │ └── management/ - │ │ - │ ├── linker/ # Internal linking - │ │ ├── __init__.py - │ │ ├── apps.py - │ │ ├── admin.py - │ │ ├── models.py - │ │ ├── serializers.py - │ │ ├── views.py - │ │ ├── urls.py - │ │ └── migrations/ - │ │ - │ ├── optimizer/ # Content optimization - │ │ ├── __init__.py - │ │ ├── apps.py - │ │ ├── admin.py - │ │ ├── models.py - │ │ ├── serializers.py - │ │ ├── views.py - │ │ ├── urls.py - │ │ └── migrations/ - │ │ - │ ├── publisher/ # WordPress publishing - │ │ ├── __init__.py - │ │ ├── apps.py - │ │ ├── views.py - │ │ └── urls.py - │ │ - │ ├── site_builder/ # Site blueprints - │ │ ├── __init__.py - │ │ ├── apps.py - │ │ ├── admin.py - │ │ ├── models.py - │ │ ├── serializers.py - │ │ ├── views.py - │ │ ├── urls.py - │ │ └── migrations/ - │ │ - │ ├── automation/ # Task automation - │ │ ├── __init__.py - │ │ ├── apps.py - │ │ ├── admin.py - │ │ ├── models.py - │ │ ├── serializers.py - │ │ ├── views.py - │ │ ├── urls.py - │ │ └── migrations/ - │ │ - │ ├── integration/ # WordPress integration - │ │ ├── __init__.py - │ │ ├── apps.py - │ │ ├── views.py - │ │ └── urls.py - │ │ - │ ├── billing/ # Credit system - │ │ ├── __init__.py - │ │ ├── apps.py - │ │ ├── admin.py - │ │ ├── models.py - │ │ ├── serializers.py - │ │ ├── views.py - │ │ ├── urls.py - │ │ └── migrations/ - │ │ - │ └── system/ # System settings - │ ├── __init__.py - │ ├── apps.py - │ ├── admin.py - │ ├── models.py # Settings, AI models - │ ├── serializers.py - │ ├── settings_models.py - │ ├── settings_serializers.py - │ ├── settings_views.py - │ ├── settings_admin.py - │ ├── views.py - │ ├── integration_views.py - │ ├── urls.py - │ ├── validators.py - │ ├── utils.py - │ ├── schemas.py - │ ├── management/ - │ └── migrations/ - │ - ├── middleware/ # Custom middleware - │ ├── __init__.py - │ ├── request_id.py # Request ID tracking - │ └── resource_tracker.py # Resource monitoring - │ - ├── tasks/ # Celery tasks - │ └── wordpress_publishing.py - │ - ├── utils/ # Shared utilities - │ ├── __init__.py - │ ├── ai_processor.py - │ ├── content_normalizer.py - │ ├── queue_manager.py - │ └── wordpress.py - │ - ├── urls/ # URL modules - │ └── [module-specific URL configs] - │ - └── migrations/ # ⭐ Database migrations (critical) - └── [all migration files] -``` - -**EXCLUDE from package (installed via package managers):** -- `.venv/` - Virtual environment (created on deployment) -- `__pycache__/` - Python cache files -- `*.pyc`, `*.pyo` - Compiled Python -- `staticfiles/` - Collected static files (generated) -- `logs/` - Log files -- `db.sqlite3`, `*.sql` - Database files -- `celerybeat-schedule` - Celery scheduler state -- Test/debug scripts: `check_*.py`, `fix_*.py`, `verify_*.py`, `diagnose_*.py`, `inject_*.py`, `test_*.py` -- `backup_*.sql` - Database backups - -### Frontend Essential Package Tree - -**Package Only These Files** - Excludes React boilerplate and node_modules: - -``` -frontend/ -├── package.json # ⭐ Dependencies manifest (critical) -├── package-lock.json # Locked dependencies -├── tsconfig.json # TypeScript config (IGNY8-modified) -├── tsconfig.app.json # App-specific TS config -├── tsconfig.node.json # Node-specific TS config -├── vite.config.ts # ⭐ Vite build config (IGNY8-customized) -├── vitest.config.ts # Test configuration -├── postcss.config.js # PostCSS config -├── eslint.config.js # ESLint configuration -├── index.html # Main HTML entry point -├── marketing.html # Marketing site entry (if used) -├── Dockerfile # ⭐ Container config (critical) -├── Dockerfile.dev # Dev container config -├── Caddyfile # Reverse proxy config -│ -├── public/ # Static assets -│ ├── images/ # IGNY8 images -│ └── marketing/ # Marketing assets -│ -└── src/ # ⭐ Application source (all critical) - ├── main.tsx # ⭐ App entry point (IGNY8-specific) - ├── App.tsx # ⭐ Main app component (IGNY8 routing) - ├── index.css # Global styles - ├── vite-env.d.ts # Vite type definitions - ├── svg.d.ts # SVG type definitions - │ - ├── config/ # ⭐ App configuration - │ ├── api.config.ts # API endpoints - │ ├── routes.config.ts # Route definitions - │ └── constants.ts # App constants - │ - ├── types/ # ⭐ TypeScript types (IGNY8 models) - │ ├── index.ts - │ ├── auth.types.ts - │ ├── planner.types.ts - │ ├── writer.types.ts - │ ├── linker.types.ts - │ ├── optimizer.types.ts - │ ├── site.types.ts - │ ├── billing.types.ts - │ └── ... - │ - ├── api/ # ⭐ API client layer - │ ├── automation.api.ts - │ ├── linker.api.ts - │ └── optimizer.api.ts - │ - ├── services/ # ⭐ Service layer - │ ├── api.ts # Base API service - │ ├── integration.api.ts - │ └── siteBuilder.api.ts - │ - ├── store/ # ⭐ Zustand state management - │ ├── authStore.ts - │ ├── billingStore.ts - │ ├── plannerStore.ts - │ ├── siteStore.ts - │ ├── siteDefinitionStore.ts - │ ├── settingsStore.ts - │ ├── sectorStore.ts - │ ├── onboardingStore.ts - │ ├── columnVisibilityStore.ts - │ └── pageSizeStore.ts - │ - ├── context/ # React Context providers - │ ├── ThemeContext.tsx - │ └── HeaderMetricsContext.tsx - │ - ├── hooks/ # Custom React hooks - │ ├── useAuth.ts - │ ├── useDebounce.ts - │ ├── useLocalStorage.ts - │ └── ... - │ - ├── layout/ # ⭐ App layout components - │ ├── AppLayout.tsx # Main layout - │ ├── AppHeader.tsx # Header - │ ├── AppSidebar.tsx # Sidebar navigation - │ ├── SidebarWidget.tsx - │ └── Backdrop.tsx - │ - ├── components/ # ⭐ IGNY8-specific components - │ ├── auth/ # Authentication - │ │ ├── ProtectedRoute.tsx - │ │ └── ... - │ │ - │ ├── common/ # Shared components - │ │ ├── ErrorBoundary.tsx - │ │ ├── LoadingStateMonitor.tsx - │ │ ├── GlobalErrorDisplay.tsx - │ │ ├── ScrollToTop.tsx - │ │ ├── ModuleGuard.tsx - │ │ └── ... - │ │ - │ ├── header/ # Header components - │ ├── sidebar/ # Sidebar components - │ ├── navigation/ # Navigation - │ ├── dashboard/ # Dashboard widgets - │ ├── content/ # Content components - │ ├── integration/ # Integration UI - │ ├── linker/ # Linker components - │ ├── optimizer/ # Optimizer components - │ ├── publishing/ # Publishing UI - │ ├── sites/ # Site management - │ ├── tasks/ # Task management - │ ├── onboarding/ # Onboarding flow - │ ├── debug/ # Debug tools - │ │ - │ ├── ui/ # UI primitives - │ │ ├── toast/ - │ │ ├── button/ - │ │ ├── modal/ - │ │ └── ... - │ │ - │ ├── tables/ # Table components - │ ├── form/ # Form components - │ ├── charts/ # Chart components - │ └── shared/ # Shared UI - │ - ├── pages/ # ⭐ Page components (routes) - │ ├── Dashboard/ - │ │ └── Home.tsx # Main dashboard - │ │ - │ ├── AuthPages/ # Auth pages - │ │ ├── SignIn.tsx - │ │ └── SignUp.tsx - │ │ - │ ├── Planner/ # Planner module - │ │ ├── Dashboard.tsx - │ │ ├── Keywords.tsx - │ │ ├── Clusters.tsx - │ │ ├── ClusterDetail.tsx - │ │ ├── Ideas.tsx - │ │ └── KeywordOpportunities.tsx - │ │ - │ ├── Writer/ # Writer module - │ │ ├── Dashboard.tsx - │ │ ├── Tasks.tsx - │ │ ├── ContentLibrary.tsx - │ │ └── ... - │ │ - │ ├── Linker/ # Linker module - │ ├── Optimizer/ # Optimizer module - │ ├── Sites/ # Site management - │ ├── Settings/ # Settings - │ ├── Billing/ # Billing - │ ├── Automation/ # Automation - │ ├── Help/ # Help pages - │ ├── Setup/ # Setup wizard - │ ├── Reference/ # Reference pages - │ ├── Thinker/ # Thinker module - │ └── OtherPage/ - │ └── NotFound.tsx - │ - ├── modules/ # Module-specific logic - │ └── siteBuilder/ - │ - ├── templates/ # Page templates - ├── icons/ # Custom icons - ├── styles/ # ⭐ IGNY8 styles - │ └── igny8-colors.css # Custom color scheme - │ - ├── utils/ # Utility functions - │ ├── dateUtils.ts - │ ├── formatters.ts - │ └── ... - │ - ├── marketing/ # Marketing site (optional) - │ └── [marketing components] - │ - └── __tests__/ # Tests - └── [test files] -``` - -**EXCLUDE from package (installed via npm):** -- `node_modules/` - Dependencies (installed via `npm install`) -- `dist/` - Build output (generated) -- `banner.png` - May be optional -- `DESIGN_SYSTEM.md`, `MIGRATION_GUIDE.md`, `README.md`, `LICENSE.md` - Documentation (optional) - -### Installation & Portability Guide - -#### Backend Deployment Steps - -```bash -# 1. Copy backend essential files to new infrastructure -# 2. Set up Python environment -python -m venv .venv -source .venv/bin/activate # or .venv\Scripts\activate on Windows - -# 3. Install dependencies from requirements.txt -pip install -r requirements.txt - -# 4. Configure environment variables (create .env file) -SECRET_KEY= -DEBUG=False -DATABASE_URL=postgresql://user:pass@host:5432/dbname -REDIS_URL=redis://redis:6379/0 -ALLOWED_HOSTS=yourdomain.com - -# 5. Run migrations to set up database -python manage.py migrate - -# 6. Create superuser -python manage.py createsuperuser - -# 7. Collect static files -python manage.py collectstatic --noinput - -# 8. Start services -# Application server -gunicorn igny8_core.wsgi:application --bind 0.0.0.0:8000 - -# Celery worker (separate terminal) -celery -A igny8_core worker --loglevel=info - -# Celery beat scheduler (separate terminal) -celery -A igny8_core beat --loglevel=info -``` - -#### Frontend Deployment Steps - -```bash -# 1. Copy frontend essential files to new infrastructure -# 2. Install dependencies from package.json -npm install -# or -npm ci # for production (uses package-lock.json) - -# 3. Configure environment (create .env file) -VITE_API_BASE_URL=https://api.yourdomain.com -VITE_APP_NAME=IGNY8 - -# 4. Build for production -npm run build - -# 5. Serve static files (multiple options) -# Option A: Using Caddy (recommended) -caddy run --config Caddyfile - -# Option B: Using preview mode -npm run preview - -# Option C: Using static server -npx serve -s dist -l 5173 -``` - -#### Docker Deployment (Recommended) - -```bash -# Build and run with Docker Compose -docker-compose -f docker-compose.app.yml up -d - -# Services will be available at: -# - Frontend: http://localhost:5173 -# - Backend API: http://localhost:8000 -# - Admin: http://localhost:8000/igny8-admin/ -``` - -### Key Differences: Framework Files vs IGNY8 Files - -#### Backend - -**Framework Files (NOT in package):** -- Django's own code (installed via pip) -- Standard Django project structure created by `django-admin startproject` -- Generic migration files for Django's contrib apps -- Python standard library modules - -**IGNY8-Specific Files (IN package):** -- All files in `igny8_core/` with custom business logic -- Custom models, views, serializers, URLs -- AI framework implementation -- Business logic services -- Module-specific code (planner, writer, linker, etc.) -- Custom middleware and utilities -- Database migrations for IGNY8 models -- `settings.py` with IGNY8 configuration -- `requirements.txt` (dependency manifest) - -#### Frontend - -**Framework Files (NOT in package):** -- React library itself (installed via npm) -- node_modules dependencies -- TypeScript compiler -- Vite build tool -- TailwindCSS framework - -**IGNY8-Specific Files (IN package):** -- All `.tsx` and `.ts` files in `src/` -- Custom components, pages, layouts -- State management stores -- API client implementation -- Type definitions for IGNY8 models -- Custom hooks and utilities -- Routing configuration -- Custom styles (`igny8-colors.css`) -- `package.json` (dependency manifest) -- `vite.config.ts` with IGNY8-specific build config - -### Portability Checklist - -✅ **Before packaging:** -- [ ] Remove all `.env` files (environment-specific) -- [ ] Remove database files (`.sqlite3`, `*.sql` backups) -- [ ] Remove logs and temporary files -- [ ] Remove virtual environments (`.venv/`, `node_modules/`) -- [ ] Remove build artifacts (`dist/`, `staticfiles/`, `__pycache__/`) -- [ ] Keep all migration files (critical for database schema) -- [ ] Keep `requirements.txt` and `package.json` (dependency manifests) -- [ ] Keep Dockerfile and docker-compose files - -✅ **After deployment to new infrastructure:** -- [ ] Install dependencies (`pip install -r requirements.txt`, `npm install`) -- [ ] Configure environment variables for new infrastructure -- [ ] Run database migrations -- [ ] Create initial superuser -- [ ] Collect static files (backend) -- [ ] Build frontend assets -- [ ] Configure reverse proxy (Caddy/Nginx) -- [ ] Set up SSL certificates -- [ ] Configure Redis and Celery workers -- [ ] Test all API endpoints -- [ ] Verify WordPress integration works - -### Size Estimation - -**Backend Package:** ~5-10 MB (excluding virtual environment and dependencies) -**Frontend Package:** ~2-5 MB (excluding node_modules) -**Total Application Code:** ~7-15 MB - -**After Installation:** -- Backend with dependencies: ~200-300 MB -- Frontend with dependencies: ~300-500 MB -- Total deployed: ~500-800 MB - ---- - -## Deployment Architecture - -### Production Stack - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Caddy │ -│ (Reverse Proxy + SSL) │ -└─────────────────────┬───────────────────────────────────────────┘ - │ - ┌───────────┴───────────┐ - ▼ ▼ -┌──────────────────┐ ┌──────────────────┐ -│ Frontend (Vite) │ │ Backend (Gunicorn)│ -│ app.igny8.com │ │ api.igny8.com │ -│ Port: 5173 │ │ Port: 8000 │ -└──────────────────┘ └─────────┬──────────┘ - │ - ┌───────────────┴───────────────┐ - ▼ ▼ - ┌──────────────────┐ ┌──────────────────┐ - │ PostgreSQL │ │ Redis │ - │ Database │ │ Celery Broker │ - └──────────────────┘ └─────────┬────────┘ - │ - ▼ - ┌──────────────────┐ - │ Celery Worker │ - │ AI Tasks │ - └──────────────────┘ -``` - -### Docker Compose - -See `docker-compose.app.yml` for full configuration. - ---- - -**End of Master Reference** - -For setup instructions, see `README.md`. -For version history, see `CHANGELOG.md`. -For API details, see `master-docs/API-COMPLETE-REFERENCE.md`. diff --git a/approved-docs/01-IGNY8-REST-API-COMPLETE-REFERENCE.md b/approved-docs/01-IGNY8-REST-API-COMPLETE-REFERENCE.md deleted file mode 100644 index 453459ce..00000000 --- a/approved-docs/01-IGNY8-REST-API-COMPLETE-REFERENCE.md +++ /dev/null @@ -1,1707 +0,0 @@ -# IGNY8 API Complete Reference v1.0 - -**Base URL**: `https://api.igny8.com/api/v1/` -**Version**: 1.0.0 -**Last Updated**: 2025-12-03 (Removed deprecated Site Builder module) -**Status**: ✅ **100% IMPLEMENTED** - All endpoints use unified format - -**Purpose**: Complete, unified reference for IGNY8 API covering authentication, endpoints, response formats, error handling, rate limiting, permissions, and integration examples. - ---- - -## Table of Contents - -1. [Quick Start](#quick-start) -2. [Overview & Architecture](#overview--architecture) -3. [Authentication & Authorization](#authentication--authorization) -4. [Response Format Standard](#response-format-standard) -5. [Error Handling](#error-handling) -6. [Rate Limiting](#rate-limiting) -7. [Pagination](#pagination) -8. [Roles & Permissions](#roles--permissions) -9. [Tenant / Site / Sector Scoping](#tenant--site--sector-scoping) -10. [Complete Endpoint Reference](#complete-endpoint-reference) -11. [Integration Examples](#integration-examples) -12. [Testing & Debugging](#testing--debugging) -13. [Change Management](#change-management) - ---- - -## Quick Start - -### Interactive Documentation - -- **Swagger UI**: `https://api.igny8.com/api/docs/` -- **ReDoc**: `https://api.igny8.com/api/redoc/` -- **OpenAPI Schema**: `https://api.igny8.com/api/schema/` - -### Basic Example - -```python -import requests - -BASE_URL = "https://api.igny8.com/api/v1" - -# 1. Login -response = requests.post( - f"{BASE_URL}/auth/login/", - json={"email": "user@example.com", "password": "password"} -) -data = response.json() - -if data['success']: - token = data['data']['access'] - - # 2. Use token for authenticated requests - headers = { - 'Authorization': f'Bearer {token}', - 'Content-Type': 'application/json' - } - - # 3. Get keywords - response = requests.get( - f"{BASE_URL}/planner/keywords/", - headers=headers - ) - result = response.json() - - if result['success']: - keywords = result['results'] # Paginated results - print(f"Found {result['count']} keywords") -``` - ---- - -## Overview & Architecture - -### API Standard v1.0 - Key Principles - -1. **Unified Response Format**: All endpoints return consistent JSON structure -2. **Layered Authorization**: Authentication → Tenant Access → Role → Site/Sector -3. **Centralized Error Handling**: All errors wrapped in unified format -4. **Scoped Rate Limiting**: Different limits for different operation types -5. **Tenant Isolation**: All resources scoped by account/site/sector -6. **Request Tracking**: Every request has a unique ID for debugging - -### Base URL Structure - -``` -Production: https://api.igny8.com/api/v1/ -Development: http://localhost:8000/api/v1/ -``` - -### Module Namespaces - -``` -/api/v1/ -├── auth/ # Authentication and user management -├── planner/ # Keywords, clusters, content ideas -├── writer/ # Tasks, content, images -├── linker/ # Internal linking operations -├── optimizer/ # Content optimization and scoring -├── publisher/ # Publishing records and site deployment -├── site-builder/ # Site blueprint management -├── automation/ # Automation rules and scheduled tasks -├── integration/ # External platform integrations -├── system/ # Settings, prompts, integrations -└── billing/ # Credits, transactions, usage -``` - -### Technology Stack - -- **Framework**: Django REST Framework (DRF) -- **Authentication**: JWT Bearer tokens (primary), Session (fallback), Basic (fallback) -- **Pagination**: CustomPageNumberPagination (default: 10, max: 100) -- **Rate Limiting**: Scoped throttles per module/operation type -- **OpenAPI**: drf-spectacular for schema generation - -### Implementation Status - -✅ **100% Complete** - All endpoints implemented with: -- Unified response format -- Proper authentication and authorization -- Rate limiting configured -- Error handling standardized -- Request ID tracking -- Complete Swagger/OpenAPI documentation - ---- - -## Authentication & Authorization - -### Authentication Methods - -#### Primary: JWT Bearer Token - -``` -Authorization: Bearer -``` - -**Token Characteristics:** -- Contains `user_id` and `account_id` -- Type: `access` (15-minute expiry) -- Automatically sets `request.account` via middleware -- Resolves account → tenant context automatically - -**Token Payload:** -```json -{ - "user_id": 1, - "account_id": 1, - "type": "access", - "exp": 1234567890 -} -``` - -#### Fallback Methods - -1. **Session Authentication** (admin panel) - - Class: `CSRFExemptSessionAuthentication` - - Use case: Django admin panel (`/admin/`) - -2. **Basic Authentication** (debug/testing) - - Class: `rest_framework.authentication.BasicAuthentication` - - Use case: API testing tools (Postman, curl) - -### Authentication Order - -1. JWT Token Authentication (tried first) -2. Session Authentication (fallback) -3. Basic Authentication (last fallback) -4. If all fail: 401 Unauthorized - -### Getting an Access Token - -**Login Endpoint:** -```http -POST /api/v1/auth/login/ -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "your_password" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "user": { - "id": 1, - "email": "user@example.com", - "username": "user", - "role": "owner", - "account": { ... } - }, - "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - }, - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Token Expiration - -- **Access Token**: 15 minutes -- **Refresh Token**: 7 days - -**Refresh Token:** -```http -POST /api/v1/auth/refresh/ -Content-Type: application/json - -{ - "refresh": "your_refresh_token" -} -``` - -### Public Endpoints (No Authentication Required) - -- `POST /api/v1/auth/register/` - User registration -- `POST /api/v1/auth/login/` - User login -- `GET /api/v1/auth/plans/` - List plans -- `GET /api/v1/auth/industries/` - List industries -- `GET /api/v1/system/status/` - System health check -- `GET /api/v1/system/ping/` - Health check endpoint -- `GET /api/v1/publisher/sites/{site_id}/definition/` - Get site definition for Sites Renderer - -**All other endpoints require JWT authentication.** - -### Authorization Layers - -Every endpoint enforces layered authorization: - -1. **User Authentication**: User must be authenticated -2. **Tenant Access**: User must belong to the tenant/account -3. **Role Authorization**: User must have appropriate role -4. **Site/Sector Access**: User must have access to requested site/sector - ---- - -## Response Format Standard - -### Mandatory Format - -**This is the global standard for all endpoints - no exceptions.** - -### Success Response - -```json -{ - "success": true, - "data": { - "id": 1, - "name": "Example Keyword", - "status": "active" - }, - "message": "Optional human-readable success message", - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Paginated Response - -```json -{ - "success": true, - "count": 120, - "next": "http://api.igny8.com/api/v1/planner/keywords/?page=3", - "previous": "http://api.igny8.com/api/v1/planner/keywords/?page=1", - "results": [ - {"id": 1, "name": "Keyword 1"}, - {"id": 2, "name": "Keyword 2"}, - ... - ], - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Error Response - -```json -{ - "success": false, - "error": "Readable top-level error message", - "errors": { - "field_name": ["Field-specific error messages"] - }, - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Response Helper Functions - -**File:** `backend/igny8_core/api/response.py` - -```python -from igny8_core.api.response import success_response, error_response, paginated_response - -# Success response -return success_response( - data={"id": 1, "name": "Example"}, - message="Resource created successfully", - status_code=status.HTTP_201_CREATED -) - -# Error response -return error_response( - error="Validation failed", - errors={"email": ["Invalid email format"]}, - status_code=status.HTTP_400_BAD_REQUEST -) - -# Paginated response -paginator = CustomPageNumberPagination() -page = paginator.paginate_queryset(queryset, request) -serializer = MySerializer(page, many=True) -paginated_data = paginator.get_paginated_response(serializer.data).data -return paginated_response(paginated_data, message="Resources retrieved successfully") -``` - ---- - -## Error Handling - -### HTTP Status Codes - -| Code | Meaning | Description | -|------|---------|-------------| -| 200 | OK | Request successful | -| 201 | Created | Resource created successfully | -| 204 | No Content | Resource deleted successfully | -| 400 | Bad Request | Validation error or invalid request | -| 401 | Unauthorized | Authentication required | -| 403 | Forbidden | Permission denied | -| 404 | Not Found | Resource not found | -| 409 | Conflict | Resource conflict (e.g., duplicate) | -| 422 | Unprocessable Entity | Validation failed | -| 429 | Too Many Requests | Rate limit exceeded | -| 500 | Internal Server Error | Server error | - -### Centralized Exception Handler - -**File:** `backend/igny8_core/api/exception_handlers.py` - -All exceptions are handled by a centralized exception handler that: -- Wraps all errors in unified format -- Uses proper HTTP status codes -- Includes sanitized validation errors under `errors` -- Always attaches `request_id` for error tracking -- Logs full exception details -- In DEBUG mode: includes traceback + request context - -### Error Response Examples - -**Validation Error (400):** -```json -{ - "success": false, - "error": "Validation failed", - "errors": { - "email": ["Invalid email format"], - "password": ["Password must be at least 8 characters"] - }, - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -**Authentication Error (401):** -```json -{ - "success": false, - "error": "Authentication required", - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -**Permission Error (403):** -```json -{ - "success": false, - "error": "Permission denied", - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -**Rate Limit (429):** -```json -{ - "success": false, - "error": "Rate limit exceeded", - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Server-side Logging - -- All 4xx errors logged as **warning** -- All 5xx errors logged as **error** -- Structured format with timestamp, request_id, endpoint, user_id, account_id, status_code, error_message -- Rotating log files -- Sentry integration hooks for production - ---- - -## Rate Limiting - -### Rate Limiting Configuration - -Rate limits are scoped by operation type. Check response headers for limit information: - -- `X-Throttle-Limit`: Maximum requests allowed -- `X-Throttle-Remaining`: Remaining requests in current window -- `X-Throttle-Reset`: Time when limit resets (Unix timestamp) - -### Rate Limit Scopes - -| Scope | Limit | Description | -|-------|-------|-------------| -| `ai_function` | 10/min | AI content generation, clustering | -| `image_gen` | 15/min | Image generation | -| `content_write` | 30/min | Content creation, updates | -| `content_read` | 100/min | Content listing, retrieval | -| `auth` | 20/min | Login, register, password reset | -| `auth_strict` | 5/min | Sensitive auth operations | -| `planner` | 60/min | Keyword, cluster, idea operations | -| `planner_ai` | 10/min | AI-powered planner operations | -| `writer` | 60/min | Task, content management | -| `writer_ai` | 10/min | AI-powered writer operations | -| `system` | 100/min | Settings, prompts, profiles | -| `system_admin` | 30/min | Admin-only system operations | -| `billing` | 30/min | Credit queries, usage logs | -| `billing_admin` | 10/min | Credit management (admin) | -| `default` | 100/min | Default for endpoints without scope | - -### Handling Rate Limits - -When rate limited (429), the response includes: -- Error message: "Rate limit exceeded" -- Headers with reset time -- Wait until `X-Throttle-Reset` before retrying - -**Example:** -```http -HTTP/1.1 429 Too Many Requests -X-Throttle-Limit: 60 -X-Throttle-Remaining: 0 -X-Throttle-Reset: 1700123456 -Retry-After: 60 - -{ - "success": false, - "error": "Rate limit exceeded", - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Debug Mode Bypass - -Set `IGNY8_DEBUG_THROTTLE=True` or `DEBUG=True` to bypass throttling in development. - ---- - -## Pagination - -### Pagination Configuration - -**Default Settings:** -- Default page size: **10** -- Maximum page size: **100** -- Query parameter: `page_size` (optional) -- Page parameter: `page` (default: 1) - -### Query Parameters - -**Pagination:** -``` -?page=2&page_size=25 -``` - -**Filtering:** -``` -?status=active -?site_id=1 -?sector_id=2 -?cluster_id=5 -``` - -**Search:** -``` -?search=keyword -``` - -**Ordering:** -``` -?ordering=-created_at -?ordering=name,status -``` - -### Pagination Response Format - -```json -{ - "success": true, - "count": 150, - "next": "http://api.igny8.com/api/v1/planner/keywords/?page=3&page_size=25", - "previous": "http://api.igny8.com/api/v1/planner/keywords/?page=1&page_size=25", - "results": [ - // Array of results - ], - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Pagination Fields - -- `count`: Total number of items -- `next`: URL to next page (null if last page) -- `previous`: URL to previous page (null if first page) -- `results`: Array of items for current page - ---- - -## Roles & Permissions - -### Role Hierarchy - -``` -owner > admin > editor > viewer > system_bot -``` - -### Standard Permission Classes - -**File:** `backend/igny8_core/api/permissions.py` - -| Permission Class | Description | Use Case | -|-----------------|------------|----------| -| `IsAuthenticatedAndActive` | User authenticated and active | Base permission for most endpoints | -| `HasTenantAccess` | User belongs to tenant/account | Tenant isolation | -| `IsViewerOrAbove` | Viewer, editor, admin, or owner | Read-only operations | -| `IsEditorOrAbove` | Editor, admin, or owner | Content operations | -| `IsAdminOrOwner` | Admin or owner only | Settings, keys, billing | - -### Permission Matrix by Endpoint Type - -| Endpoint Type | Required Permissions | Roles Allowed | -|--------------|---------------------|---------------| -| Public (register, login) | `AllowAny` | Anyone | -| Read-only (list, retrieve) | `IsAuthenticatedAndActive` + `HasTenantAccess` | All authenticated users | -| Content operations | `IsAuthenticatedAndActive` + `HasTenantAccess` + `IsEditorOrAbove` | Editor, Admin, Owner | -| User management | `IsAuthenticatedAndActive` + `HasTenantAccess` + `IsAdminOrOwner` | Admin, Owner | -| Billing/Transactions | `IsAuthenticatedAndActive` + `HasTenantAccess` + `IsAdminOrOwner` | Admin, Owner | -| Integration settings | `IsAuthenticatedAndActive` + `HasTenantAccess` + `IsAdminOrOwner` | Admin, Owner | - ---- - -## Tenant / Site / Sector Scoping - -### Scoping Rules - -Every resource created or fetched must be scoped by: - -1. **Account/Tenant** - User's account -2. **Site** - Specific site within account -3. **Sector** - Specific sector within site - -### Enforcement - -**Base Classes:** -- `AccountModelViewSet` - Handles account isolation -- `SiteSectorModelViewSet` - Filters queries by site/sector - -**Requirements:** -- All custom actions must use `.get_queryset()` to avoid bypassing filters -- Any ID list must be verified to belong to the authenticated tenant -- Site/sector access validated based on user role - -### Scoping Example - -```python -class KeywordViewSet(SiteSectorModelViewSet): - # Automatically filters by: - # 1. account (from request.account) - # 2. site_id (from query params or request) - # 3. sector_id (from query params or request) - - queryset = Keyword.objects.all() - serializer_class = KeywordSerializer - - def get_queryset(self): - # Base class handles account/site/sector filtering - queryset = super().get_queryset() - # Additional filtering can be added here - return queryset -``` - ---- - -## Complete Endpoint Reference - -### Authentication Endpoints - -**Base Path**: `/api/v1/auth/` - -#### POST `/api/v1/auth/register/` -**Purpose**: User registration -**Authentication**: None (AllowAny) - -**Request:** -```json -{ - "email": "user@example.com", - "password": "password123", - "password_confirm": "password123" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "user": { - "id": 1, - "email": "user@example.com", - "role": "owner", - "account": { ... } - } - }, - "message": "Registration successful" -} -``` - -#### POST `/api/v1/auth/login/` -**Purpose**: User login -**Authentication**: None (AllowAny) - -**Request:** -```json -{ - "email": "user@example.com", - "password": "password123" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "user": { ... }, - "access": "eyJ0eXAiOiJKV1QiLCJhbGc...", - "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc...", - "access_expires_at": "2025-01-XXT...", - "refresh_expires_at": "2025-01-XXT..." - }, - "message": "Login successful", - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -#### POST `/api/v1/auth/refresh/` -**Purpose**: Refresh access token -**Authentication**: None (requires refresh token) - -**Request:** -```json -{ - "refresh": "your_refresh_token" -} -``` - -#### POST `/api/v1/auth/change-password/` -**Purpose**: Change user password -**Authentication**: Required (IsAuthenticated) - -**Request:** -```json -{ - "old_password": "oldpass123", - "new_password": "newpass123", - "new_password_confirm": "newpass123" -} -``` - -#### GET `/api/v1/auth/me/` -**Purpose**: Get current user information -**Authentication**: Required (IsAuthenticated) - -**Response:** -```json -{ - "success": true, - "data": { - "user": { - "id": 1, - "email": "user@example.com", - "role": "owner", - "account": { ... }, - "accessible_sites": [ ... ] - } - } -} -``` - -### User Management Endpoints - -**Base Path**: `/api/v1/auth/users/` -**Permission**: IsOwnerOrAdmin - -**Standard CRUD:** -- `GET /api/v1/auth/users/` - List users -- `POST /api/v1/auth/users/` - Create user -- `GET /api/v1/auth/users/{id}/` - Get user -- `PUT /api/v1/auth/users/{id}/` - Update user -- `DELETE /api/v1/auth/users/{id}/` - Delete user - -**Custom Actions:** -- `POST /api/v1/auth/users/invite/` - Invite user -- `POST /api/v1/auth/users/{id}/activate/` - Activate user - -### Account Management Endpoints - -**Base Path**: `/api/v1/auth/accounts/` -**Permission**: IsOwnerOrAdmin - -**Standard CRUD:** -- `GET /api/v1/auth/accounts/` - List accounts -- `POST /api/v1/auth/accounts/` - Create account -- `GET /api/v1/auth/accounts/{id}/` - Get account -- `PUT /api/v1/auth/accounts/{id}/` - Update account -- `DELETE /api/v1/auth/accounts/{id}/` - Delete account - -### Site Management Endpoints - -**Base Path**: `/api/v1/auth/sites/` -**Permission**: IsEditorOrAbove - -**Standard CRUD:** -- `GET /api/v1/auth/sites/` - List sites -- `POST /api/v1/auth/sites/` - Create site -- `GET /api/v1/auth/sites/{id}/` - Get site -- `PUT /api/v1/auth/sites/{id}/` - Update site -- `DELETE /api/v1/auth/sites/{id}/` - Delete site - -**Custom Actions:** -- `GET /api/v1/auth/sites/{id}/sectors/` - Get site sectors -- `POST /api/v1/auth/sites/{id}/set_active/` - Set active site -- `POST /api/v1/auth/sites/{id}/select_sectors/` - Select sectors - -### Sector Management Endpoints - -**Base Path**: `/api/v1/auth/sectors/` -**Permission**: IsEditorOrAbove - -**Standard CRUD:** -- `GET /api/v1/auth/sectors/` - List sectors -- `POST /api/v1/auth/sectors/` - Create sector -- `GET /api/v1/auth/sectors/{id}/` - Get sector -- `PUT /api/v1/auth/sectors/{id}/` - Update sector -- `DELETE /api/v1/auth/sectors/{id}/` - Delete sector - -### Planner Module Endpoints - -**Base Path**: `/api/v1/planner/` - -#### Keyword Management - -**Base Path**: `/api/v1/planner/keywords/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/planner/keywords/` - List keywords (paginated) -- `POST /api/v1/planner/keywords/` - Create keyword -- `GET /api/v1/planner/keywords/{id}/` - Get keyword -- `PUT /api/v1/planner/keywords/{id}/` - Update keyword -- `DELETE /api/v1/planner/keywords/{id}/` - Delete keyword - -**Filtering:** -- `status` - Filter by status -- `cluster_id` - Filter by cluster -- `seed_keyword__intent` - Filter by intent -- `seed_keyword_id` - Filter by seed keyword ID -- `difficulty_min`, `difficulty_max` - Difficulty range -- `volume_min`, `volume_max` - Volume range -- `site_id` - Filter by site (query param) -- `sector_id` - Filter by sector (query param) - -**Search:** -- `search` - Search by keyword text - -**Ordering:** -- `ordering` - Order by `created_at`, `seed_keyword__volume`, `seed_keyword__difficulty` -- Default: `-created_at` (newest first) - -**Custom Actions:** -- `POST /api/v1/planner/keywords/bulk_delete/` - Bulk delete keywords - - Request: `{ "ids": [1, 2, 3] }` -- `POST /api/v1/planner/keywords/bulk_update_status/` - Bulk update status - - Request: `{ "ids": [1, 2, 3], "status": "active" }` -- `POST /api/v1/planner/keywords/bulk_add_from_seed/` - Add keywords from seed library - - Request: `{ "seed_keyword_ids": [1, 2, 3], "site_id": 1, "sector_id": 1 }` -- `GET /api/v1/planner/keywords/export/` - Export keywords to CSV -- `POST /api/v1/planner/keywords/import_keywords/` - Import keywords from CSV -- `POST /api/v1/planner/keywords/auto_cluster/` - Auto-cluster keywords using AI - - Request: `{ "ids": [1, 2, 3, ...], "sector_id": 1 }` - - Max Keywords: 20 per batch - - Returns: Celery task ID for progress tracking - -#### Cluster Management - -**Base Path**: `/api/v1/planner/clusters/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/planner/clusters/` - List clusters -- `POST /api/v1/planner/clusters/` - Create cluster -- `GET /api/v1/planner/clusters/{id}/` - Get cluster -- `PUT /api/v1/planner/clusters/{id}/` - Update cluster -- `DELETE /api/v1/planner/clusters/{id}/` - Delete cluster - -**Filtering:** -- `status` - Filter by status -- `site_id` - Filter by site (query param) -- `sector_id` - Filter by sector (query param) - -**Search:** -- `search` - Search by cluster name - -**Custom Actions:** -- `POST /api/v1/planner/clusters/bulk_delete/` - Bulk delete clusters -- `POST /api/v1/planner/clusters/auto_generate_ideas/` - Auto-generate content ideas - - Request: `{ "ids": [1] }` (max 1 cluster per batch) - - Returns: Celery task ID for progress tracking - -#### Content Ideas Management - -**Base Path**: `/api/v1/planner/ideas/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/planner/ideas/` - List content ideas -- `POST /api/v1/planner/ideas/` - Create content idea -- `GET /api/v1/planner/ideas/{id}/` - Get content idea -- `PUT /api/v1/planner/ideas/{id}/` - Update content idea -- `DELETE /api/v1/planner/ideas/{id}/` - Delete content idea - -**Filtering:** -- `status` - Filter by status -- `cluster_id` - Filter by cluster -- `content_type` - Filter by content type -- `site_id` - Filter by site (query param) -- `sector_id` - Filter by sector (query param) - -**Custom Actions:** -- `POST /api/v1/planner/ideas/bulk_delete/` - Bulk delete ideas -- `POST /api/v1/planner/ideas/bulk_queue_to_writer/` - Queue ideas to writer (create tasks) - - Request: `{ "ids": [1, 2, 3] }` - -### Writer Module Endpoints - -**Base Path**: `/api/v1/writer/` - -#### Task Management - -**Base Path**: `/api/v1/writer/tasks/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/writer/tasks/` - List tasks -- `POST /api/v1/writer/tasks/` - Create task -- `GET /api/v1/writer/tasks/{id}/` - Get task -- `PUT /api/v1/writer/tasks/{id}/` - Update task -- `DELETE /api/v1/writer/tasks/{id}/` - Delete task - -**Filtering:** -- `status` - Filter by status (draft, in_progress, review, completed, archived) -- `cluster_id` - Filter by cluster -- `content_type` - Filter by content type -- `content_structure` - Filter by content structure -- `site_id` - Filter by site (query param) -- `sector_id` - Filter by sector (query param) - -**Search:** -- `search` - Search by title or keywords - -**Custom Actions:** -- `POST /api/v1/writer/tasks/bulk_delete/` - Bulk delete tasks -- `POST /api/v1/writer/tasks/bulk_update/` - Bulk update task status -- `POST /api/v1/writer/tasks/auto_generate_content/` - Auto-generate content using AI - - Request: `{ "ids": [1, 2, 3, ...] }` (max 50 tasks per batch) - - Returns: Celery task ID for progress tracking - -#### Content Management - -**Base Path**: `/api/v1/writer/content/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/writer/content/` - List content -- `POST /api/v1/writer/content/` - Create content -- `GET /api/v1/writer/content/{id}/` - Get content -- `PUT /api/v1/writer/content/{id}/` - Update content -- `DELETE /api/v1/writer/content/{id}/` - Delete content - -**Filtering:** -- `status` - Filter by status -- `content_type` - Filter by content type -- `site_id` - Filter by site (query param) -- `sector_id` - Filter by sector (query param) - -**Custom Actions:** -- `POST /api/v1/writer/content/generate_image_prompts/` - Generate image prompts from content - - Request: `{ "ids": [1, 2, 3] }` - - Returns: Celery task ID for progress tracking - -#### Image Management - -**Base Path**: `/api/v1/writer/images/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/writer/images/` - List images -- `POST /api/v1/writer/images/` - Create image -- `GET /api/v1/writer/images/{id}/` - Get image -- `PUT /api/v1/writer/images/{id}/` - Update image -- `DELETE /api/v1/writer/images/{id}/` - Delete image - -**Filtering:** -- `image_type` - Filter by type (featured, in_article, desktop, mobile) -- `status` - Filter by status -- `content_id` - Filter by content -- `task_id` - Filter by task -- `site_id` - Filter by site (query param) -- `sector_id` - Filter by sector (query param) - -**Custom Actions:** -- `GET /api/v1/writer/images/{id}/file/` - Get image file URL -- `GET /api/v1/writer/images/content_images/` - Get images for content - - Query Params: `content_id` (required) -- `POST /api/v1/writer/images/generate_images/` - Generate images using AI - - Request: `{ "ids": [1, 2, 3, ...] }` - - Returns: Celery task ID for progress tracking -- `POST /api/v1/writer/images/bulk_update/` - Bulk update image status - -### System Module Endpoints - -**Base Path**: `/api/v1/system/` - -#### AI Prompt Management - -**Base Path**: `/api/v1/system/prompts/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: AccountModelViewSet - -**Standard CRUD:** -- `GET /api/v1/system/prompts/` - List prompts -- `POST /api/v1/system/prompts/` - Create prompt -- `GET /api/v1/system/prompts/{id}/` - Get prompt -- `PUT /api/v1/system/prompts/{id}/` - Update prompt -- `DELETE /api/v1/system/prompts/{id}/` - Delete prompt - -**Custom Actions:** -- `GET /api/v1/system/prompts/by_type/{prompt_type}/` - Get prompt by type -- `POST /api/v1/system/prompts/save/` - Save prompt (requires editor/admin) -- `POST /api/v1/system/prompts/reset/` - Reset prompt to default - -#### Integration Settings - -**Base Path**: `/api/v1/system/settings/integrations/` -**Permission**: IsAdminOrOwner - -**Custom URL Patterns:** -- `GET /api/v1/system/settings/integrations/{pk}/` - Get integration settings -- `POST /api/v1/system/settings/integrations/{pk}/save/` - Save integration settings -- `PUT /api/v1/system/settings/integrations/{pk}/` - Update integration settings -- `POST /api/v1/system/settings/integrations/{pk}/test/` - Test connection - - Request: `{ "provider": "openai" }` or `{ "provider": "runware" }` -- `POST /api/v1/system/settings/integrations/{pk}/generate/` - Test image generation -- `GET /api/v1/system/settings/task_progress/{task_id}/` - Get Celery task progress -- `GET /api/v1/system/integrations/image_generation/` - Get image generation settings - -#### System Status - -**Base Path**: `/api/v1/system/` - -- `GET /api/v1/system/status/` - System health check (AllowAny) -- `GET /api/v1/system/ping/` - Health check endpoint (AllowAny) -- `GET /api/v1/system/request-metrics/{request_id}/` - Get request metrics for debugging - -### Linker Module Endpoints - -**Base Path**: `/api/v1/linker/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess - -#### Process Content for Internal Linking - -- `POST /api/v1/linker/process/` - Process single content item for internal linking - -**Request:** -```json -{ - "content_id": 123 -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "content_id": 123, - "suggested_links": [ - { - "target_content_id": 456, - "target_title": "Related Article", - "anchor_text": "relevant keyword", - "confidence": 0.85 - } - ] - } -} -``` - -#### Batch Process Content - -- `POST /api/v1/linker/batch_process/` - Process multiple content items for linking - -**Request:** -```json -{ - "content_ids": [123, 456, 789] -} -``` - -### Optimizer Module Endpoints - -**Base Path**: `/api/v1/optimizer/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess - -#### Optimize Content - -- `POST /api/v1/optimizer/optimize/` - Optimize content (auto-detects entry point) - -**Request:** -```json -{ - "ids": [123, 456], - "entry_point": "seo" // Optional: "seo", "readability", "engagement" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "optimized_content": "...", - "seo_score": 85, - "readability_score": 78, - "engagement_score": 82 - } -} -``` - -#### Batch Optimize Content - -- `POST /api/v1/optimizer/batch_optimize/` - Batch optimize multiple content items - -**Request:** -```json -{ - "ids": [123, 456, 789], - "entry_point": "seo" // Optional -} -``` - -#### Analyze Content - -- `POST /api/v1/optimizer/analyze/` - Analyze content without optimization - -**Request:** -```json -{ - "id": 123 -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "seo_score": 75, - "readability_score": 70, - "engagement_score": 68, - "recommendations": [ - "Add more internal links", - "Improve heading structure" - ] - } -} -``` - -### Publisher Module Endpoints - -**Base Path**: `/api/v1/publisher/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess - -#### Publishing Records - -**Base Path**: `/api/v1/publisher/publishing-records/` -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/publisher/publishing-records/` - List publishing records (paginated) -- `POST /api/v1/publisher/publishing-records/` - Create publishing record -- `GET /api/v1/publisher/publishing-records/{id}/` - Get publishing record -- `PUT /api/v1/publisher/publishing-records/{id}/` - Update publishing record -- `DELETE /api/v1/publisher/publishing-records/{id}/` - Delete publishing record - -**Filtering:** -- `status` - Filter by publishing status -- `destination_type` - Filter by destination (wordpress, sites_renderer, etc.) -- `site_id` - Filter by site -- `sector_id` - Filter by sector - -#### Deployment Records - -**Base Path**: `/api/v1/publisher/deployments/` -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/publisher/deployments/` - List deployment records (paginated) -- `POST /api/v1/publisher/deployments/` - Create deployment record -- `GET /api/v1/publisher/deployments/{id}/` - Get deployment record -- `PUT /api/v1/publisher/deployments/{id}/` - Update deployment record -- `DELETE /api/v1/publisher/deployments/{id}/` - Delete deployment record - -#### Publishing Actions - -- `POST /api/v1/publisher/publish/` - Publish content to destination - -**Request:** -```json -{ - "content_id": 123, - "destination_type": "wordpress", - "destination_id": 1 -} -``` - -- `POST /api/v1/publisher/deploy/` - Deploy site blueprint - -**Request:** -```json -{ - "blueprint_id": 456, - "environment": "production" -} -``` - -- `POST /api/v1/publisher/check_readiness/` - Check deployment readiness - -**Request:** -```json -{ - "blueprint_id": 456 -} -``` - -#### Public Endpoint (No Authentication) - -- `GET /api/v1/publisher/sites/{site_id}/definition/` - Get site definition for Sites Renderer - -**Response:** -```json -{ - "success": true, - "data": { - "site_id": 1, - "slug": "example-site", - "blueprints": [...], - "pages": [...] - } -} -``` - -### Automation Module Endpoints - -**Base Path**: `/api/v1/automation/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess - -#### Automation Rules - -**Base Path**: `/api/v1/automation/rules/` -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/automation/rules/` - List automation rules (paginated) -- `POST /api/v1/automation/rules/` - Create automation rule -- `GET /api/v1/automation/rules/{id}/` - Get rule details -- `PUT /api/v1/automation/rules/{id}/` - Update rule -- `DELETE /api/v1/automation/rules/{id}/` - Delete rule - -**Custom Actions:** -- `POST /api/v1/automation/rules/{id}/execute/` - Manually execute automation rule - -**Filtering:** -- `status` - Filter by rule status (active, inactive) -- `trigger_type` - Filter by trigger type -- `site_id` - Filter by site -- `sector_id` - Filter by sector - -#### Scheduled Tasks - -**Base Path**: `/api/v1/automation/scheduled-tasks/` -**Inherits**: AccountModelViewSet - -**Standard CRUD:** -- `GET /api/v1/automation/scheduled-tasks/` - List scheduled tasks (paginated) -- `POST /api/v1/automation/scheduled-tasks/` - Create scheduled task -- `GET /api/v1/automation/scheduled-tasks/{id}/` - Get task details -- `PUT /api/v1/automation/scheduled-tasks/{id}/` - Update task -- `DELETE /api/v1/automation/scheduled-tasks/{id}/` - Delete task - -**Filtering:** -- `status` - Filter by task status -- `task_type` - Filter by task type -- `next_run_date` - Filter by next run date - -### Integration Module Endpoints - -**Base Path**: `/api/v1/integration/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess - -#### Site Integrations - -**Base Path**: `/api/v1/integration/integrations/` -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/integration/integrations/` - List site integrations (paginated) -- `POST /api/v1/integration/integrations/` - Create integration -- `GET /api/v1/integration/integrations/{id}/` - Get integration details -- `PUT /api/v1/integration/integrations/{id}/` - Update integration -- `DELETE /api/v1/integration/integrations/{id}/` - Delete integration - -**Custom Actions:** -- `POST /api/v1/integration/integrations/{id}/test_connection/` - Test connection to integrated platform - -**Request:** -```json -{} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "connected": true, - "message": "Connection successful" - } -} -``` - -- `POST /api/v1/integration/integrations/{id}/sync/` - Sync content with integrated platform - -**Request:** -```json -{ - "content_ids": [123, 456], - "sync_type": "full" // or "incremental" -} -``` - -- `GET /api/v1/integration/integrations/{id}/sync_health/` - Get sync health status - -**Response:** -```json -{ - "success": true, - "data": { - "last_sync": "2025-01-XXT...", - "sync_status": "healthy", - "pending_items": 0, - "failed_items": 0 - } -} -``` - -**Filtering:** -- `platform_type` - Filter by platform (wordpress, shopify, etc.) -- `status` - Filter by integration status -- `site_id` - Filter by site -- `sector_id` - Filter by sector - -### Billing Module Endpoints - -**Base Path**: `/api/v1/billing/` - -#### Credit Balance - -**Base Path**: `/api/v1/billing/credits/balance/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess - -- `GET /api/v1/billing/credits/balance/balance/` - Get credit balance - -**Response:** -```json -{ - "success": true, - "data": { - "credits": 1000, - "plan_credits_per_month": 500, - "credits_used_this_month": 250, - "credits_remaining": 750 - } -} -``` - -#### Credit Usage - -**Base Path**: `/api/v1/billing/credits/usage/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess - -**Standard CRUD:** -- `GET /api/v1/billing/credits/usage/` - List usage logs (paginated) -- `GET /api/v1/billing/credits/usage/{id}/` - Get usage log - -**Filtering:** -- `operation_type` - Filter by operation type -- `start_date` - Filter by start date (YYYY-MM-DD) -- `end_date` - Filter by end date (YYYY-MM-DD) - -**Custom Actions:** -- `GET /api/v1/billing/credits/usage/summary/` - Get usage summary -- `GET /api/v1/billing/credits/usage/limits/` - Get usage limits - -#### Credit Transactions - -**Base Path**: `/api/v1/billing/credits/transactions/` -**Permission**: IsAdminOrOwner - -**Standard CRUD:** -- `GET /api/v1/billing/credits/transactions/` - List transactions (paginated) -- `GET /api/v1/billing/credits/transactions/{id}/` - Get transaction - -**Filtering:** -- `transaction_type` - Filter by type -- `start_date` - Filter by start date -- `end_date` - Filter by end date - ---- - -## Integration Examples - -### Python - -```python -import requests - -BASE_URL = "https://api.igny8.com/api/v1" - -# Login -response = requests.post( - f"{BASE_URL}/auth/login/", - json={"email": "user@example.com", "password": "password"} -) -data = response.json() - -if data['success']: - token = data['data']['access'] - - # Use token for authenticated requests - headers = { - 'Authorization': f'Bearer {token}', - 'Content-Type': 'application/json' - } - - # Get keywords - response = requests.get( - f"{BASE_URL}/planner/keywords/?page=1&page_size=25", - headers=headers - ) - result = response.json() - - if result['success']: - keywords = result['results'] - print(f"Found {result['count']} keywords") - else: - print(f"Error: {result['error']}") -``` - -### JavaScript - -```javascript -const BASE_URL = 'https://api.igny8.com/api/v1'; - -// Login -const loginResponse = await fetch(`${BASE_URL}/auth/login/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - email: 'user@example.com', - password: 'password' - }) -}); - -const loginData = await loginResponse.json(); - -if (loginData.success) { - const token = loginData.data.access; - - // Use token for authenticated requests - const headers = { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }; - - // Get keywords - const keywordsResponse = await fetch( - `${BASE_URL}/planner/keywords/?page=1&page_size=25`, - { headers } - ); - - const keywordsData = await keywordsResponse.json(); - - if (keywordsData.success) { - const keywords = keywordsData.results; - console.log(`Found ${keywordsData.count} keywords`); - } else { - console.error('Error:', keywordsData.error); - } -} -``` - -### cURL - -```bash -# Login -curl -X POST https://api.igny8.com/api/v1/auth/login/ \ - -H "Content-Type: application/json" \ - -d '{"email":"user@example.com","password":"password"}' - -# Get keywords (with token) -curl -X GET https://api.igny8.com/api/v1/planner/keywords/ \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -H "Content-Type: application/json" -``` - -### Frontend Integration (TypeScript) - -```typescript -// Using fetchAPI wrapper (automatically extracts data from unified format) -import { fetchAPI } from './services/api'; - -// List keywords -const response = await fetchAPI('/v1/planner/keywords/?page=1&page_size=25'); -// response is already extracted: { count, next, previous, results } - -// Create keyword -const newKeyword = await fetchAPI('/v1/planner/keywords/', { - method: 'POST', - body: JSON.stringify({ - keyword: 'example keyword', - site_id: 1, - sector_id: 2, - status: 'active' - }) -}); - -// Error handling -try { - const data = await fetchAPI('/v1/planner/keywords/'); -} catch (error) { - // Error is already parsed from unified format - console.error(error.message); - if (error.errors) { - // Handle field-specific errors - Object.keys(error.errors).forEach(field => { - console.error(`${field}: ${error.errors[field].join(', ')}`); - }); - } -} -``` - -### WordPress Plugin Integration (PHP) - -```php -class Igny8API { - private $base_url = 'https://api.igny8.com/api/v1'; - private $token; - - public function login($email, $password) { - $response = wp_remote_post($this->base_url . '/auth/login/', [ - 'headers' => ['Content-Type' => 'application/json'], - 'body' => json_encode(['email' => $email, 'password' => $password]) - ]); - - $data = json_decode(wp_remote_retrieve_body($response), true); - - if ($data['success']) { - $this->token = $data['data']['access']; - return true; - } - - return false; - } - - public function getKeywords($page = 1, $page_size = 25) { - $response = wp_remote_get( - $this->base_url . '/planner/keywords/?page=' . $page . '&page_size=' . $page_size, - [ - 'headers' => [ - 'Authorization' => 'Bearer ' . $this->token, - 'Content-Type' => 'application/json' - ] - ] - ); - - $data = json_decode(wp_remote_retrieve_body($response), true); - - if ($data['success']) { - return $data['results']; - } - - return []; - } -} -``` - ---- - -## Testing & Debugging - -### Request ID Tracking - -Every API request includes a unique `request_id` in the response. Use this ID for: -- Debugging issues -- Log correlation -- Support requests - -The `request_id` is included in: -- All success responses -- All error responses -- Response headers (`X-Request-ID`) - -### Progress Tracking (AI Functions) - -All AI functions return a Celery task ID for progress tracking: - -**Request:** -```json -POST /api/v1/planner/keywords/auto_cluster/ -{ - "ids": [1, 2, 3, 4, 5], - "sector_id": 1 -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "task_id": "abc123-def456-ghi789" - } -} -``` - -**Poll Progress:** -```json -GET /api/v1/system/settings/task_progress/abc123-def456-ghi789/ -``` - -**Progress Response:** -```json -{ - "state": "PROGRESS", - "meta": { - "phase": "AI_CALL", - "percentage": 50, - "message": "Processing keywords...", - "request_steps": [...], - "response_steps": [...], - "cost": 0.05, - "tokens": 1500 - } -} -``` - -### Error Handling Best Practices - -**401 Unauthorized**: Trigger logout → redirect to login -**403 Forbidden**: Show permission alert -**429 Too Many Requests**: Show rate limit warning with retry time -**4xx/5xx**: Display error message from `error` field - -### Rate Limit Monitoring - -```typescript -const throttleLimit = response.headers.get('X-Throttle-Limit'); -const throttleRemaining = response.headers.get('X-Throttle-Remaining'); - -if (parseInt(throttleRemaining) < 5) { - showNotification('Approaching rate limit', 'warning'); -} -``` - ---- - -## Change Management - -### Versioning Strategy - -- **v1** remains stable long-term -- Breaking changes require **v2** -- Deprecations allowed only with explicit timeline -- Non-breaking changes can be added to v1 - -### Breaking Changes - -**Definition:** Changes that require client code updates - -**Examples:** -- Removing an endpoint -- Changing response structure -- Changing authentication method -- Removing a field from response - -**Process:** -1. Document breaking change -2. Provide migration guide -3. Deprecate in v1 with timeline -4. Implement in v2 -5. Maintain v1 for deprecation period - -### Non-Breaking Changes - -**Definition:** Changes that don't require client code updates - -**Examples:** -- Adding new endpoints -- Adding optional fields to response -- Adding new query parameters -- Performance improvements - -### Changelog - -All API changes are documented in `CHANGELOG.md` with: -- Version number -- Date -- Type (Added, Changed, Fixed, Deprecated, Removed, Security) -- Description -- Affected areas -- Migration notes (if applicable) - ---- - -## Summary - -### Implementation Status - -✅ **100% Complete** - All endpoints implemented with: -- Unified response format (`{success, data, message, errors, request_id}`) -- Proper authentication and authorization (JWT Bearer tokens) -- Rate limiting configured (scoped by operation type) -- Error handling standardized (centralized exception handler) -- Request ID tracking (every request has unique ID) -- Complete Swagger/OpenAPI documentation -- Tenant/site/sector scoping (automatic filtering) -- Pagination standardized (default: 10, max: 100) - -### Key Features - -- **100+ endpoints** across 5 modules -- **Unified response format** for all endpoints -- **Scoped rate limiting** (10-100 requests/minute depending on operation) -- **Layered authorization** (Authentication → Tenant → Role → Site/Sector) -- **Complete OpenAPI documentation** (Swagger UI, ReDoc) -- **Request ID tracking** for debugging -- **Progress tracking** for AI functions -- **Comprehensive error handling** with clear messages - -### Access Points - -- **Interactive Documentation**: `https://api.igny8.com/api/docs/` -- **ReDoc**: `https://api.igny8.com/api/redoc/` -- **OpenAPI Schema**: `https://api.igny8.com/api/schema/` - ---- - -**Last Updated**: 2025-01-XX (Added 6 missing modules: Linker, Optimizer, Publisher, Site Builder, Automation, Integration) -**API Version**: 1.0.0 -**Status**: ✅ **100% IMPLEMENTED** - All 10 modules documented with complete endpoint reference - diff --git a/approved-docs/02-PLANNER-WRITER-WORKFLOW-TECHNICAL-GUIDE.md b/approved-docs/02-PLANNER-WRITER-WORKFLOW-TECHNICAL-GUIDE.md deleted file mode 100644 index 2bc613a7..00000000 --- a/approved-docs/02-PLANNER-WRITER-WORKFLOW-TECHNICAL-GUIDE.md +++ /dev/null @@ -1,1467 +0,0 @@ -# IGNY8 Planner → Writer Workflow Documentation -**Complete Technical Implementation Guide** -**Date:** November 24, 2025 -**Version:** 1.0 - ---- - - -## Table of Contents -1. [Overview](#overview) -2. [Database Tables & Models](#database-tables--models) -3. [Frontend Pages & Components](#frontend-pages--components) -4. [Backend API Endpoints](#backend-api-endpoints) -5. [Complete Workflow](#complete-workflow) -6. [AI Functions](#ai-functions) -7. [Flowchart Diagrams](#flowchart-diagrams) - ---- - -## Overview - -The IGNY8 content workflow consists of two primary modules: -- **Planner Module**: Keyword research, clustering, and content ideation -- **Writer Module**: Task management, content generation, and publishing - -Data flows sequentially through these stages: -``` -Keywords → Clusters → Ideas → Tasks → Content → Images -``` - ---- - -## Database Tables & Models - -### Planner Module Tables - -#### 1. `igny8_keywords` (Keywords Model) -**Location:** `backend/igny8_core/business/planning/models.py` - -| Field | Type | Description | Choices | -|-------|------|-------------|---------| -| `id` | Integer | Primary key | - | -| `seed_keyword_id` | ForeignKey | Reference to global SeedKeyword | - | -| `volume_override` | Integer | Site-specific volume override | - | -| `difficulty_override` | Integer | Site-specific difficulty override | - | -| `cluster_id` | ForeignKey | Parent cluster | Clusters | -| `status` | CharField(50) | Keyword status | active, pending, archived | -| `account` | ForeignKey | Owner account | - | -| `site` | ForeignKey | Parent site | - | -| `sector` | ForeignKey | Parent sector | - | -| `created_at` | DateTime | Creation timestamp | - | -| `updated_at` | DateTime | Last update timestamp | - | - -**Indexes:** -- `seed_keyword` -- `status` -- `cluster` -- `site, sector` -- `seed_keyword, site, sector` (unique) - -**Properties (Computed from SeedKeyword):** -- `keyword` - Text from seed_keyword.keyword -- `volume` - volume_override or seed_keyword.volume -- `difficulty` - difficulty_override or seed_keyword.difficulty -- `intent` - seed_keyword.intent - ---- - -#### 2. `igny8_clusters` (Clusters Model) -**Location:** `backend/igny8_core/business/planning/models.py` - -| Field | Type | Description | Choices | -|-------|------|-------------|---------| -| `id` | Integer | Primary key | - | -| `name` | CharField(255) | Cluster name (unique) | - | -| `description` | TextField | Cluster description | - | -| `keywords_count` | Integer | Number of keywords | - | -| `volume` | Integer | Total search volume | - | -| `mapped_pages` | Integer | Number of mapped pages | - | -| `status` | CharField(50) | Cluster status | active, archived | -| `context_type` | CharField(50) | Cluster dimension | topic, attribute, service_line | -| `dimension_meta` | JSONField | Extended metadata | - | -| `account` | ForeignKey | Owner account | - | -| `site` | ForeignKey | Parent site | - | -| `sector` | ForeignKey | Parent sector | - | -| `created_at` | DateTime | Creation timestamp | - | -| `updated_at` | DateTime | Last update timestamp | - | - -**Indexes:** -- `name` -- `status` -- `site, sector` -- `context_type` - -**Relationships:** -- `keywords` (1:M) - Related Keywords -- `ideas` (1:M) - Related ContentIdeas -- `tasks` (1:M) - Related Tasks -- `contents` (1:M) - Related Content - ---- - -#### 3. `igny8_content_ideas` (ContentIdeas Model) -**Location:** `backend/igny8_core/business/planning/models.py` - -| Field | Type | Description | Choices | -|-------|------|-------------|---------| -| `id` | Integer | Primary key | - | -| `idea_title` | CharField(255) | Idea title | - | -| `description` | TextField | Idea description | - | -| `target_keywords` | CharField(500) | Comma-separated keywords (legacy) | - | -| `keyword_objects` | ManyToManyField | Keywords linked to idea | Keywords | -| `keyword_cluster_id` | ForeignKey | Parent cluster | Clusters | -| `taxonomy_id` | ForeignKey | Optional taxonomy association | ContentTaxonomy | -| `status` | CharField(50) | Idea status | new, scheduled, published | -| `estimated_word_count` | Integer | Target word count | - | -| `site_entity_type` | CharField(50) | Target entity type | post, page, product, service, taxonomy_term | -| `cluster_role` | CharField(50) | Role within cluster | hub, supporting, attribute | -| `account` | ForeignKey | Owner account | - | -| `site` | ForeignKey | Parent site | - | -| `sector` | ForeignKey | Parent sector | - | -| `created_at` | DateTime | Creation timestamp | - | -| `updated_at` | DateTime | Last update timestamp | - | - -**Indexes:** -- `idea_title` -- `status` -- `keyword_cluster` -- `site_entity_type` -- `cluster_role` -- `site, sector` - ---- - -### Writer Module Tables - -#### 4. `igny8_tasks` (Tasks Model) -**Location:** `backend/igny8_core/business/content/models.py` - -| Field | Type | Description | Choices | -|-------|------|-------------|---------| -| `id` | Integer | Primary key | - | -| `title` | CharField(255) | Task title | - | -| `description` | TextField | Task description | - | -| `keywords` | CharField(500) | Comma-separated keywords (legacy) | - | -| `cluster_id` | ForeignKey | Parent cluster | Clusters | -| `keyword_objects` | ManyToManyField | Keywords linked to task | Keywords | -| `idea_id` | ForeignKey | Source idea | ContentIdeas | -| `status` | CharField(50) | Task status | queued, in_progress, completed, failed | -| `entity_type` | CharField(50) | Content entity type | post, page, product, service, taxonomy_term | -| `taxonomy_id` | ForeignKey | Taxonomy association | ContentTaxonomy | -| `cluster_role` | CharField(50) | Role within cluster | hub, supporting, attribute | -| `account` | ForeignKey | Owner account | - | -| `site` | ForeignKey | Parent site | - | -| `sector` | ForeignKey | Parent sector | - | -| `created_at` | DateTime | Creation timestamp | - | -| `updated_at` | DateTime | Last update timestamp | - | - -**Indexes:** -- `title` -- `status` -- `cluster` -- `entity_type` -- `cluster_role` -- `site, sector` - -**Relationships:** -- `content_record` (1:1) - Related Content (OneToOneField) - ---- - -#### 5. `igny8_content` (Content Model) -**Location:** `backend/igny8_core/business/content/models.py` - -| Field | Type | Description | Choices | -|-------|------|-------------|---------| -| `id` | Integer | Primary key | - | -| `task_id` | OneToOneField | Parent task | Tasks | -| `html_content` | TextField | Final HTML content | - | -| `word_count` | Integer | Content word count | - | -| `metadata` | JSONField | Additional metadata | - | -| `title` | CharField(255) | Content title | - | -| `meta_title` | CharField(255) | SEO meta title | - | -| `meta_description` | TextField | SEO meta description | - | -| `primary_keyword` | CharField(255) | Primary keyword | - | -| `secondary_keywords` | JSONField | List of secondary keywords | - | -| `status` | CharField(50) | Content workflow status | draft, review, publish | -| `source` | CharField(50) | Content source | igny8, wordpress, shopify, custom | -| `sync_status` | CharField(50) | Sync status | native, imported, synced | -| `external_id` | CharField(255) | External platform ID | - | -| `external_url` | URLField | External platform URL | - | -| `external_type` | CharField(100) | External post type | - | -| `sync_metadata` | JSONField | Platform-specific sync metadata | - | -| `internal_links` | JSONField | Internal links (linker) | - | -| `linker_version` | Integer | Linker processing version | - | -| `optimizer_version` | Integer | Optimizer processing version | - | -| `optimization_scores` | JSONField | Optimization scores | - | -| `entity_type` | CharField(50) | Content entity type | post, page, product, service, taxonomy_term | -| `content_format` | CharField(50) | Content format (posts only) | article, listicle, guide, comparison, review, roundup | -| `cluster_role` | CharField(50) | Role within cluster | hub, supporting, attribute | -| `json_blocks` | JSONField | Structured content blocks | - | -| `structure_data` | JSONField | Content structure data | - | -| `taxonomies` | ManyToManyField | Associated taxonomy terms | ContentTaxonomy | -| `cluster_id` | ForeignKey | Primary semantic cluster | Clusters | -| `account` | ForeignKey | Owner account | - | -| `site` | ForeignKey | Parent site | - | -| `sector` | ForeignKey | Parent sector | - | -| `generated_at` | DateTime | Generation timestamp | - | -| `updated_at` | DateTime | Last update timestamp | - | - -**Indexes:** -- `task` -- `generated_at` -- `source` -- `sync_status` -- `source, sync_status` -- `entity_type` -- `content_format` -- `cluster_role` -- `cluster` -- `external_type` -- `site, entity_type` - ---- - -#### 6. `igny8_images` (Images Model) -**Location:** `backend/igny8_core/business/content/models.py` - -| Field | Type | Description | Choices | -|-------|------|-------------|---------| -| `id` | Integer | Primary key | - | -| `content_id` | ForeignKey | Parent content | Content | -| `image_type` | CharField(50) | Image type | featured, in_article | -| `position` | Integer | Position in article | - | -| `prompt` | TextField | Image generation prompt | - | -| `image_url` | URLField | Generated image URL | - | -| `status` | CharField(50) | Image status | pending, generated, failed | -| `provider` | CharField(50) | Image generation provider | dall-e, stable-diffusion, midjourney | -| `model` | CharField(100) | Image generation model | - | -| `error_message` | TextField | Error message (if failed) | - | -| `metadata` | JSONField | Additional metadata | - | -| `account` | ForeignKey | Owner account | - | -| `site` | ForeignKey | Parent site | - | -| `sector` | ForeignKey | Parent sector | - | -| `created_at` | DateTime | Creation timestamp | - | -| `updated_at` | DateTime | Last update timestamp | - | - -**Indexes:** -- `content` -- `image_type` -- `status` -- `site, sector` - ---- - -## Frontend Pages & Components - -### Planner Module Pages - -#### 1. Keywords Page (`/planner/keywords`) -**Component:** `frontend/src/pages/Planner/Keywords.tsx` - -**Key Functions:** -- `loadKeywords()` - Fetch keywords from API with filters/pagination -- `handleCreateSeedKeyword()` - Attach SeedKeyword to site/sector -- `handleEdit(keyword)` - Edit keyword (override volume/difficulty, assign cluster) -- `handleBulkAction('auto_cluster', ids)` - AI clustering (max 20 keywords) -- `handleBulkDelete(ids)` - Delete keywords -- `handleBulkUpdateStatus(ids, status)` - Update status -- `handleExport()` - Export keywords to CSV -- `handleImportClick()` - Import keywords from CSV - -**API Calls:** -```typescript -fetchKeywords(filters: KeywordFilters): Promise> -createKeyword(data: KeywordCreateData): Promise -updateKeyword(id: number, data: KeywordCreateData): Promise -deleteKeyword(id: number): Promise -bulkDeleteKeywords(ids: number[]): Promise<{ deleted_count: number }> -bulkUpdateKeywordsStatus(ids: number[], status: string): Promise<{ updated_count: number }> -autoClusterKeywords(ids: number[], sectorId?: number): Promise -``` - -**State Management:** -- `keywords` - Keyword list -- `clusters` - Cluster dropdown options -- `availableSeedKeywords` - Available SeedKeywords for attachment -- `searchTerm`, `statusFilter`, `clusterFilter`, `intentFilter`, `difficultyFilter`, `volumeMin`, `volumeMax` - Filters -- `currentPage`, `totalPages`, `totalCount` - Pagination -- `sortBy`, `sortDirection` - Sorting -- `selectedIds` - Bulk selection - -**AI Function Logs:** -When Resource Debug is enabled, logs all AI function calls: -- Request: keyword_ids, sector_id -- Success: task_id (async) or clusters_created, keywords_updated (sync) -- Progress steps: phase, percentage, message -- Errors: error message - ---- - -#### 2. Clusters Page (`/planner/clusters`) -**Component:** `frontend/src/pages/Planner/Clusters.tsx` - -**Key Functions:** -- `loadClusters()` - Fetch clusters from API -- `handleCreateCluster()` - Create new cluster manually -- `handleEdit(cluster)` - Edit cluster name/description -- `handleRowAction('generate_ideas', cluster)` - Generate ideas for single cluster -- `handleBulkAction('auto_generate_ideas', ids)` - Generate ideas for multiple clusters (max 5) -- `handleBulkDelete(ids)` - Delete clusters -- `handleBulkUpdateStatus(ids, status)` - Update status - -**API Calls:** -```typescript -fetchClusters(filters: ClusterFilters): Promise> -createCluster(data: ClusterCreateData): Promise -updateCluster(id: number, data: ClusterCreateData): Promise -deleteCluster(id: number): Promise -bulkDeleteClusters(ids: number[]): Promise<{ deleted_count: number }> -bulkUpdateClustersStatus(ids: number[], status: string): Promise<{ updated_count: number }> -autoGenerateIdeas(clusterIds: number[]): Promise -``` - -**State Management:** -- `clusters` - Cluster list -- `searchTerm`, `statusFilter`, `difficultyFilter`, `volumeMin`, `volumeMax` - Filters -- `currentPage`, `totalPages`, `totalCount` - Pagination -- `sortBy`, `sortDirection` - Sorting -- `selectedIds` - Bulk selection - -**AI Progress Modal:** -Shows progress for `auto_generate_ideas` async tasks: -- Title: "Generating Content Ideas" -- Function ID: `ai-generate-ideas-01-desktop` -- Displays: phase, percentage, message, step logs - ---- - -#### 3. Ideas Page (`/planner/ideas`) -**Component:** `frontend/src/pages/Planner/Ideas.tsx` - -**Key Functions:** -- `loadIdeas()` - Fetch content ideas from API -- `handleCreateIdea()` - Create new idea manually -- `handleEdit(idea)` - Edit idea title/description/cluster -- `handleRowAction('queue_to_writer', idea)` - Convert single idea to task -- `handleBulkAction('queue_to_writer', ids)` - Convert multiple ideas to tasks -- `handleBulkDelete(ids)` - Delete ideas -- `handleBulkUpdateStatus(ids, status)` - Update status - -**API Calls:** -```typescript -fetchContentIdeas(filters: ContentIdeasFilters): Promise> -createContentIdea(data: ContentIdeaCreateData): Promise -updateContentIdea(id: number, data: ContentIdeaCreateData): Promise -deleteContentIdea(id: number): Promise -bulkDeleteContentIdeas(ids: number[]): Promise<{ deleted_count: number }> -bulkUpdateContentIdeasStatus(ids: number[], status: string): Promise<{ updated_count: number }> -bulkQueueIdeasToWriter(ids: number[]): Promise<{ created_count: number }> -``` - -**State Management:** -- `ideas` - Idea list -- `clusters` - Cluster dropdown options -- `searchTerm`, `statusFilter`, `clusterFilter`, `structureFilter`, `typeFilter`, `entityTypeFilter` - Filters -- `currentPage`, `totalPages`, `totalCount` - Pagination -- `sortBy`, `sortDirection` - Sorting -- `selectedIds` - Bulk selection - -**Queue to Writer Logic:** -Only ideas with `status='new'` can be queued. Creates Tasks with: -- `title` = `idea_title` -- `description` = `idea.description` -- `keywords` = `idea.target_keywords` -- `cluster` = `idea.keyword_cluster` -- `idea` = `idea.id` -- `status` = 'queued' -- `entity_type` = `idea.site_entity_type` -- `cluster_role` = `idea.cluster_role` -- `taxonomy` = `idea.taxonomy` - ---- - -### Writer Module Pages - -#### 4. Tasks Page (`/writer/tasks`) -**Component:** `frontend/src/pages/Writer/Tasks.tsx` - -**Key Functions:** -- `loadTasks()` - Fetch tasks from API -- `handleCreateTask()` - Create new task manually -- `handleEdit(task)` - Edit task title/description/cluster -- `handleRowAction('generate_content', task)` - Generate content for single task (AI) -- `handleBulkAction('generate_images', ids)` - Generate images for multiple tasks (max 10) -- `handleBulkDelete(ids)` - Delete tasks -- `handleBulkUpdateStatus(ids, status)` - Update status - -**API Calls:** -```typescript -fetchTasks(filters: TasksFilters): Promise> -createTask(data: TaskCreateData): Promise -updateTask(id: number, data: TaskCreateData): Promise -deleteTask(id: number): Promise -bulkDeleteTasks(ids: number[]): Promise<{ deleted_count: number }> -bulkUpdateTasksStatus(ids: number[], status: string): Promise<{ updated_count: number }> -autoGenerateContent(taskIds: number[]): Promise -autoGenerateImages(taskIds: number[]): Promise -``` - -**State Management:** -- `tasks` - Task list -- `clusters` - Cluster dropdown options -- `searchTerm`, `statusFilter`, `clusterFilter`, `structureFilter`, `typeFilter`, `sourceFilter`, `entityTypeFilter` - Filters -- `currentPage`, `totalPages`, `totalCount` - Pagination -- `sortBy`, `sortDirection` - Sorting -- `selectedIds` - Bulk selection -- `aiLogs` - AI function logs (when Resource Debug enabled) - -**AI Function Logs:** -Logs all AI function calls: -- Request: task_ids, task_title -- Success: task_id (async) or tasks_updated, images_created (sync) -- Progress steps: phase, percentage, message -- Errors: error message - -**AI Progress Modal:** -Shows progress for `auto_generate_content` async tasks: -- Title: "Generating Content" -- Function ID: `ai-generate-content-03` -- Displays: phase, percentage, message, step logs - ---- - -#### 5. Content Page (`/writer/content`) -**Component:** `frontend/src/pages/Writer/Content.tsx` - -**Key Functions:** -- `loadContent()` - Fetch content from API -- `handleRowAction('generate_image_prompts', content)` - Generate AI image prompts -- `handleRowAction('optimize', content)` - Run optimizer on content -- `handleRowAction('send_to_optimizer', content)` - Navigate to optimizer page - -**API Calls:** -```typescript -fetchContent(filters: ContentFilters): Promise> -generateImagePrompts(contentIds: number[]): Promise -optimizerApi.optimize(contentId: number, source: string): Promise -``` - -**State Management:** -- `content` - Content list -- `searchTerm`, `statusFilter`, `sourceFilter`, `syncStatusFilter` - Filters -- `currentPage`, `totalPages`, `totalCount` - Pagination -- `sortBy`, `sortDirection` - Sorting -- `selectedIds` - Bulk selection - -**Content Row Actions:** -1. **View** - Navigate to `/writer/content/:id` (ContentView page) -2. **Generate Image Prompts** - AI function to create smart image prompts -3. **Optimize** - Run optimizer to improve SEO scores -4. **Send to Optimizer** - Navigate to `/optimizer/content?contentId=:id` - ---- - -#### 6. Images Page (`/writer/images`) -**Component:** `frontend/src/pages/Writer/Images.tsx` - -**Key Functions:** -- `loadImages()` - Fetch content images grouped by content -- `handleRowAction('update_status', imageGroup)` - Update all images for content -- `handleGenerateImages(contentId)` - Generate images using AI (opens ImageQueueModal) -- `handleBulkExport(ids)` - Export image metadata - -**API Calls:** -```typescript -fetchContentImages(filters: {}): Promise -fetchImageGenerationSettings(): Promise -generateImages(contentId: number, imageType: string, prompt: string, provider: string, model: string): Promise<{ image_url: string }> -bulkUpdateImagesStatus(contentId: number, status: string): Promise<{ updated_count: number }> -``` - -**State Management:** -- `images` - ContentImagesGroup list (one row per content) -- `searchTerm`, `statusFilter` - Filters -- `currentPage`, `totalPages`, `totalCount` - Client-side pagination -- `sortBy`, `sortDirection` - Client-side sorting -- `selectedIds` - Bulk selection -- `imageQueue` - Image generation queue items -- `taskId`, `imageModel`, `imageProvider` - Image generation settings - -**ImageQueueModal:** -Displays real-time image generation progress: -- Featured image (always first) -- In-article images (up to max_in_article_images from settings) -- Shows: index, label, type, prompt, status, progress, image preview -- Updates via polling or WebSocket - -**Content Images Group Structure:** -```typescript -{ - content_id: number; - content_title: string; - featured_image: { id, prompt, image_url, status, provider, model }; - in_article_images: [{ id, position, prompt, image_url, status, provider, model }]; - overall_status: 'pending' | 'partial' | 'complete' | 'failed'; - total_images: number; - generated_images: number; -} -``` - ---- - -## Backend API Endpoints - -### Planner Module Endpoints - -#### Keywords API -**Base URL:** `/v1/planner/keywords/` - -| Endpoint | Method | Function | Description | -|----------|--------|----------|-------------| -| `/` | GET | `list()` | List keywords with filters/pagination | -| `/` | POST | `create()` | Create new keyword (attach SeedKeyword) | -| `/:id/` | GET | `retrieve()` | Get single keyword details | -| `/:id/` | PUT/PATCH | `update()` | Update keyword | -| `/:id/` | DELETE | `destroy()` | Delete keyword | -| `/bulk_delete/` | POST | `bulk_delete()` | Delete multiple keywords | -| `/bulk_update_status/` | POST | `bulk_update_status()` | Update status for multiple keywords | -| `/auto_cluster/` | POST | `auto_cluster()` | AI clustering function | -| `/export/` | GET | `export()` | Export keywords to CSV | -| `/import/` | POST | `import()` | Import keywords from CSV | - -**ViewSet:** `KeywordsViewSet` in `backend/igny8_core/modules/planner/views.py` - ---- - -#### Clusters API -**Base URL:** `/v1/planner/clusters/` - -| Endpoint | Method | Function | Description | -|----------|--------|----------|-------------| -| `/` | GET | `list()` | List clusters with filters/pagination | -| `/` | POST | `create()` | Create new cluster | -| `/:id/` | GET | `retrieve()` | Get single cluster details | -| `/:id/` | PUT/PATCH | `update()` | Update cluster | -| `/:id/` | DELETE | `destroy()` | Delete cluster | -| `/bulk_delete/` | POST | `bulk_delete()` | Delete multiple clusters | -| `/bulk_update_status/` | POST | `bulk_update_status()` | Update status for multiple clusters | -| `/auto_generate_ideas/` | POST | `auto_generate_ideas()` | AI idea generation function | - -**ViewSet:** `ClusterViewSet` in `backend/igny8_core/modules/planner/views.py` - ---- - -#### Content Ideas API -**Base URL:** `/v1/planner/content-ideas/` - -| Endpoint | Method | Function | Description | -|----------|--------|----------|-------------| -| `/` | GET | `list()` | List ideas with filters/pagination | -| `/` | POST | `create()` | Create new idea | -| `/:id/` | GET | `retrieve()` | Get single idea details | -| `/:id/` | PUT/PATCH | `update()` | Update idea | -| `/:id/` | DELETE | `destroy()` | Delete idea | -| `/bulk_delete/` | POST | `bulk_delete()` | Delete multiple ideas | -| `/bulk_update_status/` | POST | `bulk_update_status()` | Update status for multiple ideas | -| `/bulk_queue_to_writer/` | POST | `bulk_queue_to_writer()` | Convert ideas to tasks | - -**ViewSet:** `ContentIdeasViewSet` in `backend/igny8_core/modules/planner/views.py` - ---- - -### Writer Module Endpoints - -#### Tasks API -**Base URL:** `/v1/writer/tasks/` - -| Endpoint | Method | Function | Description | -|----------|--------|----------|-------------| -| `/` | GET | `list()` | List tasks with filters/pagination | -| `/` | POST | `create()` | Create new task | -| `/:id/` | GET | `retrieve()` | Get single task details | -| `/:id/` | PUT/PATCH | `update()` | Update task | -| `/:id/` | DELETE | `destroy()` | Delete task | -| `/bulk_delete/` | POST | `bulk_delete()` | Delete multiple tasks | -| `/bulk_update_status/` | POST | `bulk_update_status()` | Update status for multiple tasks | -| `/auto_generate_content/` | POST | `auto_generate_content()` | AI content generation function | - -**ViewSet:** `TasksViewSet` in `backend/igny8_core/modules/writer/views.py` - ---- - -#### Content API -**Base URL:** `/v1/writer/content/` - -| Endpoint | Method | Function | Description | -|----------|--------|----------|-------------| -| `/` | GET | `list()` | List content with filters/pagination | -| `/:id/` | GET | `retrieve()` | Get single content details | -| `/:id/` | PUT/PATCH | `update()` | Update content | -| `/:id/` | DELETE | `destroy()` | Delete content | -| `/generate_image_prompts/` | POST | `generate_image_prompts()` | AI image prompt generation | - -**ViewSet:** `ContentViewSet` in `backend/igny8_core/modules/writer/views.py` - ---- - -#### Images API -**Base URL:** `/v1/writer/images/` - -| Endpoint | Method | Function | Description | -|----------|--------|----------|-------------| -| `/` | GET | `list()` | List images with filters/pagination | -| `/content/` | GET | `by_content()` | List images grouped by content | -| `/settings/` | GET | `get_settings()` | Get image generation settings | -| `/auto_generate/` | POST | `auto_generate_images()` | AI bulk image generation (deprecated) | -| `/generate_images/` | POST | `generate_images()` | Generate images for content | -| `/bulk_update_status/` | POST | `bulk_update_status()` | Update status for content images | - -**ViewSet:** `ImagesViewSet` in `backend/igny8_core/modules/writer/views.py` - ---- - -## Complete Workflow - -### Stage 1: Keyword Research & Clustering - -#### Step 1.1: Attach SeedKeywords to Site -**Frontend:** Keywords Page → "Add Keyword" button -**Function:** `handleCreateSeedKeyword()` -**API Call:** `POST /v1/planner/keywords/` - -**Request Payload:** -```json -{ - "seed_keyword_id": 123, - "site_id": 1, - "sector_id": 2, - "volume_override": null, - "difficulty_override": null, - "cluster_id": null, - "status": "pending" -} -``` - -**Backend Process:** -1. Validate `seed_keyword_id` exists in `seed_keywords` table -2. Validate `site_id` and `sector_id` exist and match -3. Check unique constraint: `seed_keyword + site + sector` -4. Create `Keywords` record with account/site/sector -5. Return created keyword with computed properties - -**Response:** -```json -{ - "success": true, - "data": { - "id": 456, - "seed_keyword_id": 123, - "keyword": "best coffee beans", - "volume": 12000, - "difficulty": 45, - "intent": "commercial", - "cluster_id": null, - "status": "pending", - "created_at": "2025-11-24T10:30:00Z" - }, - "message": "Keyword attached successfully" -} -``` - ---- - -#### Step 1.2: AI Auto-Clustering -**Frontend:** Keywords Page → Select keywords → Bulk Actions → "Auto-Cluster" -**Function:** `handleBulkAction('auto_cluster', ids)` -**API Call:** `POST /v1/planner/keywords/auto_cluster/` - -**Request Payload:** -```json -{ - "ids": [456, 457, 458, 459, 460], - "sector_id": 2 -} -``` - -**Backend Process (`auto_cluster()` view):** -```python -# backend/igny8_core/modules/planner/views.py line 573 -def auto_cluster(self, request): - keyword_ids = request.data.get('ids', []) - sector_id = request.data.get('sector_id') - account = getattr(request, 'account', None) - - # Use ClusteringService - service = ClusteringService() - result = service.cluster_keywords(keyword_ids, account, sector_id) - - if result.get('success'): - if 'task_id' in result: - # Async task queued - return success_response(data={'task_id': result['task_id']}, message='Clustering started') - else: - # Synchronous execution - return success_response(data=result) -``` - -**ClusteringService Logic:** -**Location:** `backend/igny8_core/business/planning/services/clustering_service.py` - -1. **Load Keywords:** - - Fetch `Keywords` by IDs - - Get keyword text, volume, difficulty, intent from seed_keyword - -2. **Check Credits:** - - Calculate credits needed (based on keyword count) - - Deduct credits from account - -3. **AI Clustering:** - - Send keywords to AI engine - - Prompt: "Group these keywords into semantic clusters..." - - AI returns: `{ clusters: [{ name, keywords: [ids] }] }` - -4. **Create/Update Clusters:** - - For each AI cluster: - - Check if cluster with same name exists - - If exists, use existing; else create new `Clusters` record - - Update `Keywords.cluster_id` for all keywords in group - - Calculate `keywords_count`, `volume`, `difficulty` for cluster - -5. **Return Result:** - - Sync: `{ success: true, clusters_created: 3, keywords_updated: 20 }` - - Async: `{ success: true, task_id: 'uuid', message: 'Clustering started' }` - -**Response (Async):** -```json -{ - "success": true, - "data": { - "task_id": "abc123-def456-ghi789" - }, - "message": "Clustering started" -} -``` - -**Frontend Progress Tracking:** -```typescript -// Opens ProgressModal -progressModal.openModal(result.task_id, 'Auto-Clustering Keywords', 'ai-auto-cluster-01'); - -// ProgressModal polls /v1/system/task-progress/:task_id/ every 2 seconds -// Shows: phase, percentage, message, step logs -``` - -**AI Task Progress Phases:** -1. **Initializing** (0-10%) - Validating keywords, loading data -2. **Processing** (10-40%) - Sending to AI engine -3. **AI Analysis** (40-80%) - AI clustering keywords -4. **Saving Results** (80-95%) - Creating clusters, updating keywords -5. **Completed** (100%) - Done - ---- - -### Stage 2: Content Idea Generation - -#### Step 2.1: AI Generate Ideas from Clusters -**Frontend:** Clusters Page → Select clusters → Bulk Actions → "Generate Ideas" -**Function:** `handleBulkAction('auto_generate_ideas', ids)` -**API Call:** `POST /v1/planner/clusters/auto_generate_ideas/` - -**Request Payload:** -```json -{ - "ids": [10, 11, 12] -} -``` - -**Backend Process (`auto_generate_ideas()` view):** -```python -# backend/igny8_core/modules/planner/views.py line 810 -def auto_generate_ideas(self, request): - cluster_ids = request.data.get('ids', []) - account = getattr(request, 'account', None) - - # Use IdeasService - service = IdeasService() - result = service.generate_ideas(cluster_ids, account) - - if result.get('success'): - if 'task_id' in result: - # Async task queued - return success_response(data={'task_id': result['task_id']}, message='Idea generation started') - else: - # Synchronous execution - return success_response(data=result) -``` - -**IdeasService Logic:** -**Location:** `backend/igny8_core/business/planning/services/ideas_service.py` - -1. **Load Clusters:** - - Fetch `Clusters` by IDs with keywords - - Get cluster name, description, keywords, volume - -2. **Check Credits:** - - Calculate credits needed (based on cluster count) - - Deduct credits from account - -3. **AI Idea Generation:** - - For each cluster: - - Send cluster data to AI engine - - Prompt: "Generate 5-10 content ideas for cluster '{name}' with keywords [{keywords}]..." - - AI returns: `[{ title, description, target_keywords, entity_type, cluster_role }]` - -4. **Create Ideas:** - - For each AI idea: - - Create `ContentIdeas` record - - Set `keyword_cluster_id` = cluster ID - - Set `status` = 'new' - - Set `site_entity_type`, `cluster_role` from AI - - Link keywords via `keyword_objects` M2M - -5. **Return Result:** - - Sync: `{ success: true, ideas_created: 25 }` - - Async: `{ success: true, task_id: 'uuid', message: 'Idea generation started' }` - -**Response (Async):** -```json -{ - "success": true, - "data": { - "task_id": "xyz789-uvw456-rst123" - }, - "message": "Idea generation started" -} -``` - -**AI Task Progress Phases:** -1. **Initializing** (0-10%) - Loading clusters, keywords -2. **Analyzing Clusters** (10-30%) - Analyzing keyword patterns -3. **Generating Ideas** (30-90%) - AI creating ideas (per cluster) -4. **Saving Ideas** (90-100%) - Creating ContentIdeas records - ---- - -#### Step 2.2: Queue Ideas to Writer -**Frontend:** Ideas Page → Select ideas (status='new') → Bulk Actions → "Queue to Writer" -**Function:** `handleBulkAction('queue_to_writer', ids)` -**API Call:** `POST /v1/planner/content-ideas/bulk_queue_to_writer/` - -**Request Payload:** -```json -{ - "ids": [50, 51, 52] -} -``` - -**Backend Process (`bulk_queue_to_writer()` view):** -```python -# backend/igny8_core/modules/planner/views.py line 997 -def bulk_queue_to_writer(self, request): - ids = request.data.get('ids', []) - queryset = self.get_queryset() - ideas = queryset.filter(id__in=ids, status='new') # Only 'new' ideas - - created_tasks = [] - for idea in ideas: - task = Tasks.objects.create( - title=idea.idea_title, - description=idea.description or '', - keywords=idea.target_keywords or '', - cluster=idea.keyword_cluster, - idea=idea, - status='queued', - account=idea.account, - site=idea.site, - sector=idea.sector, - entity_type=(idea.site_entity_type or 'post'), - taxonomy=idea.taxonomy, - cluster_role=(idea.cluster_role or 'hub'), - ) - created_tasks.append(task.id) - - # Update idea status to 'scheduled' - idea.status = 'scheduled' - idea.save() - - return success_response(data={'created_count': len(created_tasks)}) -``` - -**Response:** -```json -{ - "success": true, - "data": { - "created_count": 3 - }, - "message": "Queue complete: 3 tasks created from 3 ideas" -} -``` - ---- - -### Stage 3: Content Generation - -#### Step 3.1: AI Generate Content for Task -**Frontend:** Tasks Page → Row Action → "Generate Content" -**Function:** `handleRowAction('generate_content', task)` -**API Call:** `POST /v1/writer/tasks/auto_generate_content/` - -**Request Payload:** -```json -{ - "ids": [100] -} -``` - -**Backend Process (`auto_generate_content()` view):** -```python -# backend/igny8_core/modules/writer/views.py line 150 -def auto_generate_content(self, request): - ids = request.data.get('ids', []) - account = getattr(request, 'account', None) - - # Validate tasks exist - existing_tasks = queryset.filter(id__in=ids, account=account) - - # Use ContentGenerationService - service = ContentGenerationService() - result = service.generate_content(ids, account) - - if result.get('success'): - if 'task_id' in result: - # Async task queued - return success_response(data={'task_id': result['task_id']}, message='Content generation started') - else: - # Synchronous execution - return success_response(data=result, message='Content generated successfully') -``` - -**ContentGenerationService Logic:** -**Location:** `backend/igny8_core/business/content/services/content_generation_service.py` - -1. **Load Tasks:** - - Fetch `Tasks` by IDs with cluster, keywords - - Validate task status (can generate for any status) - -2. **Check Credits:** - - Calculate credits needed (based on word count target) - - Deduct credits from account - -3. **AI Content Generation:** - - For each task: - - Build AI prompt with: - - Title: `task.title` - - Keywords: `task.keywords` or cluster keywords - - Entity type: `task.entity_type` - - Cluster role: `task.cluster_role` - - Word count: estimate from idea or default - - Send to AI engine - - AI returns: `{ html_content, word_count, meta_title, meta_description, primary_keyword, secondary_keywords }` - -4. **Create/Update Content:** - - Check if `task.content_record` exists (OneToOne) - - If exists: update content - - If not: create new `Content` record - - Set fields: - - `html_content` = AI output - - `word_count` = AI calculated - - `meta_title`, `meta_description` = AI SEO - - `primary_keyword`, `secondary_keywords` = AI keywords - - `status` = 'draft' - - `source` = 'igny8' - - `sync_status` = 'native' - - `entity_type` = `task.entity_type` - - `cluster_role` = `task.cluster_role` - - `cluster` = `task.cluster` - - `structure_data` = AI metadata - -5. **Update Task Status:** - - Set `task.status` = 'completed' - -6. **Return Result:** - - Sync: `{ success: true, tasks_updated: 1 }` - - Async: `{ success: true, task_id: 'uuid', message: 'Content generation started' }` - -**Response (Async):** -```json -{ - "success": true, - "data": { - "task_id": "content-abc123-def456" - }, - "message": "Content generation started" -} -``` - -**AI Task Progress Phases:** -1. **Initializing** (0-10%) - Loading tasks, keywords -2. **Research** (10-30%) - Analyzing keywords, cluster -3. **Outlining** (30-50%) - Creating content structure -4. **Writing** (50-90%) - Generating content sections -5. **SEO Optimization** (90-95%) - Adding meta tags, keywords -6. **Saving** (95-100%) - Creating Content record - ---- - -### Stage 4: Image Generation - -#### Step 4.1: Generate Image Prompts (Smart Prompts) -**Frontend:** Content Page → Row Action → "Generate Image Prompts" -**Function:** `handleRowAction('generate_image_prompts', content)` -**API Call:** `POST /v1/writer/content/generate_image_prompts/` - -**Request Payload:** -```json -{ - "ids": [200] -} -``` - -**Backend Process (`generate_image_prompts()` view):** -```python -# backend/igny8_core/modules/writer/views.py line 793 -@action(detail=False, methods=['post'], url_path='generate_image_prompts', url_name='generate_image_prompts') -def generate_image_prompts(self, request): - ids = request.data.get('ids', []) - account = getattr(request, 'account', None) - - # Use ImagePromptService - service = ImagePromptService() - result = service.generate_prompts(ids, account) - - if result.get('success'): - if 'task_id' in result: - return success_response(data={'task_id': result['task_id']}, message='Prompt generation started') - else: - return success_response(data=result) -``` - -**ImagePromptService Logic:** - -1. **Load Content:** - - Fetch `Content` by IDs with `html_content` - - Parse HTML to identify image placement needs - -2. **Analyze Content:** - - Identify sections needing images - - Extract context for each image (surrounding text) - - Determine image type (featured, in-article) - -3. **AI Prompt Generation:** - - For each image placement: - - Send section context to AI - - Prompt: "Create a detailed image prompt for section about '{context}'..." - - AI returns: detailed DALL-E/Stable Diffusion prompt - -4. **Create Image Records:** - - For featured image: create `Images` record with `image_type='featured'` - - For in-article images: create `Images` records with `image_type='in_article'`, `position=N` - - Set `prompt` = AI output - - Set `status` = 'pending' - - Set `provider`, `model` from settings - -5. **Return Result:** - - `{ success: true, prompts_created: 5 }` - ---- - -#### Step 4.2: Generate Images from Prompts -**Frontend:** Images Page → Row Action → "Generate Images" -**Function:** `handleGenerateImages(contentId)` -**Opens:** `ImageQueueModal` - -**Modal Process:** -1. **Fetch Settings:** - - `GET /v1/writer/images/settings/` - - Returns: `{ max_in_article_images, default_provider, default_model }` - -2. **Build Queue:** - - Load content images: `fetchContentImages({ content_id: contentId })` - - Filter images with `status='pending'` and `prompt` - - Order: featured first, then in-article by position - - Limit: max_in_article_images from settings - -3. **Generate Images Sequentially:** - - For each queue item: - - Update UI: status='generating', progress=10% - - Call: `POST /v1/writer/images/generate_images/` - ```json - { - "content_id": 200, - "image_type": "featured", - "prompt": "Professional coffee beans in burlap sack...", - "provider": "dall-e", - "model": "dall-e-3" - } - ``` - - Backend calls image provider API (DALL-E, Stable Diffusion) - - Updates `Images` record: - - `image_url` = provider response - - `status` = 'generated' or 'failed' - - `error_message` = if failed - - Update UI: status='generated', progress=100%, imageUrl=... - -4. **Completion:** - - All images generated - - Update overall status badge - - Reload images list - -**Backend Image Generation:** -```python -# backend/igny8_core/modules/writer/views.py line 634 -@action(detail=False, methods=['post'], url_path='generate_images', url_name='generate_images') -def generate_images(self, request): - content_id = request.data.get('content_id') - image_type = request.data.get('image_type') - prompt = request.data.get('prompt') - provider = request.data.get('provider', 'dall-e') - model = request.data.get('model', 'dall-e-3') - - # Deduct credits - # Call image provider API - # Update Images record with image_url or error - - return success_response(data={'image_url': 'https://...'}) -``` - ---- - -## AI Functions - -### 1. Auto-Cluster Keywords -**Function ID:** `ai-auto-cluster-01` -**API Endpoint:** `POST /v1/planner/keywords/auto_cluster/` -**Service:** `ClusteringService` -**Location:** `backend/igny8_core/business/planning/services/clustering_service.py` - -**Input:** -- `ids`: Keyword IDs (max 20) -- `sector_id`: Sector ID - -**Process:** -1. Load keywords with seed_keyword data -2. Check credits (1 credit per 5 keywords) -3. Send to AI: keyword texts, volumes, difficulties, intents -4. AI groups keywords into semantic clusters -5. Create/update Clusters records -6. Update Keywords.cluster_id - -**Output:** -- Async: `{ task_id }` -- Sync: `{ clusters_created, keywords_updated }` - -**Credits:** 1 credit per 5 keywords (rounded up) - ---- - -### 2. Auto-Generate Ideas -**Function ID:** `ai-generate-ideas-01-desktop` -**API Endpoint:** `POST /v1/planner/clusters/auto_generate_ideas/` -**Service:** `IdeasService` -**Location:** `backend/igny8_core/business/planning/services/ideas_service.py` - -**Input:** -- `ids`: Cluster IDs (max 5) - -**Process:** -1. Load clusters with keywords -2. Check credits (2 credits per cluster) -3. For each cluster: - - Send cluster name, keywords, volumes to AI - - AI generates 5-10 content ideas -4. Create ContentIdeas records - -**Output:** -- Async: `{ task_id }` -- Sync: `{ ideas_created }` - -**Credits:** 2 credits per cluster - ---- - -### 3. Auto-Generate Content -**Function ID:** `ai-generate-content-03` -**API Endpoint:** `POST /v1/writer/tasks/auto_generate_content/` -**Service:** `ContentGenerationService` -**Location:** `backend/igny8_core/business/content/services/content_generation_service.py` - -**Input:** -- `ids`: Task IDs (max 10) - -**Process:** -1. Load tasks with clusters, keywords -2. Check credits (based on word count target) -3. For each task: - - Build AI prompt with title, keywords, entity_type - - AI generates HTML content, meta tags, SEO -4. Create/update Content records -5. Update task status to 'completed' - -**Output:** -- Async: `{ task_id }` -- Sync: `{ tasks_updated }` - -**Credits:** 1 credit per 500 words (estimated) - ---- - -### 4. Generate Image Prompts -**Function ID:** `ai-generate-image-prompts-01-desktop` -**API Endpoint:** `POST /v1/writer/content/generate_image_prompts/` -**Service:** `ImagePromptService` -**Location:** `backend/igny8_core/business/content/services/image_prompt_service.py` - -**Input:** -- `ids`: Content IDs - -**Process:** -1. Load content with HTML -2. Analyze content sections -3. For each image placement: - - Send section context to AI - - AI creates detailed image prompt -4. Create Images records with prompts - -**Output:** -- Async: `{ task_id }` -- Sync: `{ prompts_created }` - -**Credits:** 0.5 credits per prompt - ---- - -### 5. Generate Images -**Function ID:** N/A (synchronous) -**API Endpoint:** `POST /v1/writer/images/generate_images/` -**Service:** Direct image provider call - -**Input:** -- `content_id`: Content ID -- `image_type`: 'featured' or 'in_article' -- `prompt`: Image generation prompt -- `provider`: 'dall-e', 'stable-diffusion', 'midjourney' -- `model`: Provider-specific model - -**Process:** -1. Deduct credits (based on provider/model) -2. Call image provider API (DALL-E, Stable Diffusion) -3. Update Images record with image_url or error - -**Output:** -- `{ image_url: 'https://...' }` - -**Credits:** -- DALL-E 3: 4 credits per image -- DALL-E 2: 2 credits per image -- Stable Diffusion: 1 credit per image - ---- - -## Flowchart Diagrams - -### Complete Workflow Flowchart - -```mermaid -graph TD - Start([User starts workflow]) --> SelectSite[Select Site & Sector] - SelectSite --> AttachKeywords[Attach SeedKeywords
POST /keywords/] - - AttachKeywords --> ManualCluster{Manual or
AI Clustering?} - ManualCluster -->|Manual| EditKeywords[Edit Keywords
Assign to Cluster] - ManualCluster -->|AI| AutoCluster[Auto-Cluster
POST /keywords/auto_cluster/] - - AutoCluster --> ClusteringProgress{Async
Task?} - ClusteringProgress -->|Yes| PollClustering[Poll Progress
GET /task-progress/:id/] - ClusteringProgress -->|No| ClustersCreated[Clusters Created] - PollClustering --> ClustersCreated - EditKeywords --> ClustersCreated - - ClustersCreated --> ManualIdeas{Manual or
AI Ideas?} - ManualIdeas -->|Manual| CreateIdeas[Create Ideas Manually
POST /content-ideas/] - ManualIdeas -->|AI| AutoIdeas[Auto-Generate Ideas
POST /clusters/auto_generate_ideas/] - - AutoIdeas --> IdeasProgress{Async
Task?} - IdeasProgress -->|Yes| PollIdeas[Poll Progress
GET /task-progress/:id/] - IdeasProgress -->|No| IdeasCreated[Ideas Created] - PollIdeas --> IdeasCreated - CreateIdeas --> IdeasCreated - - IdeasCreated --> QueueToWriter[Queue to Writer
POST /content-ideas/bulk_queue_to_writer/] - QueueToWriter --> TasksCreated[Tasks Created
status='queued'] - - TasksCreated --> ManualContent{Manual or
AI Content?} - ManualContent -->|Manual| WriteContent[Write Content Manually] - ManualContent -->|AI| GenerateContent[Generate Content
POST /tasks/auto_generate_content/] - - GenerateContent --> ContentProgress{Async
Task?} - ContentProgress -->|Yes| PollContent[Poll Progress
GET /task-progress/:id/] - ContentProgress -->|No| ContentCreated[Content Created] - PollContent --> ContentCreated - WriteContent --> ContentCreated - - ContentCreated --> ImagePrompts{Generate
Image Prompts?} - ImagePrompts -->|Yes| AIPrompts[Generate Image Prompts
POST /content/generate_image_prompts/] - ImagePrompts -->|No| ManualPrompts[Write Prompts Manually] - - AIPrompts --> PromptsCreated[Image Records Created
status='pending'] - ManualPrompts --> PromptsCreated - - PromptsCreated --> GenerateImages{Generate
Images?} - GenerateImages -->|Yes| ImageQueue[Open ImageQueueModal
Sequential Generation] - GenerateImages -->|No| SkipImages[Skip Images] - - ImageQueue --> ImageLoop[For Each Image:
POST /images/generate_images/] - ImageLoop --> ImageCreated[Image Generated
status='generated'] - ImageCreated --> MoreImages{More
Images?} - MoreImages -->|Yes| ImageLoop - MoreImages -->|No| AllImagesComplete[All Images Complete] - - AllImagesComplete --> ReviewContent[Review Content
status='review'] - SkipImages --> ReviewContent - - ReviewContent --> PublishContent[Publish Content
status='publish'] - PublishContent --> End([Workflow Complete]) - - %% Styling - classDef aiFunction fill:#9f7aea,stroke:#805ad5,stroke-width:2px,color:#fff - classDef userAction fill:#48bb78,stroke:#38a169,stroke-width:2px,color:#fff - classDef decision fill:#ed8936,stroke:#dd6b20,stroke-width:2px,color:#fff - classDef progress fill:#4299e1,stroke:#3182ce,stroke-width:2px,color:#fff - - class AutoCluster,AutoIdeas,GenerateContent,AIPrompts,ImageQueue aiFunction - class AttachKeywords,EditKeywords,CreateIdeas,WriteContent,ManualPrompts,ReviewContent,PublishContent userAction - class ManualCluster,ManualIdeas,ManualContent,ImagePrompts,GenerateImages,ClusteringProgress,IdeasProgress,ContentProgress,MoreImages decision - class PollClustering,PollIdeas,PollContent progress -``` - ---- - -### AI Function Progress Tracking - -```mermaid -sequenceDiagram - participant U as User (Frontend) - participant A as API Endpoint - participant S as Service Layer - participant AI as AI Engine - participant C as Celery Worker - participant DB as Database - - U->>A: POST /keywords/auto_cluster/ {ids: [1,2,3]} - A->>S: ClusteringService.cluster_keywords() - S->>DB: Check credits - DB-->>S: Credits sufficient - S->>C: Queue async task - C-->>S: task_id - S-->>A: {success: true, task_id: 'abc123'} - A-->>U: {task_id: 'abc123', message: 'Clustering started'} - - U->>U: Open ProgressModal - - loop Poll every 2 seconds - U->>A: GET /task-progress/abc123/ - A->>DB: Get task progress - DB-->>A: {phase, percentage, message} - A-->>U: {status, percentage, message, details} - U->>U: Update progress bar - end - - Note over C,AI: Celery worker processes task - C->>AI: Send keywords to AI - AI-->>C: {clusters: [{name, keywords}]} - C->>DB: Create Clusters, update Keywords - DB-->>C: Success - C->>DB: Update task progress: 100%, completed - - U->>A: GET /task-progress/abc123/ - A->>DB: Get task progress - DB-->>A: {status: 'completed', percentage: 100} - A-->>U: {status: 'completed', percentage: 100} - U->>U: Close modal, show success toast - U->>A: GET /keywords/ (reload data) - A->>DB: Fetch updated keywords - DB-->>A: Keywords with cluster_id set - A-->>U: Updated keywords list -``` - ---- - -### Image Generation Queue Flow - -```mermaid -sequenceDiagram - participant U as User (Frontend) - participant M as ImageQueueModal - participant A as API Endpoint - participant P as Image Provider - participant DB as Database - - U->>A: GET /images/content/?content_id=200 - A->>DB: Fetch content images - DB-->>A: {featured_image, in_article_images[]} - A-->>U: Content images data - - U->>U: Click "Generate Images" - U->>A: GET /images/settings/ - A-->>U: {max_in_article_images: 3, default_provider: 'dall-e'} - - U->>M: Open ImageQueueModal - M->>M: Build queue: [featured, in-article-1, in-article-2, in-article-3] - M->>U: Display queue items (status: pending) - - loop For each queue item - M->>M: Update UI: status='generating', progress=10% - M->>A: POST /images/generate_images/ {content_id, image_type, prompt, provider, model} - A->>DB: Deduct credits - A->>P: Generate image (DALL-E API) - P-->>A: {image_url: 'https://...'} - A->>DB: Update Images record (image_url, status='generated') - DB-->>A: Success - A-->>M: {success: true, image_url: 'https://...'} - M->>M: Update UI: status='generated', progress=100%, show image - end - - M->>M: All images complete - M->>U: Show success message, close modal option - U->>U: Close modal - U->>A: GET /images/content/ (reload) - A->>DB: Fetch updated images - DB-->>A: All images with status='generated' - A-->>U: Updated images list -``` - ---- - -## Summary - -This workflow documentation covers: - -✅ **Database Tables:** All 6 core models with fields, types, indexes, and relationships -✅ **Frontend Pages:** 6 pages with all functions, API calls, and state management -✅ **Backend APIs:** All endpoints with methods, functions, and ViewSets -✅ **Complete Workflow:** Step-by-step from keywords to published content -✅ **AI Functions:** 5 AI functions with inputs, processes, outputs, and credits -✅ **Flowcharts:** 3 detailed diagrams (complete workflow, progress tracking, image generation) - -**Key Workflow Paths:** - -1. **Manual Path:** User creates everything manually (keywords → clusters → ideas → tasks → content) -2. **AI-Assisted Path:** User uses AI functions at each stage (auto-cluster, auto-generate ideas, auto-generate content) -3. **Hybrid Path:** Mix of manual and AI (manual keywords, AI clustering, manual ideas, AI content) - -**Credits System:** -- Auto-Cluster: 1 credit per 5 keywords -- Generate Ideas: 2 credits per cluster -- Generate Content: 1 credit per 500 words -- Image Prompts: 0.5 credits per prompt -- Generate Images: 1-4 credits per image (provider-dependent) - -**Progress Tracking:** -All async AI functions return `task_id`, which is polled via `/task-progress/:id/` to show real-time progress with phases, percentages, and step logs. - ---- - -**End of Documentation** diff --git a/approved-docs/05-WRITER-IMAGES-PAGE-SYSTEM-DESIGN.md b/approved-docs/05-WRITER-IMAGES-PAGE-SYSTEM-DESIGN.md deleted file mode 100644 index d5e3e90c..00000000 --- a/approved-docs/05-WRITER-IMAGES-PAGE-SYSTEM-DESIGN.md +++ /dev/null @@ -1,1228 +0,0 @@ -# Writer Images Page - Complete System Design & Implementation Document - -**Version**: 1.0 -**Last Updated**: 2025-11-28 -**Status**: Fully Implemented with WordPress Publishing Integration -**Scope**: End-to-End Analysis of `/writer/images` Page with Manual & Automated Publishing - ---- - -## Table of Contents - -1. [Overview](#overview) -2. [Architecture](#architecture) -3. [Component Structure](#component-structure) -4. [State Management](#state-management) -5. [API Functions Chain](#api-functions-chain) -6. [Data Flow & Lifecycle](#data-flow--lifecycle) -7. [WordPress Publishing System](#wordpress-publishing-system) -8. [Automated Publishing](#automated-publishing) -9. [Sync Functions](#sync-functions) -10. [Error Handling](#error-handling) -11. [Performance Optimizations](#performance-optimizations) - ---- - -## Overview - -The `/writer/images` page is a comprehensive content image management interface that enables users to: - -- **View** grouped content with featured and in-article images -- **Generate** AI images with real-time progress tracking -- **Publish** content to WordPress (manual or bulk) -- **Update** image statuses -- **Monitor** publication sync status - -### Key Features - -- ✅ Client-side pagination, filtering, and sorting -- ✅ Real-time image generation queue with modal -- ✅ Individual and bulk WordPress publishing -- ✅ Unified API response handling -- ✅ Automatic status synchronization with WordPress -- ✅ Comprehensive error handling with retry logic -- ✅ Resource debug logging (AI Function Logs) - ---- - -## Architecture - -### High-Level System Flow - -``` -User Interaction (Images Page) - ↓ -Frontend Page Component (Images.tsx) - ↓ -Configuration Layer (images.config.tsx, table-actions.config.tsx) - ↓ -API Service Layer (api.ts with fetchAPI) - ↓ -Unified Response Handler (success/error extraction) - ↓ -Backend Endpoints (/v1/writer/images/*, /v1/publisher/publish/) - ↓ -WordPress Bridge Integration - ↓ -WordPress Site Publication -``` - -### Technology Stack - -| Layer | Technology | Purpose | -|-------|-----------|---------| -| **Frontend** | React 18 + TypeScript | UI Component Framework | -| **State Management** | React Hooks (useState, useCallback, useEffect) | Local component state | -| **API Client** | `fetchAPI()` from services/api.ts | Unified API communication | -| **UI Components** | TablePageTemplate, Modal Components | Reusable UI elements | -| **Backend API** | Django REST Framework | REST endpoints (/v1/writer/images/, /v1/publisher/publish/) | -| **Publishing** | WordPress REST API via Bridge | Content publication target | - ---- - -## Component Structure - -### Main Component: `Images.tsx` - -**File**: `frontend/src/pages/Writer/Images.tsx` -**Lines**: 738 total -**Purpose**: Main page component managing all image-related operations - -#### Component Hierarchy - -``` -Images (Page Component) -├── PageHeader (with navigation tabs) -├── TablePageTemplate (main data table) -│ ├── Filter UI -│ ├── Table Rows (ContentImagesGroup) -│ ├── Pagination Controls -│ └── Action Buttons (Row & Bulk Actions) -├── ImageQueueModal (image generation progress) -├── SingleRecordStatusUpdateModal (status updates) -└── Modal (image preview) -``` - -### Configuration Components - -#### 1. **images.config.tsx** -- Defines page columns (featured image, in-article images 1-5+) -- Filter configurations (search, status) -- Header metrics (calculations) -- Column rendering functions - -#### 2. **table-actions.config.tsx** -- **Path**: `/writer/images` -- **Row Actions**: - - `publish_wordpress` - Publish single content to WordPress - - `update_status` - Change image status -- **Bulk Actions**: - - `bulk_publish_wordpress` - Publish multiple items -- **Visibility Logic** (shouldShow): - ```typescript - shouldShow: (row: any) => { - return row.status === 'published' && - (!row.external_id || !row.external_url) && - (!row.sync_status || row.sync_status !== 'published'); - } - ``` - ---- - -## State Management - -### Page-Level State Variables - -```typescript -// Data State -const [images, setImages] = useState([]); -const [loading, setLoading] = useState(true); - -// Filter State -const [searchTerm, setSearchTerm] = useState(''); -const [statusFilter, setStatusFilter] = useState(''); -const [selectedIds, setSelectedIds] = useState([]); - -// Pagination State (Client-side) -const [currentPage, setCurrentPage] = useState(1); -const [totalPages, setTotalPages] = useState(1); -const [totalCount, setTotalCount] = useState(0); -const pageSize = 10; // Items per page - -// Sorting State -const [sortBy, setSortBy] = useState('content_title'); -const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); - -// Image Generation Modal -const [isQueueModalOpen, setIsQueueModalOpen] = useState(false); -const [imageQueue, setImageQueue] = useState([]); -const [currentContentId, setCurrentContentId] = useState(null); -const [taskId, setTaskId] = useState(null); -const [imageModel, setImageModel] = useState(null); -const [imageProvider, setImageProvider] = useState(null); - -// Status Update Modal -const [isStatusModalOpen, setIsStatusModalOpen] = useState(false); -const [statusUpdateContentId, setStatusUpdateContentId] = useState(null); -const [statusUpdateRecordName, setStatusUpdateRecordName] = useState(''); -const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); - -// Image Preview Modal -const [isImageModalOpen, setIsImageModalOpen] = useState(false); -const [modalImageUrl, setModalImageUrl] = useState(null); - -// Debug State -const [aiLogs, setAiLogs] = useState>([]); // AI Function Logs -``` - ---- - -## API Functions Chain - -### Complete API Imports - -```typescript -import { - fetchContentImages, // GET /v1/writer/images/content_images/ - ContentImagesGroup, // Type definition - ContentImagesResponse, // Type definition - fetchImageGenerationSettings, // GET /v1/system/integrations/image_generation/ - generateImages, // POST /v1/writer/images/generate_images/ - bulkUpdateImagesStatus, // POST /v1/writer/images/bulk_update/ - ContentImage, // Type definition - fetchAPI, // Unified API fetch wrapper -} from '../../services/api'; -``` - -### API Function Definitions (From api.ts) - -#### 1. **fetchContentImages()** - -```typescript -export async function fetchContentImages( - filters: ContentImagesFilters = {} -): Promise { - const params = new URLSearchParams(); - - // Auto-inject site filter - if (!filters.site_id) { - const activeSiteId = getActiveSiteId(); - if (activeSiteId) { - filters.site_id = activeSiteId; - } - } - - // Auto-inject sector filter - if (filters.sector_id === undefined) { - const activeSectorId = getActiveSectorId(); - if (activeSectorId !== null && activeSectorId !== undefined) { - filters.sector_id = activeSectorId; - } - } - - if (filters.site_id) params.append('site_id', filters.site_id.toString()); - if (filters.sector_id) params.append('sector_id', filters.sector_id.toString()); - - const queryString = params.toString(); - return fetchAPI(`/v1/writer/images/content_images/${queryString ? `?${queryString}` : ''}`); -} -``` - -**Purpose**: Fetch content images grouped by content -**Endpoint**: `GET /v1/writer/images/content_images/` -**Response Type**: `ContentImagesResponse` - -**Response Structure**: -```typescript -{ - count: number; - results: ContentImagesGroup[]; -} - -// ContentImagesGroup structure: -{ - content_id: number; - content_title: string; - featured_image: ContentImage | null; // Featured image - in_article_images: ContentImage[]; // 1-5+ in-article images - overall_status: 'pending' | 'partial' | 'complete' | 'failed'; - status?: string; // 'published' or 'draft' - external_id?: string; // WordPress post ID - external_url?: string; // WordPress post URL - sync_status?: string; // 'published', 'synced', etc. -} - -// ContentImage structure: -{ - id: number; - image_type: string; // 'featured' or 'in_article' - image_url?: string | null; // Generated image URL - image_path?: string | null; // Local file path - prompt?: string | null; // Generation prompt - status: string; // 'pending', 'generated', 'failed' - position: number; // Order for in-article images - created_at: string; - updated_at: string; -} -``` - -#### 2. **generateImages()** - -```typescript -export async function generateImages( - imageIds: number[], - contentId?: number -): Promise<{ success: boolean; task_id?: string; message?: string; error?: string }> { - try { - const response = await fetchAPI('/v1/writer/images/generate_images/', { - method: 'POST', - body: JSON.stringify({ - ids: imageIds, - content_id: contentId - }), - }); - - return { success: true, ...response } as any; - } catch (error: any) { - if (error.response && typeof error.response === 'object') { - return { success: false, error: error.message, ...error.response } as any; - } - throw error; - } -} -``` - -**Purpose**: Start image generation for specified image IDs -**Endpoint**: `POST /v1/writer/images/generate_images/` -**Request Body**: -```json -{ - "ids": [123, 124, 125], - "content_id": 42 -} -``` - -**Response**: -```json -{ - "success": true, - "task_id": "celery-task-uuid", - "message": "Image generation started", - "images_created": 3 -} -``` - -#### 3. **bulkUpdateImagesStatus()** - -```typescript -export async function bulkUpdateImagesStatus( - contentId: number, - status: string -): Promise<{ updated_count: number }> { - return fetchAPI(`/v1/writer/images/bulk_update/`, { - method: 'POST', - body: JSON.stringify({ content_id: contentId, status }), - }); -} -``` - -**Purpose**: Update status for all images of a content -**Endpoint**: `POST /v1/writer/images/bulk_update/` -**Request Body**: -```json -{ - "content_id": 42, - "status": "generated" -} -``` - -**Response**: -```json -{ - "updated_count": 4 -} -``` - -#### 4. **fetchImageGenerationSettings()** - -```typescript -export async function fetchImageGenerationSettings(): Promise { - return fetchAPI('/v1/system/integrations/image_generation/'); -} -``` - -**Purpose**: Get image generation configuration -**Endpoint**: `GET /v1/system/integrations/image_generation/` -**Response**: -```typescript -{ - success: boolean; - config: { - provider: string; // 'openai', 'stability', etc. - model: string; // 'dall-e-3', 'stable-diffusion', etc. - image_type: string; // 'featured' or 'in_article' - max_in_article_images: number; // Usually 2-5 - image_format: string; // 'jpg', 'png', etc. - desktop_enabled: boolean; - mobile_enabled: boolean; - } -} -``` - -#### 5. **fetchAPI()** - Unified API Wrapper - -```typescript -export async function fetchAPI( - endpoint: string, - options?: RequestInit & { timeout?: number } -): Promise { - // 1. Auto-injects JWT token from auth store - // 2. Handles 401 with token refresh - // 3. Extracts unified response format - // 4. Transforms error responses - // 5. Handles network errors -} -``` - -**Response Handling**: -- **Unified Success**: `{ success: true, data: {...} }` → returns `data` -- **Paginated**: `{ success: true, count: X, results: [...] }` → returns as-is -- **Unified Error**: `{ success: false, error: "..." }` → throws error -- **Non-200 Status**: Throws with error details - ---- - -## Data Flow & Lifecycle - -### 1. **Page Load Flow** - -```sequence -Page Mount - → loadImages() called in useEffect - → fetchContentImages({}) - → API: GET /v1/writer/images/content_images/ - ↓ - Response: {count: X, results: [ContentImagesGroup]} - ← Parse & filter (client-side) - • Search filter - • Status filter - • Sort by title/status - • Paginate (10 per page) - → setImages(paginatedResults) - → setShowContent(true) - → UI renders table with data -``` - -### 2. **Image Generation Flow** - -```sequence -User clicks "Generate Images" button for content - → handleGenerateImages(contentId) triggered - → Fetch max_in_article_images from settings - → Build image queue from pending images - → Open ImageQueueModal with queue - -User clicks "Start Generation" - → generateImages(imageIds, contentId) called - → API: POST /v1/writer/images/generate_images/ - ↓ - Returns task_id (Celery task) - ← Modal starts polling for progress - • Updates progress bars per image - • Shows real-time generation status - -Generation Complete - → Modal closes - → loadImages() called to refresh - → Table shows generated images -``` - -### 3. **Manual WordPress Publishing Flow** - -```sequence -User clicks "Publish to WordPress" on a row - → handleRowAction('publish_wordpress', row) triggered - → Check conditions: - • row.status === 'published' - • No external_id OR No external_url - • sync_status !== 'published' - - → fetchAPI('/v1/publisher/publish/', { - method: 'POST', - body: { - content_id: row.content_id, - destinations: ['wordpress'] - } - }) - - → Backend: /api/v1/publisher/publish/ - ↓ - Receives: {content_id: 42, destinations: ['wordpress']} - - Calls: PublisherService.publish_content() - → Finds Content object by ID - → Maps content to WordPress post format - → Creates/updates post via WordPress Bridge - → Sets external_id, external_url, sync_status - - Returns: { - success: true, - data: { - content_id: 42, - external_id: "5678", - external_url: "https://site.com/post-title", - sync_status: "published" - } - } - - ← Frontend receives response - → toast.success('Published to WordPress') - → loadImages() to refresh status - → Table updates to show published status -``` - -### 4. **Bulk WordPress Publishing Flow** - -```sequence -User selects multiple items - → Click "Publish Ready to WordPress" - → handleBulkAction('bulk_publish_wordpress', ids) triggered - - → Filter items that are ready: - const readyItems = images - .filter(item => ids.includes(item.content_id)) - .filter(item => - item.status === 'published' && - (!item.external_id || !item.external_url) && - (!item.sync_status || item.sync_status !== 'published') - ) - - → For each readyItem: - fetchAPI('/v1/publisher/publish/', { - method: 'POST', - body: { - content_id: item.content_id, - destinations: ['wordpress'] - } - }) - - Track: successCount++, failedCount++ - - → Show summary toast: - "Published X items, Y failed" - - → loadImages() to refresh all -``` - -### 5. **Status Update Flow** - -```sequence -User clicks "Update Status" - → Modal opens for status selection - → User selects: 'pending' | 'generated' | 'failed' - → handleStatusUpdate(status) called - - → bulkUpdateImagesStatus(contentId, status) - → API: POST /v1/writer/images/bulk_update/ - {content_id: 42, status: 'generated'} - - Returns: {updated_count: 4} - - ← Modal closes - → toast.success('Updated 4 images') - → loadImages() to refresh -``` - ---- - -## WordPress Publishing System - -### Manual Publishing - -#### Architecture - -``` -Frontend (Images Page) - └── handleRowAction('publish_wordpress', row) - └── fetchAPI('/v1/publisher/publish/', {...}) - └── Backend Publisher Module - └── PublisherService.publish_content() - └── Content → WordPress Bridge - └── WordPress Site (via REST API) - └── WP Post Created/Updated -``` - -#### Conditions for Visibility - -The "Publish to WordPress" button appears when ALL conditions are met: - -1. **Content Status**: `row.status === 'published'` - - Content must be in published status internally - -2. **No WordPress Record**: `!row.external_id || !row.external_url` - - Either no WordPress ID or no WordPress URL - - Allows re-publishing if one is missing - -3. **Not Already Synced**: `!row.sync_status || row.sync_status !== 'published'` - - sync_status is not 'published' - - Allows re-sync if status changed - -#### Request Structure - -```typescript -const response = await fetchAPI('/v1/publisher/publish/', { - method: 'POST', - body: JSON.stringify({ - content_id: 42, // Content to publish - destinations: ['wordpress'] // Target platform - }) -}); -``` - -#### Response Structure - -```typescript -{ - success: true, - data: { - content_id: 42, - external_id: "5678", // WordPress post ID - external_url: "https://site.com/p/5678/", - sync_status: "published", // Status after publishing - message: "Content published to WordPress" - }, - request_id: "uuid" -} -``` - -### Bulk Publishing - -#### Flow - -```typescript -// Filter ready items -const readyItems = images - .filter(item => ids.includes(item.content_id)) - .filter(item => item.status === 'published' && - (!item.external_id || !item.external_url) && - (!item.sync_status || item.sync_status !== 'published')) - -// Publish each individually -for (const item of readyItems) { - try { - const response = await fetchAPI('/v1/publisher/publish/', { - method: 'POST', - body: JSON.stringify({ - content_id: item.content_id, - destinations: ['wordpress'] - }) - }) - - if (response.success) { - successCount++ - } else { - failedCount++ - } - } catch (error) { - failedCount++ - } -} - -// Show results -if (successCount > 0) { - toast.success(`Published ${successCount} items`) -} -if (failedCount > 0) { - toast.warning(`${failedCount} failed`) -} - -// Refresh to show updated status -loadImages() -``` - -#### Advantages of Individual Requests - -1. **Error Isolation**: Failure in one publish doesn't affect others -2. **Granular Tracking**: Knows exactly which items succeeded/failed -3. **User Feedback**: Can show detailed success/fail breakdown -4. **Partial Success**: Users see partial results instead of total failure - ---- - -## Automated Publishing - -### Automatic Status Sync (Two-Way Sync) - -The system includes automatic synchronization of content status between IGNY8 and WordPress: - -#### Components - -1. **WordPress Bridge Plugin** (`igny8-wp-integration`) - - Monitors WordPress post changes - - Syncs status back to IGNY8 API - - Location: `/includes/sync/hooks.php` - -2. **IGNY8 Backend Sync Tasks** (Celery) - - Periodic status checks - - Webhook receivers - - Status reconciliation - -#### Sync Direction - -``` -IGNY8 → WordPress: - Content published in Writer - → Publisher API called - → WordPress Bridge receives payload - → Post created/updated in WordPress - -WordPress → IGNY8: - Post updated in WordPress (draft/publish/trash) - → WordPress Hook triggered - → Bridge syncs back via PUT /writer/tasks/{id}/ - → IGNY8 Content status updated -``` - -#### Sync Status Values - -| Status | Meaning | Publishable? | -|--------|---------|-------------| -| `draft` | Not yet published to WordPress | Yes | -| `pending` | Awaiting review in WordPress | No | -| `published` | Live on WordPress | No (already published) | -| `synced` | Initial sync complete | No | -| `failed` | Publication failed | Yes (retry) | - ---- - -## Sync Functions - -### 1. **loadImages() - Main Refresh Function** - -```typescript -const loadImages = useCallback(async () => { - setLoading(true); - setShowContent(false); - try { - // Fetch all content images - const data: ContentImagesResponse = await fetchContentImages({}); - let filteredResults = data.results || []; - - // Client-side search filter - if (searchTerm) { - filteredResults = filteredResults.filter(group => - group.content_title?.toLowerCase().includes(searchTerm.toLowerCase()) - ); - } - - // Client-side status filter - if (statusFilter) { - filteredResults = filteredResults.filter(group => - group.overall_status === statusFilter - ); - } - - // Client-side sorting - filteredResults.sort((a, b) => { - let aVal: any = a.content_title; - let bVal: any = b.content_title; - - if (sortBy === 'overall_status') { - aVal = a.overall_status; - bVal = b.overall_status; - } - - if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; - if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; - return 0; - }); - - // Client-side pagination - const startIndex = (currentPage - 1) * pageSize; - const endIndex = startIndex + pageSize; - const paginatedResults = filteredResults.slice(startIndex, endIndex); - - setImages(paginatedResults); - setTotalCount(filteredResults.length); - setTotalPages(Math.ceil(filteredResults.length / pageSize)); - - setTimeout(() => { - setShowContent(true); - setLoading(false); - }, 100); - } catch (error: any) { - console.error('Error loading images:', error); - toast.error(`Failed to load images: ${error.message}`); - setShowContent(true); - setLoading(false); - } -}, [currentPage, statusFilter, sortBy, sortDirection, searchTerm, toast]); -``` - -**Called After**: -- Page mount -- Search term changes -- Status filter changes -- Sort order changes -- Page navigation -- Publishing operations complete -- Status updates complete - -### 2. **handleStatusUpdate() - Status Change Handler** - -```typescript -const handleStatusUpdate = useCallback(async (status: string) => { - if (!statusUpdateContentId) return; - - setIsUpdatingStatus(true); - try { - const result = await bulkUpdateImagesStatus(statusUpdateContentId, status); - toast.success(`Successfully updated ${result.updated_count} image(s) status to ${status}`); - setIsStatusModalOpen(false); - setStatusUpdateContentId(null); - setStatusUpdateRecordName(''); - // Reload images to reflect the changes - loadImages(); - } catch (error: any) { - toast.error(`Failed to update status: ${error.message}`); - } finally { - setIsUpdatingStatus(false); - } -}, [statusUpdateContentId, toast, loadImages]); -``` - -**Flow**: -1. User opens status modal -2. Selects new status -3. API call to update all images for content -4. Refresh table to show changes - -### 3. **Site/Sector Change Listeners** - -```typescript -useEffect(() => { - const handleSiteChange = () => { - loadImages(); - }; - - const handleSectorChange = () => { - loadImages(); - }; - - window.addEventListener('siteChanged', handleSiteChange); - window.addEventListener('sectorChanged', handleSectorChange); - - return () => { - window.removeEventListener('siteChanged', handleSiteChange); - window.removeEventListener('sectorChanged', handleSectorChange); - }; -}, [loadImages]); -``` - -**Purpose**: Auto-refresh when user switches site or sector - -### 4. **Debounced Search** - -```typescript -useEffect(() => { - const timer = setTimeout(() => { - if (currentPage === 1) { - loadImages(); - } else { - setCurrentPage(1); // Reset to page 1 - } - }, 500); // Wait 500ms after user stops typing - - return () => clearTimeout(timer); -}, [searchTerm, currentPage, loadImages]); -``` - -**Purpose**: Avoid excessive API calls during search input - ---- - -## Error Handling - -### Unified Error Response Format - -All API errors follow the unified format: - -```typescript -{ - success: false, - error: "Human-readable error message", - errors: { - field_name: ["Field-specific error"] - }, - request_id: "uuid" -} -``` - -### Error Types Handled - -| Error Type | Handling | User Feedback | -|-----------|----------|---------------| -| **Network Error** | Caught by fetchAPI | "Network error: Unable to reach API" | -| **401 Unauthorized** | Token refresh attempted | Auto-retry or "Session expired" | -| **403 Forbidden** | Authentication check failed | "Permission denied" | -| **404 Not Found** | Resource doesn't exist | "Content not found" | -| **422 Validation** | Invalid request data | "Invalid input: {field details}" | -| **500 Server Error** | Backend exception | "Server error occurred" | -| **Timeout** | Request > 30 seconds | "Request timeout" | - -### Error Handling in Publishing - -```typescript -try { - const response = await fetchAPI('/v1/publisher/publish/', { - method: 'POST', - body: JSON.stringify({ - content_id: item.content_id, - destinations: ['wordpress'] - }) - }); - - if (response.success) { - successCount++; - } else { - // Unified error format - console.warn(`Failed: ${response.error}`); - failedCount++; - } -} catch (error: any) { - // Network or unknown error - console.error('Error publishing:', error); - failedCount++; -} -``` - ---- - -## Performance Optimizations - -### 1. **Client-Side Processing** - -- **Search**: Filtered in memory, not via API -- **Sorting**: Local array sort, no API call -- **Pagination**: 10 items per page, loaded once -- **Status Filter**: In-memory filtering - -**Benefit**: Faster UX, no additional API calls - -### 2. **Memoization** - -```typescript -// Prevent unnecessary re-renders -const pageConfig = useMemo(() => { - return createImagesPageConfig({...}); -}, [searchTerm, statusFilter, maxInArticleImages, ...]); - -const headerMetrics = useMemo(() => { - return pageConfig.headerMetrics.map(...); -}, [pageConfig?.headerMetrics, images, totalCount]); -``` - -### 3. **Debouncing** - -```typescript -// 500ms delay on search to avoid API thrashing -useEffect(() => { - const timer = setTimeout(() => { - loadImages(); - }, 500); - - return () => clearTimeout(timer); -}, [searchTerm, ...]); -``` - -### 4. **Lazy Loading (Potential)** - -```typescript -// Could implement virtual scrolling for 1000+ items -// Currently: Fixed page size of 10 items -``` - -### 5. **Caching (Backend)** - -The API automatically caches: -- Content images (5-minute TTL) -- Image generation settings (server-side) -- Site metadata - ---- - -## Configuration Deep Dive - -### Table Actions Configuration - -**File**: `table-actions.config.tsx` - -```typescript -'/writer/images': { - rowActions: [ - { - key: 'publish_wordpress', - label: 'Publish to WordPress', - icon: , - variant: 'success', - shouldShow: (row: any) => { - // Only show if ready for publishing - return row.status === 'published' && - (!row.external_id || !row.external_url) && - (!row.sync_status || row.sync_status !== 'published'); - }, - }, - { - key: 'update_status', - label: 'Update Status', - icon: , - variant: 'primary', - }, - ], - bulkActions: [ - { - key: 'bulk_publish_wordpress', - label: 'Publish Ready to WordPress', - icon: , - variant: 'success', - }, - ], -} -``` - -### Images Page Config - -**File**: `images.config.tsx` - -```typescript -export const createImagesPageConfig = (handlers: { - searchTerm: string; - setSearchTerm: (value: string) => void; - statusFilter: string; - setStatusFilter: (value: string) => void; - setCurrentPage: (page: number) => void; - maxInArticleImages?: number; - onGenerateImages?: (contentId: number) => void; - onImageClick?: (contentId: number, imageType: 'featured' | 'in_article', position?: number) => void; -}): ImagesPageConfig => { - // Builds columns with featured image + up to 5 in-article images - // Builds filters: search, status - // Calculates header metrics -} -``` - ---- - -## Data Models - -### ContentImagesGroup - -```typescript -interface ContentImagesGroup { - content_id: number; - content_title: string; - featured_image: ContentImage | null; - in_article_images: ContentImage[]; - overall_status: 'pending' | 'partial' | 'complete' | 'failed'; - - // WordPress fields - status?: string; // 'published' or 'draft' - external_id?: string; // WordPress post ID - external_url?: string; // WordPress post URL - sync_status?: string; // 'published', 'synced', 'failed' -} -``` - -### ContentImage - -```typescript -interface ContentImage { - id: number; - image_type: string; // 'featured' | 'in_article' - image_url?: string | null; // Generated image URL - image_path?: string | null; // Local file path - prompt?: string | null; // Generation prompt - status: string; // 'pending' | 'generated' | 'failed' - position: number; // Order for in-article (1-5+) - created_at: string; - updated_at: string; -} -``` - ---- - -## API Endpoints Reference - -### Reader Endpoints - -| Method | Endpoint | Purpose | -|--------|----------|---------| -| GET | `/v1/writer/images/content_images/` | Fetch grouped content images | -| GET | `/v1/system/integrations/image_generation/` | Get generation settings | - -### Writer Endpoints - -| Method | Endpoint | Purpose | -|--------|----------|---------| -| POST | `/v1/writer/images/generate_images/` | Start image generation | -| POST | `/v1/writer/images/bulk_update/` | Update image statuses | - -### Publisher Endpoints - -| Method | Endpoint | Purpose | -|--------|----------|---------| -| POST | `/v1/publisher/publish/` | Publish to destinations (WordPress) | - ---- - -## Key Implementation Decisions - -### 1. **Unified Publishing Endpoint** - -**Decision**: Use existing `/v1/publisher/publish/` instead of custom WordPress endpoints -**Rationale**: -- Follows IGNY8 unified API standard -- Supports multiple destinations (WordPress, Sites Renderer, etc.) -- Centralized publishing logic -- Easier to extend - -### 2. **Client-Side Processing** - -**Decision**: Perform search, filter, sort, paginate on client -**Rationale**: -- Faster response (no API round-trips) -- Better UX for small datasets (< 1000 items) -- Works offline -- Reduces server load - -### 3. **Individual Bulk Publishes** - -**Decision**: Loop and call API individually for bulk operations -**Rationale**: -- Error isolation (one failure doesn't affect others) -- Granular progress tracking -- User sees partial success -- Easier error reporting - -### 4. **Automatic Status Sync** - -**Decision**: Refresh data after publish operations -**Rationale**: -- Ensures UI reflects latest server state -- Shows WordPress sync status -- Catches errors from background tasks -- User sees immediate feedback - ---- - -## Future Enhancements - -### Potential Improvements - -1. **Server-Side Pagination** - - Replace client-side pagination - - Reduce memory usage for large datasets - - Enable server-side filtering/sorting - -2. **Real-Time Updates** - - WebSocket for publishing progress - - Push notifications - - Live sync status updates - -3. **Batch Publishing API** - - Single endpoint for bulk operations - - Reduced network overhead - - Atomic operations (all-or-nothing) - -4. **Advanced Filtering** - - Filter by image type (featured/in-article) - - Filter by image status - - Filter by sync status - -5. **Scheduling** - - Schedule publishing for specific dates - - Queue management UI - - Publishing calendar - -6. **Retry Mechanism** - - Automatic retry on failure - - Exponential backoff - - Dead letter queue for failed items - ---- - -## Testing Checklist - -- [ ] Load page with empty data -- [ ] Load page with 50+ items -- [ ] Search functionality -- [ ] Filter by status -- [ ] Sort by title/status -- [ ] Pagination navigation -- [ ] Generate images flow -- [ ] Single item publish to WordPress -- [ ] Bulk publish operation -- [ ] Partial failure handling -- [ ] Status update operation -- [ ] Image preview modal -- [ ] Error toasts display correctly -- [ ] Reload on site change -- [ ] Reload on sector change -- [ ] Token refresh on 401 -- [ ] Network error handling - ---- - -## Debugging & Support - -### Enable Resource Debug Logs - -```typescript -// In any page using Images component -const resourceDebugEnabled = useResourceDebug(); -// Shows AI Function Logs panel with all operations -``` - -### Check API Responses - -```typescript -// In browser DevTools -// Network tab → XHR/Fetch → Filter "images" -// View each request/response - -// Look for unified format: -{ - "success": true, - "data": {...}, - "request_id": "uuid" -} -``` - -### Common Issues - -| Issue | Cause | Solution | -|-------|-------|----------| -| Publish button not showing | Status != 'published' OR already has external_id | Check row.status and row.external_id | -| Publishing succeeds but data not updated | Need to refresh | loadImages() called, wait for refresh | -| Images not loading | Site/sector not selected | Select site in header | -| API 401 error | Token expired | Should auto-refresh, check auth store | - ---- - -## Conclusion - -The `/writer/images` page implements a comprehensive content image management system with: - -✅ **Full Publishing Workflow** - Manual and bulk publishing to WordPress -✅ **Real-Time Generation** - Image generation with progress tracking -✅ **Two-Way Sync** - Automatic status synchronization with WordPress -✅ **Unified API** - Following IGNY8 API standards -✅ **Error Handling** - Comprehensive error management and user feedback -✅ **Performance** - Optimized with client-side processing and memoization -✅ **Scalability** - Designed for future enhancements - -The system is production-ready and fully integrated with the IGNY8 architecture. - ---- - -**Document End** diff --git a/approved-docs/06-FEATURE-MODIFICATION-DEVELOPER-GUIDE.md b/approved-docs/06-FEATURE-MODIFICATION-DEVELOPER-GUIDE.md deleted file mode 100644 index a36508a9..00000000 --- a/approved-docs/06-FEATURE-MODIFICATION-DEVELOPER-GUIDE.md +++ /dev/null @@ -1,1121 +0,0 @@ -# Feature Modification & Development Guide -## Complete File Reference for Adding, Modifying, and Removing Features - -**Version:** 1.0.0 -**Last Updated:** November 29, 2025 -**Purpose:** Quick reference for developers to locate exactly which files to modify for specific feature types, across both frontend and backend. - ---- - -## Table of Contents - -1. [Quick Navigation by Feature Type](#quick-navigation-by-feature-type) -2. [Feature Type Reference Matrix](#feature-type-reference-matrix) -3. [Frontend Feature Modifications](#frontend-feature-modifications) -4. [Backend Feature Modifications](#backend-feature-modifications) -5. [Cross-Module Modifications](#cross-module-modifications) -6. [Database Schema Changes](#database-schema-changes) -7. [Common Workflow Patterns](#common-workflow-patterns) -8. [File Organization Index](#file-organization-index) - ---- - -## Quick Navigation by Feature Type - -### 🎯 Find Your Feature Type - -- **Adding/Removing/Modifying a Database Column** → [Database Changes](#database-changes) -- **Adding a New Page/List View** → [New Page Feature](#new-page-feature) -- **Adding a Column to a Table/List** → [Table Column Addition](#table-column-addition) -- **Adding a Form Field** → [Form Field Addition](#form-field-addition) -- **Adding an API Endpoint** → [API Endpoint Addition](#api-endpoint-addition) -- **Adding a Filter/Search** → [Filter/Search Addition](#filtersearch-addition) -- **Adding a Status/Choice Field** → [Status Field Addition](#status-field-addition) -- **Modifying API Response** → [API Response Modification](#api-response-modification) -- **Adding Validation** → [Validation Addition](#validation-addition) -- **Adding Background Task** → [Background Task Addition](#background-task-addition) -- **Adding Webhook** → [Webhook Addition](#webhook-addition) -- **Adding Integration** → [Integration Addition](#integration-addition) - ---- - -## Feature Type Reference Matrix - -| Feature Type | Frontend Files | Backend Files | Database | Global Config | -|--------------|---|---|---|---| -| **Database Column** | - | Model + Serializer | Migration | - | -| **Table Column** | List Component | ViewSet Filter | - | - | -| **Form Field** | Form Component | Serializer | Model Migration | - | -| **Page/View** | Page Component | ViewSet | - | Routes | -| **API Endpoint** | API Hook | ViewSet + Router | - | urls.py | -| **Status Field** | Select/Enum | Model Choice | Migration | - | -| **Filter/Search** | Filter UI | filterset_fields | Index | - | -| **Validation** | Form Rules | Validator Service | - | - | -| **Background Task** | UI Trigger | Celery Task | - | settings.py | -| **Webhook** | - | Handler Function | WebhookLog Model | urls.py | -| **Integration** | - | Adapter Class | Integration Model | - | - ---- - -## Frontend Feature Modifications - -### Directory Structure - -``` -frontend/src/ -├── pages/ -│ ├── Planner/ -│ │ ├── Dashboard.tsx -│ │ ├── Keywords.tsx -│ │ ├── Clusters.tsx -│ │ ├── ClusterDetail.tsx -│ │ ├── Ideas.tsx -│ │ └── KeywordOpportunities.tsx -│ ├── Writer/ -│ │ ├── Dashboard.tsx -│ │ ├── Tasks.tsx -│ │ ├── Content.tsx -│ │ ├── ContentView.tsx -│ │ ├── Drafts.tsx -│ │ ├── Images.tsx -│ │ ├── Published.tsx -│ │ └── Review.tsx -│ ├── Linker/ -│ │ └── [Linker Pages] -│ ├── Optimizer/ -│ │ └── [Optimizer Pages] -│ ├── Settings/ -│ │ └── [Settings Pages] -│ └── [Other Modules] -├── components/ -│ ├── common/ -│ │ ├── Table.tsx -│ │ ├── Modal.tsx -│ │ ├── Form.tsx -│ │ ├── Button.tsx -│ │ └── [Shared UI Components] -│ ├── [Module-Specific Components] -│ └── [Feature Components] -├── api/ -│ ├── client.ts # Axios setup -│ ├── auth.ts # Auth API calls -│ ├── planner.ts # Planner endpoints -│ ├── writer.ts # Writer endpoints -│ ├── [module].ts # Module-specific APIs -│ └── hooks/ -│ └── useApi.ts # API hook -├── store/ -│ ├── authStore.ts # Auth state -│ ├── siteStore.ts # Site/Sector state -│ ├── [module]Store.ts # Module state -│ └── globalStore.ts # Global state -├── hooks/ -│ ├── useAuth.ts -│ ├── useApi.ts -│ ├── useSite.ts -│ └── [Custom Hooks] -├── types/ -│ ├── api.ts # API type definitions -│ ├── models.ts # Data model types -│ └── [Module Types] -└── utils/ - ├── formatters.ts - ├── validators.ts - └── [Utility Functions] -``` - -### Feature: Adding a Column to a List/Table - -**Module Example: Writer/Tasks List** - -#### Files to Modify: - -| Layer | File | Change Type | Action | -|-------|------|-------------|--------| -| **Data Type** | `frontend/src/types/models.ts` | Modify | Add field to Task interface | -| **API Response** | `frontend/src/api/writer.ts` | Auto | API will return new field from backend | -| **Page Component** | `frontend/src/pages/Writer/Tasks.tsx` | Modify | Add column to table columns config | -| **Table Component** | `frontend/src/components/Writer/TasksTable.tsx` | Modify | Render new column in JSX | -| **Backend ViewSet** | `backend/igny8_core/modules/writer/views.py` | Modify | Add to `filterset_fields` if filterable, `ordering_fields` if sortable | -| **Backend Serializer** | `backend/igny8_core/modules/writer/serializers.py` | Modify | Add field to serializer `fields` list | -| **Backend Model** | `backend/igny8_core/business/content/models.py` | Modify | Add field to Tasks model class | -| **Backend Migration** | `backend/igny8_core/migrations/` | Create | `python manage.py makemigrations` | - -#### Step-by-Step for "Adding 'priority' column to Tasks list": - -```python -# Step 1: Backend Model (models.py) -class Tasks(SiteSectorBaseModel): - # ... existing fields ... - priority = models.IntegerField( - choices=[(1, 'Low'), (2, 'Medium'), (3, 'High')], - default=2 - ) - -# Step 2: Backend Serializer (serializers.py) -class TasksSerializer(serializers.ModelSerializer): - class Meta: - model = Tasks - fields = ['id', 'title', 'status', 'priority', ...] # Add 'priority' - -# Step 3: Backend ViewSet (views.py) - if filterable/sortable -class TasksViewSet(SiteSectorModelViewSet): - filterset_fields = ['status', 'cluster_id', 'priority'] # Add 'priority' - ordering_fields = ['title', 'created_at', 'priority'] # Add 'priority' - -# Step 4: Frontend Type (types/models.ts) -interface Task { - id: number; - title: string; - status: string; - priority: number; // Add this - // ... other fields -} - -# Step 5: Frontend API (api/writer.ts) - No changes needed if using generic fetch - -# Step 6: Frontend Component (pages/Writer/Tasks.tsx) -const columns = [ - { key: 'id', label: 'ID', width: '10%' }, - { key: 'title', label: 'Title', width: '40%' }, - { key: 'status', label: 'Status', width: '20%' }, - { key: 'priority', label: 'Priority', width: '15%' }, // Add this -]; - -# Step 7: Database Migration -# bash: python manage.py makemigrations -# bash: python manage.py migrate -``` - -### Feature: Adding a Form Field - -**Module Example: Writer/Tasks Create Form** - -#### Files to Modify: - -| Layer | File | Change Type | Description | -|-------|------|-------------|-------------| -| **Backend Model** | `backend/.../models.py` | Add | New model field with validators | -| **Backend Serializer** | `backend/.../serializers.py` | Add | Serializer field with validation | -| **Backend ViewSet** | `backend/.../views.py` | Modify | Add to `filterset_fields` if needed | -| **Frontend Type** | `frontend/src/types/models.ts` | Add | Add to interface | -| **Frontend Form Component** | `frontend/src/pages/Writer/[FormPage].tsx` | Add | Add form input JSX | -| **Frontend Validation** | `frontend/src/utils/validators.ts` | Add | Client-side validation rule | -| **Frontend State** | `frontend/src/store/[module]Store.ts` | Add | Add to form state if needed | -| **Database Migration** | `backend/migrations/` | Create | If model field is added | - -#### Example: "Adding 'estimatedWordCount' field to Tasks form": - -```python -# Step 1: Backend Model -class Tasks(SiteSectorBaseModel): - # ... existing fields ... - word_count = models.IntegerField( - default=1000, - validators=[MinValueValidator(100), MaxValueValidator(10000)], - help_text="Target word count (100-10000)" - ) - -# Step 2: Backend Serializer -class TasksSerializer(serializers.ModelSerializer): - word_count = serializers.IntegerField( - required=False, - min_value=100, - max_value=10000 - ) - - class Meta: - model = Tasks - fields = [..., 'word_count'] - -# Step 3: Frontend Type -interface Task { - id: number; - title: string; - wordCount: number; // Add this (camelCase) -} - -# Step 4: Frontend Form Component -function TaskForm() { - const [formData, setFormData] = useState({ - title: '', - wordCount: 1000, // Add this - }); - - return ( -
- {/* ... other fields ... */} - setFormData({...formData, wordCount: e.target.value})} - /> -
- ); -} - -# Step 5: Frontend Validation -export const validateTaskForm = (data) => { - if (data.wordCount < 100 || data.wordCount > 10000) { - return 'Word count must be between 100 and 10000'; - } - return null; -}; -``` - -### Feature: Adding a New Page/View - -**Module Example: Adding "Writer/Scheduled" Page** - -#### Files to Create/Modify: - -| Layer | File | Change Type | Action | -|-------|------|-------------|--------| -| **New Page Component** | `frontend/src/pages/Writer/Scheduled.tsx` | Create | New React component | -| **Page Router** | `frontend/src/App.tsx` | Modify | Add route entry | -| **Navigation** | `frontend/src/layout/Sidebar.tsx` | Modify | Add menu item | -| **API Hooks** | `frontend/src/api/writer.ts` | Modify | Add API call if needed | -| **Type Definitions** | `frontend/src/types/models.ts` | Modify | Add types if needed | - -#### Step-by-Step for "Adding Writer/Scheduled Page": - -```typescript -// Step 1: Create new page component -// File: frontend/src/pages/Writer/Scheduled.tsx - -import React, { useEffect, useState } from 'react'; -import { getScheduledContent } from '../../api/writer'; -import ContentTable from '../../components/Writer/ContentTable'; - -export default function ScheduledPage() { - const [content, setContent] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const loadContent = async () => { - try { - const response = await getScheduledContent({ - siteId: currentSite.id, - sectorId: currentSector.id, - status: 'scheduled' - }); - setContent(response.data); - } catch (error) { - console.error('Failed to load scheduled content:', error); - } finally { - setLoading(false); - } - }; - loadContent(); - }, []); - - return ( -
-

Scheduled Content

- -
- ); -} - -// Step 2: Add route in App.tsx -import ScheduledPage from './pages/Writer/Scheduled'; - -function App() { - return ( - - {/* ... other routes ... */} - } /> - - ); -} - -// Step 3: Add to Sidebar navigation -// File: frontend/src/layout/Sidebar.tsx -} -/> - -// Step 4: Add API function if needed -// File: frontend/src/api/writer.ts -export const getScheduledContent = async (params) => { - return fetchAPI('/api/v1/writer/content/', { - query: { ...params, status: 'scheduled' } - }); -}; -``` - -### Feature: Adding a Filter/Search - -**Module Example: Adding "Priority Filter" to Tasks** - -#### Files to Modify: - -| Layer | File | Change Type | Description | -|-------|------|-------------|-------------| -| **Backend ViewSet** | `backend/.../views.py` | Modify | Add to `filterset_fields` | -| **Backend Model** | `backend/.../models.py` | Check | Field must have choices | -| **Frontend Filter UI** | `frontend/src/components/Writer/TasksFilter.tsx` | Add | Add filter control | -| **Frontend State** | `frontend/src/store/writerStore.ts` | Modify | Add filter state | - -#### Example: - -```python -# Backend (views.py) -class TasksViewSet(SiteSectorModelViewSet): - filterset_fields = ['status', 'cluster_id', 'priority'] # Add 'priority' - -# Frontend - Add Filter Component -interface TaskFilter { - priority?: number; - status?: string; -} - -function TasksFilter() { - const [filters, setFilters] = useState({}); - - return ( -
- -
- ); -} -``` - ---- - -## Backend Feature Modifications - -### Directory Structure - -``` -backend/igny8_core/ -├── modules/ # API ViewSets (REST endpoints) -│ ├── planner/ -│ │ ├── views.py # ViewSets -│ │ ├── serializers.py # Serializers -│ │ ├── urls.py # Route definitions -│ │ └── apps.py -│ ├── writer/ -│ │ ├── views.py -│ │ ├── serializers.py -│ │ ├── urls.py -│ │ └── apps.py -│ ├── [other modules] -│ └── urls.py # Root routing -├── business/ # Business logic & models -│ ├── planning/ -│ │ ├── models.py # Database models -│ │ └── services/ # Business logic -│ ├── content/ -│ │ ├── models.py -│ │ └── services/ -│ ├── [other domains] -│ └── models.py -├── auth/ -│ ├── models.py # User, Account, Site, Sector -│ ├── views.py -│ ├── serializers.py -│ └── urls.py -├── api/ -│ ├── base.py # Base ViewSet classes -│ ├── response.py # Response utilities -│ ├── pagination.py -│ ├── permissions.py -│ ├── throttles.py -│ └── exception_handlers.py -├── ai/ # AI Engine -│ ├── engine.py -│ ├── ai_core.py -│ ├── registry.py -│ └── functions/ -├── middleware/ -├── utils/ -├── tasks/ # Celery tasks -├── migrations/ # Database migrations -├── settings.py # Django settings -├── urls.py # Root URL config -└── wsgi.py -``` - -### Feature: Adding an API Endpoint - -**Module Example: Adding "GET /writer/tasks/{id}/brief/" endpoint** - -#### Files to Create/Modify: - -| File | Change Type | Description | -|------|-------------|-------------| -| `backend/igny8_core/modules/writer/views.py` | Add | Add `@action` method to ViewSet | -| `backend/igny8_core/modules/writer/serializers.py` | Check | Serializer for response | -| `backend/igny8_core/modules/writer/urls.py` | Auto | Routes auto-generated by DRF `DefaultRouter` | - -#### Step-by-Step Example: - -```python -# Step 1: Add action to ViewSet (views.py) -class TasksViewSet(SiteSectorModelViewSet): - # ... existing code ... - - @action(detail=True, methods=['get']) - def brief(self, request, pk=None): - """ - Get task brief - GET /api/v1/writer/tasks/{id}/brief/ - """ - task = self.get_object() - - # Business logic to generate brief - brief_data = { - 'task_id': task.id, - 'title': task.title, - 'keywords': task.keywords, - 'cluster': task.cluster.name if task.cluster else None, - 'description': task.description - } - - return success_response( - success=True, - data=brief_data, - message='Task brief retrieved' - ) - -# Step 2: That's it! DRF auto-generates the route -# URL will be: /api/v1/writer/tasks/123/brief/ -# Method: GET -# Response format: { success: true, data: {...}, message: '...' } - -# Step 3: Frontend API hook -// frontend/src/api/writer.ts -export const getTaskBrief = async (taskId: number) => { - return fetchAPI(`/api/v1/writer/tasks/${taskId}/brief/`); -}; -``` - -### Feature: Adding a Status/Choice Field - -**Module Example: Adding "priority" choice field to Tasks** - -#### Files to Modify: - -| Layer | File | Change Type | Action | -|-------|------|-------------|--------| -| **Backend Model** | `backend/.../models.py` | Add | Add field with CHOICES | -| **Database Migration** | `backend/migrations/` | Create | Auto from model | -| **Backend Serializer** | `backend/.../serializers.py` | Auto | Choices auto-included | -| **Backend ViewSet** | `backend/.../views.py` | Modify | Add to filters if needed | -| **Frontend Types** | `frontend/src/types/models.ts` | Add | Add type definition | -| **Frontend Enum/Constant** | `frontend/src/config/constants.ts` | Add | Add choice options | -| **Frontend UI** | Components using field | Use | Render as select/dropdown | - -#### Example: - -```python -# Step 1: Backend Model (models.py) -class Tasks(SiteSectorBaseModel): - PRIORITY_CHOICES = [ - (1, 'Low'), - (2, 'Medium'), - (3, 'High'), - (4, 'Urgent'), - ] - - priority = models.IntegerField( - choices=PRIORITY_CHOICES, - default=2, - help_text="Task priority level" - ) - -# Step 2: Backend Serializer (serializers.py) - No changes needed! -# DRF automatically includes choices in API response as: -# { -# "priority": 2, -# "priority_display": "Medium", -# "priority_choices": [[1, "Low"], [2, "Medium"], ...] -# } - -# Step 3: Frontend Constant (config/constants.ts) -export const TASK_PRIORITIES = { - LOW: 1, - MEDIUM: 2, - HIGH: 3, - URGENT: 4, -}; - -export const TASK_PRIORITY_LABELS = { - 1: 'Low', - 2: 'Medium', - 3: 'High', - 4: 'Urgent', -}; - -# Step 4: Frontend Type (types/models.ts) -interface Task { - id: number; - priority: 1 | 2 | 3 | 4; // Use enum values -} - -# Step 5: Frontend Component -function TaskSelect() { - return ( - - ); -} -``` - -### Feature: Adding Validation - -**Module Example: Validating task word count (100-10000)** - -#### Files to Create/Modify: - -| File | Change Type | Description | -|------|-------------|-------------| -| `backend/igny8_core/business/content/services/validation_service.py` | Create | Validation logic | -| `backend/igny8_core/modules/writer/serializers.py` | Modify | Add field validation | -| `frontend/src/utils/validators.ts` | Add | Client-side validation | - -#### Example: - -```python -# Step 1: Backend Model Field Validators (models.py) -from django.core.validators import MinValueValidator, MaxValueValidator - -class Tasks(SiteSectorBaseModel): - word_count = models.IntegerField( - default=1000, - validators=[ - MinValueValidator(100, message="Minimum word count is 100"), - MaxValueValidator(10000, message="Maximum word count is 10000") - ] - ) - -# Step 2: Backend Serializer Validation (serializers.py) -class TasksSerializer(serializers.ModelSerializer): - word_count = serializers.IntegerField( - min_value=100, - max_value=10000, - required=False - ) - - def validate_word_count(self, value): - if value and (value < 100 or value > 10000): - raise serializers.ValidationError( - "Word count must be between 100 and 10000" - ) - return value - - class Meta: - model = Tasks - fields = ['id', 'title', 'word_count', ...] - -# Step 3: Frontend Validation (utils/validators.ts) -export const validateTaskWordCount = (wordCount: number): string | null => { - if (wordCount < 100) { - return 'Word count must be at least 100'; - } - if (wordCount > 10000) { - return 'Word count cannot exceed 10000'; - } - return null; -}; - -# Step 4: Frontend Component -function TaskForm() { - const [wordCount, setWordCount] = useState(1000); - const [error, setError] = useState(''); - - const handleChange = (value) => { - setWordCount(value); - const err = validateTaskWordCount(value); - setError(err || ''); - }; - - return ( - <> - handleChange(Number(e.target.value))} - /> - {error && {error}} - - ); -} -``` - -### Feature: Adding a Background Task (Celery) - -**Module Example: Adding "Async content generation" task** - -#### Files to Create/Modify: - -| File | Change Type | Description | -|------|-------------|-------------| -| `backend/igny8_core/tasks/[domain]_tasks.py` | Create | Celery task function | -| `backend/igny8_core/modules/[module]/views.py` | Modify | Queue task from ViewSet | -| `backend/igny8_core/settings.py` | Check | Celery config (usually pre-configured) | - -#### Example: Adding "generate_content_images" async task - -```python -# Step 1: Create Celery Task (tasks/writer_tasks.py) -from celery import shared_task -import logging - -logger = logging.getLogger(__name__) - -@shared_task(bind=True, max_retries=3) -def generate_content_images(self, content_id): - """ - Generate AI images for content - """ - from igny8_core.business.content.models import Content - from igny8_core.ai.engine import AIEngine - - logger.info(f"[generate_content_images] 🎬 Starting image generation for content {content_id}") - - try: - content = Content.objects.get(id=content_id) - - # Generate images - engine = AIEngine() - images = engine.generate_images( - content_id=content.id, - title=content.title, - keywords=content.secondary_keywords - ) - - # Save images - for image_data in images: - Image.objects.create( - content=content, - url=image_data['url'], - prompt=image_data['prompt'] - ) - - logger.info(f"[generate_content_images] ✅ Successfully generated {len(images)} images") - return {'success': True, 'image_count': len(images)} - - except Content.DoesNotExist: - logger.error(f"[generate_content_images] ❌ Content {content_id} not found") - return {'success': False, 'error': 'Content not found'} - except Exception as exc: - logger.error(f"[generate_content_images] ❌ Task failed: {str(exc)}") - # Retry with exponential backoff - raise self.retry(exc=exc, countdown=2 ** self.request.retries) - -# Step 2: Queue task from ViewSet (views.py) -class ContentViewSet(SiteSectorModelViewSet): - @action(detail=True, methods=['post']) - def generate_images(self, request, pk=None): - """ - Trigger async image generation - POST /api/v1/writer/content/{id}/generate_images/ - """ - content = self.get_object() - - # Queue background task - from igny8_core.tasks.writer_tasks import generate_content_images - - task = generate_content_images.delay(content.id) - - return success_response( - success=True, - data={'task_id': task.id}, - message='Image generation started' - ) - -# Step 3: Frontend to trigger task -// frontend/src/api/writer.ts -export const generateContentImages = async (contentId: number) => { - return fetchAPI(`/api/v1/writer/content/${contentId}/generate_images/`, { - method: 'POST' - }); -}; - -// frontend/src/pages/Writer/Content.tsx -async function handleGenerateImages(contentId) { - try { - const response = await generateContentImages(contentId); - toast.success('Image generation started'); - } catch (error) { - toast.error('Failed to start image generation'); - } -} -``` - ---- - -## Database Schema Changes - -### Feature: Adding/Modifying a Database Column - -#### Complete Workflow: - -```bash -# Step 1: Add field to model -# File: backend/igny8_core/business/content/models.py -class Content(SiteSectorBaseModel): - # ... existing fields ... - seo_score = models.IntegerField( - default=0, - validators=[MinValueValidator(0), MaxValueValidator(100)], - help_text="SEO optimization score (0-100)" - ) - -# Step 2: Create migration -$ python manage.py makemigrations writer - -# Output: Migrations for 'writer': -# migrations/0015_content_seo_score.py - -# Step 3: Review migration (optional) -$ cat migrations/0015_content_seo_score.py - -# Step 4: Apply migration -$ python manage.py migrate - -# Step 5: Update serializer to include new field -# File: backend/igny8_core/modules/writer/serializers.py -class ContentSerializer(serializers.ModelSerializer): - class Meta: - model = Content - fields = [ - 'id', 'title', 'content_html', 'status', - 'seo_score', # Add this - # ... other fields ... - ] - -# Step 6: Update frontend types -// frontend/src/types/models.ts -interface Content { - id: number; - title: string; - contentHtml: string; - seoScore: number; // Add this -} - -# Step 7: Update components to use new field -``` - -### Feature: Removing a Database Column - -```python -# Step 1: Create migration to remove field -$ python manage.py makemigrations writer --remove-field - -# Step 2: Or manually edit model - comment out field -class Content(SiteSectorBaseModel): - # ... existing fields ... - # seo_score = models.IntegerField(...) # REMOVED - -# Step 3: Auto-create migration -$ python manage.py makemigrations writer - -# Step 4: Apply migration -$ python manage.py migrate - -# Step 5: Remove from serializer -# backend/igny8_core/modules/writer/serializers.py -# Remove 'seo_score' from fields list - -# Step 6: Remove from frontend types and components -``` - -### Feature: Modifying a Database Column - -```python -# Example: Change word_count max value from 10000 to 20000 - -# Step 1: Update model -class Tasks(SiteSectorBaseModel): - word_count = models.IntegerField( - default=1000, - validators=[MinValueValidator(100), MaxValueValidator(20000)] # Changed - ) - -# Step 2: Create migration -$ python manage.py makemigrations writer - -# Step 3: Apply migration -$ python manage.py migrate - -# Step 4: Update serializer if validation changed -class TasksSerializer(serializers.ModelSerializer): - word_count = serializers.IntegerField( - min_value=100, - max_value=20000 # Update - ) - -# Step 5: Update frontend validation constants -export const MAX_WORD_COUNT = 20000; // Update -``` - ---- - -## Cross-Module Modifications - -### Feature: Adding a Relationship Between Modules - -**Example: Link Content to Ideas (Writer → Planner)** - -#### Files to Modify: - -```python -# Step 1: Backend Model - Add ForeignKey -# backend/igny8_core/business/content/models.py -class Content(SiteSectorBaseModel): - # ... existing fields ... - idea = models.ForeignKey( - 'planner.ContentIdeas', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='contents', - help_text="Source content idea" - ) - -# Step 2: Create migration -$ python manage.py makemigrations writer - -# Step 3: Apply migration -$ python manage.py migrate - -# Step 4: Update serializer to include nested relationship -# backend/igny8_core/modules/writer/serializers.py -from igny8_core.modules.planner.serializers import ContentIdeasSerializer - -class ContentSerializer(serializers.ModelSerializer): - idea = ContentIdeasSerializer(read_only=True) - idea_id = serializers.PrimaryKeyRelatedField( - queryset=ContentIdeas.objects.all(), - source='idea', - write_only=True - ) - - class Meta: - model = Content - fields = ['id', 'title', 'idea', 'idea_id', ...] - -# Step 5: Update ViewSet to support filtering by idea -# backend/igny8_core/modules/writer/views.py -class ContentViewSet(SiteSectorModelViewSet): - filterset_fields = ['status', 'cluster_id', 'idea_id'] # Add idea_id - ordering_fields = ['title', 'created_at', 'idea_id'] # Add idea_id - -# Step 6: Frontend - Update type -// frontend/src/types/models.ts -interface Content { - id: number; - title: string; - idea?: Idea; // Add nested object - ideaId?: number; // Add ID for create/update -} - -# Step 7: Frontend - Display related idea -// frontend/src/pages/Writer/Content.tsx -{content.idea && ( -
Idea: {content.idea.ideaTitle}
-)} -``` - ---- - -## Common Workflow Patterns - -### Pattern 1: Adding a Simple Field to List Page - -**Timeline: ~15 minutes** - -``` -1. Backend Model → Add field with validators -2. DB Migration → makemigrations + migrate -3. Backend Serializer → Add to fields list -4. Backend ViewSet → Add to filters/ordering if needed -5. Frontend Type → Add to interface -6. Frontend Component → Add to table columns -7. Test → Verify in both UI and API -``` - -**Files Modified:** -- `backend/igny8_core/business/*/models.py` -- `backend/igny8_core/migrations/XXXX_*.py` -- `backend/igny8_core/modules/*/serializers.py` -- `backend/igny8_core/modules/*/views.py` -- `frontend/src/types/models.ts` -- `frontend/src/pages/*/[Page].tsx` - -### Pattern 2: Adding a New Feature Page - -**Timeline: ~1 hour** - -``` -1. Backend ViewSet/Action → Create endpoint -2. Backend Serializer → Define response format -3. Frontend API Hook → Create fetch function -4. Frontend Type → Define data interface -5. Frontend Page Component → Create page -6. Frontend Router → Add route -7. Frontend Navigation → Add menu item -8. Test → End-to-end flow -``` - -**Files Modified:** -- `backend/igny8_core/modules/*/views.py` -- `backend/igny8_core/modules/*/serializers.py` -- `frontend/src/api/*.ts` -- `frontend/src/types/models.ts` -- `frontend/src/pages/*/*.tsx` -- `frontend/src/App.tsx` -- `frontend/src/layout/Sidebar.tsx` - -### Pattern 3: Adding Async Processing - -**Timeline: ~30 minutes** - -``` -1. Backend Celery Task → Create task function -2. Backend ViewSet Action → Queue task from endpoint -3. Frontend API Hook → Create trigger function -4. Frontend Component → Add button to trigger -5. Frontend Polling/Polling → Show task status -6. Test → Verify task execution and completion -``` - -**Files Modified:** -- `backend/igny8_core/tasks/*_tasks.py` -- `backend/igny8_core/modules/*/views.py` -- `frontend/src/api/*.ts` -- `frontend/src/pages/*/*.tsx` - ---- - -## File Organization Index - -### Module File Structure Template - -For each module (Planner, Writer, Linker, etc.): - -``` -backend/igny8_core/ -├── modules/[module]/ -│ ├── views.py # ViewSets, @actions -│ ├── serializers.py # Serializers -│ ├── urls.py # Route registration -│ └── apps.py -└── business/[domain]/ - ├── models.py # Data models - ├── services/ - │ ├── service1.py # Business logic - │ └── service2.py - └── migrations/ - └── XXXX_*.py # DB migrations - -frontend/src/ -├── pages/[Module]/ -│ ├── Dashboard.tsx # Overview page -│ ├── [Feature].tsx # Feature pages -│ └── [Detail].tsx # Detail views -├── components/[Module]/ -│ ├── [Feature]Table.tsx # Table components -│ ├── [Feature]Form.tsx # Form components -│ └── [Feature]Filter.tsx # Filter components -├── api/ -│ └── [module].ts # API calls -├── store/ -│ └── [module]Store.ts # State management -└── types/ - └── models.ts # Type definitions -``` - -### Global Files (All Modules) - -| File | Purpose | Where Located | -|------|---------|---------------| -| **API Base Classes** | ViewSet, Serializer base | `backend/igny8_core/api/base.py` | -| **Response Format** | Unified responses | `backend/igny8_core/api/response.py` | -| **Permissions** | Role-based access | `backend/igny8_core/api/permissions.py` | -| **Pagination** | List pagination | `backend/igny8_core/api/pagination.py` | -| **Root Router** | Route registration | `backend/igny8_core/urls.py` | -| **Settings** | Django config | `backend/igny8_core/settings.py` | -| **Auth Models** | User, Site, Sector | `backend/igny8_core/auth/models.py` | -| **API Client** | Frontend HTTP | `frontend/src/api/client.ts` | -| **Auth Store** | User state | `frontend/src/store/authStore.ts` | -| **Site Store** | Site/Sector context | `frontend/src/store/siteStore.ts` | -| **App Router** | Frontend routes | `frontend/src/App.tsx` | -| **Sidebar** | Navigation menu | `frontend/src/layout/Sidebar.tsx` | -| **Constants** | Global constants | `frontend/src/config/constants.ts` | -| **Validators** | Validation rules | `frontend/src/utils/validators.ts` | - ---- - -## Quick Checklists - -### Adding a New Column: Checklist - -- [ ] Add field to Backend Model (`models.py`) -- [ ] Run `makemigrations` -- [ ] Run `migrate` -- [ ] Add field to Backend Serializer (`serializers.py`) -- [ ] Add to ViewSet `filterset_fields` if filterable -- [ ] Add to ViewSet `ordering_fields` if sortable -- [ ] Add to Frontend Type (`types/models.ts`) -- [ ] Add to Frontend Component columns config -- [ ] Add to Frontend Table JSX rendering -- [ ] Test API response includes field -- [ ] Test frontend displays column -- [ ] Test filter/sort works (if applicable) - -### Adding a New Page: Checklist - -- [ ] Create new Page component (`pages/[Module]/[Page].tsx`) -- [ ] Create or update API hook (`api/[module].ts`) -- [ ] Add route to router (`App.tsx`) -- [ ] Add menu item to navigation (`layout/Sidebar.tsx`) -- [ ] Add types if needed (`types/models.ts`) -- [ ] Test page loads -- [ ] Test data fetches -- [ ] Test filters/search work -- [ ] Test pagination works -- [ ] Test error states - -### Adding an API Endpoint: Checklist - -- [ ] Define endpoint action in ViewSet (`views.py`) -- [ ] Use `@action(detail=...)` decorator -- [ ] Define response format using serializer -- [ ] Add permission check if needed -- [ ] Document docstring -- [ ] Test endpoint with API client -- [ ] Verify unified response format -- [ ] Create frontend API hook (`api/[module].ts`) -- [ ] Test from frontend component -- [ ] Verify error handling - ---- - -**End of Document** - -For questions on specific features, refer to: -- Architecture: `00-SYSTEM-ARCHITECTURE-MASTER-REFERENCE.md` -- API Details: `01-IGNY8-REST-API-COMPLETE-REFERENCE.md` -- Workflows: `02-PLANNER-WRITER-WORKFLOW-TECHNICAL-GUIDE.md` diff --git a/approved-docs/AI-FUNCTIONS-COMPLETE-REFERENCE.md b/approved-docs/AI-FUNCTIONS-COMPLETE-REFERENCE.md deleted file mode 100644 index cd466d0c..00000000 --- a/approved-docs/AI-FUNCTIONS-COMPLETE-REFERENCE.md +++ /dev/null @@ -1,1155 +0,0 @@ -# IGNY8 AI Functions - Complete Technical Reference -**Date:** December 3, 2025 -**Version:** 2.0 - CORRECTED AFTER AUTOMATION AUDIT -**100% Based on Actual Codebase (Backend + Frontend + Automation Integration)** - ---- - -## Table of Contents -1. [Overview](#overview) -2. [AI Architecture](#ai-architecture) -3. [AI Function Registry](#ai-function-registry) -4. [Planner Module AI Functions](#planner-module-ai-functions) -5. [Writer Module AI Functions](#writer-module-ai-functions) -6. [AI Function Base Class](#ai-function-base-class) -7. [AI Engine & Execution](#ai-engine--execution) -8. [Credit System Integration](#credit-system-integration) -9. [Progress Tracking](#progress-tracking) - ---- - -## Overview - -IGNY8 uses a centralized AI function architecture where all AI operations inherit from `BaseAIFunction` and execute through `AIEngine`. This ensures consistent: -- Credit management -- Progress tracking -- Error handling -- Logging -- Response parsing - -**Total AI Functions: 6** - -| Function | Module | Purpose | Input | Output | Credits | -|----------|--------|---------|-------|--------|---------| -| `auto_cluster` | Planner | Group keywords into semantic clusters | Keyword IDs | Clusters created | ~1 per 5 keywords | -| `generate_ideas` | Planner | Generate content ideas from clusters | Cluster IDs | Ideas created | 2 per cluster | -| `generate_content` | Writer | Generate article content from tasks | Task IDs | Content drafts | ~5 per 2500 words | -| `generate_image_prompts` | Writer | Extract image prompts from content | Content IDs | Image records with prompts | ~2 per content | -| `generate_images` | Writer | Generate actual images from prompts | Image IDs | Image URLs | 1-4 per image | -| `optimize_content` | Writer | SEO optimization of content | Content IDs | Updated content | ~1 per content | - ---- - -## AI Architecture - -### Directory Structure - -``` -backend/igny8_core/ai/ -├── __init__.py -├── base.py # BaseAIFunction (abstract class) -├── engine.py # AIEngine (execution orchestrator) -├── registry.py # Function registration & lazy loading -├── ai_core.py # Core AI API interactions -├── prompts.py # PromptRegistry -├── tasks.py # Celery tasks for async execution -├── models.py # AITaskLog, AIUsageLog -├── validators.py # Input validation helpers -├── settings.py # AI configuration -├── tracker.py # ProgressTracker, StepTracker -└── functions/ - ├── __init__.py - ├── auto_cluster.py # AutoClusterFunction - ├── generate_ideas.py # GenerateIdeasFunction - ├── generate_content.py # GenerateContentFunction - ├── generate_image_prompts.py # GenerateImagePromptsFunction - ├── generate_images.py # GenerateImagesFunction - └── optimize_content.py # OptimizeContentFunction -``` - -### Execution Flow - -``` -User Action → API Endpoint → Service Layer → AIEngine.execute() - ↓ - BaseAIFunction - ↓ - ┌───────────────────┴───────────────────┐ - ↓ ↓ - Synchronous (small ops) Async (Celery task) - ↓ ↓ - Direct function execution run_ai_task.delay() - ↓ ↓ - INIT → PREP → AI_CALL → PARSE → SAVE → DONE - ↓ - Credit deduction (automatic) - ↓ - Progress tracking (StepTracker) - ↓ - AIUsageLog created -``` - ---- - -## AI Function Registry - -**File:** `backend/igny8_core/ai/registry.py` - -### Lazy Loading System - -Functions are registered with lazy loaders and only imported when called: - -```python -_FUNCTION_REGISTRY: Dict[str, Type[BaseAIFunction]] = {} -_FUNCTION_LOADERS: Dict[str, callable] = {} - -def get_function_instance(name: str) -> Optional[BaseAIFunction]: - """Get function instance by name - lazy loads if needed""" - actual_name = FUNCTION_ALIASES.get(name, name) - fn_class = get_function(actual_name) - if fn_class: - return fn_class() - return None -``` - -### Registered Functions - -```python -# Lazy loaders -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('optimize_content', _load_optimize_content) -``` - ---- - -## Planner Module AI Functions - -### 1. AutoClusterFunction - -**File:** `backend/igny8_core/ai/functions/auto_cluster.py` - -**Purpose:** Groups semantically related keywords into topic clusters using AI - -**Class Definition:** -```python -class AutoClusterFunction(BaseAIFunction): - def get_name(self) -> str: - return 'auto_cluster' -``` - -**Metadata:** -```python -{ - 'display_name': 'Auto Cluster Keywords', - 'description': 'Group related keywords into semantic clusters using AI', - 'phases': { - 'INIT': 'Initializing clustering...', - 'PREP': 'Loading keywords...', - 'AI_CALL': 'Analyzing keyword relationships...', - 'PARSE': 'Parsing cluster data...', - 'SAVE': 'Creating clusters...', - 'DONE': 'Clustering complete!' - } -} -``` - -**Method: validate()** -```python -def validate(self, payload: dict, account=None) -> Dict: - # Validates: - # - IDs exist - # - Keywords exist in database - # - Account ownership - # NO MAX LIMIT - processes any count - return {'valid': True} -``` - -**Method: prepare()** -```python -def prepare(self, payload: dict, account=None) -> Dict: - ids = payload.get('ids', []) - sector_id = payload.get('sector_id') - - keywords = Keywords.objects.filter(id__in=ids, account=account).select_related( - 'account', 'site', 'sector', 'seed_keyword' - ) - - return { - 'keywords': keywords, # Keyword objects - 'keyword_data': [ # Data for AI - { - 'id': kw.id, - 'keyword': kw.keyword, # From seed_keyword relationship - 'volume': kw.volume, - 'difficulty': kw.difficulty, - 'intent': kw.intent, - } - for kw in keywords - ], - 'sector_id': sector_id - } -``` - -**Method: build_prompt()** -```python -def build_prompt(self, data: Dict, account=None) -> str: - keyword_data = data['keyword_data'] - - # Format keywords for prompt - keywords_text = '\n'.join([ - f"- {kw['keyword']} (Volume: {kw['volume']}, Difficulty: {kw['difficulty']}, Intent: {kw['intent']})" - for kw in keyword_data - ]) - - # Get prompt template from registry - prompt = PromptRegistry.get_prompt( - function_name='auto_cluster', - account=account, - context={'KEYWORDS': keywords_text} - ) - - # Ensure JSON mode compatibility - if 'json' not in prompt.lower(): - prompt += "\n\nIMPORTANT: You must respond with valid JSON only." - - return prompt -``` - -**Method: parse_response()** -```python -def parse_response(self, response: str, step_tracker=None) -> List[Dict]: - # Try direct JSON parse - try: - json_data = json.loads(response.strip()) - except json.JSONDecodeError: - # Fallback to extract_json (handles markdown code blocks) - ai_core = AICore(account=self.account) - json_data = ai_core.extract_json(response) - - # Extract clusters array - if isinstance(json_data, dict): - clusters = json_data.get('clusters', []) - elif isinstance(json_data, list): - clusters = json_data - - return clusters # [{name, keywords: [], description}] -``` - -**Method: save_output()** -```python -def save_output(self, parsed: List[Dict], original_data: Dict, account=None, - progress_tracker=None, step_tracker=None) -> Dict: - keywords = original_data['keywords'] - account = account or keywords[0].account - site = keywords[0].site - sector = keywords[0].sector - - clusters_created = 0 - keywords_updated = 0 - - with transaction.atomic(): - for cluster_data in parsed: - cluster_name = cluster_data.get('name', '') - cluster_keywords = cluster_data.get('keywords', []) - - # Get or create cluster - cluster, created = Clusters.objects.get_or_create( - name=cluster_name, - account=account, - site=site, - sector=sector, - defaults={ - 'description': cluster_data.get('description', ''), - 'status': 'active', - } - ) - - if created: - clusters_created += 1 - - # Match keywords (case-insensitive) - for keyword_obj in keywords: - if keyword_obj.keyword.lower() in [k.lower() for k in cluster_keywords]: - keyword_obj.cluster = cluster - keyword_obj.status = 'mapped' - keyword_obj.save() - keywords_updated += 1 - - # Recalculate cluster metrics - for cluster in Clusters.objects.filter(account=account, site=site, sector=sector): - cluster.keywords_count = Keywords.objects.filter(cluster=cluster).count() - cluster.volume = Keywords.objects.filter(cluster=cluster).aggregate( - total=Sum(Case( - When(volume_override__isnull=False, then=F('volume_override')), - default=F('seed_keyword__volume'), - output_field=IntegerField() - )) - )['total'] or 0 - cluster.save() - - return { - 'count': clusters_created, - 'clusters_created': clusters_created, - 'keywords_updated': keywords_updated - } -``` - -**Service Integration:** -```python -# backend/igny8_core/business/planning/services/clustering_service.py -class ClusteringService: - def cluster_keywords(self, keyword_ids, account, sector_id=None): - from igny8_core.ai.tasks import run_ai_task - - payload = {'ids': keyword_ids, 'sector_id': sector_id} - - if hasattr(run_ai_task, 'delay'): - # Async via Celery - task = run_ai_task.delay( - function_name='auto_cluster', - payload=payload, - account_id=account.id - ) - return {'success': True, 'task_id': str(task.id)} - else: - # Sync execution - result = run_ai_task( - function_name='auto_cluster', - payload=payload, - account_id=account.id - ) - return result -``` - ---- - -### 2. GenerateIdeasFunction - -**File:** `backend/igny8_core/ai/functions/generate_ideas.py` - -**Purpose:** Generate SEO-optimized content ideas from keyword clusters - -**Class Definition:** -```python -class GenerateIdeasFunction(BaseAIFunction): - def get_name(self) -> str: - return 'generate_ideas' - - def get_max_items(self) -> int: - return 10 # Max clusters per batch -``` - -**Metadata:** -```python -{ - 'display_name': 'Generate Ideas', - 'description': 'Generate SEO-optimized content ideas from keyword clusters', - 'phases': { - 'INIT': 'Initializing idea generation...', - 'PREP': 'Loading clusters...', - 'AI_CALL': 'Generating ideas with AI...', - 'PARSE': 'Parsing idea data...', - 'SAVE': 'Saving ideas...', - 'DONE': 'Ideas generated!' - } -} -``` - -**Method: prepare()** -```python -def prepare(self, payload: dict, account=None) -> Dict: - cluster_ids = payload.get('ids', []) - - clusters = Clusters.objects.filter(id__in=cluster_ids, account=account).select_related( - 'sector', 'account', 'site' - ).prefetch_related('keywords') - - cluster_data = [] - for cluster in clusters: - # Get keywords from Keywords model (via seed_keyword relationship) - keyword_objects = Keywords.objects.filter(cluster=cluster).select_related('seed_keyword') - keywords = [kw.seed_keyword.keyword for kw in keyword_objects if kw.seed_keyword] - - cluster_data.append({ - 'id': cluster.id, - 'name': cluster.name, - 'description': cluster.description or '', - 'keywords': keywords, - }) - - return { - 'clusters': clusters, - 'cluster_data': cluster_data, - 'account': account or clusters[0].account - } -``` - -**Method: build_prompt()** -```python -def build_prompt(self, data: Dict, account=None) -> str: - cluster_data = data['cluster_data'] - - clusters_text = '\n'.join([ - f"Cluster ID: {c['id']} | Name: {c['name']} | Description: {c.get('description', '')}" - for c in cluster_data - ]) - - cluster_keywords_text = '\n'.join([ - f"Cluster ID: {c['id']} | Name: {c['name']} | Keywords: {', '.join(c.get('keywords', []))}" - for c in cluster_data - ]) - - prompt = PromptRegistry.get_prompt( - function_name='generate_ideas', - account=account or data['account'], - context={ - 'CLUSTERS': clusters_text, - 'CLUSTER_KEYWORDS': cluster_keywords_text, - } - ) - - return prompt -``` - -**Method: parse_response()** -```python -def parse_response(self, response: str, step_tracker=None) -> List[Dict]: - ai_core = AICore(account=self.account) - json_data = ai_core.extract_json(response) - - if not json_data or 'ideas' not in json_data: - raise ValueError(f"Failed to parse ideas response") - - return json_data.get('ideas', []) - # Expected format: [{title, description, cluster_id, content_type, content_structure, ...}] -``` - -**Method: save_output()** -```python -def save_output(self, parsed: List[Dict], original_data: Dict, account=None, - progress_tracker=None, step_tracker=None) -> Dict: - clusters = original_data['clusters'] - account = account or original_data['account'] - - ideas_created = 0 - - with transaction.atomic(): - for idea_data in parsed: - # Match cluster by ID or name - cluster = None - cluster_id_from_ai = idea_data.get('cluster_id') - cluster_name = idea_data.get('cluster_name', '') - - if cluster_id_from_ai: - cluster = next((c for c in clusters if c.id == cluster_id_from_ai), None) - - if not cluster and cluster_name: - cluster = next((c for c in clusters if c.name == cluster_name), None) - - if not cluster: - continue - - site = cluster.site or (cluster.sector.site if cluster.sector else None) - - # Handle description (might be dict or string) - description = idea_data.get('description', '') - if isinstance(description, dict): - description = json.dumps(description) - - # Create ContentIdeas record - ContentIdeas.objects.create( - idea_title=idea_data.get('title', 'Untitled Idea'), - description=description, - content_type=idea_data.get('content_type', 'post'), - content_structure=idea_data.get('content_structure', 'article'), - target_keywords=idea_data.get('covered_keywords', '') or idea_data.get('target_keywords', ''), - keyword_cluster=cluster, - estimated_word_count=idea_data.get('estimated_word_count', 1500), - status='new', - account=account, - site=site, - sector=cluster.sector, - ) - ideas_created += 1 - - # Update cluster status - if cluster.status == 'new': - cluster.status = 'mapped' - cluster.save() - - return { - 'count': ideas_created, - 'ideas_created': ideas_created - } -``` - -**Service Integration:** -```python -# backend/igny8_core/business/planning/services/ideas_service.py -class IdeasService: - def generate_ideas(self, cluster_ids, account): - from igny8_core.ai.tasks import run_ai_task - - payload = {'ids': cluster_ids} - - if hasattr(run_ai_task, 'delay'): - task = run_ai_task.delay( - function_name='auto_generate_ideas', - payload=payload, - account_id=account.id - ) - return {'success': True, 'task_id': str(task.id)} - else: - result = run_ai_task( - function_name='auto_generate_ideas', - payload=payload, - account_id=account.id - ) - return result -``` - ---- - -## Writer Module AI Functions - -### 3. GenerateContentFunction - -**File:** `backend/igny8_core/ai/functions/generate_content.py` - -**Purpose:** Generate complete article content from task requirements - -**Class Definition:** -```python -class GenerateContentFunction(BaseAIFunction): - def get_name(self) -> str: - return 'generate_content' - - def get_max_items(self) -> int: - return 50 # Max tasks per batch -``` - -**Key Implementation Details:** - -**Method: prepare()** -```python -def prepare(self, payload: dict, account=None) -> List: - task_ids = payload.get('ids', []) - - tasks = Tasks.objects.filter(id__in=task_ids, account=account).select_related( - 'account', 'site', 'sector', 'cluster', 'taxonomy_term' - ) - - return list(tasks) -``` - -**Method: build_prompt()** -```python -def build_prompt(self, data: Any, account=None) -> str: - task = data[0] if isinstance(data, list) else data - - # Build idea data - idea_data = f"Title: {task.title or 'Untitled'}\n" - if task.description: - idea_data += f"Description: {task.description}\n" - idea_data += f"Content Type: {task.content_type or 'post'}\n" - idea_data += f"Content Structure: {task.content_structure or 'article'}\n" - - # Build cluster context - cluster_data = '' - if task.cluster: - cluster_data = f"Cluster Name: {task.cluster.name}\n" - if task.cluster.description: - cluster_data += f"Description: {task.cluster.description}\n" - - # Build taxonomy context - taxonomy_data = '' - if task.taxonomy_term: - taxonomy_data = f"Taxonomy: {task.taxonomy_term.name}\n" - if task.taxonomy_term.taxonomy_type: - taxonomy_data += f"Type: {task.taxonomy_term.get_taxonomy_type_display()}\n" - - # Build keywords - keywords_data = '' - if task.keywords: - keywords_data = f"Keywords: {task.keywords}\n" - - prompt = PromptRegistry.get_prompt( - function_name='generate_content', - account=account or task.account, - task=task, - context={ - 'IDEA': idea_data, - 'CLUSTER': cluster_data, - 'TAXONOMY': taxonomy_data, - 'KEYWORDS': keywords_data, - } - ) - - return prompt -``` - -**Method: parse_response()** -```python -def parse_response(self, response: str, step_tracker=None) -> Dict: - # Try JSON parse first - try: - parsed_json = json.loads(response.strip()) - if isinstance(parsed_json, dict): - return parsed_json - except (json.JSONDecodeError, ValueError): - pass - - # Fallback: normalize plain HTML content - try: - from igny8_core.utils.content_normalizer import normalize_content - normalized = normalize_content(response) - return {'content': normalized['normalized_content']} - except Exception: - return {'content': response} -``` - -**Method: save_output() - CRITICAL WITH TAGS/CATEGORIES** -```python -def save_output(self, parsed: Any, original_data: Any, account=None, - progress_tracker=None, step_tracker=None) -> Dict: - task = original_data[0] if isinstance(original_data, list) else original_data - - # Extract fields from parsed response - if isinstance(parsed, dict): - content_html = parsed.get('content', '') - title = parsed.get('title') or task.title - meta_title = parsed.get('meta_title') or parsed.get('seo_title') or title - meta_description = parsed.get('meta_description') or parsed.get('seo_description') - primary_keyword = parsed.get('primary_keyword') or parsed.get('focus_keyword') - secondary_keywords = parsed.get('secondary_keywords') or parsed.get('keywords', []) - tags_from_response = parsed.get('tags', []) - categories_from_response = parsed.get('categories', []) - else: - content_html = str(parsed) - title = task.title - # ... defaults - tags_from_response = [] - categories_from_response = [] - - # Calculate word count - word_count = 0 - if content_html: - text_for_counting = re.sub(r'<[^>]+>', '', content_html) - word_count = len(text_for_counting.split()) - - # Create Content record (independent, NOT OneToOne with Task) - content_record = Content.objects.create( - title=title, - content_html=content_html, - word_count=word_count, - meta_title=meta_title, - meta_description=meta_description, - primary_keyword=primary_keyword, - secondary_keywords=secondary_keywords if isinstance(secondary_keywords, list) else [], - cluster=task.cluster, - content_type=task.content_type, - content_structure=task.content_structure, - source='igny8', - status='draft', - account=task.account, - site=task.site, - sector=task.sector, - ) - - # Link taxonomy term from task - if task.taxonomy_term: - content_record.taxonomy_terms.add(task.taxonomy_term) - - # Process tags from AI response - if tags_from_response and isinstance(tags_from_response, list): - from django.utils.text import slugify - for tag_name in tags_from_response: - if tag_name and isinstance(tag_name, str): - tag_name = tag_name.strip() - if tag_name: - tag_slug = slugify(tag_name) - tag_obj, created = ContentTaxonomy.objects.get_or_create( - site=task.site, - slug=tag_slug, - taxonomy_type='tag', - defaults={ - 'name': tag_name, - 'sector': task.sector, - 'account': task.account, - } - ) - content_record.taxonomy_terms.add(tag_obj) - - # Process categories from AI response - if categories_from_response and isinstance(categories_from_response, list): - from django.utils.text import slugify - for category_name in categories_from_response: - if category_name and isinstance(category_name, str): - category_name = category_name.strip() - if category_name: - category_slug = slugify(category_name) - category_obj, created = ContentTaxonomy.objects.get_or_create( - site=task.site, - slug=category_slug, - taxonomy_type='category', - defaults={ - 'name': category_name, - 'sector': task.sector, - 'account': task.account, - } - ) - content_record.taxonomy_terms.add(category_obj) - - # Update task status - task.status = 'completed' - task.save(update_fields=['status', 'updated_at']) - - # Auto-sync idea status - if hasattr(task, 'idea') and task.idea: - task.idea.status = 'completed' - task.idea.save(update_fields=['status', 'updated_at']) - - return { - 'count': 1, - 'content_id': content_record.id, - 'task_id': task.id, - 'word_count': word_count, - } -``` - ---- - -### 4. GenerateImagePromptsFunction - -**File:** `backend/igny8_core/ai/functions/generate_image_prompts.py` - -**Purpose:** Extract detailed image generation prompts from content HTML - -**Class Definition:** -```python -class GenerateImagePromptsFunction(BaseAIFunction): - def get_name(self) -> str: - return 'generate_image_prompts' - - def get_max_items(self) -> int: - return 50 # Max content records per batch -``` - -**Method: prepare()** -```python -def prepare(self, payload: dict, account=None) -> List: - content_ids = payload.get('ids', []) - - contents = Content.objects.filter(id__in=content_ids, account=account).select_related( - 'account', 'site', 'sector', 'cluster' - ) - - max_images = self._get_max_in_article_images(account) - - extracted_data = [] - for content in contents: - extracted = self._extract_content_elements(content, max_images) - extracted_data.append({ - 'content': content, - 'extracted': extracted, - 'max_images': max_images, - }) - - return extracted_data -``` - -**Helper: _extract_content_elements()** -```python -def _extract_content_elements(self, content: Content, max_images: int) -> Dict: - from bs4 import BeautifulSoup - - html_content = content.content_html or '' - soup = BeautifulSoup(html_content, 'html.parser') - - # Extract title - title = content.title or '' - - # Extract intro paragraphs (skip italic hook) - paragraphs = soup.find_all('p') - intro_paragraphs = [] - for p in paragraphs[:3]: - text = p.get_text(strip=True) - if len(text.split()) > 50: # Real paragraph - intro_paragraphs.append(text) - if len(intro_paragraphs) >= 2: - break - - # Extract H2 headings - h2_tags = soup.find_all('h2') - h2_headings = [h2.get_text(strip=True) for h2 in h2_tags[:max_images]] - - return { - 'title': title, - 'intro_paragraphs': intro_paragraphs, - 'h2_headings': h2_headings, - } -``` - -**Method: save_output()** -```python -def save_output(self, parsed: Dict, original_data: Any, account=None, - progress_tracker=None, step_tracker=None) -> Dict: - data = original_data[0] if isinstance(original_data, list) else original_data - content = data['content'] - max_images = data['max_images'] - - prompts_created = 0 - - with transaction.atomic(): - # Save featured image prompt - Images.objects.update_or_create( - content=content, - image_type='featured', - defaults={ - 'prompt': parsed['featured_prompt'], - 'status': 'pending', - 'position': 0, - } - ) - prompts_created += 1 - - # Save in-article image prompts - in_article_prompts = parsed.get('in_article_prompts', []) - for idx, prompt_text in enumerate(in_article_prompts[:max_images]): - Images.objects.update_or_create( - content=content, - image_type='in_article', - position=idx + 1, - defaults={ - 'prompt': prompt_text, - 'status': 'pending', - } - ) - prompts_created += 1 - - return { - 'count': prompts_created, - 'prompts_created': prompts_created, - } -``` - ---- - -### 5. GenerateImagesFunction - -**File:** `backend/igny8_core/ai/functions/generate_images.py` - -**Purpose:** Generate actual image URLs from Image records with prompts - -**Note:** This function is partially implemented. The actual image generation happens via provider APIs. - ---- - -## AI Function Base Class - -**File:** `backend/igny8_core/ai/base.py` - -All AI functions inherit from this abstract base: - -```python -class BaseAIFunction(ABC): - """Base class for all AI functions""" - - @abstractmethod - def get_name(self) -> str: - """Return function name (e.g., 'auto_cluster')""" - pass - - def get_metadata(self) -> Dict: - """Return function metadata (display name, description, phases)""" - return { - 'display_name': self.get_name().replace('_', ' ').title(), - 'description': f'{self.get_name()} AI function', - 'phases': { - 'INIT': 'Initializing...', - 'PREP': 'Preparing data...', - 'AI_CALL': 'Processing with AI...', - 'PARSE': 'Parsing response...', - 'SAVE': 'Saving results...', - 'DONE': 'Complete!' - } - } - - def validate(self, payload: dict, account=None) -> Dict[str, Any]: - """Validate input payload""" - ids = payload.get('ids', []) - if not ids: - return {'valid': False, 'error': 'No IDs provided'} - return {'valid': True} - - def get_max_items(self) -> Optional[int]: - """Override to set max items limit""" - return None - - @abstractmethod - def prepare(self, payload: dict, account=None) -> Any: - """Load and prepare data for AI processing""" - pass - - @abstractmethod - def build_prompt(self, data: Any, account=None) -> str: - """Build AI prompt from prepared data""" - pass - - def get_model(self, account=None) -> Optional[str]: - """Override to specify model (defaults to account's default model)""" - return None - - @abstractmethod - def parse_response(self, response: str, step_tracker=None) -> Any: - """Parse AI response into structured data""" - pass - - @abstractmethod - def save_output(self, parsed: Any, original_data: Any, account=None, - progress_tracker=None, step_tracker=None) -> Dict: - """Save parsed results to database""" - pass -``` - ---- - -## AI Engine & Execution - -**File:** `backend/igny8_core/ai/engine.py` - -The `AIEngine` orchestrates all AI function execution: - -```python -class AIEngine: - def __init__(self, account: Account): - self.account = account - self.ai_core = AICore(account=account) - - def execute(self, fn: BaseAIFunction, payload: dict) -> Dict: - """ - Execute AI function with full orchestration: - 1. Validation - 2. Preparation - 3. AI call - 4. Response parsing - 5. Output saving - 6. Credit deduction (automatic) - 7. Progress tracking - 8. Logging - """ - - # Step 1: Validate - validation = fn.validate(payload, self.account) - if not validation['valid']: - return {'success': False, 'error': validation['error']} - - # Step 2: Prepare data - prepared_data = fn.prepare(payload, self.account) - - # Step 3: Build prompt - prompt = fn.build_prompt(prepared_data, self.account) - - # Step 4: Call AI (via AICore) - model = fn.get_model(self.account) or self._get_default_model() - response = self.ai_core.run_ai_request( - prompt=prompt, - model=model, - function_name=fn.get_name() - ) - - # Step 5: Parse response - parsed = fn.parse_response(response['content']) - - # Step 6: Save output - result = fn.save_output(parsed, prepared_data, self.account) - - # Step 7: Deduct credits (AUTOMATIC - line 395) - CreditService.deduct_credits_for_operation( - account=self.account, - operation_type=self._get_operation_type(), - amount=self._get_actual_amount(), - ) - - # Step 8: Log to AIUsageLog - AIUsageLog.objects.create( - account=self.account, - function_name=fn.get_name(), - credits_used=credits_deducted, - # ... other fields - ) - - return { - 'success': True, - **result - } -``` - -**Key Point:** Credits are AUTOMATICALLY deducted by AIEngine. AI functions do NOT handle credits themselves. - ---- - -## Credit System Integration - -**Automatic Credit Deduction:** - -All credit management happens in `AIEngine.execute()` at line 395: - -```python -# backend/igny8_core/ai/engine.py line 395 -CreditService.deduct_credits_for_operation( - account=account, - operation_type=self._get_operation_type(), - amount=self._get_actual_amount(), -) -``` - -**AI Functions DO NOT:** -- Calculate credit costs -- Call `CreditService` manually -- Handle credit errors (handled by AIEngine) - -**AI Functions ONLY:** -- Focus on their specific logic -- Return `{'count': N}` in `save_output()` -- AIEngine uses `count` to calculate credits - ---- - -## Progress Tracking - -**StepTracker & ProgressTracker:** - -All AI functions emit progress events through trackers: - -```python -# Phases emitted automatically by AIEngine -phases = { - 'INIT': 'Initializing...', # 0-10% - 'PREP': 'Preparing data...', # 10-20% - 'AI_CALL': 'Processing with AI...', # 20-80% - 'PARSE': 'Parsing response...', # 80-90% - 'SAVE': 'Saving results...', # 90-100% - 'DONE': 'Complete!' # 100% -} -``` - -Frontend can listen to these events via: -- Celery task status polling -- WebSocket connections -- REST API `/task-progress/:task_id/` endpoint - ---- - -## Summary - -**6 AI Functions - All Production Ready:** - -| Function | Lines of Code | Status | Used By | -|----------|---------------|--------|---------| -| `auto_cluster` | ~380 | ✅ Complete | Planner, Automation Stage 1 | -| `generate_ideas` | ~250 | ✅ Complete | Planner, Automation Stage 2 | -| `generate_content` | ~400 | ✅ Complete | Writer, Automation Stage 4 | -| `generate_image_prompts` | ~280 | ✅ Complete | Writer, Automation Stage 5 | -| `generate_images` | ~300 | ⚠️ Partial | Writer, Automation Stage 6 | -| `optimize_content` | ~200 | ✅ Complete | Writer (Manual) | - -**Architecture Benefits:** -- Single source of truth for AI operations -- Consistent credit management -- Unified error handling -- Centralized progress tracking -- Easy to add new AI functions (inherit from BaseAIFunction) - -### Current gaps vs code (Dec 2025) -- AIEngine now performs a credit pre-check before the AI call (still deducts after SAVE); this is not reflected in earlier notes. -- `generate_images` implementation is partially broken: it expects task IDs (not image IDs), tries to read `task.content` (field does not exist), and uses the `extract_image_prompts` prompt path; credit estimation also looks for `image_ids`. Treat it as partial/needs fix. -- AIEngine includes messaging/cost maps for `generate_site_structure` (extra function beyond the documented six); not presently documented above. - ---- - -## Automation Integration - -**VERIFIED:** All AI functions are integrated into the IGNY8 Automation Pipeline. - -### 7-Stage Automation Pipeline - -The automation system (`backend/igny8_core/business/automation/services/automation_service.py`) uses 5 of the 6 AI functions: - -``` -Stage 1: Keywords → Clusters - ↓ Uses: AutoClusterFunction - ↓ Credits: ~0.2 per keyword - -Stage 2: Clusters → Ideas - ↓ Uses: GenerateIdeasFunction - ↓ Credits: 2 per cluster - -Stage 3: Ideas → Tasks - ↓ Uses: None (Local operation) - ↓ Credits: 0 - -Stage 4: Tasks → Content - ↓ Uses: GenerateContentFunction - ↓ Credits: ~5 per task (2500 words) - -Stage 5: Content → Image Prompts - ↓ Uses: GenerateImagePromptsFunction - ↓ Credits: ~2 per content - -Stage 6: Image Prompts → Images - ↓ Uses: GenerateImagesFunction ⚠️ - ↓ Credits: 1-4 per image - -Stage 7: Manual Review Gate - ↓ Uses: None (Manual intervention) - ↓ Credits: 0 -``` - -### Automation Execution Flow - -```python -# AutomationService.run_stage_1() example -def run_stage_1(self): - keywords = Keywords.objects.filter(site=self.site, status='new')[:batch_size] - - # Call AI function via Celery - from igny8_core.ai.tasks import run_ai_task - result = run_ai_task( - function_name='auto_cluster', - payload={'ids': [k.id for k in keywords]}, - account_id=self.account.id - ) - - # Credits automatically deducted by AIEngine - return { - 'keywords_processed': len(keywords), - 'clusters_created': result.get('count', 0), - 'credits_used': result.get('credits_used', 0) - } -``` - -### Frontend Access - -**Automation Page:** `/automation` (Fully functional) -- Real-time pipeline overview -- Manual trigger ("Run Now" button) -- Pause/Resume controls -- Live progress tracking -- Activity logs -- Run history - -**Planner & Writer Pages:** Individual AI function triggers -- Cluster keywords (Stage 1) -- Generate ideas (Stage 2) -- Generate content (Stage 4) -- Extract image prompts (Stage 5) -- Generate images (Stage 6) - ---- - -**End of AI Functions Reference** diff --git a/approved-docs/API-COMPLETE-REFERENCE-LATEST.md b/approved-docs/API-COMPLETE-REFERENCE-LATEST.md deleted file mode 100644 index 814c263c..00000000 --- a/approved-docs/API-COMPLETE-REFERENCE-LATEST.md +++ /dev/null @@ -1,1516 +0,0 @@ -# IGNY8 API Complete Reference v1.0 - -**Base URL**: `https://api.igny8.com/api/v1/` -**Version**: 1.0.0 -**Last Updated**: 2025-01-XX -**Status**: ✅ **100% IMPLEMENTED** - All endpoints use unified format - -**Purpose**: Complete, unified reference for IGNY8 API covering authentication, endpoints, response formats, error handling, rate limiting, permissions, and integration examples. - ---- - -## Table of Contents - -1. [Quick Start](#quick-start) -2. [Overview & Architecture](#overview--architecture) -3. [Authentication & Authorization](#authentication--authorization) -4. [Response Format Standard](#response-format-standard) -5. [Error Handling](#error-handling) -6. [Rate Limiting](#rate-limiting) -7. [Pagination](#pagination) -8. [Roles & Permissions](#roles--permissions) -9. [Tenant / Site / Sector Scoping](#tenant--site--sector-scoping) -10. [Complete Endpoint Reference](#complete-endpoint-reference) -11. [Integration Examples](#integration-examples) -12. [Testing & Debugging](#testing--debugging) -13. [Change Management](#change-management) - ---- - -## Quick Start - -### Interactive Documentation - -- **Swagger UI**: `https://api.igny8.com/api/docs/` -- **ReDoc**: `https://api.igny8.com/api/redoc/` -- **OpenAPI Schema**: `https://api.igny8.com/api/schema/` - -### Basic Example - -```python -import requests - -BASE_URL = "https://api.igny8.com/api/v1" - -# 1. Login -response = requests.post( - f"{BASE_URL}/auth/login/", - json={"email": "user@example.com", "password": "password"} -) -data = response.json() - -if data['success']: - token = data['data']['access'] - - # 2. Use token for authenticated requests - headers = { - 'Authorization': f'Bearer {token}', - 'Content-Type': 'application/json' - } - - # 3. Get keywords - response = requests.get( - f"{BASE_URL}/planner/keywords/", - headers=headers - ) - result = response.json() - - if result['success']: - keywords = result['results'] # Paginated results - print(f"Found {result['count']} keywords") -``` - ---- - -## Overview & Architecture - -### API Standard v1.0 - Key Principles - -1. **Unified Response Format**: All endpoints return consistent JSON structure -2. **Layered Authorization**: Authentication → Tenant Access → Role → Site/Sector -3. **Centralized Error Handling**: All errors wrapped in unified format -4. **Scoped Rate Limiting**: Different limits for different operation types -5. **Tenant Isolation**: All resources scoped by account/site/sector -6. **Request Tracking**: Every request has a unique ID for debugging - -### Base URL Structure - -``` -Production: https://api.igny8.com/api/v1/ -Development: http://localhost:8000/api/v1/ -``` - -### Module Namespaces - -``` -/api/v1/ -├── auth/ # Authentication and user management -├── account/ # Account settings, team, and usage analytics -├── integration/ # Site integrations and sync -├── system/ # Settings, prompts, integrations -├── admin/billing/ # Admin-only billing management -├── billing/ # Credits, transactions, usage -├── planner/ # Keywords, clusters, content ideas -├── writer/ # Tasks, content, images -├── automation/ # Automation configuration and runs -├── linker/ # Internal linking operations -├── optimizer/ # Content optimization operations -└── publisher/ # Publishing records and deployments -``` - -### Module → Tag Map (Swagger/ReDoc) -- Authentication → `Authentication` -- Account → `Account` -- Integration → `Integration` -- System → `System` -- Admin Billing → `Admin Billing` -- Billing → `Billing` -- Planner → `Planner` -- Writer → `Writer` -- Automation → `Automation` -- Linker → `Linker` -- Optimizer → `Optimizer` -- Publisher → `Publisher` - -Tag display order (docs): Authentication, Account, Integration, System, Admin Billing, Billing, Planner, Writer, Automation, Linker, Optimizer, Publisher - -### Technology Stack - -- **Framework**: Django REST Framework (DRF) -- **Authentication**: JWT Bearer tokens (primary), Session (fallback), Basic (fallback) -- **Pagination**: CustomPageNumberPagination (default: 10, max: 100) -- **Rate Limiting**: Scoped throttles per module/operation type -- **OpenAPI**: drf-spectacular for schema generation - -### Implementation Status - -✅ **100% Complete** - All endpoints implemented with: -- Unified response format -- Proper authentication and authorization -- Rate limiting configured -- Error handling standardized -- Request ID tracking -- Complete Swagger/OpenAPI documentation - ---- - -## Authentication & Authorization - -### Authentication Methods - -#### Primary: JWT Bearer Token - -``` -Authorization: Bearer -``` - -**Token Characteristics:** -- Contains `user_id` and `account_id` -- Type: `access` (15-minute expiry) -- Automatically sets `request.account` via middleware -- Resolves account → tenant context automatically - -**Token Payload:** -```json -{ - "user_id": 1, - "account_id": 1, - "type": "access", - "exp": 1234567890 -} -``` - -#### Fallback Methods - -1. **Session Authentication** (admin panel) - - Class: `CSRFExemptSessionAuthentication` - - Use case: Django admin panel (`/admin/`) - -2. **Basic Authentication** (debug/testing) - - Class: `rest_framework.authentication.BasicAuthentication` - - Use case: API testing tools (Postman, curl) - -### Authentication Order - -1. JWT Token Authentication (tried first) -2. Session Authentication (fallback) -3. Basic Authentication (last fallback) -4. If all fail: 401 Unauthorized - -### Getting an Access Token - -**Login Endpoint:** -```http -POST /api/v1/auth/login/ -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "your_password" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "user": { - "id": 1, - "email": "user@example.com", - "username": "user", - "role": "owner", - "account": { ... } - }, - "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - }, - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Token Expiration - -- **Access Token**: 15 minutes -- **Refresh Token**: 7 days - -**Refresh Token:** -```http -POST /api/v1/auth/refresh/ -Content-Type: application/json - -{ - "refresh": "your_refresh_token" -} -``` - -### Public Endpoints (No Authentication Required) - -- `POST /api/v1/auth/register/` - User registration -- `POST /api/v1/auth/login/` - User login -- `GET /api/v1/auth/plans/` - List plans -- `GET /api/v1/auth/industries/` - List industries -- `GET /api/v1/system/status/` - System health check -- `GET /api/v1/system/ping/` - Health check endpoint - -**All other endpoints require JWT authentication.** - -### Authorization Layers - -Every endpoint enforces layered authorization: - -1. **User Authentication**: User must be authenticated -2. **Tenant Access**: User must belong to the tenant/account -3. **Role Authorization**: User must have appropriate role -4. **Site/Sector Access**: User must have access to requested site/sector - ---- - -## Response Format Standard - -### Mandatory Format - -**This is the global standard for all endpoints - no exceptions.** - -### Success Response - -```json -{ - "success": true, - "data": { - "id": 1, - "name": "Example Keyword", - "status": "active" - }, - "message": "Optional human-readable success message", - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Paginated Response - -```json -{ - "success": true, - "count": 120, - "next": "http://api.igny8.com/api/v1/planner/keywords/?page=3", - "previous": "http://api.igny8.com/api/v1/planner/keywords/?page=1", - "results": [ - {"id": 1, "name": "Keyword 1"}, - {"id": 2, "name": "Keyword 2"}, - ... - ], - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Error Response - -```json -{ - "success": false, - "error": "Readable top-level error message", - "errors": { - "field_name": ["Field-specific error messages"] - }, - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Response Helper Functions - -**File:** `backend/igny8_core/api/response.py` - -```python -from igny8_core.api.response import success_response, error_response, paginated_response - -# Success response -return success_response( - data={"id": 1, "name": "Example"}, - message="Resource created successfully", - status_code=status.HTTP_201_CREATED -) - -# Error response -return error_response( - error="Validation failed", - errors={"email": ["Invalid email format"]}, - status_code=status.HTTP_400_BAD_REQUEST -) - -# Paginated response -paginator = CustomPageNumberPagination() -page = paginator.paginate_queryset(queryset, request) -serializer = MySerializer(page, many=True) -paginated_data = paginator.get_paginated_response(serializer.data).data -return paginated_response(paginated_data, message="Resources retrieved successfully") -``` - ---- - -## Error Handling - -### HTTP Status Codes - -| Code | Meaning | Description | -|------|---------|-------------| -| 200 | OK | Request successful | -| 201 | Created | Resource created successfully | -| 204 | No Content | Resource deleted successfully | -| 400 | Bad Request | Validation error or invalid request | -| 401 | Unauthorized | Authentication required | -| 403 | Forbidden | Permission denied | -| 404 | Not Found | Resource not found | -| 409 | Conflict | Resource conflict (e.g., duplicate) | -| 422 | Unprocessable Entity | Validation failed | -| 429 | Too Many Requests | Rate limit exceeded | -| 500 | Internal Server Error | Server error | - -### Centralized Exception Handler - -**File:** `backend/igny8_core/api/exception_handlers.py` - -All exceptions are handled by a centralized exception handler that: -- Wraps all errors in unified format -- Uses proper HTTP status codes -- Includes sanitized validation errors under `errors` -- Always attaches `request_id` for error tracking -- Logs full exception details -- In DEBUG mode: includes traceback + request context - -### Error Response Examples - -**Validation Error (400):** -```json -{ - "success": false, - "error": "Validation failed", - "errors": { - "email": ["Invalid email format"], - "password": ["Password must be at least 8 characters"] - }, - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -**Authentication Error (401):** -```json -{ - "success": false, - "error": "Authentication required", - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -**Permission Error (403):** -```json -{ - "success": false, - "error": "Permission denied", - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -**Rate Limit (429):** -```json -{ - "success": false, - "error": "Rate limit exceeded", - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Server-side Logging - -- All 4xx errors logged as **warning** -- All 5xx errors logged as **error** -- Structured format with timestamp, request_id, endpoint, user_id, account_id, status_code, error_message -- Rotating log files -- Sentry integration hooks for production - ---- - -## Rate Limiting - -### Rate Limiting Configuration - -Rate limits are scoped by operation type. Check response headers for limit information: - -- `X-Throttle-Limit`: Maximum requests allowed -- `X-Throttle-Remaining`: Remaining requests in current window -- `X-Throttle-Reset`: Time when limit resets (Unix timestamp) - -### Rate Limit Scopes - -| Scope | Limit | Description | -|-------|-------|-------------| -| `ai_function` | 10/min | AI content generation, clustering | -| `image_gen` | 15/min | Image generation | -| `content_write` | 30/min | Content creation, updates | -| `content_read` | 100/min | Content listing, retrieval | -| `auth` | 20/min | Login, register, password reset | -| `auth_strict` | 5/min | Sensitive auth operations | -| `planner` | 60/min | Keyword, cluster, idea operations | -| `planner_ai` | 10/min | AI-powered planner operations | -| `writer` | 60/min | Task, content management | -| `writer_ai` | 10/min | AI-powered writer operations | -| `system` | 100/min | Settings, prompts, profiles | -| `system_admin` | 30/min | Admin-only system operations | -| `billing` | 30/min | Credit queries, usage logs | -| `billing_admin` | 10/min | Credit management (admin) | -| `default` | 100/min | Default for endpoints without scope | - -### Handling Rate Limits - -When rate limited (429), the response includes: -- Error message: "Rate limit exceeded" -- Headers with reset time -- Wait until `X-Throttle-Reset` before retrying - -**Example:** -```http -HTTP/1.1 429 Too Many Requests -X-Throttle-Limit: 60 -X-Throttle-Remaining: 0 -X-Throttle-Reset: 1700123456 -Retry-After: 60 - -{ - "success": false, - "error": "Rate limit exceeded", - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Debug Mode Bypass - -Set `IGNY8_DEBUG_THROTTLE=True` or `DEBUG=True` to bypass throttling in development. - ---- - -## Pagination - -### Pagination Configuration - -**Default Settings:** -- Default page size: **10** -- Maximum page size: **100** -- Query parameter: `page_size` (optional) -- Page parameter: `page` (default: 1) - -### Query Parameters - -**Pagination:** -``` -?page=2&page_size=25 -``` - -**Filtering:** -``` -?status=active -?site_id=1 -?sector_id=2 -?cluster_id=5 -``` - -**Search:** -``` -?search=keyword -``` - -**Ordering:** -``` -?ordering=-created_at -?ordering=name,status -``` - -### Pagination Response Format - -```json -{ - "success": true, - "count": 150, - "next": "http://api.igny8.com/api/v1/planner/keywords/?page=3&page_size=25", - "previous": "http://api.igny8.com/api/v1/planner/keywords/?page=1&page_size=25", - "results": [ - // Array of results - ], - "request_id": "550e8400-e29b-41d4-a716-446655440000" -} -``` - -### Pagination Fields - -- `count`: Total number of items -- `next`: URL to next page (null if last page) -- `previous`: URL to previous page (null if first page) -- `results`: Array of items for current page - ---- - -## Roles & Permissions - -### Role Hierarchy - -``` -owner > admin > editor > viewer > system_bot -``` - -### Standard Permission Classes - -**File:** `backend/igny8_core/api/permissions.py` - -| Permission Class | Description | Use Case | -|-----------------|------------|----------| -| `IsAuthenticatedAndActive` | User authenticated and active | Base permission for most endpoints | -| `HasTenantAccess` | User belongs to tenant/account | Tenant isolation | -| `IsViewerOrAbove` | Viewer, editor, admin, or owner | Read-only operations | -| `IsEditorOrAbove` | Editor, admin, or owner | Content operations | -| `IsAdminOrOwner` | Admin or owner only | Settings, keys, billing | - -### Permission Matrix by Endpoint Type - -| Endpoint Type | Required Permissions | Roles Allowed | -|--------------|---------------------|---------------| -| Public (register, login) | `AllowAny` | Anyone | -| Read-only (list, retrieve) | `IsAuthenticatedAndActive` + `HasTenantAccess` | All authenticated users | -| Content operations | `IsAuthenticatedAndActive` + `HasTenantAccess` + `IsEditorOrAbove` | Editor, Admin, Owner | -| User management | `IsAuthenticatedAndActive` + `HasTenantAccess` + `IsAdminOrOwner` | Admin, Owner | -| Billing/Transactions | `IsAuthenticatedAndActive` + `HasTenantAccess` + `IsAdminOrOwner` | Admin, Owner | -| Integration settings | `IsAuthenticatedAndActive` + `HasTenantAccess` + `IsAdminOrOwner` | Admin, Owner | - ---- - -## Tenant / Site / Sector Scoping - -### Scoping Rules - -Every resource created or fetched must be scoped by: - -1. **Account/Tenant** - User's account -2. **Site** - Specific site within account -3. **Sector** - Specific sector within site - -### Enforcement - -**Base Classes:** -- `AccountModelViewSet` - Handles account isolation -- `SiteSectorModelViewSet` - Filters queries by site/sector - -**Requirements:** -- All custom actions must use `.get_queryset()` to avoid bypassing filters -- Any ID list must be verified to belong to the authenticated tenant -- Site/sector access validated based on user role - -### Scoping Example - -```python -class KeywordViewSet(SiteSectorModelViewSet): - # Automatically filters by: - # 1. account (from request.account) - # 2. site_id (from query params or request) - # 3. sector_id (from query params or request) - - queryset = Keyword.objects.all() - serializer_class = KeywordSerializer - - def get_queryset(self): - # Base class handles account/site/sector filtering - queryset = super().get_queryset() - # Additional filtering can be added here - return queryset -``` - ---- - -## Complete Endpoint Reference - -### Authentication Endpoints - -**Base Path**: `/api/v1/auth/` - -#### POST `/api/v1/auth/register/` -**Purpose**: User registration -**Authentication**: None (AllowAny) - -**Request:** -```json -{ - "email": "user@example.com", - "password": "password123", - "password_confirm": "password123" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "user": { - "id": 1, - "email": "user@example.com", - "role": "owner", - "account": { ... } - } - }, - "message": "Registration successful" -} -``` - -#### POST `/api/v1/auth/login/` -**Purpose**: User login -**Authentication**: None (AllowAny) - -**Request:** -```json -{ - "email": "user@example.com", - "password": "password123" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "user": { ... }, - "access": "eyJ0eXAiOiJKV1QiLCJhbGc...", - "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc..." - }, - "message": "Login successful" -} -``` - -#### POST `/api/v1/auth/refresh/` -**Purpose**: Refresh access token -**Authentication**: None (requires refresh token) - -**Request:** -```json -{ - "refresh": "your_refresh_token" -} -``` - -#### POST `/api/v1/auth/change-password/` -**Purpose**: Change user password -**Authentication**: Required (IsAuthenticated) - -**Request:** -```json -{ - "old_password": "oldpass123", - "new_password": "newpass123", - "new_password_confirm": "newpass123" -} -``` - -#### GET `/api/v1/auth/me/` -**Purpose**: Get current user information -**Authentication**: Required (IsAuthenticated) - -**Response:** -```json -{ - "success": true, - "data": { - "user": { - "id": 1, - "email": "user@example.com", - "role": "owner", - "account": { ... }, - "accessible_sites": [ ... ] - } - } -} -``` - -### User Management Endpoints - -**Base Path**: `/api/v1/auth/users/` -**Permission**: IsOwnerOrAdmin - -**Standard CRUD:** -- `GET /api/v1/auth/users/` - List users -- `POST /api/v1/auth/users/` - Create user -- `GET /api/v1/auth/users/{id}/` - Get user -- `PUT /api/v1/auth/users/{id}/` - Update user -- `DELETE /api/v1/auth/users/{id}/` - Delete user - -**Custom Actions:** -- `POST /api/v1/auth/users/invite/` - Invite user -- `POST /api/v1/auth/users/{id}/activate/` - Activate user - -### Account Management Endpoints - -**Base Path**: `/api/v1/auth/accounts/` -**Permission**: IsOwnerOrAdmin - -**Standard CRUD:** -- `GET /api/v1/auth/accounts/` - List accounts -- `POST /api/v1/auth/accounts/` - Create account -- `GET /api/v1/auth/accounts/{id}/` - Get account -- `PUT /api/v1/auth/accounts/{id}/` - Update account -- `DELETE /api/v1/auth/accounts/{id}/` - Delete account - -### Site Management Endpoints - -**Base Path**: `/api/v1/auth/sites/` -**Permission**: IsEditorOrAbove - -**Standard CRUD:** -- `GET /api/v1/auth/sites/` - List sites -- `POST /api/v1/auth/sites/` - Create site -- `GET /api/v1/auth/sites/{id}/` - Get site -- `PUT /api/v1/auth/sites/{id}/` - Update site -- `DELETE /api/v1/auth/sites/{id}/` - Delete site - -**Custom Actions:** -- `GET /api/v1/auth/sites/{id}/sectors/` - Get site sectors -- `POST /api/v1/auth/sites/{id}/set_active/` - Set active site -- `POST /api/v1/auth/sites/{id}/select_sectors/` - Select sectors - -### Sector Management Endpoints - -**Base Path**: `/api/v1/auth/sectors/` -**Permission**: IsEditorOrAbove - -**Standard CRUD:** -- `GET /api/v1/auth/sectors/` - List sectors -- `POST /api/v1/auth/sectors/` - Create sector -- `GET /api/v1/auth/sectors/{id}/` - Get sector -- `PUT /api/v1/auth/sectors/{id}/` - Update sector -- `DELETE /api/v1/auth/sectors/{id}/` - Delete sector - -### Planner Module Endpoints - -**Base Path**: `/api/v1/planner/` - -#### Keyword Management - -**Base Path**: `/api/v1/planner/keywords/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/planner/keywords/` - List keywords (paginated) -- `POST /api/v1/planner/keywords/` - Create keyword -- `GET /api/v1/planner/keywords/{id}/` - Get keyword -- `PUT /api/v1/planner/keywords/{id}/` - Update keyword -- `DELETE /api/v1/planner/keywords/{id}/` - Delete keyword - -**Filtering:** -- `status` - Filter by status -- `cluster_id` - Filter by cluster -- `seed_keyword__intent` - Filter by intent -- `seed_keyword_id` - Filter by seed keyword ID -- `difficulty_min`, `difficulty_max` - Difficulty range -- `volume_min`, `volume_max` - Volume range -- `site_id` - Filter by site (query param) -- `sector_id` - Filter by sector (query param) - -**Search:** -- `search` - Search by keyword text - -**Ordering:** -- `ordering` - Order by `created_at`, `seed_keyword__volume`, `seed_keyword__difficulty` -- Default: `-created_at` (newest first) - -**Custom Actions:** -- `POST /api/v1/planner/keywords/bulk_delete/` - Bulk delete keywords - - Request: `{ "ids": [1, 2, 3] }` -- `POST /api/v1/planner/keywords/bulk_update_status/` - Bulk update status - - Request: `{ "ids": [1, 2, 3], "status": "active" }` -- `POST /api/v1/planner/keywords/bulk_add_from_seed/` - Add keywords from seed library - - Request: `{ "seed_keyword_ids": [1, 2, 3], "site_id": 1, "sector_id": 1 }` -- `GET /api/v1/planner/keywords/export/` - Export keywords to CSV -- `POST /api/v1/planner/keywords/import_keywords/` - Import keywords from CSV -- `POST /api/v1/planner/keywords/auto_cluster/` - Auto-cluster keywords using AI - - Request: `{ "ids": [1, 2, 3, ...], "sector_id": 1 }` - - Max Keywords: 20 per batch - - Returns: Celery task ID for progress tracking - -#### Cluster Management - -**Base Path**: `/api/v1/planner/clusters/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/planner/clusters/` - List clusters -- `POST /api/v1/planner/clusters/` - Create cluster -- `GET /api/v1/planner/clusters/{id}/` - Get cluster -- `PUT /api/v1/planner/clusters/{id}/` - Update cluster -- `DELETE /api/v1/planner/clusters/{id}/` - Delete cluster - -**Filtering:** -- `status` - Filter by status -- `site_id` - Filter by site (query param) -- `sector_id` - Filter by sector (query param) - -**Search:** -- `search` - Search by cluster name - -**Custom Actions:** -- `POST /api/v1/planner/clusters/bulk_delete/` - Bulk delete clusters -- `POST /api/v1/planner/clusters/auto_generate_ideas/` - Auto-generate content ideas - - Request: `{ "ids": [1] }` (max 1 cluster per batch) - - Returns: Celery task ID for progress tracking - -#### Content Ideas Management - -**Base Path**: `/api/v1/planner/ideas/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/planner/ideas/` - List content ideas -- `POST /api/v1/planner/ideas/` - Create content idea -- `GET /api/v1/planner/ideas/{id}/` - Get content idea -- `PUT /api/v1/planner/ideas/{id}/` - Update content idea -- `DELETE /api/v1/planner/ideas/{id}/` - Delete content idea - -**Filtering:** -- `status` - Filter by status -- `cluster_id` - Filter by cluster -- `content_type` - Filter by content type -- `site_id` - Filter by site (query param) -- `sector_id` - Filter by sector (query param) - -**Custom Actions:** -- `POST /api/v1/planner/ideas/bulk_delete/` - Bulk delete ideas -- `POST /api/v1/planner/ideas/bulk_queue_to_writer/` - Queue ideas to writer (create tasks) - - Request: `{ "ids": [1, 2, 3] }` - -### Writer Module Endpoints - -**Base Path**: `/api/v1/writer/` - -#### Task Management - -**Base Path**: `/api/v1/writer/tasks/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/writer/tasks/` - List tasks -- `POST /api/v1/writer/tasks/` - Create task -- `GET /api/v1/writer/tasks/{id}/` - Get task -- `PUT /api/v1/writer/tasks/{id}/` - Update task -- `DELETE /api/v1/writer/tasks/{id}/` - Delete task - -**Filtering:** -- `status` - Filter by status (draft, in_progress, review, completed, archived) -- `cluster_id` - Filter by cluster -- `content_type` - Filter by content type -- `content_structure` - Filter by content structure -- `site_id` - Filter by site (query param) -- `sector_id` - Filter by sector (query param) - -**Search:** -- `search` - Search by title or keywords - -**Custom Actions:** -- `POST /api/v1/writer/tasks/bulk_delete/` - Bulk delete tasks -- `POST /api/v1/writer/tasks/bulk_update/` - Bulk update task status -- `POST /api/v1/writer/tasks/auto_generate_content/` - Auto-generate content using AI - - Request: `{ "ids": [1, 2, 3, ...] }` (max 50 tasks per batch) - - Returns: Celery task ID for progress tracking - -#### Content Management - -**Base Path**: `/api/v1/writer/content/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/writer/content/` - List content -- `POST /api/v1/writer/content/` - Create content -- `GET /api/v1/writer/content/{id}/` - Get content -- `PUT /api/v1/writer/content/{id}/` - Update content -- `DELETE /api/v1/writer/content/{id}/` - Delete content - -**Filtering:** -- `status` - Filter by status -- `content_type` - Filter by content type -- `site_id` - Filter by site (query param) -- `sector_id` - Filter by sector (query param) - -**Custom Actions:** -- `POST /api/v1/writer/content/generate_image_prompts/` - Generate image prompts from content - - Request: `{ "ids": [1, 2, 3] }` - - Returns: Celery task ID for progress tracking - -#### Image Management - -**Base Path**: `/api/v1/writer/images/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: SiteSectorModelViewSet - -**Standard CRUD:** -- `GET /api/v1/writer/images/` - List images -- `POST /api/v1/writer/images/` - Create image -- `GET /api/v1/writer/images/{id}/` - Get image -- `PUT /api/v1/writer/images/{id}/` - Update image -- `DELETE /api/v1/writer/images/{id}/` - Delete image - -**Filtering:** -- `image_type` - Filter by type (featured, in_article, desktop, mobile) -- `status` - Filter by status -- `content_id` - Filter by content -- `task_id` - Filter by task -- `site_id` - Filter by site (query param) -- `sector_id` - Filter by sector (query param) - -**Custom Actions:** -- `GET /api/v1/writer/images/{id}/file/` - Get image file URL -- `GET /api/v1/writer/images/content_images/` - Get images for content - - Query Params: `content_id` (required) -- `POST /api/v1/writer/images/generate_images/` - Generate images using AI - - Request: `{ "ids": [1, 2, 3, ...] }` - - Returns: Celery task ID for progress tracking -- `POST /api/v1/writer/images/bulk_update/` - Bulk update image status - -### System Module Endpoints - -**Base Path**: `/api/v1/system/` - -#### AI Prompt Management - -**Base Path**: `/api/v1/system/prompts/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess -**Inherits**: AccountModelViewSet - -**Standard CRUD:** -- `GET /api/v1/system/prompts/` - List prompts -- `POST /api/v1/system/prompts/` - Create prompt -- `GET /api/v1/system/prompts/{id}/` - Get prompt -- `PUT /api/v1/system/prompts/{id}/` - Update prompt -- `DELETE /api/v1/system/prompts/{id}/` - Delete prompt - -**Custom Actions:** -- `GET /api/v1/system/prompts/by_type/{prompt_type}/` - Get prompt by type -- `POST /api/v1/system/prompts/save/` - Save prompt (requires editor/admin) -- `POST /api/v1/system/prompts/reset/` - Reset prompt to default - -#### Integration Settings - -**Base Path**: `/api/v1/system/settings/integrations/` -**Permission**: IsAdminOrOwner - -**Custom URL Patterns:** -- `GET /api/v1/system/settings/integrations/{pk}/` - Get integration settings -- `POST /api/v1/system/settings/integrations/{pk}/save/` - Save integration settings -- `PUT /api/v1/system/settings/integrations/{pk}/` - Update integration settings -- `POST /api/v1/system/settings/integrations/{pk}/test/` - Test connection - - Request: `{ "provider": "openai" }` or `{ "provider": "runware" }` -- `POST /api/v1/system/settings/integrations/{pk}/generate/` - Test image generation -- `GET /api/v1/system/settings/task_progress/{task_id}/` - Get Celery task progress -- `GET /api/v1/system/integrations/image_generation/` - Get image generation settings - -#### System Status - -**Base Path**: `/api/v1/system/` - -- `GET /api/v1/system/status/` - System health check (AllowAny) -- `GET /api/v1/system/ping/` - Health check endpoint (AllowAny) -- `GET /api/v1/system/request-metrics/{request_id}/` - Get request metrics for debugging - -### Admin Billing & Credits (Admin-only, Unified) - -**Base Path**: `/api/v1/admin/billing/` (all admin billing/credits live here) - -- `GET /api/v1/admin/billing/stats/` - System billing stats (admin-only) -- `GET /api/v1/admin/billing/invoices/` - Admin invoice listing (all accounts) -- `GET /api/v1/admin/billing/payments/` - Admin payment listing (all accounts) -- `GET /api/v1/admin/billing/pending_payments/` - Pending manual payments (admin review queue) -- `POST /api/v1/admin/billing/{id}/approve_payment/` - Approve manual payment (admin-only) -- `POST /api/v1/admin/billing/{id}/reject_payment/` - Reject manual payment (admin-only) - -- `GET /api/v1/admin/billing/payment-method-configs/` - List payment method configs (country-level); query: `country_code`, `payment_method` -- `POST /api/v1/admin/billing/payment-method-configs/` - Create payment method config -- `GET /api/v1/admin/billing/payment-method-configs/{id}/` - Retrieve payment method config -- `PATCH/PUT /api/v1/admin/billing/payment-method-configs/{id}/` - Update payment method config -- `DELETE /api/v1/admin/billing/payment-method-configs/{id}/` - Delete payment method config -- `GET /api/v1/admin/billing/account-payment-methods/` - List account payment methods (query `account_id` to scope) -- `POST /api/v1/admin/billing/account-payment-methods/` - Create account payment method -- `GET /api/v1/admin/billing/account-payment-methods/{id}/` - Retrieve account payment method -- `PATCH/PUT /api/v1/admin/billing/account-payment-methods/{id}/` - Update account payment method -- `DELETE /api/v1/admin/billing/account-payment-methods/{id}/` - Delete account payment method -- `POST /api/v1/admin/billing/account-payment-methods/{id}/set_default/` - Set default account payment method -- `GET /api/v1/admin/credit-costs/` - List credit cost configurations (admin-only) -- `POST /api/v1/admin/credit-costs/` - Update credit cost configurations (admin-only) -- `GET /api/v1/admin/users/` - List users/accounts with credit info (admin-only) -- `POST /api/v1/admin/users/{user_id}/adjust-credits/` - Adjust user credits (admin-only) - -> Non-standard/legacy endpoints to deprecate and remove (do not use): -> - `/api/v1/billing/admin/stats/` -> - `/api/v1/billing/admin/invoices/` -> - `/api/v1/billing/admin/payments/` -> - `/api/v1/billing/admin/pending_payments/` -> - `/api/v1/billing/admin/{id}/approve_payment/` -> - `/api/v1/billing/admin/{id}/reject_payment/` - - -### Billing Module Endpoints - -**Base Path**: `/api/v1/billing/` - -#### Credit Balance - -**Base Path**: `/api/v1/billing/credits/balance/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess - -- `GET /api/v1/billing/credits/balance/` - Get credit balance - -**Response:** -```json -{ - "success": true, - "data": { - "credits": 1000, - "plan_credits_per_month": 500, - "credits_used_this_month": 250, - "credits_remaining": 750 - } -} -``` - -#### Credit Usage - -**Base Path**: `/api/v1/billing/credits/usage/` -**Permission**: IsAuthenticatedAndActive + HasTenantAccess - -**Standard CRUD:** -- `GET /api/v1/billing/credits/usage/` - List usage logs (paginated) -- `GET /api/v1/billing/credits/usage/{id}/` - Get usage log - -**Filtering:** -- `operation_type` - Filter by operation type -- `start_date` - Filter by start date (YYYY-MM-DD) -- `end_date` - Filter by end date (YYYY-MM-DD) - -**Custom Actions:** -- `GET /api/v1/billing/credits/usage/summary/` - Get usage summary -- `GET /api/v1/billing/credits/usage/limits/` - Get usage limits - -#### Credit Transactions - -**Base Path**: `/api/v1/billing/credits/transactions/` -**Permission**: IsAdminOrOwner - -**Standard CRUD:** -- `GET /api/v1/billing/credits/transactions/` - List transactions (paginated) -- `GET /api/v1/billing/credits/transactions/{id}/` - Get transaction - -**Filtering:** -- `transaction_type` - Filter by type -- `start_date` - Filter by start date -- `end_date` - Filter by end date - -#### Credit Packages - -**Base Path**: `/api/v1/billing/credit-packages/` -**Permission**: IsAuthenticated - -- `GET /api/v1/billing/credit-packages/` - List available credit packages -- `POST /api/v1/billing/credit-packages/{id}/purchase/` - Purchase a credit package - -#### Invoices - -**Base Path**: `/api/v1/billing/invoices/` -**Permission**: IsAuthenticated - -- `GET /api/v1/billing/invoices/` - List invoices -- `GET /api/v1/billing/invoices/{id}/` - Get invoice detail -- `GET /api/v1/billing/invoices/{id}/download_pdf/` - Download invoice PDF - -#### Payment Methods - -**Base Path**: `/api/v1/billing/payment-methods/` -**Permission**: IsAuthenticated - -- `GET /api/v1/billing/payment-methods/` - List payment methods -- `POST /api/v1/billing/payment-methods/` - Create payment method -- `GET /api/v1/billing/payment-methods/{id}/` - Get payment method -- `PUT /api/v1/billing/payment-methods/{id}/` - Update payment method -- `PATCH /api/v1/billing/payment-methods/{id}/` - Partial update payment method -- `DELETE /api/v1/billing/payment-methods/{id}/` - Delete payment method -- `POST /api/v1/billing/payment-methods/{id}/set_default/` - Set default payment method -- `GET /api/v1/billing/payment-methods/available/` - List available payment methods (config-driven) - -#### Payments - -**Base Path**: `/api/v1/billing/payments/` -**Permission**: IsAuthenticated - -- `GET /api/v1/billing/payments/` - List payments -- `POST /api/v1/billing/payments/manual/` - Submit manual payment for approval - -#### Transactions (alias-free) - -**Base Path**: `/api/v1/billing/transactions/` -**Permission**: IsAuthenticated - -- `GET /api/v1/billing/transactions/` - List transactions (with current balance included) - ---- - -## Integration Examples - -### Python - -```python -import requests - -BASE_URL = "https://api.igny8.com/api/v1" - -# Login -response = requests.post( - f"{BASE_URL}/auth/login/", - json={"email": "user@example.com", "password": "password"} -) -data = response.json() - -if data['success']: - token = data['data']['access'] - - # Use token for authenticated requests - headers = { - 'Authorization': f'Bearer {token}', - 'Content-Type': 'application/json' - } - - # Get keywords - response = requests.get( - f"{BASE_URL}/planner/keywords/?page=1&page_size=25", - headers=headers - ) - result = response.json() - - if result['success']: - keywords = result['results'] - print(f"Found {result['count']} keywords") - else: - print(f"Error: {result['error']}") -``` - -### JavaScript - -```javascript -const BASE_URL = 'https://api.igny8.com/api/v1'; - -// Login -const loginResponse = await fetch(`${BASE_URL}/auth/login/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - email: 'user@example.com', - password: 'password' - }) -}); - -const loginData = await loginResponse.json(); - -if (loginData.success) { - const token = loginData.data.access; - - // Use token for authenticated requests - const headers = { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }; - - // Get keywords - const keywordsResponse = await fetch( - `${BASE_URL}/planner/keywords/?page=1&page_size=25`, - { headers } - ); - - const keywordsData = await keywordsResponse.json(); - - if (keywordsData.success) { - const keywords = keywordsData.results; - console.log(`Found ${keywordsData.count} keywords`); - } else { - console.error('Error:', keywordsData.error); - } -} -``` - -### cURL - -```bash -# Login -curl -X POST https://api.igny8.com/api/v1/auth/login/ \ - -H "Content-Type: application/json" \ - -d '{"email":"user@example.com","password":"password"}' - -# Get keywords (with token) -curl -X GET https://api.igny8.com/api/v1/planner/keywords/ \ - -H "Authorization: Bearer YOUR_TOKEN" \ - -H "Content-Type: application/json" -``` - -### Frontend Integration (TypeScript) - -```typescript -// Using fetchAPI wrapper (automatically extracts data from unified format) -import { fetchAPI } from './services/api'; - -// List keywords -const response = await fetchAPI('/v1/planner/keywords/?page=1&page_size=25'); -// response is already extracted: { count, next, previous, results } - -// Create keyword -const newKeyword = await fetchAPI('/v1/planner/keywords/', { - method: 'POST', - body: JSON.stringify({ - keyword: 'example keyword', - site_id: 1, - sector_id: 2, - status: 'active' - }) -}); - -// Error handling -try { - const data = await fetchAPI('/v1/planner/keywords/'); -} catch (error) { - // Error is already parsed from unified format - console.error(error.message); - if (error.errors) { - // Handle field-specific errors - Object.keys(error.errors).forEach(field => { - console.error(`${field}: ${error.errors[field].join(', ')}`); - }); - } -} -``` - -### WordPress Plugin Integration (PHP) - -```php -class Igny8API { - private $base_url = 'https://api.igny8.com/api/v1'; - private $token; - - public function login($email, $password) { - $response = wp_remote_post($this->base_url . '/auth/login/', [ - 'headers' => ['Content-Type' => 'application/json'], - 'body' => json_encode(['email' => $email, 'password' => $password]) - ]); - - $data = json_decode(wp_remote_retrieve_body($response), true); - - if ($data['success']) { - $this->token = $data['data']['access']; - return true; - } - - return false; - } - - public function getKeywords($page = 1, $page_size = 25) { - $response = wp_remote_get( - $this->base_url . '/planner/keywords/?page=' . $page . '&page_size=' . $page_size, - [ - 'headers' => [ - 'Authorization' => 'Bearer ' . $this->token, - 'Content-Type' => 'application/json' - ] - ] - ); - - $data = json_decode(wp_remote_retrieve_body($response), true); - - if ($data['success']) { - return $data['results']; - } - - return []; - } -} -``` - ---- - -## Testing & Debugging - -### Request ID Tracking - -Every API request includes a unique `request_id` in the response. Use this ID for: -- Debugging issues -- Log correlation -- Support requests - -The `request_id` is included in: -- All success responses -- All error responses -- Response headers (`X-Request-ID`) - -### Progress Tracking (AI Functions) - -All AI functions return a Celery task ID for progress tracking: - -**Request:** -```json -POST /api/v1/planner/keywords/auto_cluster/ -{ - "ids": [1, 2, 3, 4, 5], - "sector_id": 1 -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "task_id": "abc123-def456-ghi789" - } -} -``` - -**Poll Progress:** -```json -GET /api/v1/system/settings/task_progress/abc123-def456-ghi789/ -``` - -**Progress Response:** -```json -{ - "state": "PROGRESS", - "meta": { - "phase": "AI_CALL", - "percentage": 50, - "message": "Processing keywords...", - "request_steps": [...], - "response_steps": [...], - "cost": 0.05, - "tokens": 1500 - } -} -``` - -### Error Handling Best Practices - -**401 Unauthorized**: Trigger logout → redirect to login -**403 Forbidden**: Show permission alert -**429 Too Many Requests**: Show rate limit warning with retry time -**4xx/5xx**: Display error message from `error` field - -### Rate Limit Monitoring - -```typescript -const throttleLimit = response.headers.get('X-Throttle-Limit'); -const throttleRemaining = response.headers.get('X-Throttle-Remaining'); - -if (parseInt(throttleRemaining) < 5) { - showNotification('Approaching rate limit', 'warning'); -} -``` - ---- - -## Change Management - -### Versioning Strategy - -- **v1** remains stable long-term -- Breaking changes require **v2** -- Deprecations allowed only with explicit timeline -- Non-breaking changes can be added to v1 - -### Breaking Changes - -**Definition:** Changes that require client code updates - -**Examples:** -- Removing an endpoint -- Changing response structure -- Changing authentication method -- Removing a field from response - -**Process:** -1. Document breaking change -2. Provide migration guide -3. Deprecate in v1 with timeline -4. Implement in v2 -5. Maintain v1 for deprecation period - -### Non-Breaking Changes - -**Definition:** Changes that don't require client code updates - -**Examples:** -- Adding new endpoints -- Adding optional fields to response -- Adding new query parameters -- Performance improvements - -### Changelog - -All API changes are documented in `CHANGELOG.md` with: -- Version number -- Date -- Type (Added, Changed, Fixed, Deprecated, Removed, Security) -- Description -- Affected areas -- Migration notes (if applicable) - -### Endpoint Change & Documentation Checklist (Unified API) - -1) Design -- Map to an existing module/tag; if new, add to Module Namespaces and Tag Map. -- Choose path under the correct base (`/api/v1/{module}/...`); avoid new sub-namespaces unless justified. - -2) Implement -- Use unified response helpers and proper permissions/rate limits. -- Add `extend_schema` tags matching the module tag. - -3) Schema & Docs -- Ensure swagger tag exists in `SPECTACULAR_SETTINGS` with the agreed order. -- Regenerate/reload the API (server restart) so `/api/schema/` reflects changes. -- Verify in Swagger UI (`/api/docs/`) and ReDoc (`/api/redoc/`) that the operation is under the right tag. - -4) Reference Updates -- Update this reference file with the new endpoint(s) under the correct module section. -- Update `CHANGELOG.md` (type, summary, impacted clients). - -5) Deprecation (if applicable) -- Mark legacy routes, add timeline, and keep compatibility shims only temporarily. - ---- - -## Summary - -### Implementation Status - -✅ **100% Complete** - All endpoints implemented with: -- Unified response format (`{success, data, message, errors, request_id}`) -- Proper authentication and authorization (JWT Bearer tokens) -- Rate limiting configured (scoped by operation type) -- Error handling standardized (centralized exception handler) -- Request ID tracking (every request has unique ID) -- Complete Swagger/OpenAPI documentation -- Tenant/site/sector scoping (automatic filtering) -- Pagination standardized (default: 10, max: 100) - -### Key Features - -- **100+ endpoints** across 5 modules -- **Unified response format** for all endpoints -- **Scoped rate limiting** (10-100 requests/minute depending on operation) -- **Layered authorization** (Authentication → Tenant → Role → Site/Sector) -- **Complete OpenAPI documentation** (Swagger UI, ReDoc) -- **Request ID tracking** for debugging -- **Progress tracking** for AI functions -- **Comprehensive error handling** with clear messages - -### Access Points - -- **Interactive Documentation**: `https://api.igny8.com/api/docs/` -- **ReDoc**: `https://api.igny8.com/api/redoc/` -- **OpenAPI Schema**: `https://api.igny8.com/api/schema/` - ---- - -**Last Updated**: 2025-01-XX -**API Version**: 1.0.0 -**Status**: ✅ **100% IMPLEMENTED** - diff --git a/approved-docs/KEYWORDS-CLUSTERS-IDEAS-COMPLETE-MAPPING.md b/approved-docs/KEYWORDS-CLUSTERS-IDEAS-COMPLETE-MAPPING.md deleted file mode 100644 index d0c3e2aa..00000000 --- a/approved-docs/KEYWORDS-CLUSTERS-IDEAS-COMPLETE-MAPPING.md +++ /dev/null @@ -1,263 +0,0 @@ -# Keywords, Clusters & Ideas - Complete Field Mapping - -**Date:** December 3, 2025 - ---- - -## 📊 KEYWORDS - Complete Field Reference - -### Backend Model Fields -**File:** `backend/igny8_core/business/planning/models.py` - -| Field Name | Type | Default | Required | Choices | Notes | -|------------|------|---------|----------|---------|-------| -| `id` | Integer PK | Auto | ✅ | - | Primary key | -| `seed_keyword` | ForeignKey | - | ✅ | - | Links to SeedKeyword (PROTECT on delete) | -| `volume_override` | Integer | NULL | ❌ | - | Site-specific override (uses seed_keyword.volume if NULL) | -| `difficulty_override` | Integer | NULL | ❌ | - | Site-specific override (uses seed_keyword.difficulty if NULL) | -| `attribute_values` | JSONField | [] | ❌ | - | Optional metadata (product specs, modifiers) | -| `cluster` | ForeignKey | NULL | ❌ | - | Parent cluster (SET_NULL on delete) | -| `status` | CharField(50) | `pending` | ✅ | `pending`, `active`, `archived` | Keyword status | -| `site` | ForeignKey | - | ✅ | - | Owner site (inherited) | -| `sector` | ForeignKey | - | ✅ | - | Owner sector (inherited) | -| `account` | ForeignKey | - | ✅ | - | Owner account (inherited) | -| `created_at` | DateTime | Auto | ✅ | - | Auto-generated | -| `updated_at` | DateTime | Auto | ✅ | - | Auto-updated | - -### Frontend Table Columns -**File:** `frontend/src/config/pages/keywords.config.tsx` - -| Column Key | Label | Visible Default | Sortable | Render Notes | -|----------|--------|-----------------|----------|--------------| -| `keyword` | Keyword | ✅ Yes | ✅ Yes | From seed_keyword.keyword (links to Keywords page) | -| `sector_name` | Sector | Conditional* | ✅ Yes | Badge (blue) - shown when viewing all sectors | -| `volume` | Volume | ✅ Yes | ✅ Yes | Formatted as number (e.g., 1,250) | -| `cluster_name` | Cluster | ✅ Yes | ✅ Yes | Parent cluster name or "-" | -| `difficulty` | Difficulty | ✅ Yes | ✅ Yes | Badge (1-5): 1-2=green, 3=amber, 4-5=red | -| `intent` | Intent | ✅ Yes | ✅ Yes | Badge colors: Transactional/Commercial=green, Navigational=amber | -| `status` | Status | ✅ Yes | ✅ Yes | Badge: pending=amber, active=green, archived=red | -| `created_at` | Created | ✅ Yes | ✅ Yes | Relative date (e.g., "2 hours ago") | -| (Hidden by default) | | | | | -| `updated_at` | Updated | ❌ No | ✅ Yes | Relative date | - -### Frontend Filter Dropdown -**File:** `frontend/src/config/pages/keywords.config.tsx` (Lines 310-360) - -| Filter Key | Label | Type | Options | Dynamic | -|-----------|-------|------|---------|---------| -| `search` | Search | Text | N/A | - | -| `status` | Status | Select | `pending`, `active`, `archived` | ❌ No | -| `intent` | Intent | Select | `informational`, `navigational`, `transactional`, `commercial` | ❌ No | -| `difficulty` | Difficulty | Select | `1-5` with labels | ❌ No | -| `cluster` | Cluster | Select | Dynamic from database | ✅ Yes (loads clusters) | -| `volume` | Volume Range | Custom | Min/Max number inputs | ❌ No (range picker) | - -### Frontend Create/Edit Form -**File:** `frontend/src/config/pages/keywords.config.tsx` (Lines 560-586) - -| Field Key | Label | Type | Required | Default | Options | -|-----------|-------|------|----------|---------|---------| -| `seed_keyword_id` | Seed Keyword | Select | ✅ Yes | - | Dynamic from availableSeedKeywords | -| `volume_override` | Volume Override | Number | ❌ No | NULL | Numeric input (optional override) | -| `difficulty_override` | Difficulty Override | Number | ❌ No | NULL | Select 1-5 | -| `cluster_id` | Cluster | Select | ❌ No | NULL | Dynamic from clusters array | -| `status` | Status | Select | ✅ Yes | `pending` | `pending`, `active`, `archived` | - ---- - -## 📊 CLUSTERS - Complete Field Reference - -### Backend Model Fields -**File:** `backend/igny8_core/business/planning/models.py` - -| Field Name | Type | Default | Required | Choices | Notes | -|------------|------|---------|----------|---------|-------| -| `id` | Integer PK | Auto | ✅ | - | Primary key | -| `name` | CharField(255) | - | ✅ | - | Unique cluster name (unique=True) | -| `description` | TextField | NULL | ❌ | - | Optional cluster description | -| `keywords_count` | Integer | 0 | ✅ | - | Cached count of linked keywords | -| `ideas_count` | Integer | 0 | ✅ | - | Cached count of linked ideas | -| `volume` | Integer | 0 | ✅ | - | Cached total volume from keywords | -| `mapped_pages` | Integer | 0 | ✅ | - | Number of mapped pages | -| `status` | CharField(50) | `new` | ✅ | `new`, `idea`, `mapped` | Cluster status | -| `site` | ForeignKey | - | ✅ | - | Owner site (inherited) | -| `sector` | ForeignKey | - | ✅ | - | Owner sector (inherited) | -| `account` | ForeignKey | - | ✅ | - | Owner account (inherited) | -| `created_at` | DateTime | Auto | ✅ | - | Auto-generated | -| `updated_at` | DateTime | Auto | ✅ | - | Auto-updated | - -### Frontend Table Columns -**File:** `frontend/src/config/pages/clusters.config.tsx` - -| Column Key | Label | Visible Default | Sortable | Render Notes | -|----------|--------|-----------------|----------|--------------| -| `name` | Cluster Name | ✅ Yes | ✅ Yes | Link to cluster detail page | -| `sector_name` | Sector | Conditional* | ✅ Yes | Badge (blue) - shown when viewing all sectors | -| `keywords_count` | Keywords | ✅ Yes | ✅ Yes | Formatted as number (e.g., 45) | -| `ideas_count` | Ideas | ✅ Yes | ✅ Yes | Formatted as number (e.g., 12) | -| `volume` | Volume | ✅ Yes | ✅ Yes | Formatted as number (e.g., 5,280) | -| `difficulty` | Difficulty | ✅ Yes | ✅ Yes | Badge (1-5): 1-2=green, 3=amber, 4-5=red | -| `content_count` | Content | ✅ Yes | ✅ Yes | Formatted as number | -| `status` | Status | ✅ Yes | ✅ Yes | Badge: new=amber, idea=blue, mapped=green | -| `created_at` | Created | ✅ Yes | ✅ Yes | Relative date | -| (Hidden by default) | | | | | -| `description` | Description | ❌ No | ❌ No | Text truncated to 250px | -| `mapped_pages` | Mapped Pages | ❌ No | ✅ Yes | Formatted number | -| `updated_at` | Updated | ❌ No | ✅ Yes | Relative date | - -### Frontend Filter Dropdown -**File:** `frontend/src/config/pages/clusters.config.tsx` (Lines 240-290) - -| Filter Key | Label | Type | Options | Dynamic | -|-----------|-------|------|---------|---------| -| `search` | Search | Text | N/A | - | -| `status` | Status | Select | `new`, `idea`, `mapped` | ❌ No | -| `difficulty` | Difficulty | Select | `1-5` with labels | ❌ No | -| `volume` | Volume Range | Custom | Min/Max number inputs | ❌ No (range picker) | - -### Frontend Create/Edit Form -**File:** `frontend/src/config/pages/clusters.config.tsx` (Lines 405-418) - -| Field Key | Label | Type | Required | Default | Options | -|-----------|-------|------|----------|---------|---------| -| `name` | Cluster Name | Text | ✅ Yes | - | Text input (placeholder: "Enter cluster name") | -| `description` | Description | Textarea | ❌ No | NULL | Textarea (placeholder: "Enter cluster description") | -| `status` | Status | Select | ✅ Yes | `new` | `new`, `idea`, `mapped` | - ---- - -## 📊 CONTENT IDEAS - Complete Field Reference - -### Backend Model Fields -**File:** `backend/igny8_core/business/planning/models.py` - -| Field Name | Type | Default | Required | Choices | Notes | -|------------|------|---------|----------|---------|-------| -| `id` | Integer PK | Auto | ✅ | - | Primary key | -| `idea_title` | CharField(255) | - | ✅ | - | Content idea title | -| `description` | TextField | NULL | ❌ | - | Content outline/description | -| `target_keywords` | CharField(500) | '' | ❌ | - | Comma-separated keywords (legacy) | -| `keyword_objects` | M2M(Keywords) | - | ❌ | - | Individual keywords linked to idea | -| `keyword_cluster` | ForeignKey(Clusters) | NULL | ❌ | - | Parent cluster (SET_NULL on delete) | -| `status` | CharField(50) | `new` | ✅ | `new`, `scheduled`, `completed`, `published` | Idea workflow status | -| `estimated_word_count` | Integer | 1000 | ✅ | - | Target article length | -| `content_type` | CharField(50) | `post` | ✅ | `post`, `page`, `product`, `taxonomy` | Content type | -| `content_structure` | CharField(50) | `article` | ✅ | See structures below | Content format/structure | -| `site` | ForeignKey | - | ✅ | - | Owner site (inherited) | -| `sector` | ForeignKey | - | ✅ | - | Owner sector (inherited) | -| `account` | ForeignKey | - | ✅ | - | Owner account (inherited) | -| `created_at` | DateTime | Auto | ✅ | - | Auto-generated | -| `updated_at` | DateTime | Auto | ✅ | - | Auto-updated | - -**Content Structure Choices (based on content_type):** -- **Post:** `article`, `guide`, `comparison`, `review`, `listicle` -- **Page:** `landing_page`, `business_page`, `service_page`, `general`, `cluster_hub` -- **Product:** `product_page` -- **Taxonomy:** `category_archive`, `tag_archive`, `attribute_archive` - -### Frontend Table Columns -**File:** `frontend/src/config/pages/ideas.config.tsx` - -| Column Key | Label | Visible Default | Sortable | Render Notes | -|----------|--------|-----------------|----------|--------------| -| `idea_title` | Title | ✅ Yes | ✅ Yes | Expandable (shows description) | -| `sector_name` | Sector | Conditional* | ✅ Yes | Badge (blue) - shown when viewing all sectors | -| `content_structure` | Structure | ✅ Yes | ✅ Yes | Badge (purple): article, guide, guide, etc. | -| `content_type` | Type | ✅ Yes | ✅ Yes | Badge (blue): post, page, product, taxonomy | -| `target_keywords` | Target Keywords | ✅ Yes | ❌ No | Text truncated to 250px | -| `keyword_cluster_name` | Cluster | ✅ Yes | ✅ Yes | Parent cluster name or "-" | -| `status` | Status | ✅ Yes | ✅ Yes | Badge: new=amber, scheduled=blue, completed=blue, published=green | -| `estimated_word_count` | Words | ✅ Yes | ✅ Yes | Formatted as number (e.g., 1,500) | -| `created_at` | Created | ✅ Yes | ✅ Yes | Relative date | -| (Hidden by default) | | | | | -| `updated_at` | Updated | ❌ No | ✅ Yes | Relative date | - -### Frontend Filter Dropdown -**File:** `frontend/src/config/pages/ideas.config.tsx` (Lines 218-270) - -| Filter Key | Label | Type | Options | Dynamic | -|-----------|-------|------|---------|---------| -| `search` | Search | Text | N/A | - | -| `status` | Status | Select | `new`, `scheduled`, `completed`, `published` | ❌ No | -| `content_structure` | Structure | Select | article, guide, comparison, etc. (all 13 options) | ❌ No | -| `content_type` | Type | Select | `post`, `page`, `product`, `taxonomy` | ❌ No | -| `cluster` | Cluster | Select | Dynamic from database | ✅ Yes (loads clusters) | - -### Frontend Create/Edit Form -**File:** `frontend/src/config/pages/ideas.config.tsx` (Lines 372-417) - -| Field Key | Label | Type | Required | Default | Options | -|-----------|-------|------|----------|---------|---------| -| `idea_title` | Title | Text | ✅ Yes | - | Text input (placeholder: "Enter idea title") | -| `description` | Description | Textarea | ❌ No | NULL | Textarea (placeholder: "Enter content outline") | -| `content_type` | Content Type | Select | ✅ Yes | `post` | `post`, `page`, `product`, `taxonomy` | -| `content_structure` | Content Structure | Select | ✅ Yes | `article` | 13 structure options (depends on content_type) | -| `target_keywords` | Target Keywords | Text | ❌ No | NULL | Text input (comma-separated) | -| `keyword_cluster_id` | Cluster | Select | ❌ No | NULL | Dynamic from clusters array | -| `status` | Status | Select | ✅ Yes | `new` | `new`, `scheduled`, `completed`, `published` | -| `estimated_word_count` | Word Count | Number | ❌ No | 1000 | Numeric input | - ---- - -## 📝 SUMMARY COMPARISON - -### Status Fields -| Module | Backend Default | Backend Choices | Frontend Form Default | Frontend Form Choices | -|--------|-----------------|-----------------|----------------------|----------------------| -| **Keywords** | `pending` | pending, active, archived | `pending` | pending, active, archived | -| **Clusters** | `new` | new, idea, mapped | `new` | new, idea, mapped | -| **Ideas** | `new` | new, scheduled, completed, published | `new` | new, scheduled, completed, published | - -### Required Fields (Must be filled) -| Module | Required Fields | -|--------|-----------------| -| **Keywords** | seed_keyword_id, status, site, sector, account | -| **Clusters** | name, status, site, sector, account | -| **Ideas** | idea_title, status, content_type, content_structure, estimated_word_count, site, sector, account | - -### Optional Fields -| Module | Optional Fields | -|--------|-----------------| -| **Keywords** | volume_override, difficulty_override, attribute_values, cluster_id | -| **Clusters** | description | -| **Ideas** | description, target_keywords, keyword_objects, keyword_cluster_id | - -### Dynamic Dropdowns (Loaded from DB) -| Module | Filter | Form | -|--------|--------|------| -| **Keywords** | cluster (dropdown) | seed_keyword_id (all available), cluster_id (all clusters for sector) | -| **Clusters** | - | - | -| **Ideas** | cluster (dropdown) | keyword_cluster_id (all clusters for sector) | - -### Visible-by-Default Table Columns -| Module | Count | Primary Columns | -|--------|-------|-----------------| -| **Keywords** | 9 | keyword, volume, cluster, difficulty, intent, status, created_at | -| **Clusters** | 11 | name, keywords_count, ideas_count, volume, difficulty, content_count, status, created_at | -| **Ideas** | 10 | idea_title, content_structure, content_type, target_keywords, cluster, status, word_count, created_at | - ---- - -## 🔑 Key Differences - -### Keywords -- **Uses:** SeedKeyword (global pool) - one keyword per site/sector -- **Overrideable:** volume, difficulty (site-specific) -- **Links to:** Clusters (via cluster FK) -- **Status:** pending (awaiting cluster), active (clustered), archived - -### Clusters -- **Type:** Pure topic clusters (semantic groupings) -- **Auto-updated:** keywords_count, ideas_count, volume (cached from keywords) -- **Status:** new (no ideas), idea (has ideas), mapped (has content) -- **No overrides:** All values are cached/calculated - -### Ideas -- **Type:** Content concepts ready for production -- **Links:** Cluster (required for workflow), Keywords (optional M2M) -- **Customizable:** content_type, content_structure, word_count -- **Status:** new → scheduled (queued to writer) → completed (content generated) → published - ---- - -**END OF COMPLETE FIELD MAPPING** diff --git a/approved-docs/TAXONOMY-RELATIONSHIP-DIAGRAM.md b/approved-docs/TAXONOMY-RELATIONSHIP-DIAGRAM.md deleted file mode 100644 index 46ae0326..00000000 --- a/approved-docs/TAXONOMY-RELATIONSHIP-DIAGRAM.md +++ /dev/null @@ -1,258 +0,0 @@ -# Content Taxonomy Relationship Diagram - -## Current Architecture (Simplified) - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ IGNY8 Content System │ -└─────────────────────────────────────────────────────────────────────┘ - -┌──────────────────────────────────────────────────────────────────────┐ -│ Multi-Tenant Hierarchy │ -│ │ -│ Account ──┬── Site ──┬── Sector ──┬── Keywords │ -│ │ │ ├── Clusters │ -│ │ │ ├── Ideas │ -│ │ │ ├── Tasks │ -│ │ │ ├── Content │ -│ │ │ └── ContentTaxonomy │ -│ │ └── Sector 2 │ -│ └── Site 2 │ -└──────────────────────────────────────────────────────────────────────┘ - - -┌──────────────────────────────────────────────────────────────────────┐ -│ Planner → Writer → Publisher Workflow │ -│ │ -│ Phase 1-3: PLANNER │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Keywords │ ──> │ Clusters │ ──> │ Ideas │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -│ │ │ │ │ -│ │ │ │ │ -│ Phase 4: WRITER │ -│ │ │ │ │ -│ └─────────────────┴─────────────────┘ │ -│ │ │ -│ v │ -│ ┌──────────┐ ┌──────────┐ │ -│ │ Tasks │ ──> │ Content │ │ -│ └──────────┘ └──────────┘ │ -│ │ │ -│ │ │ -│ Phase 5: PUBLISHER │ │ -│ v │ -│ ┌─────────────┐ │ -│ │ WordPress │ │ -│ │ Shopify │ │ -│ │ Sites │ │ -│ └─────────────┘ │ -└──────────────────────────────────────────────────────────────────────┘ - - -┌──────────────────────────────────────────────────────────────────────┐ -│ Content ↔ Taxonomy Relationship (Many-to-Many) │ -└──────────────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────┐ -│ Content Model │ -│─────────────────────────────────│ -│ PK: id │ -│ FK: site_id ──────┐ │ -│ FK: sector_id │ │ -│ FK: cluster_id │ │ -│ │ │ -│ title │ │ -│ content_html │ │ -│ word_count │ │ -│ meta_title │ │ -│ meta_description │ │ -│ │ │ -│ content_type │ │ -│ content_structure │ │ -│ │ │ -│ external_id │ │ -│ external_url │ │ -│ sync_status │ │ -│ │ │ -│ source │ │ -│ status │ │ -└───────────┬───────┘ │ - │ │ - │ Many-to-Many │ - │ (via ContentTaxonomyRelation) - │ │ - v │ -┌─────────────────────────────────┐│ -│ ContentTaxonomyRelation Model ││ -│─────────────────────────────────││ -│ PK: id ││ -│ FK: content_id ──────────────────┘ -│ FK: taxonomy_id ────────────┐ -│ │ -│ created_at │ -│ updated_at │ -│ │ -│ UNIQUE(content, taxonomy) │ -└─────────────────────────────┘ - │ - │ - v - ┌─────────────────────────────────┐ - │ ContentTaxonomy Model │ - │─────────────────────────────────│ - │ PK: id │ - │ FK: site_id ─────┐ │ - │ FK: sector_id │ │ - │ │ │ - │ name │ │ - │ slug │ │ - │ taxonomy_type │ ◄─── "category" or "tag" - │ description │ │ - │ count │ │ - │ │ │ - │ WordPress Sync: │ │ - │ external_taxonomy│ ◄─── "category", "post_tag" - │ external_id │ ◄─── WordPress term_id - │ │ │ - │ metadata (JSON) │ │ - └──────────────────┘ │ - │ │ - │ │ - UNIQUE(site, slug, taxonomy_type)│ - UNIQUE(site, external_id, external_taxonomy) - │ │ - └──────────────┘ - - -┌──────────────────────────────────────────────────────────────────────┐ -│ Usage Example │ -└──────────────────────────────────────────────────────────────────────┘ - -# Get all categories for a content piece -categories = content.taxonomy_terms.filter(taxonomy_type='category') - -# Get all tags for a content piece -tags = content.taxonomy_terms.filter(taxonomy_type='tag') - -# Add a category to content -tech_category = ContentTaxonomy.objects.get(name='Technology') -content.taxonomy_terms.add(tech_category) - -# Get all content with a specific tag -tutorial_tag = ContentTaxonomy.objects.get(name='Tutorial') -contents = tutorial_tag.contents.all() - -# WordPress publishing (automatic) -wp_categories = [term.name for term in content.taxonomy_terms.filter(taxonomy_type='category')] -wp_tags = [term.name for term in content.taxonomy_terms.filter(taxonomy_type='tag')] - - -┌──────────────────────────────────────────────────────────────────────┐ -│ WordPress Integration (Bidirectional Sync) │ -└──────────────────────────────────────────────────────────────────────┘ - -IGNY8 → WordPress: -───────────────── -1. Content created in IGNY8 -2. Categories/tags assigned via taxonomy_terms M2M -3. Publishing task created -4. wordpress_publishing.py extracts: - - categories = content.taxonomy_terms.filter(taxonomy_type='category') - - tags = content.taxonomy_terms.filter(taxonomy_type='tag') -5. REST API creates WordPress post with terms -6. external_id saved back to Content model -7. Log: [5-homeg8.com] [POST] Published: "Article Title" (ID: 123) - -WordPress → IGNY8: -───────────────── -1. WordPress plugin detects post update -2. REST API sends post data + terms to IGNY8 -3. Content updated/created with external_id -4. ContentTaxonomy created/updated with external_id -5. ContentTaxonomyRelation created linking them -6. Log: [5-homeg8.com] [SYNC] Imported: "Article Title" - - -┌──────────────────────────────────────────────────────────────────────┐ -│ Database Tables Summary │ -└──────────────────────────────────────────────────────────────────────┘ - -igny8_content -├─ id (PK) -├─ site_id (FK → sites) -├─ sector_id (FK → sectors) -├─ cluster_id (FK → clusters) -├─ title, content_html, word_count -├─ meta_title, meta_description, keywords -├─ content_type, content_structure -├─ external_id, external_url, sync_status -└─ source, status, timestamps - -igny8_content_taxonomy_relations (Through Table) -├─ id (PK) -├─ content_id (FK → igny8_content) -├─ taxonomy_id (FK → igny8_content_taxonomy_terms) -└─ timestamps - UNIQUE(content_id, taxonomy_id) - -igny8_content_taxonomy_terms -├─ id (PK) -├─ site_id (FK → sites) -├─ sector_id (FK → sectors) -├─ name, slug -├─ taxonomy_type ('category' | 'tag') -├─ description, count -├─ external_taxonomy ('category' | 'post_tag') -├─ external_id (WordPress term_id) -├─ metadata (JSON) -└─ timestamps - UNIQUE(site_id, slug, taxonomy_type) - UNIQUE(site_id, external_id, external_taxonomy) - - -┌──────────────────────────────────────────────────────────────────────┐ -│ Before vs After Comparison │ -└──────────────────────────────────────────────────────────────────────┘ - -BEFORE (Complex - with SiteBlueprint): -──────────────────────────────────── -SiteBlueprint ──┬── SiteBlueprintCluster ──> Clusters - ├── SiteBlueprintTaxonomy ──> ContentTaxonomy - └── PageBlueprint ──> Content - -Content ──> ContentTaxonomyMap ──> ContentTaxonomy - (separate table with FK) - -PublishingRecord ──┬── content_id - └── site_blueprint_id - -DeploymentRecord ──┬── content_id - └── site_blueprint_id - - -AFTER (Simple - SiteBlueprint Removed): -────────────────────────────────────── -Keywords ──> Clusters ──> Ideas ──> Tasks ──> Content - -Content ↔ ContentTaxonomy - (M2M via ContentTaxonomyRelation) - -PublishingRecord ──> content_id - -DeploymentRecord ──> content_id - - -┌──────────────────────────────────────────────────────────────────────┐ -│ Key Benefits │ -└──────────────────────────────────────────────────────────────────────┘ - -✅ Simpler architecture - removed 4 models -✅ Direct M2M relationship - easier to query -✅ Less database joins - better performance -✅ Clear taxonomy model in Django admin -✅ WordPress sync unchanged - still works perfectly -✅ Planner-Writer-Publisher workflow intact -✅ Multi-tenant security maintained -✅ No circular dependencies -✅ Clean codebase - no legacy blueprint code diff --git a/approved-docs/app-packaging-backaup-plan.md b/approved-docs/app-packaging-backaup-plan.md deleted file mode 100644 index be793b98..00000000 --- a/approved-docs/app-packaging-backaup-plan.md +++ /dev/null @@ -1,237 +0,0 @@ -bluesalman: gothrough readme and tell me which folders/files inside backeedn adn frontedn folders are acutaly part of igny8 app, and which folders are part of some tech stack which will be enever modifed by app only isntaleld when tech stack isntaleled - -GitHub Copilot: I'll create a short plan, then list the backend and frontend directories to inspect their contents. - -Created 3 todos - -Now I'll list both directories (backend and frontend) to collect the items to classify. - -Read [](file:///data/app/igny8/backend) - -Read [](file:///data/app/igny8/frontend) - -Marking plan steps completed and finishing classification. - -Completed: *List backend files* (1/3) - -Progress: I scanned backend and frontend and classified items below. - -**Backend — Classified** -- **App (igny8 application core):** - - `igny8_core/`: The Django project / app code (settings, apps, views, models, tasks). This is the main application source you will modify. - - `manage.py`: Django management entrypoint — part of the app. - - `migrations/`: Django DB migrations — app schema changes (source-controlled). - - `staticfiles/`: Collected static assets (app assets). May be generated but belongs to the app. - - `requirements.txt`: Dependency manifest for the app (used to install the Python packages the app needs). Not runtime third-party code, but part of the app repo and should be version-controlled. - -- **Repo tooling / app-maintenance scripts (part of the repo; maintained by your team):** - - `check_api_response.py`, `check_recent_keywords.py`, `cleanup_structure_categories.py`, `diagnose_generate_content.py`, `inject_test_data.py`, `sync_idea_status.py`, `test_tags_categories.py`, `verify_*.py`, `fix_*` scripts, `final_verify.py`, `deploy_automation.sh`, `example_csv/`: These are maintenance, verification, or migration scripts that are part of the repository and used by developers/ops. They are not third-party code but are repo-level tooling you may edit. - -- **Tech stack / infra artifacts (should not be modified by the app runtime; installed or produced by infra):** - - `.venv/`: Virtual environment — environment artifact; not part of app source and typically excluded from commits. - - `Dockerfile`: Container build file — infra/build config (used by ops or CI). - - `backup_postgres_20251120_232816.sql`, `db_backup_20251120_232646.sqlite3`, `rename_fields_migration.sql`: Database backups / SQL dumps — artifacts, not app source. - - `celerybeat-schedule`: Celery scheduler state file — runtime artifact. - - `logs/`: Runtime logs — generated artifacts. - - Any large generated files (e.g., DB dumps) are infra artifacts and shouldn't be edited as source. - -Notes: -- Some items like `deploy_automation.sh` or `fix_*` scripts are operational but live in the repo — treat them as repository tooling (editable by you), whereas backups, `.venv`, and docker images/configs are infra artifacts. - -**Frontend — Classified** -- **App (igny8 frontend source / code you will modify):** - - `src/`: Application source (React/Vue/Svelte/TSX/etc.) — primary frontend code to edit. - - `public/`, `index.html`, `marketing.html`, `banner.png`: Static assets and HTML entrypoints — part of the app. - - `package.json`, `package-lock.json`: NPM manifests — define packages the frontend needs (repo-managed). - - `eslint.config.js`, `postcss.config.js`, `vite.config.ts`, `vitest.config.ts`, `tsconfig*.json`: Build and tooling config that belong to the frontend source and are edited as part of app development. - - README.md, `DESIGN_SYSTEM.md`, `MIGRATION_GUIDE.md`: Documentation for the frontend — repo docs. - -- **Tech stack / infra artifacts (installed/generated by stack — do not edit at runtime):** - - `node_modules/`: Installed dependencies — generated by `npm` / `pnpm` / `yarn`; should not be manually edited and typically ignored by VCS. - - `dist/`: Built production bundle — generated artifact (output of the build). - - `Caddyfile`, `Caddyfile.marketing`, `Dockerfile`, `Dockerfile.dev`, `Dockerfile.marketing`, `Dockerfile.marketing.dev`: Webserver and container build configs — deployment/infra files. They belong to ops/build but do live in the repo; treat as infra configuration, not application logic. - - `LICENSE.md`, other top-level docs: docs, not runtime app code. - -Notes: -- Build-tool config files (e.g., `vite.config.ts`, `tsconfig.json`) are part of the app repository and are changed by developers. By contrast, `node_modules` and `dist` are installed/generated by the tech stack and should not be edited directly. - -Quick summary (one-line): -- Edit: igny8_core, `manage.py`, migrations, src, public, `package.json`, and similar source/config files — these are part of the igny8 app. -- Don't manually edit: runtime/generated/installed artifacts like `.venv/`, `node_modules/`, `dist/`, DB backups, `celerybeat-schedule`, and use `Dockerfile`/`Caddyfile` only as infra config (ops-managed). - -Would you like: -- a) A file added to the repo (e.g., `ARCHITECTURE.md` or `IGNY8-CONTENTS.md`) that labels each top-level item as "app" vs "infra", or -- b) I can produce a JSON/CSV mapping of items for automation (useful for CI), or -- c) Walk through any specific file/folder you want deeper inspection of? - -Which option do you prefer? - -bluesalman: ok now cehck teh strcutre of /data adn recrussively all its fodlers including igny8/ -waht can we do if we want to make igny8 folder as the only full app foodler requried to move to any new location, -and jsut install the portainer as current envirnpmaent and isntall teh app package on nwe locatink build the package and it runs there, - -teh curretn setup isnto standrd as it ocntians some node )moduels dist and env, -venv i know can be removed completely - -so waht do you think - -GitHub Copilot: I'll make a short plan, then list data and the key subfolders (app, igny8, and their important children) so I can analyze what to keep, remove, or package. - -Created 4 todos - -Read [](file:///data) - -Updating the plan: mark the scan step done (noting data access blocked) and keep other steps ready. Then I'll provide analysis, exact commands, Docker and non-Docker packaging options, and a sample `docker-compose.yml` and `install.sh`. - -bluesalman: waht happend you are stucl - -GitHub Copilot: I'll create a short todo plan, then list data, app, and igny8 to inspect contents before proposing a packaging strategy. - -Created 4 todos - -Read [](file:///data) - -Now I'll run a quick directory size and shallow tree under the workspace `igny8/` so we can see which folders (like `node_modules`, `.venv`, `dist`) are large and where they are. - -Ran terminal command: cd /data/app/igny8 && echo 'Top-level sizes:' && du -sh * 2>/dev/null | sort -h && echo && echo 'Directory tree (depth 3):' && find . -maxdepth 3 -type d | sed 's|^\./||' | sort - -Updating the todo list: mark the listing steps done and the analysis/packaging step in-progress. - -Starting: *Analyze and propose packaging* (4/4) - -Progress update: I inspected the workspace `igny8/` (sizes and a shallow tree). Next I analyzed which files to keep/exclude and drafted packaging, cleanup commands, and a sample `docker-compose.yml` plus a `prepare_package.sh` plan. Below are clear findings and actionable steps. - -**Findings** -- **Large/generated artifacts present:** - - .venv — virtualenv (can be removed for packaging). - - node_modules, node_modules — installed npm dependencies (do not include in package). - - dist — built frontend assets (can be produced during build; you can include or build on deploy). - - DB dumps and scheduler/state files appear under backend (e.g., `*.sql`, `*.sqlite3`, `celerybeat-schedule`) — these are artifacts and should not be bundled as app source. -- **App source & config (keep these):** - - igny8_core, manage.py, migrations, requirements.txt — Django app and deps manifest. - - src, public, package.json and build config files — frontend source and build config. - - `Dockerfile` / `Dockerfile.*` and `Caddyfile` — infra config that should remain in the repo (useful for containerization). -- **Repo tooling and docs:** `deploy_automation.sh`, `verify_*.py`, docs, README.md — keep in repo; useful for ops/dev. - -**Goal** -Make `igny8/` a single, relocatable application folder that you can move to a new host, then deploy via Portainer (or Docker Compose) without shipping generated artifacts like `node_modules`, `.venv`, or large backups. - -Recommendations and exact steps - -- **Strategy (recommended): Docker image + single `docker-compose.yml` stack** - - Keep only source, config, Dockerfiles and manifests in the app folder. - - Exclude generated artifacts from the package (`.venv`, `node_modules`, `dist`, backups, logs). - - Provide a `docker-compose.yml` that builds the backend and frontend images from the repo and defines services: `db` (Postgres), `redis`, backend (Gunicorn/Django), `worker` (Celery), and frontend (built and served by Caddy or served as static in a small nginx/caddy image). - - Deploy via Portainer by uploading the `docker-compose.yml` (Portainer can build images from contexts if configured), or pre-build images, push to container registry, and use compose with image references. - -- **Alternative:** Tarball + install script that runs `docker compose build` and `docker compose up -d` on the new host. - -- **Safety note:** Always create a backup or branch of the repo before mass-deleting artifacts. - -Exact commands to produce a clean package (run from igny8): - -```bash -# make a copy first (safe) -cp -a /data/app/igny8 /tmp/igny8-package-source - -# inside the copy, remove generated artifacts -cd /tmp/igny8-package-source -rm -rf backend/.venv -rm -rf backend/*.sql backend/*.sqlite3 backend/celerybeat-schedule backend/logs || true -rm -rf frontend/node_modules sites/node_modules -rm -rf frontend/dist -# optional: remove any large backups in root -rm -f backup_*.sql || true - -# create archive -tar -czf /tmp/igny8-clean.tgz . - -# verify size -ls -lh /tmp/igny8-clean.tgz -``` - -Suggested `.dockerignore` (place in repo root) to keep image builds small: - -``` -backend/.venv -frontend/node_modules -sites/node_modules -frontend/dist -*.sql -*.sqlite3 -logs/ -*.log -.git -node_modules -``` - -Sample minimal `docker-compose.yml` (adapt to your app's env vars and Dockerfiles): - -```yaml -version: '3.8' -services: - db: - image: postgres:15 - environment: - POSTGRES_DB: igny8 - POSTGRES_USER: igny8 - POSTGRES_PASSWORD: changeme - volumes: - - db_data:/var/lib/postgresql/data - - redis: - image: redis:7 - command: redis-server --appendonly yes - - backend: - build: - context: ./backend - dockerfile: Dockerfile - depends_on: - - db - - redis - environment: - DATABASE_URL: postgres://igny8:changeme@db:5432/igny8 - REDIS_URL: redis://redis:6379/0 - ports: - - "8000:8000" - volumes: - - static_volume:/app/staticfiles - - worker: - build: - context: ./backend - dockerfile: Dockerfile - command: celery -A igny8_core worker --loglevel=info - depends_on: - - redis - - db - - frontend: - build: - context: ./frontend - dockerfile: Dockerfile - ports: - - "3000:80" - depends_on: - - backend - -volumes: - db_data: - static_volume: -``` - -Notes: -- The Dockerfile should install Python deps from `requirements.txt`, collect static, run migrations in a start-up script, and run Gunicorn. If not present, we can produce a small `Dockerfile`. -- The Dockerfile should `npm ci && npm run build` and copy `dist/` into a small static-server image (Caddy or nginx). If you prefer to serve frontend via the backend/CDN, build and copy the static files into backend static volume. - -What I'd do next (I can do these for you) -- Option A (recommended): Create `docker-compose.yml` and `prepare_package.sh` in the repo (I can add both files), plus `.dockerignore` and a minimal Dockerfile / Dockerfile if missing. Then test `docker compose up --build` locally. -- Option B: Create an archive script only (if you prefer to handle Dockerfiles yourself). - -Would you like me to: -- **(1)** Create the `prepare_package.sh`, `.dockerignore`, and a `docker-compose.yml` in the repository and test the compose up here, or -- **(2)** Only produce the files and commands you can run on your target host (no changes to repo), or -- **(3)** Generate a simple Dockerfile and Dockerfile for a fully self-contained build? - -Pick an option and I’ll implement it. \ No newline at end of file diff --git a/approved-docs/user-flow-plan-in-progress-1.md b/approved-docs/user-flow-plan-in-progress-1.md deleted file mode 100644 index c61494d4..00000000 --- a/approved-docs/user-flow-plan-in-progress-1.md +++ /dev/null @@ -1,46 +0,0 @@ -## Session Configuration Summary (Signup → Activation → Billing) - -This doc captures all changes made in this session across backend and frontend to stabilize signup, onboarding, plans, payments, throttling, and admin flows. - -### Backend Changes -- Registration slug collision handling - - Now generates unique account slugs by appending numeric suffixes to avoid `IntegrityError` on duplicate slugs during signup. - - File: `backend/igny8_core/auth/serializers.py` -- Plans endpoint throttling removed - - `/api/v1/auth/plans/` no longer uses throttling to prevent 429 responses during onboarding/plan fetch. - - File: `backend/igny8_core/auth/views.py` - -### Frontend Changes -- Plans visibility and throttling behavior - - Removed plan filtering to only Starter/Growth/Scale; all active plans returned by the API are shown. - - Added a 429 retry: on throttling while loading billing/plans, shows “throttled, retrying” and auto-retries once after 2 seconds. - - File: `frontend/src/pages/account/PlansAndBillingPage.tsx` -- Signup redirect hard fallback - - After successful signup, still calls router navigate to `/account/plans`, and also forces a 500ms hard redirect to `/account/plans` if navigation stalls. - - File: `frontend/src/components/auth/SignUpForm.tsx` -- Payment method preference - - Auto-selects payment method id 14 when available and best-effort sets it as default; prefers enabled methods. - - File: `frontend/src/pages/account/PlansAndBillingPage.tsx` -- Admin subscriptions actions and linking - - Added Activate/Cancel and Refresh actions on `/admin/subscriptions`. - - `/admin/accounts` “Manage” links now deep-link to `/admin/subscriptions?account_id=...`. - - Files: `frontend/src/pages/admin/AdminSubscriptionsPage.tsx`, `frontend/src/pages/admin/AdminAllAccountsPage.tsx` -- Upgrade gating helper - - Introduced `isUpgradeError` / `showUpgradeToast` and applied to Sites list to surface upgrade prompts on 402/403. ModuleGuard imported/prepared. - - Files: `frontend/src/utils/upgrade.ts`, `frontend/src/pages/Sites/List.tsx`, `frontend/src/components/common/ModuleGuard.tsx` -- Balance error UX - - Shows “Balance unavailable” with retry; clears stale balance on error instead of silently showing defaults. - - Files: `frontend/src/components/dashboard/CreditBalanceWidget.tsx`, `frontend/src/store/billingStore.ts` - -### Behavior Notes / Outcomes -- Signup now survives duplicate slug cases (unique slug generation). -- Plans fetch should not be throttled; all active plans returned by the API will render. -- If SPA navigation fails post-signup, the hard redirect ensures landing on `/account/plans`. -- Payment method id 14 is preferred when present; otherwise any default/available method can be used. -- Admins can activate/cancel subscriptions from the subscriptions page; Manage links carry `account_id`. -- Upgrade prompts now surface on 402/403 in Sites list; balance widgets show proper error/retry state. - -### Operational Dependencies / Reminders -- Ensure the backend exposes at least one active plan; otherwise the list will remain empty. -- Ensure at least one payment method exists (id 14 preferred) so plan selection/purchases are not blocked. - diff --git a/approved-docs/user-flow-plan-in-progress-2.md b/approved-docs/user-flow-plan-in-progress-2.md deleted file mode 100644 index 3a9ff35e..00000000 --- a/approved-docs/user-flow-plan-in-progress-2.md +++ /dev/null @@ -1,13 +0,0 @@ -Differences from the two docs (user-flow + session-config-summary) in the current implementation: - -1) Subscriptions throttling still impacts normal users -- Docs expect “plans fetch should not be throttled” and smooth onboarding; in practice, `/v1/auth/subscriptions/` is still returning 429s for normal users until the backend restart picks up the relaxed scope. - -2) Payment methods are not auto-present for all accounts -- Docs assume payment methods are available so plan selection isn’t blocked. Account 29 had none until we inserted bank_transfer and PK manual; the UI showed “No payment methods available.” - -3) Enterprise plan is hidden for non-aws-admin -- Not called out in the docs; current UI filters out Enterprise for normal tenants. - -4) Onboarding friction from auth/plan calls -- Docs expect a seamless redirect and retry-once for 429; current flow still shows visible errors (429/403) when token is missing/expired or when subscriptions are throttled. \ No newline at end of file diff --git a/approved-docs/user-flow-plan.md b/approved-docs/user-flow-plan.md deleted file mode 100644 index ab986881..00000000 --- a/approved-docs/user-flow-plan.md +++ /dev/null @@ -1,200 +0,0 @@ -## IGNY8 User & Billing Flow — Current State and Remediation Plan - -### 0) Scope / Sources -- Frontend routing: `frontend/src/App.tsx` -- Auth store and token handling: `frontend/src/store/authStore.ts` -- Balance/usage/billing client: `frontend/src/services/api.ts`, `frontend/src/services/billing.api.ts` -- Admin billing UI: `frontend/src/pages/Admin/AdminBilling.tsx` -- Backend auth/billing routers: `backend/igny8_core/auth/urls.py`, `backend/igny8_core/business/billing/urls.py`, `backend/igny8_core/modules/billing/urls.py` - ---- - -### 1) Signup / Login -**Backend endpoints (auth/urls.py)** -- POST `/api/v1/auth/register/` → creates user; no email verification enforced. -- POST `/api/v1/auth/login/` → issues JWT access/refresh; requires valid account + plan for “happy path”. -- POST `/api/v1/auth/refresh/` → refresh token. - -**Frontend behavior (authStore)** -- `register()` hits `/v1/auth/register/`; on success stores tokens and marks authenticated. -- `login()` hits `/v1/auth/login/`; if user.account missing → throws `ACCOUNT_REQUIRED`; if plan missing → `PLAN_REQUIRED`. So the UI blocks login success if the account/plan is absent. -- After login, routes are guarded by `ProtectedRoute` → land on main dashboard (`/`). - -**Gaps** -- No email verification or onboarding; user is dropped to dashboard. -- If account/plan is missing, errors surface but no guided remediation (no redirect to plan selection). - ---- - -### 2) Tenant Billing Data & Balance -**Current endpoints used** -- Balance: `/v1/billing/transactions/balance/` (business billing CreditTransactionViewSet.balance). -- Credit transactions: `/api/v1/billing/transactions/`. -- Usage logs/summary/limits: `/api/v1/billing/credits/usage/…`, `/v1/billing/credits/usage/summary/`, `/v1/billing/credits/usage/limits/`. -- Invoices: `/api/v1/billing/invoices/` (+ detail, pdf). -- Payments: `/api/v1/billing/payments/`. -- Credit packages: `/v1/billing/credit-packages/`. - -**UI touchpoints** -- Header metric uses `fetchCreditBalance()` → `/v1/billing/transactions/balance/`. -- Usage & Analytics, Credits pages, Billing panels use `getCreditBalance()` → same endpoint. - -**Gaps** -- Subscription lifecycle (select plan, subscribe/upgrade/cancel) not wired in UI. -- Payment methods add/remove not exposed in UI. -- Balance fetch falls back to zero on any non-200; no inline “balance unavailable” messaging. - ---- - -### 3) Admin Billing (pages/Admin/AdminBilling.tsx) -**Current tabs & data** -- Overview: stats from `/v1/admin/billing/stats/` (totals, usage). -- User Management: `/v1/admin/users/` (credit balances), adjust credits via `/v1/admin/users/{id}/adjust-credits/`. -- Credit Pricing: `/v1/admin/credit-costs/` with editable cost per operation; Save posts updates. -- Credit Packages: read-only cards from `/v1/billing/credit-packages/`. - -**Gaps** -- No admin CRUD for packages (only read). -- No exports/filters for payments/invoices in this page (handled elsewhere). - ---- - -### 4) Plan Limits / Access -**Backend** -- Auth login blocks if account plan missing (frontend enforced). Backend exposes plans/subscriptions via `auth/urls.py` (routers: `plans`, `subscriptions`). -- Automation service checks `account.credits` directly before heavy AI work (no HTTP). - -**Frontend** -- No explicit plan-limit enforcement in UI (sites, seats, module gating) beyond route/module guards already present per module. -- No upgrade prompts when hitting limits. - ---- - -### 5) AI / Automation Credit Checks -- Backend: `automation_service.py` estimates credits, adds 20% buffer, aborts if `account.credits` < required. Not dependent on billing APIs. -- Frontend: no preflight credit warning; errors are backend-driven. - ---- - -### 6) What’s Missing for a Complete E2E Paid Flow -1) **Subscription UX**: Add tenant plan selection + subscribe/upgrade/cancel wired to `/api/v1/auth/subscriptions/...` (or billing subscription endpoints if exposed separately). Show current plan and renewal status. -2) **Payment Methods**: Expose add/list/remove default; required for Stripe/PayPal/manual flows. -3) **Invoices/Payments (tenant)**: List history with PDF download; surface payment status. -4) **Manual payments (if supported)**: Provide upload/reference input; show approval status. -5) **Plan limits**: Enforce and message limits on sites, seats, modules; show “Upgrade to increase limit.” -6) **Balance UX**: If `/v1/billing/transactions/balance/` fails, show inline warning and retry instead of silently showing 0. -7) **Admin packages**: If needed, add CRUD for credit packages (activate/feature/sort/price IDs). -8) **Onboarding**: Post-signup redirect to “Choose plan” if no active plan; optional email verification. - ---- - -### 7) Recommended Happy Path (Target) -1) Signup → email verify (optional) → redirect to Plan selection. -2) Choose plan → subscribe (create payment method + subscription intent) → confirm → landing on dashboard with non-zero credits and plan limits loaded. -3) User can: - - Add sites up to plan limit. - - Use Planner/Writer modules; preflight check shows remaining credits. - - View Credits/Usage/Invoices/Payments; purchase extra credit packages if needed. -4) Header shows live balance from `/v1/billing/transactions/balance/`; errors visible if fetch fails. -5) Admin can: - - Adjust credit costs, see stats/payments/invoices/approvals. - - Manage credit packages (CRUD) if enabled. - ---- - -### 8) Quick Endpoint Reference (current wiring) -- Balance: `/v1/billing/transactions/balance/` -- Tenant: invoices `/api/v1/billing/invoices/`, payments `/api/v1/billing/payments/`, credit-packages `/v1/billing/credit-packages/`, usage `/api/v1/billing/credits/usage/` -- Admin: stats `/v1/admin/billing/stats/`, users `/v1/admin/users/`, credit-costs `/v1/admin/credit-costs/`, admin payments `/v1/billing/admin/payments/`, pending approvals `/v1/billing/admin/pending_payments/` - ---- - -## Remediation Plan (Do Now) - -### A) Access control / onboarding -- After register → force redirect to Plans page. -- Lock all app routes except Account Profile, Plans, Billing until subscription is active (route guard). -- Optional: email verification gate before plan selection. - -### B) Plans & subscription UX (tenant) -- Plans page: list plans from `/api/v1/auth/plans/` (or billing subscriptions endpoint), show limits. -- Actions: subscribe/upgrade/cancel wired to subscription endpoints; show status/renewal date. -- CTA in header/sidebar if no active plan. - -### C) Payment methods -- Add/list/remove default payment methods (Stripe/PayPal/manual) via billing endpoints. -- Show “add payment method” inline on Plans/Billing if none exists. - -### D) Checkout / payments -- Credit packages purchase flow wired to `/v1/billing/credit-packages/…/purchase/`. -- If manual payments allowed: upload/reference form and surface status; tie to admin approvals. -- Invoice list/history with PDF download; payment history with status badges. - -### E) Balance and usage UX -- Use single endpoint `/v1/billing/transactions/balance/`; on non-200 show inline warning + retry (no silent zero). -- Surface plan limits and usage (sites, seats, module access) with upgrade prompts when near/at limit. - -### F) Module access & limits -- Enforce plan limits on: add site, team invites, module guards (planner/writer/automation/etc.). -- On 403/422 from backend: show upgrade prompt with link to Plans. - -### G) Admin -- Optional: add CRUD for credit packages (create/edit/sort/feature) if backend endpoints available. -- Keep credit costs editable (already wired); add filters/exports later if needed. - -### H) AI task preflight -- Before costly AI actions, fetch balance and show “insufficient credits—purchase/upgrade” prompt. -- Handle backend credit errors with actionable UI (purchase/upgrade CTA). - -### I) Error handling / UX polish -- Balance widget: display “Balance unavailable” on error; retry button. -- Centralize billing error toasts with clear messages (404 vs 402/403 vs 500). - -### J) Implementation order -1) Route guard + post-signup redirect to Plans; hide other routes until active plan. -2) Plans page with subscribe/upgrade/cancel wiring; add payment method UI. -3) Billing page: invoices/payments/history + credit package purchase CTA. -4) Balance/usage error handling; plan limit prompts; AI preflight checks. -5) Admin package CRUD (if needed) and exports/filters. - - - -[User lands] - | - v -[/signup] -- create account --> [Tokens stored] - | - v -[/account/plans] (onboarding gate) - | - |-- list plans (/v1/auth/plans/) - |-- choose plan + payment method (default/selected) - |-- subscribe (/v1/auth/subscriptions/) - v -[Plan active] -----> [Billing tabs available] - | | \ - | | \ - | v v - | [Purchase Credits] [Invoices/Payments] - | /api/v1/... (list + PDF download) - | | - | v - | [Balance/Usage/Limits] (shared store, retry) - | - v -[Protected routes unlocked: /, modules, account] - | - v -[Sites -> Add/Manage site] - | - |-- check plan limits (sites/seats/modules) - |-- on limit: show upgrade/purchase prompt - v -[Site created] - | - v -[Start workflow] - |-- Planner/Writer/Automation, etc. - |-- preflight: balance/limits; on 402/403 -> prompt upgrade/purchase - v -[User executes tasks with credits] \ No newline at end of file diff --git a/final-tenancy-accounts-payments/CURRENT-STATE-CONTEXT.md b/tenancy-accounts-payments-still-have issues/CURRENT-STATE-CONTEXT.md similarity index 100% rename from final-tenancy-accounts-payments/CURRENT-STATE-CONTEXT.md rename to tenancy-accounts-payments-still-have issues/CURRENT-STATE-CONTEXT.md diff --git a/final-tenancy-accounts-payments/FINAL-IMPLEMENTATION-PLAN-COMPLETE.md b/tenancy-accounts-payments-still-have issues/FINAL-IMPLEMENTATION-PLAN-COMPLETE.md similarity index 100% rename from final-tenancy-accounts-payments/FINAL-IMPLEMENTATION-PLAN-COMPLETE.md rename to tenancy-accounts-payments-still-have issues/FINAL-IMPLEMENTATION-PLAN-COMPLETE.md diff --git a/final-tenancy-accounts-payments/FINAL-IMPLEMENTATION-REQUIREMENTS.md b/tenancy-accounts-payments-still-have issues/FINAL-IMPLEMENTATION-REQUIREMENTS.md similarity index 100% rename from final-tenancy-accounts-payments/FINAL-IMPLEMENTATION-REQUIREMENTS.md rename to tenancy-accounts-payments-still-have issues/FINAL-IMPLEMENTATION-REQUIREMENTS.md diff --git a/final-tenancy-accounts-payments/FREE-TRIAL-SIGNUP-FIX.md b/tenancy-accounts-payments-still-have issues/FREE-TRIAL-SIGNUP-FIX.md similarity index 100% rename from final-tenancy-accounts-payments/FREE-TRIAL-SIGNUP-FIX.md rename to tenancy-accounts-payments-still-have issues/FREE-TRIAL-SIGNUP-FIX.md diff --git a/final-tenancy-accounts-payments/IMPLEMENTATION-COMPLETE-SUMMARY.md b/tenancy-accounts-payments-still-have issues/IMPLEMENTATION-COMPLETE-SUMMARY.md similarity index 100% rename from final-tenancy-accounts-payments/IMPLEMENTATION-COMPLETE-SUMMARY.md rename to tenancy-accounts-payments-still-have issues/IMPLEMENTATION-COMPLETE-SUMMARY.md diff --git a/final-tenancy-accounts-payments/IMPLEMENTATION-SUMMARY.md b/tenancy-accounts-payments-still-have issues/IMPLEMENTATION-SUMMARY.md similarity index 100% rename from final-tenancy-accounts-payments/IMPLEMENTATION-SUMMARY.md rename to tenancy-accounts-payments-still-have issues/IMPLEMENTATION-SUMMARY.md diff --git a/final-tenancy-accounts-payments/PRICING-TO-PAID-SIGNUP-GAP.md b/tenancy-accounts-payments-still-have issues/PRICING-TO-PAID-SIGNUP-GAP.md similarity index 100% rename from final-tenancy-accounts-payments/PRICING-TO-PAID-SIGNUP-GAP.md rename to tenancy-accounts-payments-still-have issues/PRICING-TO-PAID-SIGNUP-GAP.md diff --git a/final-tenancy-accounts-payments/README-START-HERE.md b/tenancy-accounts-payments-still-have issues/README-START-HERE.md similarity index 100% rename from final-tenancy-accounts-payments/README-START-HERE.md rename to tenancy-accounts-payments-still-have issues/README-START-HERE.md diff --git a/tenant-temp/backend/igny8_core/ai/ai_core.py b/tenant-temp/backend/igny8_core/ai/ai_core.py deleted file mode 100644 index df9f166b..00000000 --- a/tenant-temp/backend/igny8_core/ai/ai_core.py +++ /dev/null @@ -1,868 +0,0 @@ -""" -AI Core - Centralized execution and logging layer for all AI requests -Handles API calls, model selection, response parsing, and console logging -""" -import logging -import json -import re -import requests -import time -from typing import Dict, Any, Optional, List -from django.conf import settings - -from .constants import ( - DEFAULT_AI_MODEL, - JSON_MODE_MODELS, - MODEL_RATES, - IMAGE_MODEL_RATES, - VALID_OPENAI_IMAGE_MODELS, - VALID_SIZES_BY_MODEL, - DEBUG_MODE, -) -from .tracker import ConsoleStepTracker - -logger = logging.getLogger(__name__) - - -class AICore: - """ - Centralized AI operations handler with console logging. - All AI requests go through run_ai_request() for consistent execution and logging. - """ - - def __init__(self, account=None): - """ - Initialize AICore with account context. - - Args: - account: Optional account object for API key/model loading - """ - self.account = account - self._openai_api_key = None - self._runware_api_key = None - self._load_account_settings() - - def _load_account_settings(self): - """Load API keys from IntegrationSettings with fallbacks (account -> system account -> Django settings)""" - def get_system_account(): - try: - from igny8_core.auth.models import Account - for slug in ['aws-admin', 'default-account', 'default']: - acct = Account.objects.filter(slug=slug).first() - if acct: - return acct - except Exception: - return None - return None - - def get_integration_key(integration_type: str, account): - if not account: - return None - try: - from igny8_core.modules.system.models import IntegrationSettings - settings_obj = IntegrationSettings.objects.filter( - integration_type=integration_type, - account=account, - is_active=True - ).first() - if settings_obj and settings_obj.config: - return settings_obj.config.get('apiKey') - except Exception as e: - logger.warning(f"Could not load {integration_type} settings for account {getattr(account, 'id', None)}: {e}", exc_info=True) - return None - - # 1) Account-specific keys - if self.account: - self._openai_api_key = get_integration_key('openai', self.account) - self._runware_api_key = get_integration_key('runware', self.account) - - # 2) Fallback to system account keys (shared across tenants) - if not self._openai_api_key or not self._runware_api_key: - system_account = get_system_account() - if not self._openai_api_key: - self._openai_api_key = get_integration_key('openai', system_account) - if not self._runware_api_key: - self._runware_api_key = get_integration_key('runware', system_account) - - # 3) Fallback to Django settings - if not self._openai_api_key: - self._openai_api_key = getattr(settings, 'OPENAI_API_KEY', None) - if not self._runware_api_key: - self._runware_api_key = getattr(settings, 'RUNWARE_API_KEY', None) - - def get_api_key(self, integration_type: str = 'openai') -> Optional[str]: - """Get API key for integration type""" - if integration_type == 'openai': - return self._openai_api_key - elif integration_type == 'runware': - return self._runware_api_key - return None - - def get_model(self, integration_type: str = 'openai') -> str: - """ - Get model for integration type. - DEPRECATED: Model should be passed directly to run_ai_request(). - This method is kept for backward compatibility but raises an error. - """ - raise ValueError( - "get_model() is deprecated. Model must be passed directly to run_ai_request(). " - "Use get_model_config() from settings.py to get model from IntegrationSettings." - ) - - def run_ai_request( - self, - prompt: str, - model: str, - max_tokens: int = 4000, - temperature: float = 0.7, - response_format: Optional[Dict] = None, - api_key: Optional[str] = None, - function_name: str = 'ai_request', - function_id: Optional[str] = None, - tracker: Optional[ConsoleStepTracker] = None - ) -> Dict[str, Any]: - """ - Centralized AI request handler with console logging. - All AI text generation requests go through this method. - - Args: - prompt: Prompt text - model: Model name (required - must be provided from IntegrationSettings) - max_tokens: Maximum tokens - temperature: Temperature (0-1) - response_format: Optional response format dict (for JSON mode) - api_key: Optional API key override - function_name: Function name for logging (e.g., 'cluster_keywords') - tracker: Optional ConsoleStepTracker instance for logging - - Returns: - Dict with 'content', 'input_tokens', 'output_tokens', 'total_tokens', - 'model', 'cost', 'error', 'api_id' - - Raises: - ValueError: If model is not provided - """ - # Use provided tracker or create a new one - if tracker is None: - tracker = ConsoleStepTracker(function_name) - - tracker.ai_call("Preparing request...") - - # Step 1: Validate model is provided - if not model: - error_msg = "Model is required. Ensure IntegrationSettings is configured for the account." - tracker.error('ConfigurationError', error_msg) - logger.error(f"[AICore] {error_msg}") - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': None, - 'cost': 0.0, - 'api_id': None, - } - - # Step 2: Validate API key - api_key = api_key or self._openai_api_key - if not api_key: - error_msg = 'OpenAI API key not configured' - tracker.error('ConfigurationError', error_msg) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': model, - 'cost': 0.0, - 'api_id': None, - } - - # Step 3: Use provided model (no fallback) - active_model = model - - # Debug logging: Show model used - logger.info(f"[AICore] Model Configuration:") - logger.info(f" - Model parameter passed: {model}") - logger.info(f" - Model used in request: {active_model}") - tracker.ai_call(f"Using model: {active_model}") - - if active_model not in MODEL_RATES: - error_msg = f"Model '{active_model}' is not supported. Supported models: {list(MODEL_RATES.keys())}" - logger.error(f"[AICore] {error_msg}") - tracker.error('ConfigurationError', error_msg) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': None, - } - - tracker.ai_call(f"Using model: {active_model}") - - # Step 3: Auto-enable JSON mode for supported models - if response_format is None and active_model in JSON_MODE_MODELS: - response_format = {'type': 'json_object'} - tracker.ai_call(f"Auto-enabled JSON mode for {active_model}") - elif response_format: - tracker.ai_call(f"Using custom response format: {response_format}") - else: - tracker.ai_call("Using text response format") - - # Step 4: Validate prompt length and add function_id - prompt_length = len(prompt) - tracker.ai_call(f"Prompt length: {prompt_length} characters") - - # Add function_id to prompt if provided (for tracking) - final_prompt = prompt - if function_id: - function_id_prefix = f'function_id: "{function_id}"\n\n' - final_prompt = function_id_prefix + prompt - tracker.ai_call(f"Added function_id to prompt: {function_id}") - - # Step 5: Build request payload - url = 'https://api.openai.com/v1/chat/completions' - headers = { - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json', - } - - body_data = { - 'model': active_model, - 'messages': [{'role': 'user', 'content': final_prompt}], - 'temperature': temperature, - } - - if max_tokens: - body_data['max_tokens'] = max_tokens - - if response_format: - body_data['response_format'] = response_format - - tracker.ai_call(f"Request payload prepared (model={active_model}, max_tokens={max_tokens}, temp={temperature})") - - # Step 6: Send request - tracker.ai_call("Sending request to OpenAI API...") - request_start = time.time() - - try: - response = requests.post(url, headers=headers, json=body_data, timeout=60) - request_duration = time.time() - request_start - tracker.ai_call(f"Received response in {request_duration:.2f}s (status={response.status_code})") - - # Step 7: Validate HTTP response - if response.status_code != 200: - error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {} - error_message = f"HTTP {response.status_code} error" - - if isinstance(error_data, dict) and 'error' in error_data: - if isinstance(error_data['error'], dict) and 'message' in error_data['error']: - error_message += f": {error_data['error']['message']}" - - # Check for rate limit - if response.status_code == 429: - retry_after = response.headers.get('retry-after', '60') - tracker.rate_limit(retry_after) - error_message += f" (Rate limit - retry after {retry_after}s)" - else: - tracker.error('HTTPError', error_message) - - logger.error(f"OpenAI API HTTP error {response.status_code}: {error_message}") - - return { - 'content': None, - 'error': error_message, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': None, - } - - # Step 8: Parse response JSON - try: - data = response.json() - except json.JSONDecodeError as e: - error_msg = f'Failed to parse JSON response: {str(e)}' - tracker.malformed_json(str(e)) - logger.error(error_msg) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': None, - } - - api_id = data.get('id') - - # Step 9: Extract content - if 'choices' in data and len(data['choices']) > 0: - content = data['choices'][0]['message']['content'] - usage = data.get('usage', {}) - input_tokens = usage.get('prompt_tokens', 0) - output_tokens = usage.get('completion_tokens', 0) - total_tokens = usage.get('total_tokens', 0) - - tracker.parse(f"Received {total_tokens} tokens (input: {input_tokens}, output: {output_tokens})") - tracker.parse(f"Content length: {len(content)} characters") - - # Step 10: Calculate cost - rates = MODEL_RATES.get(active_model, {'input': 2.00, 'output': 8.00}) - cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1_000_000 - tracker.parse(f"Cost calculated: ${cost:.6f}") - - tracker.done("Request completed successfully") - - return { - 'content': content, - 'input_tokens': input_tokens, - 'output_tokens': output_tokens, - 'total_tokens': total_tokens, - 'model': active_model, - 'cost': cost, - 'error': None, - 'api_id': api_id, - 'duration': request_duration, # Add duration tracking - } - else: - error_msg = 'No content in OpenAI response' - tracker.error('EmptyResponse', error_msg) - logger.error(error_msg) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': api_id, - } - - except requests.exceptions.Timeout: - error_msg = 'Request timeout (60s exceeded)' - tracker.timeout(60) - logger.error(error_msg) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': None, - } - except requests.exceptions.RequestException as e: - error_msg = f'Request exception: {str(e)}' - tracker.error('RequestException', error_msg, e) - logger.error(f"OpenAI API error: {error_msg}", exc_info=True) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': None, - } - except Exception as e: - error_msg = f'Unexpected error: {str(e)}' - logger.error(f"[AI][{function_name}][Error] {error_msg}", exc_info=True) - if tracker: - tracker.error('UnexpectedError', error_msg, e) - return { - 'content': None, - 'error': error_msg, - 'input_tokens': 0, - 'output_tokens': 0, - 'total_tokens': 0, - 'model': active_model, - 'cost': 0.0, - 'api_id': None, - } - - def extract_json(self, response_text: str) -> Optional[Dict]: - """ - Extract JSON from response text. - Handles markdown code blocks, multiline JSON, etc. - - Args: - response_text: Raw response text from AI - - Returns: - Parsed JSON dict or None - """ - if not response_text or not response_text.strip(): - return None - - # Try direct JSON parse first - try: - return json.loads(response_text.strip()) - except json.JSONDecodeError: - pass - - # Try to extract JSON from markdown code blocks - json_block_pattern = r'```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```' - matches = re.findall(json_block_pattern, response_text, re.DOTALL) - if matches: - try: - return json.loads(matches[0]) - except json.JSONDecodeError: - pass - - # Try to find JSON object/array in text - json_pattern = r'(\{.*\}|\[.*\])' - matches = re.findall(json_pattern, response_text, re.DOTALL) - for match in matches: - try: - return json.loads(match) - except json.JSONDecodeError: - continue - - return None - - def generate_image( - self, - prompt: str, - provider: str = 'openai', - model: Optional[str] = None, - size: str = '1024x1024', - n: int = 1, - api_key: Optional[str] = None, - negative_prompt: Optional[str] = None, - function_name: str = 'generate_image' - ) -> Dict[str, Any]: - """ - Generate image using AI with console logging. - - Args: - prompt: Image prompt - provider: 'openai' or 'runware' - model: Model name - size: Image size - n: Number of images - api_key: Optional API key override - negative_prompt: Optional negative prompt - function_name: Function name for logging - - Returns: - Dict with 'url', 'revised_prompt', 'cost', 'error', etc. - """ - print(f"[AI][{function_name}] Step 1: Preparing image generation request...") - - if provider == 'openai': - return self._generate_image_openai(prompt, model, size, n, api_key, negative_prompt, function_name) - elif provider == 'runware': - return self._generate_image_runware(prompt, model, size, n, api_key, negative_prompt, function_name) - else: - error_msg = f'Unknown provider: {provider}' - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': provider, - 'cost': 0.0, - 'error': error_msg, - } - - def _generate_image_openai( - self, - prompt: str, - model: Optional[str], - size: str, - n: int, - api_key: Optional[str], - negative_prompt: Optional[str], - function_name: str - ) -> Dict[str, Any]: - """Generate image using OpenAI DALL-E""" - print(f"[AI][{function_name}] Provider: OpenAI") - - # Determine character limit based on model - # DALL-E 2: 1000 chars, DALL-E 3: 4000 chars - model = model or 'dall-e-3' - if model == 'dall-e-2': - max_length = 1000 - elif model == 'dall-e-3': - max_length = 4000 - else: - # Default to 1000 for safety - max_length = 1000 - - # CRITICAL: Truncate prompt to model-specific limit BEFORE any processing - if len(prompt) > max_length: - print(f"[AI][{function_name}][Warning] Prompt too long ({len(prompt)} chars), truncating to {max_length} for {model}") - # Try word-aware truncation, but fallback to hard truncate if no space found - truncated = prompt[:max_length - 3] - last_space = truncated.rfind(' ') - if last_space > max_length * 0.9: # Only use word-aware if we have a reasonable space - prompt = truncated[:last_space] + "..." - else: - prompt = prompt[:max_length] # Hard truncate if no good space found - print(f"[AI][{function_name}] Truncated prompt length: {len(prompt)}") - # Final safety check - if len(prompt) > max_length: - prompt = prompt[:max_length] - print(f"[AI][{function_name}][Error] Had to hard truncate to exactly {max_length} chars") - - api_key = api_key or self._openai_api_key - if not api_key: - error_msg = 'OpenAI API key not configured' - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_msg, - } - - model = model or 'dall-e-3' - print(f"[AI][{function_name}] Step 2: Using model: {model}, size: {size}") - - # Validate model - if model not in VALID_OPENAI_IMAGE_MODELS: - error_msg = f"Model '{model}' is not valid for OpenAI image generation. Only {', '.join(VALID_OPENAI_IMAGE_MODELS)} are supported." - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_msg, - } - - # Validate size - valid_sizes = VALID_SIZES_BY_MODEL.get(model, []) - if size not in valid_sizes: - error_msg = f"Image size '{size}' is not valid for model '{model}'. Valid sizes: {', '.join(valid_sizes)}" - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_msg, - } - - url = 'https://api.openai.com/v1/images/generations' - print(f"[AI][{function_name}] Step 3: Sending request to OpenAI Images API...") - - headers = { - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json', - } - - data = { - 'model': model, - 'prompt': prompt, - 'n': n, - 'size': size - } - - if negative_prompt: - # Note: OpenAI DALL-E doesn't support negative_prompt in API, but we log it - print(f"[AI][{function_name}] Note: Negative prompt provided but OpenAI DALL-E doesn't support it") - - request_start = time.time() - try: - response = requests.post(url, headers=headers, json=data, timeout=150) - request_duration = time.time() - request_start - print(f"[AI][{function_name}] Step 4: Received response in {request_duration:.2f}s (status={response.status_code})") - - if response.status_code != 200: - error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {} - error_message = f"HTTP {response.status_code} error" - if isinstance(error_data, dict) and 'error' in error_data: - if isinstance(error_data['error'], dict) and 'message' in error_data['error']: - error_message += f": {error_data['error']['message']}" - - print(f"[AI][{function_name}][Error] {error_message}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_message, - } - - body = response.json() - if 'data' in body and len(body['data']) > 0: - image_data = body['data'][0] - image_url = image_data.get('url') - revised_prompt = image_data.get('revised_prompt') - - cost = IMAGE_MODEL_RATES.get(model, 0.040) * n - print(f"[AI][{function_name}] Step 5: Image generated successfully") - print(f"[AI][{function_name}] Step 6: Cost: ${cost:.4f}") - print(f"[AI][{function_name}][Success] Image generation completed") - - return { - 'url': image_url, - 'revised_prompt': revised_prompt, - 'provider': 'openai', - 'cost': cost, - 'error': None, - } - else: - error_msg = 'No image data in response' - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_msg, - } - - except requests.exceptions.Timeout: - error_msg = 'Request timeout (150s exceeded)' - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_msg, - } - except Exception as e: - error_msg = f'Unexpected error: {str(e)}' - print(f"[AI][{function_name}][Error] {error_msg}") - logger.error(error_msg, exc_info=True) - return { - 'url': None, - 'revised_prompt': None, - 'provider': 'openai', - 'cost': 0.0, - 'error': error_msg, - } - - def _generate_image_runware( - self, - prompt: str, - model: Optional[str], - size: str, - n: int, - api_key: Optional[str], - negative_prompt: Optional[str], - function_name: str - ) -> Dict[str, Any]: - """Generate image using Runware""" - print(f"[AI][{function_name}] Provider: Runware") - - api_key = api_key or self._runware_api_key - if not api_key: - error_msg = 'Runware API key not configured' - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'provider': 'runware', - 'cost': 0.0, - 'error': error_msg, - } - - runware_model = model or 'runware:97@1' - print(f"[AI][{function_name}] Step 2: Using model: {runware_model}, size: {size}") - - # Parse size - try: - width, height = map(int, size.split('x')) - except ValueError: - error_msg = f"Invalid size format: {size}. Expected format: WIDTHxHEIGHT" - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'provider': 'runware', - 'cost': 0.0, - 'error': error_msg, - } - - url = 'https://api.runware.ai/v1' - print(f"[AI][{function_name}] Step 3: Sending request to Runware API...") - print(f"[AI][{function_name}] Runware API key check: has_key={bool(api_key)}, key_length={len(api_key) if api_key else 0}") - - # Runware uses array payload with authentication task first, then imageInference - # Reference: image-generation.php lines 79-97 - import uuid - payload = [ - { - 'taskType': 'authentication', - 'apiKey': api_key - }, - { - 'taskType': 'imageInference', - 'taskUUID': str(uuid.uuid4()), - 'positivePrompt': prompt, - 'negativePrompt': negative_prompt or '', - 'model': runware_model, - 'width': width, - 'height': height, - 'steps': 30, - 'CFGScale': 7.5, - 'numberResults': 1, - 'outputFormat': 'webp' - } - ] - - request_start = time.time() - try: - response = requests.post(url, json=payload, timeout=150) - request_duration = time.time() - request_start - print(f"[AI][{function_name}] Step 4: Received response in {request_duration:.2f}s (status={response.status_code})") - - if response.status_code != 200: - error_msg = f"HTTP {response.status_code} error" - print(f"[AI][{function_name}][Error] {error_msg}") - return { - 'url': None, - 'provider': 'runware', - 'cost': 0.0, - 'error': error_msg, - } - - body = response.json() - print(f"[AI][{function_name}] Runware response type: {type(body)}, length: {len(body) if isinstance(body, list) else 'N/A'}") - logger.info(f"[AI][{function_name}] Runware response body (first 1000 chars): {str(body)[:1000]}") - - # Runware returns array: [auth_result, image_result] - # image_result has 'data' array with image objects containing 'imageURL' - # Reference: AIProcessor has more robust parsing - match that logic - image_url = None - error_msg = None - - if isinstance(body, list): - # Case 1: Array response - find the imageInference result - print(f"[AI][{function_name}] Response is array with {len(body)} elements") - for idx, item in enumerate(body): - print(f"[AI][{function_name}] Array element {idx}: {type(item)}, keys: {list(item.keys()) if isinstance(item, dict) else 'N/A'}") - if isinstance(item, dict): - # Check if this is the image result with 'data' key - if 'data' in item: - data = item['data'] - print(f"[AI][{function_name}] Found 'data' key, type: {type(data)}") - if isinstance(data, list) and len(data) > 0: - first_item = data[0] - print(f"[AI][{function_name}] First data item keys: {list(first_item.keys()) if isinstance(first_item, dict) else 'N/A'}") - image_url = first_item.get('imageURL') or first_item.get('image_url') - if image_url: - print(f"[AI][{function_name}] Found imageURL: {image_url[:50]}...") - break - # Check for errors - if 'errors' in item: - errors = item['errors'] - print(f"[AI][{function_name}] Found 'errors' key, type: {type(errors)}") - if isinstance(errors, list) and len(errors) > 0: - error_obj = errors[0] - error_msg = error_obj.get('message') or error_obj.get('error') or str(error_obj) - print(f"[AI][{function_name}][Error] Error in response: {error_msg}") - break - # Check for error at root level - if 'error' in item: - error_msg = item['error'] - print(f"[AI][{function_name}][Error] Error at root level: {error_msg}") - break - elif isinstance(body, dict): - # Case 2: Direct dict response - print(f"[AI][{function_name}] Response is dict with keys: {list(body.keys())}") - if 'data' in body: - data = body['data'] - print(f"[AI][{function_name}] Found 'data' key, type: {type(data)}") - if isinstance(data, list) and len(data) > 0: - first_item = data[0] - print(f"[AI][{function_name}] First data item keys: {list(first_item.keys()) if isinstance(first_item, dict) else 'N/A'}") - image_url = first_item.get('imageURL') or first_item.get('image_url') - elif 'errors' in body: - errors = body['errors'] - print(f"[AI][{function_name}] Found 'errors' key, type: {type(errors)}") - if isinstance(errors, list) and len(errors) > 0: - error_obj = errors[0] - error_msg = error_obj.get('message') or error_obj.get('error') or str(error_obj) - print(f"[AI][{function_name}][Error] Error in response: {error_msg}") - elif 'error' in body: - error_msg = body['error'] - print(f"[AI][{function_name}][Error] Error at root level: {error_msg}") - - if error_msg: - print(f"[AI][{function_name}][Error] Runware API error: {error_msg}") - return { - 'url': None, - 'provider': 'runware', - 'cost': 0.0, - 'error': error_msg, - } - - if image_url: - - cost = 0.009 * n # Runware pricing - print(f"[AI][{function_name}] Step 5: Image generated successfully") - print(f"[AI][{function_name}] Step 6: Cost: ${cost:.4f}") - print(f"[AI][{function_name}][Success] Image generation completed") - - return { - 'url': image_url, - 'provider': 'runware', - 'cost': cost, - 'error': None, - } - else: - # If we get here, we couldn't parse the response - error_msg = f'No image data in Runware response. Response type: {type(body).__name__}' - print(f"[AI][{function_name}][Error] {error_msg}") - logger.error(f"[AI][{function_name}] Full Runware response: {json.dumps(body, indent=2) if isinstance(body, (dict, list)) else str(body)}") - return { - 'url': None, - 'provider': 'runware', - 'cost': 0.0, - 'error': error_msg, - } - - except Exception as e: - error_msg = f'Unexpected error: {str(e)}' - print(f"[AI][{function_name}][Error] {error_msg}") - logger.error(error_msg, exc_info=True) - return { - 'url': None, - 'provider': 'runware', - 'cost': 0.0, - 'error': error_msg, - } - - def calculate_cost(self, model: str, input_tokens: int, output_tokens: int, model_type: str = 'text') -> float: - """Calculate cost for API call""" - if model_type == 'text': - rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00}) - input_cost = (input_tokens / 1_000_000) * rates['input'] - output_cost = (output_tokens / 1_000_000) * rates['output'] - return input_cost + output_cost - elif model_type == 'image': - rate = IMAGE_MODEL_RATES.get(model, 0.040) - return rate * 1 - return 0.0 - - # Legacy method names for backward compatibility - def call_openai(self, prompt: str, model: Optional[str] = None, max_tokens: int = 4000, - temperature: float = 0.7, response_format: Optional[Dict] = None, - api_key: Optional[str] = None) -> Dict[str, Any]: - """Legacy method - redirects to run_ai_request()""" - return self.run_ai_request( - prompt=prompt, - model=model, - max_tokens=max_tokens, - temperature=temperature, - response_format=response_format, - api_key=api_key, - function_name='call_openai' - ) diff --git a/tenant-temp/backend/igny8_core/api/base.py b/tenant-temp/backend/igny8_core/api/base.py deleted file mode 100644 index 3303e49e..00000000 --- a/tenant-temp/backend/igny8_core/api/base.py +++ /dev/null @@ -1,401 +0,0 @@ -""" -Base ViewSet with account filtering support -Unified API Standard v1.0 compliant -""" -from rest_framework import viewsets, status -from rest_framework.response import Response -from rest_framework.exceptions import ValidationError as DRFValidationError -from django.core.exceptions import PermissionDenied -from .response import success_response, error_response - - -class AccountModelViewSet(viewsets.ModelViewSet): - """ - Base ViewSet that automatically filters by account. - All module ViewSets should inherit from this. - """ - def get_queryset(self): - queryset = super().get_queryset() - # Filter by account if model has account field - if hasattr(queryset.model, 'account'): - user = getattr(self.request, 'user', None) - - if user and hasattr(user, 'is_authenticated') and user.is_authenticated: - try: - account = getattr(self.request, 'account', None) - if not account and hasattr(self.request, 'user') and self.request.user and hasattr(self.request.user, 'is_authenticated') and self.request.user.is_authenticated: - user_account = getattr(self.request.user, 'account', None) - if user_account: - account = user_account - - if account: - queryset = queryset.filter(account=account) - else: - # No account context -> block access - return queryset.none() - except (AttributeError, TypeError): - # If there's an error accessing user attributes, return empty queryset - return queryset.none() - else: - # Require authentication - return empty queryset for unauthenticated users - return queryset.none() - return queryset - - def perform_create(self, serializer): - # Set account from request (set by middleware) - account = getattr(self.request, 'account', None) - if not account and hasattr(self.request, 'user') and self.request.user and self.request.user.is_authenticated: - try: - account = getattr(self.request.user, 'account', None) - except (AttributeError, Exception): - account = None - - if hasattr(serializer.Meta.model, 'account'): - if not account: - raise PermissionDenied("Account context is required to create this object.") - serializer.save(account=account) - else: - serializer.save() - - def get_serializer_context(self): - context = super().get_serializer_context() - # Add account to context for serializers - account = getattr(self.request, 'account', None) - if account: - context['account'] = account - return context - - def retrieve(self, request, *args, **kwargs): - """ - Override retrieve to return unified format - """ - try: - instance = self.get_object() - serializer = self.get_serializer(instance) - return success_response(data=serializer.data, request=request) - except Exception as e: - return error_response( - error=str(e), - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - def create(self, request, *args, **kwargs): - """ - Override create to return unified format - """ - serializer = self.get_serializer(data=request.data) - try: - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return success_response( - data=serializer.data, - message='Created successfully', - request=request, - status_code=status.HTTP_201_CREATED - ) - except DRFValidationError as e: - return error_response( - error='Validation error', - errors=e.detail if hasattr(e, 'detail') else str(e), - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.error(f"Error in create method: {str(e)}", exc_info=True) - # Check if it's a validation-related error - if 'required' in str(e).lower() or 'invalid' in str(e).lower() or 'validation' in str(e).lower(): - return error_response( - error='Validation error', - errors=str(e), - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - # For other errors, return 500 - return error_response( - error=f'Internal server error: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - def update(self, request, *args, **kwargs): - """ - Override update to return unified format - """ - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - try: - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - return success_response( - data=serializer.data, - message='Updated successfully', - request=request - ) - except DRFValidationError as e: - return error_response( - error='Validation error', - errors=e.detail if hasattr(e, 'detail') else str(e), - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.error(f"Error in create method: {str(e)}", exc_info=True) - # Check if it's a validation-related error - if 'required' in str(e).lower() or 'invalid' in str(e).lower() or 'validation' in str(e).lower(): - return error_response( - error='Validation error', - errors=str(e), - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - # For other errors, return 500 - return error_response( - error=f'Internal server error: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - def destroy(self, request, *args, **kwargs): - """ - Override destroy to return unified format - """ - try: - instance = self.get_object() - # Protect system account - if hasattr(instance, 'slug') and getattr(instance, 'slug', '') == 'aws-admin': - from django.core.exceptions import PermissionDenied - raise PermissionDenied("System account cannot be deleted.") - - if hasattr(instance, 'soft_delete'): - user = getattr(request, 'user', None) - retention_days = None - account = getattr(instance, 'account', None) - if account and hasattr(account, 'deletion_retention_days'): - retention_days = account.deletion_retention_days - elif hasattr(instance, 'deletion_retention_days'): - retention_days = getattr(instance, 'deletion_retention_days', None) - instance.soft_delete( - user=user if getattr(user, 'is_authenticated', False) else None, - retention_days=retention_days, - reason='api_delete' - ) - else: - self.perform_destroy(instance) - return success_response( - data=None, - message='Deleted successfully', - request=request, - status_code=status.HTTP_204_NO_CONTENT - ) - except Exception as e: - return error_response( - error=str(e), - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - def list(self, request, *args, **kwargs): - """ - Override list to return unified format - """ - queryset = self.filter_queryset(self.get_queryset()) - - # Check if pagination is enabled - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - # Use paginator's get_paginated_response which already returns unified format - return self.get_paginated_response(serializer.data) - - # No pagination - return all results in unified format - serializer = self.get_serializer(queryset, many=True) - return success_response( - data=serializer.data, - request=request - ) - - -class SiteSectorModelViewSet(AccountModelViewSet): - """ - Base ViewSet for models that belong to Site and Sector (Keywords, Clusters, etc.). - Automatically filters by: - 1. Account (via parent class) - 2. User's accessible sites (based on role and SiteUserAccess) - 3. Optional site/sector query parameters - """ - def get_queryset(self): - queryset = super().get_queryset() - - # Check if model has site and sector fields (SiteSectorBaseModel) - if hasattr(queryset.model, 'site') and hasattr(queryset.model, 'sector'): - user = getattr(self.request, 'user', None) - - # Check if user is authenticated and is a proper User instance (not AnonymousUser) - if user and hasattr(user, 'is_authenticated') and user.is_authenticated and hasattr(user, 'get_accessible_sites'): - try: - # Get user's accessible sites - accessible_sites = user.get_accessible_sites() - - # If no accessible sites, return empty queryset - if not accessible_sites.exists(): - queryset = queryset.none() - else: - # Filter by accessible sites - queryset = queryset.filter(site__in=accessible_sites) - except (AttributeError, TypeError): - # If there's an error accessing user attributes, return empty queryset - queryset = queryset.none() - else: - # Require authentication - return empty queryset for unauthenticated users - queryset = queryset.none() - - # Optional: Filter by specific site (from query params) - # Safely access query_params (DRF wraps request with Request class) - try: - query_params = getattr(self.request, 'query_params', None) - if query_params is None: - # Fallback for non-DRF requests - query_params = getattr(self.request, 'GET', {}) - site_id = query_params.get('site_id') or query_params.get('site') - else: - site_id = query_params.get('site_id') or query_params.get('site') - except AttributeError: - site_id = None - - if site_id: - try: - # Convert site_id to int if it's a string - site_id_int = int(site_id) if site_id else None - if site_id_int: - if user and hasattr(user, 'is_authenticated') and user.is_authenticated and hasattr(user, 'get_accessible_sites'): - try: - accessible_sites = user.get_accessible_sites() - if accessible_sites.filter(id=site_id_int).exists(): - queryset = queryset.filter(site_id=site_id_int) - else: - queryset = queryset.none() # Site not accessible - except (AttributeError, TypeError): - # If there's an error accessing user attributes, return empty queryset - queryset = queryset.none() - else: - # Require authentication for site filtering - queryset = queryset.none() - except (ValueError, TypeError): - # Invalid site_id, return empty queryset - queryset = queryset.none() - - # Optional: Filter by specific sector (from query params) - # Safely access query_params (DRF wraps request with Request class) - try: - query_params = getattr(self.request, 'query_params', None) - if query_params is None: - # Fallback for non-DRF requests - query_params = getattr(self.request, 'GET', {}) - sector_id = query_params.get('sector_id') - else: - sector_id = query_params.get('sector_id') - except AttributeError: - sector_id = None - - if sector_id: - try: - # Convert sector_id to int if it's a string - sector_id_int = int(sector_id) if sector_id else None - if sector_id_int: - queryset = queryset.filter(sector_id=sector_id_int) - # If site_id also provided, ensure sector belongs to that site - if site_id: - try: - site_id_int = int(site_id) if site_id else None - if site_id_int: - queryset = queryset.filter(site_id=site_id_int) - except (ValueError, TypeError): - pass - except (ValueError, TypeError): - # Invalid sector_id, return empty queryset - queryset = queryset.none() - - # Always exclude records where site is null (orphaned records) - # This prevents showing keywords/clusters/ideas/tasks that aren't associated with any site - # Only skip this if explicitly requested (e.g., for admin cleanup operations) - queryset = queryset.exclude(site__isnull=True) - - return queryset - - def perform_create(self, serializer): - # First call parent to set account - super().perform_create(serializer) - - # If model has site and sector fields, validate access - if hasattr(serializer.Meta.model, 'site') and hasattr(serializer.Meta.model, 'sector'): - user = getattr(self.request, 'user', None) - site = serializer.validated_data.get('site') - sector = serializer.validated_data.get('sector') - - if user and hasattr(user, 'is_authenticated') and user.is_authenticated and site: - try: - if hasattr(user, 'get_accessible_sites'): - accessible_sites = user.get_accessible_sites() - if not accessible_sites.filter(id=site.id).exists(): - raise PermissionDenied("You do not have access to this site") - - # Verify sector belongs to site - if sector and hasattr(sector, 'site') and sector.site != site: - raise PermissionDenied("Sector must belong to the selected site") - except (AttributeError, TypeError) as e: - # If there's an error accessing user attributes, raise permission denied - raise PermissionDenied("Unable to verify access permissions") - - def get_serializer_context(self): - context = super().get_serializer_context() - user = getattr(self.request, 'user', None) - - # Add accessible sites to context for serializer (e.g., for dropdown choices) - if user and hasattr(user, 'is_authenticated') and user.is_authenticated and hasattr(user, 'get_accessible_sites'): - try: - context['accessible_sites'] = user.get_accessible_sites() - # Get accessible sectors from accessible sites - from igny8_core.auth.models import Sector - context['accessible_sectors'] = Sector.objects.filter( - site__in=context['accessible_sites'], - is_active=True - ) - except (AttributeError, TypeError): - # If there's an error, set empty querysets - from igny8_core.auth.models import Site, Sector - context['accessible_sites'] = Site.objects.none() - context['accessible_sectors'] = Sector.objects.none() - else: - # Set empty querysets for unauthenticated users - from igny8_core.auth.models import Site, Sector - context['accessible_sites'] = Site.objects.none() - context['accessible_sectors'] = Sector.objects.none() - - return context - - -class StandardResponseMixin: - """ - Mixin for standard API response format. - """ - def get_response(self, data, message=None, status_code=200): - return Response({ - 'success': True, - 'message': message, - 'data': data - }, status=status_code) - - def get_error_response(self, message, errors=None, status_code=400): - return Response({ - 'success': False, - 'message': message, - 'errors': errors - }, status=status_code) - diff --git a/tenant-temp/backend/igny8_core/api/permissions.py b/tenant-temp/backend/igny8_core/api/permissions.py deleted file mode 100644 index de1ce31d..00000000 --- a/tenant-temp/backend/igny8_core/api/permissions.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Standardized Permission Classes -Provides consistent permission checking across all endpoints -""" -from rest_framework import permissions -from rest_framework.exceptions import PermissionDenied - - -class IsAuthenticatedAndActive(permissions.BasePermission): - """ - Permission class that requires user to be authenticated and active - Base permission for most endpoints - """ - def has_permission(self, request, view): - if not request.user or not request.user.is_authenticated: - return False - - # Check if user is active - if hasattr(request.user, 'is_active'): - return request.user.is_active - - return True - - -class HasTenantAccess(permissions.BasePermission): - """ - Permission class that requires user to belong to the tenant/account - Ensures tenant isolation - """ - def has_permission(self, request, view): - if not request.user or not request.user.is_authenticated: - return False - - # Get account from request (set by middleware) - account = getattr(request, 'account', None) - - # If no account in request, try to get from user - if not account and hasattr(request.user, 'account'): - try: - account = request.user.account - except (AttributeError, Exception): - pass - - # Regular users must have account access - if account: - # Check if user belongs to this account - if hasattr(request.user, 'account'): - try: - user_account = request.user.account - return user_account == account or user_account.id == account.id - except (AttributeError, Exception): - pass - - return False - - -class IsViewerOrAbove(permissions.BasePermission): - """ - Permission class that requires viewer, editor, admin, or owner role - For read-only operations - """ - def has_permission(self, request, view): - if not request.user or not request.user.is_authenticated: - return False - - # Check user role - if hasattr(request.user, 'role'): - role = request.user.role - # viewer, editor, admin, owner all have access - return role in ['viewer', 'editor', 'admin', 'owner'] - - # If no role system, allow authenticated users - return True - - -class IsEditorOrAbove(permissions.BasePermission): - """ - Permission class that requires editor, admin, or owner role - For content operations - """ - def has_permission(self, request, view): - if not request.user or not request.user.is_authenticated: - return False - - # Check user role - if hasattr(request.user, 'role'): - role = request.user.role - # editor, admin, owner have access - return role in ['editor', 'admin', 'owner'] - - # If no role system, allow authenticated users - return True - - -class IsAdminOrOwner(permissions.BasePermission): - """ - Permission class that requires admin or owner role only - For settings, keys, billing operations - """ - def has_permission(self, request, view): - if not request.user or not request.user.is_authenticated: - return False - - # Check user role - if hasattr(request.user, 'role'): - role = request.user.role - # admin, owner have access - return role in ['admin', 'owner'] - - # If no role system, deny by default for security - return False - - -class IsSystemAccountOrDeveloper(permissions.BasePermission): - """ - Allow only system accounts (aws-admin/default-account/default) or developer role. - Use for sensitive, globally-scoped settings like integration API keys. - """ - def has_permission(self, request, view): - user = getattr(request, "user", None) - if not user or not user.is_authenticated: - return False - - account_slug = getattr(getattr(user, "account", None), "slug", None) - if user.role == "developer": - return True - if account_slug in ["aws-admin", "default-account", "default"]: - return True - return False - - diff --git a/tenant-temp/backend/igny8_core/api/throttles.py b/tenant-temp/backend/igny8_core/api/throttles.py deleted file mode 100644 index b67cb8bc..00000000 --- a/tenant-temp/backend/igny8_core/api/throttles.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -Scoped Rate Throttling -Provides rate limiting with different scopes for different operation types -""" -from rest_framework.throttling import ScopedRateThrottle -from django.conf import settings -import logging - -logger = logging.getLogger(__name__) - - -class DebugScopedRateThrottle(ScopedRateThrottle): - """ - Scoped rate throttle that can be bypassed in debug mode - - Usage: - class MyViewSet(viewsets.ModelViewSet): - throttle_scope = 'planner' - throttle_classes = [DebugScopedRateThrottle] - """ - - def allow_request(self, request, view): - """ - Check if request should be throttled - - Bypasses throttling if: - - DEBUG mode is True - - 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 authenticated users (avoid user-facing 429s) - authenticated_bypass = False - if hasattr(request, 'user') and request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated: - authenticated_bypass = True # Do not throttle logged-in users - - if debug_bypass or env_bypass or public_blueprint_bypass or authenticated_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'): - # Set headers for debugging - self.scope = getattr(view, 'throttle_scope', None) - if self.scope: - # Get rate for this scope - rate = self.get_rate() - if rate: - # Parse rate (e.g., "10/min") - num_requests, duration = self.parse_rate(rate) - # Set headers - request._throttle_debug_info = { - 'scope': self.scope, - 'rate': rate, - 'limit': num_requests, - 'duration': duration - } - return True - - # Normal throttling behavior - return super().allow_request(request, view) - - def get_rate(self): - """ - Get rate for the current scope - """ - if not self.scope: - return None - - # Get throttle rates from settings - throttle_rates = getattr(settings, 'REST_FRAMEWORK', {}).get('DEFAULT_THROTTLE_RATES', {}) - - # Get rate for this scope - rate = throttle_rates.get(self.scope) - - # Fallback to default if scope not found - if not rate: - rate = throttle_rates.get('default', '100/min') - - return rate - - def parse_rate(self, rate): - """ - Parse rate string (e.g., "10/min") into (num_requests, duration) - - Returns: - tuple: (num_requests, duration_in_seconds) - """ - if not rate: - return None, None - - try: - num, period = rate.split('/') - num_requests = int(num) - - # Parse duration - period = period.strip().lower() - if period == 'sec' or period == 's': - duration = 1 - elif period == 'min' or period == 'm': - duration = 60 - elif period == 'hour' or period == 'h': - duration = 3600 - elif period == 'day' or period == 'd': - duration = 86400 - else: - # Default to seconds - duration = 1 - - return num_requests, duration - except (ValueError, AttributeError): - # Invalid rate format, default to 100/min - logger.warning(f"Invalid rate format: {rate}, defaulting to 100/min") - return 100, 60 - - def throttle_success(self): - """ - Called when request is allowed - Sets throttle headers on response - """ - # This is called by DRF after allow_request returns True - # Headers are set automatically by ScopedRateThrottle - pass - - diff --git a/tenant-temp/backend/igny8_core/auth/middleware.py b/tenant-temp/backend/igny8_core/auth/middleware.py deleted file mode 100644 index 9628dc47..00000000 --- a/tenant-temp/backend/igny8_core/auth/middleware.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -Multi-Account Middleware -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: - import jwt - JWT_AVAILABLE = True -except ImportError: - JWT_AVAILABLE = False - -from django.conf import settings - - -class AccountContextMiddleware(MiddlewareMixin): - """ - Middleware that extracts account information from JWT token - and adds it to request context for account isolation. - """ - - def process_request(self, request): - """Extract account from JWT token in Authorization header or session.""" - # Skip for admin and auth endpoints - if request.path.startswith('/admin/') or request.path.startswith('/api/v1/auth/'): - return None - - # First, try to get user from Django session (cookie-based auth) - # This handles cases where frontend uses credentials: 'include' with session cookies - if hasattr(request, 'user') and request.user and request.user.is_authenticated: - # User is authenticated via session - refresh from DB to get latest account/plan data - # This ensures changes to account/plan are reflected immediately without re-login - try: - from .models import User as UserModel - # Refresh user from DB with account and plan relationships to get latest data - # This is important so account/plan changes are reflected immediately - user = UserModel.objects.select_related('account', 'account__plan').get(id=request.user.id) - # Update request.user with fresh data - 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 - 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): - pass - # If account access fails (e.g., column mismatch), set to None - request.account = None - return None - - # Get token from Authorization header (JWT auth - for future implementation) - auth_header = request.META.get('HTTP_AUTHORIZATION', '') - if not auth_header.startswith('Bearer '): - # No JWT token - if session auth didn't work, set account to None - # But don't set request.user to None - it might be set by Django's auth middleware - if not hasattr(request, 'account'): - request.account = None - return None - - token = auth_header.split(' ')[1] if len(auth_header.split(' ')) > 1 else None - if not token: - if not hasattr(request, 'account'): - request.account = None - return None - - try: - if not JWT_AVAILABLE: - # JWT library not installed yet - skip for now - request.account = None - return None - - # Decode JWT token with signature verification - # Use JWT_SECRET_KEY from settings (falls back to SECRET_KEY if not set) - jwt_secret = getattr(settings, 'JWT_SECRET_KEY', getattr(settings, 'SECRET_KEY', None)) - if not jwt_secret: - raise ValueError("JWT_SECRET_KEY or SECRET_KEY must be set in settings") - - decoded = jwt.decode(token, jwt_secret, algorithms=[getattr(settings, 'JWT_ALGORITHM', 'HS256')]) - - # Extract user and account info from token - user_id = decoded.get('user_id') - account_id = decoded.get('account_id') - - 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 - 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 - if account_id: - # Verify account still exists - try: - account = Account.objects.get(id=account_id) - 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 - except (User.DoesNotExist, Account.DoesNotExist): - request.account = None - else: - request.account = None - - except jwt.InvalidTokenError: - request.account = None - except Exception: - # Fail silently for now - allow unauthenticated access - request.account = 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, - ) - diff --git a/tenant-temp/backend/igny8_core/auth/models.py b/tenant-temp/backend/igny8_core/auth/models.py deleted file mode 100644 index 4d4fca3d..00000000 --- a/tenant-temp/backend/igny8_core/auth/models.py +++ /dev/null @@ -1,634 +0,0 @@ -""" -Multi-Account and Authentication Models -""" -from django.db import models -from django.contrib.auth.models import AbstractUser -from django.utils.translation import gettext_lazy as _ -from django.core.validators import MinValueValidator, MaxValueValidator -from igny8_core.common.soft_delete import SoftDeletableModel, SoftDeleteManager - - -class AccountBaseModel(models.Model): - """ - Abstract base model for all account-isolated models. - All models should inherit from this to ensure account isolation. - """ - account = models.ForeignKey('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='%(class)s_set', db_index=True, db_column='tenant_id') - created_at = models.DateTimeField(auto_now_add=True, db_index=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - abstract = True - indexes = [ - models.Index(fields=['account', 'created_at']), - ] - - - -class SiteSectorBaseModel(AccountBaseModel): - """ - Abstract base model for models that belong to a Site and Sector. - Provides automatic filtering by site/sector based on user access. - Models like Keywords and Clusters should inherit from this. - """ - site = models.ForeignKey('igny8_core_auth.Site', on_delete=models.CASCADE, related_name='%(class)s_set', db_index=True) - sector = models.ForeignKey('igny8_core_auth.Sector', on_delete=models.CASCADE, related_name='%(class)s_set', db_index=True) - - class Meta: - abstract = True - indexes = [ - models.Index(fields=['account', 'site', 'sector']), - models.Index(fields=['site', 'sector']), - ] - - def save(self, *args, **kwargs): - """Ensure site and sector belong to same account.""" - # Set account from site - if self.site: - self.account = self.site.account - # Ensure sector belongs to site - if self.sector and self.sector.site != self.site: - from django.core.exceptions import ValidationError - raise ValidationError("Sector must belong to the same site") - super().save(*args, **kwargs) - - -class Account(SoftDeletableModel): - """ - Account/Organization model for multi-account support. - """ - STATUS_CHOICES = [ - ('active', 'Active'), - ('suspended', 'Suspended'), - ('trial', 'Trial'), - ('cancelled', 'Cancelled'), - ] - - name = models.CharField(max_length=255) - slug = models.SlugField(unique=True, max_length=255) - owner = models.ForeignKey( - 'igny8_core_auth.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='owned_accounts', - ) - stripe_customer_id = models.CharField(max_length=255, blank=True, null=True) - plan = models.ForeignKey('igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='accounts') - credits = models.IntegerField(default=0, validators=[MinValueValidator(0)]) - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial') - deletion_retention_days = models.PositiveIntegerField( - default=14, - validators=[MinValueValidator(1), MaxValueValidator(365)], - help_text="Retention window (days) before soft-deleted items are purged", - ) - - # Billing information - billing_email = models.EmailField(blank=True, null=True, help_text="Email for billing notifications") - billing_address_line1 = models.CharField(max_length=255, blank=True, help_text="Street address") - billing_address_line2 = models.CharField(max_length=255, blank=True, help_text="Apt, suite, etc.") - billing_city = models.CharField(max_length=100, blank=True) - billing_state = models.CharField(max_length=100, blank=True, help_text="State/Province/Region") - billing_postal_code = models.CharField(max_length=20, blank=True) - billing_country = models.CharField(max_length=2, blank=True, help_text="ISO 2-letter country code") - tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number") - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - db_table = 'igny8_tenants' - verbose_name = 'Account' - verbose_name_plural = 'Accounts' - indexes = [ - models.Index(fields=['slug']), - models.Index(fields=['status']), - ] - - objects = SoftDeleteManager() - all_objects = models.Manager() - - def __str__(self): - return self.name - - def is_system_account(self): - """Check if this account is a system account with highest access level.""" - # System accounts bypass all filtering restrictions - return self.slug in ['aws-admin', 'default-account', 'default'] - - def soft_delete(self, user=None, reason=None, retention_days=None): - if self.is_system_account(): - from django.core.exceptions import PermissionDenied - raise PermissionDenied("System account cannot be deleted.") - return super().soft_delete(user=user, reason=reason, retention_days=retention_days) - - def delete(self, using=None, keep_parents=False): - return self.soft_delete() - - -class Plan(models.Model): - """ - Subscription plan model - Phase 0: Credit-only system. - Plans define credits, billing, and account management limits only. - """ - BILLING_CYCLE_CHOICES = [ - ('monthly', 'Monthly'), - ('annual', 'Annual'), - ] - - # Plan Info - name = models.CharField(max_length=255) - slug = models.SlugField(unique=True, max_length=255) - price = models.DecimalField(max_digits=10, decimal_places=2) - billing_cycle = models.CharField(max_length=20, choices=BILLING_CYCLE_CHOICES, default='monthly') - features = models.JSONField(default=list, blank=True, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])") - is_active = models.BooleanField(default=True) - created_at = models.DateTimeField(auto_now_add=True) - - # Account Management Limits (kept - not operation limits) - max_users = models.IntegerField(default=1, validators=[MinValueValidator(1)], help_text="Total users allowed per account") - max_sites = models.IntegerField( - default=1, - validators=[MinValueValidator(1)], - help_text="Maximum number of sites allowed" - ) - 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) - 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?") - auto_credit_topup_threshold = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(0)], help_text="Auto top-up trigger point (optional)") - auto_credit_topup_amount = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="How many credits to auto-buy") - - # Stripe Integration - stripe_product_id = models.CharField(max_length=255, blank=True, null=True, help_text="For Stripe plan sync") - stripe_price_id = models.CharField(max_length=255, blank=True, null=True, help_text="Monthly price ID for Stripe") - - # Legacy field for backward compatibility - credits_per_month = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="DEPRECATED: Use included_credits instead") - - class Meta: - db_table = 'igny8_plans' - ordering = ['price'] - - def __str__(self): - return self.name - - def clean(self): - """Validate plan limits.""" - from django.core.exceptions import ValidationError - if self.max_sites < 1: - raise ValidationError("max_sites must be >= 1") - if self.included_credits < 0: - raise ValidationError("included_credits must be >= 0") - - def get_effective_credits_per_month(self): - """Get effective credits per month (use included_credits if set, otherwise credits_per_month for backward compatibility).""" - return self.included_credits if self.included_credits > 0 else self.credits_per_month - - -class Subscription(models.Model): - """ - Account subscription model linking to Stripe. - """ - STATUS_CHOICES = [ - ('active', 'Active'), - ('past_due', 'Past Due'), - ('canceled', 'Canceled'), - ('trialing', 'Trialing'), - ] - - account = models.OneToOneField('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='subscription', db_column='tenant_id') - stripe_subscription_id = models.CharField(max_length=255, unique=True) - status = models.CharField(max_length=20, choices=STATUS_CHOICES) - 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) - - class Meta: - db_table = 'igny8_subscriptions' - indexes = [ - models.Index(fields=['status']), - ] - - def __str__(self): - return f"{self.account.name} - {self.status}" - - - -class Site(SoftDeletableModel, AccountBaseModel): - """ - Site model - Each account can have multiple sites based on their plan. - Each site belongs to ONE industry and can have 1-5 sectors from that industry. - """ - STATUS_CHOICES = [ - ('active', 'Active'), - ('inactive', 'Inactive'), - ('suspended', 'Suspended'), - ] - - name = models.CharField(max_length=255) - slug = models.SlugField(max_length=255) - domain = models.URLField(blank=True, null=True, help_text="Primary domain URL") - description = models.TextField(blank=True, null=True) - industry = models.ForeignKey( - 'igny8_core_auth.Industry', - on_delete=models.PROTECT, - related_name='sites', - null=True, - blank=True, - help_text="Industry this site belongs to" - ) - is_active = models.BooleanField(default=True, db_index=True) - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active') - 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)") - 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" - ) - - objects = SoftDeleteManager() - all_objects = models.Manager() - - class Meta: - db_table = 'igny8_sites' - unique_together = [['account', 'slug']] # Slug unique per account - ordering = ['-created_at'] # Order by creation date for consistent pagination - indexes = [ - 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): - return f"{self.account.name} - {self.name}" - - - def get_active_sectors_count(self): - """Get count of active sectors for this site.""" - return self.sectors.filter(is_active=True).count() - - def get_max_sectors_limit(self): - """Get the maximum sectors allowed for this site based on plan, defaulting to 5 if not set.""" - try: - if self.account and self.account.plan and self.account.plan.max_industries is not None: - return self.account.plan.max_industries - except (AttributeError, Exception): - pass - # Default limit: 5 sectors per site - return 5 - - def can_add_sector(self): - """Check if site can add another sector based on plan limits.""" - return self.get_active_sectors_count() < self.get_max_sectors_limit() - - -class Industry(models.Model): - """ - Industry model - Global industry templates. - These are predefined industry definitions that sites can reference. - """ - name = models.CharField(max_length=255, unique=True) - slug = models.SlugField(unique=True, max_length=255, db_index=True) - description = models.TextField(blank=True, null=True) - is_active = models.BooleanField(default=True, db_index=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - db_table = 'igny8_industries' - ordering = ['name'] - verbose_name = 'Industry' - verbose_name_plural = 'Industries' - indexes = [ - models.Index(fields=['slug']), - models.Index(fields=['is_active']), - ] - - def __str__(self): - return self.name - - -class IndustrySector(models.Model): - """ - Industry Sector model - Sector templates within industries. - These define the available sectors for each industry. - """ - industry = models.ForeignKey('igny8_core_auth.Industry', on_delete=models.CASCADE, related_name='sectors') - name = models.CharField(max_length=255) - slug = models.SlugField(max_length=255, db_index=True) - 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(default=True, db_index=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - db_table = 'igny8_industry_sectors' - unique_together = [['industry', 'slug']] # Slug unique per industry - verbose_name = 'Industry Sector' - verbose_name_plural = 'Industry Sectors' - indexes = [ - models.Index(fields=['industry', 'is_active']), - models.Index(fields=['slug']), - ] - ordering = ['industry', 'name'] - - def __str__(self): - return f"{self.industry.name} - {self.name}" - - -class SeedKeyword(models.Model): - """ - Global, permanent keyword suggestions scoped by industry + sector. - These are canonical keywords that can be imported into account-specific Keywords. - Non-deletable global reference data. - """ - INTENT_CHOICES = [ - ('informational', 'Informational'), - ('navigational', 'Navigational'), - ('commercial', 'Commercial'), - ('transactional', 'Transactional'), - ] - - keyword = models.CharField(max_length=255, db_index=True) - industry = models.ForeignKey('igny8_core_auth.Industry', on_delete=models.CASCADE, related_name='seed_keywords') - sector = models.ForeignKey('igny8_core_auth.IndustrySector', on_delete=models.CASCADE, related_name='seed_keywords') - volume = models.IntegerField(default=0, help_text='Search volume estimate') - difficulty = models.IntegerField( - default=0, - validators=[MinValueValidator(0), MaxValueValidator(100)], - help_text='Keyword difficulty (0-100)' - ) - intent = models.CharField(max_length=50, choices=INTENT_CHOICES, default='informational') - is_active = models.BooleanField(default=True, db_index=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - db_table = 'igny8_seed_keywords' - unique_together = [['keyword', 'industry', 'sector']] - verbose_name = 'Seed Keyword' - verbose_name_plural = 'Global Keywords Database' - indexes = [ - models.Index(fields=['keyword']), - models.Index(fields=['industry', 'sector']), - models.Index(fields=['industry', 'sector', 'is_active']), - models.Index(fields=['intent']), - ] - ordering = ['keyword'] - - def __str__(self): - return f"{self.keyword} ({self.industry.name} - {self.sector.name})" - - -class Sector(SoftDeletableModel, AccountBaseModel): - """ - Sector model - Each site can have 1-5 sectors. - Sectors are site-specific instances that reference an IndustrySector template. - Sectors contain keywords and clusters. - """ - STATUS_CHOICES = [ - ('active', 'Active'), - ('inactive', 'Inactive'), - ] - - site = models.ForeignKey('igny8_core_auth.Site', on_delete=models.CASCADE, related_name='sectors') - industry_sector = models.ForeignKey( - 'igny8_core_auth.IndustrySector', - on_delete=models.PROTECT, - related_name='site_sectors', - null=True, - blank=True, - help_text="Reference to the industry sector template" - ) - name = models.CharField(max_length=255) - slug = models.SlugField(max_length=255) - description = models.TextField(blank=True, null=True) - is_active = models.BooleanField(default=True, db_index=True) - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active') - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - objects = SoftDeleteManager() - all_objects = models.Manager() - - class Meta: - db_table = 'igny8_sectors' - unique_together = [['site', 'slug']] # Slug unique per site - indexes = [ - models.Index(fields=['site', 'is_active']), - models.Index(fields=['account', 'site']), - models.Index(fields=['industry_sector']), - ] - - def __str__(self): - return f"{self.site.name} - {self.name}" - - @property - def industry(self): - """Get the industry for this sector.""" - return self.industry_sector.industry if self.industry_sector else None - - def save(self, *args, **kwargs): - """Ensure site belongs to same account, validate sector limit, and industry match.""" - # Set account from site - if self.site: - self.account = self.site.account - - # Validate that sector's industry_sector belongs to site's industry - if self.site and self.site.industry and self.industry_sector: - if self.industry_sector.industry != self.site.industry: - from django.core.exceptions import ValidationError - raise ValidationError( - f"Sector must belong to site's industry ({self.site.industry.name}). " - f"Selected sector belongs to {self.industry_sector.industry.name}." - ) - - super().save(*args, **kwargs) - - # Validate sector limit based on plan - only for new active sectors - if self.is_active: - max_sectors = self.site.get_max_sectors_limit() - if self.site.get_active_sectors_count() > max_sectors: - from django.core.exceptions import ValidationError - raise ValidationError(f"Maximum {max_sectors} sectors allowed per site for this plan") - - -class SiteUserAccess(models.Model): - """ - Many-to-many relationship between Users and Sites. - Controls which users can access which sites. - Owners and Admins have access to all sites automatically. - """ - user = models.ForeignKey('igny8_core_auth.User', on_delete=models.CASCADE, related_name='site_access') - site = models.ForeignKey('igny8_core_auth.Site', on_delete=models.CASCADE, related_name='user_access') - granted_at = models.DateTimeField(auto_now_add=True) - granted_by = models.ForeignKey( - 'igny8_core_auth.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='granted_site_accesses' - ) - - class Meta: - db_table = 'igny8_site_user_access' - unique_together = [['user', 'site']] - verbose_name = 'Site User Access' - verbose_name_plural = 'Site User Access' - indexes = [ - models.Index(fields=['user', 'site']), - ] - - def __str__(self): - return f"{self.user.email} -> {self.site.name}" - - -class PasswordResetToken(models.Model): - """Password reset token model for password reset flow""" - user = models.ForeignKey('igny8_core_auth.User', on_delete=models.CASCADE, related_name='password_reset_tokens') - token = models.CharField(max_length=255, unique=True, db_index=True) - expires_at = models.DateTimeField() - used = models.BooleanField(default=False) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - db_table = 'igny8_password_reset_tokens' - indexes = [ - models.Index(fields=['token']), - models.Index(fields=['user', 'used']), - models.Index(fields=['expires_at']), - ] - ordering = ['-created_at'] - - def __str__(self): - return f"Password reset token for {self.user.email}" - - def is_valid(self): - """Check if token is valid (not used and not expired)""" - from django.utils import timezone - return not self.used and self.expires_at > timezone.now() - - -class User(AbstractUser): - """ - Custom user model with account relationship and role support. - """ - ROLE_CHOICES = [ - ('developer', 'Developer / Super Admin'), - ('owner', 'Owner'), - ('admin', 'Admin'), - ('editor', 'Editor'), - ('viewer', 'Viewer'), - ('system_bot', 'System Bot'), - ] - - account = models.ForeignKey('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='users', null=True, blank=True, db_column='tenant_id') - role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='viewer') - email = models.EmailField(_('email address'), unique=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - USERNAME_FIELD = 'email' - REQUIRED_FIELDS = ['username'] - - class Meta: - db_table = 'igny8_users' - indexes = [ - models.Index(fields=['account', 'role']), - models.Index(fields=['email']), - ] - - def __str__(self): - return self.email - - def has_role(self, *roles): - """Check if user has any of the specified roles.""" - return self.role in roles - - def is_owner_or_admin(self): - """Check if user is owner or admin.""" - return self.role in ['owner', 'admin'] - - def is_developer(self): - """Check if user is a developer/super admin with full access.""" - return self.role == 'developer' or self.is_superuser - - def is_admin_or_developer(self): - """Check if user is admin or developer.""" - return self.role in ['admin', 'developer'] or self.is_superuser - - def is_system_account_user(self): - """Check if user belongs to a system account with highest access level.""" - try: - return self.account and self.account.is_system_account() - except (AttributeError, Exception): - # If account access fails (e.g., column mismatch), return False - return False - - def get_accessible_sites(self): - """Get all sites the user can access.""" - try: - if not self.account: - return Site.objects.none() - - base_sites = Site.objects.filter(account=self.account, is_active=True) - - if self.role in ['owner', 'admin', 'developer'] or self.is_superuser or self.is_system_account_user(): - return base_sites - - # Other users can only access sites explicitly granted via SiteUserAccess - return base_sites.filter(user_access__user=self).distinct() - except (AttributeError, Exception): - # If account access fails (e.g., column mismatch), return empty queryset - return Site.objects.none() - diff --git a/tenant-temp/backend/igny8_core/auth/permissions.py b/tenant-temp/backend/igny8_core/auth/permissions.py deleted file mode 100644 index a81932e5..00000000 --- a/tenant-temp/backend/igny8_core/auth/permissions.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Role-Based Access Control (RBAC) Permissions -""" -from rest_framework import permissions - - -class IsOwnerOrAdmin(permissions.BasePermission): - """Allow access only to owners and admins.""" - - def has_permission(self, request, view): - user = getattr(request, "user", None) - if not user or not user.is_authenticated: - return False - if getattr(user, "is_superuser", False): - return True - return user.role in ['owner', 'admin', 'developer'] - - -class IsEditorOrAbove(permissions.BasePermission): - """Allow access to editors, admins, and owners.""" - - def has_permission(self, request, view): - user = getattr(request, "user", None) - if not user or not user.is_authenticated: - return False - if getattr(user, "is_superuser", False): - return True - return user.role in ['owner', 'admin', 'editor', 'developer'] - - -class IsViewerOrAbove(permissions.BasePermission): - """Allow access to all authenticated users.""" - - def has_permission(self, request, view): - user = getattr(request, "user", None) - if not user or not user.is_authenticated: - return False - return True - - -class AccountPermission(permissions.BasePermission): - """Ensure user belongs to the account being accessed.""" - - def has_permission(self, request, view): - if not request.user or not request.user.is_authenticated: - return False - - # System bots can access all accounts - if request.user.role == 'system_bot': - return True - - # Users must have an account - user_account = getattr(request.user, 'account', None) - if not user_account: - return False - - # For now, allow access if user has account (will be refined with object-level checks) - return True - - def has_object_permission(self, request, view, obj): - if not request.user or not request.user.is_authenticated: - return False - - # System bots can access all - if request.user.role == 'system_bot': - return True - - # Check if object has account and it matches user's account - obj_account = getattr(obj, 'account', None) - user_account = getattr(request.user, 'account', None) - if obj_account: - return obj_account == user_account - - # If no account on object, allow (for non-account models) - return True - - diff --git a/tenant-temp/backend/igny8_core/auth/views.py b/tenant-temp/backend/igny8_core/auth/views.py deleted file mode 100644 index df276371..00000000 --- a/tenant-temp/backend/igny8_core/auth/views.py +++ /dev/null @@ -1,1523 +0,0 @@ -""" -Authentication Views - Structured as: Groups, Users, Accounts, Subscriptions, Site User Access -Unified API Standard v1.0 compliant -""" -from rest_framework import viewsets, status, permissions, filters -from rest_framework.decorators import action -from rest_framework.response import Response -from rest_framework.views import APIView -from django.contrib.auth import authenticate -from django.utils import timezone -from django.db import transaction -from django_filters.rest_framework import DjangoFilterBackend -from drf_spectacular.utils import extend_schema, extend_schema_view -from igny8_core.api.base import AccountModelViewSet -from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication -from igny8_core.api.response import success_response, error_response -from igny8_core.api.throttles import DebugScopedRateThrottle -from igny8_core.api.pagination import CustomPageNumberPagination -from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess -from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword -from .serializers import ( - UserSerializer, AccountSerializer, PlanSerializer, SubscriptionSerializer, - RegisterSerializer, LoginSerializer, ChangePasswordSerializer, - SiteSerializer, SectorSerializer, SiteUserAccessSerializer, - IndustrySerializer, IndustrySectorSerializer, SeedKeywordSerializer, - RefreshTokenSerializer, RequestPasswordResetSerializer, ResetPasswordSerializer -) -from .permissions import IsOwnerOrAdmin, IsEditorOrAbove -from .utils import generate_access_token, generate_refresh_token, get_token_expiry, decode_token -from .models import PasswordResetToken -import jwt - - -# ============================================================================ -# 1. GROUPS - Define user roles and permissions across the system -# ============================================================================ - -@extend_schema_view( - list=extend_schema(tags=['Authentication']), - retrieve=extend_schema(tags=['Authentication']), -) -class GroupsViewSet(viewsets.ViewSet): - """ - ViewSet for managing user roles and permissions (Groups). - Groups are defined by the User.ROLE_CHOICES. - Unified API Standard v1.0 compliant - """ - permission_classes = [IsOwnerOrAdmin] - throttle_scope = 'auth' - throttle_classes = [DebugScopedRateThrottle] - - def list(self, request): - """List all available roles/groups.""" - roles = [ - { - 'id': 'developer', - 'name': 'Developer / Super Admin', - 'description': 'Full access across all accounts (bypasses all filters)', - 'permissions': ['full_access', 'bypass_filters', 'all_modules'] - }, - { - 'id': 'owner', - 'name': 'Owner', - 'description': 'Full account access, billing, automation', - 'permissions': ['account_management', 'billing', 'automation', 'all_sites'] - }, - { - 'id': 'admin', - 'name': 'Admin', - 'description': 'Manage content modules, view billing (no edit)', - 'permissions': ['content_management', 'view_billing', 'all_sites'] - }, - { - 'id': 'editor', - 'name': 'Editor', - 'description': 'Generate AI content, manage clusters/tasks', - 'permissions': ['ai_content', 'manage_clusters', 'manage_tasks', 'assigned_sites'] - }, - { - 'id': 'viewer', - 'name': 'Viewer', - 'description': 'Read-only dashboards', - 'permissions': ['read_only', 'assigned_sites'] - }, - { - 'id': 'system_bot', - 'name': 'System Bot', - 'description': 'System automation user', - 'permissions': ['automation_only'] - } - ] - return success_response(data={'groups': roles}, request=request) - - @action(detail=False, methods=['get'], url_path='permissions') - def permissions(self, request): - """Get permissions for a specific role.""" - role = request.query_params.get('role') - if not role: - return error_response( - error='role parameter is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - role_permissions = { - 'developer': ['full_access', 'bypass_filters', 'all_modules', 'all_accounts'], - 'owner': ['account_management', 'billing', 'automation', 'all_sites', 'user_management'], - 'admin': ['content_management', 'view_billing', 'all_sites', 'user_management'], - 'editor': ['ai_content', 'manage_clusters', 'manage_tasks', 'assigned_sites'], - 'viewer': ['read_only', 'assigned_sites'], - 'system_bot': ['automation_only'] - } - - permissions_list = role_permissions.get(role, []) - return success_response( - data={ - 'role': role, - 'permissions': permissions_list - }, - request=request - ) - - -# ============================================================================ -# 2. USERS - Manage global user records and credentials -# ============================================================================ - -@extend_schema_view( - list=extend_schema(tags=['Authentication']), - create=extend_schema(tags=['Authentication']), - retrieve=extend_schema(tags=['Authentication']), - update=extend_schema(tags=['Authentication']), - partial_update=extend_schema(tags=['Authentication']), - destroy=extend_schema(tags=['Authentication']), -) -class UsersViewSet(AccountModelViewSet): - """ - ViewSet for managing global user records and credentials. - Users are global, but belong to accounts. - Unified API Standard v1.0 compliant - """ - queryset = User.objects.all() - serializer_class = UserSerializer - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsOwnerOrAdmin] - pagination_class = CustomPageNumberPagination - throttle_scope = 'auth' - throttle_classes = [DebugScopedRateThrottle] - - def get_queryset(self): - """Return users based on access level.""" - user = self.request.user - if not user or not user.is_authenticated: - return User.objects.none() - - # Developers can see all users - if user.is_developer(): - return User.objects.all() - - # Owners/Admins can see users in their account - if user.role in ['owner', 'admin'] and user.account: - return User.objects.filter(account=user.account) - - # Others can only see themselves - return User.objects.filter(id=user.id) - - @action(detail=False, methods=['post']) - def create_user(self, request): - """Create a new user (separate from registration).""" - from django.contrib.auth.password_validation import validate_password - - email = request.data.get('email') - username = request.data.get('username') - password = request.data.get('password') - role = request.data.get('role', 'viewer') - account_id = request.data.get('account_id') - - if not email or not username or not password: - return error_response( - error='email, username, and password are required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Validate password - try: - validate_password(password) - except Exception as e: - return error_response( - error=str(e), - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get account - account = None - if account_id: - try: - account = Account.objects.get(id=account_id) - except Account.DoesNotExist: - return error_response( - error=f'Account with id {account_id} does not exist', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - else: - # Use current user's account - if request.user.account: - account = request.user.account - - # Create user - try: - user = User.objects.create_user( - username=username, - email=email, - password=password, - role=role, - account=account - ) - serializer = UserSerializer(user) - return success_response( - data={'user': serializer.data}, - status_code=status.HTTP_201_CREATED, - request=request - ) - except Exception as e: - return error_response( - error=str(e), - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - @action(detail=True, methods=['post']) - def update_role(self, request, pk=None): - """Update user role.""" - user = self.get_object() - new_role = request.data.get('role') - - if not new_role: - return error_response( - error='role is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - if new_role not in [choice[0] for choice in User.ROLE_CHOICES]: - return error_response( - error=f'Invalid role. Must be one of: {[c[0] for c in User.ROLE_CHOICES]}', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - user.role = new_role - user.save() - - serializer = UserSerializer(user) - return success_response(data={'user': serializer.data}, request=request) - - -# ============================================================================ -# 3. ACCOUNTS - Register each unique organization/user space -# ============================================================================ - -@extend_schema_view( - list=extend_schema(tags=['Authentication']), - create=extend_schema(tags=['Authentication']), - retrieve=extend_schema(tags=['Authentication']), - update=extend_schema(tags=['Authentication']), - partial_update=extend_schema(tags=['Authentication']), - destroy=extend_schema(tags=['Authentication']), -) -class AccountsViewSet(AccountModelViewSet): - """ - ViewSet for managing accounts (unique organization/user spaces). - Unified API Standard v1.0 compliant - """ - queryset = Account.objects.all() - serializer_class = AccountSerializer - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsOwnerOrAdmin] - pagination_class = CustomPageNumberPagination - throttle_scope = 'auth' - throttle_classes = [DebugScopedRateThrottle] - - def get_queryset(self): - """Return accounts based on access level.""" - user = self.request.user - if not user or not user.is_authenticated: - return Account.objects.none() - - # Developers can see all accounts - if user.is_developer(): - return Account.objects.all() - - # Owners can see their own accounts - if user.role == 'owner': - return Account.objects.filter(owner=user) - - # Admins can see their account - if user.role == 'admin' and user.account: - return Account.objects.filter(id=user.account.id) - - return Account.objects.none() - - def perform_create(self, serializer): - """Create account with owner.""" - user = self.request.user - - # plan_id is mapped to plan in serializer (source='plan') - plan = serializer.validated_data.get('plan') - - if not plan: - from rest_framework.exceptions import ValidationError - raise ValidationError("plan_id is required") - - # Set owner to current user if not provided - owner = serializer.validated_data.get('owner') - if not owner: - owner = user - - account = serializer.save(plan=plan, owner=owner) - return account - - - -# ============================================================================ -# 4. SUBSCRIPTIONS - Control plan level, limits, and billing per account -# ============================================================================ - -@extend_schema_view( - list=extend_schema(tags=['Authentication']), - create=extend_schema(tags=['Authentication']), - retrieve=extend_schema(tags=['Authentication']), - update=extend_schema(tags=['Authentication']), - partial_update=extend_schema(tags=['Authentication']), - destroy=extend_schema(tags=['Authentication']), -) -class SubscriptionsViewSet(AccountModelViewSet): - """ - ViewSet for managing subscriptions (plan level, limits, billing per account). - Unified API Standard v1.0 compliant - """ - queryset = Subscription.objects.all() - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsOwnerOrAdmin] - pagination_class = CustomPageNumberPagination - # Use relaxed auth throttle to avoid 429s during onboarding plan fetches - throttle_scope = 'auth_read' - throttle_classes = [DebugScopedRateThrottle] - - def get_queryset(self): - """Return subscriptions based on access level.""" - user = self.request.user - if not user or not user.is_authenticated: - return Subscription.objects.none() - - # Developers can see all subscriptions - if user.is_developer(): - return Subscription.objects.all() - - # Owners/Admins can see subscriptions for their account - if user.role in ['owner', 'admin'] and user.account: - return Subscription.objects.filter(account=user.account) - - return Subscription.objects.none() - - def get_serializer_class(self): - """Return appropriate serializer.""" - return SubscriptionSerializer - - @action(detail=False, methods=['get'], url_path='by-account/(?P[^/.]+)') - def by_account(self, request, account_id=None): - """Get subscription for a specific account.""" - try: - subscription = Subscription.objects.get(account_id=account_id) - serializer = self.get_serializer(subscription) - return success_response( - data={'subscription': serializer.data}, - request=request - ) - except Subscription.DoesNotExist: - return error_response( - error='Subscription not found for this account', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - -# ============================================================================ -# 5. SITE USER ACCESS - Assign users access to specific sites within account -# ============================================================================ - -@extend_schema_view( - list=extend_schema(tags=['Authentication']), - create=extend_schema(tags=['Authentication']), - retrieve=extend_schema(tags=['Authentication']), - update=extend_schema(tags=['Authentication']), - partial_update=extend_schema(tags=['Authentication']), - destroy=extend_schema(tags=['Authentication']), -) -class SiteUserAccessViewSet(AccountModelViewSet): - """ - ViewSet for managing Site-User access permissions. - Assign users access to specific sites within their account. - Unified API Standard v1.0 compliant - """ - serializer_class = SiteUserAccessSerializer - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsOwnerOrAdmin] - pagination_class = CustomPageNumberPagination - throttle_scope = 'auth' - throttle_classes = [DebugScopedRateThrottle] - - def get_queryset(self): - """Return access records for sites in user's account.""" - user = self.request.user - if not user or not user.is_authenticated: - return SiteUserAccess.objects.none() - - # Developers can see all access records - if user.is_developer(): - return SiteUserAccess.objects.all() - - if not user.account: - return SiteUserAccess.objects.none() - - # Return access records for sites in user's account - return SiteUserAccess.objects.filter(site__account=user.account) - - def perform_create(self, serializer): - """Create site user access with granted_by.""" - user = self.request.user - serializer.save(granted_by=user) - - -# ============================================================================ -# SUPPORTING VIEWSETS (Sites, Sectors, Industries, Plans, Auth) -# ============================================================================ - -@extend_schema_view( - list=extend_schema(tags=['Authentication']), - retrieve=extend_schema(tags=['Authentication']), -) -class PlanViewSet(viewsets.ReadOnlyModelViewSet): - """ - ViewSet for listing active subscription plans. - Unified API Standard v1.0 compliant - """ - queryset = Plan.objects.filter(is_active=True) - serializer_class = PlanSerializer - permission_classes = [permissions.AllowAny] - pagination_class = CustomPageNumberPagination - # Plans are public and should not throttle aggressively to avoid blocking signup/onboarding - throttle_scope = None - throttle_classes: list = [] - - def retrieve(self, request, *args, **kwargs): - """Override retrieve to return unified format""" - try: - instance = self.get_object() - serializer = self.get_serializer(instance) - return success_response(data=serializer.data, request=request) - except Exception as e: - return error_response( - error=str(e), - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - -@extend_schema_view( - list=extend_schema(tags=['Authentication']), - create=extend_schema(tags=['Authentication']), - retrieve=extend_schema(tags=['Authentication']), - update=extend_schema(tags=['Authentication']), - partial_update=extend_schema(tags=['Authentication']), - destroy=extend_schema(tags=['Authentication']), -) -class SiteViewSet(AccountModelViewSet): - """ViewSet for managing Sites.""" - serializer_class = SiteSerializer - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsEditorOrAbove] - authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] - - 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 - - account = getattr(user, 'account', None) - if not account: - return Site.objects.none() - - if hasattr(user, 'get_accessible_sites'): - return user.get_accessible_sites() - - return Site.objects.filter(account=account) - - def perform_create(self, serializer): - """Create site with account.""" - account = getattr(self.request, 'account', None) - if not account: - user = self.request.user - if user and user.is_authenticated: - account = getattr(user, 'account', None) - - # Multiple sites can be active simultaneously - no constraint - serializer.save(account=account) - - def perform_update(self, serializer): - """Update site.""" - account = getattr(self.request, 'account', None) - if not account: - account = getattr(serializer.instance, 'account', None) - - # Multiple sites can be active simultaneously - no constraint - serializer.save() - - @action(detail=True, methods=['get']) - def sectors(self, request, pk=None): - """Get all sectors for this site.""" - site = self.get_object() - sectors = site.sectors.filter(is_active=True) - serializer = SectorSerializer(sectors, many=True) - return success_response( - data=serializer.data, - request=request - ) - - @action(detail=True, methods=['post'], url_path='set_active') - def set_active(self, request, pk=None): - """Set this site as active (multiple sites can be active simultaneously).""" - site = self.get_object() - - # Simply activate this site - no need to deactivate others - site.is_active = True - site.status = 'active' - site.save() - - serializer = self.get_serializer(site) - return success_response( - data={'site': serializer.data}, - message=f'Site "{site.name}" is now active', - request=request - ) - - @action(detail=True, methods=['post'], url_path='select_sectors') - def select_sectors(self, request, pk=None): - """Select industry and sectors for this site.""" - import logging - logger = logging.getLogger(__name__) - - try: - site = self.get_object() - except Exception as e: - logger.error(f"Error getting site object: {str(e)}", exc_info=True) - return error_response( - error=f'Site not found: {str(e)}', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - sector_slugs = request.data.get('sector_slugs', []) - industry_slug = request.data.get('industry_slug') - - if not industry_slug: - return error_response( - error='Industry slug is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - try: - industry = Industry.objects.get(slug=industry_slug, is_active=True) - except Industry.DoesNotExist: - return error_response( - error=f'Industry with slug "{industry_slug}" not found', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - site.industry = industry - site.save() - - if not sector_slugs: - return success_response( - data={ - 'site': SiteSerializer(site).data, - 'sectors': [] - }, - message=f'Industry "{industry.name}" set for site. No sectors selected.', - request=request - ) - - # Get plan's max_industries limit (if set), otherwise default to 5 - max_sectors = site.get_max_sectors_limit() - - if len(sector_slugs) > max_sectors: - return error_response( - error=f'Maximum {max_sectors} sectors allowed per site for this plan', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - created_sectors = [] - updated_sectors = [] - - existing_sector_slugs = set(sector_slugs) - site.sectors.exclude(slug__in=existing_sector_slugs).update(is_active=False) - - industry_sectors_map = {} - for sector_slug in sector_slugs: - industry_sector = IndustrySector.objects.filter( - industry=industry, - slug=sector_slug, - is_active=True - ).first() - - if not industry_sector: - return error_response( - error=f'Sector "{sector_slug}" not found in industry "{industry.name}"', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - industry_sectors_map[sector_slug] = industry_sector - - for sector_slug, industry_sector in industry_sectors_map.items(): - try: - # Check if site has account before proceeding - if not site.account: - logger.error(f"Site {site.id} has no account assigned") - return error_response( - error=f'Site "{site.name}" has no account assigned. Please contact support.', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - # Create or get sector - account will be set automatically in save() method - # But we need to pass it in defaults for get_or_create to work - sector, created = Sector.objects.get_or_create( - site=site, - slug=sector_slug, - defaults={ - 'industry_sector': industry_sector, - 'name': industry_sector.name, - 'description': industry_sector.description or '', - 'is_active': True, - 'status': 'active', - 'account': site.account # Pass the account object, not the ID - } - ) - - if not created: - # Update existing sector - sector.industry_sector = industry_sector - sector.name = industry_sector.name - sector.description = industry_sector.description or '' - sector.is_active = True - sector.status = 'active' - # Ensure account is set (save() will also set it, but be explicit) - if not sector.account: - sector.account = site.account - sector.save() - updated_sectors.append(sector) - else: - created_sectors.append(sector) - except Exception as e: - logger.error(f"Error creating/updating sector {sector_slug}: {str(e)}", exc_info=True) - return error_response( - error=f'Failed to create/update sector "{sector_slug}": {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - # Get plan's max_industries limit (if set), otherwise default to 5 - max_sectors = site.get_max_sectors_limit() - - if site.get_active_sectors_count() > max_sectors: - return error_response( - error=f'Maximum {max_sectors} sectors allowed per site for this plan', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - serializer = SectorSerializer(site.sectors.filter(is_active=True), many=True) - return success_response( - data={ - 'created_count': len(created_sectors), - 'updated_count': len(updated_sectors), - 'sectors': serializer.data, - 'site': SiteSerializer(site).data - }, - message=f'Selected {len(sector_slugs)} sectors from industry "{industry.name}".', - request=request - ) - - -@extend_schema_view( - list=extend_schema(tags=['Authentication']), - create=extend_schema(tags=['Authentication']), - retrieve=extend_schema(tags=['Authentication']), - update=extend_schema(tags=['Authentication']), - partial_update=extend_schema(tags=['Authentication']), - destroy=extend_schema(tags=['Authentication']), -) -class SectorViewSet(AccountModelViewSet): - """ViewSet for managing Sectors.""" - serializer_class = SectorSerializer - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsEditorOrAbove] - authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] - - def get_queryset(self): - """Return sectors from sites accessible to the current user.""" - user = self.request.user - if not user or not user.is_authenticated: - return Sector.objects.none() - accessible_sites = user.get_accessible_sites() - return Sector.objects.filter(site__in=accessible_sites) - - def get_queryset_with_site_filter(self): - """Get queryset, optionally filtered by site_id.""" - queryset = self.get_queryset() - site_id = self.request.query_params.get('site_id') - if site_id: - queryset = queryset.filter(site_id=site_id) - return queryset - - def list(self, request, *args, **kwargs): - """Override list to apply site filter.""" - queryset = self.get_queryset_with_site_filter() - serializer = self.get_serializer(queryset, many=True) - return success_response( - data=serializer.data, - request=request - ) - - -@extend_schema_view( - list=extend_schema(tags=['Authentication']), - retrieve=extend_schema(tags=['Authentication']), -) -class IndustryViewSet(viewsets.ReadOnlyModelViewSet): - """ - ViewSet for industry templates. - Unified API Standard v1.0 compliant - """ - queryset = Industry.objects.filter(is_active=True).prefetch_related('sectors') - serializer_class = IndustrySerializer - permission_classes = [permissions.AllowAny] - pagination_class = CustomPageNumberPagination - throttle_scope = 'auth' - throttle_classes = [DebugScopedRateThrottle] - - def list(self, request): - """Get all industries with their sectors.""" - industries = self.get_queryset() - serializer = self.get_serializer(industries, many=True) - return success_response( - data={'industries': serializer.data}, - request=request - ) - - def retrieve(self, request, *args, **kwargs): - """Override retrieve to return unified format""" - try: - instance = self.get_object() - serializer = self.get_serializer(instance) - return success_response(data=serializer.data, request=request) - except Exception as e: - return error_response( - error=str(e), - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - -@extend_schema_view( - list=extend_schema(tags=['Authentication']), - retrieve=extend_schema(tags=['Authentication']), -) -class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): - """ - ViewSet for SeedKeyword - Global reference data (read-only for non-admins). - Unified API Standard v1.0 compliant - """ - queryset = SeedKeyword.objects.filter(is_active=True).select_related('industry', 'sector') - serializer_class = SeedKeywordSerializer - permission_classes = [permissions.AllowAny] # Read-only, allow any authenticated user - pagination_class = CustomPageNumberPagination - throttle_scope = 'auth' - throttle_classes = [DebugScopedRateThrottle] - - filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend] - search_fields = ['keyword'] - ordering_fields = ['keyword', 'volume', 'difficulty', 'created_at'] - ordering = ['keyword'] - filterset_fields = ['industry', 'sector', 'intent', 'is_active'] - - def retrieve(self, request, *args, **kwargs): - """Override retrieve to return unified format""" - try: - instance = self.get_object() - serializer = self.get_serializer(instance) - return success_response(data=serializer.data, request=request) - except Exception as e: - return error_response( - error=str(e), - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - def get_queryset(self): - """Filter by industry and sector if provided.""" - queryset = super().get_queryset() - industry_id = self.request.query_params.get('industry_id') - industry_name = self.request.query_params.get('industry_name') - sector_id = self.request.query_params.get('sector_id') - sector_name = self.request.query_params.get('sector_name') - - if industry_id: - queryset = queryset.filter(industry_id=industry_id) - if industry_name: - queryset = queryset.filter(industry__name__icontains=industry_name) - if sector_id: - queryset = queryset.filter(sector_id=sector_id) - if sector_name: - queryset = queryset.filter(sector__name__icontains=sector_name) - - return queryset - - @action(detail=False, methods=['post'], url_path='import_seed_keywords', url_name='import_seed_keywords') - def import_seed_keywords(self, request): - """ - Import seed keywords from CSV (Admin/Superuser only). - Expected columns: keyword, industry_name, sector_name, volume, difficulty, intent - """ - import csv - from django.db import transaction - - # Check admin/superuser permission - if not (request.user.is_staff or request.user.is_superuser): - return error_response( - error='Admin or superuser access required', - status_code=status.HTTP_403_FORBIDDEN, - request=request - ) - - if 'file' not in request.FILES: - return error_response( - error='No file provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - file = request.FILES['file'] - if not file.name.endswith('.csv'): - return error_response( - error='File must be a CSV', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - try: - # Parse CSV - decoded_file = file.read().decode('utf-8') - csv_reader = csv.DictReader(decoded_file.splitlines()) - - imported_count = 0 - skipped_count = 0 - errors = [] - - with transaction.atomic(): - for row_num, row in enumerate(csv_reader, start=2): # Start at 2 (header is row 1) - try: - keyword_text = row.get('keyword', '').strip() - industry_name = row.get('industry_name', '').strip() - sector_name = row.get('sector_name', '').strip() - - if not all([keyword_text, industry_name, sector_name]): - skipped_count += 1 - continue - - # Get or create industry - industry = Industry.objects.filter(name=industry_name).first() - if not industry: - errors.append(f"Row {row_num}: Industry '{industry_name}' not found") - skipped_count += 1 - continue - - # Get or create industry sector - sector = IndustrySector.objects.filter( - industry=industry, - name=sector_name - ).first() - if not sector: - errors.append(f"Row {row_num}: Sector '{sector_name}' not found for industry '{industry_name}'") - skipped_count += 1 - continue - - # Check if keyword already exists - existing = SeedKeyword.objects.filter( - keyword=keyword_text, - industry=industry, - sector=sector - ).first() - - if existing: - skipped_count += 1 - continue - - # Create seed keyword - SeedKeyword.objects.create( - keyword=keyword_text, - industry=industry, - sector=sector, - volume=int(row.get('volume', 0) or 0), - difficulty=int(row.get('difficulty', 0) or 0), - intent=row.get('intent', 'informational') or 'informational', - is_active=True - ) - imported_count += 1 - - except Exception as e: - errors.append(f"Row {row_num}: {str(e)}") - skipped_count += 1 - - return success_response( - data={ - 'imported': imported_count, - 'skipped': skipped_count, - 'errors': errors[:10] if errors else [] # Limit errors to first 10 - }, - message=f'Import completed: {imported_count} keywords imported, {skipped_count} skipped', - request=request - ) - - except Exception as e: - return error_response( - error=f'Failed to import keywords: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - -# ============================================================================ -# AUTHENTICATION ENDPOINTS (Register, Login, Change Password, Me) -# ============================================================================ - -@extend_schema_view( - register=extend_schema(tags=['Authentication']), - login=extend_schema(tags=['Authentication']), - change_password=extend_schema(tags=['Authentication']), - refresh_token=extend_schema(tags=['Authentication']), -) -class AuthViewSet(viewsets.GenericViewSet): - """Authentication endpoints. - Unified API Standard v1.0 compliant - """ - permission_classes = [permissions.AllowAny] - throttle_scope = 'auth_strict' - throttle_classes = [DebugScopedRateThrottle] - - @action(detail=False, methods=['post']) - def register(self, request): - """User registration endpoint.""" - serializer = RegisterSerializer(data=request.data) - if serializer.is_valid(): - user = serializer.save() - - # 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) - access_expires_at = get_token_expiry('access') - refresh_expires_at = get_token_expiry('refresh') - - user_serializer = UserSerializer(user) - return success_response( - data={ - 'user': user_serializer.data, - 'tokens': { - 'access': access_token, - 'refresh': refresh_token, - 'access_expires_at': access_expires_at.isoformat(), - 'refresh_expires_at': refresh_expires_at.isoformat(), - } - }, - message='Registration successful', - status_code=status.HTTP_201_CREATED, - request=request - ) - return error_response( - error='Validation failed', - errors=serializer.errors, - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - @action(detail=False, methods=['post']) - def login(self, request): - """User login endpoint.""" - serializer = LoginSerializer(data=request.data) - if serializer.is_valid(): - email = serializer.validated_data['email'] - password = serializer.validated_data['password'] - - try: - user = User.objects.select_related('account', 'account__plan').get(email=email) - except User.DoesNotExist: - return error_response( - error='Invalid credentials', - status_code=status.HTTP_401_UNAUTHORIZED, - request=request - ) - - 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) - - # Generate JWT tokens - access_token = generate_access_token(user, account) - refresh_token = generate_refresh_token(user, account) - access_expires_at = get_token_expiry('access') - refresh_expires_at = get_token_expiry('refresh') - - user_serializer = UserSerializer(user) - 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(), - }, - message='Login successful', - request=request - ) - - return error_response( - error='Invalid credentials', - status_code=status.HTTP_401_UNAUTHORIZED, - request=request - ) - - return error_response( - error='Validation failed', - errors=serializer.errors, - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - @action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated]) - def change_password(self, request): - """Change password endpoint.""" - serializer = ChangePasswordSerializer(data=request.data, context={'request': request}) - if serializer.is_valid(): - user = request.user - if not user.check_password(serializer.validated_data['old_password']): - return error_response( - error='Current password is incorrect', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - user.set_password(serializer.validated_data['new_password']) - user.save() - - return success_response( - message='Password changed successfully', - request=request - ) - - return error_response( - error='Validation failed', - errors=serializer.errors, - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) - def me(self, request): - """Get current user information.""" - # Refresh user from DB to get latest account/plan data - # This ensures account/plan changes are reflected immediately - user = User.objects.select_related('account', 'account__plan').get(id=request.user.id) - serializer = UserSerializer(user) - return success_response( - data={'user': serializer.data}, - request=request - ) - - @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) - def refresh(self, request): - """Refresh access token using refresh token.""" - 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.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_id = payload.get('account_id') - account = None - if account_id: - try: - account = Account.objects.get(id=account_id) - except Account.DoesNotExist: - 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 as e: - return error_response( - error='Invalid or expired refresh token', - status_code=status.HTTP_401_UNAUTHORIZED, - request=request - ) - - @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) - def request_reset(self, request): - """Request password reset - sends email with reset token.""" - serializer = RequestPasswordResetSerializer(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 - ) - - email = serializer.validated_data['email'] - - try: - user = User.objects.get(email=email) - except User.DoesNotExist: - # Don't reveal if email exists - return success anyway - return success_response( - message='If an account with that email exists, a password reset link has been sent.', - request=request - ) - - # Generate secure token - import secrets - token = secrets.token_urlsafe(32) - - # Create reset token (expires in 1 hour) - from django.utils import timezone - from datetime import timedelta - expires_at = timezone.now() + timedelta(hours=1) - - PasswordResetToken.objects.create( - user=user, - token=token, - expires_at=expires_at - ) - - # Send email (async via Celery if available, otherwise sync) - try: - from igny8_core.modules.system.tasks import send_password_reset_email - send_password_reset_email.delay(user.id, token) - except: - # Fallback to sync email sending - from django.core.mail import send_mail - from django.conf import settings - - reset_url = f"{request.scheme}://{request.get_host()}/reset-password?token={token}" - - send_mail( - subject='Reset Your IGNY8 Password', - message=f'Click the following link to reset your password: {reset_url}\n\nThis link expires in 1 hour.', - from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@igny8.com'), - recipient_list=[user.email], - fail_silently=False, - ) - - return success_response( - message='If an account with that email exists, a password reset link has been sent.', - request=request - ) - - @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) - def reset_password(self, request): - """Reset password using reset token.""" - serializer = ResetPasswordSerializer(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 - ) - - token = serializer.validated_data['token'] - new_password = serializer.validated_data['new_password'] - - try: - reset_token = PasswordResetToken.objects.get(token=token) - except PasswordResetToken.DoesNotExist: - return error_response( - error='Invalid reset token', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Check if token is valid - if not reset_token.is_valid(): - return error_response( - error='Reset token has expired or has already been used', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Update password - user = reset_token.user - user.set_password(new_password) - user.save() - - # Mark token as used - reset_token.used = True - reset_token.save() - - return success_response( - message='Password has been reset successfully', - request=request - ) - - -# ============================================================================ -# CSV Import/Export Views for Admin -# ============================================================================ - -from django.http import HttpResponse, JsonResponse -from django.contrib.admin.views.decorators import staff_member_required -from django.views.decorators.http import require_http_methods -import csv -import io - - -@staff_member_required -@require_http_methods(["GET"]) -def industry_csv_template(request): - """Download CSV template for Industry import""" - response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = 'attachment; filename="industry_template.csv"' - - writer = csv.writer(response) - writer.writerow(['name', 'description', 'is_active']) - writer.writerow(['Technology', 'Technology industry', 'true']) - writer.writerow(['Healthcare', 'Healthcare and medical services', 'true']) - - return response - - -@staff_member_required -@require_http_methods(["POST"]) -def industry_csv_import(request): - """Import industries from CSV""" - if not request.FILES.get('csv_file'): - return JsonResponse({'success': False, 'error': 'No CSV file provided'}, status=400) - - csv_file = request.FILES['csv_file'] - decoded_file = csv_file.read().decode('utf-8') - io_string = io.StringIO(decoded_file) - reader = csv.DictReader(io_string) - - created = 0 - updated = 0 - errors = [] - - from django.utils.text import slugify - - for row_num, row in enumerate(reader, start=2): - try: - is_active = row.get('is_active', 'true').lower() in ['true', '1', 'yes'] - slug = slugify(row['name']) - - industry, created_flag = Industry.objects.update_or_create( - name=row['name'], - defaults={ - 'slug': slug, - 'description': row.get('description', ''), - 'is_active': is_active - } - ) - if created_flag: - created += 1 - else: - updated += 1 - except Exception as e: - errors.append(f"Row {row_num}: {str(e)}") - - return JsonResponse({ - 'success': True, - 'created': created, - 'updated': updated, - 'errors': errors - }) - - -@staff_member_required -@require_http_methods(["GET"]) -def industrysector_csv_template(request): - """Download CSV template for IndustrySector import""" - response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = 'attachment; filename="industrysector_template.csv"' - - writer = csv.writer(response) - writer.writerow(['name', 'industry', 'description', 'is_active']) - writer.writerow(['Software Development', 'Technology', 'Software and app development', 'true']) - writer.writerow(['Healthcare IT', 'Healthcare', 'Healthcare information technology', 'true']) - - return response - - -@staff_member_required -@require_http_methods(["POST"]) -def industrysector_csv_import(request): - """Import industry sectors from CSV""" - if not request.FILES.get('csv_file'): - return JsonResponse({'success': False, 'error': 'No CSV file provided'}, status=400) - - csv_file = request.FILES['csv_file'] - decoded_file = csv_file.read().decode('utf-8') - io_string = io.StringIO(decoded_file) - reader = csv.DictReader(io_string) - - created = 0 - updated = 0 - errors = [] - - from django.utils.text import slugify - - for row_num, row in enumerate(reader, start=2): - try: - is_active = row.get('is_active', 'true').lower() in ['true', '1', 'yes'] - slug = slugify(row['name']) - - # Find industry by name - try: - industry = Industry.objects.get(name=row['industry']) - except Industry.DoesNotExist: - errors.append(f"Row {row_num}: Industry '{row['industry']}' not found") - continue - - sector, created_flag = IndustrySector.objects.update_or_create( - name=row['name'], - industry=industry, - defaults={ - 'slug': slug, - 'description': row.get('description', ''), - 'is_active': is_active - } - ) - if created_flag: - created += 1 - else: - updated += 1 - except Exception as e: - errors.append(f"Row {row_num}: {str(e)}") - - return JsonResponse({ - 'success': True, - 'created': created, - 'updated': updated, - 'errors': errors - }) - - -@staff_member_required -@require_http_methods(["GET"]) -def seedkeyword_csv_template(request): - """Download CSV template for SeedKeyword import""" - response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = 'attachment; filename="seedkeyword_template.csv"' - - writer = csv.writer(response) - writer.writerow(['keyword', 'industry', 'sector', 'volume', 'difficulty', 'intent', 'is_active']) - writer.writerow(['python programming', 'Technology', 'Software Development', '10000', '45', 'Informational', 'true']) - writer.writerow(['medical software', 'Healthcare', 'Healthcare IT', '5000', '60', 'Commercial', 'true']) - - return response - - -@staff_member_required -@require_http_methods(["POST"]) -def seedkeyword_csv_import(request): - """Import seed keywords from CSV""" - if not request.FILES.get('csv_file'): - return JsonResponse({'success': False, 'error': 'No CSV file provided'}, status=400) - - csv_file = request.FILES['csv_file'] - decoded_file = csv_file.read().decode('utf-8') - io_string = io.StringIO(decoded_file) - reader = csv.DictReader(io_string) - - created = 0 - updated = 0 - errors = [] - - for row_num, row in enumerate(reader, start=2): - try: - is_active = row.get('is_active', 'true').lower() in ['true', '1', 'yes'] - - # Find industry and sector by name - try: - industry = Industry.objects.get(name=row['industry']) - except Industry.DoesNotExist: - errors.append(f"Row {row_num}: Industry '{row['industry']}' not found") - continue - - try: - sector = IndustrySector.objects.get(name=row['sector'], industry=industry) - except IndustrySector.DoesNotExist: - errors.append(f"Row {row_num}: Sector '{row['sector']}' not found in industry '{row['industry']}'") - continue - - keyword, created_flag = SeedKeyword.objects.update_or_create( - keyword=row['keyword'], - industry=industry, - sector=sector, - defaults={ - 'volume': int(row.get('volume', 0)), - 'difficulty': int(row.get('difficulty', 0)), - 'intent': row.get('intent', 'Informational'), - 'is_active': is_active - } - ) - if created_flag: - created += 1 - else: - updated += 1 - except Exception as e: - errors.append(f"Row {row_num}: {str(e)}") - - return JsonResponse({ - 'success': True, - 'created': created, - 'updated': updated, - 'errors': errors - }) - diff --git a/tenant-temp/backend/igny8_core/middleware/request_id.py b/tenant-temp/backend/igny8_core/middleware/request_id.py deleted file mode 100644 index 9494eee8..00000000 --- a/tenant-temp/backend/igny8_core/middleware/request_id.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Request ID Middleware -Generates unique request ID for every request and includes it in response headers -""" -import uuid -import logging -from django.utils.deprecation import MiddlewareMixin - -logger = logging.getLogger(__name__) - - -class RequestIDMiddleware(MiddlewareMixin): - """ - Middleware that generates a unique request ID for every request - and includes it in response headers as X-Request-ID - """ - - def process_request(self, request): - """Generate or retrieve request ID""" - # Check if request ID already exists in headers - request_id = request.META.get('HTTP_X_REQUEST_ID') or request.META.get('X-Request-ID') - - if not request_id: - # Generate new request ID - request_id = str(uuid.uuid4()) - - # Store in request for use in views/exception handlers - request.request_id = request_id - - return None - - def process_response(self, request, response): - """Add request ID to response headers""" - # Get request ID from request - request_id = getattr(request, 'request_id', None) - - if request_id: - # Add to response headers - response['X-Request-ID'] = request_id - - return response - - diff --git a/tenant-temp/backend/igny8_core/middleware/resource_tracker.py b/tenant-temp/backend/igny8_core/middleware/resource_tracker.py deleted file mode 100644 index ce7b07be..00000000 --- a/tenant-temp/backend/igny8_core/middleware/resource_tracker.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -Resource Tracking Middleware -Tracks CPU, memory, and I/O usage per request for admin debugging. -""" -import psutil -import time -import threading -import logging -from django.utils.deprecation import MiddlewareMixin -from django.core.cache import cache - -logger = logging.getLogger(__name__) - - -class ResourceTrackingMiddleware(MiddlewareMixin): - """ - Middleware to track resource usage per request. - Stores metrics in cache with request ID for retrieval. - Only tracks for authenticated admin/developer users. - """ - thread_local = threading.local() - - def process_request(self, request): - """Start tracking resources for this request""" - # Only track if user is authenticated - if not hasattr(request, 'user') or not request.user.is_authenticated: - return None - - # Check if user is admin/developer - if not (hasattr(request.user, 'is_admin_or_developer') and request.user.is_admin_or_developer()): - return None - - # Check if debug tracking is enabled via header (set by frontend) - debug_enabled = request.headers.get('X-Debug-Resource-Tracking', '').lower() == 'true' - if not debug_enabled: - return None - - try: - # Generate request ID - request_id = f"req_{int(time.time() * 1000000)}" - request.resource_tracking_id = request_id - - # Get initial process stats - process = psutil.Process() - initial_cpu_times = process.cpu_times() - initial_memory = process.memory_info() - initial_io = process.io_counters() if hasattr(process, 'io_counters') else None - - # Store initial state - self.thread_local.start_time = time.time() - self.thread_local.initial_cpu_times = initial_cpu_times - self.thread_local.initial_memory = initial_memory - self.thread_local.initial_io = initial_io - self.thread_local.process = process - self.thread_local.request_id = request_id - - except Exception as e: - logger.warning(f"Error starting resource tracking: {str(e)}") - # Don't break the request if tracking fails - - return None - - def process_response(self, request, response): - """Calculate and store resource usage for this request""" - if not hasattr(request, 'resource_tracking_id'): - return response - - try: - # Calculate elapsed time - elapsed_time = time.time() - self.thread_local.start_time - - # Get final process stats - process = self.thread_local.process - final_cpu_times = process.cpu_times() - final_memory = process.memory_info() - final_io = process.io_counters() if hasattr(process, 'io_counters') else None - - # Calculate CPU usage (user + system time) - cpu_user_time = (final_cpu_times.user - self.thread_local.initial_cpu_times.user) * 1000 # ms - cpu_system_time = (final_cpu_times.system - self.thread_local.initial_cpu_times.system) * 1000 # ms - cpu_total_time = cpu_user_time + cpu_system_time - - # Calculate memory delta - memory_delta = final_memory.rss - self.thread_local.initial_memory.rss - - # Calculate I/O - io_read = 0 - io_write = 0 - if final_io and self.thread_local.initial_io: - io_read = final_io.read_bytes - self.thread_local.initial_io.read_bytes - io_write = final_io.write_bytes - self.thread_local.initial_io.write_bytes - - # Get system-wide stats - cpu_percent = psutil.cpu_percent(interval=0.1) - memory = psutil.virtual_memory() - - # Store metrics in cache (expire after 5 minutes) - metrics = { - 'request_id': request.resource_tracking_id, - 'path': request.path, - 'method': request.method, - 'elapsed_time_ms': round(elapsed_time * 1000, 2), - 'cpu': { - 'user_time_ms': round(cpu_user_time, 2), - 'system_time_ms': round(cpu_system_time, 2), - 'total_time_ms': round(cpu_total_time, 2), - 'system_percent': round(cpu_percent, 2), - }, - 'memory': { - 'delta_bytes': memory_delta, - 'delta_mb': round(memory_delta / (1024**2), 2), - 'final_rss_mb': round(final_memory.rss / (1024**2), 2), - 'system_used_percent': round(memory.percent, 2), - }, - 'io': { - 'read_bytes': io_read, - 'read_mb': round(io_read / (1024**2), 2), - 'write_bytes': io_write, - 'write_mb': round(io_write / (1024**2), 2), - }, - 'timestamp': time.time(), - } - - # Store in cache with 5 minute expiry - cache.set(f"resource_tracking_{request.resource_tracking_id}", metrics, 300) - - # Add request ID to response header - response['X-Resource-Tracking-ID'] = request.resource_tracking_id - - except Exception as e: - logger.warning(f"Error calculating resource tracking: {str(e)}") - # Don't break the response if tracking fails - - return response - diff --git a/tenant-temp/backend/igny8_core/modules/billing/views.py b/tenant-temp/backend/igny8_core/modules/billing/views.py deleted file mode 100644 index acaa2ea2..00000000 --- a/tenant-temp/backend/igny8_core/modules/billing/views.py +++ /dev/null @@ -1,581 +0,0 @@ -""" -ViewSets for Billing API -Unified API Standard v1.0 compliant -""" -from rest_framework import viewsets, status, permissions -from rest_framework.decorators import action -from rest_framework.response import Response -from django.db.models import Sum, Count, Q -from django.utils import timezone -from datetime import timedelta -from decimal import Decimal -from drf_spectacular.utils import extend_schema, extend_schema_view -from igny8_core.api.base import AccountModelViewSet -from igny8_core.api.pagination import CustomPageNumberPagination -from igny8_core.api.response import success_response, error_response -from igny8_core.api.throttles import DebugScopedRateThrottle -from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication -from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner -from .models import CreditTransaction, CreditUsageLog -from .serializers import ( - CreditTransactionSerializer, CreditUsageLogSerializer, - CreditBalanceSerializer, UsageSummarySerializer, UsageLimitsSerializer -) -from .services import CreditService -from .exceptions import InsufficientCreditsError - - -@extend_schema_view( - list=extend_schema(tags=['Billing'], summary='Get credit balance'), -) -class CreditBalanceViewSet(viewsets.ViewSet): - """ - ViewSet for credit balance operations - Unified API Standard v1.0 compliant - """ - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] - authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] - throttle_scope = 'billing' - throttle_classes = [DebugScopedRateThrottle] - - def list(self, request): - """Get current credit balance and usage""" - account = getattr(request, 'account', None) - if not account: - user = getattr(request, 'user', None) - if user and user.is_authenticated: - from igny8_core.auth.models import User as UserModel - user = UserModel.objects.select_related('account', 'account__plan').get(id=user.id) - account = user.account - request.account = account - - if not account: - return success_response(data={ - 'credits': 0, - 'plan_credits_per_month': 0, - 'credits_used_this_month': 0, - 'credits_remaining': 0, - }, request=request) - - # Get plan credits - plan is already associated - plan_credits_per_month = 0 - if account.plan: - plan_credits_per_month = account.plan.get_effective_credits_per_month() - - # Calculate credits used this month - now = timezone.now() - start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - credits_used_this_month = CreditUsageLog.objects.filter( - account=account, - created_at__gte=start_of_month - ).aggregate(total=Sum('credits_used'))['total'] or 0 - - credits = account.credits or 0 - credits_remaining = credits - - data = { - 'credits': credits, - 'plan_credits_per_month': plan_credits_per_month, - 'credits_used_this_month': credits_used_this_month, - 'credits_remaining': credits_remaining, - } - - # Validate and serialize data - serializer = CreditBalanceSerializer(data=data) - serializer.is_valid(raise_exception=True) - return success_response(data=serializer.validated_data, request=request) - - -@extend_schema_view( - list=extend_schema(tags=['Billing']), - retrieve=extend_schema(tags=['Billing']), -) -class CreditUsageViewSet(AccountModelViewSet): - """ - ViewSet for credit usage logs - Unified API Standard v1.0 compliant - """ - queryset = CreditUsageLog.objects.all() - serializer_class = CreditUsageLogSerializer - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] - authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] - pagination_class = CustomPageNumberPagination - throttle_scope = 'billing' - throttle_classes = [DebugScopedRateThrottle] - - filter_backends = [] - - def get_queryset(self): - """Get usage logs for current account - base class handles account filtering""" - queryset = super().get_queryset() - - # Filter by operation type - operation_type = self.request.query_params.get('operation_type') - if operation_type: - queryset = queryset.filter(operation_type=operation_type) - - # Filter by date range - start_date = self.request.query_params.get('start_date') - end_date = self.request.query_params.get('end_date') - if start_date: - queryset = queryset.filter(created_at__gte=start_date) - if end_date: - queryset = queryset.filter(created_at__lte=end_date) - - return queryset.order_by('-created_at') - - @extend_schema(tags=['Billing'], summary='Get usage summary') - @action(detail=False, methods=['get']) - def summary(self, request): - """Get usage summary for date range""" - account = getattr(request, 'account', None) - if not account: - user = getattr(request, 'user', None) - if user: - account = getattr(user, 'account', None) - - if not account: - return error_response( - error='Account not found', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get date range from query params - start_date = request.query_params.get('start_date') - end_date = request.query_params.get('end_date') - - # Default to current month if not provided - now = timezone.now() - if not start_date: - start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - else: - from django.utils.dateparse import parse_datetime - start_date = parse_datetime(start_date) or start_date - - if not end_date: - end_date = now - else: - from django.utils.dateparse import parse_datetime - end_date = parse_datetime(end_date) or end_date - - # Get usage logs in date range - usage_logs = CreditUsageLog.objects.filter( - account=account, - created_at__gte=start_date, - created_at__lte=end_date - ) - - # Calculate totals - total_credits_used = usage_logs.aggregate(total=Sum('credits_used'))['total'] or 0 - total_cost_usd = usage_logs.aggregate(total=Sum('cost_usd'))['total'] or Decimal('0.00') - - # Group by operation type - by_operation = {} - for operation_type, _ in CreditUsageLog.OPERATION_TYPE_CHOICES: - operation_logs = usage_logs.filter(operation_type=operation_type) - credits = operation_logs.aggregate(total=Sum('credits_used'))['total'] or 0 - cost = operation_logs.aggregate(total=Sum('cost_usd'))['total'] or Decimal('0.00') - count = operation_logs.count() - - if credits > 0 or count > 0: - by_operation[operation_type] = { - 'credits': credits, - 'cost': float(cost), - 'count': count - } - - # Group by model - by_model = {} - model_stats = usage_logs.values('model_used').annotate( - credits=Sum('credits_used'), - cost=Sum('cost_usd'), - count=Count('id') - ).filter(model_used__isnull=False).exclude(model_used='') - - for stat in model_stats: - model = stat['model_used'] - by_model[model] = { - 'credits': stat['credits'] or 0, - 'cost': float(stat['cost'] or Decimal('0.00')) - } - - data = { - 'period': { - 'start': start_date.isoformat() if hasattr(start_date, 'isoformat') else str(start_date), - 'end': end_date.isoformat() if hasattr(end_date, 'isoformat') else str(end_date), - }, - 'total_credits_used': total_credits_used, - 'total_cost_usd': float(total_cost_usd), - 'by_operation': by_operation, - 'by_model': by_model, - } - - serializer = UsageSummarySerializer(data) - return success_response(data=serializer.data, request=request) - - @extend_schema(tags=['Billing'], summary='Get usage limits') - @action(detail=False, methods=['get'], url_path='limits', url_name='limits') - def limits(self, request): - """ - Get account limits and credit usage statistics (Phase 0: Credit-only system). - Returns account management limits and credit usage only. - """ - # Try multiple ways to get account - account = getattr(request, 'account', None) - - if not account: - user = getattr(request, 'user', None) - if user and user.is_authenticated: - # Try to get account from user - refresh from DB to ensure we have latest - try: - from igny8_core.auth.models import User as UserModel - # Refresh user from DB to get account relationship - user = UserModel.objects.select_related('account', 'account__plan').get(id=user.id) - account = user.account - # Also set it on request for future use - request.account = account - except (AttributeError, UserModel.DoesNotExist, Exception) as e: - account = None - - if not account: - # Return empty limits instead of error - frontend will show "no data" message - return success_response(data={'limits': []}, request=request) - - plan = account.plan - if not plan: - # Return empty limits instead of error - allows frontend to show "no plan" message - return success_response(data={'limits': []}, request=request) - - # Import models - from igny8_core.auth.models import User, Site - - # Get current month boundaries - now = timezone.now() - start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - - # Calculate usage statistics - limits_data = [] - - # Credit Usage (Phase 0: Credit-only system) - credits_used_month = CreditUsageLog.objects.filter( - account=account, - created_at__gte=start_of_month - ).aggregate(total=Sum('credits_used'))['total'] or 0 - - # Get credits by operation type - cluster_credits = CreditUsageLog.objects.filter( - account=account, - operation_type__in=['clustering'], - created_at__gte=start_of_month - ).aggregate(total=Sum('credits_used'))['total'] or 0 - - content_credits = CreditUsageLog.objects.filter( - account=account, - operation_type__in=['content', 'content_generation'], - created_at__gte=start_of_month - ).aggregate(total=Sum('credits_used'))['total'] or 0 - - image_credits = CreditUsageLog.objects.filter( - account=account, - operation_type__in=['images', 'image_generation', 'image_prompt_extraction'], - created_at__gte=start_of_month - ).aggregate(total=Sum('credits_used'))['total'] or 0 - - idea_credits = CreditUsageLog.objects.filter( - account=account, - operation_type__in=['ideas', 'idea_generation'], - created_at__gte=start_of_month - ).aggregate(total=Sum('credits_used'))['total'] or 0 - - # Use included_credits from plan (Phase 0: Credit-only) - plan_credits = plan.included_credits or plan.credits_per_month or 0 - - limits_data.extend([ - { - 'title': 'Monthly Credits', - 'limit': plan_credits, - 'used': credits_used_month, - 'available': max(0, plan_credits - credits_used_month), - 'unit': 'credits', - 'category': 'credits', - 'percentage': (credits_used_month / plan_credits * 100) if plan_credits else 0 - }, - { - 'title': 'Current Balance', - 'limit': None, # No limit - shows current balance - 'used': None, - 'available': account.credits, - 'unit': 'credits', - 'category': 'credits', - 'percentage': None - }, - { - 'title': 'Clustering Credits', - 'limit': None, - 'used': cluster_credits, - 'available': None, - 'unit': 'credits', - 'category': 'credits', - 'percentage': None - }, - { - 'title': 'Content Generation Credits', - 'limit': None, - 'used': content_credits, - 'available': None, - 'unit': 'credits', - 'category': 'credits', - 'percentage': None - }, - { - 'title': 'Image Generation Credits', - 'limit': None, - 'used': image_credits, - 'available': None, - 'unit': 'credits', - 'category': 'credits', - 'percentage': None - }, - { - 'title': 'Idea Generation Credits', - 'limit': None, - 'used': idea_credits, - 'available': None, - 'unit': 'credits', - 'category': 'credits', - 'percentage': None - }, - ]) - - # Account Management Limits (kept - not operation limits) - users_count = User.objects.filter(account=account).count() - sites_count = Site.objects.filter(account=account).count() - - limits_data.extend([ - { - 'title': 'Users', - 'limit': plan.max_users or 0, - 'used': users_count, - 'available': max(0, (plan.max_users or 0) - users_count), - 'unit': 'users', - 'category': 'account', - 'percentage': (users_count / (plan.max_users or 1)) * 100 if plan.max_users else 0 - }, - { - 'title': 'Sites', - 'limit': plan.max_sites or 0, - 'used': sites_count, - 'available': max(0, (plan.max_sites or 0) - sites_count), - 'unit': 'sites', - 'category': 'account', - 'percentage': (sites_count / (plan.max_sites or 1)) * 100 if plan.max_sites else 0 - }, - ]) - - # Return data directly - serializer validation not needed for read-only endpoint - return success_response(data={'limits': limits_data}, request=request) - - -@extend_schema_view( - list=extend_schema(tags=['Billing']), - retrieve=extend_schema(tags=['Billing']), -) -class CreditTransactionViewSet(AccountModelViewSet): - """ - ViewSet for credit transaction history - Unified API Standard v1.0 compliant - """ - queryset = CreditTransaction.objects.all() - serializer_class = CreditTransactionSerializer - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner] - authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] - pagination_class = CustomPageNumberPagination - throttle_scope = 'billing' - throttle_classes = [DebugScopedRateThrottle] - - def get_queryset(self): - """Get transactions for current account - base class handles account filtering""" - queryset = super().get_queryset() - - # Filter by transaction type - transaction_type = self.request.query_params.get('transaction_type') - if transaction_type: - queryset = queryset.filter(transaction_type=transaction_type) - - return queryset.order_by('-created_at') - - -class BillingOverviewViewSet(viewsets.ViewSet): - """User-facing billing overview API""" - permission_classes = [IsAuthenticatedAndActive] - authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] - - def account_balance(self, request): - """Get account balance with subscription info""" - account = getattr(request, 'account', None) or request.user.account - - # Get subscription plan - subscription_plan = 'Free' - monthly_credits_included = 0 - if account.plan: - subscription_plan = account.plan.name - monthly_credits_included = account.plan.get_effective_credits_per_month() - - # Calculate bonus credits (credits beyond monthly allowance) - bonus_credits = max(0, account.credits - monthly_credits_included) - - data = { - 'credits': account.credits or 0, - 'subscription_plan': subscription_plan, - 'monthly_credits_included': monthly_credits_included, - 'bonus_credits': bonus_credits, - } - - return Response(data) - - -@extend_schema_view( - stats=extend_schema(tags=['Admin Billing'], summary='Admin billing stats'), - list_users=extend_schema(tags=['Admin Billing'], summary='List users with credit info'), - adjust_credits=extend_schema(tags=['Admin Billing'], summary='Adjust user credits'), - list_credit_costs=extend_schema(tags=['Admin Billing'], summary='List credit cost configurations'), - update_credit_costs=extend_schema(tags=['Admin Billing'], summary='Update credit cost configurations'), -) -class AdminBillingViewSet(viewsets.ViewSet): - """Admin-only billing management API""" - permission_classes = [IsAuthenticatedAndActive, permissions.IsAdminUser] - authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] - - def stats(self, request): - """Get system-wide billing statistics""" - from igny8_core.auth.models import Account - - total_users = Account.objects.filter(status='active').count() - active_users = Account.objects.filter(status='active').exclude(users__last_login__isnull=True).count() - - total_credits_issued = Account.objects.aggregate( - total=Sum('credits') - )['total'] or 0 - - total_credits_used = CreditUsageLog.objects.aggregate( - total=Sum('credits_used') - )['total'] or 0 - - return Response({ - 'total_users': total_users, - 'active_users': active_users, - 'total_credits_issued': total_credits_issued, - 'total_credits_used': total_credits_used, - }) - - def list_users(self, request): - """List all users with credit information""" - from igny8_core.auth.models import Account - from django.db.models import Q - - # Get search query from request - search = request.query_params.get('search', '') - - queryset = Account.objects.filter(status='active').prefetch_related('users') - - # Apply search filter - if search: - queryset = queryset.filter( - Q(user__username__icontains=search) | - Q(user__email__icontains=search) - ) - - accounts = queryset[:100] - - data = [] - for acc in accounts: - user = acc.users.first() if acc.users.exists() else None - data.append({ - 'id': acc.id, - 'username': user.username if user else 'N/A', - 'email': user.email if user else 'N/A', - 'credits': acc.credits or 0, - 'subscription_plan': acc.plan.name if acc.plan else 'Free', - 'is_active': acc.status == 'active', - 'date_joined': acc.created_at - }) - - return Response({'results': data}) - - def adjust_credits(self, request, user_id): - """Adjust credits for a specific user""" - from igny8_core.auth.models import Account - - try: - account = Account.objects.get(id=user_id) - except Account.DoesNotExist: - return Response({'error': 'User not found'}, status=404) - - amount = request.data.get('amount', 0) - reason = request.data.get('reason', 'Admin adjustment') - - try: - amount = int(amount) - except (ValueError, TypeError): - return Response({'error': 'Invalid amount'}, status=400) - - # Adjust credits - old_balance = account.credits - account.credits = (account.credits or 0) + amount - account.save() - - # Log the adjustment - CreditUsageLog.objects.create( - account=account, - operation_type='admin_adjustment', - credits_used=-amount, # Negative for additions - credits_balance_after=account.credits, - details={'reason': reason, 'old_balance': old_balance, 'adjusted_by': request.user.id} - ) - - return Response({ - 'success': True, - 'new_balance': account.credits, - 'old_balance': old_balance, - 'adjustment': amount - }) - - def list_credit_costs(self, request): - """List credit cost configurations""" - from igny8_core.business.billing.models import CreditCostConfig - - configs = CreditCostConfig.objects.filter(is_active=True) - - data = [{ - 'id': c.id, - 'operation_type': c.operation_type, - 'display_name': c.display_name, - 'credits_cost': c.credits_cost, - 'unit': c.unit, - 'is_active': c.is_active, - 'created_at': c.created_at - } for c in configs] - - return Response({'results': data}) - - def update_credit_costs(self, request): - """Update credit cost configurations""" - from igny8_core.business.billing.models import CreditCostConfig - - updates = request.data.get('updates', []) - - for update in updates: - config_id = update.get('id') - new_cost = update.get('cost') - - if config_id and new_cost is not None: - try: - config = CreditCostConfig.objects.get(id=config_id) - config.cost = new_cost - config.save() - except CreditCostConfig.DoesNotExist: - continue - - return Response({'success': True}) - diff --git a/tenant-temp/backend/igny8_core/modules/planner/views.py b/tenant-temp/backend/igny8_core/modules/planner/views.py deleted file mode 100644 index f90cb1f3..00000000 --- a/tenant-temp/backend/igny8_core/modules/planner/views.py +++ /dev/null @@ -1,1182 +0,0 @@ -from rest_framework import viewsets, filters, status -from rest_framework.decorators import action -from rest_framework.response import Response -from django_filters.rest_framework import DjangoFilterBackend -from django.db import transaction -from django.db.models import Max, Count, Sum, Q -from django.http import HttpResponse -import csv -import json -import time -from drf_spectacular.utils import extend_schema, extend_schema_view -from igny8_core.api.base import SiteSectorModelViewSet -from igny8_core.api.pagination import CustomPageNumberPagination -from igny8_core.api.response import success_response, error_response -from igny8_core.api.throttles import DebugScopedRateThrottle -from igny8_core.api.permissions import IsAuthenticatedAndActive, IsViewerOrAbove, IsEditorOrAbove -from .models import Keywords, Clusters, ContentIdeas -from .serializers import KeywordSerializer, ContentIdeasSerializer -from .cluster_serializers import ClusterSerializer -from igny8_core.business.planning.services.clustering_service import ClusteringService -from igny8_core.business.planning.services.ideas_service import IdeasService -from igny8_core.business.billing.exceptions import InsufficientCreditsError - - -@extend_schema_view( - list=extend_schema(tags=['Planner']), - create=extend_schema(tags=['Planner']), - retrieve=extend_schema(tags=['Planner']), - update=extend_schema(tags=['Planner']), - partial_update=extend_schema(tags=['Planner']), - destroy=extend_schema(tags=['Planner']), -) -class KeywordViewSet(SiteSectorModelViewSet): - """ - ViewSet for managing keywords with CRUD operations - Provides list, create, retrieve, update, and destroy actions - Unified API Standard v1.0 compliant - """ - queryset = Keywords.objects.all() - serializer_class = KeywordSerializer - permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] - pagination_class = CustomPageNumberPagination # Explicitly use custom pagination - throttle_scope = 'planner' - throttle_classes = [DebugScopedRateThrottle] - - # DRF filtering configuration - filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] - - # Search configuration - search by seed_keyword's keyword field - search_fields = ['seed_keyword__keyword'] - - # Ordering configuration - allow ordering by created_at, volume, difficulty (from seed_keyword) - ordering_fields = ['created_at', 'seed_keyword__volume', 'seed_keyword__difficulty'] - ordering = ['-created_at'] # Default ordering (newest first) - - # Filter configuration - filter by status, cluster_id, and seed_keyword fields - filterset_fields = ['status', 'cluster_id', 'seed_keyword__intent', 'seed_keyword_id'] - - def get_queryset(self): - """ - Override to support custom difficulty range filtering - Uses parent's get_queryset() which properly handles developer role and site/sector filtering - """ - import logging - logger = logging.getLogger(__name__) - - try: - # Use parent's get_queryset() which handles developer role and site filtering correctly - queryset = super().get_queryset() - - # Safely access query_params (DRF wraps request with Request class) - try: - query_params = getattr(self.request, 'query_params', None) - if query_params is None: - # Fallback for non-DRF requests - query_params = getattr(self.request, 'GET', {}) - except AttributeError: - query_params = {} - - # Custom difficulty range filtering (check override first, then seed_keyword) - difficulty_min = query_params.get('difficulty_min') - difficulty_max = query_params.get('difficulty_max') - if difficulty_min is not None: - try: - # Filter by seed_keyword difficulty (override logic handled in property) - queryset = queryset.filter( - Q(difficulty_override__gte=int(difficulty_min)) | - Q(difficulty_override__isnull=True, seed_keyword__difficulty__gte=int(difficulty_min)) - ) - except (ValueError, TypeError): - pass - if difficulty_max is not None: - try: - queryset = queryset.filter( - Q(difficulty_override__lte=int(difficulty_max)) | - Q(difficulty_override__isnull=True, seed_keyword__difficulty__lte=int(difficulty_max)) - ) - except (ValueError, TypeError): - pass - - # Custom volume range filtering (check override first, then seed_keyword) - volume_min = query_params.get('volume_min') - volume_max = query_params.get('volume_max') - if volume_min is not None: - try: - queryset = queryset.filter( - Q(volume_override__gte=int(volume_min)) | - Q(volume_override__isnull=True, seed_keyword__volume__gte=int(volume_min)) - ) - except (ValueError, TypeError): - pass - if volume_max is not None: - try: - queryset = queryset.filter( - Q(volume_override__lte=int(volume_max)) | - Q(volume_override__isnull=True, seed_keyword__volume__lte=int(volume_max)) - ) - except (ValueError, TypeError): - pass - - return queryset - except Exception as e: - logger.error(f"Error in KeywordViewSet.get_queryset(): {type(e).__name__}: {str(e)}", exc_info=True) - # Return empty queryset instead of raising exception - return Keywords.objects.none() - - def list(self, request, *args, **kwargs): - """ - Override list method to add error handling - """ - import logging - logger = logging.getLogger(__name__) - - try: - queryset = self.filter_queryset(self.get_queryset()) - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - return self.get_paginated_response(serializer.data) - - serializer = self.get_serializer(queryset, many=True) - return success_response( - data=serializer.data, - request=request - ) - except Exception as e: - logger.error(f"Error in KeywordViewSet.list(): {type(e).__name__}: {str(e)}", exc_info=True) - return error_response( - error=f'Error loading keywords: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - def perform_create(self, serializer): - """Require explicit site_id and sector_id - no defaults.""" - user = getattr(self.request, 'user', None) - - # Get site_id and sector_id from validated_data or query params - # Safely access query_params - try: - query_params = getattr(self.request, 'query_params', None) - if query_params is None: - query_params = getattr(self.request, 'GET', {}) - except AttributeError: - query_params = {} - - site_id = serializer.validated_data.get('site_id') or query_params.get('site_id') - sector_id = serializer.validated_data.get('sector_id') or query_params.get('sector_id') - - # Import here to avoid circular imports - from igny8_core.auth.models import Site, Sector - from rest_framework.exceptions import ValidationError - - # Site ID is REQUIRED - if not site_id: - raise ValidationError("site_id is required. Please select a site.") - - try: - site = Site.objects.get(id=site_id) - except Site.DoesNotExist: - raise ValidationError(f"Site with id {site_id} does not exist") - - # Sector ID is REQUIRED - if not sector_id: - raise ValidationError("sector_id is required. Please select a sector.") - - try: - sector = Sector.objects.get(id=sector_id) - # Verify sector belongs to the site - if sector.site_id != site_id: - raise ValidationError(f"Sector '{sector.name}' does not belong to the selected site") - except Sector.DoesNotExist: - raise ValidationError(f"Sector with id {sector_id} does not exist") - - # Remove site_id and sector_id from validated_data as they're not model fields - serializer.validated_data.pop('site_id', None) - serializer.validated_data.pop('sector_id', None) - - # Get account from site or user - account = getattr(self.request, 'account', None) - if not account and user and user.is_authenticated: - account = getattr(user, 'account', None) - - if not account: - account = getattr(site, 'account', None) - - # Save with all required fields explicitly - serializer.save(account=account, site=site, sector=sector) - - @action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete') - def bulk_delete(self, request): - """Bulk delete keywords""" - ids = request.data.get('ids', []) - if not ids: - return error_response( - error='No IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - queryset = self.get_queryset() - deleted_count, _ = queryset.filter(id__in=ids).delete() - - return success_response(data={'deleted_count': deleted_count}, request=request) - - @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update') - def bulk_update(self, request): - """Bulk update cluster status""" - ids = request.data.get('ids', []) - status_value = request.data.get('status') - - if not ids: - return error_response( - error='No IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - if not status_value: - return error_response( - error='No status provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - queryset = self.get_queryset() - updated_count = queryset.filter(id__in=ids).update(status=status_value) - - return success_response(data={'updated_count': updated_count}, request=request) - - @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update') - def bulk_update(self, request): - """Bulk update keyword status""" - ids = request.data.get('ids', []) - status_value = request.data.get('status') - - if not ids: - return error_response( - error='No IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - if not status_value: - return error_response( - error='No status provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - queryset = self.get_queryset() - updated_count = queryset.filter(id__in=ids).update(status=status_value) - - return success_response(data={'updated_count': updated_count}, request=request) - - @action(detail=False, methods=['post'], url_path='bulk_add_from_seed', url_name='bulk_add_from_seed') - def bulk_add_from_seed(self, request): - """Bulk add SeedKeywords to workflow (create Keywords records)""" - from igny8_core.auth.models import SeedKeyword, Site, Sector - - seed_keyword_ids = request.data.get('seed_keyword_ids', []) - site_id = request.data.get('site_id') - sector_id = request.data.get('sector_id') - - if not seed_keyword_ids: - return error_response( - error='No seed keyword IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - if not site_id: - return error_response( - error='site_id is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - if not sector_id: - return error_response( - error='sector_id is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - try: - site = Site.objects.get(id=site_id) - sector = Sector.objects.get(id=sector_id) - except (Site.DoesNotExist, Sector.DoesNotExist) as e: - return error_response( - error=f'Invalid site or sector: {str(e)}', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Validate sector belongs to site - if sector.site != site: - return error_response( - error='Sector does not belong to the specified site', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get account from site - account = site.account - if not account: - return error_response( - error='Site has no account assigned', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get SeedKeywords - seed_keywords = SeedKeyword.objects.filter(id__in=seed_keyword_ids, is_active=True) - - if not seed_keywords.exists(): - return error_response( - error='No valid seed keywords found', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - created_count = 0 - skipped_count = 0 - errors = [] - - with transaction.atomic(): - for seed_keyword in seed_keywords: - try: - # Validate industry/sector match - if site.industry != seed_keyword.industry: - errors.append( - f"Keyword '{seed_keyword.keyword}': industry mismatch " - f"(site={site.industry.name if site.industry else 'None'}, " - f"seed={seed_keyword.industry.name if seed_keyword.industry else 'None'})" - ) - skipped_count += 1 - continue - - # Check if sector has industry_sector set - if not sector.industry_sector: - errors.append( - f"Keyword '{seed_keyword.keyword}': sector '{sector.name}' has no industry_sector set. " - f"Please update the sector to reference an industry sector." - ) - skipped_count += 1 - continue - - if sector.industry_sector != seed_keyword.sector: - errors.append( - f"Keyword '{seed_keyword.keyword}': sector mismatch " - f"(sector={sector.industry_sector.name if sector.industry_sector else 'None'}, " - f"seed={seed_keyword.sector.name if seed_keyword.sector else 'None'})" - ) - skipped_count += 1 - continue - - # Create Keyword if it doesn't exist - # New keywords should default to status 'new' (per updated workflow plan) - keyword, created = Keywords.objects.get_or_create( - seed_keyword=seed_keyword, - site=site, - sector=sector, - defaults={ - 'status': 'new', - 'account': account - } - ) - - # Ensure status is explicitly set to 'new' for newly created keywords - if created: - if getattr(keyword, 'status', None) != 'new': - keyword.status = 'new' - keyword.save(update_fields=['status']) - created_count += 1 - else: - skipped_count += 1 - - except Exception as e: - errors.append(f"Error adding '{seed_keyword.keyword}': {str(e)}") - skipped_count += 1 - - return success_response( - data={ - 'created': created_count, - 'skipped': skipped_count, - 'errors': errors[:10] if errors else [] # Limit errors to first 10 - }, - request=request - ) - - @action(detail=False, methods=['get'], url_path='export', url_name='export') - def export(self, request): - """ - Export keywords to CSV - Query params: search, status, cluster_id, ids (comma-separated) - Note: Always exports as CSV. The 'format' parameter is ignored to avoid DRF format suffix conflicts. - If 'ids' parameter is provided, ONLY those IDs will be exported (other filters are ignored). - """ - # Get base queryset with site/sector/account filtering - queryset = self.get_queryset() - - # Handle IDs filter for bulk export of selected records - # If IDs are provided, ONLY export those IDs and ignore all other filters - ids_param = request.query_params.get('ids', '') - if ids_param: - try: - ids_list = [int(id_str.strip()) for id_str in ids_param.split(',') if id_str.strip()] - if ids_list: - print(f"Backend parses IDs: {ids_list}") - # Filter ONLY by IDs when IDs parameter is present - queryset = queryset.filter(id__in=ids_list) - print(f"Backend filters queryset: queryset.filter(id__in={ids_list})") - except (ValueError, TypeError): - # If IDs parsing fails, fall through to regular filtering - queryset = self.filter_queryset(queryset) - else: - # Apply all filters from query params (search, status, cluster_id) when no IDs specified - queryset = self.filter_queryset(queryset) - - # Export all matching records - keywords = queryset.all() - record_count = keywords.count() - print(f"Backend generates CSV with only those {record_count} records") - - # Generate CSV - response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = 'attachment; filename="keywords.csv"' - - writer = csv.writer(response) - # Header row - writer.writerow(['ID', 'Keyword', 'Volume', 'Difficulty', 'Intent', 'Status', 'Cluster ID', 'Created At']) - - # Data rows - for keyword in keywords: - writer.writerow([ - keyword.id, - keyword.keyword, - keyword.volume, - keyword.difficulty, - keyword.intent, - keyword.status, - keyword.cluster_id or '', - keyword.created_at.isoformat() if keyword.created_at else '', - ]) - - # Print raw CSV content for debugging - csv_content = response.content.decode('utf-8') - print("=== RAW CSV CONTENT ===") - print(csv_content) - print("=== END CSV CONTENT ===") - print("Backend returns CSV as HTTP response") - - return response - - @action(detail=False, methods=['post'], url_path='import_keywords', url_name='import_keywords') - def import_keywords(self, request): - """ - Import keywords from CSV file. - Automatically links keywords to current active site/sector. - """ - if 'file' not in request.FILES: - return error_response( - error='No file provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - file = request.FILES['file'] - if not file.name.endswith('.csv'): - return error_response( - error='File must be a CSV', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - user = getattr(request, 'user', None) - - # Get site_id and sector_id from query params or use active site - try: - query_params = getattr(request, 'query_params', None) - if query_params is None: - query_params = getattr(request, 'GET', {}) - except AttributeError: - query_params = {} - - site_id = query_params.get('site_id') - sector_id = query_params.get('sector_id') - - # Import here to avoid circular imports - from igny8_core.auth.models import Site, Sector - from rest_framework.exceptions import ValidationError - - # Site ID is REQUIRED - if not site_id: - return error_response( - error='site_id is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - try: - site = Site.objects.get(id=site_id) - except Site.DoesNotExist: - return error_response( - error=f'Site with id {site_id} does not exist', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Sector ID is REQUIRED - if not sector_id: - return error_response( - error='sector_id is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - try: - sector = Sector.objects.get(id=sector_id) - if sector.site_id != site_id: - return error_response( - error='Sector does not belong to the selected site', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - except Sector.DoesNotExist: - return error_response( - error=f'Sector with id {sector_id} does not exist', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get account - account = getattr(request, 'account', None) - if not account and user and user.is_authenticated: - account = getattr(user, 'account', None) - if not account: - account = getattr(site, 'account', None) - - # Parse CSV - try: - decoded_file = file.read().decode('utf-8') - csv_reader = csv.DictReader(decoded_file.splitlines()) - - imported_count = 0 - skipped_count = 0 - errors = [] - - with transaction.atomic(): - for row_num, row in enumerate(csv_reader, start=2): # Start at 2 (header is row 1) - try: - keyword_text = row.get('keyword', '').strip() - if not keyword_text: - skipped_count += 1 - continue - - # Check if keyword already exists for this site/sector - existing = Keywords.objects.filter( - keyword=keyword_text, - site=site, - sector=sector, - account=account - ).first() - - if existing: - skipped_count += 1 - continue - - # Create keyword - Keywords.objects.create( - keyword=keyword_text, - volume=int(row.get('volume', 0) or 0), - difficulty=int(row.get('difficulty', 0) or 0), - intent=row.get('intent', 'informational') or 'informational', - status=row.get('status', 'new') or 'new', - site=site, - sector=sector, - account=account - ) - imported_count += 1 - except Exception as e: - errors.append(f"Row {row_num}: {str(e)}") - continue - - return success_response( - data={ - 'imported': imported_count, - 'skipped': skipped_count, - 'errors': errors[:10] if errors else [] # Limit errors to first 10 - }, - request=request - ) - - except Exception as e: - return error_response( - error=f'Failed to parse CSV: {str(e)}', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - @action(detail=False, methods=['post'], url_path='auto_cluster', url_name='auto_cluster') - def auto_cluster(self, request): - """Auto-cluster keywords using ClusteringService""" - import logging - from igny8_core.ai.validators.cluster_validators import validate_minimum_keywords - - logger = logging.getLogger(__name__) - - try: - keyword_ids = request.data.get('ids', []) - sector_id = request.data.get('sector_id') - - # Get account - account = getattr(request, 'account', None) - if not account: - return error_response( - error='Account is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # NEW: Validate minimum keywords BEFORE queuing task - if not keyword_ids: - return error_response( - error='No keyword IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - validation = validate_minimum_keywords( - keyword_ids=keyword_ids, - account=account, - min_required=5 - ) - - if not validation['valid']: - return error_response( - error=validation['error'], - status_code=status.HTTP_400_BAD_REQUEST, - request=request, - extra_data={ - 'count': validation.get('count'), - 'required': validation.get('required') - } - ) - - # Validation passed - proceed with clustering - # Use service to cluster keywords - service = ClusteringService() - try: - result = service.cluster_keywords(keyword_ids, account, sector_id) - - if result.get('success'): - if 'task_id' in result: - # Async task queued - return success_response( - data={'task_id': result['task_id']}, - message=f'Clustering started with {validation["count"]} keywords', - request=request - ) - else: - # Synchronous execution - return success_response( - data=result, - request=request - ) - else: - return error_response( - error=result.get('error', 'Clustering failed'), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - except InsufficientCreditsError as e: - return error_response( - error=str(e), - status_code=status.HTTP_402_PAYMENT_REQUIRED, - request=request - ) - except Exception as e: - logger.error(f"Error in auto_cluster: {str(e)}", exc_info=True) - return error_response( - error=str(e), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - except Exception as e: - logger.error(f"Unexpected error in auto_cluster: {str(e)}", exc_info=True) - return error_response( - error=f'Unexpected error: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - -@extend_schema_view( - list=extend_schema(tags=['Planner']), - create=extend_schema(tags=['Planner']), - retrieve=extend_schema(tags=['Planner']), - update=extend_schema(tags=['Planner']), - partial_update=extend_schema(tags=['Planner']), - destroy=extend_schema(tags=['Planner']), -) -class ClusterViewSet(SiteSectorModelViewSet): - """ - ViewSet for managing clusters with CRUD operations - Unified API Standard v1.0 compliant - """ - queryset = Clusters.objects.all() - serializer_class = ClusterSerializer - permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] - pagination_class = CustomPageNumberPagination # Explicitly use custom pagination - throttle_scope = 'planner' - throttle_classes = [DebugScopedRateThrottle] - - # DRF filtering configuration - filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] - - # Search configuration - search by name - search_fields = ['name'] - - # Ordering configuration - ordering_fields = ['name', 'created_at', 'keywords_count', 'volume', 'difficulty'] - ordering = ['name'] # Default ordering - - # Filter configuration - filterset_fields = ['status'] - - def get_queryset(self): - """ - Get all clusters - keywords_count, volume, and difficulty are calculated in the serializer - since there's no ForeignKey relationship between Clusters and Keywords - Uses parent's get_queryset for filtering - Annotates queryset with volume and difficulty for filtering - """ - queryset = super().get_queryset() - - # Annotate queryset with aggregated volume and difficulty for filtering - from django.db.models import Sum, Avg, Case, When, F, IntegerField - - # Since volume and difficulty are properties (not DB fields), we need to use - # COALESCE to check volume_override/difficulty_override first, then fallback to seed_keyword - # Volume: COALESCE(volume_override, seed_keyword__volume) - # Difficulty: COALESCE(difficulty_override, seed_keyword__difficulty) - queryset = queryset.annotate( - _annotated_volume=Sum( - Case( - When(keywords__volume_override__isnull=False, then=F('keywords__volume_override')), - default=F('keywords__seed_keyword__volume'), - output_field=IntegerField() - ) - ), - _annotated_difficulty=Avg( - Case( - When(keywords__difficulty_override__isnull=False, then=F('keywords__difficulty_override')), - default=F('keywords__seed_keyword__difficulty'), - output_field=IntegerField() - ) - ) - ) - - # Apply volume range filtering - query_params = getattr(self.request, 'query_params', {}) - volume_min = query_params.get('volume_min') - volume_max = query_params.get('volume_max') - if volume_min is not None: - try: - queryset = queryset.filter(_annotated_volume__gte=int(volume_min)) - except (ValueError, TypeError): - pass - if volume_max is not None: - try: - queryset = queryset.filter(_annotated_volume__lte=int(volume_max)) - except (ValueError, TypeError): - pass - - # Apply difficulty range filtering - difficulty_min = query_params.get('difficulty_min') - difficulty_max = query_params.get('difficulty_max') - if difficulty_min is not None: - try: - queryset = queryset.filter(_annotated_difficulty__gte=float(difficulty_min)) - except (ValueError, TypeError): - pass - if difficulty_max is not None: - try: - queryset = queryset.filter(_annotated_difficulty__lte=float(difficulty_max)) - except (ValueError, TypeError): - pass - - return queryset - - def perform_create(self, serializer): - """Require explicit site_id and sector_id - no defaults.""" - user = getattr(self.request, 'user', None) - - # Get site_id and sector_id from validated_data or query params - # Safely access query_params - try: - query_params = getattr(self.request, 'query_params', None) - if query_params is None: - # Fallback for non-DRF requests - query_params = getattr(self.request, 'GET', {}) - except AttributeError: - query_params = {} - - site_id = serializer.validated_data.get('site_id') or query_params.get('site_id') - sector_id = serializer.validated_data.get('sector_id') or query_params.get('sector_id') - - # Import here to avoid circular imports - from igny8_core.auth.models import Site, Sector - from rest_framework.exceptions import ValidationError - - # Site ID is REQUIRED - if not site_id: - raise ValidationError("site_id is required. Please select a site.") - - try: - site = Site.objects.get(id=site_id) - except Site.DoesNotExist: - raise ValidationError(f"Site with id {site_id} does not exist") - - # Sector ID is REQUIRED - if not sector_id: - raise ValidationError("sector_id is required. Please select a sector.") - - try: - sector = Sector.objects.get(id=sector_id) - # Verify sector belongs to the site - if sector.site_id != site_id: - raise ValidationError(f"Sector '{sector.name}' does not belong to the selected site") - except Sector.DoesNotExist: - raise ValidationError(f"Sector with id {sector_id} does not exist") - - # Remove site_id and sector_id from validated_data as they're not model fields - serializer.validated_data.pop('site_id', None) - serializer.validated_data.pop('sector_id', None) - - # Get account from site or user - account = getattr(self.request, 'account', None) - if not account and user and user.is_authenticated: - account = getattr(user, 'account', None) - - if not account: - account = getattr(site, 'account', None) - - # Save with all required fields explicitly - serializer.save(account=account, site=site, sector=sector) - - @action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete') - def bulk_delete(self, request): - """Bulk delete clusters""" - ids = request.data.get('ids', []) - if not ids: - return error_response( - error='No IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - queryset = self.get_queryset() - deleted_count, _ = queryset.filter(id__in=ids).delete() - - return success_response(data={'deleted_count': deleted_count}, request=request) - - @action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas') - def auto_generate_ideas(self, request): - """Auto-generate ideas for clusters using IdeasService""" - import logging - - logger = logging.getLogger(__name__) - - try: - cluster_ids = request.data.get('ids', []) - - # Get account - account = getattr(request, 'account', None) - if not account: - return error_response( - error='Account is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Use service to generate ideas - service = IdeasService() - try: - result = service.generate_ideas(cluster_ids, account) - - if result.get('success'): - if 'task_id' in result: - # Async task queued - return success_response( - data={'task_id': result['task_id']}, - message=result.get('message', 'Idea generation started'), - request=request - ) - else: - # Synchronous execution - return success_response( - data=result, - request=request - ) - else: - return error_response( - error=result.get('error', 'Idea generation failed'), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - except InsufficientCreditsError as e: - return error_response( - error=str(e), - status_code=status.HTTP_402_PAYMENT_REQUIRED, - request=request - ) - except Exception as e: - logger.error(f"Error in auto_generate_ideas: {str(e)}", exc_info=True) - return error_response( - error=str(e), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - except Exception as e: - logger.error(f"Unexpected error in auto_generate_ideas: {str(e)}", exc_info=True) - return error_response( - error=f'Unexpected error: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - def list(self, request, *args, **kwargs): - """ - Override list to optimize keyword stats calculation using bulk aggregation - """ - queryset = self.filter_queryset(self.get_queryset()) - - # Handle pagination first - page = self.paginate_queryset(queryset) - if page is not None: - # Optimize keyword stats for the paginated clusters - cluster_list = list(page) - ClusterSerializer.prefetch_keyword_stats(cluster_list) - serializer = self.get_serializer(cluster_list, many=True) - return self.get_paginated_response(serializer.data) - - # No pagination - optimize all clusters - cluster_list = list(queryset) - ClusterSerializer.prefetch_keyword_stats(cluster_list) - serializer = self.get_serializer(cluster_list, many=True) - return success_response( - data=serializer.data, - request=request - ) - - -@extend_schema_view( - list=extend_schema(tags=['Planner']), - create=extend_schema(tags=['Planner']), - retrieve=extend_schema(tags=['Planner']), - update=extend_schema(tags=['Planner']), - partial_update=extend_schema(tags=['Planner']), - destroy=extend_schema(tags=['Planner']), -) -class ContentIdeasViewSet(SiteSectorModelViewSet): - """ - ViewSet for managing content ideas with CRUD operations - Unified API Standard v1.0 compliant - """ - queryset = ContentIdeas.objects.all() - serializer_class = ContentIdeasSerializer - permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] - pagination_class = CustomPageNumberPagination - throttle_scope = 'planner' - throttle_classes = [DebugScopedRateThrottle] # Explicitly use custom pagination - - # DRF filtering configuration - filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] - - # Search configuration - search by idea_title - search_fields = ['idea_title'] - - # Ordering configuration - ordering_fields = ['idea_title', 'created_at', 'estimated_word_count'] - ordering = ['-created_at'] # Default ordering (newest first) - - # Filter configuration (updated for new structure) - filterset_fields = ['status', 'keyword_cluster_id', 'content_type', 'content_structure'] - - def perform_create(self, serializer): - """Require explicit site_id and sector_id - no defaults.""" - user = getattr(self.request, 'user', None) - - try: - query_params = getattr(self.request, 'query_params', None) - if query_params is None: - query_params = getattr(self.request, 'GET', {}) - except AttributeError: - query_params = {} - - site_id = serializer.validated_data.get('site_id') or query_params.get('site_id') - sector_id = serializer.validated_data.get('sector_id') or query_params.get('sector_id') - - from igny8_core.auth.models import Site, Sector - from rest_framework.exceptions import ValidationError - - # Site ID is REQUIRED - if not site_id: - raise ValidationError("site_id is required. Please select a site.") - - try: - site = Site.objects.get(id=site_id) - except Site.DoesNotExist: - raise ValidationError(f"Site with id {site_id} does not exist") - - # Sector ID is REQUIRED - if not sector_id: - raise ValidationError("sector_id is required. Please select a sector.") - - try: - sector = Sector.objects.get(id=sector_id) - if sector.site_id != site_id: - raise ValidationError(f"Sector does not belong to the selected site") - except Sector.DoesNotExist: - raise ValidationError(f"Sector with id {sector_id} does not exist") - - serializer.validated_data.pop('site_id', None) - serializer.validated_data.pop('sector_id', None) - - account = getattr(self.request, 'account', None) - if not account and user and user.is_authenticated: - account = getattr(user, 'account', None) - if not account: - account = getattr(site, 'account', None) - - serializer.save(account=account, site=site, sector=sector) - - @action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete') - def bulk_delete(self, request): - """Bulk delete content ideas""" - ids = request.data.get('ids', []) - if not ids: - return error_response( - error='No IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - queryset = self.get_queryset() - deleted_count, _ = queryset.filter(id__in=ids).delete() - - return success_response(data={'deleted_count': deleted_count}, request=request) - - @action(detail=False, methods=['post'], url_path='bulk_queue_to_writer', url_name='bulk_queue_to_writer') - def bulk_queue_to_writer(self, request): - """Queue ideas to writer by creating Tasks""" - ids = request.data.get('ids', []) - if not ids: - return error_response( - error='No IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - queryset = self.get_queryset() - # Get ALL requested ideas first (don't filter by status yet) - all_ideas = queryset.filter(id__in=ids) - - # Check which ones can be queued (status='new') - queueable_ideas = all_ideas.filter(status='new') - - from igny8_core.modules.writer.models import Tasks - - created_tasks = [] - errors = [] - skipped = [] - - # Add skipped ideas (not 'new' status) - for idea in all_ideas: - if idea.status != 'new': - skipped.append({ - 'idea_id': idea.id, - 'title': idea.idea_title, - 'reason': f'Already {idea.status}' - }) - - # Process queueable ideas - for idea in queueable_ideas: - try: - # Validate required fields - if not idea.keyword_cluster: - errors.append({ - 'idea_id': idea.id, - 'title': idea.idea_title, - 'error': 'Missing required cluster - assign idea to a cluster first' - }) - continue - - # Build keywords string from idea's keyword objects - keywords_str = '' - if idea.keyword_objects.exists(): - keywords_str = ', '.join([kw.keyword for kw in idea.keyword_objects.all()]) - elif idea.target_keywords: - keywords_str = idea.target_keywords - - # Direct copy - no mapping needed - task = Tasks.objects.create( - title=idea.idea_title, - description=idea.description or '', - cluster=idea.keyword_cluster, - content_type=idea.content_type or 'post', - content_structure=idea.content_structure or 'article', - taxonomy_term=None, # Can be set later if taxonomy is available - keywords=keywords_str, # Comma-separated keywords string - status='queued', - account=idea.account, - site=idea.site, - sector=idea.sector, - idea=idea, # Link back to the original idea - ) - - created_tasks.append(task.id) - - # Update idea status to queued - idea.status = 'queued' - idea.save() - except Exception as e: - errors.append({ - 'idea_id': idea.id, - 'title': idea.idea_title, - 'error': str(e) - }) - - # Return appropriate response based on results - if len(created_tasks) == 0 and (errors or skipped): - # Complete failure - return error_response( - error=f'Failed to queue any ideas: {len(errors)} errors, {len(skipped)} skipped', - errors=errors if errors else skipped, - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - elif errors: - # Partial success - some created, some failed - return success_response( - data={ - 'created_count': len(created_tasks), - 'task_ids': created_tasks, - 'errors': errors, - 'skipped': skipped, - }, - message=f'Queued {len(created_tasks)} ideas ({len(errors)} failed, {len(skipped)} skipped)', - request=request - ) - else: - # Complete success - return success_response( - data={ - 'created_count': len(created_tasks), - 'task_ids': created_tasks, - 'skipped': skipped, - }, - message=f'Successfully queued {len(created_tasks)} ideas to writer' + (f' ({len(skipped)} already scheduled)' if skipped else ''), - request=request - ) - - # REMOVED: generate_idea action - idea generation function removed diff --git a/tenant-temp/backend/igny8_core/modules/system/integration_views.py b/tenant-temp/backend/igny8_core/modules/system/integration_views.py deleted file mode 100644 index e900f2de..00000000 --- a/tenant-temp/backend/igny8_core/modules/system/integration_views.py +++ /dev/null @@ -1,1392 +0,0 @@ -""" -Integration settings views - for OpenAI, Runware, GSC integrations -Unified API Standard v1.0 compliant -""" -import logging -from rest_framework import viewsets, status -from rest_framework.decorators import action -from django.db import transaction -from drf_spectacular.utils import extend_schema, extend_schema_view -from igny8_core.api.base import AccountModelViewSet -from igny8_core.api.response import success_response, error_response -from igny8_core.api.throttles import DebugScopedRateThrottle -from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsSystemAccountOrDeveloper -from django.conf import settings - -logger = logging.getLogger(__name__) - - -@extend_schema_view( - list=extend_schema(tags=['System']), - retrieve=extend_schema(tags=['System']), - update=extend_schema(tags=['System']), - test_connection=extend_schema(tags=['System']), - task_progress=extend_schema(tags=['System']), - get_image_generation_settings=extend_schema(tags=['System']), -) -class IntegrationSettingsViewSet(viewsets.ViewSet): - """ - ViewSet for managing integration settings (OpenAI, Runware, GSC) - Following reference plugin pattern: WordPress uses update_option() for igny8_api_settings - We store in IntegrationSettings model with account isolation - """ - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsSystemAccountOrDeveloper] - - throttle_scope = 'system_admin' - throttle_classes = [DebugScopedRateThrottle] - - def list(self, request): - """List all integrations - for debugging URL patterns""" - logger.info("[IntegrationSettingsViewSet] list() called") - return success_response( - data={ - 'message': 'IntegrationSettingsViewSet is working', - 'available_endpoints': [ - 'GET /api/v1/system/settings/integrations//', - 'POST /api/v1/system/settings/integrations//save/', - 'POST /api/v1/system/settings/integrations//test/', - 'POST /api/v1/system/settings/integrations//generate/', - ] - }, - request=request - ) - - def retrieve(self, request, pk=None): - """Get integration settings - GET /api/v1/system/settings/integrations/{pk}/""" - return self.get_settings(request, pk) - - def update(self, request, pk=None): - """Save integration settings (PUT) - PUT /api/v1/system/settings/integrations/{pk}/""" - return self.save_settings(request, pk) - - def save_post(self, request, pk=None, **kwargs): - """Save integration settings (POST) - POST /api/v1/system/settings/integrations/{pk}/save/ - This matches the frontend endpoint call exactly. - Reference plugin: WordPress form submits to options.php which calls update_option() via register_setting callback. - We save to IntegrationSettings model instead. - """ - # Extract pk from kwargs if not passed as parameter (DRF passes via **kwargs) - if not pk: - pk = kwargs.get('pk') - return self.save_settings(request, pk) - - @action(detail=True, methods=['post'], url_path='test', url_name='test') - def test_connection(self, request, pk=None): - """ - Test API connection for OpenAI or Runware - Supports two modes: - - with_response=false: Simple connection test (GET /v1/models) - - with_response=true: Full response test with ping message - """ - integration_type = pk # 'openai', 'runware' - - logger.info(f"[test_connection] Called for integration_type={integration_type}, user={getattr(request, 'user', None)}, account={getattr(request, 'account', None)}") - - if not integration_type: - return error_response( - error='Integration type is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get API key and config from request or saved settings - config = request.data.get('config', {}) if isinstance(request.data.get('config'), dict) else {} - api_key = request.data.get('apiKey') or config.get('apiKey') - - # Merge request.data with config if config is a dict - if not isinstance(config, dict): - config = {} - - if not api_key: - # Try to get from saved settings - account = getattr(request, 'account', None) - logger.info(f"[test_connection] Account from request: {account.id if account else None}") - # Fallback to user's account - if not account: - user = getattr(request, 'user', None) - if user and hasattr(user, 'is_authenticated') and user.is_authenticated: - account = getattr(user, 'account', None) - # Fallback to default account - if not account: - from igny8_core.auth.models import Account - try: - account = Account.objects.first() - except Exception: - pass - - if account: - try: - from .models import IntegrationSettings - logger.info(f"[test_connection] Looking for saved settings for account {account.id}") - saved_settings = IntegrationSettings.objects.get( - integration_type=integration_type, - account=account - ) - api_key = saved_settings.config.get('apiKey') - logger.info(f"[test_connection] Found saved settings, has_apiKey={bool(api_key)}") - except IntegrationSettings.DoesNotExist: - logger.warning(f"[test_connection] No saved settings found for {integration_type} and account {account.id}") - pass - - if not api_key: - logger.error(f"[test_connection] No API key found in request or saved settings") - return error_response( - error='API key is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - logger.info(f"[test_connection] Testing {integration_type} connection with API key (length={len(api_key) if api_key else 0})") - try: - if integration_type == 'openai': - return self._test_openai(api_key, config, request) - elif integration_type == 'runware': - return self._test_runware(api_key, request) - else: - return error_response( - error=f'Validation not supported for {integration_type}', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - except Exception as e: - logger.error(f"Error testing {integration_type} connection: {str(e)}", exc_info=True) - import traceback - error_trace = traceback.format_exc() - logger.error(f"Full traceback: {error_trace}") - return error_response( - error=str(e), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - def _test_openai(self, api_key: str, config: dict = None, request=None): - """ - Test OpenAI API connection. - EXACT match to reference plugin's igny8_test_connection() function. - Reference: ai/openai-api.php line 186-309 - """ - import requests - - # Get model from config or use default (reference plugin: get_option('igny8_model', 'gpt-4.1')) - model = (config or {}).get('model', 'gpt-4.1') if config else 'gpt-4.1' - - # Check if test with response is requested (reference plugin: $with_response parameter) - with_response = (config or {}).get('with_response', False) if config else False - - if with_response: - # Test with actual API call (reference plugin: test with chat completion) - request_body = { - 'model': model, - 'messages': [ - { - 'role': 'user', - 'content': 'test ping, reply with: OK! Ping Received. Also tell me: what is your maximum token limit that I can use in 1 request?' - } - ], - 'temperature': 0.7, - } - - try: - response = requests.post( - 'https://api.openai.com/v1/chat/completions', - headers={ - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json', - }, - json=request_body, - timeout=15 - ) - - if response.status_code >= 200 and response.status_code < 300: - response_data = response.json() - - if 'choices' in response_data and len(response_data['choices']) > 0: - response_text = response_data['choices'][0]['message']['content'].strip() - - # Extract token usage information (reference plugin: line 269-271) - usage = response_data.get('usage', {}) - input_tokens = usage.get('prompt_tokens', 0) - output_tokens = usage.get('completion_tokens', 0) - total_tokens = usage.get('total_tokens', 0) - - # Calculate cost using model rates (reference plugin: line 274-275) - from igny8_core.utils.ai_processor import MODEL_RATES - rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00}) - cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1000000 - - return success_response( - data={ - 'message': 'API connection and response test successful!', - 'model_used': model, - 'response': response_text, - 'tokens_used': f"{input_tokens} / {output_tokens}", - 'total_tokens': total_tokens, - 'cost': f'${cost:.4f}', - 'full_response': response_data, - }, - request=request - ) - else: - return error_response( - error='API responded but no content received', - errors={'response': [response.text[:500]]}, - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - else: - body = response.text - # Map OpenAI API errors to appropriate HTTP status codes - # OpenAI 401 (invalid API key) should be 400 (Bad Request) in our API - # OpenAI 4xx errors are client errors (invalid request) -> 400 - # OpenAI 5xx errors are server errors -> 500 - if response.status_code == 401: - # Invalid API key - this is a validation error, not an auth error - status_code = status.HTTP_400_BAD_REQUEST - elif 400 <= response.status_code < 500: - # Other client errors from OpenAI (invalid request, rate limit, etc.) - status_code = status.HTTP_400_BAD_REQUEST - elif response.status_code >= 500: - # Server errors from OpenAI - status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - else: - status_code = response.status_code - - return error_response( - error=f'HTTP {response.status_code} – {body[:200]}', - status_code=status_code, - request=request - ) - except requests.exceptions.RequestException as e: - return error_response( - error=str(e), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - else: - # Simple connection test without API call (reference plugin: GET /v1/models) - try: - response = requests.get( - 'https://api.openai.com/v1/models', - headers={ - 'Authorization': f'Bearer {api_key}', - }, - timeout=10 - ) - - if response.status_code >= 200 and response.status_code < 300: - return success_response( - data={ - 'message': 'API connection successful!', - 'model_used': model, - 'response': 'Connection verified without API call' - }, - request=request - ) - else: - body = response.text - # Map OpenAI API errors to appropriate HTTP status codes - # OpenAI 401 (invalid API key) should be 400 (Bad Request) in our API - # OpenAI 4xx errors are client errors (invalid request) -> 400 - # OpenAI 5xx errors are server errors -> 500 - if response.status_code == 401: - # Invalid API key - this is a validation error, not an auth error - status_code = status.HTTP_400_BAD_REQUEST - elif 400 <= response.status_code < 500: - # Other client errors from OpenAI (invalid request, rate limit, etc.) - status_code = status.HTTP_400_BAD_REQUEST - elif response.status_code >= 500: - # Server errors from OpenAI - status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - else: - status_code = response.status_code - - return error_response( - error=f'HTTP {response.status_code} – {body[:200]}', - status_code=status_code, - request=request - ) - except requests.exceptions.RequestException as e: - return error_response( - error=str(e), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - def _test_runware(self, api_key: str, request): - """ - Test Runware API connection using 64x64 image generation (ping validation) - Reference: Uses same format as image generation but with minimal 64x64 size for fast validation - """ - from igny8_core.utils.ai_processor import AIProcessor - - # Get account from request - account = getattr(request, 'account', None) - if not account: - user = getattr(request, 'user', None) - if user and hasattr(user, 'is_authenticated') and user.is_authenticated: - account = getattr(user, 'account', None) - # Fallback to default account - if not account: - from igny8_core.auth.models import Account - try: - account = Account.objects.first() - except Exception: - pass - - try: - # EXACT match to reference plugin: core/admin/ajax.php line 4946-5003 - # Reference plugin uses: 128x128, steps=2, CFGScale=5, prompt='test image connection' - import requests - import uuid - import json - - test_prompt = 'test image connection' - - # Prepare payload EXACTLY as reference plugin - payload = [ - { - 'taskType': 'authentication', - 'apiKey': api_key - }, - { - 'taskType': 'imageInference', - 'taskUUID': str(uuid.uuid4()), - 'positivePrompt': test_prompt, - 'model': 'runware:97@1', - 'width': 128, # Reference plugin uses 128x128, not 64x64 - 'height': 128, - 'negativePrompt': 'text, watermark, logo, overlay, title, caption, writing on walls, writing on objects, UI, infographic elements, post title', - 'steps': 2, # Low steps for fast testing - 'CFGScale': 5, - 'numberResults': 1 - } - ] - - logger.info("[_test_runware] Testing Runware API with 128x128 image generation (matching reference plugin)") - logger.info(f"[_test_runware] Payload: {json.dumps(payload, indent=2)}") - - # Make API request - response = requests.post( - 'https://api.runware.ai/v1', - headers={'Content-Type': 'application/json'}, - json=payload, - timeout=30 - ) - - logger.info(f"[_test_runware] Response status: {response.status_code}") - - if response.status_code != 200: - error_text = response.text - logger.error(f"[_test_runware] HTTP error {response.status_code}: {error_text[:200]}") - return error_response( - error=f'HTTP {response.status_code}: {error_text[:200]}', - status_code=response.status_code, - request=request - ) - - # Parse response - Reference plugin checks: $body['data'][0]['imageURL'] - body = response.json() - logger.info(f"[_test_runware] Response body type: {type(body)}") - logger.info(f"[_test_runware] Response body: {json.dumps(body, indent=2)[:1000]}") - - # Reference plugin line 4996: if (isset($body['data'][0]['imageURL'])) - if isinstance(body, dict) and 'data' in body: - data = body['data'] - if isinstance(data, list) and len(data) > 0: - first_item = data[0] - image_url = first_item.get('imageURL') or first_item.get('image_url') - if image_url: - logger.info(f"[_test_runware] Success! Image URL: {image_url[:50]}...") - return success_response( - data={ - 'message': '✅ Runware API connected successfully!', - 'image_url': image_url, - 'cost': '$0.0090', - 'provider': 'runware', - 'model': 'runware:97@1', - 'size': '128x128' - }, - request=request - ) - - # Check for errors - Reference plugin line 4998: elseif (isset($body['errors'][0]['message'])) - if isinstance(body, dict) and 'errors' in body: - errors = body['errors'] - if isinstance(errors, list) and len(errors) > 0: - error_msg = errors[0].get('message', 'Unknown Runware API error') - logger.error(f"[_test_runware] Runware API error: {error_msg}") - return error_response( - error=f'❌ {error_msg}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - # Unknown response format - logger.error(f"[_test_runware] Unknown response format: {body}") - return error_response( - error='❌ Unknown response from Runware.', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - except Exception as e: - logger.error(f"[_test_runware] Exception in Runware API test: {str(e)}", exc_info=True) - return error_response( - error=f'Runware API test failed: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - def generate_image(self, request, pk=None, **kwargs): - """ - Generate image using the configured image generation service - POST /api/v1/system/settings/integrations/image_generation/generate/ - Note: This method is called via custom URL pattern, not @action decorator - """ - # Extract pk from kwargs if not passed as parameter (DRF passes via **kwargs) - if not pk: - pk = kwargs.get('pk') - - # Log detailed request info for debugging - logger.info("=" * 80) - logger.info("[generate_image] ENDPOINT CALLED - Image generation request received") - logger.info(f"[generate_image] pk parameter: {pk}") - logger.info(f"[generate_image] kwargs: {kwargs}") - logger.info(f"[generate_image] request.path: {request.path}") - logger.info(f"[generate_image] request.method: {request.method}") - logger.info(f"[generate_image] request.META.get('PATH_INFO'): {request.META.get('PATH_INFO')}") - logger.info(f"[generate_image] request.META.get('REQUEST_URI'): {request.META.get('REQUEST_URI', 'N/A')}") - logger.info(f"[generate_image] request.META.get('HTTP_HOST'): {request.META.get('HTTP_HOST', 'N/A')}") - logger.info(f"[generate_image] request.META.get('HTTP_REFERER'): {request.META.get('HTTP_REFERER', 'N/A')}") - logger.info(f"[generate_image] request.data: {request.data}") - - if pk != 'image_generation': - logger.error(f"[generate_image] Invalid pk: {pk}, expected 'image_generation'") - return error_response( - error=f'Image generation endpoint only available for image_generation integration, got: {pk}', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get account - logger.info("[generate_image] Step 1: Getting account") - account = getattr(request, 'account', None) - if not account: - user = getattr(request, 'user', None) - logger.info(f"[generate_image] No account in request, checking user: {user}") - if user and hasattr(user, 'is_authenticated') and user.is_authenticated: - account = getattr(user, 'account', None) - logger.info(f"[generate_image] Got account from user: {account}") - if not account: - logger.info("[generate_image] No account found, trying to get first account from DB") - from igny8_core.auth.models import Account - try: - account = Account.objects.first() - logger.info(f"[generate_image] Got first account from DB: {account}") - except Exception as e: - logger.error(f"[generate_image] Error getting account from DB: {e}") - pass - - if not account: - logger.error("[generate_image] ERROR: No account found, returning error response") - return error_response( - error='Account not found', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - logger.info(f"[generate_image] Account resolved: {account.id if account else 'None'}") - - # Get request parameters - logger.info("[generate_image] Step 2: Extracting request parameters") - prompt = request.data.get('prompt', '') - negative_prompt = request.data.get('negative_prompt', '') - image_type = request.data.get('image_type', 'realistic') - image_size = request.data.get('image_size', '1024x1024') - image_format = request.data.get('image_format', 'webp') - provider = request.data.get('provider', 'openai') - model = request.data.get('model', 'dall-e-3') - - logger.info(f"[generate_image] Request parameters: provider={provider}, model={model}, image_type={image_type}, image_size={image_size}, prompt_length={len(prompt)}") - logger.info(f"[generate_image] IMPORTANT: Using ONLY {provider.upper()} provider for this request. NOT using both providers.") - - if not prompt: - logger.error("[generate_image] ERROR: Prompt is empty") - return error_response( - error='Prompt is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get API key from saved settings for the specified provider only - logger.info(f"[generate_image] Step 3: Getting API key for provider: {provider}") - from .models import IntegrationSettings - - # Only fetch settings for the specified provider - api_key = None - integration_enabled = False - integration_type = provider # 'openai' or 'runware' - - try: - integration_settings = IntegrationSettings.objects.get( - integration_type=integration_type, - account=account - ) - api_key = integration_settings.config.get('apiKey') - integration_enabled = integration_settings.is_active - logger.info(f"[generate_image] {integration_type.upper()} settings found: enabled={integration_enabled}, has_key={bool(api_key)}") - except IntegrationSettings.DoesNotExist: - logger.warning(f"[generate_image] {integration_type.upper()} settings not found in database") - api_key = None - integration_enabled = False - except Exception as e: - logger.error(f"[generate_image] Error getting {integration_type.upper()} settings: {e}") - api_key = None - integration_enabled = False - - # Validate provider and API key - logger.info(f"[generate_image] Step 4: Validating {provider} provider and API key") - if provider not in ['openai', 'runware']: - logger.error(f"[generate_image] ERROR: Invalid provider: {provider}") - return error_response( - error=f'Invalid provider: {provider}. Must be "openai" or "runware"', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - if not api_key or not integration_enabled: - logger.error(f"[generate_image] ERROR: {provider.upper()} API key not configured or integration not enabled") - return error_response( - error=f'{provider.upper()} API key not configured or integration not enabled', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - logger.info(f"[generate_image] {provider.upper()} API key validated successfully") - - # Generate image using AIProcessor - logger.info("[generate_image] Step 5: Creating AIProcessor and generating image") - try: - from igny8_core.utils.ai_processor import AIProcessor - processor = AIProcessor(account=account) - logger.info("[generate_image] AIProcessor created successfully") - - # Parse size - width, height = map(int, image_size.split('x')) - size_str = f'{width}x{height}' - logger.info(f"[generate_image] Image size parsed: {size_str}") - - logger.info(f"[generate_image] Calling processor.generate_image with: provider={provider}, model={model}, size={size_str}") - result = processor.generate_image( - prompt=prompt, - provider=provider, - model=model, - size=size_str, - n=1, - api_key=api_key, - negative_prompt=negative_prompt if provider == 'runware' else None, # OpenAI doesn't support negative prompts - ) - - logger.info(f"[generate_image] AIProcessor.generate_image returned: has_url={bool(result.get('url'))}, has_error={bool(result.get('error'))}") - - if result.get('error'): - logger.error(f"[generate_image] ERROR from AIProcessor: {result.get('error')}") - return error_response( - error=result['error'], - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - logger.info("[generate_image] Image generation successful, returning response") - response_data = { - 'image_url': result.get('url'), - 'revised_prompt': result.get('revised_prompt'), - 'model': model, - 'provider': provider, - 'cost': f"${result.get('cost', 0):.4f}" if result.get('cost') else None, - } - logger.info(f"[generate_image] Returning success response: {response_data}") - return success_response( - data=response_data, - request=request - ) - except Exception as e: - logger.error(f"[generate_image] EXCEPTION in image generation: {str(e)}", exc_info=True) - return error_response( - error=f'Failed to generate image: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - def create(self, request): - """Create integration settings""" - integration_type = request.data.get('integration_type') - if not integration_type: - return error_response( - error='integration_type is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - return self.save_settings(request, integration_type) - - def save_settings(self, request, pk=None): - """Save integration settings""" - integration_type = pk # 'openai', 'runware', 'gsc' - - logger.info(f"[save_settings] Called for integration_type={integration_type}, user={getattr(request, 'user', None)}, account={getattr(request, 'account', None)}") - - if not integration_type: - return error_response( - error='Integration type is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Ensure config is a dict - config = dict(request.data) if hasattr(request.data, 'dict') else (request.data if isinstance(request.data, dict) else {}) - logger.info(f"[save_settings] Config keys: {list(config.keys()) if isinstance(config, dict) else 'Not a dict'}") - - try: - # Get account - try multiple methods - account = getattr(request, 'account', None) - logger.info(f"[save_settings] Account from request: {account.id if account else None}") - - # Fallback 1: Get from authenticated user's account - if not account: - user = getattr(request, 'user', None) - if user and hasattr(user, 'is_authenticated') and user.is_authenticated: - try: - account = getattr(user, 'account', None) - except Exception as e: - logger.warning(f"Error getting account from user: {e}") - account = None - - # Fallback 2: If still no account, get default account (for development) - if not account: - from igny8_core.auth.models import Account - try: - # Get the first account as fallback (development only) - account = Account.objects.first() - except Exception as e: - logger.warning(f"Error getting default account: {e}") - account = None - - if not account: - logger.error(f"[save_settings] No account found after all fallbacks") - return error_response( - error='Account not found. Please ensure you are logged in.', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - logger.info(f"[save_settings] Using account: {account.id} ({account.name}, slug={account.slug}, status={account.status})") - - # Store integration settings in a simple model or settings table - # For now, we'll use a simple approach - store in IntegrationSettings model - # or use Django settings/database - - # Import IntegrationSettings model - from .models import IntegrationSettings - - # For image_generation, ensure provider is set correctly - if integration_type == 'image_generation': - # Map service to provider if service is provided - if 'service' in config and 'provider' not in config: - config['provider'] = config['service'] - # Ensure provider is set - if 'provider' not in config: - config['provider'] = config.get('service', 'openai') - # Set model based on provider - if config.get('provider') == 'openai' and 'model' not in config: - config['model'] = config.get('imageModel', 'dall-e-3') - elif config.get('provider') == 'runware' and 'model' not in config: - config['model'] = config.get('runwareModel', 'runware:97@1') - # Ensure all image settings have defaults - config.setdefault('image_type', 'realistic') - config.setdefault('max_in_article_images', 2) - config.setdefault('image_format', 'webp') - config.setdefault('desktop_enabled', True) - config.setdefault('mobile_enabled', True) - - # Set default image sizes based on provider/model - provider = config.get('provider', 'openai') - model = config.get('model', 'dall-e-3') - - if not config.get('featured_image_size'): - if provider == 'runware': - config['featured_image_size'] = '1280x832' - else: # openai - config['featured_image_size'] = '1024x1024' - - if not config.get('desktop_image_size'): - config['desktop_image_size'] = '1024x1024' - - # Get or create integration settings - logger.info(f"[save_settings] Attempting get_or_create for {integration_type} with account {account.id}") - integration_settings, created = IntegrationSettings.objects.get_or_create( - integration_type=integration_type, - account=account, - defaults={'config': config, 'is_active': config.get('enabled', False)} - ) - logger.info(f"[save_settings] get_or_create result: created={created}, id={integration_settings.id}") - - if not created: - logger.info(f"[save_settings] Updating existing settings (id={integration_settings.id})") - integration_settings.config = config - integration_settings.is_active = config.get('enabled', False) - integration_settings.save() - logger.info(f"[save_settings] Settings updated successfully") - - logger.info(f"[save_settings] Successfully saved settings for {integration_type}") - return success_response( - data={'config': config}, - message=f'{integration_type.upper()} settings saved successfully', - request=request - ) - - except Exception as e: - logger.error(f"Error saving integration settings for {integration_type}: {str(e)}", exc_info=True) - import traceback - error_trace = traceback.format_exc() - logger.error(f"Full traceback: {error_trace}") - return error_response( - error=f'Failed to save settings: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - def get_settings(self, request, pk=None): - """Get integration settings - defaults to AWS-admin settings if account doesn't have its own""" - integration_type = pk - - if not integration_type: - return error_response( - error='Integration type is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - try: - # Get account - try multiple methods (same as save_settings) - account = getattr(request, 'account', None) - - # Fallback 1: Get from authenticated user's account - if not account: - user = getattr(request, 'user', None) - if user and hasattr(user, 'is_authenticated') and user.is_authenticated: - try: - account = getattr(user, 'account', None) - except Exception as e: - logger.warning(f"Error getting account from user: {e}") - account = None - - from .models import IntegrationSettings - - # Get account-specific settings - if account: - try: - integration_settings = IntegrationSettings.objects.get( - integration_type=integration_type, - account=account - ) - return success_response( - data=integration_settings.config, - request=request - ) - except IntegrationSettings.DoesNotExist: - pass - except Exception as e: - logger.error(f"Error getting account-specific settings: {e}", exc_info=True) - - # Return empty config if no settings found - return success_response( - data={}, - request=request - ) - except Exception as e: - logger.error(f"Unexpected error in get_settings for {integration_type}: {e}", exc_info=True) - return error_response( - error=f'Failed to get settings: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - @action(detail=False, methods=['get'], url_path='image_generation', url_name='image_generation_settings') - def get_image_generation_settings(self, request): - """Get image generation settings for current account""" - account = getattr(request, 'account', None) - - if not account: - # Fallback to user's account - user = getattr(request, 'user', None) - if user and hasattr(user, 'is_authenticated') and user.is_authenticated: - account = getattr(user, 'account', None) - # Fallback to default account - if not account: - from igny8_core.auth.models import Account - try: - account = Account.objects.first() - except Exception: - pass - - if not account: - return error_response( - error='Account not found', - status_code=status.HTTP_401_UNAUTHORIZED, - request=request - ) - - try: - from .models import IntegrationSettings - integration = IntegrationSettings.objects.get( - account=account, - integration_type='image_generation', - is_active=True - ) - - config = integration.config or {} - - # Debug: Log what's actually in the config - logger.info(f"[get_image_generation_settings] Full config: {config}") - logger.info(f"[get_image_generation_settings] Config keys: {list(config.keys())}") - logger.info(f"[get_image_generation_settings] model field: {config.get('model')}") - logger.info(f"[get_image_generation_settings] imageModel field: {config.get('imageModel')}") - - # Get model - try 'model' first, then 'imageModel' as fallback - model = config.get('model') or config.get('imageModel') or 'dall-e-3' - - # Set defaults for image sizes if not present - provider = config.get('provider', 'openai') - default_featured_size = '1280x832' if provider == 'runware' else '1024x1024' - - return success_response( - data={ - 'config': { - 'provider': config.get('provider', 'openai'), - 'model': model, - 'image_type': config.get('image_type', 'realistic'), - 'max_in_article_images': config.get('max_in_article_images', 2), - 'image_format': config.get('image_format', 'webp'), - 'desktop_enabled': config.get('desktop_enabled', True), - 'mobile_enabled': config.get('mobile_enabled', True), - 'featured_image_size': config.get('featured_image_size', default_featured_size), - 'desktop_image_size': config.get('desktop_image_size', '1024x1024'), - } - }, - request=request - ) - except IntegrationSettings.DoesNotExist: - return success_response( - data={ - 'config': { - 'provider': 'openai', - 'model': 'dall-e-3', - 'image_type': 'realistic', - 'max_in_article_images': 2, - 'image_format': 'webp', - 'desktop_enabled': True, - 'mobile_enabled': True, - } - }, - request=request - ) - except Exception as e: - logger.error(f"[get_image_generation_settings] Error: {str(e)}", exc_info=True) - return error_response( - error=str(e), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - @action(detail=False, methods=['get'], url_path='task_progress/(?P[^/.]+)', url_name='task-progress') - def task_progress(self, request, task_id=None): - """ - Get Celery task progress status - GET /api/v1/system/settings/task_progress// - """ - if not task_id: - return error_response( - error='Task ID is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - import logging - logger = logging.getLogger(__name__) - - try: - # Try to import Celery AsyncResult - try: - from celery.result import AsyncResult - from kombu.exceptions import OperationalError as KombuOperationalError - # Try to import redis ConnectionError, but it might not be available - try: - from redis.exceptions import ConnectionError as RedisConnectionError - except ImportError: - # Redis might not be installed or ConnectionError might not exist - RedisConnectionError = ConnectionError - except ImportError: - logger.warning("Celery not available - task progress cannot be retrieved") - return success_response( - data={ - 'state': 'PENDING', - 'meta': { - 'percentage': 0, - 'message': 'Celery not available - cannot retrieve task status', - 'error': 'Celery not configured' - } - }, - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - request=request - ) - - try: - # Create AsyncResult - this should not raise an exception even if task doesn't exist - task = AsyncResult(task_id) - - # Safely get task state - accessing task.state can raise ValueError if exception info is malformed - # or ConnectionError if backend is unavailable - try: - task_state = task.state - except (ValueError, KeyError) as state_exc: - # Task has malformed exception info - try to get error from multiple sources - logger.warning(f"Error accessing task.state (malformed exception info): {str(state_exc)}") - error_msg = 'Task failed - exception details unavailable' - error_type = 'UnknownError' - request_steps = [] - response_steps = [] - - # First, try to get from backend's stored meta (most reliable for our update_state calls) - try: - backend = task.backend - if hasattr(backend, 'get_task_meta'): - stored_meta = backend.get_task_meta(task_id) - if stored_meta and isinstance(stored_meta, dict): - meta = stored_meta.get('meta', {}) - if isinstance(meta, dict): - if 'error' in meta: - error_msg = meta.get('error') - if 'error_type' in meta: - error_type = meta.get('error_type', error_type) - if 'request_steps' in meta: - request_steps = meta.get('request_steps', []) - if 'response_steps' in meta: - response_steps = meta.get('response_steps', []) - except Exception as e: - logger.debug(f"Error getting from backend meta: {str(e)}") - - # Try to get error from task.result - if error_msg == 'Task failed - exception details unavailable': - try: - if hasattr(task, 'result'): - result = task.result - if isinstance(result, dict): - error_msg = result.get('error', error_msg) - error_type = result.get('error_type', error_type) - request_steps = result.get('request_steps', request_steps) - response_steps = result.get('response_steps', response_steps) - elif isinstance(result, str): - error_msg = result - except Exception as e: - logger.debug(f"Error extracting error from task.result: {str(e)}") - - # Also try to get error from task.info - if error_msg == 'Task failed - exception details unavailable': - try: - if hasattr(task, 'info') and task.info: - if isinstance(task.info, dict): - if 'error' in task.info: - error_msg = task.info['error'] - if 'error_type' in task.info: - error_type = task.info['error_type'] - if 'request_steps' in task.info: - request_steps = task.info.get('request_steps', request_steps) - if 'response_steps' in task.info: - response_steps = task.info.get('response_steps', response_steps) - except Exception as e: - logger.debug(f"Error extracting error from task.info: {str(e)}") - - return success_response( - data={ - 'state': 'FAILURE', - 'meta': { - 'error': error_msg, - 'error_type': error_type, - 'percentage': 0, - 'message': f'Error: {error_msg}', - 'request_steps': request_steps, - 'response_steps': response_steps, - } - }, - request=request - ) - except (KombuOperationalError, RedisConnectionError, ConnectionError) as conn_exc: - # Backend connection error - task might not be registered yet or backend is down - logger.warning(f"Backend connection error accessing task.state for {task_id}: {type(conn_exc).__name__}: {str(conn_exc)}") - return success_response( - data={ - 'state': 'PENDING', - 'meta': { - 'percentage': 0, - 'message': 'Task is being queued...', - 'phase': 'initializing', - 'error': None # Don't show as error, just pending - } - }, - request=request - ) - except Exception as state_exc: - logger.error(f"Unexpected error accessing task.state: {type(state_exc).__name__}: {str(state_exc)}") - return success_response( - data={ - 'state': 'UNKNOWN', - 'meta': { - 'error': f'Error accessing task: {str(state_exc)}', - 'percentage': 0, - 'message': f'Error: {str(state_exc)}', - } - }, - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - # Check if task exists and is accessible - if task_state is None: - # Task doesn't exist or hasn't been registered yet - return success_response( - data={ - 'state': 'PENDING', - 'meta': { - 'percentage': 0, - 'message': 'Task not found or not yet registered', - 'phase': 'initializing', - } - }, - request=request - ) - - # Safely get task info/result - # Try to get error from multiple sources - task_result = None - task_info = None - error_message = None - error_type = None - - # First, try to get from backend's stored meta (most reliable for our update_state calls) - try: - backend = task.backend - if hasattr(backend, 'get_task_meta'): - stored_meta = backend.get_task_meta(task_id) - if stored_meta and isinstance(stored_meta, dict): - meta = stored_meta.get('meta', {}) - if isinstance(meta, dict): - if 'error' in meta: - error_message = meta.get('error') - error_type = meta.get('error_type', 'UnknownError') - except Exception as backend_err: - logger.debug(f"Could not get from backend meta: {backend_err}") - - try: - # Try to get result first - this often has the actual error - if not error_message and hasattr(task, 'result'): - try: - task_result = task.result - # If result is a dict with error, extract it - if isinstance(task_result, dict): - if 'error' in task_result: - error_message = task_result.get('error') - error_type = task_result.get('error_type', 'UnknownError') - elif 'success' in task_result and not task_result.get('success'): - error_message = task_result.get('error', 'Task failed') - error_type = task_result.get('error_type', 'UnknownError') - except Exception: - pass # Will try task.info next - except Exception: - pass - - # Then try task.info - if not error_message and hasattr(task, 'info'): - try: - task_info = task.info - if isinstance(task_info, dict): - if 'error' in task_info: - error_message = task_info.get('error') - error_type = task_info.get('error_type', 'UnknownError') - except (ValueError, KeyError, AttributeError) as info_exc: - # Log the actual exception that occurred - logger.error(f"Error accessing task.info for {task_id}: {type(info_exc).__name__}: {str(info_exc)}", exc_info=True) - # Try to get error from traceback if available - try: - if hasattr(task, 'traceback'): - error_message = f"Task failed: {str(task.traceback)}" - except: - pass - except (KombuOperationalError, RedisConnectionError, ConnectionError) as conn_exc: - # Backend connection error - task might not be registered yet - logger.warning(f"Backend connection error accessing task.info for {task_id}: {str(conn_exc)}") - task_info = None - except Exception as e: - logger.error(f"Unexpected error accessing task.info: {type(e).__name__}: {str(e)}", exc_info=True) - else: - if not hasattr(task, 'info'): - task_info = None - - # If still no error message, try to get from task.result again - if not error_message and hasattr(task, 'result'): - try: - task_result = task.result - if isinstance(task_result, dict): - if 'error' in task_result: - error_message = task_result.get('error') - error_type = task_result.get('error_type', 'UnknownError') - elif 'success' in task_result and not task_result.get('success'): - error_message = task_result.get('error', 'Task failed') - error_type = task_result.get('error_type', 'UnknownError') - elif isinstance(task_result, str): - error_message = task_result - elif isinstance(task_result, Exception): - error_message = str(task_result) - error_type = type(task_result).__name__ - except (ValueError, KeyError) as result_exc: - logger.warning(f"Error accessing task.result: {str(result_exc)}") - task_result = None - except (KombuOperationalError, RedisConnectionError, ConnectionError) as conn_exc: - # Backend connection error - logger.warning(f"Backend connection error accessing task.result for {task_id}: {str(conn_exc)}") - task_result = None - except Exception as info_error: - logger.warning(f"Unexpected error accessing task result: {str(info_error)}") - task_result = None - - # Use extracted error or fallback - try traceback as last resort - if not error_message: - try: - if hasattr(task, 'traceback') and task.traceback: - error_message = f"Task failed: {str(task.traceback)}" - except Exception: - pass - - if not error_message: - error_message = f"Task failed - check Celery worker logs for task {task_id}" - - if task_state == 'PROGRESS': - meta = task_info or {} - response_meta = { - 'current': meta.get('current', 0) if isinstance(meta, dict) else 0, - 'total': meta.get('total', 0) if isinstance(meta, dict) else 0, - 'percentage': meta.get('percentage', 0) if isinstance(meta, dict) else 0, - 'message': meta.get('message', 'Processing...') if isinstance(meta, dict) else 'Processing...', - 'phase': meta.get('phase', 'processing') if isinstance(meta, dict) else 'processing', - 'current_item': meta.get('current_item') if isinstance(meta, dict) else None, - 'completed': meta.get('completed', 0) if isinstance(meta, dict) else 0, - # Image generation progress fields - 'current_image': meta.get('current_image') if isinstance(meta, dict) else None, - 'current_image_id': meta.get('current_image_id') if isinstance(meta, dict) else None, - 'current_image_progress': meta.get('current_image_progress') if isinstance(meta, dict) else None, - 'total_images': meta.get('total_images') if isinstance(meta, dict) else None, - 'failed': meta.get('failed', 0) if isinstance(meta, dict) else 0, - 'results': meta.get('results', []) if isinstance(meta, dict) else [], - } - # Include step logs if available - if isinstance(meta, dict): - if 'request_steps' in meta: - response_meta['request_steps'] = meta['request_steps'] - if 'response_steps' in meta: - response_meta['response_steps'] = meta['response_steps'] - # Include image_queue if available (for image generation) - if 'image_queue' in meta: - response_meta['image_queue'] = meta['image_queue'] - return success_response( - data={ - 'state': task_state, - 'meta': response_meta - }, - request=request - ) - elif task_state == 'SUCCESS': - result = task_result or {} - meta = result if isinstance(result, dict) else {} - response_meta = { - 'percentage': 100, - 'message': meta.get('message', 'Task completed successfully') if isinstance(meta, dict) else 'Task completed successfully', - 'result': result, - 'details': meta if isinstance(meta, dict) else {}, - } - # Include step logs if available - if isinstance(meta, dict): - if 'request_steps' in meta: - response_meta['request_steps'] = meta['request_steps'] - if 'response_steps' in meta: - response_meta['response_steps'] = meta['response_steps'] - return success_response( - data={ - 'state': task_state, - 'meta': response_meta - }, - request=request - ) - elif task_state == 'FAILURE': - # Try to get error from task.info meta first (this is where run_ai_task sets it) - if not error_message and isinstance(task_info, dict): - error_message = task_info.get('error') or task_info.get('message', '') - error_type = task_info.get('error_type', 'UnknownError') - # Also check if message contains error info - if not error_message and 'message' in task_info: - msg = task_info.get('message', '') - if msg and 'Error:' in msg: - error_message = msg.replace('Error: ', '') - - # Use extracted error_message if available, otherwise try to get from error_info - if not error_message: - error_info = task_info - if isinstance(error_info, Exception): - error_message = str(error_info) - elif isinstance(error_info, dict): - error_message = error_info.get('error') or error_info.get('message', '') or str(error_info) - elif error_info: - error_message = str(error_info) - - # Final fallback - ensure we always have an error message - if not error_message or error_message.strip() == '': - error_message = f'Task execution failed - check Celery worker logs for task {task_id}' - error_type = 'ExecutionError' - - # If still no error message, try to get from task backend directly - if not error_message: - try: - # Try to get from backend's stored result - backend = task.backend - if hasattr(backend, 'get'): - stored = backend.get(task_id) - if stored and isinstance(stored, dict): - if 'error' in stored: - error_message = stored['error'] - elif isinstance(stored.get('result'), dict) and 'error' in stored['result']: - error_message = stored['result']['error'] - except Exception as backend_err: - logger.warning(f"Error getting from backend: {backend_err}") - - # Final fallback - if not error_message: - error_message = 'Task failed - check backend logs for details' - - response_meta = { - 'error': error_message, - 'percentage': 0, - 'message': f'Error: {error_message}', - } - - # Include error_type if available - if error_type: - response_meta['error_type'] = error_type - - # Include step logs if available (from task result or error_info) - result = task_result or {} - meta = result if isinstance(result, dict) else (task_info if isinstance(task_info, dict) else {}) - if isinstance(meta, dict): - if 'request_steps' in meta: - response_meta['request_steps'] = meta['request_steps'] - if 'response_steps' in meta: - response_meta['response_steps'] = meta['response_steps'] - # Also include error_type if available in meta - if 'error_type' in meta and not error_type: - response_meta['error_type'] = meta['error_type'] - # Also check for error in meta directly - if 'error' in meta and not error_message: - error_message = meta['error'] - response_meta['error'] = error_message - if 'error_type' in meta and not error_type: - error_type = meta['error_type'] - response_meta['error_type'] = error_type - - return success_response( - data={ - 'state': task_state, - 'meta': response_meta - }, - request=request - ) - else: - # PENDING, STARTED, or other states - return success_response( - data={ - 'state': task_state, - 'meta': { - 'percentage': 0, - 'message': 'Task is starting...', - 'phase': 'initializing', - } - }, - request=request - ) - except (KombuOperationalError, RedisConnectionError, ConnectionError) as conn_error: - # Backend connection error - task might not be registered yet or backend is down - logger.warning(f"Backend connection error for task {task_id}: {type(conn_error).__name__}: {str(conn_error)}") - return success_response( - data={ - 'state': 'PENDING', - 'meta': { - 'percentage': 0, - 'message': 'Task is being queued...', - 'phase': 'initializing', - 'error': None # Don't show as error, just pending - } - }, - request=request - ) - except Exception as task_error: - logger.error(f"Error accessing Celery task {task_id}: {type(task_error).__name__}: {str(task_error)}", exc_info=True) - return success_response( - data={ - 'state': 'UNKNOWN', - 'meta': { - 'percentage': 0, - 'message': f'Error accessing task: {str(task_error)}', - 'error': str(task_error) - } - }, - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - except Exception as e: - # Check if it's a connection-related error - treat as PENDING instead of error - error_type = type(e).__name__ - error_str = str(e).lower() - is_connection_error = ( - 'connection' in error_str or - 'connect' in error_str or - 'timeout' in error_str or - 'unavailable' in error_str or - 'network' in error_str or - error_type in ('ConnectionError', 'TimeoutError', 'OperationalError') - ) - - if is_connection_error: - logger.warning(f"Connection error getting task progress for {task_id}: {error_type}: {str(e)}") - return success_response( - data={ - 'state': 'PENDING', - 'meta': { - 'percentage': 0, - 'message': 'Task is being queued...', - 'phase': 'initializing', - 'error': None - } - }, - request=request - ) - else: - logger.error(f"Error getting task progress for {task_id}: {error_type}: {str(e)}", exc_info=True) - return success_response( - data={ - 'state': 'ERROR', - 'meta': { - 'error': f'Error getting task status: {str(e)}', - 'percentage': 0, - 'message': f'Error: {str(e)}' - } - }, - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - diff --git a/tenant-temp/backend/igny8_core/modules/system/views.py b/tenant-temp/backend/igny8_core/modules/system/views.py deleted file mode 100644 index b23c0b26..00000000 --- a/tenant-temp/backend/igny8_core/modules/system/views.py +++ /dev/null @@ -1,782 +0,0 @@ -""" -System module views - for global settings and prompts -""" -import psutil -import os -import logging -from rest_framework import viewsets, status as http_status, filters -from rest_framework.decorators import action, api_view, permission_classes -from rest_framework.response import Response -from rest_framework.permissions import AllowAny -from django.db import transaction, connection -from django.core.cache import cache -from django.utils import timezone -from django_filters.rest_framework import DjangoFilterBackend -from drf_spectacular.utils import extend_schema, extend_schema_view -from igny8_core.api.base import AccountModelViewSet -from igny8_core.api.response import success_response, error_response -from igny8_core.api.permissions import IsEditorOrAbove, IsAuthenticatedAndActive, IsViewerOrAbove, HasTenantAccess -from igny8_core.api.throttles import DebugScopedRateThrottle -from igny8_core.api.pagination import CustomPageNumberPagination -from .models import AIPrompt, AuthorProfile, Strategy -from .serializers import AIPromptSerializer, AuthorProfileSerializer, StrategySerializer - -logger = logging.getLogger(__name__) - - -@extend_schema_view( - list=extend_schema(tags=['System']), - create=extend_schema(tags=['System']), - retrieve=extend_schema(tags=['System']), - update=extend_schema(tags=['System']), - partial_update=extend_schema(tags=['System']), - destroy=extend_schema(tags=['System']), -) -class AIPromptViewSet(AccountModelViewSet): - """ - ViewSet for managing AI prompts - Unified API Standard v1.0 compliant - """ - queryset = AIPrompt.objects.all() - serializer_class = AIPromptSerializer - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] - throttle_scope = 'system' - throttle_classes = [DebugScopedRateThrottle] - pagination_class = CustomPageNumberPagination # Explicitly use custom pagination - - def get_queryset(self): - """Get prompts for the current account""" - return super().get_queryset().order_by('prompt_type') - - @action(detail=False, methods=['get'], url_path='by_type/(?P[^/.]+)', url_name='by_type') - def get_by_type(self, request, prompt_type=None): - """Get prompt by type""" - try: - prompt = self.get_queryset().get(prompt_type=prompt_type) - serializer = self.get_serializer(prompt) - return success_response(data=serializer.data, request=request) - except AIPrompt.DoesNotExist: - # Return default if not found - from .utils import get_default_prompt - default_value = get_default_prompt(prompt_type) - return success_response( - data={ - 'prompt_type': prompt_type, - 'prompt_value': default_value, - 'default_prompt': default_value, - 'is_active': True, - }, - request=request - ) - - @action(detail=False, methods=['post'], url_path='save', url_name='save') - def save_prompt(self, request): - """Save or update a prompt - requires editor or above""" - # Check if user has editor or above permissions - if not IsEditorOrAbove().has_permission(request, self): - return error_response( - error='Permission denied. Editor or above role required.', - status_code=http_status.HTTP_403_FORBIDDEN, - request=request - ) - - prompt_type = request.data.get('prompt_type') - prompt_value = request.data.get('prompt_value') - - if not prompt_type: - return error_response( - error='prompt_type is required', - status_code=http_status.HTTP_400_BAD_REQUEST, - request=request - ) - if prompt_value is None: - return error_response( - error='prompt_value is required', - status_code=http_status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get account - try multiple methods - account = getattr(request, 'account', None) - - # Fallback 1: Get from authenticated user's account - if not account: - user = getattr(request, 'user', None) - if user and hasattr(user, 'is_authenticated') and user.is_authenticated: - account = getattr(user, 'account', None) - - # Fallback 2: If still no account, get default account (for development) - if not account: - from igny8_core.auth.models import Account - try: - account = Account.objects.first() - except Exception: - pass - - if not account: - return error_response( - error='Account not found. Please ensure you are logged in.', - status_code=http_status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get default prompt value if creating new - from .utils import get_default_prompt - default_value = get_default_prompt(prompt_type) - - # Get or create prompt - prompt, created = AIPrompt.objects.get_or_create( - prompt_type=prompt_type, - account=account, - defaults={ - 'prompt_value': prompt_value, - 'default_prompt': default_value, - 'is_active': True, - } - ) - - if not created: - prompt.prompt_value = prompt_value - prompt.save() - - serializer = self.get_serializer(prompt) - return success_response( - data=serializer.data, - message=f'{prompt.get_prompt_type_display()} saved successfully', - request=request - ) - - @action(detail=False, methods=['post'], url_path='reset', url_name='reset') - def reset_prompt(self, request): - """Reset prompt to default - requires editor or above""" - # Check if user has editor or above permissions - if not IsEditorOrAbove().has_permission(request, self): - return error_response( - error='Permission denied. Editor or above role required.', - status_code=http_status.HTTP_403_FORBIDDEN, - request=request - ) - - prompt_type = request.data.get('prompt_type') - - if not prompt_type: - return error_response( - error='prompt_type is required', - status_code=http_status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get account - try multiple methods (same as integration_views) - account = getattr(request, 'account', None) - - # Fallback 1: Get from authenticated user's account - if not account: - user = getattr(request, 'user', None) - if user and hasattr(user, 'is_authenticated') and user.is_authenticated: - account = getattr(user, 'account', None) - - # Fallback 2: If still no account, get default account (for development) - if not account: - from igny8_core.auth.models import Account - try: - account = Account.objects.first() - except Exception: - pass - - if not account: - return error_response( - error='Account not found. Please ensure you are logged in.', - status_code=http_status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get default prompt - from .utils import get_default_prompt - default_value = get_default_prompt(prompt_type) - - # Update or create prompt - prompt, created = AIPrompt.objects.get_or_create( - prompt_type=prompt_type, - account=account, - defaults={ - 'prompt_value': default_value, - 'default_prompt': default_value, - 'is_active': True, - } - ) - - if not created: - prompt.prompt_value = default_value - prompt.save() - - serializer = self.get_serializer(prompt) - return success_response( - data=serializer.data, - message=f'{prompt.get_prompt_type_display()} reset to default', - request=request - ) - - -@extend_schema_view( - list=extend_schema(tags=['System']), - create=extend_schema(tags=['System']), - retrieve=extend_schema(tags=['System']), - update=extend_schema(tags=['System']), - partial_update=extend_schema(tags=['System']), - destroy=extend_schema(tags=['System']), -) -class AuthorProfileViewSet(AccountModelViewSet): - """ - ViewSet for managing Author Profiles - Unified API Standard v1.0 compliant - """ - queryset = AuthorProfile.objects.all() - serializer_class = AuthorProfileSerializer - permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] - throttle_scope = 'system' - throttle_classes = [DebugScopedRateThrottle] - - filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] - search_fields = ['name', 'description', 'tone'] - ordering_fields = ['name', 'created_at', 'updated_at'] - ordering = ['name'] - filterset_fields = ['is_active', 'language'] - - -@extend_schema_view( - list=extend_schema(tags=['System']), - create=extend_schema(tags=['System']), - retrieve=extend_schema(tags=['System']), - update=extend_schema(tags=['System']), - partial_update=extend_schema(tags=['System']), - destroy=extend_schema(tags=['System']), -) -class StrategyViewSet(AccountModelViewSet): - """ - ViewSet for managing Strategies - Unified API Standard v1.0 compliant - """ - queryset = Strategy.objects.all() - serializer_class = StrategySerializer - permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] - throttle_scope = 'system' - throttle_classes = [DebugScopedRateThrottle] - - filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] - search_fields = ['name', 'description'] - ordering_fields = ['name', 'created_at', 'updated_at'] - ordering = ['name'] - filterset_fields = ['is_active', 'sector'] - - -@api_view(['GET']) -@permission_classes([AllowAny]) # Public endpoint -@extend_schema( - tags=['System'], - summary='Health Check', - description='Simple health check endpoint to verify API is responding' -) -def ping(request): - """ - Simple health check endpoint - Returns unified format: {success: true, data: {status: 'ok'}} - """ - return success_response( - data={'status': 'ok'}, - request=request - ) - - -@api_view(['GET']) -@permission_classes([AllowAny]) # Public endpoint for monitoring -def system_status(request): - """ - Comprehensive system status endpoint for monitoring - Returns CPU, memory, disk, database, Redis, Celery, and process information - """ - status_data = { - 'timestamp': timezone.now().isoformat(), - 'system': {}, - 'database': {}, - 'redis': {}, - 'celery': {}, - 'processes': {}, - 'modules': {}, - } - - try: - # System Resources - cpu_percent = psutil.cpu_percent(interval=1) - cpu_count = psutil.cpu_count() - memory = psutil.virtual_memory() - disk = psutil.disk_usage('/') - - status_data['system'] = { - 'cpu': { - 'usage_percent': cpu_percent, - 'cores': cpu_count, - 'status': 'healthy' if cpu_percent < 80 else 'warning' if cpu_percent < 95 else 'critical' - }, - 'memory': { - 'total_gb': round(memory.total / (1024**3), 2), - 'used_gb': round(memory.used / (1024**3), 2), - 'available_gb': round(memory.available / (1024**3), 2), - 'usage_percent': memory.percent, - 'status': 'healthy' if memory.percent < 80 else 'warning' if memory.percent < 95 else 'critical' - }, - 'disk': { - 'total_gb': round(disk.total / (1024**3), 2), - 'used_gb': round(disk.used / (1024**3), 2), - 'free_gb': round(disk.free / (1024**3), 2), - 'usage_percent': disk.percent, - 'status': 'healthy' if disk.percent < 80 else 'warning' if disk.percent < 95 else 'critical' - } - } - except Exception as e: - logger.error(f"Error getting system resources: {str(e)}") - status_data['system'] = {'error': str(e)} - - try: - # Database Status - with connection.cursor() as cursor: - cursor.execute("SELECT 1") - db_conn = True - cursor.execute("SELECT version()") - db_version = cursor.fetchone()[0] if cursor.rowcount > 0 else 'Unknown' - - # Get database size (PostgreSQL) - try: - cursor.execute(""" - SELECT pg_size_pretty(pg_database_size(current_database())) - """) - db_size = cursor.fetchone()[0] if cursor.rowcount > 0 else 'Unknown' - except: - db_size = 'Unknown' - - # Count active connections - try: - cursor.execute("SELECT count(*) FROM pg_stat_activity WHERE state = 'active'") - active_connections = cursor.fetchone()[0] if cursor.rowcount > 0 else 0 - except: - active_connections = 0 - - status_data['database'] = { - 'connected': db_conn, - 'version': db_version, - 'size': db_size, - 'active_connections': active_connections, - 'status': 'healthy' if db_conn else 'critical' - } - except Exception as e: - logger.error(f"Error getting database status: {str(e)}") - status_data['database'] = {'connected': False, 'error': str(e), 'status': 'critical'} - - try: - # Redis Status - redis_conn = False - redis_info = {} - try: - cache.set('status_check', 'ok', 10) - test_value = cache.get('status_check') - redis_conn = test_value == 'ok' - - # Try to get Redis info if available - if hasattr(cache, 'client'): - try: - redis_client = cache.client.get_client() - redis_info = redis_client.info() - except: - pass - except Exception as e: - redis_conn = False - redis_info = {'error': str(e)} - - status_data['redis'] = { - 'connected': redis_conn, - 'status': 'healthy' if redis_conn else 'critical', - 'info': redis_info if redis_info else {} - } - except Exception as e: - logger.error(f"Error getting Redis status: {str(e)}") - status_data['redis'] = {'connected': False, 'error': str(e), 'status': 'critical'} - - try: - # Celery Status - celery_workers = [] - celery_tasks = { - 'active': 0, - 'scheduled': 0, - 'reserved': 0, - } - - try: - from celery import current_app - inspect = current_app.control.inspect() - - # Get active workers - active_workers = inspect.active() or {} - scheduled = inspect.scheduled() or {} - reserved = inspect.reserved() or {} - - celery_workers = list(active_workers.keys()) - celery_tasks['active'] = sum(len(tasks) for tasks in active_workers.values()) - celery_tasks['scheduled'] = sum(len(tasks) for tasks in scheduled.values()) - celery_tasks['reserved'] = sum(len(tasks) for tasks in reserved.values()) - - except Exception as e: - logger.warning(f"Error getting Celery status: {str(e)}") - celery_workers = [] - celery_tasks = {'error': str(e)} - - status_data['celery'] = { - 'workers': celery_workers, - 'worker_count': len(celery_workers), - 'tasks': celery_tasks, - 'status': 'healthy' if len(celery_workers) > 0 else 'warning' - } - except Exception as e: - logger.error(f"Error getting Celery status: {str(e)}") - status_data['celery'] = {'error': str(e), 'status': 'warning'} - - try: - # Process Monitoring by Stack/Component - processes = { - 'gunicorn': [], - 'celery': [], - 'postgres': [], - 'redis': [], - 'nginx': [], - 'other': [] - } - - process_stats = { - 'gunicorn': {'count': 0, 'cpu': 0, 'memory_mb': 0}, - 'celery': {'count': 0, 'cpu': 0, 'memory_mb': 0}, - 'postgres': {'count': 0, 'cpu': 0, 'memory_mb': 0}, - 'redis': {'count': 0, 'cpu': 0, 'memory_mb': 0}, - 'nginx': {'count': 0, 'cpu': 0, 'memory_mb': 0}, - } - - for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'cpu_percent', 'memory_info']): - try: - proc_info = proc.info - name = proc_info['name'].lower() - cmdline = ' '.join(proc_info['cmdline']) if proc_info['cmdline'] else '' - cmdline_lower = cmdline.lower() - - cpu = proc_info.get('cpu_percent', 0) or 0 - memory = proc_info.get('memory_info', None) - memory_mb = (memory.rss / (1024**2)) if memory else 0 - - # Categorize processes - if 'gunicorn' in cmdline_lower or 'gunicorn' in name: - processes['gunicorn'].append({ - 'pid': proc_info['pid'], - 'name': name, - 'cpu_percent': round(cpu, 2), - 'memory_mb': round(memory_mb, 2) - }) - process_stats['gunicorn']['count'] += 1 - process_stats['gunicorn']['cpu'] += cpu - process_stats['gunicorn']['memory_mb'] += memory_mb - elif 'celery' in cmdline_lower or 'celery' in name: - processes['celery'].append({ - 'pid': proc_info['pid'], - 'name': name, - 'cpu_percent': round(cpu, 2), - 'memory_mb': round(memory_mb, 2) - }) - process_stats['celery']['count'] += 1 - process_stats['celery']['cpu'] += cpu - process_stats['celery']['memory_mb'] += memory_mb - elif 'postgres' in name or 'postgresql' in name: - processes['postgres'].append({ - 'pid': proc_info['pid'], - 'name': name, - 'cpu_percent': round(cpu, 2), - 'memory_mb': round(memory_mb, 2) - }) - process_stats['postgres']['count'] += 1 - process_stats['postgres']['cpu'] += cpu - process_stats['postgres']['memory_mb'] += memory_mb - elif 'redis' in name or 'redis-server' in name: - processes['redis'].append({ - 'pid': proc_info['pid'], - 'name': name, - 'cpu_percent': round(cpu, 2), - 'memory_mb': round(memory_mb, 2) - }) - process_stats['redis']['count'] += 1 - process_stats['redis']['cpu'] += cpu - process_stats['redis']['memory_mb'] += memory_mb - elif 'nginx' in name or 'caddy' in name: - processes['nginx'].append({ - 'pid': proc_info['pid'], - 'name': name, - 'cpu_percent': round(cpu, 2), - 'memory_mb': round(memory_mb, 2) - }) - process_stats['nginx']['count'] += 1 - process_stats['nginx']['cpu'] += cpu - process_stats['nginx']['memory_mb'] += memory_mb - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - continue - - # Round stats - for key in process_stats: - process_stats[key]['cpu'] = round(process_stats[key]['cpu'], 2) - process_stats[key]['memory_mb'] = round(process_stats[key]['memory_mb'], 2) - - status_data['processes'] = { - 'by_stack': process_stats, - 'details': {k: v[:10] for k, v in processes.items()} # Limit details to 10 per type - } - except Exception as e: - logger.error(f"Error getting process information: {str(e)}") - status_data['processes'] = {'error': str(e)} - - try: - # Module-specific task counts - from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas - from igny8_core.modules.writer.models import Tasks, Images - - status_data['modules'] = { - 'planner': { - 'keywords': Keywords.objects.count(), - 'clusters': Clusters.objects.count(), - 'content_ideas': ContentIdeas.objects.count(), - }, - 'writer': { - 'tasks': Tasks.objects.count(), - 'images': Images.objects.count(), - } - } - except Exception as e: - logger.error(f"Error getting module statistics: {str(e)}") - status_data['modules'] = {'error': str(e)} - - return success_response(data=status_data, request=request) - - -@api_view(['GET']) -@permission_classes([AllowAny]) # Will check admin in view -def get_request_metrics(request, request_id): - """ - Get resource metrics for a specific request. - Only accessible to admins/developers. - """ - # Check if user is admin/developer - if not request.user.is_authenticated: - return error_response( - error='Authentication required', - status_code=http_status.HTTP_401_UNAUTHORIZED, - request=request - ) - - if not (hasattr(request.user, 'is_admin_or_developer') and request.user.is_admin_or_developer()): - return error_response( - error='Admin access required', - status_code=http_status.HTTP_403_FORBIDDEN, - request=request - ) - - # Get metrics from cache - from django.core.cache import cache - metrics = cache.get(f"resource_tracking_{request_id}") - - if not metrics: - return error_response( - error='Metrics not found or expired', - status_code=http_status.HTTP_404_NOT_FOUND, - request=request - ) - - return success_response(data=metrics, request=request) - - -@api_view(['POST']) -@permission_classes([AllowAny]) -def gitea_webhook(request): - """ - Webhook endpoint to receive push events from Gitea. - Handles automatic deployment when code is pushed to the repository. - """ - import json - import subprocess - import os - - try: - # Parse webhook payload - payload = json.loads(request.body) - event_type = request.headers.get('X-Gitea-Event', 'push') - - logger.info(f"[Webhook] Received {event_type} event from Gitea") - - # Only process push events - if event_type != 'push': - return success_response( - data={'status': 'ignored'}, - message=f'Event type {event_type} is not processed', - request=request - ) - - # Extract repository information - repository = payload.get('repository', {}) - repo_name = repository.get('name', '') - repo_full_name = repository.get('full_name', '') - ref = payload.get('ref', '') - - # Only process pushes to main branch - if ref != 'refs/heads/main': - logger.info(f"[Webhook] Ignoring push to {ref}, only processing main branch") - return success_response( - data={'status': 'ignored'}, - message=f'Push to {ref} ignored, only main branch is processed', - request=request - ) - - # Get commit information - commits = payload.get('commits', []) - commit_count = len(commits) - pusher = payload.get('pusher', {}).get('username', 'unknown') - - logger.info(f"[Webhook] Processing push: {commit_count} commit(s) by {pusher} to {repo_full_name}") - - # Pull latest code - run git pull directly - try: - import subprocess - logger.info(f"[Webhook] Pulling latest code...") - # Set safe directory first - subprocess.run( - ['git', 'config', '--global', '--add', 'safe.directory', '/data/app/igny8'], - capture_output=True, - timeout=5 - ) - # Pull latest code - result = subprocess.run( - ['git', '-C', '/data/app/igny8', 'pull', 'origin', 'main'], - capture_output=True, - text=True, - timeout=30 - ) - if result.returncode == 0: - logger.info(f"[Webhook] Git pull successful") - else: - logger.error(f"[Webhook] Git pull failed: {result.stderr}") - except Exception as e: - logger.error(f"[Webhook] Git pull error: {e}") - - # Trigger deployment - restart containers - deployment_success = False - deployment_error = None - - try: - # Try to use docker Python library first, fallback to subprocess - try: - import docker as docker_lib - client = docker_lib.DockerClient(base_url='unix://var/run/docker.sock') - - # Restart frontend container (don't restart backend from within itself) - logger.info(f"[Webhook] Restarting frontend container...") - frontend_container = client.containers.get("igny8_frontend") - frontend_container.restart(timeout=30) - logger.info(f"[Webhook] Frontend container restarted successfully") - - # Schedule backend restart via subprocess in background (non-blocking) - # This avoids deadlock from restarting the container we're running in - logger.info(f"[Webhook] Scheduling backend container restart...") - import threading - def restart_backend(): - import time - time.sleep(2) # Give webhook time to respond - try: - backend_container = client.containers.get("igny8_backend") - backend_container.restart(timeout=30) - logger.info(f"[Webhook] Backend container restarted successfully (delayed)") - except Exception as e: - logger.error(f"[Webhook] Delayed backend restart failed: {e}") - - restart_thread = threading.Thread(target=restart_backend, daemon=True) - restart_thread.start() - - deployment_success = True - - except ImportError: - # Fallback to subprocess with docker command - logger.info(f"[Webhook] Docker library not available, using subprocess...") - - # Try /usr/bin/docker or docker in PATH - docker_cmd = "/usr/bin/docker" - import shutil - if not os.path.exists(docker_cmd): - docker_cmd = shutil.which("docker") or "docker" - - # Restart backend container - logger.info(f"[Webhook] Restarting backend container...") - backend_result = subprocess.run( - [docker_cmd, "restart", "igny8_backend"], - capture_output=True, - text=True, - timeout=30 - ) - - if backend_result.returncode != 0: - raise Exception(f"Backend restart failed: {backend_result.stderr}") - logger.info(f"[Webhook] Backend container restarted successfully") - - # Restart frontend container - logger.info(f"[Webhook] Restarting frontend container...") - frontend_result = subprocess.run( - [docker_cmd, "restart", "igny8_frontend"], - capture_output=True, - text=True, - timeout=30 - ) - - if frontend_result.returncode != 0: - raise Exception(f"Frontend restart failed: {frontend_result.stderr}") - logger.info(f"[Webhook] Frontend container restarted successfully") - - deployment_success = True - - logger.info(f"[Webhook] Deployment completed: containers restarted") - - except subprocess.TimeoutExpired as e: - deployment_error = f"Deployment timeout: {str(e)}" - logger.error(f"[Webhook] {deployment_error}") - except Exception as deploy_error: - deployment_error = str(deploy_error) - logger.error(f"[Webhook] Deployment error: {deploy_error}", exc_info=True) - - return success_response( - data={ - 'status': 'success' if deployment_success else 'partial', - 'repository': repo_full_name, - 'branch': ref, - 'commits': commit_count, - 'pusher': pusher, - 'event': event_type, - 'deployment': { - 'success': deployment_success, - 'error': deployment_error - } - }, - message='Webhook received and processed', - request=request - ) - - except json.JSONDecodeError as e: - logger.error(f"[Webhook] Invalid JSON payload: {e}") - return error_response( - error='Invalid JSON payload', - status_code=http_status.HTTP_400_BAD_REQUEST, - request=request - ) - - except Exception as e: - logger.error(f"[Webhook] Error processing webhook: {e}", exc_info=True) - return error_response( - error=str(e), - status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) diff --git a/tenant-temp/backend/igny8_core/modules/writer/views.py b/tenant-temp/backend/igny8_core/modules/writer/views.py deleted file mode 100644 index 76cba558..00000000 --- a/tenant-temp/backend/igny8_core/modules/writer/views.py +++ /dev/null @@ -1,1710 +0,0 @@ -from rest_framework import viewsets, filters, status -from rest_framework.decorators import action -from rest_framework.response import Response -from django_filters.rest_framework import DjangoFilterBackend -from django.db import transaction, models -from django.db.models import Q -from drf_spectacular.utils import extend_schema, extend_schema_view -from igny8_core.api.base import SiteSectorModelViewSet -from igny8_core.api.pagination import CustomPageNumberPagination -from igny8_core.api.response import success_response, error_response -from igny8_core.api.throttles import DebugScopedRateThrottle -from igny8_core.api.permissions import IsAuthenticatedAndActive, IsViewerOrAbove, IsEditorOrAbove -from .models import Tasks, Images, Content -from .serializers import ( - TasksSerializer, - ImagesSerializer, - ContentSerializer, - ContentTaxonomySerializer, -) -from igny8_core.business.content.models import ContentTaxonomy # ContentAttribute model exists but serializer removed in Stage 1 -from igny8_core.business.content.services.content_generation_service import ContentGenerationService -from igny8_core.business.content.services.validation_service import ContentValidationService -from igny8_core.business.content.services.metadata_mapping_service import MetadataMappingService -from igny8_core.business.billing.exceptions import InsufficientCreditsError - - - -@extend_schema_view( - list=extend_schema(tags=['Writer']), - create=extend_schema(tags=['Writer']), - retrieve=extend_schema(tags=['Writer']), - update=extend_schema(tags=['Writer']), - partial_update=extend_schema(tags=['Writer']), - destroy=extend_schema(tags=['Writer']), -) -class TasksViewSet(SiteSectorModelViewSet): - """ - ViewSet for managing tasks with CRUD operations - Unified API Standard v1.0 compliant - Stage 1 Refactored - removed deprecated filters - """ - queryset = Tasks.objects.select_related('cluster', 'site', 'sector') - serializer_class = TasksSerializer - permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] - pagination_class = CustomPageNumberPagination # Explicitly use custom pagination - throttle_scope = 'writer' - throttle_classes = [DebugScopedRateThrottle] - - # DRF filtering configuration - filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] - - # Search configuration - search_fields = ['title', 'keywords'] - - # Ordering configuration - ordering_fields = ['title', 'created_at', 'status'] - ordering = ['-created_at'] # Default ordering (newest first) - - # Filter configuration - Stage 1: removed entity_type, cluster_role - filterset_fields = ['status', 'cluster_id', 'content_type', 'content_structure'] - - def perform_create(self, serializer): - """Require explicit site_id and sector_id - no defaults.""" - user = getattr(self.request, 'user', None) - - try: - query_params = getattr(self.request, 'query_params', None) - if query_params is None: - query_params = getattr(self.request, 'GET', {}) - except AttributeError: - query_params = {} - - site_id = serializer.validated_data.get('site_id') or query_params.get('site_id') - sector_id = serializer.validated_data.get('sector_id') or query_params.get('sector_id') - - from igny8_core.auth.models import Site, Sector - from rest_framework.exceptions import ValidationError - - # Site ID is REQUIRED - if not site_id: - raise ValidationError("site_id is required. Please select a site.") - - try: - site = Site.objects.get(id=site_id) - except Site.DoesNotExist: - raise ValidationError(f"Site with id {site_id} does not exist") - - # Sector ID is REQUIRED - if not sector_id: - raise ValidationError("sector_id is required. Please select a sector.") - - try: - sector = Sector.objects.get(id=sector_id) - if sector.site_id != site_id: - raise ValidationError(f"Sector does not belong to the selected site") - except Sector.DoesNotExist: - raise ValidationError(f"Sector with id {sector_id} does not exist") - - serializer.validated_data.pop('site_id', None) - serializer.validated_data.pop('sector_id', None) - - account = getattr(self.request, 'account', None) - if not account and user and user.is_authenticated and user.account: - account = user.account - if not account: - account = site.account - - serializer.save(account=account, site=site, sector=sector) - - @action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete') - def bulk_delete(self, request): - """Bulk delete tasks""" - ids = request.data.get('ids', []) - if not ids: - return error_response( - error='No IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - queryset = self.get_queryset() - deleted_count, _ = queryset.filter(id__in=ids).delete() - - return success_response(data={'deleted_count': deleted_count}, request=request) - - @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update') - def bulk_update(self, request): - """Bulk update task status""" - ids = request.data.get('ids', []) - status_value = request.data.get('status') - - if not ids: - return error_response( - error='No IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - if not status_value: - return error_response( - error='No status provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - queryset = self.get_queryset() - updated_count = queryset.filter(id__in=ids).update(status=status_value) - - return success_response(data={'updated_count': updated_count}, request=request) - - @action(detail=False, methods=['post'], url_path='auto_generate_content', url_name='auto_generate_content') - def auto_generate_content(self, request): - """Auto-generate content for tasks using ContentGenerationService""" - import logging - - logger = logging.getLogger(__name__) - - try: - ids = request.data.get('ids', []) - if not ids: - return error_response( - error='No IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - if len(ids) > 10: - return error_response( - error='Maximum 10 tasks allowed for content generation', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get account - account = getattr(request, 'account', None) - if not account: - return error_response( - error='Account is required', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Validate task IDs exist - queryset = self.get_queryset() - existing_tasks = queryset.filter(id__in=ids, account=account) - existing_count = existing_tasks.count() - - if existing_count == 0: - return error_response( - error=f'No tasks found for the provided IDs: {ids}', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - # Use service to generate content - service = ContentGenerationService() - try: - result = service.generate_content(ids, account) - - if result.get('success'): - if 'task_id' in result: - # Async task queued - return success_response( - data={'task_id': result['task_id']}, - message=result.get('message', 'Content generation started'), - request=request - ) - else: - # Synchronous execution - return success_response( - data=result, - message='Content generated successfully', - request=request - ) - else: - return error_response( - error=result.get('error', 'Content generation failed'), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - except InsufficientCreditsError as e: - return error_response( - error=str(e), - status_code=status.HTTP_402_PAYMENT_REQUIRED, - request=request - ) - except Exception as e: - logger.error(f"Error in auto_generate_content: {str(e)}", exc_info=True) - return error_response( - error=f'Content generation failed: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - except Exception as e: - logger.error(f"Unexpected error in auto_generate_content: {str(e)}", exc_info=True) - return error_response( - error=f'Unexpected error: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - -@extend_schema_view( - list=extend_schema(tags=['Writer']), - create=extend_schema(tags=['Writer']), - retrieve=extend_schema(tags=['Writer']), - update=extend_schema(tags=['Writer']), - partial_update=extend_schema(tags=['Writer']), - destroy=extend_schema(tags=['Writer']), -) -class ImagesViewSet(SiteSectorModelViewSet): - """ - ViewSet for managing content images - Unified API Standard v1.0 compliant - """ - queryset = Images.objects.all() - serializer_class = ImagesSerializer - permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] - pagination_class = CustomPageNumberPagination - throttle_scope = 'writer' - throttle_classes = [DebugScopedRateThrottle] - - filter_backends = [DjangoFilterBackend, filters.OrderingFilter] - ordering_fields = ['created_at', 'position', 'id'] - ordering = ['-id'] # Sort by ID descending (newest first) - filterset_fields = ['task_id', 'content_id', 'image_type', 'status'] - - def perform_create(self, serializer): - """Override to automatically set account, site, and sector""" - from rest_framework.exceptions import ValidationError - - # Get site and sector from request (set by middleware) or user's active context - site = getattr(self.request, 'site', None) - sector = getattr(self.request, 'sector', None) - - if not site: - # Fallback to user's active site if not set by middleware - user = getattr(self.request, 'user', None) - if user and user.is_authenticated and hasattr(user, 'active_site'): - site = user.active_site - - if not sector and site: - # Fallback to default sector for the site if not set by middleware - from igny8_core.auth.models import Sector - sector = site.sectors.filter(is_default=True).first() - - # Site and sector are required - raise ValidationError if not available - # Use dict format for ValidationError to ensure proper error structure - if not site: - raise ValidationError({"site": ["Site is required for image creation. Please select a site."]}) - if not sector: - raise ValidationError({"sector": ["Sector is required for image creation. Please select a sector."]}) - - # Add site and sector to validated_data so base class can validate access - serializer.validated_data['site'] = site - serializer.validated_data['sector'] = sector - - # Call parent to set account and validate access - super().perform_create(serializer) - - @action(detail=True, methods=['get'], url_path='file', url_name='image_file') - def serve_image_file(self, request, pk=None): - """ - Serve image file from local path via URL - GET /api/v1/writer/images/{id}/file/ - """ - import os - from django.http import FileResponse, Http404 - from django.conf import settings - - try: - # Get image directly without account filtering for file serving - # This allows public access to image files - try: - image = Images.objects.get(pk=pk) - except Images.DoesNotExist: - return error_response( - error='Image not found', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - # Check if image has a local path - if not image.image_path: - return error_response( - error='No local file path available for this image', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - file_path = image.image_path - - # Verify file exists at the saved path - if not os.path.exists(file_path): - logger.error(f"[serve_image_file] Image {pk} - File not found at saved path: {file_path}") - return error_response( - error=f'Image file not found at: {file_path}', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - # Check if file is readable - if not os.access(file_path, os.R_OK): - return error_response( - error='Image file is not readable', - status_code=status.HTTP_403_FORBIDDEN, - request=request - ) - - # Determine content type from file extension - import mimetypes - content_type, _ = mimetypes.guess_type(file_path) - if not content_type: - content_type = 'image/png' # Default to PNG - - # Serve the file - try: - return FileResponse( - open(file_path, 'rb'), - content_type=content_type, - filename=os.path.basename(file_path) - ) - except Exception as e: - return error_response( - error=f'Failed to serve file: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - except Images.DoesNotExist: - return error_response( - error='Image not found', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.error(f"Error serving image file: {str(e)}", exc_info=True) - return error_response( - error=f'Failed to serve image: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - @action(detail=False, methods=['post'], url_path='auto_generate', url_name='auto_generate_images') - def auto_generate_images(self, request): - """Auto-generate images for tasks using AI""" - task_ids = request.data.get('task_ids', []) - if not task_ids: - return error_response( - error='No task IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - if len(task_ids) > 10: - return error_response( - error='Maximum 10 tasks allowed for image generation', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Get account - account = getattr(request, 'account', None) - account_id = account.id if account else None - - # Try to queue Celery task, fall back to synchronous if Celery not available - try: - from igny8_core.ai.tasks import run_ai_task - from kombu.exceptions import OperationalError as KombuOperationalError - - if hasattr(run_ai_task, 'delay'): - # Celery is available - queue async task - task = run_ai_task.delay( - function_name='generate_images', - payload={'ids': task_ids}, - account_id=account_id - ) - return success_response( - data={'task_id': str(task.id)}, - message='Image generation started', - request=request - ) - else: - # Celery not available - execute synchronously - result = run_ai_task( - function_name='generate_images', - payload={'ids': task_ids}, - account_id=account_id - ) - if result.get('success'): - return success_response( - data={'images_created': result.get('count', 0)}, - message=result.get('message', 'Image generation completed'), - request=request - ) - else: - return error_response( - error=result.get('error', 'Image generation failed'), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - except KombuOperationalError as e: - return error_response( - error='Task queue unavailable. Please try again.', - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - request=request - ) - except ImportError: - # Tasks module not available - return error_response( - error='Image generation task not available', - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - request=request - ) - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.error(f"Error queuing image generation task: {str(e)}", exc_info=True) - return error_response( - error=f'Failed to start image generation: {str(e)}', - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update') - def bulk_update(self, request): - """Bulk update image status by content_id or image IDs - Updates all images for a content record (featured + 1-6 in-article images) - """ - from django.db.models import Q - from .models import Content - - content_id = request.data.get('content_id') - image_ids = request.data.get('ids', []) - status_value = request.data.get('status') - - if not status_value: - return error_response( - error='No status provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - queryset = self.get_queryset() - - # Update by content_id if provided, otherwise by image IDs - if content_id: - try: - # Get the content object to also update images linked directly to content - content = Content.objects.get(id=content_id) - - # Update images linked directly to content (all images: featured + in-article) - # Note: task field was removed in refactor - images now link directly to content - updated_count = queryset.filter(content=content).update(status=status_value) - except Content.DoesNotExist: - return error_response( - error='Content not found', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - elif image_ids: - updated_count = queryset.filter(id__in=image_ids).update(status=status_value) - else: - return error_response( - error='Either content_id or ids must be provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - return success_response(data={'updated_count': updated_count}, request=request) - - @action(detail=False, methods=['get'], url_path='content_images', url_name='content_images') - def content_images(self, request): - """Get images grouped by content - one row per content with featured and in-article images""" - from .serializers import ContentImagesGroupSerializer, ContentImageSerializer - - account = getattr(request, 'account', None) - - # Get site_id and sector_id from query parameters - site_id = request.query_params.get('site_id') - sector_id = request.query_params.get('sector_id') - - # Get all content that has images (either directly or via task) - # First, get content with direct image links - queryset = Content.objects.filter(images__isnull=False) - if account: - queryset = queryset.filter(account=account) - - # Apply site/sector filtering if provided - if site_id: - try: - queryset = queryset.filter(site_id=int(site_id)) - except (ValueError, TypeError): - pass - - if sector_id: - try: - queryset = queryset.filter(sector_id=int(sector_id)) - except (ValueError, TypeError): - pass - - # Task field removed in Stage 1 - images are now only linked to content directly - # All images must be linked via content, not task - - # Build grouped response - grouped_data = [] - content_ids = set(queryset.values_list('id', flat=True).distinct()) - - for content_id in content_ids: - try: - content = Content.objects.get(id=content_id) - - # Get images linked directly to content - content_images = Images.objects.filter(content=content).order_by('position') - - # Get featured image - featured_image = content_images.filter(image_type='featured').first() - - # Get in-article images (sorted by position) - in_article_images = list(content_images.filter(image_type='in_article').order_by('position')) - - # Determine overall status - all_images = list(content_images) - if not all_images: - overall_status = 'pending' - elif all(img.status == 'generated' for img in all_images): - overall_status = 'complete' - elif any(img.status == 'failed' for img in all_images): - overall_status = 'failed' - elif any(img.status == 'generated' for img in all_images): - overall_status = 'partial' - else: - overall_status = 'pending' - - # Create serializer instances with request context for proper URL generation - featured_serializer = ContentImageSerializer(featured_image, context={'request': request}) if featured_image else None - in_article_serializers = [ContentImageSerializer(img, context={'request': request}) for img in in_article_images] - - grouped_data.append({ - 'content_id': content.id, - 'content_title': content.title or content.meta_title or f"Content #{content.id}", - 'content_status': content.status, # Add content status - 'featured_image': featured_serializer.data if featured_serializer else None, - 'in_article_images': [s.data for s in in_article_serializers], - 'overall_status': overall_status, - }) - except Content.DoesNotExist: - continue - - # Sort by content title - grouped_data.sort(key=lambda x: x['content_title']) - - return success_response( - data={ - 'count': len(grouped_data), - 'results': grouped_data - }, - request=request - ) - - @action(detail=False, methods=['post'], url_path='generate_images', url_name='generate_images') - def generate_images(self, request): - """Generate images from prompts - queues Celery task for sequential processing""" - from igny8_core.ai.tasks import process_image_generation_queue - - account = getattr(request, 'account', None) - image_ids = request.data.get('ids', []) - content_id = request.data.get('content_id') - - if not image_ids: - return error_response( - error='No image IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - account_id = account.id if account else None - - # Queue Celery task - try: - if hasattr(process_image_generation_queue, 'delay'): - task = process_image_generation_queue.delay( - image_ids=image_ids, - account_id=account_id, - content_id=content_id - ) - return success_response( - data={'task_id': str(task.id)}, - message='Image generation started', - request=request - ) - else: - # Fallback to synchronous execution (for testing) - result = process_image_generation_queue( - image_ids=image_ids, - account_id=account_id, - content_id=content_id - ) - return success_response(data=result, request=request) - except Exception as e: - logger.error(f"[generate_images] Error: {str(e)}", exc_info=True) - return error_response( - error=str(e), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - @action(detail=True, methods=['get'], url_path='validation', url_name='validation') - def validation(self, request, pk=None): - """ - Stage 3: Get validation checklist for content. - - GET /api/v1/writer/content/{id}/validation/ - Returns aggregated validation checklist for Writer UI. - """ - content = self.get_object() - validation_service = ContentValidationService() - - errors = validation_service.validate_content(content) - publish_errors = validation_service.validate_for_publish(content) - - return success_response( - data={ - 'content_id': content.id, - 'is_valid': len(errors) == 0, - 'ready_to_publish': len(publish_errors) == 0, - 'validation_errors': errors, - 'publish_errors': publish_errors, - 'metadata': { - 'has_entity_type': bool(content.content_type), - 'entity_type': content.content_type, - 'has_cluster_mapping': self._has_cluster_mapping(content), - 'has_taxonomy_mapping': self._has_taxonomy_mapping(content), - } - }, - request=request - ) - - @action(detail=True, methods=['post'], url_path='validate', url_name='validate') - def validate(self, request, pk=None): - """ - Stage 3: Re-run validators and return actionable errors. - - POST /api/v1/writer/content/{id}/validate/ - Re-validates content and returns structured errors. - """ - content = self.get_object() - validation_service = ContentValidationService() - - # Persist metadata mappings if task exists - # Metadata is now persisted directly on content - no task linkage needed - # mapping_service = MetadataMappingService() # DEPRECATED - # mapping_service.persist_task_metadata_to_content(content) # DEPRECATED - - errors = validation_service.validate_for_publish(content) - - return success_response( - data={ - 'content_id': content.id, - 'is_valid': len(errors) == 0, - 'errors': errors, - }, - request=request - ) - - def _has_cluster_mapping(self, content): - """Helper to check if content has cluster mapping""" - from igny8_core.business.content.models import ContentClusterMap - return ContentClusterMap.objects.filter(content=content).exists() - - def _has_taxonomy_mapping(self, content): - """Helper to check if content has taxonomy mapping""" - from igny8_core.business.content.models import ContentTaxonomyMap - return ContentTaxonomyMap.objects.filter(content=content).exists() - -@extend_schema_view( - list=extend_schema(tags=['Writer']), - create=extend_schema(tags=['Writer']), - retrieve=extend_schema(tags=['Writer']), - update=extend_schema(tags=['Writer']), - partial_update=extend_schema(tags=['Writer']), - destroy=extend_schema(tags=['Writer']), -) -class ContentViewSet(SiteSectorModelViewSet): - """ - ViewSet for managing content with new unified structure - Unified API Standard v1.0 compliant - Stage 1 Refactored - removed deprecated fields - """ - queryset = Content.objects.select_related('cluster', 'site', 'sector').prefetch_related('taxonomy_terms') - serializer_class = ContentSerializer - permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] - pagination_class = CustomPageNumberPagination - throttle_scope = 'writer' - throttle_classes = [DebugScopedRateThrottle] - - filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] - search_fields = ['title', 'content_html', 'external_url'] - ordering_fields = ['created_at', 'updated_at', 'status'] - ordering = ['-created_at'] - # Stage 1: removed task_id, entity_type, content_format, cluster_role, sync_status, external_type - filterset_fields = [ - 'cluster_id', - 'status', - 'content_type', - 'content_structure', - 'source', - ] - - def perform_create(self, serializer): - """Override to automatically set account""" - account = getattr(self.request, 'account', None) - if account: - serializer.save(account=account) - else: - serializer.save() - - @action(detail=False, methods=['POST'], url_path='bulk_delete', url_name='bulk_delete') - def bulk_delete(self, request): - """Bulk delete content""" - ids = request.data.get('ids', []) - if not ids: - return error_response( - error='No IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - queryset = self.get_queryset() - deleted_count, _ = queryset.filter(id__in=ids).delete() - - return success_response(data={'deleted_count': deleted_count}, request=request) - - @action(detail=True, methods=['post'], url_path='publish', url_name='publish', permission_classes=[IsAuthenticatedAndActive, IsEditorOrAbove]) - def publish(self, request, pk=None): - """ - STAGE 3: Publish content to WordPress site via Celery task. - Mirrors the automated publishing flow for manual publishing from Review page. - - POST /api/v1/writer/content/{id}/publish/ - { - "site_integration_id": 1 // Optional - defaults to finding WordPress integration for content's site - } - """ - from igny8_core.business.integration.models import SiteIntegration - from igny8_core.tasks.wordpress_publishing import publish_content_to_wordpress - import logging - - logger = logging.getLogger(__name__) - - content = self.get_object() - - # STAGE 3: Prevent duplicate publishing - if content.external_id: - return error_response( - error='Content already published. Use WordPress to update or unpublish first.', - status_code=status.HTTP_400_BAD_REQUEST, - request=request, - errors={'external_id': [f'Already published with ID: {content.external_id}']} - ) - - # Get site integration (use content's site if not specified) - site_integration_id = request.data.get('site_integration_id') - - if not site_integration_id: - # Find WordPress integration for this site - site_integrations = SiteIntegration.objects.filter( - site=content.site, - platform='wordpress', - is_active=True - ) - - if not site_integrations.exists(): - return error_response( - error='No active WordPress integration found for this site', - status_code=status.HTTP_400_BAD_REQUEST, - request=request, - errors={'site_integration': ['WordPress integration is required to publish']} - ) - - site_integration = site_integrations.first() - else: - try: - site_integration = SiteIntegration.objects.get( - id=site_integration_id, - site=content.site, - platform='wordpress' - ) - except SiteIntegration.DoesNotExist: - return error_response( - error=f'WordPress integration with id {site_integration_id} not found for this site', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - # OPTIMISTIC UPDATE: Set status to published immediately for better UX - # The Celery task will update external_id and external_url when WordPress responds - content.status = 'published' - content.save(update_fields=['status', 'updated_at']) - - # Queue publishing task (same as automated flow) - try: - result = publish_content_to_wordpress.delay( - content_id=content.id, - site_integration_id=site_integration.id - ) - - logger.info(f"[ContentViewSet.publish] Queued Celery task {result.id} for content {content.id}, status set to 'published'") - - return success_response( - data={ - 'content_id': content.id, - 'task_id': result.id, - 'status': 'published', - 'message': 'Publishing queued - content will be published to WordPress shortly' - }, - message='Content status updated to published and queued for WordPress', - request=request, - status_code=status.HTTP_202_ACCEPTED - ) - - except Exception as e: - logger.error(f"[ContentViewSet.publish] Error queuing publish task: {str(e)}", exc_info=True) - # Revert status on error - content.status = 'review' - content.save(update_fields=['status', 'updated_at']) - return error_response( - error=f"Failed to queue publishing task: {str(e)}", - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - @action(detail=True, methods=['get'], url_path='wordpress_status', url_name='wordpress_status') - def wordpress_status(self, request, pk=None): - """ - Get WordPress post status for published content. - Calls WordPress REST API to get current status. - - GET /api/v1/writer/content/{id}/wordpress_status/ - Returns: { - 'wordpress_status': 'publish'|'draft'|'pending'|null, - 'external_id': 123, - 'external_url': 'https://...', - 'last_checked': '2025-11-30T...' - } - """ - import requests - from django.utils import timezone - from igny8_core.business.integration.models import SiteIntegration - import logging - - logger = logging.getLogger(__name__) - content = self.get_object() - - if not content.external_id: - return success_response( - data={ - 'wordpress_status': None, - 'external_id': None, - 'external_url': None, - 'message': 'Content not published to WordPress yet' - }, - request=request - ) - - # Get WordPress integration for this content's site - try: - site_integration = SiteIntegration.objects.filter( - site=content.site, - platform='wordpress', - is_active=True - ).first() - - if not site_integration: - return error_response( - error='No active WordPress integration found', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - # Call WordPress REST API to get post status - wordpress_url = f"{site_integration.site_url}/wp-json/igny8/v1/post-status/{content.external_id}/" - headers = { - 'X-IGNY8-API-KEY': site_integration.api_key, - } - - response = requests.get(wordpress_url, headers=headers, timeout=10) - - if response.status_code == 200: - wp_data = response.json().get('data', {}) - return success_response( - data={ - 'wordpress_status': wp_data.get('post_status'), - 'external_id': content.external_id, - 'external_url': content.external_url, - 'post_title': wp_data.get('post_title'), - 'post_modified': wp_data.get('post_modified'), - 'last_checked': timezone.now().isoformat() - }, - request=request - ) - else: - logger.error(f"WordPress API error: {response.status_code} - {response.text}") - return error_response( - error=f'Failed to get WordPress status: {response.status_code}', - status_code=status.HTTP_502_BAD_GATEWAY, - request=request - ) - - except requests.RequestException as e: - logger.error(f"Request to WordPress failed: {str(e)}") - return error_response( - error=f'Connection to WordPress failed: {str(e)}', - status_code=status.HTTP_502_BAD_GATEWAY, - request=request - ) - except Exception as e: - logger.error(f"Error getting WordPress status: {str(e)}", exc_info=True) - return error_response( - error=str(e), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - @action(detail=True, methods=['post'], url_path='unpublish', url_name='unpublish', permission_classes=[IsAuthenticatedAndActive, IsEditorOrAbove]) - def unpublish(self, request, pk=None): - """ - STAGE 3: Unpublish content - clear external references and revert to draft. - Note: This does NOT delete the WordPress post, only clears the link. - - POST /api/v1/writer/content/{id}/unpublish/ - """ - content = self.get_object() - - if not content.external_id: - return error_response( - error='Content is not published', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - # Store the old values for response - old_external_id = content.external_id - old_external_url = content.external_url - - # Clear external references and revert status - content.external_id = None - content.external_url = None - content.status = 'draft' - content.save(update_fields=['external_id', 'external_url', 'status', 'updated_at']) - - return success_response( - data={ - 'content_id': content.id, - 'status': content.status, - 'was_external_id': old_external_id, - 'was_external_url': old_external_url, - }, - message='Content unpublished successfully. WordPress post was not deleted.', - request=request - ) - - @action(detail=False, methods=['post'], url_path='generate_image_prompts', url_name='generate_image_prompts') - def generate_image_prompts(self, request): - """Generate image prompts for content records - same pattern as other AI functions""" - from igny8_core.ai.tasks import run_ai_task - - account = getattr(request, 'account', None) - ids = request.data.get('ids', []) - - if not ids: - return error_response( - error='No IDs provided', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - account_id = account.id if account else None - - # Queue Celery task - try: - if hasattr(run_ai_task, 'delay'): - task = run_ai_task.delay( - function_name='generate_image_prompts', - payload={'ids': ids}, - account_id=account_id - ) - return success_response( - data={'task_id': str(task.id)}, - message='Image prompt generation started', - request=request - ) - else: - # Fallback to synchronous execution - result = run_ai_task( - function_name='generate_image_prompts', - payload={'ids': ids}, - account_id=account_id - ) - if result.get('success'): - return success_response( - data={'prompts_created': result.get('count', 0)}, - message='Image prompts generated successfully', - request=request - ) - else: - return error_response( - error=result.get('error', 'Image prompt generation failed'), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - except Exception as e: - return error_response( - error=str(e), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - @action(detail=True, methods=['get'], url_path='validation', url_name='validation') - def validation(self, request, pk=None): - """ - Stage 3: Get validation checklist for content. - - GET /api/v1/writer/content/{id}/validation/ - Returns aggregated validation checklist for Writer UI. - """ - content = self.get_object() - validation_service = ContentValidationService() - - errors = validation_service.validate_content(content) - publish_errors = validation_service.validate_for_publish(content) - - return success_response( - data={ - 'content_id': content.id, - 'is_valid': len(errors) == 0, - 'ready_to_publish': len(publish_errors) == 0, - 'validation_errors': errors, - 'publish_errors': publish_errors, - 'metadata': { - 'has_entity_type': bool(content.content_type), - 'entity_type': content.content_type, - 'has_cluster_mapping': self._has_cluster_mapping(content), - 'has_taxonomy_mapping': self._has_taxonomy_mapping(content), - } - }, - request=request - ) - - @action(detail=True, methods=['post'], url_path='validate', url_name='validate') - def validate(self, request, pk=None): - """ - Stage 3: Re-run validators and return actionable errors. - - POST /api/v1/writer/content/{id}/validate/ - Re-validates content and returns structured errors. - """ - content = self.get_object() - validation_service = ContentValidationService() - - # Persist metadata mappings if task exists - # Metadata is now persisted directly on content - no task linkage needed - # mapping_service = MetadataMappingService() # DEPRECATED - # mapping_service.persist_task_metadata_to_content(content) # DEPRECATED - - errors = validation_service.validate_for_publish(content) - - return success_response( - data={ - 'content_id': content.id, - 'is_valid': len(errors) == 0, - 'errors': errors, - }, - request=request - ) - - def _has_cluster_mapping(self, content): - """Helper to check if content has cluster mapping""" - from igny8_core.business.content.models import ContentClusterMap - return ContentClusterMap.objects.filter(content=content).exists() - - def _has_taxonomy_mapping(self, content): - """Helper to check if content has taxonomy mapping""" - from igny8_core.business.content.models import ContentTaxonomyMap - return ContentTaxonomyMap.objects.filter(content=content).exists() - - @action(detail=False, methods=['post'], url_path='generate_product', url_name='generate_product') - def generate_product(self, request): - """ - Generate product content (Phase 8). - - POST /api/v1/writer/content/generate_product/ - { - "name": "Product Name", - "description": "Product description", - "features": ["Feature 1", "Feature 2"], - "target_audience": "Target audience", - "primary_keyword": "Primary keyword", - "site_id": 1, // optional - "sector_id": 1 // optional - } - """ - from igny8_core.business.content.services.content_generation_service import ContentGenerationService - from igny8_core.auth.models import Site, Sector - - account = getattr(request, 'account', None) - if not account: - return error_response( - error='Account not found', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - product_data = request.data - site_id = product_data.get('site_id') - sector_id = product_data.get('sector_id') - - site = None - sector = None - - if site_id: - try: - site = Site.objects.get(id=site_id, account=account) - except Site.DoesNotExist: - return error_response( - error='Site not found', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - if sector_id: - try: - sector = Sector.objects.get(id=sector_id, account=account) - except Sector.DoesNotExist: - return error_response( - error='Sector not found', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - service = ContentGenerationService() - - try: - result = service.generate_product_content( - product_data=product_data, - account=account, - site=site, - sector=sector - ) - - if result.get('success'): - return success_response( - data={'task_id': result.get('task_id')}, - message=result.get('message', 'Product content generation started'), - request=request - ) - else: - return error_response( - error=result.get('error', 'Product content generation failed'), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - except Exception as e: - return error_response( - error=str(e), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - @action(detail=True, methods=['get'], url_path='validation', url_name='validation') - def validation(self, request, pk=None): - """ - Stage 3: Get validation checklist for content. - - GET /api/v1/writer/content/{id}/validation/ - Returns aggregated validation checklist for Writer UI. - """ - content = self.get_object() - validation_service = ContentValidationService() - - errors = validation_service.validate_content(content) - publish_errors = validation_service.validate_for_publish(content) - - return success_response( - data={ - 'content_id': content.id, - 'is_valid': len(errors) == 0, - 'ready_to_publish': len(publish_errors) == 0, - 'validation_errors': errors, - 'publish_errors': publish_errors, - 'metadata': { - 'has_entity_type': bool(content.content_type), - 'entity_type': content.content_type, - 'has_cluster_mapping': self._has_cluster_mapping(content), - 'has_taxonomy_mapping': self._has_taxonomy_mapping(content), - } - }, - request=request - ) - - @action(detail=True, methods=['post'], url_path='validate', url_name='validate') - def validate(self, request, pk=None): - """ - Stage 3: Re-run validators and return actionable errors. - - POST /api/v1/writer/content/{id}/validate/ - Re-validates content and returns structured errors. - """ - content = self.get_object() - validation_service = ContentValidationService() - - # Persist metadata mappings if task exists - # Metadata is now persisted directly on content - no task linkage needed - # mapping_service = MetadataMappingService() # DEPRECATED - # mapping_service.persist_task_metadata_to_content(content) # DEPRECATED - - errors = validation_service.validate_for_publish(content) - - return success_response( - data={ - 'content_id': content.id, - 'is_valid': len(errors) == 0, - 'errors': errors, - }, - request=request - ) - - def _has_cluster_mapping(self, content): - """Helper to check if content has cluster mapping""" - from igny8_core.business.content.models import ContentClusterMap - return ContentClusterMap.objects.filter(content=content).exists() - - def _has_taxonomy_mapping(self, content): - """Helper to check if content has taxonomy mapping""" - from igny8_core.business.content.models import ContentTaxonomyMap - return ContentTaxonomyMap.objects.filter(content=content).exists() - - @action(detail=False, methods=['post'], url_path='generate_service', url_name='generate_service') - def generate_service(self, request): - """ - Generate service page content (Phase 8). - - POST /api/v1/writer/content/generate_service/ - { - "name": "Service Name", - "description": "Service description", - "benefits": ["Benefit 1", "Benefit 2"], - "target_audience": "Target audience", - "primary_keyword": "Primary keyword", - "site_id": 1, // optional - "sector_id": 1 // optional - } - """ - from igny8_core.business.content.services.content_generation_service import ContentGenerationService - from igny8_core.auth.models import Site, Sector - - account = getattr(request, 'account', None) - if not account: - return error_response( - error='Account not found', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - service_data = request.data - site_id = service_data.get('site_id') - sector_id = service_data.get('sector_id') - - site = None - sector = None - - if site_id: - try: - site = Site.objects.get(id=site_id, account=account) - except Site.DoesNotExist: - return error_response( - error='Site not found', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - if sector_id: - try: - sector = Sector.objects.get(id=sector_id, account=account) - except Sector.DoesNotExist: - return error_response( - error='Sector not found', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - service = ContentGenerationService() - - try: - result = service.generate_service_page( - service_data=service_data, - account=account, - site=site, - sector=sector - ) - - if result.get('success'): - return success_response( - data={'task_id': result.get('task_id')}, - message=result.get('message', 'Service page generation started'), - request=request - ) - else: - return error_response( - error=result.get('error', 'Service page generation failed'), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - except Exception as e: - return error_response( - error=str(e), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - @action(detail=True, methods=['get'], url_path='validation', url_name='validation') - def validation(self, request, pk=None): - """ - Stage 3: Get validation checklist for content. - - GET /api/v1/writer/content/{id}/validation/ - Returns aggregated validation checklist for Writer UI. - """ - content = self.get_object() - validation_service = ContentValidationService() - - errors = validation_service.validate_content(content) - publish_errors = validation_service.validate_for_publish(content) - - return success_response( - data={ - 'content_id': content.id, - 'is_valid': len(errors) == 0, - 'ready_to_publish': len(publish_errors) == 0, - 'validation_errors': errors, - 'publish_errors': publish_errors, - 'metadata': { - 'has_entity_type': bool(content.content_type), - 'entity_type': content.content_type, - 'has_cluster_mapping': self._has_cluster_mapping(content), - 'has_taxonomy_mapping': self._has_taxonomy_mapping(content), - } - }, - request=request - ) - - @action(detail=True, methods=['post'], url_path='validate', url_name='validate') - def validate(self, request, pk=None): - """ - Stage 3: Re-run validators and return actionable errors. - - POST /api/v1/writer/content/{id}/validate/ - Re-validates content and returns structured errors. - """ - content = self.get_object() - validation_service = ContentValidationService() - - # Persist metadata mappings if task exists - # Metadata is now persisted directly on content - no task linkage needed - # mapping_service = MetadataMappingService() # DEPRECATED - # mapping_service.persist_task_metadata_to_content(content) # DEPRECATED - - errors = validation_service.validate_for_publish(content) - - return success_response( - data={ - 'content_id': content.id, - 'is_valid': len(errors) == 0, - 'errors': errors, - }, - request=request - ) - - def _has_cluster_mapping(self, content): - """Helper to check if content has cluster mapping""" - from igny8_core.business.content.models import ContentClusterMap - return ContentClusterMap.objects.filter(content=content).exists() - - def _has_taxonomy_mapping(self, content): - """Helper to check if content has taxonomy mapping""" - from igny8_core.business.content.models import ContentTaxonomyMap - return ContentTaxonomyMap.objects.filter(content=content).exists() - - @action(detail=False, methods=['post'], url_path='generate_taxonomy', url_name='generate_taxonomy') - def generate_taxonomy(self, request): - """ - Generate taxonomy page content (Phase 8). - - POST /api/v1/writer/content/generate_taxonomy/ - { - "name": "Taxonomy Name", - "description": "Taxonomy description", - "items": ["Item 1", "Item 2"], - "primary_keyword": "Primary keyword", - "site_id": 1, // optional - "sector_id": 1 // optional - } - """ - from igny8_core.business.content.services.content_generation_service import ContentGenerationService - from igny8_core.auth.models import Site, Sector - - account = getattr(request, 'account', None) - if not account: - return error_response( - error='Account not found', - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - taxonomy_data = request.data - site_id = taxonomy_data.get('site_id') - sector_id = taxonomy_data.get('sector_id') - - site = None - sector = None - - if site_id: - try: - site = Site.objects.get(id=site_id, account=account) - except Site.DoesNotExist: - return error_response( - error='Site not found', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - if sector_id: - try: - sector = Sector.objects.get(id=sector_id, account=account) - except Sector.DoesNotExist: - return error_response( - error='Sector not found', - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - service = ContentGenerationService() - - try: - result = service.generate_taxonomy( - taxonomy_data=taxonomy_data, - account=account, - site=site, - sector=sector - ) - - if result.get('success'): - return success_response( - data={'task_id': result.get('task_id')}, - message=result.get('message', 'Taxonomy generation started'), - request=request - ) - else: - return error_response( - error=result.get('error', 'Taxonomy generation failed'), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - except Exception as e: - return error_response( - error=str(e), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - request=request - ) - - @action(detail=True, methods=['get'], url_path='validation', url_name='validation') - def validation(self, request, pk=None): - """ - Stage 3: Get validation checklist for content. - - GET /api/v1/writer/content/{id}/validation/ - Returns aggregated validation checklist for Writer UI. - """ - content = self.get_object() - validation_service = ContentValidationService() - - errors = validation_service.validate_content(content) - publish_errors = validation_service.validate_for_publish(content) - - return success_response( - data={ - 'content_id': content.id, - 'is_valid': len(errors) == 0, - 'ready_to_publish': len(publish_errors) == 0, - 'validation_errors': errors, - 'publish_errors': publish_errors, - 'metadata': { - 'has_entity_type': bool(content.content_type), - 'entity_type': content.content_type, - 'has_cluster_mapping': self._has_cluster_mapping(content), - 'has_taxonomy_mapping': self._has_taxonomy_mapping(content), - } - }, - request=request - ) - - @action(detail=True, methods=['post'], url_path='validate', url_name='validate') - def validate(self, request, pk=None): - """ - Stage 3: Re-run validators and return actionable errors. - - POST /api/v1/writer/content/{id}/validate/ - Re-validates content and returns structured errors. - """ - content = self.get_object() - validation_service = ContentValidationService() - - # Persist metadata mappings if task exists - # Metadata is now persisted directly on content - no task linkage needed - # mapping_service = MetadataMappingService() # DEPRECATED - # mapping_service.persist_task_metadata_to_content(content) # DEPRECATED - - errors = validation_service.validate_for_publish(content) - - return success_response( - data={ - 'content_id': content.id, - 'is_valid': len(errors) == 0, - 'errors': errors, - }, - request=request - ) - - def _has_cluster_mapping(self, content): - """Helper to check if content has cluster mapping""" - from igny8_core.business.content.models import ContentClusterMap - return ContentClusterMap.objects.filter(content=content).exists() - - def _has_taxonomy_mapping(self, content): - """Helper to check if content has taxonomy mapping""" - # Check new M2M relationship - return content.taxonomy_terms.exists() - - -@extend_schema_view( - list=extend_schema(tags=['Writer']), - create=extend_schema(tags=['Writer']), - retrieve=extend_schema(tags=['Writer']), - update=extend_schema(tags=['Writer']), - partial_update=extend_schema(tags=['Writer']), - destroy=extend_schema(tags=['Writer']), -) -class ContentTaxonomyViewSet(SiteSectorModelViewSet): - """ - ViewSet for managing content taxonomies (categories, tags, product attributes) - Unified API Standard v1.0 compliant - """ - queryset = ContentTaxonomy.objects.select_related('parent', 'site', 'sector').prefetch_related('clusters', 'contents') - serializer_class = ContentTaxonomySerializer - permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] - pagination_class = CustomPageNumberPagination - throttle_scope = 'writer' - throttle_classes = [DebugScopedRateThrottle] - - # DRF filtering configuration - filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] - - # Search configuration - search_fields = ['name', 'slug', 'description', 'external_taxonomy'] - - # Ordering configuration - ordering_fields = ['name', 'taxonomy_type', 'count', 'created_at'] - ordering = ['taxonomy_type', 'name'] - - # Filter configuration - # Removed "parent" to avoid non-model field in filterset (breaks drf-spectacular) - filterset_fields = ['taxonomy_type', 'sync_status', 'external_id', 'external_taxonomy'] - - def perform_create(self, serializer): - """Create taxonomy with site/sector context""" - user = getattr(self.request, 'user', None) - - try: - query_params = getattr(self.request, 'query_params', None) - if query_params is None: - query_params = getattr(self.request, 'GET', {}) - except AttributeError: - query_params = {} - - site_id = serializer.validated_data.get('site_id') or query_params.get('site_id') - sector_id = serializer.validated_data.get('sector_id') or query_params.get('sector_id') - - from igny8_core.auth.models import Site, Sector - from rest_framework.exceptions import ValidationError - - if not site_id: - raise ValidationError("site_id is required") - - try: - site = Site.objects.get(id=site_id) - except Site.DoesNotExist: - raise ValidationError(f"Site with id {site_id} does not exist") - - if not sector_id: - raise ValidationError("sector_id is required") - - try: - sector = Sector.objects.get(id=sector_id) - if sector.site_id != site_id: - raise ValidationError(f"Sector does not belong to the selected site") - except Sector.DoesNotExist: - raise ValidationError(f"Sector with id {sector_id} does not exist") - - serializer.validated_data.pop('site_id', None) - serializer.validated_data.pop('sector_id', None) - - account = getattr(self.request, 'account', None) - if not account and user and user.is_authenticated and user.account: - account = user.account - if not account: - account = site.account - - serializer.save(account=account, site=site, sector=sector) - - @action(detail=True, methods=['post'], permission_classes=[IsAuthenticatedAndActive, IsEditorOrAbove]) - def map_to_cluster(self, request, pk=None): - """Map taxonomy to semantic cluster""" - taxonomy = self.get_object() - cluster_id = request.data.get('cluster_id') - - if not cluster_id: - return error_response( - error="cluster_id is required", - status_code=status.HTTP_400_BAD_REQUEST, - request=request - ) - - from igny8_core.business.planning.models import Clusters - try: - cluster = Clusters.objects.get(id=cluster_id, site=taxonomy.site) - taxonomy.clusters.add(cluster) - - return success_response( - data={'message': f'Taxonomy "{taxonomy.name}" mapped to cluster "{cluster.name}"'}, - message="Taxonomy mapped to cluster successfully", - request=request - ) - except Clusters.DoesNotExist: - return error_response( - error=f"Cluster with id {cluster_id} not found", - status_code=status.HTTP_404_NOT_FOUND, - request=request - ) - - @action(detail=True, methods=['get']) - def contents(self, request, pk=None): - """Get all content associated with this taxonomy""" - taxonomy = self.get_object() - contents = taxonomy.contents.all() - - serializer = ContentSerializer(contents, many=True, context={'request': request}) - - return success_response( - data=serializer.data, - message=f"Found {contents.count()} content items for taxonomy '{taxonomy.name}'", - request=request - ) - - -# ContentAttributeViewSet temporarily disabled - ContentAttributeSerializer was removed in Stage 1 -# TODO: Re-implement or remove completely based on Stage 1 architecture decisions - - diff --git a/tenant-temp/backend/igny8_core/settings.py b/tenant-temp/backend/igny8_core/settings.py deleted file mode 100644 index 22ecaf77..00000000 --- a/tenant-temp/backend/igny8_core/settings.py +++ /dev/null @@ -1,616 +0,0 @@ -""" -Django settings for igny8_core project. -""" - -from pathlib import Path -from datetime import timedelta -from urllib.parse import urlparse -import os - -BASE_DIR = Path(__file__).resolve().parent.parent - -# SECURITY: SECRET_KEY must be set via environment variable in production -# Generate a new key with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" -SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-)#i8!6+_&j97eb_4actu86=qtg)p+p#)vr48!ahjs8u=o5#5aw') - -# SECURITY: DEBUG should be False in production -# Set DEBUG=False via environment variable for production deployments -DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' - -# Unified API Standard v1.0 Feature Flags -# Set IGNY8_USE_UNIFIED_EXCEPTION_HANDLER=True to enable unified exception handler -# Set IGNY8_DEBUG_THROTTLE=True to bypass rate limiting in development -IGNY8_DEBUG_THROTTLE = os.getenv('IGNY8_DEBUG_THROTTLE', str(DEBUG)).lower() == 'true' -USE_SITE_BUILDER_REFACTOR = os.getenv('USE_SITE_BUILDER_REFACTOR', 'false').lower() == 'true' - -ALLOWED_HOSTS = [ - '*', # Allow all hosts for flexibility - 'api.igny8.com', - 'app.igny8.com', - 'igny8.com', - 'www.igny8.com', - 'localhost', - '127.0.0.1', - # Note: Do NOT add static IP addresses here - they change on container restart - # Use container names or domain names instead -] - -INSTALLED_APPS = [ - 'igny8_core.admin.apps.Igny8AdminConfig', # Custom admin config - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'rest_framework', - 'django_filters', - 'corsheaders', - 'drf_spectacular', # OpenAPI 3.0 schema generation - 'igny8_core.auth.apps.Igny8CoreAuthConfig', # Use app config with custom label - 'igny8_core.ai.apps.AIConfig', # AI Framework - 'igny8_core.modules.planner.apps.PlannerConfig', - 'igny8_core.modules.writer.apps.WriterConfig', - 'igny8_core.modules.system.apps.SystemConfig', - 'igny8_core.modules.billing.apps.BillingConfig', - 'igny8_core.business.automation', # AI Automation Pipeline - 'igny8_core.business.optimization.apps.OptimizationConfig', - 'igny8_core.business.publishing.apps.PublishingConfig', - 'igny8_core.business.integration.apps.IntegrationConfig', - 'igny8_core.modules.linker.apps.LinkerConfig', - 'igny8_core.modules.optimizer.apps.OptimizerConfig', - 'igny8_core.modules.publisher.apps.PublisherConfig', - 'igny8_core.modules.integration.apps.IntegrationConfig', -] - -# System module needs explicit registration for admin - -AUTH_USER_MODEL = 'igny8_core_auth.User' - -CSRF_TRUSTED_ORIGINS = [ - 'https://api.igny8.com', - 'https://app.igny8.com', - 'http://localhost:8011', - 'http://127.0.0.1:8011', -] - -# Only use secure cookies in production (HTTPS) -# Default to False - set USE_SECURE_COOKIES=True in docker-compose for production -# This allows local development to work without HTTPS -USE_SECURE_COOKIES = os.getenv('USE_SECURE_COOKIES', 'False').lower() == 'true' -SESSION_COOKIE_SECURE = USE_SECURE_COOKIES -CSRF_COOKIE_SECURE = USE_SECURE_COOKIES - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'whitenoise.middleware.WhiteNoiseMiddleware', - 'corsheaders.middleware.CorsMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'igny8_core.middleware.request_id.RequestIDMiddleware', # Request ID tracking (must be early) - 'igny8_core.auth.middleware.AccountContextMiddleware', # Multi-account support - # AccountContextMiddleware sets request.account from JWT - 'igny8_core.middleware.resource_tracker.ResourceTrackingMiddleware', # Resource tracking for admin debug - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'igny8_core.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'igny8_core' / 'templates'], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'igny8_core.wsgi.application' - -DATABASES = {} - -database_url = os.getenv("DATABASE_URL") -db_engine = os.getenv("DB_ENGINE", "").lower() -force_postgres = os.getenv("DJANGO_FORCE_POSTGRES", "false").lower() == "true" - -if database_url: - parsed = urlparse(database_url) - scheme = (parsed.scheme or "").lower() - - if scheme in {"sqlite", "sqlite3"}: - # Support both absolute and project-relative SQLite paths - netloc_path = f"{parsed.netloc}{parsed.path}" if parsed.netloc else parsed.path - db_path = netloc_path.lstrip("/") or "db.sqlite3" - if os.path.isabs(netloc_path): - sqlite_name = netloc_path - else: - sqlite_name = Path(db_path) if os.path.isabs(db_path) else BASE_DIR / db_path - DATABASES["default"] = { - "ENGINE": "django.db.backends.sqlite3", - "NAME": str(sqlite_name), - } - else: - DATABASES["default"] = { - "ENGINE": "django.db.backends.postgresql", - "NAME": parsed.path.lstrip("/") or os.getenv("DB_NAME", "igny8_db"), - "USER": parsed.username or os.getenv("DB_USER", "igny8"), - "PASSWORD": parsed.password or os.getenv("DB_PASSWORD", "igny8pass"), - "HOST": parsed.hostname or os.getenv("DB_HOST", "postgres"), - "PORT": str(parsed.port or os.getenv("DB_PORT", "5432")), - } -elif db_engine in {"sqlite", "sqlite3"} or os.getenv("USE_SQLITE", "false").lower() == "true": - sqlite_name = os.getenv("SQLITE_NAME") - if not sqlite_name: - sqlite_name = BASE_DIR / "db.sqlite3" - DATABASES["default"] = { - "ENGINE": "django.db.backends.sqlite3", - "NAME": str(sqlite_name), - } -elif DEBUG and not force_postgres and not os.getenv("DB_HOST") and not os.getenv("DB_NAME") and not os.getenv("DB_USER"): - DATABASES["default"] = { - "ENGINE": "django.db.backends.sqlite3", - "NAME": str(BASE_DIR / "db.sqlite3"), - } -else: - DATABASES["default"] = { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.getenv("DB_NAME", "igny8_db"), - "USER": os.getenv("DB_USER", "igny8"), - "PASSWORD": os.getenv("DB_PASSWORD", "igny8pass"), - "HOST": os.getenv("DB_HOST", "postgres"), - "PORT": os.getenv("DB_PORT", "5432"), - } - -AUTH_PASSWORD_VALIDATORS = [ - {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, - {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, - {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, - {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, -] - -LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'UTC' -USE_I18N = True -USE_TZ = True - -STATIC_URL = '/static/' -STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - -# Only use SECURE_PROXY_SSL_HEADER in production behind reverse proxy -# Default to False - set USE_SECURE_PROXY_HEADER=True in docker-compose for production -# Caddy sets X-Forwarded-Proto header, so enable this when behind Caddy -USE_SECURE_PROXY = os.getenv('USE_SECURE_PROXY_HEADER', 'False').lower() == 'true' -if USE_SECURE_PROXY: - SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') -else: - SECURE_PROXY_SSL_HEADER = None - -# Admin login URL - use relative URL to avoid hardcoded domain -LOGIN_URL = '/admin/login/' -LOGIN_REDIRECT_URL = '/admin/' - -# Force Django to use request.get_host() instead of Sites framework -# This ensures redirects use the current request's host -USE_X_FORWARDED_HOST = False - -# REST Framework Configuration -REST_FRAMEWORK = { - 'DEFAULT_PAGINATION_CLASS': 'igny8_core.api.pagination.CustomPageNumberPagination', - 'PAGE_SIZE': 10, - 'DEFAULT_FILTER_BACKENDS': [ - 'django_filters.rest_framework.DjangoFilterBackend', - 'rest_framework.filters.SearchFilter', - 'rest_framework.filters.OrderingFilter', - ], - 'DEFAULT_PERMISSION_CLASSES': [ - 'igny8_core.api.permissions.IsAuthenticatedAndActive', - 'igny8_core.api.permissions.HasTenantAccess', - ], - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'igny8_core.api.authentication.APIKeyAuthentication', # WordPress API key authentication (check first) - 'igny8_core.api.authentication.JWTAuthentication', # JWT token authentication - 'igny8_core.api.authentication.CSRFExemptSessionAuthentication', # Session auth without CSRF for API - 'rest_framework.authentication.BasicAuthentication', # Enable basic auth as fallback - ], - # Unified API Standard v1.0 Configuration - # Exception handler - wraps all errors in unified format - # Unified API Standard v1.0: Exception handler enabled by default - # Set IGNY8_USE_UNIFIED_EXCEPTION_HANDLER=False to disable - 'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler' if os.getenv('IGNY8_USE_UNIFIED_EXCEPTION_HANDLER', 'True').lower() == 'false' else 'igny8_core.api.exception_handlers.custom_exception_handler', - # Rate limiting - configured but bypassed in DEBUG mode - 'DEFAULT_THROTTLE_CLASSES': [ - 'igny8_core.api.throttles.DebugScopedRateThrottle', - ], - 'DEFAULT_THROTTLE_RATES': { - # AI Functions - Expensive operations (kept modest but higher to reduce false 429s) - 'ai_function': '60/min', - 'image_gen': '90/min', - # Content Operations - 'content_write': '180/min', - 'content_read': '600/min', - # Authentication - 'auth': '300/min', # Login, register, password reset - 'auth_strict': '120/min', # Sensitive auth operations - 'auth_read': '600/min', # Read-only auth-adjacent endpoints (e.g., subscriptions, industries) - # Planner Operations - 'planner': '300/min', - 'planner_ai': '60/min', - # Writer Operations - 'writer': '300/min', - 'writer_ai': '60/min', - # System Operations - 'system': '600/min', - 'system_admin': '120/min', - # Billing Operations - 'billing': '180/min', - 'billing_admin': '60/min', - 'linker': '180/min', - 'optimizer': '60/min', - 'integration': '600/min', - # Default fallback - 'default': '600/min', - }, - # OpenAPI Schema Generation (drf-spectacular) - 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', -} - -# drf-spectacular Settings for OpenAPI 3.0 Schema Generation -SPECTACULAR_SETTINGS = { - 'TITLE': 'IGNY8 API v1.0', - 'DESCRIPTION': ''' - IGNY8 Unified API Standard v1.0 - - A comprehensive REST API for content planning, creation, and management. - - ## Features - - **Unified Response Format**: All endpoints return consistent JSON structure - - **Layered Authorization**: Authentication → Tenant Access → Role → Site/Sector - - **Centralized Error Handling**: All errors wrapped in unified format - - **Scoped Rate Limiting**: Different limits for different operation types - - **Tenant Isolation**: All resources scoped by account/site/sector - - **Request Tracking**: Every request has a unique ID for debugging - - ## Authentication - All endpoints require JWT Bearer token authentication except: - - `GET /api/v1/system/ping/` - Health check endpoint - - `POST /api/v1/auth/login/` - User login - - `POST /api/v1/auth/register/` - User registration - - `GET /api/v1/auth/plans/` - List subscription plans - - `GET /api/v1/auth/industries/` - List industries - - `GET /api/v1/system/status/` - System status - - Include token in Authorization header: - ``` - Authorization: Bearer - ``` - - ## Response Format - All successful responses follow this format: - ```json - { - "success": true, - "data": {...}, - "message": "Optional success message", - "request_id": "uuid" - } - ``` - - All error responses follow this format: - ```json - { - "success": false, - "error": "Error message", - "errors": { - "field_name": ["Field-specific errors"] - }, - "request_id": "uuid" - } - ``` - - ## Rate Limiting - Rate limits are scoped by operation type. Check response headers: - - `X-Throttle-Limit`: Maximum requests allowed - - `X-Throttle-Remaining`: Remaining requests in current window - - `X-Throttle-Reset`: Time when limit resets (Unix timestamp) - - ## Pagination - List endpoints support pagination with query parameters: - - `page`: Page number (default: 1) - - `page_size`: Items per page (default: 10, max: 100) - - Paginated responses include: - ```json - { - "success": true, - "count": 100, - "next": "http://api.igny8.com/api/v1/endpoint/?page=2", - "previous": null, - "results": [...] - } - ``` - ''', - 'VERSION': '1.0.0', - 'SERVE_INCLUDE_SCHEMA': False, - 'SCHEMA_PATH_PREFIX': '/api/v1', - 'COMPONENT_SPLIT_REQUEST': True, - 'COMPONENT_NO_READ_ONLY_REQUIRED': True, - # Custom schema generator to include unified response format - 'SCHEMA_GENERATOR_CLASS': 'drf_spectacular.generators.SchemaGenerator', - # Include request/response examples - 'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'], - 'SERVE_AUTHENTICATION': None, # Allow unauthenticated access to docs - - # Tag configuration - prevent auto-generation and use explicit tags - 'TAGS': [ - {'name': 'Authentication', 'description': 'User authentication and registration'}, - {'name': 'Account', 'description': 'Account settings, team, and usage analytics'}, - {'name': 'Integration', 'description': 'Site integrations and sync'}, - {'name': 'System', 'description': 'Settings, prompts, and integrations'}, - {'name': 'Admin Billing', 'description': 'Admin-only billing management'}, - {'name': 'Billing', 'description': 'Credits, usage, and transactions'}, - {'name': 'Planner', 'description': 'Keywords, clusters, and content ideas'}, - {'name': 'Writer', 'description': 'Tasks, content, and images'}, - {'name': 'Automation', 'description': 'Automation configuration and runs'}, - {'name': 'Linker', 'description': 'Internal linking operations'}, - {'name': 'Optimizer', 'description': 'Content optimization operations'}, - {'name': 'Publisher', 'description': 'Publishing records and deployments'}, - ], - 'TAGS_ORDER': [ - 'Authentication', - 'Account', - 'Integration', - 'System', - 'Admin Billing', - 'Billing', - 'Planner', - 'Writer', - 'Automation', - 'Linker', - 'Optimizer', - 'Publisher', - ], - # Postprocessing hook to filter out auto-generated tags - 'POSTPROCESSING_HOOKS': ['igny8_core.api.schema_extensions.postprocess_schema_filter_tags'], - - # Swagger UI configuration - 'SWAGGER_UI_SETTINGS': { - 'deepLinking': True, - 'displayOperationId': False, - 'defaultModelsExpandDepth': 1, # Collapse models by default - 'defaultModelExpandDepth': 1, # Collapse model properties by default - 'defaultModelRendering': 'model', # Show models in a cleaner format - 'displayRequestDuration': True, - 'docExpansion': 'none', # Collapse all operations by default - 'filter': True, # Enable filter box - 'showExtensions': True, - 'showCommonExtensions': True, - 'tryItOutEnabled': True, # Enable "Try it out" by default - }, - - # ReDoc configuration - 'REDOC_UI_SETTINGS': { - 'hideDownloadButton': False, - 'hideHostname': False, - 'hideLoading': False, - 'hideSingleRequestSampleTab': False, - 'expandResponses': '200,201', # Expand successful responses - 'jsonSampleExpandLevel': 2, # Expand JSON samples 2 levels - 'hideFab': False, - 'theme': { - 'colors': { - 'primary': { - 'main': '#32329f' - } - } - } - }, - - # Schema presentation improvements - 'SCHEMA_COERCE_PATH_PK': True, - 'SCHEMA_COERCE_METHOD_NAMES': { - 'retrieve': 'get', - 'list': 'list', - 'create': 'post', - 'update': 'put', - 'partial_update': 'patch', - 'destroy': 'delete', - }, - - # Custom response format documentation - 'EXTENSIONS_INFO': { - 'x-code-samples': [ - { - 'lang': 'Python', - 'source': ''' -import requests - -headers = { - 'Authorization': 'Bearer ', - 'Content-Type': 'application/json' -} - -response = requests.get('https://api.igny8.com/api/v1/planner/keywords/', headers=headers) -data = response.json() - -if data['success']: - keywords = data['results'] # or data['data'] for single objects -else: - print(f"Error: {data['error']}") - ''' - }, - { - 'lang': 'JavaScript', - 'source': ''' -const response = await fetch('https://api.igny8.com/api/v1/planner/keywords/', { - headers: { - 'Authorization': 'Bearer ', - 'Content-Type': 'application/json' - } -}); - -const data = await response.json(); - -if (data.success) { - const keywords = data.results || data.data; -} else { - console.error('Error:', data.error); -} - ''' - } - ] - } -} - -# CORS Configuration -CORS_ALLOWED_ORIGINS = [ - "https://app.igny8.com", - "https://igny8.com", - "https://www.igny8.com", - "https://sites.igny8.com", - "http://localhost:5173", - "http://localhost:5174", - "http://localhost:5176", - "http://localhost:8024", - "http://localhost:3000", - "http://127.0.0.1:5173", - "http://127.0.0.1:5174", - "http://127.0.0.1:5176", - "http://127.0.0.1:8024", - "http://31.97.144.105:8024", -] - -CORS_ALLOW_CREDENTIALS = True - -# Allow custom headers for resource tracking -# Include default headers plus our custom debug header -CORS_ALLOW_HEADERS = [ - 'accept', - 'accept-encoding', - 'authorization', - 'content-type', - 'dnt', - 'origin', - 'user-agent', - 'x-csrftoken', - 'x-requested-with', - 'x-debug-resource-tracking', # Allow debug tracking header -] - -# Note: django-cors-headers has default headers that include the above. -# If you want to extend defaults, you can import default_headers from corsheaders.defaults -# For now, we're explicitly listing all needed headers including our custom one. - -# Expose custom headers to frontend -CORS_EXPOSE_HEADERS = [ - 'x-resource-tracking-id', # Expose request tracking ID -] - -# JWT Configuration -JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', SECRET_KEY) -JWT_ALGORITHM = 'HS256' -JWT_ACCESS_TOKEN_EXPIRY = timedelta(minutes=15) -JWT_REFRESH_TOKEN_EXPIRY = timedelta(days=30) # Extended to 30 days for persistent login - -# Celery Configuration -# FIXED: Use redis:// URL with explicit string parameters to avoid Celery backend key serialization issues -CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', f"redis://{os.getenv('REDIS_HOST', 'redis')}:{os.getenv('REDIS_PORT', '6379')}/0") -CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', f"redis://{os.getenv('REDIS_HOST', 'redis')}:{os.getenv('REDIS_PORT', '6379')}/0") -CELERY_ACCEPT_CONTENT = ['json'] -CELERY_TASK_SERIALIZER = 'json' -CELERY_RESULT_SERIALIZER = 'json' -CELERY_TIMEZONE = TIME_ZONE -CELERY_ENABLE_UTC = True -CELERY_TASK_TRACK_STARTED = True -CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes -CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # 25 minutes -CELERY_WORKER_PREFETCH_MULTIPLIER = 1 -CELERY_WORKER_MAX_TASKS_PER_CHILD = 1000 -# FIXED: Add explicit backend options to prevent key serialization issues -CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = { - 'master_name': 'mymaster' -} if os.getenv('REDIS_SENTINEL_ENABLED', 'false').lower() == 'true' else {} -CELERY_REDIS_BACKEND_USE_SSL = os.getenv('REDIS_SSL_ENABLED', 'false').lower() == 'true' - -# Publish/Sync Logging Configuration -PUBLISH_SYNC_LOG_DIR = os.path.join(BASE_DIR, 'logs', 'publish-sync-logs') -os.makedirs(PUBLISH_SYNC_LOG_DIR, exist_ok=True) - -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'verbose': { - 'format': '[{asctime}] [{levelname}] [{name}] {message}', - 'style': '{', - 'datefmt': '%Y-%m-%d %H:%M:%S', - }, - 'publish_sync': { - 'format': '[{asctime}] [{levelname}] {message}', - 'style': '{', - 'datefmt': '%Y-%m-%d %H:%M:%S', - }, - }, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - 'formatter': 'verbose', - }, - 'publish_sync_file': { - 'class': 'logging.handlers.RotatingFileHandler', - 'filename': os.path.join(PUBLISH_SYNC_LOG_DIR, 'publish-sync.log'), - 'maxBytes': 10 * 1024 * 1024, # 10 MB - 'backupCount': 10, - 'formatter': 'publish_sync', - }, - 'wordpress_api_file': { - 'class': 'logging.handlers.RotatingFileHandler', - 'filename': os.path.join(PUBLISH_SYNC_LOG_DIR, 'wordpress-api.log'), - 'maxBytes': 10 * 1024 * 1024, # 10 MB - 'backupCount': 10, - 'formatter': 'publish_sync', - }, - 'webhook_file': { - 'class': 'logging.handlers.RotatingFileHandler', - 'filename': os.path.join(PUBLISH_SYNC_LOG_DIR, 'webhooks.log'), - 'maxBytes': 10 * 1024 * 1024, # 10 MB - 'backupCount': 10, - 'formatter': 'publish_sync', - }, - }, - 'loggers': { - 'publish_sync': { - 'handlers': ['console', 'publish_sync_file'], - 'level': 'INFO', - 'propagate': False, - }, - 'wordpress_api': { - 'handlers': ['console', 'wordpress_api_file'], - 'level': 'INFO', - 'propagate': False, - }, - 'webhooks': { - 'handlers': ['console', 'webhook_file'], - 'level': 'INFO', - 'propagate': False, - }, - }, -} - -# Billing / Payments configuration -STRIPE_PUBLIC_KEY = os.getenv('STRIPE_PUBLIC_KEY', '') -STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '') -STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET', '') -PAYPAL_CLIENT_ID = os.getenv('PAYPAL_CLIENT_ID', '') -PAYPAL_CLIENT_SECRET = os.getenv('PAYPAL_CLIENT_SECRET', '') -PAYPAL_API_BASE = os.getenv('PAYPAL_API_BASE', 'https://api-m.sandbox.paypal.com') diff --git a/tenant-temp/frontend/src/App.tsx b/tenant-temp/frontend/src/App.tsx deleted file mode 100644 index 222d4e7c..00000000 --- a/tenant-temp/frontend/src/App.tsx +++ /dev/null @@ -1,843 +0,0 @@ -import { Suspense, lazy, useEffect } from "react"; -import { Routes, Route, Navigate } from "react-router-dom"; -import { HelmetProvider } from "react-helmet-async"; -import AppLayout from "./layout/AppLayout"; -import { ScrollToTop } from "./components/common/ScrollToTop"; -import ProtectedRoute from "./components/auth/ProtectedRoute"; -import ModuleGuard from "./components/common/ModuleGuard"; -import AdminGuard from "./components/auth/AdminGuard"; -import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay"; -import LoadingStateMonitor from "./components/common/LoadingStateMonitor"; -import { useAuthStore } from "./store/authStore"; - -// Auth pages - loaded immediately (needed for login) -import SignIn from "./pages/AuthPages/SignIn"; -import SignUp from "./pages/AuthPages/SignUp"; -import NotFound from "./pages/OtherPage/NotFound"; - -// Lazy load all other pages - only loads when navigated to -const Home = lazy(() => import("./pages/Dashboard/Home")); - -// Planner Module - Lazy loaded -const PlannerDashboard = lazy(() => import("./pages/Planner/Dashboard")); -const Keywords = lazy(() => import("./pages/Planner/Keywords")); -const Clusters = lazy(() => import("./pages/Planner/Clusters")); -const ClusterDetail = lazy(() => import("./pages/Planner/ClusterDetail")); -const Ideas = lazy(() => import("./pages/Planner/Ideas")); -const KeywordOpportunities = lazy(() => import("./pages/Planner/KeywordOpportunities")); - -// Writer Module - Lazy loaded -const WriterDashboard = lazy(() => import("./pages/Writer/Dashboard")); -const Tasks = lazy(() => import("./pages/Writer/Tasks")); -const Content = lazy(() => import("./pages/Writer/Content")); -const ContentView = lazy(() => import("./pages/Writer/ContentView")); -const Drafts = lazy(() => import("./pages/Writer/Drafts")); -const Images = lazy(() => import("./pages/Writer/Images")); -const Review = lazy(() => import("./pages/Writer/Review")); -const Published = lazy(() => import("./pages/Writer/Published")); - -// Automation Module - Lazy loaded -const AutomationPage = lazy(() => import("./pages/Automation/AutomationPage")); - -// Linker Module - Lazy loaded -const LinkerDashboard = lazy(() => import("./pages/Linker/Dashboard")); -const LinkerContentList = lazy(() => import("./pages/Linker/ContentList")); - -// Optimizer Module - Lazy loaded -const OptimizerDashboard = lazy(() => import("./pages/Optimizer/Dashboard")); -const OptimizerContentSelector = lazy(() => import("./pages/Optimizer/ContentSelector")); -const AnalysisPreview = lazy(() => import("./pages/Optimizer/AnalysisPreview")); - -// Thinker Module - Lazy loaded -const ThinkerDashboard = lazy(() => import("./pages/Thinker/Dashboard")); -const Prompts = lazy(() => import("./pages/Thinker/Prompts")); -const AuthorProfiles = lazy(() => import("./pages/Thinker/AuthorProfiles")); -const ThinkerProfile = lazy(() => import("./pages/Thinker/Profile")); -const Strategies = lazy(() => import("./pages/Thinker/Strategies")); -const ImageTesting = lazy(() => import("./pages/Thinker/ImageTesting")); - -// Billing Module - Lazy loaded -const Credits = lazy(() => import("./pages/Billing/Credits")); -const Transactions = lazy(() => import("./pages/Billing/Transactions")); -const Usage = lazy(() => import("./pages/Billing/Usage")); -const CreditsAndBilling = lazy(() => import("./pages/Settings/CreditsAndBilling")); -const PurchaseCreditsPage = lazy(() => import("./pages/account/PurchaseCreditsPage")); -const AccountBillingPage = lazy(() => import("./pages/account/AccountBillingPage")); -const PlansAndBillingPage = lazy(() => import("./pages/account/PlansAndBillingPage")); -const AccountSettingsPage = lazy(() => import("./pages/account/AccountSettingsPage")); -const TeamManagementPage = lazy(() => import("./pages/account/TeamManagementPage")); -const UsageAnalyticsPage = lazy(() => import("./pages/account/UsageAnalyticsPage")); - -// Admin Module - Lazy loaded (mixed folder casing in repo, match actual file paths) -const AdminBilling = lazy(() => import("./pages/Admin/AdminBilling")); -const PaymentApprovalPage = lazy(() => import("./pages/admin/PaymentApprovalPage")); -const AdminSystemDashboard = lazy(() => import("./pages/admin/AdminSystemDashboard")); -const AdminAllAccountsPage = lazy(() => import("./pages/admin/AdminAllAccountsPage")); -const AdminSubscriptionsPage = lazy(() => import("./pages/admin/AdminSubscriptionsPage")); -const AdminAccountLimitsPage = lazy(() => import("./pages/admin/AdminAccountLimitsPage")); -const AdminAllInvoicesPage = lazy(() => import("./pages/admin/AdminAllInvoicesPage")); -const AdminAllPaymentsPage = lazy(() => import("./pages/admin/AdminAllPaymentsPage")); -const AdminCreditPackagesPage = lazy(() => import("./pages/admin/AdminCreditPackagesPage")); -const AdminCreditCostsPage = lazy(() => import("./pages/Admin/AdminCreditCostsPage")); -const AdminAllUsersPage = lazy(() => import("./pages/admin/AdminAllUsersPage")); -const AdminRolesPermissionsPage = lazy(() => import("./pages/admin/AdminRolesPermissionsPage")); -const AdminActivityLogsPage = lazy(() => import("./pages/admin/AdminActivityLogsPage")); -const AdminSystemSettingsPage = lazy(() => import("./pages/admin/AdminSystemSettingsPage")); -const AdminSystemHealthPage = lazy(() => import("./pages/admin/AdminSystemHealthPage")); -const AdminAPIMonitorPage = lazy(() => import("./pages/admin/AdminAPIMonitorPage")); - -// Reference Data - Lazy loaded -const SeedKeywords = lazy(() => import("./pages/Reference/SeedKeywords")); -const ReferenceIndustries = lazy(() => import("./pages/Reference/Industries")); - -// Setup Pages - Lazy loaded -const IndustriesSectorsKeywords = lazy(() => import("./pages/Setup/IndustriesSectorsKeywords")); - -// Settings - Lazy loaded -const GeneralSettings = lazy(() => import("./pages/Settings/General")); -const ProfileSettingsPage = lazy(() => import("./pages/settings/ProfileSettingsPage")); -const Users = lazy(() => import("./pages/Settings/Users")); -const Subscriptions = lazy(() => import("./pages/Settings/Subscriptions")); -const SystemSettings = lazy(() => import("./pages/Settings/System")); -const AccountSettings = lazy(() => import("./pages/Settings/Account")); -const ModuleSettings = lazy(() => import("./pages/Settings/Modules")); -const AISettings = lazy(() => import("./pages/Settings/AI")); -const Plans = lazy(() => import("./pages/Settings/Plans")); -const Industries = lazy(() => import("./pages/Settings/Industries")); -const MasterStatus = lazy(() => import("./pages/Settings/MasterStatus")); -const ApiMonitor = lazy(() => import("./pages/Settings/ApiMonitor")); -const DebugStatus = lazy(() => import("./pages/Settings/DebugStatus")); -const Integration = lazy(() => import("./pages/Settings/Integration")); -const Publishing = lazy(() => import("./pages/Settings/Publishing")); -const Sites = lazy(() => import("./pages/Settings/Sites")); -const ImportExport = lazy(() => import("./pages/Settings/ImportExport")); - -// Sites - Lazy loaded -const SiteList = lazy(() => import("./pages/Sites/List")); -const SiteManage = lazy(() => import("./pages/Sites/Manage")); -const SiteDashboard = lazy(() => import("./pages/Sites/Dashboard")); -const SiteContent = lazy(() => import("./pages/Sites/Content")); -const PageManager = lazy(() => import("./pages/Sites/PageManager")); -const PostEditor = lazy(() => import("./pages/Sites/PostEditor")); -const SiteSettings = lazy(() => import("./pages/Sites/Settings")); -const SyncDashboard = lazy(() => import("./pages/Sites/SyncDashboard")); -const DeploymentPanel = lazy(() => import("./pages/Sites/DeploymentPanel")); - -// Help - Lazy loaded -const Help = lazy(() => import("./pages/Help/Help")); -const Docs = lazy(() => import("./pages/Help/Docs")); -const SystemTesting = lazy(() => import("./pages/Help/SystemTesting")); -const FunctionTesting = lazy(() => import("./pages/Help/FunctionTesting")); - -// Components - Lazy loaded -const Components = lazy(() => import("./pages/Components")); - -// UI Elements - Lazy loaded (rarely used) -const Alerts = lazy(() => import("./pages/Settings/UiElements/Alerts")); -const Avatars = lazy(() => import("./pages/Settings/UiElements/Avatars")); -const Badges = lazy(() => import("./pages/Settings/UiElements/Badges")); -const Breadcrumb = lazy(() => import("./pages/Settings/UiElements/Breadcrumb")); -const Buttons = lazy(() => import("./pages/Settings/UiElements/Buttons")); -const ButtonsGroup = lazy(() => import("./pages/Settings/UiElements/ButtonsGroup")); -const Cards = lazy(() => import("./pages/Settings/UiElements/Cards")); -const Carousel = lazy(() => import("./pages/Settings/UiElements/Carousel")); -const Dropdowns = lazy(() => import("./pages/Settings/UiElements/Dropdowns")); -const ImagesUI = lazy(() => import("./pages/Settings/UiElements/Images")); -const Links = lazy(() => import("./pages/Settings/UiElements/Links")); -const List = lazy(() => import("./pages/Settings/UiElements/List")); -const Modals = lazy(() => import("./pages/Settings/UiElements/Modals")); -const Notifications = lazy(() => import("./pages/Settings/UiElements/Notifications")); -const Pagination = lazy(() => import("./pages/Settings/UiElements/Pagination")); -const Popovers = lazy(() => import("./pages/Settings/UiElements/Popovers")); -const PricingTable = lazy(() => import("./pages/Settings/UiElements/PricingTable")); -const Progressbar = lazy(() => import("./pages/Settings/UiElements/Progressbar")); -const Ribbons = lazy(() => import("./pages/Settings/UiElements/Ribbons")); -const Spinners = lazy(() => import("./pages/Settings/UiElements/Spinners")); -const Tabs = lazy(() => import("./pages/Settings/UiElements/Tabs")); -const Tooltips = lazy(() => import("./pages/Settings/UiElements/Tooltips")); -const Videos = lazy(() => import("./pages/Settings/UiElements/Videos")); - -export default function App() { - const isAuthenticated = useAuthStore((state) => state.isAuthenticated); - const refreshUser = useAuthStore((state) => state.refreshUser); - const logout = useAuthStore((state) => state.logout); - - useEffect(() => { - const { token } = useAuthStore.getState(); - if (!isAuthenticated || !token) return; - - refreshUser().catch((error) => { - // Avoid log spam on auth pages when token is missing/expired - if (error?.message?.includes('Authentication credentials were not provided')) { - return; - } - console.warn('Session validation failed:', error); - logout(); - }); - }, [isAuthenticated, refreshUser, logout]); - - return ( - <> - - - - - - {/* Auth Routes - Public */} - } /> - } /> - - {/* Protected Routes - Require Authentication */} - - - - } - > - {/* Dashboard */} - - - - } /> - - {/* Planner Module - Redirect dashboard to keywords */} - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - {/* Writer Module - Redirect dashboard to tasks */} - } /> - - - - - - } /> - {/* Writer Content Routes - Order matters: list route must come before detail route */} - - - - - - } /> - {/* Content detail view - matches /writer/content/:id (e.g., /writer/content/10) */} - - - - - - } /> - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - {/* Automation Module */} - - - - } /> - - {/* Linker Module - Redirect dashboard to content */} - } /> - - - - - - } /> - - {/* Optimizer Module - Redirect dashboard to content */} - } /> - - - - - - } /> - - - - - - } /> - - {/* Thinker Module */} - {/* Thinker Module - Redirect dashboard to prompts */} - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - {/* Billing Module */} - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - {/* Account Section - Billing & Management Pages */} - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - {/* Admin Routes */} - {/* Admin Dashboard */} - - - - } /> - - {/* Admin Account Management */} - - - - } /> - - - - } /> - - - - } /> - - {/* Admin Billing Administration */} - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - {/* Admin User Administration */} - - - - } /> - - - - } /> - - - - } /> - - {/* Admin System Configuration */} - - - - } /> - - {/* Admin Monitoring */} - - - - } /> - - - - } /> - - {/* Reference Data */} - - - - } /> - - - - } /> - - - - } /> - - {/* Setup Pages */} - - - - } /> - {/* Legacy redirect */} - } /> - - {/* Settings */} - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - {/* Sites Management */} - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - {/* Help */} - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - {/* UI Elements */} - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - {/* Components (Showcase Page) */} - - - - } /> - - {/* Redirect old notification route */} - - - - } /> - - - {/* Fallback Route */} - } /> - - - - ); -} diff --git a/tenant-temp/frontend/src/components/auth/AdminGuard.tsx b/tenant-temp/frontend/src/components/auth/AdminGuard.tsx deleted file mode 100644 index 88b407cf..00000000 --- a/tenant-temp/frontend/src/components/auth/AdminGuard.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { ReactNode } from "react"; -import { Navigate } from "react-router-dom"; -import { useAuthStore } from "../../store/authStore"; - -interface AdminGuardProps { - children: ReactNode; -} - -/** - * AdminGuard - restricts access to system account (aws-admin/default) or developer - */ -export default function AdminGuard({ children }: AdminGuardProps) { - const { user } = useAuthStore(); - const role = user?.role; - const accountSlug = user?.account?.slug; - const isSystemAccount = accountSlug === 'aws-admin' || accountSlug === 'default-account' || accountSlug === 'default'; - const allowed = role === 'developer' || isSystemAccount; - - if (!allowed) { - return ; - } - - return <>{children}; -} - diff --git a/tenant-temp/frontend/src/components/auth/ProtectedRoute.tsx b/tenant-temp/frontend/src/components/auth/ProtectedRoute.tsx deleted file mode 100644 index f55f851d..00000000 --- a/tenant-temp/frontend/src/components/auth/ProtectedRoute.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { useEffect, ReactNode, useState } from "react"; -import { Navigate, useLocation } from "react-router-dom"; -import { useAuthStore } from "../../store/authStore"; -import { useErrorHandler } from "../../hooks/useErrorHandler"; -import { trackLoading } from "../common/LoadingStateMonitor"; -import { fetchAPI } from "../../services/api"; - -interface ProtectedRouteProps { - children: ReactNode; -} - -/** - * ProtectedRoute component - guards routes requiring authentication - * Redirects to /signin if user is not authenticated - */ -export default function ProtectedRoute({ children }: ProtectedRouteProps) { - const { isAuthenticated, loading, user, logout } = useAuthStore(); - const location = useLocation(); - const { addError } = useErrorHandler('ProtectedRoute'); - const [showError, setShowError] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); - const [paymentCheck, setPaymentCheck] = useState<{ - loading: boolean; - hasDefault: boolean; - hasAny: boolean; - }>({ loading: true, hasDefault: false, hasAny: false }); - - const PLAN_ALLOWED_PATHS = [ - '/account/plans', - '/account/billing', - '/account/purchase-credits', - '/account/settings', - '/account/team', - '/account/usage', - '/billing', - ]; - - const isPlanAllowedPath = PLAN_ALLOWED_PATHS.some((prefix) => - location.pathname.startsWith(prefix) - ); - - // Track loading state - useEffect(() => { - trackLoading('auth-loading', loading); - }, [loading]); - - // Fetch payment methods to confirm default method availability - useEffect(() => { - if (!isAuthenticated) { - setPaymentCheck({ loading: false, hasDefault: false, hasAny: false }); - return; - } - - let cancelled = false; - const loadPaymentMethods = async () => { - setPaymentCheck((prev) => ({ ...prev, loading: true })); - try { - const data = await fetchAPI('/v1/billing/payment-methods/'); - const methods = data?.results || []; - const hasAny = methods.length > 0; - // Treat id 14 as the intended default, or any method marked default - const hasDefault = methods.some((m: any) => m.is_default) || methods.some((m: any) => String(m.id) === '14'); - if (!cancelled) { - setPaymentCheck({ loading: false, hasDefault, hasAny }); - } - } catch (err) { - if (!cancelled) { - setPaymentCheck({ loading: false, hasDefault: false, hasAny: false }); - console.warn('ProtectedRoute: failed to fetch payment methods', err); - } - } - }; - - loadPaymentMethods(); - - return () => { - cancelled = true; - }; - }, [isAuthenticated]); - - // Validate account + plan whenever auth/user changes - useEffect(() => { - if (!isAuthenticated) { - return; - } - - if (!user?.account) { - setErrorMessage('This user is not linked to an account. Please contact support.'); - logout(); - return; - } - }, [isAuthenticated, user, logout]); - - // Immediate check on mount: if loading is true, reset it immediately - useEffect(() => { - if (loading) { - console.warn('ProtectedRoute: Loading state is true on mount, resetting immediately'); - useAuthStore.setState({ loading: false }); - } - }, []); - - // Safety timeout: if loading becomes true and stays stuck, show error - useEffect(() => { - if (loading) { - const timeout1 = setTimeout(() => { - setErrorMessage('Authentication check is taking longer than expected. This may indicate a network or server issue.'); - setShowError(true); - addError(new Error('Auth loading stuck for 3 seconds'), 'ProtectedRoute'); - }, 3000); - - const timeout2 = setTimeout(() => { - console.error('ProtectedRoute: Loading state stuck for 5 seconds, forcing reset'); - useAuthStore.setState({ loading: false }); - setShowError(false); - }, 5000); - - return () => { - clearTimeout(timeout1); - clearTimeout(timeout2); - }; - } else { - setShowError(false); - } - }, [loading, addError]); - - // Show loading state while checking authentication - if (loading) { - return ( -
-
-
-

Loading...

- - {showError && ( -
-

- {errorMessage} -

- -
- )} -
-
- ); - } - - // Redirect to signin if not authenticated - if (!isAuthenticated) { - return ; - } - - // If authenticated but missing an active plan, keep user inside billing/onboarding - const accountStatus = user?.account?.status; - const accountInactive = accountStatus && ['suspended', 'cancelled'].includes(accountStatus); - const missingPlan = user?.account && !user.account.plan; - const missingPayment = !paymentCheck.loading && (!paymentCheck.hasDefault || !paymentCheck.hasAny); - - if ((missingPlan || accountInactive || missingPayment) && !isPlanAllowedPath) { - if (paymentCheck.loading) { - return ( -
-
-
-

Checking billing status...

-
-
- ); - } - return ; - } - - return <>{children}; -} - diff --git a/tenant-temp/frontend/src/components/common/ModuleGuard.tsx b/tenant-temp/frontend/src/components/common/ModuleGuard.tsx deleted file mode 100644 index a33cea96..00000000 --- a/tenant-temp/frontend/src/components/common/ModuleGuard.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { ReactNode, useEffect } from 'react'; -import { Navigate } from 'react-router-dom'; -import { useSettingsStore } from '../../store/settingsStore'; -import { isModuleEnabled } from '../../config/modules.config'; -import { isUpgradeError } from '../../utils/upgrade'; - -interface ModuleGuardProps { - module: string; - children: ReactNode; - redirectTo?: string; -} - -/** - * ModuleGuard - Protects routes based on module enable status - * Redirects to settings page if module is disabled - */ -export default function ModuleGuard({ module, children, redirectTo = '/settings/modules' }: ModuleGuardProps) { - const { moduleEnableSettings, loadModuleEnableSettings, loading } = useSettingsStore(); - - useEffect(() => { - // Load module enable settings if not already loaded - if (!moduleEnableSettings && !loading) { - loadModuleEnableSettings(); - } - }, [moduleEnableSettings, loading, loadModuleEnableSettings]); - - // While loading, show children (optimistic rendering) - if (loading || !moduleEnableSettings) { - return <>{children}; - } - - // Check if module is enabled - const enabled = isModuleEnabled(module, moduleEnableSettings as any); - - if (!enabled) { - return ; - } - - return <>{children}; -} - diff --git a/tenant-temp/frontend/src/layout/AppSidebar.tsx b/tenant-temp/frontend/src/layout/AppSidebar.tsx deleted file mode 100644 index ea2e4a08..00000000 --- a/tenant-temp/frontend/src/layout/AppSidebar.tsx +++ /dev/null @@ -1,636 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Link, useLocation } from "react-router-dom"; - -// Assume these icons are imported from an icon library -import { - ChevronDownIcon, - GridIcon, - HorizontaLDots, - ListIcon, - PieChartIcon, - PlugInIcon, - TaskIcon, - BoltIcon, - DocsIcon, - PageIcon, - DollarLineIcon, - FileIcon, - UserIcon, - UserCircleIcon, -} from "../icons"; -import { useSidebar } from "../context/SidebarContext"; -import SidebarWidget from "./SidebarWidget"; -import { APP_VERSION } from "../config/version"; -import { useAuthStore } from "../store/authStore"; -import { useSettingsStore } from "../store/settingsStore"; -import ApiStatusIndicator from "../components/sidebar/ApiStatusIndicator"; - -type NavItem = { - name: string; - icon: React.ReactNode; - path?: string; - subItems?: { name: string; path: string; pro?: boolean; new?: boolean }[]; -}; - -type MenuSection = { - label: string; - items: NavItem[]; -}; - -const AppSidebar: React.FC = () => { - const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar(); - const location = useLocation(); - const { user, isAuthenticated } = useAuthStore(); - const { moduleEnableSettings, isModuleEnabled: checkModuleEnabled, loadModuleEnableSettings, loading: settingsLoading } = useSettingsStore(); - - // Show admin menu only for system account (aws-admin/default) or developer - const isAwsAdminAccount = Boolean( - user?.account?.slug === 'aws-admin' || - user?.account?.slug === 'default-account' || - user?.account?.slug === 'default' || - user?.role === 'developer' - ); - - // Helper to check if module is enabled - memoized to prevent infinite loops - const moduleEnabled = useCallback((moduleName: string): boolean => { - if (!moduleEnableSettings) return true; // Default to enabled if not loaded - return checkModuleEnabled(moduleName); - }, [moduleEnableSettings, checkModuleEnabled]); - - const [openSubmenu, setOpenSubmenu] = useState<{ - sectionIndex: number; - itemIndex: number; - } | null>(null); - const [subMenuHeight, setSubMenuHeight] = useState>( - {} - ); - const subMenuRefs = useRef>({}); - - const isActive = useCallback( - (path: string) => location.pathname === path, - [location.pathname] - ); - - // Load module enable settings on mount (only once) - but only if user is authenticated - useEffect(() => { - // Only load if user is authenticated and settings aren't already loaded - // Skip for non-module pages to reduce unnecessary calls (e.g., account/billing/signup) - const path = location.pathname || ''; - const isModulePage = [ - '/planner', - '/writer', - '/automation', - '/thinker', - '/linker', - '/optimizer', - '/publisher', - '/dashboard', - '/home', - ].some((p) => path.startsWith(p)); - - if (user && isAuthenticated && isModulePage && !moduleEnableSettings && !settingsLoading) { - loadModuleEnableSettings().catch((error) => { - console.warn('Failed to load module enable settings:', error); - }); - } - }, [user, isAuthenticated, location.pathname]); // Only run when user/auth or route changes - - // Define menu sections with useMemo to prevent recreation on every render - // Filter out disabled modules based on module enable settings - // New structure: Dashboard (standalone) → SETUP → WORKFLOW → SETTINGS - const menuSections: MenuSection[] = useMemo(() => { - // SETUP section items (single items, no dropdowns - submenus shown as in-page navigation) - const setupItems: NavItem[] = [ - { - icon: , - name: "Add Keywords", - path: "/setup/add-keywords", - }, - { - icon: , - name: "Sites", - path: "/sites", // Submenus shown as in-page navigation - }, - ]; - - // Add Thinker if enabled (single item, no dropdown) - if (moduleEnabled('thinker')) { - setupItems.push({ - icon: , - name: "Thinker", - path: "/thinker/prompts", // Default to prompts, submenus shown as in-page navigation - }); - } - - // WORKFLOW section items (single items, no dropdowns - submenus shown as in-page navigation) - const workflowItems: NavItem[] = []; - - // Add Planner if enabled (single item, no dropdown) - if (moduleEnabled('planner')) { - workflowItems.push({ - icon: , - name: "Planner", - path: "/planner/keywords", // Default to keywords, submenus shown as in-page navigation - }); - } - - // Add Writer if enabled (single item, no dropdown) - if (moduleEnabled('writer')) { - workflowItems.push({ - icon: , - name: "Writer", - path: "/writer/tasks", // Default to tasks, submenus shown as in-page navigation - }); - } - - // Add Automation (always available if Writer is enabled) - if (moduleEnabled('writer')) { - workflowItems.push({ - icon: , - name: "Automation", - path: "/automation", - }); - } - - // Add Linker if enabled (single item, no dropdown) - if (moduleEnabled('linker')) { - workflowItems.push({ - icon: , - name: "Linker", - path: "/linker/content", - }); - } - - // Add Optimizer if enabled (single item, no dropdown) - if (moduleEnabled('optimizer')) { - workflowItems.push({ - icon: , - name: "Optimizer", - path: "/optimizer/content", - }); - } - - return [ - // Dashboard is standalone (no section header) - { - label: "", // Empty label for standalone Dashboard - items: [ - { - icon: , - name: "Dashboard", - path: "/", - }, - ], - }, - { - label: "SETUP", - items: setupItems, - }, - { - label: "WORKFLOW", - items: workflowItems, - }, - { - label: "ACCOUNT", - items: [ - { - icon: , - name: "Account Settings", - path: "/account/settings", - }, - { - icon: , - name: "Plans & Billing", - path: "/account/billing", - }, - { - icon: , - name: "Plans", - path: "/account/plans", - }, - { - icon: , - name: "Team Management", - path: "/account/team", - }, - { - icon: , - name: "Usage & Analytics", - path: "/account/usage", - }, - ], - }, - { - label: "SETTINGS", - items: [ - { - icon: , - name: "Profile Settings", - path: "/settings/profile", - }, - // Integration is admin-only; hide for non-privileged users (handled in render) - { - icon: , - name: "Integration", - path: "/settings/integration", - adminOnly: true, - }, - { - icon: , - name: "Publishing", - path: "/settings/publishing", - }, - { - icon: , - name: "Import / Export", - path: "/settings/import-export", - }, - ], - }, - { - label: "HELP & DOCS", - items: [ - { - icon: , - name: "Help & Documentation", - path: "/help", - }, - ], - }, - ]; - }, [moduleEnabled]); - - // Admin section - only shown for users in aws-admin account - const adminSection: MenuSection = useMemo(() => ({ - label: "ADMIN", - items: [ - { - icon: , - name: "System Dashboard", - path: "/admin/dashboard", - }, - { - icon: , - name: "Account Management", - subItems: [ - { name: "All Accounts", path: "/admin/accounts" }, - { name: "Subscriptions", path: "/admin/subscriptions" }, - { name: "Account Limits", path: "/admin/account-limits" }, - ], - }, - { - icon: , - name: "Billing Administration", - subItems: [ - { name: "Billing Overview", path: "/admin/billing" }, - { name: "Invoices", path: "/admin/invoices" }, - { name: "Payments", path: "/admin/payments" }, - { name: "Credit Costs Config", path: "/admin/credit-costs" }, - { name: "Credit Packages", path: "/admin/credit-packages" }, - ], - }, - { - icon: , - name: "User Administration", - subItems: [ - { name: "All Users", path: "/admin/users" }, - { name: "Roles & Permissions", path: "/admin/roles" }, - { name: "Activity Logs", path: "/admin/activity-logs" }, - ], - }, - { - icon: , - name: "System Configuration", - subItems: [ - { name: "System Settings", path: "/admin/system-settings" }, - { name: "AI Settings", path: "/admin/ai-settings" }, - { name: "Module Settings", path: "/admin/module-settings" }, - { name: "Integration Settings", path: "/admin/integration-settings" }, - ], - }, - { - icon: , - name: "Monitoring", - subItems: [ - { name: "System Health", path: "/settings/status" }, - { name: "API Monitor", path: "/settings/api-monitor" }, - { name: "Debug Status", path: "/settings/debug-status" }, - ], - }, - { - icon: , - name: "Developer Tools", - subItems: [ - { name: "Function Testing", path: "/admin/function-testing" }, - { name: "System Testing", path: "/admin/system-testing" }, - { name: "UI Elements", path: "/admin/ui-elements" }, - ], - }, - ], - }), []); - - // Combine all sections, including admin if user is in aws-admin account - const allSections = useMemo(() => { - const baseSections = menuSections.map(section => { - // Filter adminOnly items for non-system users - const filteredItems = section.items.filter((item: any) => { - if ((item as any).adminOnly && !isAwsAdminAccount) return false; - return true; - }); - return { ...section, items: filteredItems }; - }); - return isAwsAdminAccount - ? [...baseSections, adminSection] - : baseSections; - }, [isAwsAdminAccount, menuSections, adminSection]); - - useEffect(() => { - const currentPath = location.pathname; - let foundMatch = false; - - // Find the matching submenu for the current path - allSections.forEach((section, sectionIndex) => { - section.items.forEach((nav, itemIndex) => { - if (nav.subItems && !foundMatch) { - const shouldOpen = nav.subItems.some((subItem) => { - if (currentPath === subItem.path) return true; - if (subItem.path !== '/' && currentPath.startsWith(subItem.path + '/')) return true; - return false; - }); - - if (shouldOpen) { - setOpenSubmenu((prev) => { - // Only update if different to prevent infinite loops - if (prev?.sectionIndex === sectionIndex && prev?.itemIndex === itemIndex) { - return prev; - } - return { - sectionIndex, - itemIndex, - }; - }); - foundMatch = true; - } - } - }); - }); - - // If no match found and we're not on a submenu path, don't change the state - // This allows manual toggles to persist - }, [location.pathname, allSections]); - - useEffect(() => { - if (openSubmenu !== null) { - const key = `${openSubmenu.sectionIndex}-${openSubmenu.itemIndex}`; - // Use requestAnimationFrame and setTimeout to ensure DOM is ready - const frameId = requestAnimationFrame(() => { - setTimeout(() => { - const element = subMenuRefs.current[key]; - if (element) { - // scrollHeight should work even when height is 0px due to overflow-hidden - const scrollHeight = element.scrollHeight; - if (scrollHeight > 0) { - setSubMenuHeight((prevHeights) => { - // Only update if height changed to prevent infinite loops - if (prevHeights[key] === scrollHeight) { - return prevHeights; - } - return { - ...prevHeights, - [key]: scrollHeight, - }; - }); - } - } - }, 50); - }); - return () => cancelAnimationFrame(frameId); - } - }, [openSubmenu]); - - const handleSubmenuToggle = (sectionIndex: number, itemIndex: number) => { - setOpenSubmenu((prevOpenSubmenu) => { - if ( - prevOpenSubmenu && - prevOpenSubmenu.sectionIndex === sectionIndex && - prevOpenSubmenu.itemIndex === itemIndex - ) { - return null; - } - return { sectionIndex, itemIndex }; - }); - }; - - const renderMenuItems = (items: NavItem[], sectionIndex: number) => ( -
    - {items.map((nav, itemIndex) => ( -
  • - {nav.subItems ? ( - - ) : ( - nav.path && ( - - - {nav.icon} - - {(isExpanded || isHovered || isMobileOpen) && ( - {nav.name} - )} - - ) - )} - {nav.subItems && (isExpanded || isHovered || isMobileOpen) && ( -
    { - subMenuRefs.current[`${sectionIndex}-${itemIndex}`] = el; - }} - className="overflow-hidden transition-all duration-300" - style={{ - height: - openSubmenu?.sectionIndex === sectionIndex && openSubmenu?.itemIndex === itemIndex - ? `${subMenuHeight[`${sectionIndex}-${itemIndex}`]}px` - : "0px", - }} - > -
      - {nav.subItems.map((subItem) => ( -
    • - - {subItem.name} - - {subItem.new && ( - - new - - )} - {subItem.pro && ( - - pro - - )} - - -
    • - ))} -
    -
    - )} -
  • - ))} -
- ); - - return ( - - ); -}; - -export default AppSidebar; diff --git a/tenant-temp/master-docs/00-system/07-MULTITENANCY-ACCESS-REFERENCE.md b/tenant-temp/master-docs/00-system/07-MULTITENANCY-ACCESS-REFERENCE.md deleted file mode 100644 index 78de8df3..00000000 --- a/tenant-temp/master-docs/00-system/07-MULTITENANCY-ACCESS-REFERENCE.md +++ /dev/null @@ -1,104 +0,0 @@ -# Multi-Tenancy & Access Reference (Current State) - -## Purpose -Authoritative map of tenant isolation, role access, and payment/API-key handling across the stack. Built from code as of Dec 2025. - -## Core Enforcement Points (backend) -- Middleware: - - `backend/igny8_core/auth/middleware.py` (`AccountContextMiddleware`, ~L1-L220): resolves `request.account` from JWT/API key; blocks inactive/suspended accounts. - - `backend/igny8_core/middleware/request_id.py` (~L1-L70): request ID (not tenancy). - - `backend/igny8_core/middleware/resource_tracker.py` (~L1-L170): metrics (not tenancy). -- Base viewsets: - - `backend/igny8_core/api/base.py` (`AccountModelViewSet`, ~L1-L240): filters by `request.account`; admin/developer/system overrides; sets account on create. - - `backend/igny8_core/api/base.py` (`SiteSectorModelViewSet`, ~L238-L430): additionally filters by site/sector and user’s accessible sites (SiteUserAccess) unless admin/developer/system. -- Permissions: - - `backend/igny8_core/api/permissions.py`: - - `IsAuthenticatedAndActive`, `HasTenantAccess` (default in settings). - - `IsViewerOrAbove`, `IsEditorOrAbove`, `IsAdminOrOwner`. - - `IsSystemAccountOrDeveloper` (system/admin for integrations). - - Module-specific permissions also appear in `backend/igny8_core/auth/permissions.py` (legacy IsOwnerOrAdmin, IsEditorOrAbove, IsViewerOrAbove, AccountPermission). -- Settings defaults: - - `backend/igny8_core/settings.py` REST_FRAMEWORK `DEFAULT_PERMISSION_CLASSES` = `IsAuthenticatedAndActive` + `HasTenantAccess`. - - Auth order: APIKeyAuthentication → JWTAuthentication → CSRFExemptSessionAuthentication → BasicAuth. - - Throttling: `DebugScopedRateThrottle` bypasses throttles for authenticated users/system/debug. -- Models with enforced account/site/sector: - - Base models `AccountBaseModel`, `SiteSectorBaseModel` in `backend/igny8_core/auth/models.py` (top of file). - -## Flow (text flowchart) -``` -Request - -> Middleware: AccountContextMiddleware sets request.account (JWT/API key), validates account status/plan - -> DRF Auth: APIKey/JWT/Session - -> Permissions: IsAuthenticatedAndActive + HasTenantAccess (+ role-specific) - -> ViewSet: - AccountModelViewSet filters by account - SiteSectorModelViewSet filters by account + site/sector + SiteUserAccess - -> Action-specific role checks (IsEditorOrAbove, IsAdminOrOwner, IsSystemAccountOrDeveloper) - -> Business logic (services) + credit checks (billing) - -> Response -``` - -## Module Access (backend ViewSets & guards) -- Accounts/Users/Plans/Subscriptions: - - `auth/views.py`: `UsersViewSet`, `AccountsViewSet`, `SubscriptionsViewSet`, `SiteUserAccessViewSet` (account-scoped via AccountModelViewSet + role guards). - - Roles: owner/admin (or developer/system) can manage; others limited to self (UsersViewSet get_queryset). -- Sites/Sectors: - - `auth/views.py` (`SiteViewSet`, `Sector` actions): SiteSectorModelViewSet enforces account + site/sector + SiteUserAccess; public slug read is AllowAny for active site slug only. -- Planner: - - `modules/planner/views.py` (KeywordViewSet, ClusterViewSet, ContentIdeasViewSet) inherit SiteSectorModelViewSet; require site_id/sector_id; role: typically editor+ for writes. -- Writer: - - `modules/writer/views.py` (TasksViewSet, ContentViewSet, ImagesViewSet, ContentTaxonomyViewSet) inherit SiteSectorModelViewSet; site/sector scoping; editor+ for writes. -- Automation: - - `business/automation/views.py` (AutomationViewSet) inherits AccountModelViewSet/SiteSectorModelViewSet patterns; requires site_id for run/config; role: editor+ for mutate. -- System settings (non-integrations): - - `modules/system/views.py` / `settings_views.py`: AccountModelViewSet; role usually admin/owner; authenticated + tenant required. -- Integrations (OpenAI/Runware API keys): - - `modules/system/integration_views.py`: guarded by `IsSystemAccountOrDeveloper` (system account or developer only); tenant-scoped but effectively system-only for keys. -- Billing: - - `modules/billing/views.py`: AccountModelViewSet; `IsAdminOrOwner` for credit transactions/payment methods; balance/usage requires auth + tenant. -- Payments/Payment Methods: - - Payment methods: `AccountPaymentMethodViewSet` account-scoped; IsAuthenticated; default selection per account; admin/owner should manage. - - Payments: `PaymentViewSet` account-scoped; IsAuthenticated; list/available_methods/manual payment for current account only. - -## Frontend Guards -- Route protection: `ProtectedRoute` (auth required, checks account/plan/payment methods), `ModuleGuard` (module enabled), `AdminGuard` (integration/admin pages only for system account or developer). -- Sidebar hides Integration for non-system/developer; admin section shown only for system/developer. - -## AI Key Resolution -- `ai/ai_core.py` `_load_account_settings`: tries tenant IntegrationSettings → system account IntegrationSettings (`aws-admin`/`default-account`/`default`) → Django settings (`OPENAI_API_KEY`, `RUNWARE_API_KEY`). All users run AI with shared keys if tenant keys absent. - -## Throttling -- `api/throttles.py` `DebugScopedRateThrottle`: bypass for authenticated users/system/debug; per-scope rates in `settings.py`. Prevents 429s for normal users. - -## Payment / Billing Workflow (happy path) -1) User authenticates (JWT) → request.account set. -2) Payment methods (account-scoped) fetched via `/v1/billing/payment-methods/available/`; admin/owner can CRUD `/v1/billing/payment-methods/`. -3) Invoices/Payments via billing endpoints (account-scoped; admin/owner). -4) Credits used via CreditService on AI/automation calls (backend). - -## Access Summary by Role (runtime enforcement) -- Viewer: read-only where viewsets allow `IsViewerOrAbove`; no writes. -- Editor: can write planner/writer/automation; cannot manage billing/integration. -- Admin/Owner: manage account/team/billing/payment methods; full module writes. -- Developer/System account: cross-tenant overrides in some base filters; integration settings and admin menus. - -## Key Files (with line bands) -- Middleware: `auth/middleware.py` (~L1-220) -- Base viewsets: `api/base.py` (~L1-430) -- Permissions: `api/permissions.py` (~L1-200), `auth/permissions.py` (~L1-120) -- Settings (REST/Throttle): `settings.py` (REST_FRAMEWORK block, ~L200-360) -- AI core key loading: `ai/ai_core.py` (~L1-120) -- Integration settings views: `modules/system/integration_views.py` (~L1-300 main guards; actions throughout) -- Planner views: `modules/planner/views.py` (all ViewSets inherit SiteSectorModelViewSet) -- Writer views: `modules/writer/views.py` -- Automation: `business/automation/views.py`, `services/automation_service.py` -- Billing: `modules/billing/views.py`, `business/billing/services/credit_service.py` -- Payment methods: `modules/billing/views.py` AccountPaymentMethodViewSet -- Frontend guards: `src/components/auth/ProtectedRoute.tsx`, `src/components/auth/AdminGuard.tsx`, `src/components/common/ModuleGuard.tsx` -- Sidebar gating: `src/layout/AppSidebar.tsx` - -## Open Items / Risks -- Ensure public endpoints explicitly override default permissions (e.g., auth register/login, site slug read). -- Validate all viewsets still inherit AccountModelViewSet/SiteSectorModelViewSet after future changes. -- Add automated tests for cross-tenant denial, role gates, plan limits, and integration access.*** -