docs
This commit is contained in:
@@ -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
|
|
||||||
@@ -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
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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**
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
|
|
||||||
@@ -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.
|
|
||||||
@@ -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]
|
|
||||||
@@ -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'
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
|
|
||||||
@@ -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()
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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})
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<prompt_type>[^/.]+)', 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
|
|
||||||
)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 <your_access_token>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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 <your_token>',
|
|
||||||
'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 <your_token>',
|
|
||||||
'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')
|
|
||||||
@@ -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 (
|
|
||||||
<>
|
|
||||||
<GlobalErrorDisplay />
|
|
||||||
<LoadingStateMonitor />
|
|
||||||
<HelmetProvider>
|
|
||||||
<ScrollToTop />
|
|
||||||
<Routes>
|
|
||||||
{/* Auth Routes - Public */}
|
|
||||||
<Route path="/signin" element={<SignIn />} />
|
|
||||||
<Route path="/signup" element={<SignUp />} />
|
|
||||||
|
|
||||||
{/* Protected Routes - Require Authentication */}
|
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<AppLayout />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Dashboard */}
|
|
||||||
<Route index path="/" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Home />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Planner Module - Redirect dashboard to keywords */}
|
|
||||||
<Route path="/planner" element={<Navigate to="/planner/keywords" replace />} />
|
|
||||||
<Route path="/planner/keywords" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="planner">
|
|
||||||
<Keywords />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/planner/clusters" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="planner">
|
|
||||||
<Clusters />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/planner/clusters/:id" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="planner">
|
|
||||||
<ClusterDetail />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/planner/ideas" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="planner">
|
|
||||||
<Ideas />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Writer Module - Redirect dashboard to tasks */}
|
|
||||||
<Route path="/writer" element={<Navigate to="/writer/tasks" replace />} />
|
|
||||||
<Route path="/writer/tasks" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="writer">
|
|
||||||
<Tasks />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
{/* Writer Content Routes - Order matters: list route must come before detail route */}
|
|
||||||
<Route path="/writer/content" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="writer">
|
|
||||||
<Content />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
{/* Content detail view - matches /writer/content/:id (e.g., /writer/content/10) */}
|
|
||||||
<Route path="/writer/content/:id" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="writer">
|
|
||||||
<ContentView />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/writer/drafts" element={<Navigate to="/writer/content" replace />} />
|
|
||||||
<Route path="/writer/images" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="writer">
|
|
||||||
<Images />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/writer/review" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="writer">
|
|
||||||
<Review />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/writer/published" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="writer">
|
|
||||||
<Published />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Automation Module */}
|
|
||||||
<Route path="/automation" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AutomationPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Linker Module - Redirect dashboard to content */}
|
|
||||||
<Route path="/linker" element={<Navigate to="/linker/content" replace />} />
|
|
||||||
<Route path="/linker/content" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="linker">
|
|
||||||
<LinkerContentList />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Optimizer Module - Redirect dashboard to content */}
|
|
||||||
<Route path="/optimizer" element={<Navigate to="/optimizer/content" replace />} />
|
|
||||||
<Route path="/optimizer/content" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="optimizer">
|
|
||||||
<OptimizerContentSelector />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/optimizer/analyze/:id" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="optimizer">
|
|
||||||
<AnalysisPreview />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Thinker Module */}
|
|
||||||
{/* Thinker Module - Redirect dashboard to prompts */}
|
|
||||||
<Route path="/thinker" element={<Navigate to="/thinker/prompts" replace />} />
|
|
||||||
<Route path="/thinker/prompts" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="thinker">
|
|
||||||
<Prompts />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/thinker/author-profiles" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="thinker">
|
|
||||||
<AuthorProfiles />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/thinker/profile" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="thinker">
|
|
||||||
<ThinkerProfile />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/thinker/strategies" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="thinker">
|
|
||||||
<Strategies />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/thinker/image-testing" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleGuard module="thinker">
|
|
||||||
<ImageTesting />
|
|
||||||
</ModuleGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Billing Module */}
|
|
||||||
<Route path="/billing" element={<Navigate to="/billing/overview" replace />} />
|
|
||||||
<Route path="/billing/overview" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<CreditsAndBilling />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/billing/credits" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Credits />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/billing/transactions" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Transactions />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/billing/usage" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Usage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Account Section - Billing & Management Pages */}
|
|
||||||
<Route path="/account/plans" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PlansAndBillingPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/account/billing" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AccountBillingPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/account/purchase-credits" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PurchaseCreditsPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/account/settings" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AccountSettingsPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/account/team" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<TeamManagementPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/account/usage" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<UsageAnalyticsPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Admin Routes */}
|
|
||||||
{/* Admin Dashboard */}
|
|
||||||
<Route path="/admin/dashboard" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminSystemDashboard />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Admin Account Management */}
|
|
||||||
<Route path="/admin/accounts" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminAllAccountsPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/admin/subscriptions" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminSubscriptionsPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/admin/account-limits" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminAccountLimitsPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Admin Billing Administration */}
|
|
||||||
<Route path="/admin/billing" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminBilling />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/admin/invoices" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminAllInvoicesPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/admin/payments" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminAllPaymentsPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/admin/payments/approvals" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PaymentApprovalPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/admin/credit-packages" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminCreditPackagesPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/admin/credit-costs" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminCreditCostsPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Admin User Administration */}
|
|
||||||
<Route path="/admin/users" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminAllUsersPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/admin/roles" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminRolesPermissionsPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/admin/activity-logs" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminActivityLogsPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Admin System Configuration */}
|
|
||||||
<Route path="/admin/settings/system" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminSystemSettingsPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Admin Monitoring */}
|
|
||||||
<Route path="/admin/monitoring/health" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminSystemHealthPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/admin/monitoring/api" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminAPIMonitorPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Reference Data */}
|
|
||||||
<Route path="/reference/seed-keywords" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<SeedKeywords />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/planner/keyword-opportunities" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<KeywordOpportunities />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/reference/industries" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ReferenceIndustries />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Setup Pages */}
|
|
||||||
<Route path="/setup/add-keywords" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<IndustriesSectorsKeywords />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
{/* Legacy redirect */}
|
|
||||||
<Route path="/setup/industries-sectors-keywords" element={<Navigate to="/setup/add-keywords" replace />} />
|
|
||||||
|
|
||||||
{/* Settings */}
|
|
||||||
<Route path="/settings/profile" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ProfileSettingsPage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<GeneralSettings />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/users" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Users />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/subscriptions" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Subscriptions />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/system" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<SystemSettings />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/account" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AccountSettings />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/modules" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModuleSettings />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/ai" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AISettings />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/plans" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Plans />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/industries" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Industries />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/status" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<MasterStatus />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/api-monitor" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ApiMonitor />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/debug-status" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<DebugStatus />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/integration" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AdminGuard>
|
|
||||||
<Integration />
|
|
||||||
</AdminGuard>
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/publishing" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Publishing />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/sites" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Sites />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/settings/import-export" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ImportExport />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Sites Management */}
|
|
||||||
<Route path="/sites" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<SiteList />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/sites/manage" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<SiteManage />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/sites/:id" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<SiteDashboard />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/sites/:id/pages" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PageManager />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/sites/:id/pages/new" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PageManager />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/sites/:id/pages/:pageId/edit" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PageManager />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/sites/:id/content" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<SiteContent />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/sites/:id/settings" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<SiteSettings />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/sites/:id/sync" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<SyncDashboard />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/sites/:id/deploy" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<DeploymentPanel />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/sites/:id/posts/:postId" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PostEditor />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/sites/:id/posts/:postId/edit" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PostEditor />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
|
|
||||||
{/* Help */}
|
|
||||||
<Route path="/help" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Help />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/help/docs" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Docs />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/help/system-testing" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<SystemTesting />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/help/function-testing" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<FunctionTesting />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* UI Elements */}
|
|
||||||
<Route path="/ui-elements/alerts" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Alerts />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/avatars" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Avatars />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/badges" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Badges />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/breadcrumb" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Breadcrumb />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/buttons" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Buttons />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/buttons-group" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ButtonsGroup />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/cards" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Cards />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/carousel" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Carousel />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/dropdowns" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Dropdowns />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/images" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ImagesUI />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/links" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Links />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/list" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<List />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/modals" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Modals />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/notifications" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Notifications />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/pagination" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Pagination />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/popovers" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Popovers />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/pricing-table" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<PricingTable />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/progressbar" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Progressbar />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/ribbons" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Ribbons />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/spinners" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Spinners />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/tabs" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Tabs />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/tooltips" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Tooltips />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
<Route path="/ui-elements/videos" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Videos />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Components (Showcase Page) */}
|
|
||||||
<Route path="/components" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Components />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Redirect old notification route */}
|
|
||||||
<Route path="/notifications" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Notifications />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Fallback Route */}
|
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
</Routes>
|
|
||||||
</HelmetProvider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 <Navigate to="/" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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<string>('');
|
|
||||||
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 (
|
|
||||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
||||||
<div className="text-center max-w-md px-4">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mb-4"></div>
|
|
||||||
<p className="text-lg font-medium text-gray-800 dark:text-white mb-2">Loading...</p>
|
|
||||||
|
|
||||||
{showError && (
|
|
||||||
<div className="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
|
||||||
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-3">
|
|
||||||
{errorMessage}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
useAuthStore.setState({ loading: false });
|
|
||||||
setShowError(false);
|
|
||||||
window.location.reload();
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 text-sm bg-yellow-600 text-white rounded hover:bg-yellow-700"
|
|
||||||
>
|
|
||||||
Retry or Reload Page
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to signin if not authenticated
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return <Navigate to="/signin" state={{ from: location }} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
|
|
||||||
<div className="text-center max-w-md px-4">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mb-4"></div>
|
|
||||||
<p className="text-lg font-medium text-gray-800 dark:text-white mb-2">Checking billing status...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <Navigate to="/account/plans" state={{ from: location }} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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 <Navigate to={redirectTo} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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<Record<string, number>>(
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
|
||||||
|
|
||||||
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: <DocsIcon />,
|
|
||||||
name: "Add Keywords",
|
|
||||||
path: "/setup/add-keywords",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <GridIcon />,
|
|
||||||
name: "Sites",
|
|
||||||
path: "/sites", // Submenus shown as in-page navigation
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add Thinker if enabled (single item, no dropdown)
|
|
||||||
if (moduleEnabled('thinker')) {
|
|
||||||
setupItems.push({
|
|
||||||
icon: <BoltIcon />,
|
|
||||||
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: <ListIcon />,
|
|
||||||
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: <TaskIcon />,
|
|
||||||
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: <BoltIcon />,
|
|
||||||
name: "Automation",
|
|
||||||
path: "/automation",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Linker if enabled (single item, no dropdown)
|
|
||||||
if (moduleEnabled('linker')) {
|
|
||||||
workflowItems.push({
|
|
||||||
icon: <PlugInIcon />,
|
|
||||||
name: "Linker",
|
|
||||||
path: "/linker/content",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Optimizer if enabled (single item, no dropdown)
|
|
||||||
if (moduleEnabled('optimizer')) {
|
|
||||||
workflowItems.push({
|
|
||||||
icon: <BoltIcon />,
|
|
||||||
name: "Optimizer",
|
|
||||||
path: "/optimizer/content",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
// Dashboard is standalone (no section header)
|
|
||||||
{
|
|
||||||
label: "", // Empty label for standalone Dashboard
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
icon: <GridIcon />,
|
|
||||||
name: "Dashboard",
|
|
||||||
path: "/",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "SETUP",
|
|
||||||
items: setupItems,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "WORKFLOW",
|
|
||||||
items: workflowItems,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "ACCOUNT",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
icon: <UserCircleIcon />,
|
|
||||||
name: "Account Settings",
|
|
||||||
path: "/account/settings",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <DollarLineIcon />,
|
|
||||||
name: "Plans & Billing",
|
|
||||||
path: "/account/billing",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <DollarLineIcon />,
|
|
||||||
name: "Plans",
|
|
||||||
path: "/account/plans",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <UserIcon />,
|
|
||||||
name: "Team Management",
|
|
||||||
path: "/account/team",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <PieChartIcon />,
|
|
||||||
name: "Usage & Analytics",
|
|
||||||
path: "/account/usage",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "SETTINGS",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
icon: <UserCircleIcon />,
|
|
||||||
name: "Profile Settings",
|
|
||||||
path: "/settings/profile",
|
|
||||||
},
|
|
||||||
// Integration is admin-only; hide for non-privileged users (handled in render)
|
|
||||||
{
|
|
||||||
icon: <PlugInIcon />,
|
|
||||||
name: "Integration",
|
|
||||||
path: "/settings/integration",
|
|
||||||
adminOnly: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <PageIcon />,
|
|
||||||
name: "Publishing",
|
|
||||||
path: "/settings/publishing",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <FileIcon />,
|
|
||||||
name: "Import / Export",
|
|
||||||
path: "/settings/import-export",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "HELP & DOCS",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
icon: <DocsIcon />,
|
|
||||||
name: "Help & Documentation",
|
|
||||||
path: "/help",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [moduleEnabled]);
|
|
||||||
|
|
||||||
// Admin section - only shown for users in aws-admin account
|
|
||||||
const adminSection: MenuSection = useMemo(() => ({
|
|
||||||
label: "ADMIN",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
icon: <GridIcon />,
|
|
||||||
name: "System Dashboard",
|
|
||||||
path: "/admin/dashboard",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <UserIcon />,
|
|
||||||
name: "Account Management",
|
|
||||||
subItems: [
|
|
||||||
{ name: "All Accounts", path: "/admin/accounts" },
|
|
||||||
{ name: "Subscriptions", path: "/admin/subscriptions" },
|
|
||||||
{ name: "Account Limits", path: "/admin/account-limits" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <DollarLineIcon />,
|
|
||||||
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: <UserCircleIcon />,
|
|
||||||
name: "User Administration",
|
|
||||||
subItems: [
|
|
||||||
{ name: "All Users", path: "/admin/users" },
|
|
||||||
{ name: "Roles & Permissions", path: "/admin/roles" },
|
|
||||||
{ name: "Activity Logs", path: "/admin/activity-logs" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <PlugInIcon />,
|
|
||||||
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: <PieChartIcon />,
|
|
||||||
name: "Monitoring",
|
|
||||||
subItems: [
|
|
||||||
{ name: "System Health", path: "/settings/status" },
|
|
||||||
{ name: "API Monitor", path: "/settings/api-monitor" },
|
|
||||||
{ name: "Debug Status", path: "/settings/debug-status" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <BoltIcon />,
|
|
||||||
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) => (
|
|
||||||
<ul className="flex flex-col gap-2">
|
|
||||||
{items.map((nav, itemIndex) => (
|
|
||||||
<li key={nav.name}>
|
|
||||||
{nav.subItems ? (
|
|
||||||
<button
|
|
||||||
onClick={() => handleSubmenuToggle(sectionIndex, itemIndex)}
|
|
||||||
className={`menu-item group ${
|
|
||||||
openSubmenu?.sectionIndex === sectionIndex && openSubmenu?.itemIndex === itemIndex ||
|
|
||||||
(nav.subItems && nav.subItems.some(subItem => isActive(subItem.path)))
|
|
||||||
? "menu-item-active"
|
|
||||||
: "menu-item-inactive"
|
|
||||||
} cursor-pointer ${
|
|
||||||
!isExpanded && !isHovered
|
|
||||||
? "lg:justify-center"
|
|
||||||
: "lg:justify-start"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`menu-item-icon-size ${
|
|
||||||
(openSubmenu?.sectionIndex === sectionIndex && openSubmenu?.itemIndex === itemIndex) ||
|
|
||||||
(nav.subItems && nav.subItems.some(subItem => isActive(subItem.path)))
|
|
||||||
? "menu-item-icon-active"
|
|
||||||
: "menu-item-icon-inactive"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{nav.icon}
|
|
||||||
</span>
|
|
||||||
{(isExpanded || isHovered || isMobileOpen) && (
|
|
||||||
<span className="menu-item-text">{nav.name}</span>
|
|
||||||
)}
|
|
||||||
{(isExpanded || isHovered || isMobileOpen) && (
|
|
||||||
<ChevronDownIcon
|
|
||||||
className={`ml-auto w-5 h-5 transition-transform duration-200 ${
|
|
||||||
openSubmenu?.sectionIndex === sectionIndex &&
|
|
||||||
openSubmenu?.itemIndex === itemIndex
|
|
||||||
? "rotate-180 text-brand-500"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
nav.path && (
|
|
||||||
<Link
|
|
||||||
to={nav.path}
|
|
||||||
className={`menu-item group ${
|
|
||||||
isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`menu-item-icon-size ${
|
|
||||||
isActive(nav.path)
|
|
||||||
? "menu-item-icon-active"
|
|
||||||
: "menu-item-icon-inactive"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{nav.icon}
|
|
||||||
</span>
|
|
||||||
{(isExpanded || isHovered || isMobileOpen) && (
|
|
||||||
<span className="menu-item-text">{nav.name}</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
|
|
||||||
<div
|
|
||||||
ref={(el) => {
|
|
||||||
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",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ul className="mt-2 flex flex-col gap-1 ml-9">
|
|
||||||
{nav.subItems.map((subItem) => (
|
|
||||||
<li key={subItem.name}>
|
|
||||||
<Link
|
|
||||||
to={subItem.path}
|
|
||||||
className={`menu-dropdown-item ${
|
|
||||||
isActive(subItem.path)
|
|
||||||
? "menu-dropdown-item-active"
|
|
||||||
: "menu-dropdown-item-inactive"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{subItem.name}
|
|
||||||
<span className="flex items-center gap-1 ml-auto">
|
|
||||||
{subItem.new && (
|
|
||||||
<span
|
|
||||||
className={`ml-auto ${
|
|
||||||
isActive(subItem.path)
|
|
||||||
? "menu-dropdown-badge-active"
|
|
||||||
: "menu-dropdown-badge-inactive"
|
|
||||||
} menu-dropdown-badge`}
|
|
||||||
>
|
|
||||||
new
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{subItem.pro && (
|
|
||||||
<span
|
|
||||||
className={`ml-auto ${
|
|
||||||
isActive(subItem.path)
|
|
||||||
? "menu-dropdown-badge-active"
|
|
||||||
: "menu-dropdown-badge-inactive"
|
|
||||||
} menu-dropdown-badge`}
|
|
||||||
>
|
|
||||||
pro
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
|
|
||||||
${
|
|
||||||
isExpanded || isMobileOpen
|
|
||||||
? "w-[290px]"
|
|
||||||
: isHovered
|
|
||||||
? "w-[290px]"
|
|
||||||
: "w-[90px]"
|
|
||||||
}
|
|
||||||
${isMobileOpen ? "translate-x-0" : "-translate-x-full"}
|
|
||||||
lg:translate-x-0`}
|
|
||||||
onMouseEnter={() => !isExpanded && setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`py-8 flex flex-col justify-center items-center gap-3`}
|
|
||||||
>
|
|
||||||
<Link to="/" className="flex justify-center items-center">
|
|
||||||
{isExpanded || isHovered || isMobileOpen ? (
|
|
||||||
<>
|
|
||||||
<img
|
|
||||||
className="dark:hidden"
|
|
||||||
src="/images/logo/logo.svg"
|
|
||||||
alt="Logo"
|
|
||||||
width={113}
|
|
||||||
height={30}
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
className="hidden dark:block"
|
|
||||||
src="/images/logo/logo-dark.svg"
|
|
||||||
alt="Logo"
|
|
||||||
width={113}
|
|
||||||
height={30}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src="/images/logo/logo-icon.svg"
|
|
||||||
alt="Logo"
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
{/* Version Badge - Only show when sidebar is expanded */}
|
|
||||||
{(isExpanded || isHovered || isMobileOpen) && (
|
|
||||||
<div className="flex justify-center items-center">
|
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-900 dark:bg-gray-700 text-gray-100 dark:text-gray-300">
|
|
||||||
v{APP_VERSION}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
|
|
||||||
{/* API Status Indicator - above OVERVIEW section */}
|
|
||||||
<ApiStatusIndicator />
|
|
||||||
<nav className="mb-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{allSections.map((section, sectionIndex) => (
|
|
||||||
<div key={section.label || `section-${sectionIndex}`}>
|
|
||||||
{section.label && (
|
|
||||||
<h2
|
|
||||||
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${
|
|
||||||
!isExpanded && !isHovered
|
|
||||||
? "lg:justify-center"
|
|
||||||
: "justify-start"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isExpanded || isHovered || isMobileOpen ? (
|
|
||||||
section.label
|
|
||||||
) : (
|
|
||||||
<HorizontaLDots className="size-6" />
|
|
||||||
)}
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
{renderMenuItems(section.items, sectionIndex)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
{isExpanded || isHovered || isMobileOpen ? <SidebarWidget /> : null}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AppSidebar;
|
|
||||||
@@ -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.***
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user