This commit is contained in:
alorig
2025-12-08 14:57:26 +05:00
parent 8231c499c2
commit affa783a4f
45 changed files with 0 additions and 24385 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 Ill implement it.

View File

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

View File

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

View File

@@ -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) Whats 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]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 users 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.***