Compare commits
232 Commits
phase-0-fo
...
0e0b862e4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e0b862e4f | ||
|
|
bcdbbfe233 | ||
|
|
1aead06939 | ||
|
|
8103c20341 | ||
|
|
e360c5fede | ||
|
|
3fcba76d0b | ||
|
|
7c4ed6a16c | ||
|
|
10ec7fb33b | ||
|
|
5f25631329 | ||
|
|
636b7ddca9 | ||
|
|
f76e791de7 | ||
|
|
081f94ffdb | ||
|
|
719e477a2f | ||
|
|
00096ad884 | ||
|
|
d042f565ba | ||
|
|
7733f93e57 | ||
|
|
362be640a9 | ||
|
|
326297eecf | ||
|
|
04ee3e2e98 | ||
|
|
cc4752a25a | ||
|
|
e09198a8fd | ||
|
|
4204cdb9a4 | ||
|
|
54457680aa | ||
|
|
9b9352b9d2 | ||
|
|
94a8aee0e2 | ||
|
|
f88aae78b1 | ||
|
|
2ef98b5113 | ||
|
|
403432770b | ||
|
|
d7533934b8 | ||
|
|
1cbc347cdc | ||
|
|
4fe68cc271 | ||
|
|
451594bd29 | ||
|
|
51bb2eafd0 | ||
|
|
b6ace0c37d | ||
|
|
f3c8f7739e | ||
|
|
53ea0c34ce | ||
|
|
67ba00d714 | ||
|
|
ba842d8332 | ||
|
|
807ced7527 | ||
|
|
a5ef36016c | ||
|
|
65a7d00fba | ||
|
|
e3aa1f1f8c | ||
|
|
d19ea662ea | ||
|
|
f63ce92587 | ||
|
|
ef735eb70b | ||
|
|
2c4cf6a0f5 | ||
|
|
0bd603f925 | ||
|
|
93923f25aa | ||
|
|
af6b29b8f8 | ||
|
|
f255e3c0a0 | ||
|
|
9ee03f4f7f | ||
|
|
d4990fb088 | ||
|
|
e2c0d3d0fc | ||
|
|
6f50b3c88f | ||
|
|
6e25c5e307 | ||
|
|
8510b87a67 | ||
|
|
8296685fbd | ||
|
|
cbb6198214 | ||
|
|
c54ecd47fe | ||
|
|
abd5518cf1 | ||
|
|
a0d9bccb05 | ||
|
|
3b3be535d6 | ||
|
|
029c66a0f1 | ||
|
|
1a1214d93f | ||
|
|
aa3574287d | ||
|
|
e99bec5067 | ||
|
|
3fb86eacf1 | ||
|
|
3d3ac0647e | ||
|
|
dfeceb392d | ||
|
|
ab15546979 | ||
|
|
5971750295 | ||
|
|
bcee76fab7 | ||
|
|
3580acf61e | ||
|
|
84c18848b0 | ||
|
|
c84bb9bc14 | ||
|
|
3735f99207 | ||
|
|
554c1667b3 | ||
|
|
c1ce8de9fb | ||
|
|
005ea0d622 | ||
|
|
55dfd5ad19 | ||
|
|
a82be89d21 | ||
|
|
1227df4a41 | ||
|
|
9ec1aa8948 | ||
|
|
c35b3c3641 | ||
|
|
1eba4a4e15 | ||
|
|
4a39c349f6 | ||
|
|
744e5d55c6 | ||
|
|
b293856ef2 | ||
|
|
5106f7b200 | ||
|
|
c8adfe06d1 | ||
|
|
6bb918bad6 | ||
|
|
a4d8cdbec1 | ||
|
|
d14d6d89b1 | ||
|
|
b38553cfc3 | ||
|
|
c31567ec9f | ||
|
|
1b4cd59e5b | ||
|
|
781052c719 | ||
|
|
3e142afc7a | ||
|
|
45dc0d1fa2 | ||
|
|
b0409d965b | ||
|
|
8b798ed191 | ||
|
|
8489b2ea48 | ||
|
|
09232aa1c0 | ||
|
|
8e7afa76cd | ||
|
|
a0de0cf6b1 | ||
|
|
584dce7b8e | ||
|
|
ec3ca2da5d | ||
|
|
6c05adc990 | ||
|
|
746a51715f | ||
|
|
bae9ea47d8 | ||
|
|
38f6026e73 | ||
|
|
7321803006 | ||
|
|
52c9c9f3d5 | ||
|
|
72e1f25bc7 | ||
|
|
4ca85ae0e5 | ||
|
|
b5cc262f04 | ||
|
|
3802d2e9a3 | ||
|
|
094a252c21 | ||
|
|
8b7ed02759 | ||
|
|
142077ce85 | ||
|
|
c7f05601df | ||
|
|
4c3da7da2b | ||
|
|
e4e7ddfdf3 | ||
|
|
6c6133a683 | ||
|
|
8ab15d1d79 | ||
|
|
0ee4acb6f0 | ||
|
|
c4b79802ec | ||
|
|
adc681af8c | ||
|
|
0eb039e1a7 | ||
|
|
d696d55309 | ||
|
|
49ac8f10c1 | ||
|
|
c378e503d8 | ||
|
|
ce6da7d2d5 | ||
|
|
4cfe4c3238 | ||
|
|
a29ba4850f | ||
|
|
801ae5c102 | ||
|
|
3dbf9c7775 | ||
|
|
bb7af0e866 | ||
|
|
7ff73122c7 | ||
|
|
11766454e9 | ||
|
|
f1a3504b72 | ||
|
|
4232faa5e9 | ||
|
|
f48bb54607 | ||
|
|
d600249788 | ||
|
|
1ceeabed67 | ||
|
|
040ba79621 | ||
|
|
26ec2ae03e | ||
|
|
5d97ab6e49 | ||
|
|
3ea519483d | ||
|
|
8508af37c7 | ||
|
|
b05421325c | ||
|
|
155a73d928 | ||
|
|
856b40ed0b | ||
|
|
5552e698be | ||
|
|
2074191eee | ||
|
|
51c3986e01 | ||
|
|
873f97ea3f | ||
|
|
ef16ad760f | ||
|
|
68a98208b1 | ||
|
|
9facd12082 | ||
|
|
a6a80ad005 | ||
|
|
9a6d47b91b | ||
|
|
a0f3e3a778 | ||
|
|
40d379dd7e | ||
|
|
342d9eab17 | ||
|
|
8040d983de | ||
|
|
abcbf687ae | ||
|
|
ee56f9bbac | ||
|
|
0818dfe385 | ||
|
|
aa74fb0d65 | ||
|
|
a7d432500f | ||
|
|
b6b1aecdce | ||
|
|
f7115190dc | ||
|
|
4b9e1a49a9 | ||
|
|
5a36686844 | ||
|
|
e3d4ba2c02 | ||
|
|
2605c62eec | ||
|
|
41c1501764 | ||
|
|
fe7af3c81c | ||
|
|
ea9ffedc01 | ||
|
|
bf6589449f | ||
|
|
75ba407df5 | ||
|
|
4b21009cf8 | ||
|
|
8a9dd8ed2f | ||
|
|
9930728e8a | ||
|
|
fe95d09bbe | ||
|
|
4ecc1706bc | ||
|
|
0f02bd6409 | ||
|
|
1134285a12 | ||
|
|
1c2c9354ba | ||
|
|
92f51859fe | ||
|
|
7f8982a0ab | ||
|
|
455358ecfc | ||
|
|
cb0e42bb8d | ||
|
|
9ab87416d8 | ||
|
|
56c30e4904 | ||
|
|
51cd021f85 | ||
|
|
fc6dd5623a | ||
|
|
1531f41226 | ||
|
|
37a64fa1ef | ||
|
|
c4daeb1870 | ||
|
|
79aab68acd | ||
|
|
11a5a66c8b | ||
|
|
ab292de06c | ||
|
|
8a9dd44c50 | ||
|
|
b2e60b749a | ||
|
|
9f3c4a6cdd | ||
|
|
219dae83c6 | ||
|
|
066b81dd2a | ||
|
|
8171014a7e | ||
|
|
46b5b5f1b2 | ||
|
|
a267fc0715 | ||
|
|
9ec8908091 | ||
|
|
0d468ef15a | ||
|
|
8fc483251e | ||
|
|
1d39f3f00a | ||
|
|
b20fab8ec1 | ||
|
|
437b0c7516 | ||
|
|
4de9128430 | ||
|
|
f195b6a72a | ||
|
|
ab6b6cc4be | ||
|
|
d0e6b342b5 | ||
|
|
461f3211dd | ||
|
|
abbf6dbabb | ||
|
|
a10e89ab08 | ||
|
|
5842ca2dfc | ||
|
|
9b3fb25bc9 | ||
|
|
dbe8da589f | ||
|
|
8102aa74eb | ||
|
|
13bd7fa134 | ||
|
|
a73b2ae22b | ||
|
|
5b11c4001e |
8
.cursor/igny8.code-workspace
Normal file
8
.cursor/igny8.code-workspace
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": ".."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
97
.github/copilot-instructions.md
vendored
Normal file
97
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
<!-- Copilot / AI agent instructions for the IGNY8 monorepo (backend + frontend) -->
|
||||
# IGNY8 — Copilot Instructions
|
||||
|
||||
Purpose: quickly orient AI coding agents so they can make safe, high-value changes across the IGNY8 monorepo.
|
||||
|
||||
Key facts (big picture)
|
||||
- **Monorepo layout:** Backend (Django) lives under `backend/` and the core Python package is `igny8_core/`. Frontend is in `frontend/` (Vite + React/TS). Documentation and architecture notes live in `master-docs/` and repo root `README.md`.
|
||||
- **Services & runtime:** The project uses Django with Celery (see `igny8_core/celery.py` and `backend/celerybeat-schedule`), and a modern frontend build in `frontend/`. There is a `docker-compose.app.yml` to bring up combined services for development.
|
||||
- **APIs:** Backend exposes REST endpoints under its `api/` modules and integrates with the WordPress bridge (see `WORDPRESS-PLUGIN-INTEGRATION.md` in `master-docs/`). AI components live under `igny8_core/ai/`.
|
||||
|
||||
Where to look first
|
||||
- `backend/manage.py` — Django project CLI and common quick tests.
|
||||
- `igny8_core/settings.py` — central configuration; check how secrets and environment variables are read before altering settings.
|
||||
- `igny8_core/celery.py` and `backend/celerybeat-schedule` — Celery setup, periodic tasks, and beat schedule.
|
||||
- `backend/requirements.txt` — Python dependencies; use it when creating or updating virtualenvs.
|
||||
- `docker-compose.app.yml` — local dev composition (DB, cache, web, worker); follow it for local integration testing.
|
||||
- `frontend/package.json`, `vite.config.ts` — frontend scripts and dev server commands.
|
||||
- `master-docs/` — architecture and process documents (use these before making cross-cutting changes).
|
||||
|
||||
Project-specific conventions and patterns
|
||||
- Python package: main Django app is `igny8_core` — add new reusable modules under `igny8_core/*` and keep app boundaries clear (`ai/`, `api/`, `auth/`, `modules/`).
|
||||
- Settings & secrets: inspect `igny8_core/settings.py` for environment-based config; prefer adding new env vars there rather than hard-coding secrets.
|
||||
- Celery tasks live near the domain logic; follow established naming and task registration patterns in `igny8_core/celery.py`.
|
||||
- Frontend: components live under `frontend/src/` grouped by feature; tests use `vitest` (see `vitest.config.ts`).
|
||||
- Docs-first: many architectural decisions are documented — prefer updating `master-docs/*` when you change interfaces, APIs, or long-lived behavior.
|
||||
|
||||
Integration points & external dependencies
|
||||
- Database and cache: check `docker-compose.app.yml` for configured DB and cache services used in local dev (use same service names when writing connection logic).
|
||||
- Celery & broker: Celery is configured in `igny8_core/celery.py`; ensure worker-related changes are tested with `celery -A igny8_core worker` and `celery -A igny8_core beat` when applicable.
|
||||
- AI modules: `igny8_core/ai/` contains AI engine integration points — review `ai_core.py` and `engine.py` before changing model orchestration logic.
|
||||
- Frontend ↔ Backend: frontend calls backend REST APIs; update API shapes in `api/` and coordinate with `frontend/src/api/`.
|
||||
|
||||
Developer workflows (copyable commands)
|
||||
- Create a Python virtualenv and run backend locally:
|
||||
```powershell
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
pip install -r requirements.txt
|
||||
python manage.py migrate
|
||||
python manage.py runserver
|
||||
```
|
||||
- Run Celery worker (in a separate shell):
|
||||
```powershell
|
||||
cd backend
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
celery -A igny8_core worker -l info
|
||||
celery -A igny8_core beat -l info
|
||||
```
|
||||
- Run the app stack via Docker Compose (local integration):
|
||||
```powershell
|
||||
docker-compose -f docker-compose.app.yml up --build
|
||||
```
|
||||
- Frontend dev server:
|
||||
```powershell
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
- Run backend tests (Django or pytest as configured):
|
||||
```powershell
|
||||
cd backend
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
python manage.py test
|
||||
# or `pytest` if pytest is configured
|
||||
```
|
||||
|
||||
Rules for safe automated changes
|
||||
- Preserve API contracts: when changing backend API responses, update `frontend/` calls and `master-docs/API-COMPLETE-REFERENCE.md`.
|
||||
- Keep settings/environment changes explicit: add new env vars to `settings.py` and document them in `master-docs/`.
|
||||
- When adding scheduled work, register tasks via Celery and update any existing beat schedules (and document in `master-docs/`).
|
||||
- Avoid breaking the frontend build: run `npm run build` or `npm run dev` locally after API changes that affect the client.
|
||||
|
||||
Search hacks / quick finds
|
||||
- Find API surface: search `api/` and `igny8_core/api/` for endpoint implementations.
|
||||
- Find AI orchestration: search `igny8_core/ai/` for `engine.py` and `ai_core.py` references.
|
||||
- Find Celery tasks: grep for `@shared_task` or `@app.task` in `igny8_core/`.
|
||||
|
||||
Testing and safety
|
||||
- Use the repo `requirements.txt` and virtualenv for backend testing; run `python manage.py test` or `pytest` depending on existing test setup.
|
||||
- For frontend, run `npm run test` or `vitest` as configured in `vitest.config.ts`.
|
||||
- For integration changes, run the `docker-compose.app.yml` stack and exercise both web and worker services.
|
||||
|
||||
When you are unsure
|
||||
- Read `master-docs/02-APPLICATION-ARCHITECTURE.md` and `04-BACKEND-IMPLEMENTATION.md` before large changes.
|
||||
- Open an issue and link to `master-docs/` when proposing cross-cutting changes (database, schema, API contract).
|
||||
|
||||
Examples & quick references
|
||||
- Start backend: `backend/manage.py runserver`
|
||||
- Celery entrypoint: `igny8_core/celery.py`
|
||||
- Local stack: `docker-compose.app.yml`
|
||||
- Frontend dev: `frontend/package.json` & `vite.config.ts`
|
||||
|
||||
Next steps for humans
|
||||
- Tell me which parts you want expanded: API reference, CI/CD snippets, deployment docs, or example tickets to implement.
|
||||
|
||||
— End
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -45,6 +45,11 @@ backend/.venv/
|
||||
dist/
|
||||
*.egg
|
||||
|
||||
# Celery scheduler database (binary file, regenerated by celery beat)
|
||||
celerybeat-schedule
|
||||
**/celerybeat-schedule
|
||||
backend/celerybeat-schedule
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
1354
CHANGELOG.md
1354
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
1394
MASTER_REFERENCE.md
Normal file
1394
MASTER_REFERENCE.md
Normal file
File diff suppressed because it is too large
Load Diff
615
README.md
615
README.md
@@ -1,385 +1,358 @@
|
||||
# IGNY8 Platform
|
||||
# IGNY8 - AI-Powered SEO Content Platform
|
||||
|
||||
Full-stack SaaS platform for SEO keyword management and AI-driven content generation, built with Django REST Framework and React.
|
||||
|
||||
**Last Updated:** 2025-01-XX
|
||||
**Version:** 1.0.0
|
||||
**License:** Proprietary
|
||||
**Website:** https://igny8.com
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
## What is IGNY8?
|
||||
|
||||
- **Backend**: Django 5.2+ with Django REST Framework (Port 8010/8011)
|
||||
- **Frontend**: React 19 with TypeScript and Vite (Port 5173/8021)
|
||||
- **Database**: PostgreSQL 15
|
||||
- **Task Queue**: Celery with Redis
|
||||
- **Reverse Proxy**: Caddy (HTTPS on port 443)
|
||||
- **Deployment**: Docker-based containerization
|
||||
IGNY8 is a full-stack SaaS platform that combines AI-powered content generation with intelligent SEO management. It helps content creators, marketers, and agencies streamline their content workflow from keyword research to published articles.
|
||||
|
||||
## 📁 Project Structure
|
||||
### Key Features
|
||||
|
||||
- 🔍 **Smart Keyword Management** - Import, cluster, and organize keywords with AI
|
||||
- ✍️ **AI Content Generation** - Generate SEO-optimized blog posts using GPT-4
|
||||
- 🖼️ **AI Image Creation** - Auto-generate featured and in-article images
|
||||
- 🔗 **Internal Linking** - AI-powered link suggestions for SEO
|
||||
- 📊 **Content Optimization** - Analyze and score content quality
|
||||
- 🔄 **WordPress Integration** - Bidirectional sync with WordPress sites
|
||||
- 📈 **Usage-Based Billing** - Credit system for AI operations
|
||||
- 👥 **Multi-Tenancy** - Manage multiple sites and teams
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
This monorepo contains two main applications:
|
||||
|
||||
```
|
||||
igny8/
|
||||
├── backend/ # Django backend
|
||||
│ ├── igny8_core/ # Django project
|
||||
│ │ ├── modules/ # Feature modules (Planner, Writer, System, Billing, Auth)
|
||||
│ │ ├── ai/ # AI framework
|
||||
│ │ ├── api/ # API base classes
|
||||
│ │ └── middleware/ # Custom middleware
|
||||
│ ├── Dockerfile
|
||||
│ └── requirements.txt
|
||||
├── frontend/ # React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── services/ # API clients
|
||||
│ │ ├── components/ # UI components
|
||||
│ │ ├── config/ # Configuration files
|
||||
│ │ └── stores/ # Zustand stores
|
||||
│ ├── Dockerfile
|
||||
│ ├── Dockerfile.dev # Development mode
|
||||
│ └── vite.config.ts
|
||||
├── docs/ # Complete documentation
|
||||
│ ├── 00-DOCUMENTATION-MANAGEMENT.md # Documentation & changelog management (READ FIRST)
|
||||
│ ├── 01-TECH-STACK-AND-INFRASTRUCTURE.md
|
||||
│ ├── 02-APPLICATION-ARCHITECTURE.md
|
||||
│ ├── 03-FRONTEND-ARCHITECTURE.md
|
||||
│ ├── 04-BACKEND-IMPLEMENTATION.md
|
||||
│ ├── 05-AI-FRAMEWORK-IMPLEMENTATION.md
|
||||
│ ├── 06-FUNCTIONAL-BUSINESS-LOGIC.md
|
||||
│ ├── API-COMPLETE-REFERENCE.md # Complete unified API documentation
|
||||
│ ├── planning/ # Architecture & implementation planning documents
|
||||
│ │ ├── IGNY8-HOLISTIC-ARCHITECTURE-PLAN.md # Complete architecture plan
|
||||
│ │ ├── IGNY8-IMPLEMENTATION-PLAN.md # Step-by-step implementation plan
|
||||
│ │ ├── Igny8-phase-2-plan.md # Phase 2 feature specifications
|
||||
│ │ ├── CONTENT-WORKFLOW-DIAGRAM.md # Content workflow diagrams
|
||||
│ │ ├── ARCHITECTURE_CONTEXT.md # Architecture context reference
|
||||
│ │ └── sample-usage-limits-credit-system # Credit system specification
|
||||
│ └── refactor/ # Refactoring plans and documentation
|
||||
├── CHANGELOG.md # Version history and changes (only updated after user confirmation)
|
||||
└── docker-compose.app.yml
|
||||
├── backend/ # Django REST API + Celery
|
||||
├── frontend/ # React + Vite SPA
|
||||
├── master-docs/ # Architecture documentation
|
||||
└── docker-compose.app.yml # Docker deployment config
|
||||
```
|
||||
|
||||
**Separate Repository:**
|
||||
- [igny8-wp-integration](https://github.com/alorig/igny8-wp-integration) - WordPress bridge plugin
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker & Docker Compose
|
||||
- Node.js 18+ (for local development)
|
||||
- Python 3.11+ (for local development)
|
||||
- **Python 3.11+**
|
||||
- **Node.js 18+**
|
||||
- **PostgreSQL 14+**
|
||||
- **Redis 7+**
|
||||
- **Docker** (optional, recommended for local development)
|
||||
|
||||
### Development Setup
|
||||
### Local Development with Docker
|
||||
|
||||
1. **Navigate to the project directory:**
|
||||
```bash
|
||||
cd /data/app/igny8
|
||||
1. **Clone the repository**
|
||||
```powershell
|
||||
git clone https://github.com/alorig/igny8-app.git
|
||||
cd igny8
|
||||
```
|
||||
|
||||
2. **Backend Setup:**
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python manage.py migrate
|
||||
python manage.py createsuperuser
|
||||
python manage.py runserver
|
||||
2. **Set environment variables**
|
||||
|
||||
Create `.env` file in `backend/` directory:
|
||||
```env
|
||||
SECRET_KEY=your-secret-key-here
|
||||
DEBUG=True
|
||||
DATABASE_URL=postgresql://postgres:postgres@db:5432/igny8
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
OPENAI_API_KEY=your-openai-key
|
||||
RUNWARE_API_KEY=your-runware-key
|
||||
```
|
||||
|
||||
3. **Frontend Setup:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
3. **Start services**
|
||||
```powershell
|
||||
docker-compose -f docker-compose.app.yml up --build
|
||||
```
|
||||
|
||||
4. **Access:**
|
||||
4. **Access applications**
|
||||
- Frontend: http://localhost:5173
|
||||
- Backend API: http://localhost:8011/api/
|
||||
- Admin: http://localhost:8011/admin/
|
||||
- Backend API: http://localhost:8000
|
||||
- API Docs: http://localhost:8000/api/docs/
|
||||
- Django Admin: http://localhost:8000/admin/
|
||||
|
||||
### Docker Setup
|
||||
### Manual Setup (Without Docker)
|
||||
|
||||
```bash
|
||||
# Build images
|
||||
docker build -f backend/Dockerfile -t igny8-backend ./backend
|
||||
docker build -f frontend/Dockerfile.dev -t igny8-frontend-dev ./frontend
|
||||
#### Backend Setup
|
||||
|
||||
# Run with docker-compose
|
||||
docker-compose -f docker-compose.app.yml up
|
||||
```powershell
|
||||
cd backend
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run migrations
|
||||
python manage.py migrate
|
||||
|
||||
# Create superuser
|
||||
python manage.py createsuperuser
|
||||
|
||||
# Run development server
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
For complete installation guide, see [docs/01-TECH-STACK-AND-INFRASTRUCTURE.md](docs/01-TECH-STACK-AND-INFRASTRUCTURE.md).
|
||||
In separate terminals, start Celery:
|
||||
|
||||
---
|
||||
```powershell
|
||||
# Celery worker
|
||||
celery -A igny8_core worker -l info
|
||||
|
||||
## 📚 Features
|
||||
|
||||
### ✅ Implemented
|
||||
|
||||
- **Foundation**: Multi-tenancy system, Authentication (login/register), RBAC permissions
|
||||
- **Planner Module**: Keywords, Clusters, Content Ideas (full CRUD, filtering, pagination, bulk operations, CSV import/export, AI clustering)
|
||||
- **Writer Module**: Tasks, Content, Images (full CRUD, AI content generation, AI image generation)
|
||||
- **Thinker Module**: Prompts, Author Profiles, Strategies, Image Testing
|
||||
- **System Module**: Settings, Integrations (OpenAI, Runware), AI Prompts
|
||||
- **Billing Module**: Credits, Transactions, Usage Logs
|
||||
- **AI Functions**: 5 AI operations (Auto Cluster, Generate Ideas, Generate Content, Generate Image Prompts, Generate Images)
|
||||
- **Frontend**: Complete component library, 4 master templates, config-driven UI system
|
||||
- **Backend**: REST API with tenant isolation, Site > Sector hierarchy, Celery async tasks
|
||||
- **WordPress Integration**: Direct publishing to WordPress sites
|
||||
- **Development**: Docker Compose setup, hot reload, TypeScript + React
|
||||
|
||||
### 🚧 In Progress
|
||||
|
||||
- Planner Dashboard enhancement with KPIs
|
||||
- Automation & CRON tasks
|
||||
- Advanced analytics
|
||||
|
||||
### 🔄 Planned
|
||||
|
||||
- Analytics module enhancements
|
||||
- Advanced scheduling features
|
||||
- Additional AI model integrations
|
||||
|
||||
---
|
||||
|
||||
## 🔗 API Documentation
|
||||
|
||||
### Interactive Documentation
|
||||
|
||||
- **Swagger UI**: `https://api.igny8.com/api/docs/`
|
||||
- **ReDoc**: `https://api.igny8.com/api/redoc/`
|
||||
- **OpenAPI Schema**: `https://api.igny8.com/api/schema/`
|
||||
|
||||
### API Complete Reference
|
||||
|
||||
**[API Complete Reference](docs/API-COMPLETE-REFERENCE.md)** - Comprehensive unified API documentation (single source of truth)
|
||||
- Complete endpoint reference (100+ endpoints across all modules)
|
||||
- Authentication & authorization guide
|
||||
- Response format standards (unified format: `{success, data, message, errors, request_id}`)
|
||||
- Error handling
|
||||
- Rate limiting (scoped by operation type)
|
||||
- Pagination
|
||||
- Roles & permissions
|
||||
- Tenant/site/sector scoping
|
||||
- Integration examples (Python, JavaScript, cURL, PHP)
|
||||
- Testing & debugging
|
||||
- Change management
|
||||
|
||||
### API Standard Features
|
||||
|
||||
- ✅ **Unified Response Format** - Consistent JSON structure for all endpoints
|
||||
- ✅ **Layered Authorization** - Authentication → Tenant → Role → Site/Sector
|
||||
- ✅ **Centralized Error Handling** - All errors in unified format with request_id
|
||||
- ✅ **Scoped Rate Limiting** - Different limits per operation type (10-100/min)
|
||||
- ✅ **Tenant Isolation** - Account/site/sector scoping
|
||||
- ✅ **Request Tracking** - Unique request ID for debugging
|
||||
- ✅ **100% Implemented** - All endpoints use unified format
|
||||
|
||||
### Quick API Example
|
||||
|
||||
```bash
|
||||
# Login
|
||||
curl -X POST https://api.igny8.com/api/v1/auth/login/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"user@example.com","password":"password"}'
|
||||
|
||||
# Get keywords (with token)
|
||||
curl -X GET https://api.igny8.com/api/v1/planner/keywords/ \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json"
|
||||
# Celery beat (scheduled tasks)
|
||||
celery -A igny8_core beat -l info
|
||||
```
|
||||
|
||||
### Additional API Guides
|
||||
#### Frontend Setup
|
||||
|
||||
- **[Authentication Guide](docs/AUTHENTICATION-GUIDE.md)** - Detailed JWT authentication guide
|
||||
- **[Error Codes Reference](docs/ERROR-CODES.md)** - Complete error code reference
|
||||
- **[Rate Limiting Guide](docs/RATE-LIMITING.md)** - Rate limiting and throttling details
|
||||
- **[Migration Guide](docs/MIGRATION-GUIDE.md)** - Migrating to API v1.0
|
||||
- **[WordPress Plugin Integration](docs/WORDPRESS-PLUGIN-INTEGRATION.md)** - WordPress integration guide
|
||||
```powershell
|
||||
cd frontend
|
||||
|
||||
For backend implementation details, see [docs/04-BACKEND-IMPLEMENTATION.md](docs/04-BACKEND-IMPLEMENTATION.md).
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation
|
||||
## Project Architecture
|
||||
|
||||
All documentation is consolidated in the `/docs/` folder.
|
||||
### System Overview
|
||||
|
||||
**⚠️ IMPORTANT FOR AI AGENTS**: Before making any changes, read:
|
||||
1. **[00-DOCUMENTATION-MANAGEMENT.md](docs/00-DOCUMENTATION-MANAGEMENT.md)** - Versioning, changelog, and DRY principles
|
||||
2. **[CHANGELOG.md](CHANGELOG.md)** - Current version and change history
|
||||
```
|
||||
User Interface (React)
|
||||
↓
|
||||
REST API (Django)
|
||||
↓
|
||||
┌───────┴────────┐
|
||||
│ │
|
||||
Database AI Engine
|
||||
(PostgreSQL) (Celery + OpenAI)
|
||||
│
|
||||
WordPress Plugin
|
||||
(Bidirectional Sync)
|
||||
```
|
||||
|
||||
### Core Documentation
|
||||
|
||||
0. **[00-DOCUMENTATION-MANAGEMENT.md](docs/00-DOCUMENTATION-MANAGEMENT.md)** ⚠️ **READ FIRST**
|
||||
- Documentation and changelog management system
|
||||
- Versioning system (Semantic Versioning)
|
||||
- Changelog update rules (only after user confirmation)
|
||||
- DRY principles and standards
|
||||
- AI agent instructions
|
||||
|
||||
1. **[01-TECH-STACK-AND-INFRASTRUCTURE.md](docs/01-TECH-STACK-AND-INFRASTRUCTURE.md)**
|
||||
- Technology stack overview
|
||||
- Infrastructure components
|
||||
- Docker deployment architecture
|
||||
- Fresh installation guide
|
||||
- External service integrations
|
||||
|
||||
2. **[02-APPLICATION-ARCHITECTURE.md](docs/02-APPLICATION-ARCHITECTURE.md)**
|
||||
- IGNY8 application architecture
|
||||
- System hierarchy and relationships
|
||||
- User roles and access control
|
||||
- Module organization
|
||||
- Complete workflows
|
||||
- Data models and relationships
|
||||
- Multi-tenancy architecture
|
||||
- API architecture
|
||||
- Security architecture
|
||||
|
||||
3. **[03-FRONTEND-ARCHITECTURE.md](docs/03-FRONTEND-ARCHITECTURE.md)**
|
||||
- Frontend architecture
|
||||
- Project structure
|
||||
- Routing system
|
||||
- Template system
|
||||
- Component library
|
||||
- State management
|
||||
- API integration
|
||||
- Configuration system
|
||||
- All pages and features
|
||||
|
||||
4. **[04-BACKEND-IMPLEMENTATION.md](docs/04-BACKEND-IMPLEMENTATION.md)**
|
||||
- Backend architecture
|
||||
- Project structure
|
||||
- Models and relationships
|
||||
- ViewSets and API endpoints
|
||||
- Serializers
|
||||
- Celery tasks
|
||||
- Middleware
|
||||
- All modules (Planner, Writer, System, Billing, Auth)
|
||||
|
||||
5. **[05-AI-FRAMEWORK-IMPLEMENTATION.md](docs/05-AI-FRAMEWORK-IMPLEMENTATION.md)**
|
||||
- AI framework architecture and code structure
|
||||
- All 5 AI functions (technical implementation)
|
||||
- AI function execution flow
|
||||
- Progress tracking
|
||||
- Cost tracking
|
||||
- Prompt management
|
||||
- Model configuration
|
||||
|
||||
6. **[06-FUNCTIONAL-BUSINESS-LOGIC.md](docs/06-FUNCTIONAL-BUSINESS-LOGIC.md)**
|
||||
- Complete functional and business logic documentation
|
||||
- All workflows and processes
|
||||
- All features and functions
|
||||
- How the application works from business perspective
|
||||
- Credit system details
|
||||
- WordPress integration
|
||||
- Data flow and state management
|
||||
|
||||
### Quick Start Guide
|
||||
|
||||
**For AI Agents**: Start with [00-DOCUMENTATION-MANAGEMENT.md](docs/00-DOCUMENTATION-MANAGEMENT.md) to understand versioning, changelog, and DRY principles.
|
||||
|
||||
1. **New to IGNY8?** Start with [01-TECH-STACK-AND-INFRASTRUCTURE.md](docs/01-TECH-STACK-AND-INFRASTRUCTURE.md) for technology overview
|
||||
2. **Understanding the System?** Read [02-APPLICATION-ARCHITECTURE.md](docs/02-APPLICATION-ARCHITECTURE.md) for complete architecture
|
||||
3. **Frontend Development?** See [03-FRONTEND-ARCHITECTURE.md](docs/03-FRONTEND-ARCHITECTURE.md) for all frontend details
|
||||
4. **Backend Development?** See [04-BACKEND-IMPLEMENTATION.md](docs/04-BACKEND-IMPLEMENTATION.md) for all backend details
|
||||
5. **Working with AI?** See [05-AI-FRAMEWORK-IMPLEMENTATION.md](docs/05-AI-FRAMEWORK-IMPLEMENTATION.md) for AI framework implementation
|
||||
6. **Understanding Business Logic?** See [06-FUNCTIONAL-BUSINESS-LOGIC.md](docs/06-FUNCTIONAL-BUSINESS-LOGIC.md) for complete workflows and features
|
||||
7. **What's New?** Check [CHANGELOG.md](CHANGELOG.md) for recent changes
|
||||
|
||||
### Finding Information
|
||||
|
||||
**By Topic:**
|
||||
- **API Documentation**: [API-COMPLETE-REFERENCE.md](docs/API-COMPLETE-REFERENCE.md) - Complete unified API reference (single source of truth)
|
||||
- **Infrastructure & Deployment**: [01-TECH-STACK-AND-INFRASTRUCTURE.md](docs/01-TECH-STACK-AND-INFRASTRUCTURE.md)
|
||||
- **Application Architecture**: [02-APPLICATION-ARCHITECTURE.md](docs/02-APPLICATION-ARCHITECTURE.md)
|
||||
- **Frontend Development**: [03-FRONTEND-ARCHITECTURE.md](docs/03-FRONTEND-ARCHITECTURE.md)
|
||||
- **Backend Development**: [04-BACKEND-IMPLEMENTATION.md](docs/04-BACKEND-IMPLEMENTATION.md)
|
||||
- **AI Framework Implementation**: [05-AI-FRAMEWORK-IMPLEMENTATION.md](docs/05-AI-FRAMEWORK-IMPLEMENTATION.md)
|
||||
- **Business Logic & Workflows**: [06-FUNCTIONAL-BUSINESS-LOGIC.md](docs/06-FUNCTIONAL-BUSINESS-LOGIC.md)
|
||||
- **Changes & Updates**: [CHANGELOG.md](CHANGELOG.md)
|
||||
- **Documentation Management**: [00-DOCUMENTATION-MANAGEMENT.md](docs/00-DOCUMENTATION-MANAGEMENT.md) ⚠️ **For AI Agents**
|
||||
|
||||
**By Module:**
|
||||
- **Planner**: See [02-APPLICATION-ARCHITECTURE.md](docs/02-APPLICATION-ARCHITECTURE.md) (Module Organization) and [04-BACKEND-IMPLEMENTATION.md](docs/04-BACKEND-IMPLEMENTATION.md) (Planner Module)
|
||||
- **Writer**: See [02-APPLICATION-ARCHITECTURE.md](docs/02-APPLICATION-ARCHITECTURE.md) (Module Organization) and [04-BACKEND-IMPLEMENTATION.md](docs/04-BACKEND-IMPLEMENTATION.md) (Writer Module)
|
||||
- **Thinker**: See [03-FRONTEND-ARCHITECTURE.md](docs/03-FRONTEND-ARCHITECTURE.md) (Thinker Pages) and [04-BACKEND-IMPLEMENTATION.md](docs/04-BACKEND-IMPLEMENTATION.md) (System Module)
|
||||
- **System**: See [04-BACKEND-IMPLEMENTATION.md](docs/04-BACKEND-IMPLEMENTATION.md) (System Module)
|
||||
- **Billing**: See [04-BACKEND-IMPLEMENTATION.md](docs/04-BACKEND-IMPLEMENTATION.md) (Billing Module)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Technology Stack
|
||||
### Tech Stack
|
||||
|
||||
**Backend:**
|
||||
- Django 5.2+
|
||||
- Django REST Framework
|
||||
- PostgreSQL 15
|
||||
- Celery 5.3+
|
||||
- Redis 7
|
||||
- Django 5.2+ (Python web framework)
|
||||
- Django REST Framework (API)
|
||||
- PostgreSQL (Database)
|
||||
- Celery (Async task queue)
|
||||
- Redis (Message broker)
|
||||
- OpenAI API (Content generation)
|
||||
|
||||
**Frontend:**
|
||||
- React 19
|
||||
- TypeScript 5.7+
|
||||
- Vite 6.1+
|
||||
- Tailwind CSS 4.0+
|
||||
- Zustand 5.0+
|
||||
- React 19 (UI library)
|
||||
- Vite 6 (Build tool)
|
||||
- Zustand (State management)
|
||||
- React Router v7 (Routing)
|
||||
- Tailwind CSS 4 (Styling)
|
||||
|
||||
**Infrastructure:**
|
||||
- Docker & Docker Compose
|
||||
- Caddy (Reverse Proxy)
|
||||
- Portainer (Container Management)
|
||||
|
||||
### System Capabilities
|
||||
|
||||
- **Multi-Tenancy**: Complete account isolation with automatic filtering
|
||||
- **Planner Module**: Keywords, Clusters, Content Ideas management
|
||||
- **Writer Module**: Tasks, Content, Images generation and management
|
||||
- **Thinker Module**: Prompts, Author Profiles, Strategies, Image Testing
|
||||
- **System Module**: Settings, Integrations, AI Prompts
|
||||
- **Billing Module**: Credits, Transactions, Usage Logs
|
||||
- **AI Functions**: 5 AI operations (Auto Cluster, Generate Ideas, Generate Content, Generate Image Prompts, Generate Images)
|
||||
**WordPress Plugin:**
|
||||
- PHP 7.4+ (WordPress compatibility)
|
||||
- REST API integration
|
||||
- Bidirectional sync
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
## How IGNY8 Works
|
||||
|
||||
## 🔒 Documentation & Changelog Management
|
||||
### Content Creation Workflow
|
||||
|
||||
### Versioning System
|
||||
```
|
||||
1. Import Keywords
|
||||
↓
|
||||
2. AI Clusters Keywords
|
||||
↓
|
||||
3. Generate Content Ideas
|
||||
↓
|
||||
4. Create Writer Tasks
|
||||
↓
|
||||
5. AI Generates Content
|
||||
↓
|
||||
6. AI Creates Images
|
||||
↓
|
||||
7. Publish to WordPress
|
||||
↓
|
||||
8. Sync Status Back
|
||||
```
|
||||
|
||||
- **Format**: Semantic Versioning (MAJOR.MINOR.PATCH)
|
||||
- **Current Version**: `1.0.0`
|
||||
- **Location**: `CHANGELOG.md` (root directory)
|
||||
- **Rules**: Only updated after user confirmation that fix/feature is complete
|
||||
### WordPress Integration
|
||||
|
||||
### Changelog Management
|
||||
The WordPress bridge plugin (`igny8-wp-integration`) creates a bidirectional connection:
|
||||
|
||||
- **Location**: `CHANGELOG.md` (root directory)
|
||||
- **Rules**: Only updated after user confirmation
|
||||
- **Structure**: Added, Changed, Fixed, Deprecated, Removed, Security
|
||||
- **For Details**: See [00-DOCUMENTATION-MANAGEMENT.md](docs/00-DOCUMENTATION-MANAGEMENT.md)
|
||||
- **IGNY8 → WordPress:** Publish AI-generated content to WordPress
|
||||
- **WordPress → IGNY8:** Sync post status updates back to IGNY8
|
||||
|
||||
### DRY Principles
|
||||
|
||||
**Core Principle**: Always use existing, predefined, standardized components, utilities, functions, and configurations.
|
||||
|
||||
**Frontend**: Use existing templates, components, stores, contexts, utilities, and Tailwind CSS
|
||||
**Backend**: Use existing base classes, AI framework, services, and middleware
|
||||
|
||||
**For Complete Guidelines**: See [00-DOCUMENTATION-MANAGEMENT.md](docs/00-DOCUMENTATION-MANAGEMENT.md)
|
||||
|
||||
**⚠️ For AI Agents**: Read `docs/00-DOCUMENTATION-MANAGEMENT.md` at the start of every session.
|
||||
**Setup:**
|
||||
1. Install WordPress plugin on your site
|
||||
2. Generate API key in IGNY8 app
|
||||
3. Connect plugin using email, password, and API key
|
||||
4. Plugin syncs automatically
|
||||
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
## Documentation
|
||||
|
||||
[Add license information]
|
||||
Comprehensive documentation is available in the `master-docs/` directory:
|
||||
|
||||
- **[MASTER_REFERENCE.md](./MASTER_REFERENCE.md)** - Complete system architecture and navigation
|
||||
- **[API-COMPLETE-REFERENCE.md](./master-docs/API-COMPLETE-REFERENCE.md)** - Full API documentation
|
||||
- **[02-APPLICATION-ARCHITECTURE.md](./master-docs/02-APPLICATION-ARCHITECTURE.md)** - System design
|
||||
- **[04-BACKEND-IMPLEMENTATION.md](./master-docs/04-BACKEND-IMPLEMENTATION.md)** - Backend details
|
||||
- **[03-FRONTEND-ARCHITECTURE.md](./master-docs/03-FRONTEND-ARCHITECTURE.md)** - Frontend details
|
||||
- **[WORDPRESS-PLUGIN-INTEGRATION.md](./master-docs/WORDPRESS-PLUGIN-INTEGRATION.md)** - Plugin integration guide
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
## Development Workflow
|
||||
|
||||
For questions or clarifications about the documentation, refer to the specific document in the `/docs/` folder or contact the development team.
|
||||
### Running Tests
|
||||
|
||||
```powershell
|
||||
# Backend tests
|
||||
cd backend
|
||||
python manage.py test
|
||||
|
||||
# Frontend tests
|
||||
cd frontend
|
||||
npm run test
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```powershell
|
||||
# Frontend linting
|
||||
cd frontend
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Building for Production
|
||||
|
||||
```powershell
|
||||
# Backend
|
||||
cd backend
|
||||
python manage.py collectstatic
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Overview
|
||||
|
||||
**Base URL:** `https://api.igny8.com/api/v1/`
|
||||
|
||||
**Authentication:** JWT Bearer token
|
||||
|
||||
**Key Endpoints:**
|
||||
- `/auth/login/` - User authentication
|
||||
- `/planner/keywords/` - Keyword management
|
||||
- `/planner/clusters/` - Keyword clusters
|
||||
- `/writer/tasks/` - Content tasks
|
||||
- `/writer/content/` - Generated content
|
||||
- `/integration/integrations/` - WordPress integrations
|
||||
|
||||
**Interactive Docs:**
|
||||
- Swagger UI: https://api.igny8.com/api/docs/
|
||||
- ReDoc: https://api.igny8.com/api/redoc/
|
||||
|
||||
See [API-COMPLETE-REFERENCE.md](./master-docs/API-COMPLETE-REFERENCE.md) for full documentation.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Tenancy
|
||||
|
||||
IGNY8 supports complete account isolation:
|
||||
|
||||
```
|
||||
Account (Organization)
|
||||
├── Users (with roles: owner, admin, editor, viewer)
|
||||
├── Sites (multiple WordPress sites)
|
||||
└── Sectors (content categories)
|
||||
└── Keywords, Clusters, Content
|
||||
```
|
||||
|
||||
All data is automatically scoped to the authenticated user's account.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a private repository. For internal development:
|
||||
|
||||
1. Create feature branch: `git checkout -b feature/your-feature`
|
||||
2. Make changes and test thoroughly
|
||||
3. Commit: `git commit -m "Add your feature"`
|
||||
4. Push: `git push origin feature/your-feature`
|
||||
5. Create Pull Request
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Production Deployment
|
||||
|
||||
1. **Set production environment variables**
|
||||
2. **Build frontend:** `npm run build`
|
||||
3. **Collect static files:** `python manage.py collectstatic`
|
||||
4. **Run migrations:** `python manage.py migrate`
|
||||
5. **Use docker-compose:** `docker-compose -f docker-compose.app.yml up -d`
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Required for production:
|
||||
|
||||
```env
|
||||
SECRET_KEY=<random-secret-key>
|
||||
DEBUG=False
|
||||
ALLOWED_HOSTS=api.igny8.com,app.igny8.com
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/dbname
|
||||
REDIS_URL=redis://host:6379/0
|
||||
OPENAI_API_KEY=<openai-key>
|
||||
RUNWARE_API_KEY=<runware-key>
|
||||
USE_SECURE_COOKIES=True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For support and questions:
|
||||
- Check [MASTER_REFERENCE.md](./MASTER_REFERENCE.md) for detailed documentation
|
||||
- Review API docs at `/api/docs/`
|
||||
- Contact development team
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Proprietary. All rights reserved.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for version history and updates.
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ by the IGNY8 team**
|
||||
|
||||
101
WORDPRESS_PUBLISH_UI_CHANGES.md
Normal file
101
WORDPRESS_PUBLISH_UI_CHANGES.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# WordPress Publishing UI Update Summary
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 🚀 **MOVED** WordPress Publishing from Content Page to Images Page
|
||||
|
||||
**Reasoning**: Content page only contains text content without generated images, making it premature to publish. Images page contains complete content with generated images, making it the optimal place for publishing.
|
||||
|
||||
### 📍 **WordPress Publishing Now Available On Images Page**
|
||||
|
||||
#### **1. Individual Content Publishing**
|
||||
- **Location**: 3-dot dropdown menu on each row in `/writer/images`
|
||||
- **Visibility**: Only shows "Publish to WordPress" when:
|
||||
- ✅ Images are generated (status = 'complete')
|
||||
- ✅ Not already published/publishing to WordPress
|
||||
- **Action**: Single click publishes individual content with all images, SEO metadata, categories, and content
|
||||
|
||||
#### **2. Bulk Publishing**
|
||||
- **Location**: Top toolbar next to "Columns" selector in `/writer/images`
|
||||
- **Button Text**: "Publish Ready ({count})" - dynamically shows count of ready-to-publish items
|
||||
- **Visibility**: Only appears when there are items ready to publish
|
||||
- **Conditions**:
|
||||
- ✅ Images must be generated
|
||||
- ✅ Not already published
|
||||
- ✅ Not currently publishing
|
||||
- **Action**: Opens dialog showing all ready items, allows bulk publish with progress tracking
|
||||
|
||||
#### **3. Smart Status Checks**
|
||||
- Uses existing image generation status badges/logic
|
||||
- Automatically filters eligible content
|
||||
- Real-time status updates after publishing
|
||||
- Error handling with detailed feedback
|
||||
|
||||
### 🔄 **Updated Components**
|
||||
|
||||
#### **Enhanced WordPress Publishing Components**
|
||||
```
|
||||
frontend/src/components/WordPressPublish/
|
||||
├── WordPressPublish.tsx # Enhanced with image status checks
|
||||
├── BulkWordPressPublish.tsx # NEW: Bulk publishing with progress
|
||||
├── ContentActionsMenu.tsx # NEW: Smart dropdown with conditional visibility
|
||||
└── index.ts # Export all components
|
||||
```
|
||||
|
||||
#### **Updated Page Configuration**
|
||||
```
|
||||
frontend/src/config/pages/table-actions.config.tsx
|
||||
├── /writer/images # Added WordPress actions
|
||||
│ ├── rowActions[] # "Publish to WordPress" (conditional)
|
||||
│ └── bulkActions[] # "Publish Ready to WordPress"
|
||||
└── /writer/content # Removed WordPress actions
|
||||
└── rowActions[] # Removed publish/unpublish
|
||||
```
|
||||
|
||||
#### **Updated Pages**
|
||||
```
|
||||
frontend/src/pages/Writer/
|
||||
├── Images.tsx # Added WordPress publish handling
|
||||
│ ├── handleRowAction() # WordPress single publish
|
||||
│ ├── handleBulkAction() # WordPress bulk publish
|
||||
│ └── import { api } # API for WordPress calls
|
||||
└── Content.tsx # Removed WordPress functionality
|
||||
├── handleRowAction() # Removed publish/unpublish logic
|
||||
└── imports # Removed publishContent, unpublishContent
|
||||
```
|
||||
|
||||
### 🎯 **User Experience Improvements**
|
||||
|
||||
#### **Before** (Content Page)
|
||||
- ❌ WordPress publish available even when images not ready
|
||||
- ❌ Users would publish incomplete content
|
||||
- ❌ Required manual coordination between content creation and image generation
|
||||
|
||||
#### **After** (Images Page)
|
||||
- ✅ Publish only when content is complete with images
|
||||
- ✅ Smart button visibility based on actual readiness
|
||||
- ✅ Clear labeling: "Publish Ready (X)" shows exactly what's eligible
|
||||
- ✅ Bulk operations for efficiency
|
||||
- ✅ Real-time status tracking and feedback
|
||||
|
||||
### 📋 **Status Explanations**
|
||||
|
||||
The UI now uses short but explanatory labels:
|
||||
- **"Publish Ready (X)"** - X items have generated images and are ready for WordPress
|
||||
- **"Awaiting Images"** - Individual items waiting for image generation
|
||||
- **"Images Pending"** - Status chip when images aren't complete
|
||||
- **"Images Generated ✓"** - Confirmation in publish dialog
|
||||
|
||||
### 🔧 **Technical Implementation**
|
||||
|
||||
1. **Conditional Rendering**: Uses `shouldShow` functions to intelligently display actions
|
||||
2. **Status Integration**: Leverages existing image generation status tracking
|
||||
3. **API Integration**: Seamless connection to WordPress publishing endpoints
|
||||
4. **Error Handling**: Comprehensive error messages and retry logic
|
||||
5. **State Management**: Automatic reload of data after publishing actions
|
||||
|
||||
### 🎊 **Result**
|
||||
|
||||
Users now have a **streamlined, intelligent WordPress publishing workflow** that prevents premature publishing and ensures complete content (text + images) is always published together.
|
||||
|
||||
The system automatically guides users to publish only when content is truly ready, improving content quality and user experience.
|
||||
1228
WRITER_IMAGES_PAGE_SYSTEM_DESIGN.md
Normal file
1228
WRITER_IMAGES_PAGE_SYSTEM_DESIGN.md
Normal file
File diff suppressed because it is too large
Load Diff
1306
WRITER_MODULE_REFACTORING_PLAN.md
Normal file
1306
WRITER_MODULE_REFACTORING_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
||||
Collecting drf-spectacular
|
||||
Downloading drf_spectacular-0.29.0-py3-none-any.whl.metadata (14 kB)
|
||||
Requirement already satisfied: Django>=2.2 in /usr/local/lib/python3.11/site-packages (from drf-spectacular) (5.2.8)
|
||||
Requirement already satisfied: djangorestframework>=3.10.3 in /usr/local/lib/python3.11/site-packages (from drf-spectacular) (3.16.1)
|
||||
Collecting uritemplate>=2.0.0 (from drf-spectacular)
|
||||
Downloading uritemplate-4.2.0-py3-none-any.whl.metadata (2.6 kB)
|
||||
Collecting PyYAML>=5.1 (from drf-spectacular)
|
||||
Downloading pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (2.4 kB)
|
||||
Collecting jsonschema>=2.6.0 (from drf-spectacular)
|
||||
Downloading jsonschema-4.25.1-py3-none-any.whl.metadata (7.6 kB)
|
||||
Collecting inflection>=0.3.1 (from drf-spectacular)
|
||||
Downloading inflection-0.5.1-py2.py3-none-any.whl.metadata (1.7 kB)
|
||||
Requirement already satisfied: asgiref>=3.8.1 in /usr/local/lib/python3.11/site-packages (from Django>=2.2->drf-spectacular) (3.10.0)
|
||||
Requirement already satisfied: sqlparse>=0.3.1 in /usr/local/lib/python3.11/site-packages (from Django>=2.2->drf-spectacular) (0.5.3)
|
||||
Collecting attrs>=22.2.0 (from jsonschema>=2.6.0->drf-spectacular)
|
||||
Downloading attrs-25.4.0-py3-none-any.whl.metadata (10 kB)
|
||||
Collecting jsonschema-specifications>=2023.03.6 (from jsonschema>=2.6.0->drf-spectacular)
|
||||
Downloading jsonschema_specifications-2025.9.1-py3-none-any.whl.metadata (2.9 kB)
|
||||
Collecting referencing>=0.28.4 (from jsonschema>=2.6.0->drf-spectacular)
|
||||
Downloading referencing-0.37.0-py3-none-any.whl.metadata (2.8 kB)
|
||||
Collecting rpds-py>=0.7.1 (from jsonschema>=2.6.0->drf-spectacular)
|
||||
Downloading rpds_py-0.28.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (4.1 kB)
|
||||
Requirement already satisfied: typing-extensions>=4.4.0 in /usr/local/lib/python3.11/site-packages (from referencing>=0.28.4->jsonschema>=2.6.0->drf-spectacular) (4.15.0)
|
||||
Downloading drf_spectacular-0.29.0-py3-none-any.whl (105 kB)
|
||||
Downloading inflection-0.5.1-py2.py3-none-any.whl (9.5 kB)
|
||||
Downloading jsonschema-4.25.1-py3-none-any.whl (90 kB)
|
||||
Downloading attrs-25.4.0-py3-none-any.whl (67 kB)
|
||||
Downloading jsonschema_specifications-2025.9.1-py3-none-any.whl (18 kB)
|
||||
Downloading pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (806 kB)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 806.6/806.6 kB 36.0 MB/s 0:00:00
|
||||
Downloading referencing-0.37.0-py3-none-any.whl (26 kB)
|
||||
Downloading rpds_py-0.28.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (382 kB)
|
||||
Downloading uritemplate-4.2.0-py3-none-any.whl (11 kB)
|
||||
Installing collected packages: uritemplate, rpds-py, PyYAML, inflection, attrs, referencing, jsonschema-specifications, jsonschema, drf-spectacular
|
||||
|
||||
Successfully installed PyYAML-6.0.3 attrs-25.4.0 drf-spectacular-0.29.0 inflection-0.5.1 jsonschema-4.25.1 jsonschema-specifications-2025.9.1 referencing-0.37.0 rpds-py-0.28.0 uritemplate-4.2.0
|
||||
WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.
|
||||
11278
backend/backup_postgres_20251120_232816.sql
Normal file
11278
backend/backup_postgres_20251120_232816.sql
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
31
backend/check_api_response.py
Normal file
31
backend/check_api_response.py
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import django
|
||||
import json
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||
django.setup()
|
||||
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
from igny8_core.auth.models import Site
|
||||
from django.test import RequestFactory
|
||||
from igny8_core.modules.integration.views import IntegrationViewSet
|
||||
|
||||
# Create a fake request
|
||||
factory = RequestFactory()
|
||||
request = factory.get('/api/v1/integration/integrations/1/content-types/')
|
||||
|
||||
# Create view and call the action
|
||||
integration = SiteIntegration.objects.get(id=1)
|
||||
viewset = IntegrationViewSet()
|
||||
viewset.format_kwarg = None
|
||||
viewset.request = request
|
||||
viewset.kwargs = {'pk': 1}
|
||||
|
||||
# Get the response data
|
||||
response = viewset.content_types_summary(request, pk=1)
|
||||
|
||||
print("Response Status:", response.status_code)
|
||||
print("\nResponse Data:")
|
||||
print(json.dumps(response.data, indent=2, default=str))
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Script to create 3 real users with 3 paid packages (Starter, Growth, Scale)
|
||||
All accounts will be active and properly configured.
|
||||
Email format: plan-name@igny8.com
|
||||
"""
|
||||
import os
|
||||
import django
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
|
||||
# Setup Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||
django.setup()
|
||||
|
||||
from django.db import transaction
|
||||
from igny8_core.auth.models import Plan, Account, User
|
||||
from django.utils.text import slugify
|
||||
|
||||
# User data - 3 users with 3 different paid plans
|
||||
# Email format: plan-name@igny8.com
|
||||
USERS_DATA = [
|
||||
{
|
||||
"email": "starter@igny8.com",
|
||||
"username": "starter",
|
||||
"first_name": "Starter",
|
||||
"last_name": "Account",
|
||||
"password": "SecurePass123!@#",
|
||||
"plan_slug": "starter", # $89/month
|
||||
"account_name": "Starter Account",
|
||||
},
|
||||
{
|
||||
"email": "growth@igny8.com",
|
||||
"username": "growth",
|
||||
"first_name": "Growth",
|
||||
"last_name": "Account",
|
||||
"password": "SecurePass123!@#",
|
||||
"plan_slug": "growth", # $139/month
|
||||
"account_name": "Growth Account",
|
||||
},
|
||||
{
|
||||
"email": "scale@igny8.com",
|
||||
"username": "scale",
|
||||
"first_name": "Scale",
|
||||
"last_name": "Account",
|
||||
"password": "SecurePass123!@#",
|
||||
"plan_slug": "scale", # $229/month
|
||||
"account_name": "Scale Account",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def create_user_with_plan(user_data):
|
||||
"""Create a user with account and assigned plan."""
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Get the plan
|
||||
try:
|
||||
plan = Plan.objects.get(slug=user_data['plan_slug'], is_active=True)
|
||||
except Plan.DoesNotExist:
|
||||
print(f"❌ ERROR: Plan '{user_data['plan_slug']}' not found or inactive!")
|
||||
return None
|
||||
|
||||
# Check if user already exists
|
||||
if User.objects.filter(email=user_data['email']).exists():
|
||||
print(f"⚠️ User {user_data['email']} already exists. Updating...")
|
||||
existing_user = User.objects.get(email=user_data['email'])
|
||||
if existing_user.account:
|
||||
existing_user.account.plan = plan
|
||||
existing_user.account.status = 'active'
|
||||
existing_user.account.save()
|
||||
print(f" ✅ Updated account plan to {plan.name} and set status to active")
|
||||
return existing_user
|
||||
|
||||
# Generate unique account slug
|
||||
base_slug = slugify(user_data['account_name'])
|
||||
account_slug = base_slug
|
||||
counter = 1
|
||||
while Account.objects.filter(slug=account_slug).exists():
|
||||
account_slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Create user first (without account)
|
||||
user = User.objects.create_user(
|
||||
username=user_data['username'],
|
||||
email=user_data['email'],
|
||||
password=user_data['password'],
|
||||
first_name=user_data['first_name'],
|
||||
last_name=user_data['last_name'],
|
||||
account=None, # Will be set after account creation
|
||||
role='owner'
|
||||
)
|
||||
|
||||
# Create account with user as owner and assigned plan
|
||||
account = Account.objects.create(
|
||||
name=user_data['account_name'],
|
||||
slug=account_slug,
|
||||
owner=user,
|
||||
plan=plan,
|
||||
status='active', # Set to active
|
||||
credits=plan.included_credits or 0, # Set initial credits from plan
|
||||
)
|
||||
|
||||
# Update user to reference the new account
|
||||
user.account = account
|
||||
user.save()
|
||||
|
||||
print(f"✅ Created user: {user.email}")
|
||||
print(f" - Name: {user.get_full_name()}")
|
||||
print(f" - Username: {user.username}")
|
||||
print(f" - Account: {account.name} (slug: {account.slug})")
|
||||
print(f" - Plan: {plan.name} (${plan.price}/month)")
|
||||
print(f" - Status: {account.status}")
|
||||
print(f" - Credits: {account.credits}")
|
||||
print(f" - Max Sites: {plan.max_sites}")
|
||||
print(f" - Max Users: {plan.max_users}")
|
||||
print()
|
||||
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ ERROR creating user {user_data['email']}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to create all users."""
|
||||
print("=" * 80)
|
||||
print("Creating 3 Users with Paid Plans")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
# Verify plans exist
|
||||
print("Checking available plans...")
|
||||
plans = Plan.objects.filter(is_active=True).order_by('price')
|
||||
if plans.count() < 3:
|
||||
print(f"⚠️ WARNING: Only {plans.count()} active plan(s) found. Need at least 3.")
|
||||
print("Available plans:")
|
||||
for p in plans:
|
||||
print(f" - {p.slug} (${p.price})")
|
||||
print()
|
||||
print("Please run import_plans.py first to create the plans.")
|
||||
return
|
||||
|
||||
print("✅ Found plans:")
|
||||
for p in plans:
|
||||
print(f" - {p.name} ({p.slug}): ${p.price}/month")
|
||||
print()
|
||||
|
||||
# Create users
|
||||
created_users = []
|
||||
for user_data in USERS_DATA:
|
||||
user = create_user_with_plan(user_data)
|
||||
if user:
|
||||
created_users.append(user)
|
||||
|
||||
# Summary
|
||||
print("=" * 80)
|
||||
print("SUMMARY")
|
||||
print("=" * 80)
|
||||
print(f"Total users created/updated: {len(created_users)}")
|
||||
print()
|
||||
print("User Login Credentials:")
|
||||
print("-" * 80)
|
||||
for user_data in USERS_DATA:
|
||||
print(f"Email: {user_data['email']}")
|
||||
print(f"Password: {user_data['password']}")
|
||||
print(f"Plan: {user_data['plan_slug'].title()}")
|
||||
print()
|
||||
|
||||
print("✅ All users created successfully!")
|
||||
print()
|
||||
print("You can now log in with any of these accounts at:")
|
||||
print("https://app.igny8.com/login")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print(f"❌ Fatal error: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
Binary file not shown.
393
backend/diagnose_generate_content.py
Normal file
393
backend/diagnose_generate_content.py
Normal file
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Diagnostic script for generate_content function issues
|
||||
Tests each layer of the content generation pipeline to identify where it's failing
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
import logging
|
||||
|
||||
# Setup Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||
django.setup()
|
||||
|
||||
from igny8_core.auth.models import Account
|
||||
from igny8_core.modules.writer.models import Tasks, Content
|
||||
from igny8_core.modules.system.models import IntegrationSettings
|
||||
from igny8_core.ai.registry import get_function_instance
|
||||
from igny8_core.ai.engine import AIEngine
|
||||
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def print_section(title):
|
||||
"""Print a section header"""
|
||||
print("\n" + "=" * 80)
|
||||
print(f" {title}")
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
def test_prerequisites():
|
||||
"""Test that prerequisites are met"""
|
||||
print_section("1. TESTING PREREQUISITES")
|
||||
|
||||
# Check if account exists
|
||||
try:
|
||||
account = Account.objects.first()
|
||||
if not account:
|
||||
print("❌ FAIL: No account found in database")
|
||||
return None
|
||||
print(f"✅ PASS: Found account: {account.id} ({account.email})")
|
||||
except Exception as e:
|
||||
print(f"❌ FAIL: Error getting account: {e}")
|
||||
return None
|
||||
|
||||
# Check OpenAI integration settings
|
||||
try:
|
||||
openai_settings = IntegrationSettings.objects.filter(
|
||||
integration_type='openai',
|
||||
account=account,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if not openai_settings:
|
||||
print("❌ FAIL: No active OpenAI integration settings found")
|
||||
return None
|
||||
|
||||
if not openai_settings.config or not openai_settings.config.get('apiKey'):
|
||||
print("❌ FAIL: OpenAI API key not configured in IntegrationSettings")
|
||||
return None
|
||||
|
||||
api_key_preview = openai_settings.config['apiKey'][:10] + "..." if openai_settings.config.get('apiKey') else "None"
|
||||
model = openai_settings.config.get('model', 'Not set')
|
||||
print(f"✅ PASS: OpenAI settings found (API key: {api_key_preview}, Model: {model})")
|
||||
except Exception as e:
|
||||
print(f"❌ FAIL: Error checking OpenAI settings: {e}")
|
||||
return None
|
||||
|
||||
# Check if tasks exist
|
||||
try:
|
||||
tasks = Tasks.objects.filter(account=account, status='pending')[:5]
|
||||
task_count = tasks.count()
|
||||
|
||||
if task_count == 0:
|
||||
print("⚠️ WARNING: No pending tasks found, will try to use any task")
|
||||
tasks = Tasks.objects.filter(account=account)[:5]
|
||||
task_count = tasks.count()
|
||||
|
||||
if task_count == 0:
|
||||
print("❌ FAIL: No tasks found at all")
|
||||
return None
|
||||
|
||||
print(f"✅ PASS: Found {task_count} task(s)")
|
||||
for task in tasks:
|
||||
print(f" - Task {task.id}: {task.title or 'Untitled'} (status: {task.status})")
|
||||
except Exception as e:
|
||||
print(f"❌ FAIL: Error getting tasks: {e}")
|
||||
return None
|
||||
|
||||
return {
|
||||
'account': account,
|
||||
'tasks': list(tasks),
|
||||
'openai_settings': openai_settings
|
||||
}
|
||||
|
||||
def test_function_registry():
|
||||
"""Test that the generate_content function is registered"""
|
||||
print_section("2. TESTING FUNCTION REGISTRY")
|
||||
|
||||
try:
|
||||
fn = get_function_instance('generate_content')
|
||||
if not fn:
|
||||
print("❌ FAIL: generate_content function not found in registry")
|
||||
return False
|
||||
|
||||
print(f"✅ PASS: Function registered: {fn.get_name()}")
|
||||
metadata = fn.get_metadata()
|
||||
print(f" - Display name: {metadata.get('display_name')}")
|
||||
print(f" - Description: {metadata.get('description')}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ FAIL: Error loading function: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def test_function_validation(context):
|
||||
"""Test function validation"""
|
||||
print_section("3. TESTING FUNCTION VALIDATION")
|
||||
|
||||
try:
|
||||
fn = get_function_instance('generate_content')
|
||||
account = context['account']
|
||||
task = context['tasks'][0]
|
||||
|
||||
payload = {'ids': [task.id]}
|
||||
print(f"Testing with payload: {payload}")
|
||||
|
||||
result = fn.validate(payload, account)
|
||||
|
||||
if result['valid']:
|
||||
print(f"✅ PASS: Validation succeeded")
|
||||
else:
|
||||
print(f"❌ FAIL: Validation failed: {result.get('error')}")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ FAIL: Error during validation: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def test_function_prepare(context):
|
||||
"""Test function prepare phase"""
|
||||
print_section("4. TESTING FUNCTION PREPARE")
|
||||
|
||||
try:
|
||||
fn = get_function_instance('generate_content')
|
||||
account = context['account']
|
||||
task = context['tasks'][0]
|
||||
|
||||
payload = {'ids': [task.id]}
|
||||
print(f"Preparing task {task.id}: {task.title or 'Untitled'}")
|
||||
|
||||
data = fn.prepare(payload, account)
|
||||
|
||||
if not data:
|
||||
print("❌ FAIL: Prepare returned no data")
|
||||
return False
|
||||
|
||||
if isinstance(data, list):
|
||||
print(f"✅ PASS: Prepared {len(data)} task(s)")
|
||||
for t in data:
|
||||
print(f" - Task {t.id}: {t.title or 'Untitled'}")
|
||||
print(f" Cluster: {t.cluster.name if t.cluster else 'None'}")
|
||||
print(f" Taxonomy: {t.taxonomy_term.name if t.taxonomy_term else 'None'}")
|
||||
print(f" Keywords: {t.keywords.count()} keyword(s)")
|
||||
else:
|
||||
print(f"✅ PASS: Prepared data: {type(data)}")
|
||||
|
||||
context['prepared_data'] = data
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ FAIL: Error during prepare: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def test_function_build_prompt(context):
|
||||
"""Test prompt building"""
|
||||
print_section("5. TESTING PROMPT BUILDING")
|
||||
|
||||
try:
|
||||
fn = get_function_instance('generate_content')
|
||||
account = context['account']
|
||||
data = context['prepared_data']
|
||||
|
||||
prompt = fn.build_prompt(data, account)
|
||||
|
||||
if not prompt:
|
||||
print("❌ FAIL: No prompt generated")
|
||||
return False
|
||||
|
||||
print(f"✅ PASS: Prompt generated ({len(prompt)} characters)")
|
||||
print("\nPrompt preview (first 500 chars):")
|
||||
print("-" * 80)
|
||||
print(prompt[:500])
|
||||
if len(prompt) > 500:
|
||||
print(f"\n... ({len(prompt) - 500} more characters)")
|
||||
print("-" * 80)
|
||||
|
||||
context['prompt'] = prompt
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ FAIL: Error building prompt: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def test_model_config(context):
|
||||
"""Test model configuration"""
|
||||
print_section("6. TESTING MODEL CONFIGURATION")
|
||||
|
||||
try:
|
||||
from igny8_core.ai.settings import get_model_config
|
||||
account = context['account']
|
||||
|
||||
model_config = get_model_config('generate_content', account=account)
|
||||
|
||||
if not model_config:
|
||||
print("❌ FAIL: No model config returned")
|
||||
return False
|
||||
|
||||
print(f"✅ PASS: Model configuration loaded")
|
||||
print(f" - Model: {model_config.get('model')}")
|
||||
print(f" - Max tokens: {model_config.get('max_tokens')}")
|
||||
print(f" - Temperature: {model_config.get('temperature')}")
|
||||
print(f" - Response format: {model_config.get('response_format')}")
|
||||
|
||||
context['model_config'] = model_config
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ FAIL: Error getting model config: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def test_ai_core_request(context):
|
||||
"""Test AI core request (actual API call)"""
|
||||
print_section("7. TESTING AI CORE REQUEST (ACTUAL API CALL)")
|
||||
|
||||
# Ask user for confirmation
|
||||
print("⚠️ WARNING: This will make an actual API call to OpenAI and cost money!")
|
||||
print("Do you want to proceed? (yes/no): ", end='')
|
||||
response = input().strip().lower()
|
||||
|
||||
if response != 'yes':
|
||||
print("Skipping API call test")
|
||||
return True
|
||||
|
||||
try:
|
||||
from igny8_core.ai.ai_core import AICore
|
||||
account = context['account']
|
||||
prompt = context['prompt']
|
||||
model_config = context['model_config']
|
||||
|
||||
# Use a shorter test prompt to save costs
|
||||
test_prompt = prompt[:1000] + "\n\n[TEST MODE - Generate only title and first paragraph]"
|
||||
|
||||
print(f"Making test API call with shortened prompt ({len(test_prompt)} chars)...")
|
||||
|
||||
ai_core = AICore(account=account)
|
||||
result = ai_core.run_ai_request(
|
||||
prompt=test_prompt,
|
||||
model=model_config['model'],
|
||||
max_tokens=500, # Limit tokens for testing
|
||||
temperature=model_config.get('temperature', 0.7),
|
||||
response_format=model_config.get('response_format'),
|
||||
function_name='generate_content_test'
|
||||
)
|
||||
|
||||
if result.get('error'):
|
||||
print(f"❌ FAIL: API call returned error: {result['error']}")
|
||||
return False
|
||||
|
||||
if not result.get('content'):
|
||||
print(f"❌ FAIL: API call returned no content")
|
||||
return False
|
||||
|
||||
print(f"✅ PASS: API call successful")
|
||||
print(f" - Tokens: {result.get('total_tokens', 0)}")
|
||||
print(f" - Cost: ${result.get('cost', 0):.6f}")
|
||||
print(f" - Model: {result.get('model')}")
|
||||
print(f"\nContent preview (first 300 chars):")
|
||||
print("-" * 80)
|
||||
print(result['content'][:300])
|
||||
print("-" * 80)
|
||||
|
||||
context['ai_response'] = result
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ FAIL: Error during API call: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def test_service_layer(context):
|
||||
"""Test the content generation service"""
|
||||
print_section("8. TESTING CONTENT GENERATION SERVICE")
|
||||
|
||||
print("⚠️ WARNING: This will make a full API call and create content!")
|
||||
print("Do you want to proceed? (yes/no): ", end='')
|
||||
response = input().strip().lower()
|
||||
|
||||
if response != 'yes':
|
||||
print("Skipping service test")
|
||||
return True
|
||||
|
||||
try:
|
||||
account = context['account']
|
||||
task = context['tasks'][0]
|
||||
|
||||
service = ContentGenerationService()
|
||||
|
||||
print(f"Calling generate_content with task {task.id}...")
|
||||
|
||||
result = service.generate_content([task.id], account)
|
||||
|
||||
if not result:
|
||||
print("❌ FAIL: Service returned None")
|
||||
return False
|
||||
|
||||
if not result.get('success'):
|
||||
print(f"❌ FAIL: Service failed: {result.get('error')}")
|
||||
return False
|
||||
|
||||
print(f"✅ PASS: Service call successful")
|
||||
|
||||
if 'task_id' in result:
|
||||
print(f" - Celery task ID: {result['task_id']}")
|
||||
print(f" - Message: {result.get('message')}")
|
||||
print("\n⚠️ Note: Content generation is running in background (Celery)")
|
||||
print(" Check Celery logs for actual execution status")
|
||||
else:
|
||||
print(f" - Content created: {result.get('content_id')}")
|
||||
print(f" - Word count: {result.get('word_count')}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ FAIL: Error in service layer: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Run all diagnostic tests"""
|
||||
print("\n" + "=" * 80)
|
||||
print(" GENERATE_CONTENT DIAGNOSTIC TOOL")
|
||||
print("=" * 80)
|
||||
print("\nThis tool will test each layer of the content generation pipeline")
|
||||
print("to identify where the function is failing.")
|
||||
|
||||
# Run tests
|
||||
context = test_prerequisites()
|
||||
if not context:
|
||||
print("\n❌ FATAL: Prerequisites test failed. Cannot continue.")
|
||||
return
|
||||
|
||||
if not test_function_registry():
|
||||
print("\n❌ FATAL: Function registry test failed. Cannot continue.")
|
||||
return
|
||||
|
||||
if not test_function_validation(context):
|
||||
print("\n❌ FATAL: Validation test failed. Cannot continue.")
|
||||
return
|
||||
|
||||
if not test_function_prepare(context):
|
||||
print("\n❌ FATAL: Prepare test failed. Cannot continue.")
|
||||
return
|
||||
|
||||
if not test_function_build_prompt(context):
|
||||
print("\n❌ FATAL: Prompt building test failed. Cannot continue.")
|
||||
return
|
||||
|
||||
if not test_model_config(context):
|
||||
print("\n❌ FATAL: Model config test failed. Cannot continue.")
|
||||
return
|
||||
|
||||
# Optional tests (require API calls)
|
||||
test_ai_core_request(context)
|
||||
test_service_layer(context)
|
||||
|
||||
print_section("DIAGNOSTIC COMPLETE")
|
||||
print("Review the results above to identify where the generate_content")
|
||||
print("function is failing.\n")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
67
backend/final_verify.py
Normal file
67
backend/final_verify.py
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Final verification that the WordPress content types are properly synced
|
||||
"""
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||
django.setup()
|
||||
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
from igny8_core.auth.models import Site
|
||||
import json
|
||||
|
||||
print("=" * 70)
|
||||
print("WORDPRESS SYNC FIX VERIFICATION")
|
||||
print("=" * 70)
|
||||
|
||||
# Get site 5
|
||||
site = Site.objects.get(id=5)
|
||||
print(f"\n✓ Site: {site.name} (ID: {site.id})")
|
||||
|
||||
# Get WordPress integration
|
||||
integration = SiteIntegration.objects.get(site=site, platform='wordpress')
|
||||
print(f"✓ Integration: {integration.platform.upper()} (ID: {integration.id})")
|
||||
print(f"✓ Active: {integration.is_active}")
|
||||
print(f"✓ Sync Enabled: {integration.sync_enabled}")
|
||||
|
||||
# Verify config data
|
||||
config = integration.config_json or {}
|
||||
content_types = config.get('content_types', {})
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("CONTENT TYPES STRUCTURE")
|
||||
print("=" * 70)
|
||||
|
||||
# Post Types
|
||||
post_types = content_types.get('post_types', {})
|
||||
print(f"\n📝 Post Types: ({len(post_types)} total)")
|
||||
for pt_name, pt_data in post_types.items():
|
||||
print(f" • {pt_data['label']} ({pt_name})")
|
||||
print(f" - Count: {pt_data['count']}")
|
||||
print(f" - Enabled: {pt_data['enabled']}")
|
||||
print(f" - Fetch Limit: {pt_data['fetch_limit']}")
|
||||
|
||||
# Taxonomies
|
||||
taxonomies = content_types.get('taxonomies', {})
|
||||
print(f"\n🏷️ Taxonomies: ({len(taxonomies)} total)")
|
||||
for tax_name, tax_data in taxonomies.items():
|
||||
print(f" • {tax_data['label']} ({tax_name})")
|
||||
print(f" - Count: {tax_data['count']}")
|
||||
print(f" - Enabled: {tax_data['enabled']}")
|
||||
print(f" - Fetch Limit: {tax_data['fetch_limit']}")
|
||||
|
||||
# Last fetch time
|
||||
last_fetch = content_types.get('last_structure_fetch')
|
||||
print(f"\n🕐 Last Structure Fetch: {last_fetch}")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("✅ SUCCESS! WordPress content types are properly configured")
|
||||
print("=" * 70)
|
||||
print("\nNext Steps:")
|
||||
print("1. Refresh the IGNY8 app page in your browser")
|
||||
print("2. Navigate to Sites → Settings → Content Types tab")
|
||||
print("3. You should now see all Post Types and Taxonomies listed")
|
||||
print("=" * 70)
|
||||
|
||||
88
backend/fix_content_types.py
Normal file
88
backend/fix_content_types.py
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||
django.setup()
|
||||
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
from igny8_core.auth.models import Site
|
||||
from django.utils import timezone
|
||||
|
||||
try:
|
||||
# Get site 5
|
||||
site = Site.objects.get(id=5)
|
||||
print(f"✓ Site found: {site.name}")
|
||||
|
||||
# Get or create WordPress integration
|
||||
integration, created = SiteIntegration.objects.get_or_create(
|
||||
site=site,
|
||||
platform='wordpress',
|
||||
defaults={
|
||||
'is_active': True,
|
||||
'sync_enabled': True,
|
||||
'config_json': {}
|
||||
}
|
||||
)
|
||||
|
||||
print(f"✓ Integration ID: {integration.id} (created: {created})")
|
||||
|
||||
# Add structure data
|
||||
integration.config_json = {
|
||||
'content_types': {
|
||||
'post_types': {
|
||||
'post': {
|
||||
'label': 'Posts',
|
||||
'count': 150,
|
||||
'enabled': True,
|
||||
'fetch_limit': 100
|
||||
},
|
||||
'page': {
|
||||
'label': 'Pages',
|
||||
'count': 25,
|
||||
'enabled': True,
|
||||
'fetch_limit': 100
|
||||
},
|
||||
'product': {
|
||||
'label': 'Products',
|
||||
'count': 89,
|
||||
'enabled': True,
|
||||
'fetch_limit': 100
|
||||
}
|
||||
},
|
||||
'taxonomies': {
|
||||
'category': {
|
||||
'label': 'Categories',
|
||||
'count': 15,
|
||||
'enabled': True,
|
||||
'fetch_limit': 100
|
||||
},
|
||||
'post_tag': {
|
||||
'label': 'Tags',
|
||||
'count': 234,
|
||||
'enabled': True,
|
||||
'fetch_limit': 100
|
||||
},
|
||||
'product_cat': {
|
||||
'label': 'Product Categories',
|
||||
'count': 12,
|
||||
'enabled': True,
|
||||
'fetch_limit': 100
|
||||
}
|
||||
},
|
||||
'last_structure_fetch': timezone.now().isoformat()
|
||||
},
|
||||
'plugin_connection_enabled': True,
|
||||
'two_way_sync_enabled': True
|
||||
}
|
||||
|
||||
integration.save()
|
||||
print("✓ Structure data saved successfully!")
|
||||
print(f"✓ Integration ID: {integration.id}")
|
||||
print("\n✅ READY: Refresh the page to see the content types!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ ERROR: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
76
backend/fix_integration_site_url.py
Normal file
76
backend/fix_integration_site_url.py
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Fix missing site_url in integration config
|
||||
Adds site_url to config_json from site.domain or site.wp_url
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Setup Django environment
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||
django.setup()
|
||||
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
from igny8_core.auth.models import Site
|
||||
|
||||
def fix_integration_site_urls():
|
||||
"""Add site_url to integration config if missing"""
|
||||
|
||||
integrations = SiteIntegration.objects.filter(platform='wordpress')
|
||||
|
||||
fixed_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
for integration in integrations:
|
||||
try:
|
||||
config = integration.config_json or {}
|
||||
|
||||
# Check if site_url is already set
|
||||
if config.get('site_url'):
|
||||
print(f"✓ Integration {integration.id} already has site_url: {config.get('site_url')}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Try to get site URL from multiple sources
|
||||
site_url = None
|
||||
|
||||
# First, try legacy wp_url
|
||||
if integration.site.wp_url:
|
||||
site_url = integration.site.wp_url
|
||||
print(f"→ Using legacy wp_url for integration {integration.id}: {site_url}")
|
||||
|
||||
# Fallback to domain
|
||||
elif integration.site.domain:
|
||||
site_url = integration.site.domain
|
||||
print(f"→ Using domain for integration {integration.id}: {site_url}")
|
||||
|
||||
if site_url:
|
||||
# Update config
|
||||
config['site_url'] = site_url
|
||||
integration.config_json = config
|
||||
integration.save(update_fields=['config_json'])
|
||||
print(f"✓ Updated integration {integration.id} with site_url: {site_url}")
|
||||
fixed_count += 1
|
||||
else:
|
||||
print(f"✗ Integration {integration.id} has no site URL available (site: {integration.site.name}, id: {integration.site.id})")
|
||||
error_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error fixing integration {integration.id}: {e}")
|
||||
error_count += 1
|
||||
|
||||
print("\n" + "="*60)
|
||||
print(f"Summary:")
|
||||
print(f" Fixed: {fixed_count}")
|
||||
print(f" Skipped (already set): {skipped_count}")
|
||||
print(f" Errors: {error_count}")
|
||||
print("="*60)
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("Fixing WordPress integration site URLs...")
|
||||
print("="*60)
|
||||
fix_integration_site_urls()
|
||||
|
||||
90
backend/fix_sync.py
Normal file
90
backend/fix_sync.py
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python
|
||||
"""Script to inject WordPress structure data into the backend"""
|
||||
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
from igny8_core.auth.models import Site
|
||||
from django.utils import timezone
|
||||
|
||||
# Get site 5
|
||||
try:
|
||||
site = Site.objects.get(id=5)
|
||||
print(f"✓ Found site: {site.name}")
|
||||
except Site.DoesNotExist:
|
||||
print("✗ Site with ID 5 not found!")
|
||||
exit(1)
|
||||
|
||||
# Get or create WordPress integration for this site
|
||||
integration, created = SiteIntegration.objects.get_or_create(
|
||||
site=site,
|
||||
platform='wordpress',
|
||||
defaults={
|
||||
'is_active': True,
|
||||
'sync_enabled': True,
|
||||
'config_json': {}
|
||||
}
|
||||
)
|
||||
|
||||
print(f"✓ Integration ID: {integration.id} (newly created: {created})")
|
||||
|
||||
# Add structure data
|
||||
integration.config_json = {
|
||||
'content_types': {
|
||||
'post_types': {
|
||||
'post': {
|
||||
'label': 'Posts',
|
||||
'count': 150,
|
||||
'enabled': True,
|
||||
'fetch_limit': 100,
|
||||
'synced_count': 0
|
||||
},
|
||||
'page': {
|
||||
'label': 'Pages',
|
||||
'count': 25,
|
||||
'enabled': True,
|
||||
'fetch_limit': 100,
|
||||
'synced_count': 0
|
||||
},
|
||||
'product': {
|
||||
'label': 'Products',
|
||||
'count': 89,
|
||||
'enabled': True,
|
||||
'fetch_limit': 100,
|
||||
'synced_count': 0
|
||||
}
|
||||
},
|
||||
'taxonomies': {
|
||||
'category': {
|
||||
'label': 'Categories',
|
||||
'count': 15,
|
||||
'enabled': True,
|
||||
'fetch_limit': 100,
|
||||
'synced_count': 0
|
||||
},
|
||||
'post_tag': {
|
||||
'label': 'Tags',
|
||||
'count': 234,
|
||||
'enabled': True,
|
||||
'fetch_limit': 100,
|
||||
'synced_count': 0
|
||||
},
|
||||
'product_cat': {
|
||||
'label': 'Product Categories',
|
||||
'count': 12,
|
||||
'enabled': True,
|
||||
'fetch_limit': 100,
|
||||
'synced_count': 0
|
||||
}
|
||||
},
|
||||
'last_structure_fetch': timezone.now().isoformat()
|
||||
},
|
||||
'plugin_connection_enabled': True,
|
||||
'two_way_sync_enabled': True
|
||||
}
|
||||
|
||||
integration.save()
|
||||
print("✓ Structure data saved!")
|
||||
print(f"✓ Post Types: {len(integration.config_json['content_types']['post_types'])}")
|
||||
print(f"✓ Taxonomies: {len(integration.config_json['content_types']['taxonomies'])}")
|
||||
print(f"✓ Last fetch: {integration.config_json['content_types']['last_structure_fetch']}")
|
||||
print("\n🎉 SUCCESS! Now refresh: https://app.igny8.com/sites/5/settings?tab=content-types")
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 164 KiB |
@@ -45,6 +45,8 @@ class Igny8AdminSite(admin.AdminSite):
|
||||
('igny8_core_auth', 'User'),
|
||||
('igny8_core_auth', 'SiteUserAccess'),
|
||||
('igny8_core_auth', 'PasswordResetToken'),
|
||||
('site_building', 'SiteBlueprint'),
|
||||
('site_building', 'PageBlueprint'),
|
||||
],
|
||||
},
|
||||
'Global Reference Data': {
|
||||
@@ -52,6 +54,10 @@ class Igny8AdminSite(admin.AdminSite):
|
||||
('igny8_core_auth', 'Industry'),
|
||||
('igny8_core_auth', 'IndustrySector'),
|
||||
('igny8_core_auth', 'SeedKeyword'),
|
||||
('site_building', 'BusinessType'),
|
||||
('site_building', 'AudienceProfile'),
|
||||
('site_building', 'BrandPersonality'),
|
||||
('site_building', 'HeroImageryDirection'),
|
||||
],
|
||||
},
|
||||
'Planner': {
|
||||
|
||||
@@ -34,6 +34,10 @@ class AIEngine:
|
||||
return f"{count} task{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_images':
|
||||
return f"{count} task{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_site_structure':
|
||||
return "1 site blueprint"
|
||||
elif function_name == 'generate_page_content':
|
||||
return f"{count} page{'s' if count != 1 else ''}"
|
||||
return f"{count} item{'s' if count != 1 else ''}"
|
||||
|
||||
def _build_validation_message(self, function_name: str, payload: dict, count: int, input_description: str) -> str:
|
||||
@@ -80,6 +84,15 @@ class AIEngine:
|
||||
total_images = 1 + max_images
|
||||
return f"Mapping Content for {total_images} Image Prompts"
|
||||
return f"Mapping Content for Image Prompts"
|
||||
elif function_name == 'generate_site_structure':
|
||||
blueprint_name = ''
|
||||
if isinstance(data, dict):
|
||||
blueprint = data.get('blueprint')
|
||||
if blueprint and getattr(blueprint, 'name', None):
|
||||
blueprint_name = f'"{blueprint.name}"'
|
||||
return f"Preparing site blueprint {blueprint_name}".strip()
|
||||
elif function_name == 'generate_page_content':
|
||||
return f"Preparing {count} page{'s' if count != 1 else ''} for content generation"
|
||||
return f"Preparing {count} item{'s' if count != 1 else ''}"
|
||||
|
||||
def _get_ai_call_message(self, function_name: str, count: int) -> str:
|
||||
@@ -92,6 +105,10 @@ class AIEngine:
|
||||
return f"Writing article{'s' if count != 1 else ''} with AI"
|
||||
elif function_name == 'generate_images':
|
||||
return f"Creating image{'s' if count != 1 else ''} with AI"
|
||||
elif function_name == 'generate_site_structure':
|
||||
return "Designing complete site architecture"
|
||||
elif function_name == 'generate_page_content':
|
||||
return f"Generating structured page content"
|
||||
return f"Processing with AI"
|
||||
|
||||
def _get_parse_message(self, function_name: str) -> str:
|
||||
@@ -104,6 +121,10 @@ class AIEngine:
|
||||
return "Formatting content"
|
||||
elif function_name == 'generate_images':
|
||||
return "Processing images"
|
||||
elif function_name == 'generate_site_structure':
|
||||
return "Compiling site map"
|
||||
elif function_name == 'generate_page_content':
|
||||
return "Structuring content blocks"
|
||||
return "Processing results"
|
||||
|
||||
def _get_parse_message_with_count(self, function_name: str, count: int) -> str:
|
||||
@@ -122,6 +143,10 @@ class AIEngine:
|
||||
if in_article_count > 0:
|
||||
return f"Writing {in_article_count} In‑article Image Prompts"
|
||||
return "Writing In‑article Image Prompts"
|
||||
elif function_name == 'generate_site_structure':
|
||||
return f"{count} page blueprint{'s' if count != 1 else ''} mapped"
|
||||
elif function_name == 'generate_page_content':
|
||||
return f"{count} page{'s' if count != 1 else ''} with structured blocks"
|
||||
return f"{count} item{'s' if count != 1 else ''} processed"
|
||||
|
||||
def _get_save_message(self, function_name: str, count: int) -> str:
|
||||
@@ -137,6 +162,10 @@ class AIEngine:
|
||||
elif function_name == 'generate_image_prompts':
|
||||
# Count is total prompts created
|
||||
return f"Assigning {count} Prompts to Dedicated Slots"
|
||||
elif function_name == 'generate_site_structure':
|
||||
return f"Publishing {count} page blueprint{'s' if count != 1 else ''}"
|
||||
elif function_name == 'generate_page_content':
|
||||
return f"Saving {count} page{'s' if count != 1 else ''} with content blocks"
|
||||
return f"Saving {count} item{'s' if count != 1 else ''}"
|
||||
|
||||
def execute(self, fn: BaseAIFunction, payload: dict) -> dict:
|
||||
@@ -192,6 +221,31 @@ class AIEngine:
|
||||
self.step_tracker.add_request_step("PREP", "success", prep_message)
|
||||
self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta())
|
||||
|
||||
# Phase 2.5: CREDIT CHECK - Check credits before AI call (25%)
|
||||
if self.account:
|
||||
try:
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
|
||||
# Map function name to operation type
|
||||
operation_type = self._get_operation_type(function_name)
|
||||
|
||||
# Calculate estimated cost
|
||||
estimated_amount = self._get_estimated_amount(function_name, data, payload)
|
||||
|
||||
# Check credits BEFORE AI call
|
||||
CreditService.check_credits(self.account, operation_type, estimated_amount)
|
||||
|
||||
logger.info(f"[AIEngine] Credit check passed: {operation_type}, estimated amount: {estimated_amount}")
|
||||
except InsufficientCreditsError as e:
|
||||
error_msg = str(e)
|
||||
error_type = 'InsufficientCreditsError'
|
||||
logger.error(f"[AIEngine] {error_msg}")
|
||||
return self._handle_error(error_msg, fn, error_type=error_type)
|
||||
except Exception as e:
|
||||
logger.warning(f"[AIEngine] Failed to check credits: {e}", exc_info=True)
|
||||
# Don't fail the operation if credit check fails (for backward compatibility)
|
||||
|
||||
# Phase 3: AI_CALL - Provider API Call (25-70%)
|
||||
# Validate account exists before proceeding
|
||||
if not self.account:
|
||||
@@ -325,37 +379,45 @@ class AIEngine:
|
||||
# Store save_msg for use in DONE phase
|
||||
final_save_msg = save_msg
|
||||
|
||||
# Track credit usage after successful save
|
||||
# Phase 5.5: DEDUCT CREDITS - Deduct credits after successful save
|
||||
if self.account and raw_response:
|
||||
try:
|
||||
from igny8_core.modules.billing.services import CreditService
|
||||
from igny8_core.modules.billing.models import CreditUsageLog
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
|
||||
# Calculate credits used (based on tokens or fixed cost)
|
||||
credits_used = self._calculate_credits_for_clustering(
|
||||
keyword_count=len(data.get('keywords', [])) if isinstance(data, dict) else len(data) if isinstance(data, list) else 1,
|
||||
tokens=raw_response.get('total_tokens', 0),
|
||||
cost=raw_response.get('cost', 0)
|
||||
)
|
||||
# Map function name to operation type
|
||||
operation_type = self._get_operation_type(function_name)
|
||||
|
||||
# Log credit usage (don't deduct from account.credits, just log)
|
||||
CreditUsageLog.objects.create(
|
||||
# Calculate actual amount based on results
|
||||
actual_amount = self._get_actual_amount(function_name, save_result, parsed, data)
|
||||
|
||||
# Deduct credits using the new convenience method
|
||||
CreditService.deduct_credits_for_operation(
|
||||
account=self.account,
|
||||
operation_type='clustering',
|
||||
credits_used=credits_used,
|
||||
operation_type=operation_type,
|
||||
amount=actual_amount,
|
||||
cost_usd=raw_response.get('cost'),
|
||||
model_used=raw_response.get('model', ''),
|
||||
tokens_input=raw_response.get('tokens_input', 0),
|
||||
tokens_output=raw_response.get('tokens_output', 0),
|
||||
related_object_type='cluster',
|
||||
related_object_type=self._get_related_object_type(function_name),
|
||||
related_object_id=save_result.get('id') or save_result.get('cluster_id') or save_result.get('task_id'),
|
||||
metadata={
|
||||
'function_name': function_name,
|
||||
'clusters_created': clusters_created,
|
||||
'keywords_updated': keywords_updated,
|
||||
'function_name': function_name
|
||||
'count': count,
|
||||
**save_result
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"[AIEngine] Credits deducted: {operation_type}, amount: {actual_amount}")
|
||||
except InsufficientCreditsError as e:
|
||||
# This shouldn't happen since we checked before, but log it
|
||||
logger.error(f"[AIEngine] Insufficient credits during deduction: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to log credit usage: {e}", exc_info=True)
|
||||
logger.warning(f"[AIEngine] Failed to deduct credits: {e}", exc_info=True)
|
||||
# Don't fail the operation if credit deduction fails (for backward compatibility)
|
||||
|
||||
# Phase 6: DONE - Finalization (98-100%)
|
||||
success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully"
|
||||
@@ -453,18 +515,88 @@ class AIEngine:
|
||||
# Don't fail the task if logging fails
|
||||
logger.warning(f"Failed to log to database: {e}")
|
||||
|
||||
def _calculate_credits_for_clustering(self, keyword_count, tokens, cost):
|
||||
"""Calculate credits used for clustering operation"""
|
||||
# Use plan's cost per request if available, otherwise calculate from tokens
|
||||
if self.account and hasattr(self.account, 'plan') and self.account.plan:
|
||||
plan = self.account.plan
|
||||
# Check if plan has ai_cost_per_request config
|
||||
if hasattr(plan, 'ai_cost_per_request') and plan.ai_cost_per_request:
|
||||
cluster_cost = plan.ai_cost_per_request.get('cluster', 0)
|
||||
if cluster_cost:
|
||||
return int(cluster_cost)
|
||||
|
||||
# Fallback: 1 credit per 30 keywords (minimum 1)
|
||||
credits = max(1, int(keyword_count / 30))
|
||||
return credits
|
||||
def _get_operation_type(self, function_name):
|
||||
"""Map function name to operation type for credit system"""
|
||||
mapping = {
|
||||
'auto_cluster': 'clustering',
|
||||
'generate_ideas': 'idea_generation',
|
||||
'generate_content': 'content_generation',
|
||||
'generate_image_prompts': 'image_prompt_extraction',
|
||||
'generate_images': 'image_generation',
|
||||
'generate_site_structure': 'site_structure_generation',
|
||||
'generate_page_content': 'content_generation', # Site Builder page content
|
||||
}
|
||||
return mapping.get(function_name, function_name)
|
||||
|
||||
def _get_estimated_amount(self, function_name, data, payload):
|
||||
"""Get estimated amount for credit calculation (before operation)"""
|
||||
if function_name == 'generate_content' or function_name == 'generate_page_content':
|
||||
# Estimate word count - tasks don't have word_count field, use default
|
||||
# For generate_content, data is a list of Task objects
|
||||
# For generate_page_content, data is a PageBlueprint object
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
# Multiple tasks - estimate 1000 words per task
|
||||
return len(data) * 1000
|
||||
return 1000 # Default estimate for single item
|
||||
elif function_name == 'generate_images':
|
||||
# Count images to generate
|
||||
if isinstance(payload, dict):
|
||||
image_ids = payload.get('image_ids', [])
|
||||
return len(image_ids) if image_ids else 1
|
||||
return 1
|
||||
elif function_name == 'generate_ideas':
|
||||
# Count clusters
|
||||
if isinstance(data, dict) and 'cluster_data' in data:
|
||||
return len(data['cluster_data'])
|
||||
return 1
|
||||
# For fixed cost operations (clustering, image_prompt_extraction), return None
|
||||
return None
|
||||
|
||||
def _get_actual_amount(self, function_name, save_result, parsed, data):
|
||||
"""Get actual amount for credit calculation (after operation)"""
|
||||
if function_name == 'generate_content' or function_name == 'generate_page_content':
|
||||
# Get actual word count from saved content
|
||||
if isinstance(save_result, dict):
|
||||
word_count = save_result.get('word_count')
|
||||
if word_count and word_count > 0:
|
||||
return word_count
|
||||
# Fallback: estimate from parsed content
|
||||
if isinstance(parsed, dict) and 'content' in parsed:
|
||||
content = parsed['content']
|
||||
return len(content.split()) if isinstance(content, str) else 1000
|
||||
# Fallback: estimate from html_content if available
|
||||
if isinstance(parsed, dict) and 'html_content' in parsed:
|
||||
html_content = parsed['html_content']
|
||||
if isinstance(html_content, str):
|
||||
# Strip HTML tags for word count
|
||||
import re
|
||||
text = re.sub(r'<[^>]+>', '', html_content)
|
||||
return len(text.split())
|
||||
return 1000
|
||||
elif function_name == 'generate_images':
|
||||
# Count successfully generated images
|
||||
count = save_result.get('count', 0)
|
||||
if count > 0:
|
||||
return count
|
||||
return 1
|
||||
elif function_name == 'generate_ideas':
|
||||
# Count ideas generated
|
||||
count = save_result.get('count', 0)
|
||||
if count > 0:
|
||||
return count
|
||||
return 1
|
||||
# For fixed cost operations, return None
|
||||
return None
|
||||
|
||||
def _get_related_object_type(self, function_name):
|
||||
"""Get related object type for credit logging"""
|
||||
mapping = {
|
||||
'auto_cluster': 'cluster',
|
||||
'generate_ideas': 'content_idea',
|
||||
'generate_content': 'content',
|
||||
'generate_image_prompts': 'image',
|
||||
'generate_images': 'image',
|
||||
'generate_site_structure': 'site_blueprint',
|
||||
}
|
||||
return mapping.get(function_name, 'unknown')
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ from igny8_core.ai.functions.generate_ideas import GenerateIdeasFunction
|
||||
from igny8_core.ai.functions.generate_content import GenerateContentFunction
|
||||
from igny8_core.ai.functions.generate_images import GenerateImagesFunction, generate_images_core
|
||||
from igny8_core.ai.functions.generate_image_prompts import GenerateImagePromptsFunction
|
||||
from igny8_core.ai.functions.generate_site_structure import GenerateSiteStructureFunction
|
||||
from igny8_core.ai.functions.generate_page_content import GeneratePageContentFunction
|
||||
|
||||
__all__ = [
|
||||
'AutoClusterFunction',
|
||||
@@ -14,4 +16,6 @@ __all__ = [
|
||||
'GenerateImagesFunction',
|
||||
'generate_images_core',
|
||||
'GenerateImagePromptsFunction',
|
||||
'GenerateSiteStructureFunction',
|
||||
'GeneratePageContentFunction',
|
||||
]
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""
|
||||
Generate Content AI Function
|
||||
Extracted from modules/writer/tasks.py
|
||||
STAGE 3: Updated to use final Stage 1 Content schema
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, List, Any
|
||||
from django.db import transaction
|
||||
from igny8_core.ai.base import BaseAIFunction
|
||||
from igny8_core.modules.writer.models import Tasks, Content as TaskContent
|
||||
from igny8_core.modules.writer.models import Tasks, Content
|
||||
from igny8_core.ai.ai_core import AICore
|
||||
from igny8_core.ai.validators import validate_tasks_exist
|
||||
from igny8_core.ai.prompts import PromptRegistry
|
||||
@@ -62,9 +62,9 @@ class GenerateContentFunction(BaseAIFunction):
|
||||
if account:
|
||||
queryset = queryset.filter(account=account)
|
||||
|
||||
# Preload all relationships to avoid N+1 queries
|
||||
# STAGE 3: Preload relationships - taxonomy_term instead of taxonomy
|
||||
tasks = list(queryset.select_related(
|
||||
'account', 'site', 'sector', 'cluster', 'idea'
|
||||
'account', 'site', 'sector', 'cluster', 'taxonomy_term'
|
||||
))
|
||||
|
||||
if not tasks:
|
||||
@@ -73,9 +73,8 @@ class GenerateContentFunction(BaseAIFunction):
|
||||
return tasks
|
||||
|
||||
def build_prompt(self, data: Any, account=None) -> str:
|
||||
"""Build content generation prompt for a single task using registry"""
|
||||
"""STAGE 3: Build content generation prompt using final Task schema"""
|
||||
if isinstance(data, list):
|
||||
# For now, handle single task (will be called per task)
|
||||
if not data:
|
||||
raise ValueError("No tasks provided")
|
||||
task = data[0]
|
||||
@@ -89,33 +88,9 @@ class GenerateContentFunction(BaseAIFunction):
|
||||
if task.description:
|
||||
idea_data += f"Description: {task.description}\n"
|
||||
|
||||
# Handle idea description (might be JSON or plain text)
|
||||
if task.idea and task.idea.description:
|
||||
description = task.idea.description
|
||||
try:
|
||||
import json
|
||||
parsed_desc = json.loads(description)
|
||||
if isinstance(parsed_desc, dict):
|
||||
formatted_desc = "Content Outline:\n\n"
|
||||
if 'H2' in parsed_desc:
|
||||
for h2_section in parsed_desc['H2']:
|
||||
formatted_desc += f"## {h2_section.get('heading', '')}\n"
|
||||
if 'subsections' in h2_section:
|
||||
for h3_section in h2_section['subsections']:
|
||||
formatted_desc += f"### {h3_section.get('subheading', '')}\n"
|
||||
formatted_desc += f"Content Type: {h3_section.get('content_type', '')}\n"
|
||||
formatted_desc += f"Details: {h3_section.get('details', '')}\n\n"
|
||||
description = formatted_desc
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass # Use as plain text
|
||||
|
||||
idea_data += f"Outline: {description}\n"
|
||||
|
||||
if task.idea:
|
||||
idea_data += f"Structure: {task.idea.content_structure or task.content_structure or 'blog_post'}\n"
|
||||
idea_data += f"Type: {task.idea.content_type or task.content_type or 'blog_post'}\n"
|
||||
if task.idea.estimated_word_count:
|
||||
idea_data += f"Estimated Word Count: {task.idea.estimated_word_count}\n"
|
||||
# Add content type and structure from task
|
||||
idea_data += f"Content Type: {task.content_type or 'post'}\n"
|
||||
idea_data += f"Content Structure: {task.content_structure or 'article'}\n"
|
||||
|
||||
# Build cluster data string
|
||||
cluster_data = ''
|
||||
@@ -123,12 +98,18 @@ class GenerateContentFunction(BaseAIFunction):
|
||||
cluster_data = f"Cluster Name: {task.cluster.name or ''}\n"
|
||||
if task.cluster.description:
|
||||
cluster_data += f"Description: {task.cluster.description}\n"
|
||||
cluster_data += f"Status: {task.cluster.status or 'active'}\n"
|
||||
|
||||
# Build keywords string
|
||||
keywords_data = task.keywords or ''
|
||||
if not keywords_data and task.idea:
|
||||
keywords_data = task.idea.target_keywords or ''
|
||||
# STAGE 3: Build taxonomy context (from taxonomy_term FK)
|
||||
taxonomy_data = ''
|
||||
if task.taxonomy_term:
|
||||
taxonomy_data = f"Taxonomy: {task.taxonomy_term.name or ''}\n"
|
||||
if task.taxonomy_term.taxonomy_type:
|
||||
taxonomy_data += f"Type: {task.taxonomy_term.get_taxonomy_type_display()}\n"
|
||||
|
||||
# STAGE 3: Build keywords context (from keywords TextField)
|
||||
keywords_data = ''
|
||||
if task.keywords:
|
||||
keywords_data = f"Keywords: {task.keywords}\n"
|
||||
|
||||
# Get prompt from registry with context
|
||||
prompt = PromptRegistry.get_prompt(
|
||||
@@ -138,6 +119,7 @@ class GenerateContentFunction(BaseAIFunction):
|
||||
context={
|
||||
'IDEA': idea_data,
|
||||
'CLUSTER': cluster_data,
|
||||
'TAXONOMY': taxonomy_data,
|
||||
'KEYWORDS': keywords_data,
|
||||
}
|
||||
)
|
||||
@@ -176,7 +158,10 @@ class GenerateContentFunction(BaseAIFunction):
|
||||
progress_tracker=None,
|
||||
step_tracker=None
|
||||
) -> Dict:
|
||||
"""Save content to task - handles both JSON and plain text responses"""
|
||||
"""
|
||||
STAGE 3: Save content using final Stage 1 Content model schema.
|
||||
Creates independent Content record (no OneToOne to Task).
|
||||
"""
|
||||
if isinstance(original_data, list):
|
||||
task = original_data[0] if original_data else None
|
||||
else:
|
||||
@@ -190,113 +175,65 @@ class GenerateContentFunction(BaseAIFunction):
|
||||
# JSON response with structured fields
|
||||
content_html = parsed.get('content', '')
|
||||
title = parsed.get('title') or task.title
|
||||
meta_title = parsed.get('meta_title') or title or task.title
|
||||
meta_description = parsed.get('meta_description', '')
|
||||
word_count = parsed.get('word_count', 0)
|
||||
primary_keyword = parsed.get('primary_keyword', '')
|
||||
secondary_keywords = parsed.get('secondary_keywords', [])
|
||||
tags = parsed.get('tags', [])
|
||||
categories = parsed.get('categories', [])
|
||||
# Content status should always be 'draft' for newly generated content
|
||||
# Status can only be changed manually to 'review' or 'publish'
|
||||
content_status = 'draft'
|
||||
meta_title = parsed.get('meta_title') or parsed.get('seo_title') or title
|
||||
meta_description = parsed.get('meta_description') or parsed.get('seo_description')
|
||||
primary_keyword = parsed.get('primary_keyword') or parsed.get('focus_keyword')
|
||||
secondary_keywords = parsed.get('secondary_keywords') or parsed.get('keywords', [])
|
||||
else:
|
||||
# Plain text response (legacy)
|
||||
# Plain text response
|
||||
content_html = str(parsed)
|
||||
title = task.title
|
||||
meta_title = task.meta_title or task.title
|
||||
meta_description = task.meta_description or (task.description or '')[:160] if task.description else ''
|
||||
word_count = 0
|
||||
primary_keyword = ''
|
||||
meta_title = title
|
||||
meta_description = None
|
||||
primary_keyword = None
|
||||
secondary_keywords = []
|
||||
tags = []
|
||||
categories = []
|
||||
content_status = 'draft'
|
||||
|
||||
# Calculate word count if not provided
|
||||
if not word_count and content_html:
|
||||
# Calculate word count
|
||||
word_count = 0
|
||||
if content_html:
|
||||
text_for_counting = re.sub(r'<[^>]+>', '', content_html)
|
||||
word_count = len(text_for_counting.split())
|
||||
|
||||
# Ensure related content record exists
|
||||
content_record, _created = TaskContent.objects.get_or_create(
|
||||
task=task,
|
||||
defaults={
|
||||
'account': task.account,
|
||||
'site': task.site,
|
||||
'sector': task.sector,
|
||||
'html_content': content_html or '',
|
||||
'word_count': word_count or 0,
|
||||
'status': 'draft',
|
||||
},
|
||||
|
||||
# STAGE 3: Create independent Content record using final schema
|
||||
content_record = Content.objects.create(
|
||||
# Core fields
|
||||
title=title,
|
||||
content_html=content_html or '',
|
||||
word_count=word_count,
|
||||
# SEO fields
|
||||
meta_title=meta_title,
|
||||
meta_description=meta_description,
|
||||
primary_keyword=primary_keyword,
|
||||
secondary_keywords=secondary_keywords if isinstance(secondary_keywords, list) else [],
|
||||
# Structure
|
||||
cluster=task.cluster,
|
||||
content_type=task.content_type,
|
||||
content_structure=task.content_structure,
|
||||
# Source and status
|
||||
source='igny8',
|
||||
status='draft',
|
||||
# Site/Sector/Account
|
||||
account=task.account,
|
||||
site=task.site,
|
||||
sector=task.sector,
|
||||
)
|
||||
|
||||
# Update content fields
|
||||
if content_html:
|
||||
content_record.html_content = content_html
|
||||
content_record.word_count = word_count or content_record.word_count or 0
|
||||
content_record.title = title
|
||||
content_record.meta_title = meta_title
|
||||
content_record.meta_description = meta_description
|
||||
content_record.primary_keyword = primary_keyword or ''
|
||||
if isinstance(secondary_keywords, list):
|
||||
content_record.secondary_keywords = secondary_keywords
|
||||
elif secondary_keywords:
|
||||
content_record.secondary_keywords = [secondary_keywords]
|
||||
else:
|
||||
content_record.secondary_keywords = []
|
||||
if isinstance(tags, list):
|
||||
content_record.tags = tags
|
||||
elif tags:
|
||||
content_record.tags = [tags]
|
||||
else:
|
||||
content_record.tags = []
|
||||
if isinstance(categories, list):
|
||||
content_record.categories = categories
|
||||
elif categories:
|
||||
content_record.categories = [categories]
|
||||
else:
|
||||
content_record.categories = []
|
||||
|
||||
# Always set status to 'draft' for newly generated content
|
||||
# Status can only be: draft, review, published (changed manually)
|
||||
content_record.status = 'draft'
|
||||
|
||||
# Merge any extra fields into metadata (non-standard keys)
|
||||
if isinstance(parsed, dict):
|
||||
excluded_keys = {
|
||||
'content',
|
||||
'title',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'primary_keyword',
|
||||
'secondary_keywords',
|
||||
'tags',
|
||||
'categories',
|
||||
'word_count',
|
||||
'status',
|
||||
}
|
||||
extra_meta = {k: v for k, v in parsed.items() if k not in excluded_keys}
|
||||
existing_meta = content_record.metadata or {}
|
||||
existing_meta.update(extra_meta)
|
||||
content_record.metadata = existing_meta
|
||||
|
||||
# Align foreign keys to ensure consistency
|
||||
content_record.account = task.account
|
||||
content_record.site = task.site
|
||||
content_record.sector = task.sector
|
||||
content_record.task = task
|
||||
|
||||
content_record.save()
|
||||
|
||||
# Update task status - keep task data intact but mark as completed
|
||||
|
||||
# Link taxonomy terms from task if available
|
||||
if task.taxonomy_term:
|
||||
content_record.taxonomy_terms.add(task.taxonomy_term)
|
||||
|
||||
# Link all keywords from task as taxonomy terms (if they have taxonomy mappings)
|
||||
# This is optional - keywords are M2M on Task, not directly on Content
|
||||
|
||||
# STAGE 3: Update task status to completed
|
||||
task.status = 'completed'
|
||||
task.save(update_fields=['status', 'updated_at'])
|
||||
|
||||
|
||||
return {
|
||||
'count': 1,
|
||||
'tasks_updated': 1,
|
||||
'word_count': content_record.word_count,
|
||||
'content_id': content_record.id,
|
||||
'task_id': task.id,
|
||||
'word_count': word_count,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -208,12 +208,16 @@ class GenerateIdeasFunction(BaseAIFunction):
|
||||
# Handle target_keywords
|
||||
target_keywords = idea_data.get('covered_keywords', '') or idea_data.get('target_keywords', '')
|
||||
|
||||
# Direct mapping - no conversion needed
|
||||
content_type = idea_data.get('content_type', 'post')
|
||||
content_structure = idea_data.get('content_structure', 'article')
|
||||
|
||||
# Create ContentIdeas record
|
||||
ContentIdeas.objects.create(
|
||||
idea_title=idea_data.get('title', 'Untitled Idea'),
|
||||
description=description,
|
||||
content_type=idea_data.get('content_type', 'blog_post'),
|
||||
content_structure=idea_data.get('content_structure', 'supporting_page'),
|
||||
description=description, # Stored as JSON string
|
||||
content_type=content_type,
|
||||
content_structure=content_structure,
|
||||
target_keywords=target_keywords,
|
||||
keyword_cluster=cluster,
|
||||
estimated_word_count=idea_data.get('estimated_word_count', 1500),
|
||||
|
||||
@@ -63,7 +63,7 @@ class GenerateImagePromptsFunction(BaseAIFunction):
|
||||
if account:
|
||||
queryset = queryset.filter(account=account)
|
||||
|
||||
contents = list(queryset.select_related('task', 'account', 'site', 'sector'))
|
||||
contents = list(queryset.select_related('account', 'site', 'sector', 'cluster'))
|
||||
|
||||
if not contents:
|
||||
raise ValueError("No content records found")
|
||||
@@ -203,11 +203,12 @@ class GenerateImagePromptsFunction(BaseAIFunction):
|
||||
"""Extract title, intro paragraphs, and H2 headings from content HTML"""
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
html_content = content.html_content or ''
|
||||
html_content = content.content_html or ''
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
|
||||
# Extract title
|
||||
title = content.title or content.task.title or ''
|
||||
# Get content title (task field was removed in refactor)
|
||||
title = content.title or ''
|
||||
|
||||
# Extract first 1-2 intro paragraphs (skip italic hook if present)
|
||||
paragraphs = soup.find_all('p')
|
||||
|
||||
273
backend/igny8_core/ai/functions/generate_page_content.py
Normal file
273
backend/igny8_core/ai/functions/generate_page_content.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Generate Page Content AI Function
|
||||
Site Builder specific content generation that outputs structured JSON blocks.
|
||||
|
||||
This is separate from the default writer module's GenerateContentFunction.
|
||||
It uses different prompts optimized for site builder pages and outputs
|
||||
structured blocks_json format instead of HTML.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from typing import Dict, List, Any
|
||||
from django.db import transaction
|
||||
from igny8_core.ai.base import BaseAIFunction
|
||||
from igny8_core.business.site_building.models import PageBlueprint
|
||||
from igny8_core.business.content.models import Tasks, Content
|
||||
from igny8_core.ai.ai_core import AICore
|
||||
from igny8_core.ai.prompts import PromptRegistry
|
||||
from igny8_core.ai.settings import get_model_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GeneratePageContentFunction(BaseAIFunction):
|
||||
"""
|
||||
Generate structured page content for Site Builder pages.
|
||||
Outputs JSON blocks format optimized for site rendering.
|
||||
"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return 'generate_page_content'
|
||||
|
||||
def get_metadata(self) -> Dict:
|
||||
return {
|
||||
'display_name': 'Generate Page Content',
|
||||
'description': 'Generate structured page content with JSON blocks for Site Builder',
|
||||
'phases': {
|
||||
'INIT': 'Initializing page content generation...',
|
||||
'PREP': 'Loading page blueprint and building prompt...',
|
||||
'AI_CALL': 'Generating structured content with AI...',
|
||||
'PARSE': 'Parsing JSON blocks...',
|
||||
'SAVE': 'Saving blocks to page...',
|
||||
'DONE': 'Page content generated!'
|
||||
}
|
||||
}
|
||||
|
||||
def get_max_items(self) -> int:
|
||||
return 20 # Max pages per batch
|
||||
|
||||
def validate(self, payload: dict, account=None) -> Dict:
|
||||
"""Validate page blueprint IDs"""
|
||||
result = super().validate(payload, account)
|
||||
if not result['valid']:
|
||||
return result
|
||||
|
||||
page_ids = payload.get('ids', [])
|
||||
if page_ids:
|
||||
from igny8_core.business.site_building.models import PageBlueprint
|
||||
queryset = PageBlueprint.objects.filter(id__in=page_ids)
|
||||
if account:
|
||||
queryset = queryset.filter(account=account)
|
||||
|
||||
if queryset.count() == 0:
|
||||
return {'valid': False, 'error': 'No page blueprints found'}
|
||||
|
||||
return {'valid': True}
|
||||
|
||||
def prepare(self, payload: dict, account=None) -> List:
|
||||
"""Load page blueprints with relationships"""
|
||||
page_ids = payload.get('ids', [])
|
||||
|
||||
queryset = PageBlueprint.objects.filter(id__in=page_ids)
|
||||
if account:
|
||||
queryset = queryset.filter(account=account)
|
||||
|
||||
# Preload relationships
|
||||
pages = list(queryset.select_related(
|
||||
'site_blueprint', 'account', 'site', 'sector'
|
||||
))
|
||||
|
||||
if not pages:
|
||||
raise ValueError("No page blueprints found")
|
||||
|
||||
return pages
|
||||
|
||||
def build_prompt(self, data: Any, account=None) -> str:
|
||||
"""Build page content generation prompt optimized for Site Builder"""
|
||||
if isinstance(data, list):
|
||||
page = data[0] if data else None
|
||||
else:
|
||||
page = data
|
||||
|
||||
if not page:
|
||||
raise ValueError("No page blueprint provided")
|
||||
|
||||
account = account or page.account
|
||||
|
||||
# Build page context
|
||||
page_context = {
|
||||
'PAGE_TITLE': page.title or page.slug.replace('-', ' ').title(),
|
||||
'PAGE_SLUG': page.slug,
|
||||
'PAGE_TYPE': page.type or 'custom',
|
||||
'SITE_NAME': page.site_blueprint.name if page.site_blueprint else '',
|
||||
'SITE_DESCRIPTION': page.site_blueprint.description or '',
|
||||
}
|
||||
|
||||
# Extract existing block structure hints
|
||||
block_hints = []
|
||||
if page.blocks_json:
|
||||
for block in page.blocks_json[:5]: # First 5 blocks as hints
|
||||
if isinstance(block, dict):
|
||||
block_type = block.get('type', '')
|
||||
heading = block.get('heading') or block.get('title') or ''
|
||||
if block_type and heading:
|
||||
block_hints.append(f"- {block_type}: {heading}")
|
||||
|
||||
if block_hints:
|
||||
page_context['EXISTING_BLOCKS'] = '\n'.join(block_hints)
|
||||
else:
|
||||
page_context['EXISTING_BLOCKS'] = 'None (new page)'
|
||||
|
||||
# Get site blueprint structure hints
|
||||
structure_hints = ''
|
||||
if page.site_blueprint and page.site_blueprint.structure_json:
|
||||
structure = page.site_blueprint.structure_json
|
||||
if isinstance(structure, dict):
|
||||
layout = structure.get('layout', 'default')
|
||||
theme = structure.get('theme', {})
|
||||
structure_hints = f"Layout: {layout}\nTheme: {json.dumps(theme, indent=2)}"
|
||||
|
||||
page_context['STRUCTURE_HINTS'] = structure_hints or 'Default layout'
|
||||
|
||||
# Get prompt from registry (site-builder specific)
|
||||
prompt = PromptRegistry.get_prompt(
|
||||
function_name='generate_page_content',
|
||||
account=account,
|
||||
context=page_context
|
||||
)
|
||||
|
||||
return prompt
|
||||
|
||||
def parse_response(self, response: str, step_tracker=None) -> Dict:
|
||||
"""Parse AI response - must be JSON with blocks structure"""
|
||||
import json
|
||||
|
||||
# Try to extract JSON from response
|
||||
try:
|
||||
# Try direct JSON parse
|
||||
parsed = json.loads(response.strip())
|
||||
except json.JSONDecodeError:
|
||||
# Try to extract JSON object from text
|
||||
try:
|
||||
# Look for JSON object in response
|
||||
start = response.find('{')
|
||||
end = response.rfind('}')
|
||||
if start != -1 and end != -1 and end > start:
|
||||
json_str = response[start:end + 1]
|
||||
parsed = json.loads(json_str)
|
||||
else:
|
||||
raise ValueError("No JSON object found in response")
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.error(f"Failed to parse page content response as JSON: {e}")
|
||||
logger.error(f"Response preview: {response[:500]}")
|
||||
raise ValueError(f"Invalid JSON response from AI: {str(e)}")
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("Response must be a JSON object")
|
||||
|
||||
# Validate required fields
|
||||
if 'blocks' not in parsed and 'blocks_json' not in parsed:
|
||||
raise ValueError("Response must include 'blocks' or 'blocks_json' field")
|
||||
|
||||
# Normalize to 'blocks' key
|
||||
if 'blocks_json' in parsed:
|
||||
parsed['blocks'] = parsed.pop('blocks_json')
|
||||
|
||||
return parsed
|
||||
|
||||
def save_output(
|
||||
self,
|
||||
parsed: Any,
|
||||
original_data: Any,
|
||||
account=None,
|
||||
progress_tracker=None,
|
||||
step_tracker=None
|
||||
) -> Dict:
|
||||
"""Save blocks to PageBlueprint and create/update Content record"""
|
||||
if isinstance(original_data, list):
|
||||
page = original_data[0] if original_data else None
|
||||
else:
|
||||
page = original_data
|
||||
|
||||
if not page:
|
||||
raise ValueError("No page blueprint provided for saving")
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("Parsed response must be a dict")
|
||||
|
||||
blocks = parsed.get('blocks', [])
|
||||
if not blocks:
|
||||
raise ValueError("No blocks found in parsed response")
|
||||
|
||||
# Ensure blocks is a list
|
||||
if not isinstance(blocks, list):
|
||||
blocks = [blocks]
|
||||
|
||||
with transaction.atomic():
|
||||
# Update PageBlueprint with generated blocks
|
||||
page.blocks_json = blocks
|
||||
page.status = 'ready' # Mark as ready after content generation
|
||||
page.save(update_fields=['blocks_json', 'status', 'updated_at'])
|
||||
|
||||
# Find or create associated Task
|
||||
task_title = f"[Site Builder] {page.title or page.slug.replace('-', ' ').title()}"
|
||||
task = Tasks.objects.filter(
|
||||
account=page.account,
|
||||
site=page.site,
|
||||
sector=page.sector,
|
||||
title=task_title
|
||||
).first()
|
||||
|
||||
# Create or update Content record with blocks
|
||||
if task:
|
||||
content_record, created = Content.objects.get_or_create(
|
||||
task=task,
|
||||
defaults={
|
||||
'account': page.account,
|
||||
'site': page.site,
|
||||
'sector': page.sector,
|
||||
'title': parsed.get('title') or page.title,
|
||||
'html_content': parsed.get('html_content', ''),
|
||||
'word_count': parsed.get('word_count', 0),
|
||||
'status': 'draft',
|
||||
'json_blocks': blocks, # Store blocks in json_blocks
|
||||
'metadata': {
|
||||
'page_id': page.id,
|
||||
'page_slug': page.slug,
|
||||
'page_type': page.type,
|
||||
'generated_by': 'generate_page_content'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if not created:
|
||||
# Update existing content
|
||||
content_record.json_blocks = blocks
|
||||
content_record.html_content = parsed.get('html_content', content_record.html_content)
|
||||
content_record.word_count = parsed.get('word_count', content_record.word_count)
|
||||
content_record.title = parsed.get('title') or content_record.title or page.title
|
||||
if not content_record.metadata:
|
||||
content_record.metadata = {}
|
||||
content_record.metadata.update({
|
||||
'page_id': page.id,
|
||||
'page_slug': page.slug,
|
||||
'page_type': page.type,
|
||||
'generated_by': 'generate_page_content'
|
||||
})
|
||||
content_record.save()
|
||||
else:
|
||||
logger.warning(f"No task found for page {page.id}, skipping Content record creation")
|
||||
content_record = None
|
||||
|
||||
logger.info(
|
||||
f"[GeneratePageContentFunction] Saved {len(blocks)} blocks to page {page.id} "
|
||||
f"(Content ID: {content_record.id if content_record else 'N/A'})"
|
||||
)
|
||||
|
||||
return {
|
||||
'count': 1,
|
||||
'pages_updated': 1,
|
||||
'blocks_count': len(blocks),
|
||||
'content_id': content_record.id if content_record else None
|
||||
}
|
||||
|
||||
214
backend/igny8_core/ai/functions/generate_site_structure.py
Normal file
214
backend/igny8_core/ai/functions/generate_site_structure.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Generate Site Structure AI Function
|
||||
Phase 3 – Site Builder
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from django.utils.text import slugify
|
||||
|
||||
from igny8_core.ai.base import BaseAIFunction
|
||||
from igny8_core.ai.prompts import PromptRegistry
|
||||
from igny8_core.business.site_building.models import SiteBlueprint, PageBlueprint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GenerateSiteStructureFunction(BaseAIFunction):
|
||||
"""AI function that turns a business brief into a full site blueprint."""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return 'generate_site_structure'
|
||||
|
||||
def get_metadata(self) -> Dict:
|
||||
metadata = super().get_metadata()
|
||||
metadata.update({
|
||||
'display_name': 'Generate Site Structure',
|
||||
'description': 'Create site/page architecture from business brief, objectives, and style guides.',
|
||||
'phases': {
|
||||
'INIT': 'Validating blueprint data…',
|
||||
'PREP': 'Preparing site context…',
|
||||
'AI_CALL': 'Generating site structure with AI…',
|
||||
'PARSE': 'Parsing generated blueprint…',
|
||||
'SAVE': 'Saving pages and blocks…',
|
||||
'DONE': 'Site structure ready!'
|
||||
}
|
||||
})
|
||||
return metadata
|
||||
|
||||
def validate(self, payload: dict, account=None) -> Dict[str, Any]:
|
||||
if not payload.get('ids'):
|
||||
return {'valid': False, 'error': 'Site blueprint ID is required'}
|
||||
return {'valid': True}
|
||||
|
||||
def prepare(self, payload: dict, account=None) -> Dict[str, Any]:
|
||||
blueprint_ids = payload.get('ids', [])
|
||||
queryset = SiteBlueprint.objects.filter(id__in=blueprint_ids)
|
||||
if account:
|
||||
queryset = queryset.filter(account=account)
|
||||
blueprint = queryset.select_related('account', 'site').prefetch_related('pages').first()
|
||||
if not blueprint:
|
||||
raise ValueError("Site blueprint not found")
|
||||
|
||||
config = blueprint.config_json or {}
|
||||
business_brief = payload.get('business_brief') or config.get('business_brief') or ''
|
||||
objectives = payload.get('objectives') or config.get('objectives') or []
|
||||
style = payload.get('style') or config.get('style') or {}
|
||||
|
||||
return {
|
||||
'blueprint': blueprint,
|
||||
'business_brief': business_brief,
|
||||
'objectives': objectives,
|
||||
'style': style,
|
||||
}
|
||||
|
||||
def build_prompt(self, data: Dict[str, Any], account=None) -> str:
|
||||
blueprint: SiteBlueprint = data['blueprint']
|
||||
objectives = data.get('objectives') or []
|
||||
objectives_text = '\n'.join(f"- {obj}" for obj in objectives) if isinstance(objectives, list) else objectives
|
||||
style = data.get('style') or {}
|
||||
style_text = json.dumps(style, indent=2) if isinstance(style, dict) and style else str(style)
|
||||
|
||||
existing_pages = [
|
||||
{
|
||||
'title': page.title,
|
||||
'slug': page.slug,
|
||||
'type': page.type,
|
||||
'status': page.status,
|
||||
}
|
||||
for page in blueprint.pages.all()
|
||||
]
|
||||
|
||||
context = {
|
||||
'BUSINESS_BRIEF': data.get('business_brief', ''),
|
||||
'OBJECTIVES': objectives_text or 'Create a full marketing site with clear navigation.',
|
||||
'STYLE': style_text or 'Modern, responsive, accessible web design.',
|
||||
'SITE_INFO': json.dumps({
|
||||
'site_name': blueprint.name,
|
||||
'site_description': blueprint.description,
|
||||
'hosting_type': blueprint.hosting_type,
|
||||
'existing_pages': existing_pages,
|
||||
'existing_structure': blueprint.structure_json or {},
|
||||
}, indent=2)
|
||||
}
|
||||
|
||||
return PromptRegistry.get_prompt(
|
||||
'generate_site_structure',
|
||||
account=account or blueprint.account,
|
||||
context=context
|
||||
)
|
||||
|
||||
def parse_response(self, response: str, step_tracker=None) -> Dict[str, Any]:
|
||||
if not response:
|
||||
raise ValueError("AI response is empty")
|
||||
|
||||
response = response.strip()
|
||||
try:
|
||||
return self._ensure_dict(json.loads(response))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Response not valid JSON, attempting to extract JSON object")
|
||||
cleaned = self._extract_json_object(response)
|
||||
if cleaned:
|
||||
return self._ensure_dict(json.loads(cleaned))
|
||||
raise ValueError("Unable to parse AI response into JSON")
|
||||
|
||||
def save_output(
|
||||
self,
|
||||
parsed: Dict[str, Any],
|
||||
original_data: Dict[str, Any],
|
||||
account=None,
|
||||
progress_tracker=None,
|
||||
step_tracker=None
|
||||
) -> Dict[str, Any]:
|
||||
blueprint: SiteBlueprint = original_data['blueprint']
|
||||
structure = self._ensure_dict(parsed)
|
||||
pages = structure.get('pages', [])
|
||||
|
||||
blueprint.structure_json = structure
|
||||
blueprint.status = 'ready'
|
||||
blueprint.save(update_fields=['structure_json', 'status', 'updated_at'])
|
||||
|
||||
created, updated, deleted = self._sync_page_blueprints(blueprint, pages)
|
||||
|
||||
message = f"Pages synced (created: {created}, updated: {updated}, deleted: {deleted})"
|
||||
logger.info("[GenerateSiteStructure] %s for blueprint %s", message, blueprint.id)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'count': created + updated,
|
||||
'site_blueprint_id': blueprint.id,
|
||||
'pages_created': created,
|
||||
'pages_updated': updated,
|
||||
'pages_deleted': deleted,
|
||||
}
|
||||
|
||||
# Helpers -----------------------------------------------------------------
|
||||
|
||||
def _ensure_dict(self, data: Any) -> Dict[str, Any]:
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
raise ValueError("AI response must be a JSON object with site metadata")
|
||||
|
||||
def _extract_json_object(self, text: str) -> str:
|
||||
start = text.find('{')
|
||||
end = text.rfind('}')
|
||||
if start != -1 and end != -1 and end > start:
|
||||
return text[start:end + 1]
|
||||
return ''
|
||||
|
||||
def _sync_page_blueprints(self, blueprint: SiteBlueprint, pages: List[Dict[str, Any]]) -> Tuple[int, int, int]:
|
||||
existing = {page.slug: page for page in blueprint.pages.all()}
|
||||
seen_slugs = set()
|
||||
created = updated = 0
|
||||
|
||||
for order, page_data in enumerate(pages or []):
|
||||
slug = page_data.get('slug') or page_data.get('id') or page_data.get('title') or f"page-{order + 1}"
|
||||
slug = slugify(slug) or f"page-{order + 1}"
|
||||
seen_slugs.add(slug)
|
||||
|
||||
defaults = {
|
||||
'title': page_data.get('title') or page_data.get('name') or slug.replace('-', ' ').title(),
|
||||
'type': self._map_page_type(page_data.get('type')),
|
||||
'blocks_json': page_data.get('blocks') or page_data.get('sections') or [],
|
||||
'status': page_data.get('status') or 'draft',
|
||||
'order': order,
|
||||
}
|
||||
|
||||
page_obj, created_flag = PageBlueprint.objects.update_or_create(
|
||||
site_blueprint=blueprint,
|
||||
slug=slug,
|
||||
defaults=defaults
|
||||
)
|
||||
if created_flag:
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
# Delete pages not present in new structure
|
||||
deleted = 0
|
||||
for slug, page in existing.items():
|
||||
if slug not in seen_slugs:
|
||||
page.delete()
|
||||
deleted += 1
|
||||
|
||||
return created, updated, deleted
|
||||
|
||||
def _map_page_type(self, page_type: Any) -> str:
|
||||
allowed = {choice[0] for choice in PageBlueprint._meta.get_field('type').choices}
|
||||
if isinstance(page_type, str):
|
||||
normalized = page_type.lower()
|
||||
if normalized in allowed:
|
||||
return normalized
|
||||
# Map friendly names
|
||||
mapping = {
|
||||
'homepage': 'home',
|
||||
'landing': 'home',
|
||||
'service': 'services',
|
||||
'product': 'products',
|
||||
}
|
||||
mapped = mapping.get(normalized)
|
||||
if mapped in allowed:
|
||||
return mapped
|
||||
return 'custom'
|
||||
|
||||
167
backend/igny8_core/ai/functions/optimize_content.py
Normal file
167
backend/igny8_core/ai/functions/optimize_content.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Optimize Content AI Function
|
||||
Phase 4 – Linker & Optimizer
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from igny8_core.ai.base import BaseAIFunction
|
||||
from igny8_core.ai.prompts import PromptRegistry
|
||||
from igny8_core.business.content.models import Content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OptimizeContentFunction(BaseAIFunction):
|
||||
"""AI function that optimizes content for SEO, readability, and engagement."""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return 'optimize_content'
|
||||
|
||||
def get_metadata(self) -> Dict:
|
||||
metadata = super().get_metadata()
|
||||
metadata.update({
|
||||
'display_name': 'Optimize Content',
|
||||
'description': 'Optimize content for SEO, readability, and engagement.',
|
||||
'phases': {
|
||||
'INIT': 'Validating content data…',
|
||||
'PREP': 'Preparing content context…',
|
||||
'AI_CALL': 'Optimizing content with AI…',
|
||||
'PARSE': 'Parsing optimized content…',
|
||||
'SAVE': 'Saving optimized content…',
|
||||
'DONE': 'Content optimized!'
|
||||
}
|
||||
})
|
||||
return metadata
|
||||
|
||||
def validate(self, payload: dict, account=None) -> Dict[str, Any]:
|
||||
if not payload.get('ids'):
|
||||
return {'valid': False, 'error': 'Content ID is required'}
|
||||
return {'valid': True}
|
||||
|
||||
def prepare(self, payload: dict, account=None) -> Dict[str, Any]:
|
||||
content_ids = payload.get('ids', [])
|
||||
queryset = Content.objects.filter(id__in=content_ids)
|
||||
if account:
|
||||
queryset = queryset.filter(account=account)
|
||||
content = queryset.select_related('account', 'site', 'sector').first()
|
||||
if not content:
|
||||
raise ValueError("Content not found")
|
||||
|
||||
# Get current scores from analyzer
|
||||
from igny8_core.business.optimization.services.analyzer import ContentAnalyzer
|
||||
analyzer = ContentAnalyzer()
|
||||
scores_before = analyzer.analyze(content)
|
||||
|
||||
return {
|
||||
'content': content,
|
||||
'scores_before': scores_before,
|
||||
'html_content': content.html_content or '',
|
||||
'meta_title': content.meta_title or '',
|
||||
'meta_description': content.meta_description or '',
|
||||
'primary_keyword': content.primary_keyword or '',
|
||||
}
|
||||
|
||||
def build_prompt(self, data: Dict[str, Any], account=None) -> str:
|
||||
content: Content = data['content']
|
||||
scores_before = data.get('scores_before', {})
|
||||
|
||||
context = {
|
||||
'CONTENT_TITLE': content.title or 'Untitled',
|
||||
'HTML_CONTENT': data.get('html_content', ''),
|
||||
'META_TITLE': data.get('meta_title', ''),
|
||||
'META_DESCRIPTION': data.get('meta_description', ''),
|
||||
'PRIMARY_KEYWORD': data.get('primary_keyword', ''),
|
||||
'WORD_COUNT': str(content.word_count or 0),
|
||||
'CURRENT_SCORES': json.dumps(scores_before, indent=2),
|
||||
'SOURCE': content.source,
|
||||
'INTERNAL_LINKS_COUNT': str(len(content.internal_links) if content.internal_links else 0),
|
||||
}
|
||||
|
||||
return PromptRegistry.get_prompt(
|
||||
'optimize_content',
|
||||
account=account or content.account,
|
||||
context=context
|
||||
)
|
||||
|
||||
def parse_response(self, response: str, step_tracker=None) -> Dict[str, Any]:
|
||||
if not response:
|
||||
raise ValueError("AI response is empty")
|
||||
|
||||
response = response.strip()
|
||||
try:
|
||||
return self._ensure_dict(json.loads(response))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Response not valid JSON, attempting to extract JSON object")
|
||||
cleaned = self._extract_json_object(response)
|
||||
if cleaned:
|
||||
return self._ensure_dict(json.loads(cleaned))
|
||||
raise ValueError("Unable to parse AI response into JSON")
|
||||
|
||||
def save_output(
|
||||
self,
|
||||
parsed: Dict[str, Any],
|
||||
original_data: Dict[str, Any],
|
||||
account=None,
|
||||
progress_tracker=None,
|
||||
step_tracker=None
|
||||
) -> Dict[str, Any]:
|
||||
content: Content = original_data['content']
|
||||
|
||||
# Extract optimized content
|
||||
optimized_html = parsed.get('html_content') or parsed.get('content') or content.html_content
|
||||
optimized_meta_title = parsed.get('meta_title') or content.meta_title
|
||||
optimized_meta_description = parsed.get('meta_description') or content.meta_description
|
||||
|
||||
# Update content
|
||||
content.html_content = optimized_html
|
||||
if optimized_meta_title:
|
||||
content.meta_title = optimized_meta_title
|
||||
if optimized_meta_description:
|
||||
content.meta_description = optimized_meta_description
|
||||
|
||||
# Recalculate word count
|
||||
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
||||
content_service = ContentGenerationService()
|
||||
content.word_count = content_service._count_words(optimized_html)
|
||||
|
||||
# Increment optimizer version
|
||||
content.optimizer_version += 1
|
||||
|
||||
# Get scores after optimization
|
||||
from igny8_core.business.optimization.services.analyzer import ContentAnalyzer
|
||||
analyzer = ContentAnalyzer()
|
||||
scores_after = analyzer.analyze(content)
|
||||
content.optimization_scores = scores_after
|
||||
|
||||
content.save(update_fields=[
|
||||
'html_content', 'meta_title', 'meta_description',
|
||||
'word_count', 'optimizer_version', 'optimization_scores', 'updated_at'
|
||||
])
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'content_id': content.id,
|
||||
'scores_before': original_data.get('scores_before', {}),
|
||||
'scores_after': scores_after,
|
||||
'word_count_before': original_data.get('word_count', 0),
|
||||
'word_count_after': content.word_count,
|
||||
'html_content': optimized_html,
|
||||
'meta_title': optimized_meta_title,
|
||||
'meta_description': optimized_meta_description,
|
||||
}
|
||||
|
||||
# Helper methods
|
||||
def _ensure_dict(self, data: Any) -> Dict[str, Any]:
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
raise ValueError("AI response must be a JSON object")
|
||||
|
||||
def _extract_json_object(self, text: str) -> str:
|
||||
start = text.find('{')
|
||||
end = text.rfind('}')
|
||||
if start != -1 and end != -1 and end > start:
|
||||
return text[start:end + 1]
|
||||
return ''
|
||||
|
||||
2
backend/igny8_core/ai/functions/tests/__init__.py
Normal file
2
backend/igny8_core/ai/functions/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# AI functions tests
|
||||
|
||||
179
backend/igny8_core/ai/functions/tests/test_optimize_content.py
Normal file
179
backend/igny8_core/ai/functions/tests/test_optimize_content.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Tests for OptimizeContentFunction
|
||||
"""
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.ai.functions.optimize_content import OptimizeContentFunction
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class OptimizeContentFunctionTests(IntegrationTestBase):
|
||||
"""Tests for OptimizeContentFunction"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.function = OptimizeContentFunction()
|
||||
|
||||
# Create test content
|
||||
self.content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test Content",
|
||||
html_content="<p>This is test content.</p>",
|
||||
meta_title="Test Title",
|
||||
meta_description="Test description",
|
||||
primary_keyword="test keyword",
|
||||
word_count=500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
def test_function_validation_phase(self):
|
||||
"""Test validation phase"""
|
||||
# Valid payload
|
||||
result = self.function.validate({'ids': [self.content.id]}, self.account)
|
||||
self.assertTrue(result['valid'])
|
||||
|
||||
# Invalid payload - missing ids
|
||||
result = self.function.validate({}, self.account)
|
||||
self.assertFalse(result['valid'])
|
||||
self.assertIn('error', result)
|
||||
|
||||
def test_function_prep_phase(self):
|
||||
"""Test prep phase"""
|
||||
payload = {'ids': [self.content.id]}
|
||||
|
||||
data = self.function.prepare(payload, self.account)
|
||||
|
||||
self.assertIn('content', data)
|
||||
self.assertIn('scores_before', data)
|
||||
self.assertIn('html_content', data)
|
||||
self.assertEqual(data['content'].id, self.content.id)
|
||||
|
||||
def test_function_prep_phase_content_not_found(self):
|
||||
"""Test prep phase with non-existent content"""
|
||||
payload = {'ids': [99999]}
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.function.prepare(payload, self.account)
|
||||
|
||||
@patch('igny8_core.ai.functions.optimize_content.PromptRegistry.get_prompt')
|
||||
def test_function_build_prompt(self, mock_get_prompt):
|
||||
"""Test prompt building"""
|
||||
mock_get_prompt.return_value = "Test prompt"
|
||||
|
||||
data = {
|
||||
'content': self.content,
|
||||
'html_content': '<p>Test</p>',
|
||||
'meta_title': 'Title',
|
||||
'meta_description': 'Description',
|
||||
'primary_keyword': 'keyword',
|
||||
'scores_before': {'overall_score': 50.0}
|
||||
}
|
||||
|
||||
prompt = self.function.build_prompt(data, self.account)
|
||||
|
||||
self.assertEqual(prompt, "Test prompt")
|
||||
mock_get_prompt.assert_called_once()
|
||||
# Check that context was passed
|
||||
call_args = mock_get_prompt.call_args
|
||||
self.assertIn('context', call_args.kwargs)
|
||||
|
||||
def test_function_parse_response_valid_json(self):
|
||||
"""Test parsing valid JSON response"""
|
||||
response = '{"html_content": "<p>Optimized</p>", "meta_title": "New Title"}'
|
||||
|
||||
parsed = self.function.parse_response(response)
|
||||
|
||||
self.assertIn('html_content', parsed)
|
||||
self.assertEqual(parsed['html_content'], "<p>Optimized</p>")
|
||||
self.assertEqual(parsed['meta_title'], "New Title")
|
||||
|
||||
def test_function_parse_response_invalid_json(self):
|
||||
"""Test parsing invalid JSON response"""
|
||||
response = "This is not JSON"
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.function.parse_response(response)
|
||||
|
||||
def test_function_parse_response_extracts_json_object(self):
|
||||
"""Test that JSON object is extracted from text"""
|
||||
response = 'Some text {"html_content": "<p>Optimized</p>"} more text'
|
||||
|
||||
parsed = self.function.parse_response(response)
|
||||
|
||||
self.assertIn('html_content', parsed)
|
||||
self.assertEqual(parsed['html_content'], "<p>Optimized</p>")
|
||||
|
||||
@patch('igny8_core.business.optimization.services.analyzer.ContentAnalyzer.analyze')
|
||||
@patch('igny8_core.business.content.services.content_generation_service.ContentGenerationService._count_words')
|
||||
def test_function_save_phase(self, mock_count_words, mock_analyze):
|
||||
"""Test save phase updates content"""
|
||||
mock_count_words.return_value = 600
|
||||
mock_analyze.return_value = {
|
||||
'seo_score': 75.0,
|
||||
'readability_score': 80.0,
|
||||
'engagement_score': 70.0,
|
||||
'overall_score': 75.0
|
||||
}
|
||||
|
||||
parsed = {
|
||||
'html_content': '<p>Optimized content.</p>',
|
||||
'meta_title': 'Optimized Title',
|
||||
'meta_description': 'Optimized Description'
|
||||
}
|
||||
|
||||
original_data = {
|
||||
'content': self.content,
|
||||
'scores_before': {'overall_score': 50.0},
|
||||
'word_count': 500
|
||||
}
|
||||
|
||||
result = self.function.save_output(parsed, original_data, self.account)
|
||||
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual(result['content_id'], self.content.id)
|
||||
|
||||
# Refresh content from DB
|
||||
self.content.refresh_from_db()
|
||||
self.assertEqual(self.content.html_content, '<p>Optimized content.</p>')
|
||||
self.assertEqual(self.content.optimizer_version, 1)
|
||||
self.assertIsNotNone(self.content.optimization_scores)
|
||||
|
||||
def test_function_handles_invalid_content_id(self):
|
||||
"""Test that function handles invalid content ID"""
|
||||
payload = {'ids': [99999]}
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.function.prepare(payload, self.account)
|
||||
|
||||
def test_function_respects_account_isolation(self):
|
||||
"""Test that function respects account isolation"""
|
||||
from igny8_core.auth.models import Account
|
||||
other_account = Account.objects.create(
|
||||
name="Other Account",
|
||||
slug="other",
|
||||
plan=self.plan,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
payload = {'ids': [self.content.id]}
|
||||
|
||||
# Should not find content from different account
|
||||
with self.assertRaises(ValueError):
|
||||
self.function.prepare(payload, other_account)
|
||||
|
||||
def test_get_name(self):
|
||||
"""Test get_name method"""
|
||||
self.assertEqual(self.function.get_name(), 'optimize_content')
|
||||
|
||||
def test_get_metadata(self):
|
||||
"""Test get_metadata method"""
|
||||
metadata = self.function.get_metadata()
|
||||
|
||||
self.assertIn('display_name', metadata)
|
||||
self.assertIn('description', metadata)
|
||||
self.assertIn('phases', metadata)
|
||||
self.assertEqual(metadata['display_name'], 'Optimize Content')
|
||||
|
||||
39
backend/igny8_core/ai/migrations/0001_initial.py
Normal file
39
backend/igny8_core/ai/migrations/0001_initial.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-20 23:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AITaskLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('task_id', models.CharField(blank=True, db_index=True, max_length=255, null=True)),
|
||||
('function_name', models.CharField(db_index=True, max_length=100)),
|
||||
('phase', models.CharField(default='INIT', max_length=50)),
|
||||
('message', models.TextField(blank=True)),
|
||||
('status', models.CharField(choices=[('success', 'Success'), ('error', 'Error'), ('pending', 'Pending')], default='pending', max_length=20)),
|
||||
('duration', models.IntegerField(blank=True, help_text='Duration in milliseconds', null=True)),
|
||||
('cost', models.DecimalField(decimal_places=6, default=0.0, max_digits=10)),
|
||||
('tokens', models.IntegerField(default=0)),
|
||||
('request_steps', models.JSONField(blank=True, default=list)),
|
||||
('response_steps', models.JSONField(blank=True, default=list)),
|
||||
('error', models.TextField(blank=True, null=True)),
|
||||
('payload', models.JSONField(blank=True, null=True)),
|
||||
('result', models.JSONField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'igny8_ai_task_logs',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
34
backend/igny8_core/ai/migrations/0002_initial.py
Normal file
34
backend/igny8_core/ai/migrations/0002_initial.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-20 23:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('ai', '0001_initial'),
|
||||
('igny8_core_auth', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aitasklog',
|
||||
name='account',
|
||||
field=models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='aitasklog',
|
||||
index=models.Index(fields=['task_id'], name='igny8_ai_ta_task_id_310356_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='aitasklog',
|
||||
index=models.Index(fields=['function_name', 'account'], name='igny8_ai_ta_functio_0e5a30_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='aitasklog',
|
||||
index=models.Index(fields=['status', 'created_at'], name='igny8_ai_ta_status_ed93b5_idx'),
|
||||
),
|
||||
]
|
||||
@@ -123,17 +123,17 @@ Output JSON Example:
|
||||
"introduction": {
|
||||
"hook": "Transform your sleep with organic cotton that blends comfort and sustainability.",
|
||||
"paragraphs": [
|
||||
{"content_type": "paragraph", "details": "Overview of organic cotton's rise in bedding industry."},
|
||||
{"content_type": "paragraph", "details": "Why consumers prefer organic bedding over synthetic alternatives."}
|
||||
{"format": "paragraph", "details": "Overview of organic cotton's rise in bedding industry."},
|
||||
{"format": "paragraph", "details": "Why consumers prefer organic bedding over synthetic alternatives."}
|
||||
]
|
||||
},
|
||||
"H2": [
|
||||
{
|
||||
"heading": "Why Choose Organic Cotton for Bedding?",
|
||||
"subsections": [
|
||||
{"subheading": "Health and Skin Benefits", "content_type": "paragraph", "details": "Discuss hypoallergenic and chemical-free aspects."},
|
||||
{"subheading": "Environmental Sustainability", "content_type": "list", "details": "Eco benefits like low water use, no pesticides."},
|
||||
{"subheading": "Long-Term Cost Savings", "content_type": "table", "details": "Compare durability and pricing over time."}
|
||||
{"subheading": "Health and Skin Benefits", "format": "paragraph", "details": "Discuss hypoallergenic and chemical-free aspects."},
|
||||
{"subheading": "Environmental Sustainability", "format": "list", "details": "Eco benefits like low water use, no pesticides."},
|
||||
{"subheading": "Long-Term Cost Savings", "format": "table", "details": "Compare durability and pricing over time."}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -145,39 +145,25 @@ Output JSON Example:
|
||||
"covered_keywords": "organic duvet covers, eco-friendly bedding, sustainable sheets"
|
||||
}
|
||||
]
|
||||
}""",
|
||||
|
||||
'content_generation': """You are an editorial content strategist. Your task is to generate a complete JSON response object that includes all the fields listed below, based on the provided content idea, keyword cluster, and keyword list.
|
||||
}
|
||||
|
||||
Only the `content` field should contain HTML inside JSON object.
|
||||
Valid content_type values: post, page, product, taxonomy
|
||||
|
||||
Valid content_structure by 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""",
|
||||
|
||||
'content_generation': """You are an editorial content strategist. Your task is to generate a complete JSON response object based on the provided content idea, keyword cluster, keyword list, and metadata context.
|
||||
|
||||
==================
|
||||
Generate a complete JSON response object matching this structure:
|
||||
==================
|
||||
|
||||
{
|
||||
"title": "[Blog title using the primary keyword — full sentence case]",
|
||||
"meta_title": "[Meta title under 60 characters — natural, optimized, and compelling]",
|
||||
"meta_description": "[Meta description under 160 characters — clear and enticing summary]",
|
||||
"content": "[HTML content — full editorial structure with <p>, <h2>, <h3>, <ul>, <ol>, <table>]",
|
||||
"word_count": [Exact integer — word count of HTML body only],
|
||||
"primary_keyword": "[Single primary keyword used in title and first paragraph]",
|
||||
"secondary_keywords": [
|
||||
"[Keyword 1]",
|
||||
"[Keyword 2]",
|
||||
"[Keyword 3]"
|
||||
],
|
||||
"tags": [
|
||||
"[2–4 word lowercase tag 1]",
|
||||
"[2–4 word lowercase tag 2]",
|
||||
"[2–4 word lowercase tag 3]",
|
||||
"[2–4 word lowercase tag 4]",
|
||||
"[2–4 word lowercase tag 5]"
|
||||
],
|
||||
"categories": [
|
||||
"[Parent Category > Child Category]",
|
||||
"[Optional Second Category > Optional Subcategory]"
|
||||
]
|
||||
"title": "[Article title using target keywords — full sentence case]",
|
||||
"content": "[HTML content — full editorial structure with <p>, <h2>, <h3>, <ul>, <ol>, <table>]"
|
||||
}
|
||||
|
||||
===========================
|
||||
@@ -201,15 +187,12 @@ Each section should be 250–300 words and follow this format:
|
||||
- Never begin any section or sub-section with a list or table
|
||||
|
||||
===========================
|
||||
KEYWORD & SEO RULES
|
||||
STYLE & QUALITY RULES
|
||||
===========================
|
||||
|
||||
- **Primary keyword** must appear in:
|
||||
- The title
|
||||
- First paragraph of the introduction
|
||||
- At least 2 H2 headings
|
||||
|
||||
- **Secondary keywords** must be used naturally, not forced
|
||||
- **Keyword Usage:**
|
||||
- Use keywords naturally in title, introduction, and headings
|
||||
- Prioritize readability over keyword density
|
||||
|
||||
- **Tone & style guidelines:**
|
||||
- No robotic or passive voice
|
||||
@@ -217,7 +200,28 @@ KEYWORD & SEO RULES
|
||||
- Don't repeat heading in opening sentence
|
||||
- Vary sentence structure and length
|
||||
|
||||
===========================
|
||||
STAGE 3: METADATA CONTEXT (NEW)
|
||||
===========================
|
||||
|
||||
**Content Structure:**
|
||||
[IGNY8_CONTENT_STRUCTURE]
|
||||
- If structure is "cluster_hub": Create comprehensive, authoritative content that serves as the main resource for this topic cluster. Include overview sections, key concepts, and links to related topics.
|
||||
- If structure is "article" or "guide": Create detailed, focused content that dives deep into the topic with actionable insights.
|
||||
- Other structures: Follow the appropriate format (listicle, comparison, review, landing_page, service_page, product_page, category_archive, tag_archive, attribute_archive).
|
||||
|
||||
**Taxonomy Context:**
|
||||
[IGNY8_TAXONOMY]
|
||||
- Use taxonomy information to structure categories and tags appropriately.
|
||||
- Align content with taxonomy hierarchy and relationships.
|
||||
- Ensure content fits within the defined taxonomy structure.
|
||||
|
||||
**Product/Service Attributes:**
|
||||
[IGNY8_ATTRIBUTES]
|
||||
- If attributes are provided (e.g., product specs, service modifiers), incorporate them naturally into the content.
|
||||
- For product content: Include specifications, features, dimensions, materials, etc. as relevant.
|
||||
- For service content: Include service tiers, pricing modifiers, availability, etc. as relevant.
|
||||
- Present attributes in a user-friendly format (tables, lists, or integrated into narrative).
|
||||
|
||||
===========================
|
||||
INPUT VARIABLES
|
||||
@@ -238,6 +242,73 @@ OUTPUT FORMAT
|
||||
|
||||
Return ONLY the final JSON object.
|
||||
Do NOT include any comments, formatting, or explanations.""",
|
||||
|
||||
'site_structure_generation': """You are a senior UX architect and information designer. Use the business brief, objectives, style references, and existing site info to propose a complete multi-page marketing website structure.
|
||||
|
||||
INPUT CONTEXT
|
||||
==============
|
||||
BUSINESS BRIEF:
|
||||
[IGNY8_BUSINESS_BRIEF]
|
||||
|
||||
PRIMARY OBJECTIVES:
|
||||
[IGNY8_OBJECTIVES]
|
||||
|
||||
STYLE & BRAND NOTES:
|
||||
[IGNY8_STYLE]
|
||||
|
||||
SITE INFO / CURRENT STRUCTURE:
|
||||
[IGNY8_SITE_INFO]
|
||||
|
||||
OUTPUT REQUIREMENTS
|
||||
====================
|
||||
Return ONE JSON object with the following keys:
|
||||
|
||||
{
|
||||
"site": {
|
||||
"name": "...",
|
||||
"primary_navigation": ["home", "services", "about", "contact"],
|
||||
"secondary_navigation": ["blog", "faq"],
|
||||
"hero_message": "High level value statement",
|
||||
"tone": "voice + tone summary"
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"slug": "home",
|
||||
"title": "Home",
|
||||
"type": "home | about | services | products | blog | contact | custom",
|
||||
"status": "draft",
|
||||
"objective": "Explain the core brand promise and primary CTA",
|
||||
"primary_cta": "Book a strategy call",
|
||||
"seo": {
|
||||
"meta_title": "...",
|
||||
"meta_description": "..."
|
||||
},
|
||||
"blocks": [
|
||||
{
|
||||
"type": "hero | features | services | stats | testimonials | faq | contact | custom",
|
||||
"heading": "Section headline",
|
||||
"subheading": "Support copy",
|
||||
"layout": "full-width | two-column | cards | carousel",
|
||||
"content": [
|
||||
"Bullet or short paragraph describing what to render in this block"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
RULES
|
||||
=====
|
||||
- Include 5–8 pages covering the complete buyer journey (awareness → evaluation → conversion → trust).
|
||||
- Every page must have at least 3 blocks with concrete guidance (no placeholders like "Lorem ipsum").
|
||||
- Use consistent slug naming, all lowercase with hyphens.
|
||||
- Type must match the allowed enum and reflect page intent.
|
||||
- Ensure the navigation arrays align with the page list.
|
||||
- Focus on practical descriptions that an engineering team can hand off directly to the Site Builder.
|
||||
|
||||
Return ONLY valid JSON. No commentary, explanations, or Markdown.
|
||||
""",
|
||||
|
||||
'image_prompt_extraction': """Extract image prompts from the following article content.
|
||||
|
||||
@@ -265,6 +336,423 @@ Make sure each prompt is detailed enough for image generation, describing the vi
|
||||
'image_prompt_template': 'Create a high-quality {image_type} image to use as a featured photo for a blog post titled "{post_title}". The image should visually represent the theme, mood, and subject implied by the image prompt: {image_prompt}. Focus on a realistic, well-composed scene that naturally communicates the topic without text or logos. Use balanced lighting, pleasing composition, and photographic detail suitable for lifestyle or editorial web content. Avoid adding any visible or readable text, brand names, or illustrative effects. **And make sure image is not blurry.**',
|
||||
|
||||
'negative_prompt': 'text, watermark, logo, overlay, title, caption, writing on walls, writing on objects, UI, infographic elements, post title',
|
||||
|
||||
'optimize_content': """You are an expert content optimizer specializing in SEO, readability, and engagement.
|
||||
|
||||
Your task is to optimize the provided content to improve its SEO score, readability, and engagement metrics.
|
||||
|
||||
CURRENT CONTENT:
|
||||
Title: {CONTENT_TITLE}
|
||||
Word Count: {WORD_COUNT}
|
||||
Source: {SOURCE}
|
||||
Primary Keyword: {PRIMARY_KEYWORD}
|
||||
Internal Links: {INTERNAL_LINKS_COUNT}
|
||||
|
||||
CURRENT META DATA:
|
||||
Meta Title: {META_TITLE}
|
||||
Meta Description: {META_DESCRIPTION}
|
||||
|
||||
CURRENT SCORES:
|
||||
{CURRENT_SCORES}
|
||||
|
||||
HTML CONTENT:
|
||||
{HTML_CONTENT}
|
||||
|
||||
OPTIMIZATION REQUIREMENTS:
|
||||
|
||||
1. SEO Optimization:
|
||||
- Ensure meta title is 30-60 characters (if provided)
|
||||
- Ensure meta description is 120-160 characters (if provided)
|
||||
- Optimize primary keyword usage (natural, not keyword stuffing)
|
||||
- Improve heading structure (H1, H2, H3 hierarchy)
|
||||
- Add internal links where relevant (maintain existing links)
|
||||
|
||||
2. Readability:
|
||||
- Average sentence length: 15-20 words
|
||||
- Use clear, concise language
|
||||
- Break up long paragraphs
|
||||
- Use bullet points and lists where appropriate
|
||||
- Ensure proper paragraph structure
|
||||
|
||||
3. Engagement:
|
||||
- Add compelling headings
|
||||
- Include relevant images placeholders (alt text)
|
||||
- Use engaging language
|
||||
- Create clear call-to-action sections
|
||||
- Improve content flow and structure
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Return ONLY a JSON object in this format:
|
||||
{{
|
||||
"html_content": "[Optimized HTML content]",
|
||||
"meta_title": "[Optimized meta title, 30-60 chars]",
|
||||
"meta_description": "[Optimized meta description, 120-160 chars]",
|
||||
"optimization_notes": "[Brief notes on what was optimized]"
|
||||
}}
|
||||
|
||||
Do not include any explanations, text, or commentary outside the JSON output.
|
||||
""",
|
||||
|
||||
# Phase 8: Universal Content Types
|
||||
'product_generation': """You are a product content specialist. Generate comprehensive product content that includes detailed descriptions, features, specifications, pricing, and benefits.
|
||||
|
||||
INPUT:
|
||||
Product Name: [IGNY8_PRODUCT_NAME]
|
||||
Product Description: [IGNY8_PRODUCT_DESCRIPTION]
|
||||
Product Features: [IGNY8_PRODUCT_FEATURES]
|
||||
Target Audience: [IGNY8_TARGET_AUDIENCE]
|
||||
Primary Keyword: [IGNY8_PRIMARY_KEYWORD]
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Return ONLY a JSON object in this format:
|
||||
{
|
||||
"title": "[Product name and key benefit]",
|
||||
"meta_title": "[SEO-optimized meta title, 30-60 chars]",
|
||||
"meta_description": "[Compelling meta description, 120-160 chars]",
|
||||
"html_content": "[Complete HTML product page content]",
|
||||
"word_count": [Integer word count],
|
||||
"primary_keyword": "[Primary keyword]",
|
||||
"secondary_keywords": ["keyword1", "keyword2", "keyword3"],
|
||||
"tags": ["tag1", "tag2", "tag3"],
|
||||
"categories": ["Category > Subcategory"],
|
||||
"json_blocks": [
|
||||
{
|
||||
"type": "product_overview",
|
||||
"heading": "Product Overview",
|
||||
"content": "Detailed product description"
|
||||
},
|
||||
{
|
||||
"type": "features",
|
||||
"heading": "Key Features",
|
||||
"items": ["Feature 1", "Feature 2", "Feature 3"]
|
||||
},
|
||||
{
|
||||
"type": "specifications",
|
||||
"heading": "Specifications",
|
||||
"data": {"Spec 1": "Value 1", "Spec 2": "Value 2"}
|
||||
},
|
||||
{
|
||||
"type": "pricing",
|
||||
"heading": "Pricing",
|
||||
"content": "Pricing information"
|
||||
},
|
||||
{
|
||||
"type": "benefits",
|
||||
"heading": "Benefits",
|
||||
"items": ["Benefit 1", "Benefit 2", "Benefit 3"]
|
||||
}
|
||||
],
|
||||
"structure_data": {
|
||||
"product_type": "[Product type]",
|
||||
"price_range": "[Price range]",
|
||||
"target_market": "[Target market]"
|
||||
}
|
||||
}
|
||||
|
||||
CONTENT REQUIREMENTS:
|
||||
- Include compelling product overview
|
||||
- List key features with benefits
|
||||
- Provide detailed specifications
|
||||
- Include pricing information (if available)
|
||||
- Highlight unique selling points
|
||||
- Use SEO-optimized headings
|
||||
- Include call-to-action sections
|
||||
- Ensure natural keyword usage
|
||||
""",
|
||||
|
||||
'service_generation': """You are a service page content specialist. Generate comprehensive service page content that explains services, benefits, process, and pricing.
|
||||
|
||||
INPUT:
|
||||
Service Name: [IGNY8_SERVICE_NAME]
|
||||
Service Description: [IGNY8_SERVICE_DESCRIPTION]
|
||||
Service Benefits: [IGNY8_SERVICE_BENEFITS]
|
||||
Target Audience: [IGNY8_TARGET_AUDIENCE]
|
||||
Primary Keyword: [IGNY8_PRIMARY_KEYWORD]
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Return ONLY a JSON object in this format:
|
||||
{
|
||||
"title": "[Service name and value proposition]",
|
||||
"meta_title": "[SEO-optimized meta title, 30-60 chars]",
|
||||
"meta_description": "[Compelling meta description, 120-160 chars]",
|
||||
"html_content": "[Complete HTML service page content]",
|
||||
"word_count": [Integer word count],
|
||||
"primary_keyword": "[Primary keyword]",
|
||||
"secondary_keywords": ["keyword1", "keyword2", "keyword3"],
|
||||
"tags": ["tag1", "tag2", "tag3"],
|
||||
"categories": ["Category > Subcategory"],
|
||||
"json_blocks": [
|
||||
{
|
||||
"type": "service_overview",
|
||||
"heading": "Service Overview",
|
||||
"content": "Detailed service description"
|
||||
},
|
||||
{
|
||||
"type": "benefits",
|
||||
"heading": "Benefits",
|
||||
"items": ["Benefit 1", "Benefit 2", "Benefit 3"]
|
||||
},
|
||||
{
|
||||
"type": "process",
|
||||
"heading": "Our Process",
|
||||
"steps": ["Step 1", "Step 2", "Step 3"]
|
||||
},
|
||||
{
|
||||
"type": "pricing",
|
||||
"heading": "Pricing",
|
||||
"content": "Pricing information"
|
||||
},
|
||||
{
|
||||
"type": "faq",
|
||||
"heading": "Frequently Asked Questions",
|
||||
"items": [{"question": "Q1", "answer": "A1"}]
|
||||
}
|
||||
],
|
||||
"structure_data": {
|
||||
"service_type": "[Service type]",
|
||||
"duration": "[Service duration]",
|
||||
"target_market": "[Target market]"
|
||||
}
|
||||
}
|
||||
|
||||
CONTENT REQUIREMENTS:
|
||||
- Clear service overview and value proposition
|
||||
- Detailed benefits and outcomes
|
||||
- Step-by-step process explanation
|
||||
- Pricing information (if available)
|
||||
- FAQ section addressing common questions
|
||||
- Include testimonials or case studies (if applicable)
|
||||
- Use SEO-optimized headings
|
||||
- Include call-to-action sections
|
||||
""",
|
||||
|
||||
'generate_page_content': """You are a Site Builder content specialist. Generate structured page content optimized for website pages with JSON blocks format.
|
||||
|
||||
Your task is to generate content that will be rendered as a modern website page with structured blocks (hero, features, testimonials, text, CTA, etc.).
|
||||
|
||||
INPUT DATA:
|
||||
----------
|
||||
Page Title: [IGNY8_PAGE_TITLE]
|
||||
Page Slug: [IGNY8_PAGE_SLUG]
|
||||
Page Type: [IGNY8_PAGE_TYPE] (home, products, blog, contact, about, services, custom)
|
||||
Site Name: [IGNY8_SITE_NAME]
|
||||
Site Description: [IGNY8_SITE_DESCRIPTION]
|
||||
Existing Block Hints: [IGNY8_EXISTING_BLOCKS]
|
||||
Structure Hints: [IGNY8_STRUCTURE_HINTS]
|
||||
|
||||
OUTPUT FORMAT:
|
||||
--------------
|
||||
Return ONLY a JSON object in this exact structure:
|
||||
|
||||
{{
|
||||
"title": "[Page title - SEO optimized, natural]",
|
||||
"html_content": "[Full HTML content for fallback/SEO - complete article]",
|
||||
"word_count": [Integer - word count of HTML content],
|
||||
"blocks": [
|
||||
{{
|
||||
"type": "hero",
|
||||
"data": {{
|
||||
"heading": "[Compelling hero headline]",
|
||||
"subheading": "[Supporting subheadline]",
|
||||
"content": "[Brief hero description - 1-2 sentences]",
|
||||
"buttonText": "[CTA button text]",
|
||||
"buttonLink": "[CTA link URL]"
|
||||
}}
|
||||
}},
|
||||
{{
|
||||
"type": "text",
|
||||
"data": {{
|
||||
"heading": "[Section heading]",
|
||||
"content": "[Rich text content with paragraphs, lists, etc.]"
|
||||
}}
|
||||
}},
|
||||
{{
|
||||
"type": "features",
|
||||
"data": {{
|
||||
"heading": "[Features section heading]",
|
||||
"content": [
|
||||
"[Feature 1: Description]",
|
||||
"[Feature 2: Description]",
|
||||
"[Feature 3: Description]"
|
||||
]
|
||||
}}
|
||||
}},
|
||||
{{
|
||||
"type": "testimonials",
|
||||
"data": {{
|
||||
"heading": "[Testimonials heading]",
|
||||
"subheading": "[Optional subheading]",
|
||||
"content": [
|
||||
"[Testimonial quote 1]",
|
||||
"[Testimonial quote 2]",
|
||||
"[Testimonial quote 3]"
|
||||
]
|
||||
}}
|
||||
}},
|
||||
{{
|
||||
"type": "cta",
|
||||
"data": {{
|
||||
"heading": "[CTA heading]",
|
||||
"subheading": "[CTA subheading]",
|
||||
"content": "[CTA description]",
|
||||
"buttonText": "[Button text]",
|
||||
"buttonLink": "[Button link]"
|
||||
}}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
BLOCK TYPE GUIDELINES:
|
||||
----------------------
|
||||
Based on page type, use appropriate blocks:
|
||||
|
||||
**Home Page:**
|
||||
- Start with "hero" block (compelling headline + CTA)
|
||||
- Follow with "features" or "text" blocks
|
||||
- Include "testimonials" block
|
||||
- End with "cta" block
|
||||
|
||||
**Products Page:**
|
||||
- Start with "text" block (product overview)
|
||||
- Use "features" or "grid" blocks for product listings
|
||||
- Include "text" blocks for product details
|
||||
|
||||
**Blog Page:**
|
||||
- Use "text" blocks for article content
|
||||
- Can include "quote" blocks for highlights
|
||||
- Structure as readable article format
|
||||
|
||||
**Contact Page:**
|
||||
- Start with "text" block (contact info)
|
||||
- Use "form" block structure hints
|
||||
- Include "text" blocks for location/hours
|
||||
|
||||
**About Page:**
|
||||
- Start with "hero" or "text" block
|
||||
- Use "features" for team/values
|
||||
- Include "stats" block if applicable
|
||||
- End with "text" block
|
||||
|
||||
**Services Page:**
|
||||
- Start with "text" block (service overview)
|
||||
- Use "features" for service offerings
|
||||
- Include "text" blocks for details
|
||||
|
||||
CONTENT REQUIREMENTS:
|
||||
---------------------
|
||||
1. **Hero Block** (for home/about pages):
|
||||
- Compelling headline (8-12 words)
|
||||
- Clear value proposition
|
||||
- Strong CTA button
|
||||
|
||||
2. **Text Blocks**:
|
||||
- Natural, engaging copy
|
||||
- SEO-optimized headings
|
||||
- Varied content (paragraphs, lists, emphasis)
|
||||
|
||||
3. **Features Blocks**:
|
||||
- 3-6 features
|
||||
- Clear benefit statements
|
||||
- Action-oriented language
|
||||
|
||||
4. **Testimonials Blocks**:
|
||||
- 3-5 authentic-sounding testimonials
|
||||
- Specific, believable quotes
|
||||
- Varied lengths
|
||||
|
||||
5. **CTA Blocks**:
|
||||
- Clear value proposition
|
||||
- Strong action words
|
||||
- Compelling button text
|
||||
|
||||
6. **HTML Content** (for SEO):
|
||||
- Complete article version of all blocks
|
||||
- Proper HTML structure
|
||||
- SEO-optimized with headings, paragraphs, lists
|
||||
- 800-1500 words total
|
||||
|
||||
TONE & STYLE:
|
||||
-------------
|
||||
- Professional but approachable
|
||||
- Clear and concise
|
||||
- Benefit-focused
|
||||
- Action-oriented
|
||||
- Natural keyword usage (not forced)
|
||||
- No generic phrases or placeholder text
|
||||
|
||||
IMPORTANT:
|
||||
----------
|
||||
- Return ONLY the JSON object
|
||||
- Do NOT include markdown formatting
|
||||
- Do NOT include explanations or comments
|
||||
- Ensure all blocks have proper "type" and "data" structure
|
||||
- HTML content should be complete and standalone
|
||||
- Blocks should be optimized for the specific page type""",
|
||||
|
||||
'taxonomy_generation': """You are a taxonomy and categorization specialist. Generate comprehensive taxonomy page content that organizes and explains categories, tags, and hierarchical structures.
|
||||
|
||||
INPUT:
|
||||
Taxonomy Name: [IGNY8_TAXONOMY_NAME]
|
||||
Taxonomy Description: [IGNY8_TAXONOMY_DESCRIPTION]
|
||||
Taxonomy Items: [IGNY8_TAXONOMY_ITEMS]
|
||||
Primary Keyword: [IGNY8_PRIMARY_KEYWORD]
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Return ONLY a JSON object in this format:
|
||||
{{
|
||||
"title": "[Taxonomy name and purpose]",
|
||||
"meta_title": "[SEO-optimized meta title, 30-60 chars]",
|
||||
"meta_description": "[Compelling meta description, 120-160 chars]",
|
||||
"html_content": "[Complete HTML taxonomy page content]",
|
||||
"word_count": [Integer word count],
|
||||
"primary_keyword": "[Primary keyword]",
|
||||
"secondary_keywords": ["keyword1", "keyword2", "keyword3"],
|
||||
"tags": ["tag1", "tag2", "tag3"],
|
||||
"categories": ["Category > Subcategory"],
|
||||
"json_blocks": [
|
||||
{{
|
||||
"type": "taxonomy_overview",
|
||||
"heading": "Taxonomy Overview",
|
||||
"content": "Detailed taxonomy description"
|
||||
}},
|
||||
{{
|
||||
"type": "categories",
|
||||
"heading": "Categories",
|
||||
"items": [
|
||||
{{
|
||||
"name": "Category 1",
|
||||
"description": "Category description",
|
||||
"subcategories": ["Subcat 1", "Subcat 2"]
|
||||
}}
|
||||
]
|
||||
}},
|
||||
{{
|
||||
"type": "tags",
|
||||
"heading": "Tags",
|
||||
"items": ["Tag 1", "Tag 2", "Tag 3"]
|
||||
}},
|
||||
{{
|
||||
"type": "hierarchy",
|
||||
"heading": "Taxonomy Hierarchy",
|
||||
"structure": {{"Level 1": {{"Level 2": ["Level 3"]}}}}
|
||||
}}
|
||||
],
|
||||
"structure_data": {{
|
||||
"taxonomy_type": "[Taxonomy type]",
|
||||
"item_count": [Integer],
|
||||
"hierarchy_levels": [Integer]
|
||||
}}
|
||||
}}
|
||||
|
||||
CONTENT REQUIREMENTS:
|
||||
- Clear taxonomy overview and purpose
|
||||
- Organized category structure
|
||||
- Tag organization and relationships
|
||||
- Hierarchical structure visualization
|
||||
- SEO-optimized headings
|
||||
- Include navigation and organization benefits
|
||||
- Use clear, descriptive language
|
||||
""",
|
||||
}
|
||||
|
||||
# Mapping from function names to prompt types
|
||||
@@ -275,6 +763,13 @@ Make sure each prompt is detailed enough for image generation, describing the vi
|
||||
'generate_images': 'image_prompt_extraction',
|
||||
'extract_image_prompts': 'image_prompt_extraction',
|
||||
'generate_image_prompts': 'image_prompt_extraction',
|
||||
'generate_site_structure': 'site_structure_generation',
|
||||
'generate_page_content': 'generate_page_content', # Site Builder specific
|
||||
'optimize_content': 'optimize_content',
|
||||
# Phase 8: Universal Content Types
|
||||
'generate_product_content': 'product_generation',
|
||||
'generate_service_page': 'service_generation',
|
||||
'generate_taxonomy': 'taxonomy_generation',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -370,7 +865,7 @@ Make sure each prompt is detailed enough for image generation, describing the vi
|
||||
if '{' in rendered and '}' in rendered:
|
||||
try:
|
||||
rendered = rendered.format(**normalized_context)
|
||||
except (KeyError, ValueError) as e:
|
||||
except (KeyError, ValueError, IndexError) as e:
|
||||
# If .format() fails, log warning but keep the [IGNY8_*] replacements
|
||||
logger.warning(f"Failed to format prompt with .format(): {e}. Using [IGNY8_*] replacements only.")
|
||||
|
||||
|
||||
@@ -94,9 +94,21 @@ def _load_generate_image_prompts():
|
||||
from igny8_core.ai.functions.generate_image_prompts import GenerateImagePromptsFunction
|
||||
return GenerateImagePromptsFunction
|
||||
|
||||
def _load_generate_site_structure():
|
||||
"""Lazy loader for generate_site_structure function"""
|
||||
from igny8_core.ai.functions.generate_site_structure import GenerateSiteStructureFunction
|
||||
return GenerateSiteStructureFunction
|
||||
|
||||
def _load_optimize_content():
|
||||
"""Lazy loader for optimize_content function"""
|
||||
from igny8_core.ai.functions.optimize_content import OptimizeContentFunction
|
||||
return OptimizeContentFunction
|
||||
|
||||
register_lazy_function('auto_cluster', _load_auto_cluster)
|
||||
register_lazy_function('generate_ideas', _load_generate_ideas)
|
||||
register_lazy_function('generate_content', _load_generate_content)
|
||||
register_lazy_function('generate_images', _load_generate_images)
|
||||
register_lazy_function('generate_image_prompts', _load_generate_image_prompts)
|
||||
register_lazy_function('generate_site_structure', _load_generate_site_structure)
|
||||
register_lazy_function('optimize_content', _load_optimize_content)
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from igny8_core.ai.functions.generate_site_structure import GenerateSiteStructureFunction
|
||||
from igny8_core.business.site_building.models import PageBlueprint
|
||||
from igny8_core.business.site_building.tests.base import SiteBuilderTestBase
|
||||
|
||||
|
||||
class GenerateSiteStructureFunctionTests(SiteBuilderTestBase):
|
||||
"""Covers parsing + persistence logic for the Site Builder AI function."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.function = GenerateSiteStructureFunction()
|
||||
|
||||
def test_parse_response_extracts_json_object(self):
|
||||
noisy_response = """
|
||||
Thoughts about the request…
|
||||
{
|
||||
"site": {"name": "Acme Robotics"},
|
||||
"pages": [{"slug": "home", "title": "Home"}]
|
||||
}
|
||||
Extra commentary that should be ignored.
|
||||
"""
|
||||
parsed = self.function.parse_response(noisy_response)
|
||||
self.assertEqual(parsed['site']['name'], 'Acme Robotics')
|
||||
self.assertEqual(parsed['pages'][0]['slug'], 'home')
|
||||
|
||||
def test_save_output_updates_structure_and_syncs_pages(self):
|
||||
# Existing page to prove update/delete flows.
|
||||
legacy_page = PageBlueprint.objects.create(
|
||||
site_blueprint=self.blueprint,
|
||||
slug='legacy',
|
||||
title='Legacy Page',
|
||||
type='custom',
|
||||
blocks_json=[],
|
||||
order=5,
|
||||
)
|
||||
|
||||
parsed = {
|
||||
'site': {'name': 'Future Robotics'},
|
||||
'pages': [
|
||||
{
|
||||
'slug': 'home',
|
||||
'title': 'Homepage',
|
||||
'type': 'home',
|
||||
'status': 'ready',
|
||||
'blocks': [{'type': 'hero', 'heading': 'Build faster'}],
|
||||
},
|
||||
{
|
||||
'slug': 'about',
|
||||
'title': 'About Us',
|
||||
'type': 'about',
|
||||
'blocks': [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = self.function.save_output(parsed, {'blueprint': self.blueprint})
|
||||
|
||||
self.blueprint.refresh_from_db()
|
||||
self.assertEqual(self.blueprint.status, 'ready')
|
||||
self.assertEqual(self.blueprint.structure_json['site']['name'], 'Future Robotics')
|
||||
self.assertEqual(result['pages_created'], 1)
|
||||
self.assertEqual(result['pages_updated'], 1)
|
||||
self.assertEqual(result['pages_deleted'], 1)
|
||||
|
||||
slugs = set(self.blueprint.pages.values_list('slug', flat=True))
|
||||
self.assertIn('home', slugs)
|
||||
self.assertIn('about', slugs)
|
||||
self.assertNotIn(legacy_page.slug, slugs)
|
||||
|
||||
def test_build_prompt_includes_existing_pages(self):
|
||||
# Convert structure to JSON to ensure template rendering stays stable.
|
||||
data = self.function.prepare(
|
||||
payload={'ids': [self.blueprint.id]},
|
||||
account=self.account,
|
||||
)
|
||||
prompt = self.function.build_prompt(data, account=self.account)
|
||||
self.assertIn(self.blueprint.name, prompt)
|
||||
self.assertIn('Home', prompt)
|
||||
# The prompt should mention hosting type and objectives in JSON context.
|
||||
self.assertIn(self.blueprint.hosting_type, prompt)
|
||||
for objective in self.blueprint.config_json.get('objectives', []):
|
||||
self.assertIn(objective, prompt)
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""
|
||||
Test script for AI functions
|
||||
Run this to verify all AI functions work with console logging
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Setup Django
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../../../'))
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8.settings')
|
||||
django.setup()
|
||||
|
||||
from igny8_core.ai.functions.auto_cluster import AutoClusterFunction
|
||||
from igny8_core.ai.functions.generate_images import generate_images_core
|
||||
from igny8_core.ai.ai_core import AICore
|
||||
|
||||
|
||||
def test_ai_core():
|
||||
"""Test AICore.run_ai_request() directly"""
|
||||
print("\n" + "="*80)
|
||||
print("TEST 1: AICore.run_ai_request() - Direct API Call")
|
||||
print("="*80)
|
||||
|
||||
ai_core = AICore()
|
||||
result = ai_core.run_ai_request(
|
||||
prompt="Say 'Hello, World!' in JSON format: {\"message\": \"your message\"}",
|
||||
max_tokens=100,
|
||||
function_name='test_ai_core'
|
||||
)
|
||||
|
||||
if result.get('error'):
|
||||
print(f"❌ Error: {result['error']}")
|
||||
else:
|
||||
print(f"✅ Success! Content: {result.get('content', '')[:100]}")
|
||||
print(f" Tokens: {result.get('total_tokens')}, Cost: ${result.get('cost', 0):.6f}")
|
||||
|
||||
|
||||
def test_auto_cluster():
|
||||
"""Test auto cluster function"""
|
||||
print("\n" + "="*80)
|
||||
print("TEST 2: Auto Cluster Function")
|
||||
print("="*80)
|
||||
print("Note: This requires actual keyword IDs in the database")
|
||||
print("Skipping - requires database setup")
|
||||
# Uncomment to test with real data:
|
||||
# fn = AutoClusterFunction()
|
||||
# result = fn.validate({'ids': [1, 2, 3]})
|
||||
# print(f"Validation result: {result}")
|
||||
|
||||
|
||||
def test_generate_content():
|
||||
"""Test generate content function"""
|
||||
print("\n" + "="*80)
|
||||
print("TEST 3: Generate Content Function")
|
||||
print("="*80)
|
||||
print("Note: This requires actual task IDs in the database")
|
||||
print("Skipping - requires database setup")
|
||||
|
||||
|
||||
def test_generate_images():
|
||||
"""Test generate images function"""
|
||||
print("\n" + "="*80)
|
||||
print("TEST 4: Generate Images Function")
|
||||
print("="*80)
|
||||
print("Note: This requires actual task IDs in the database")
|
||||
print("Skipping - requires database setup")
|
||||
# Uncomment to test with real data:
|
||||
# result = generate_images_core(task_ids=[1], account_id=1)
|
||||
# print(f"Result: {result}")
|
||||
|
||||
|
||||
def test_json_extraction():
|
||||
"""Test JSON extraction"""
|
||||
print("\n" + "="*80)
|
||||
print("TEST 5: JSON Extraction")
|
||||
print("="*80)
|
||||
|
||||
ai_core = AICore()
|
||||
|
||||
# Test 1: Direct JSON
|
||||
json_text = '{"clusters": [{"name": "Test", "keywords": ["test"]}]}'
|
||||
result = ai_core.extract_json(json_text)
|
||||
print(f"✅ Direct JSON: {result is not None}")
|
||||
|
||||
# Test 2: JSON in markdown
|
||||
json_markdown = '```json\n{"clusters": [{"name": "Test"}]}\n```'
|
||||
result = ai_core.extract_json(json_markdown)
|
||||
print(f"✅ JSON in markdown: {result is not None}")
|
||||
|
||||
# Test 3: Invalid JSON
|
||||
invalid_json = "This is not JSON"
|
||||
result = ai_core.extract_json(invalid_json)
|
||||
print(f"✅ Invalid JSON handled: {result is None}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("\n" + "="*80)
|
||||
print("AI FUNCTIONS TEST SUITE")
|
||||
print("="*80)
|
||||
print("Testing all AI functions with console logging enabled")
|
||||
print("="*80)
|
||||
|
||||
# Run tests
|
||||
test_ai_core()
|
||||
test_json_extraction()
|
||||
test_auto_cluster()
|
||||
test_generate_content()
|
||||
test_generate_images()
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("TEST SUITE COMPLETE")
|
||||
print("="*80)
|
||||
print("\nAll console logging should be visible above.")
|
||||
print("Check for [AI][function_name] Step X: messages")
|
||||
|
||||
@@ -67,16 +67,10 @@ class JWTAuthentication(BaseAuthentication):
|
||||
try:
|
||||
account = Account.objects.get(id=account_id)
|
||||
except Account.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not account:
|
||||
try:
|
||||
account = getattr(user, 'account', None)
|
||||
except (AttributeError, Exception):
|
||||
# If account access fails, set to None
|
||||
# Account from token doesn't exist - don't fallback, set to None
|
||||
account = None
|
||||
|
||||
# Set account on request
|
||||
# Set account on request (only if account_id was in token and account exists)
|
||||
request.account = account
|
||||
|
||||
return (user, token)
|
||||
@@ -89,3 +83,60 @@ class JWTAuthentication(BaseAuthentication):
|
||||
# This allows session authentication to work if JWT fails
|
||||
return None
|
||||
|
||||
|
||||
class APIKeyAuthentication(BaseAuthentication):
|
||||
"""
|
||||
API Key authentication for WordPress integration.
|
||||
Validates API keys stored in Site.wp_api_key field.
|
||||
"""
|
||||
def authenticate(self, request):
|
||||
"""
|
||||
Authenticate using WordPress API key.
|
||||
Returns (user, api_key) tuple if valid.
|
||||
"""
|
||||
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
|
||||
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return None # Not an API key request
|
||||
|
||||
api_key = auth_header.split(' ')[1] if len(auth_header.split(' ')) > 1 else None
|
||||
if not api_key or len(api_key) < 20: # API keys should be at least 20 chars
|
||||
return None
|
||||
|
||||
# Don't try to authenticate JWT tokens (they start with 'ey')
|
||||
if api_key.startswith('ey'):
|
||||
return None # Let JWTAuthentication handle it
|
||||
|
||||
try:
|
||||
from igny8_core.auth.models import Site, User
|
||||
|
||||
# Find site by API key
|
||||
site = Site.objects.select_related('account', 'account__owner').filter(
|
||||
wp_api_key=api_key,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if not site:
|
||||
return None # API key not found or site inactive
|
||||
|
||||
# Get account and user
|
||||
account = site.account
|
||||
user = account.owner # Use account owner as the authenticated user
|
||||
|
||||
if not user.is_active:
|
||||
raise AuthenticationFailed('User account is disabled.')
|
||||
|
||||
# Set account on request for tenant isolation
|
||||
request.account = account
|
||||
|
||||
# Set site on request for WordPress integration context
|
||||
request.site = site
|
||||
|
||||
return (user, api_key)
|
||||
|
||||
except Exception as e:
|
||||
# Log the error but return None to allow other auth classes to try
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f'APIKeyAuthentication error: {str(e)}')
|
||||
return None
|
||||
|
||||
@@ -265,9 +265,9 @@ class SiteSectorModelViewSet(AccountModelViewSet):
|
||||
if query_params is None:
|
||||
# Fallback for non-DRF requests
|
||||
query_params = getattr(self.request, 'GET', {})
|
||||
site_id = query_params.get('site_id')
|
||||
site_id = query_params.get('site_id') or query_params.get('site')
|
||||
else:
|
||||
site_id = query_params.get('site_id')
|
||||
site_id = query_params.get('site_id') or query_params.get('site')
|
||||
except AttributeError:
|
||||
site_id = None
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ Provides consistent response format across all endpoints
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
import uuid
|
||||
from typing import Any
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
def get_request_id(request):
|
||||
@@ -74,6 +76,28 @@ def error_response(error=None, errors=None, status_code=status.HTTP_400_BAD_REQU
|
||||
'success': False,
|
||||
}
|
||||
|
||||
# Backwards compatibility: some callers used positional args in the order
|
||||
# (error, status_code, request) which maps to (error, errors, status_code=request)
|
||||
# causing `status_code` to be a Request object and raising TypeError.
|
||||
# Detect this misuse and normalize arguments:
|
||||
try:
|
||||
if request is None and status_code is not None:
|
||||
# If status_code appears to be a Request object, shift arguments
|
||||
if isinstance(status_code, HttpRequest) or hasattr(status_code, 'META'):
|
||||
# original call looked like: error_response(msg, status.HTTP_400_BAD_REQUEST, request)
|
||||
# which resulted in: errors = status.HTTP_400..., status_code = request
|
||||
request = status_code
|
||||
# If `errors` holds an int-like HTTP status, use it as status_code
|
||||
if isinstance(errors, int):
|
||||
status_code = errors
|
||||
errors = None
|
||||
else:
|
||||
# fallback to default 400
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
except Exception:
|
||||
# Defensive: if introspection fails, continue with provided args
|
||||
pass
|
||||
|
||||
if error:
|
||||
response_data['error'] = error
|
||||
elif status_code == status.HTTP_400_BAD_REQUEST:
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Test runner script for API tests
|
||||
Run all tests: python manage.py test igny8_core.api.tests
|
||||
Run specific test: python manage.py test igny8_core.api.tests.test_response
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Setup Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||
django.setup()
|
||||
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Run all API tests
|
||||
if len(sys.argv) > 1:
|
||||
# Custom test specified
|
||||
execute_from_command_line(['manage.py', 'test'] + sys.argv[1:])
|
||||
else:
|
||||
# Run all API tests
|
||||
execute_from_command_line(['manage.py', 'test', 'igny8_core.api.tests', '--verbosity=2'])
|
||||
|
||||
@@ -28,11 +28,19 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
|
||||
- IGNY8_DEBUG_THROTTLE environment variable is True
|
||||
- User belongs to aws-admin or other system accounts
|
||||
- User is admin/developer role
|
||||
- Public blueprint list request with site filter (for Sites Renderer)
|
||||
"""
|
||||
# Check if throttling should be bypassed
|
||||
debug_bypass = getattr(settings, 'DEBUG', False)
|
||||
env_bypass = getattr(settings, 'IGNY8_DEBUG_THROTTLE', False)
|
||||
|
||||
# Bypass for public blueprint list requests (Sites Renderer fallback)
|
||||
public_blueprint_bypass = False
|
||||
if hasattr(view, 'action') and view.action == 'list':
|
||||
if hasattr(request, 'query_params') and request.query_params.get('site'):
|
||||
if not request.user or not hasattr(request.user, 'is_authenticated') or not request.user.is_authenticated:
|
||||
public_blueprint_bypass = True
|
||||
|
||||
# Bypass for system account users (aws-admin, default-account, etc.)
|
||||
system_account_bypass = False
|
||||
if hasattr(request, 'user') and request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated:
|
||||
@@ -47,7 +55,7 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
|
||||
# If checking fails, continue with normal throttling
|
||||
pass
|
||||
|
||||
if debug_bypass or env_bypass or system_account_bypass:
|
||||
if debug_bypass or env_bypass or system_account_bypass or public_blueprint_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'):
|
||||
|
||||
400
backend/igny8_core/api/wordpress_publishing.py
Normal file
400
backend/igny8_core/api/wordpress_publishing.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
WordPress Publishing API Views
|
||||
Handles manual content publishing to WordPress sites
|
||||
"""
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from igny8_core.models import ContentPost, SiteIntegration
|
||||
from igny8_core.tasks.wordpress_publishing import (
|
||||
publish_content_to_wordpress,
|
||||
bulk_publish_content_to_wordpress
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def publish_single_content(request, content_id: int) -> Response:
|
||||
"""
|
||||
Publish a single content item to WordPress
|
||||
|
||||
POST /api/v1/content/{content_id}/publish-to-wordpress/
|
||||
|
||||
Body:
|
||||
{
|
||||
"site_integration_id": 123, // Optional - will use default if not provided
|
||||
"force": false // Optional - force republish even if already published
|
||||
}
|
||||
"""
|
||||
try:
|
||||
content = get_object_or_404(ContentPost, id=content_id)
|
||||
|
||||
# Check permissions
|
||||
if not request.user.has_perm('content.change_contentpost'):
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': 'Permission denied',
|
||||
'error': 'insufficient_permissions'
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Get site integration
|
||||
site_integration_id = request.data.get('site_integration_id')
|
||||
force = request.data.get('force', False)
|
||||
|
||||
if site_integration_id:
|
||||
site_integration = get_object_or_404(SiteIntegration, id=site_integration_id)
|
||||
else:
|
||||
# Get default WordPress integration for user's organization
|
||||
site_integration = SiteIntegration.objects.filter(
|
||||
platform='wordpress',
|
||||
is_active=True,
|
||||
# Add organization filter if applicable
|
||||
).first()
|
||||
|
||||
if not site_integration:
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': 'No WordPress integration found',
|
||||
'error': 'no_integration'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check if already published (unless force is true)
|
||||
if not force and content.wordpress_sync_status == 'success':
|
||||
return Response(
|
||||
{
|
||||
'success': True,
|
||||
'message': 'Content already published to WordPress',
|
||||
'data': {
|
||||
'content_id': content.id,
|
||||
'wordpress_post_id': content.wordpress_post_id,
|
||||
'wordpress_post_url': content.wordpress_post_url,
|
||||
'status': 'already_published'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Check if currently syncing
|
||||
if content.wordpress_sync_status == 'syncing':
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': 'Content is currently being published to WordPress',
|
||||
'error': 'sync_in_progress'
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
)
|
||||
|
||||
# Validate content is ready for publishing
|
||||
if not content.title or not (content.content_html or content.content):
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': 'Content is incomplete - missing title or content',
|
||||
'error': 'incomplete_content'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Set status to pending and queue the task
|
||||
content.wordpress_sync_status = 'pending'
|
||||
content.save(update_fields=['wordpress_sync_status'])
|
||||
|
||||
# Get task_id if content is associated with a writer task
|
||||
task_id = None
|
||||
if hasattr(content, 'writer_task'):
|
||||
task_id = content.writer_task.id
|
||||
|
||||
# Queue the publishing task
|
||||
task_result = publish_content_to_wordpress.delay(
|
||||
content.id,
|
||||
site_integration.id,
|
||||
task_id
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'success': True,
|
||||
'message': 'Content queued for WordPress publishing',
|
||||
'data': {
|
||||
'content_id': content.id,
|
||||
'site_integration_id': site_integration.id,
|
||||
'task_id': task_result.id,
|
||||
'status': 'queued'
|
||||
}
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': f'Error queuing content for WordPress publishing: {str(e)}',
|
||||
'error': 'server_error'
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def bulk_publish_content(request) -> Response:
|
||||
"""
|
||||
Bulk publish multiple content items to WordPress
|
||||
|
||||
POST /api/v1/content/bulk-publish-to-wordpress/
|
||||
|
||||
Body:
|
||||
{
|
||||
"content_ids": [1, 2, 3, 4],
|
||||
"site_integration_id": 123, // Optional
|
||||
"force": false // Optional
|
||||
}
|
||||
"""
|
||||
try:
|
||||
content_ids = request.data.get('content_ids', [])
|
||||
site_integration_id = request.data.get('site_integration_id')
|
||||
force = request.data.get('force', False)
|
||||
|
||||
if not content_ids:
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': 'No content IDs provided',
|
||||
'error': 'missing_content_ids'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check permissions
|
||||
if not request.user.has_perm('content.change_contentpost'):
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': 'Permission denied',
|
||||
'error': 'insufficient_permissions'
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Get site integration
|
||||
if site_integration_id:
|
||||
site_integration = get_object_or_404(SiteIntegration, id=site_integration_id)
|
||||
else:
|
||||
site_integration = SiteIntegration.objects.filter(
|
||||
platform='wordpress',
|
||||
is_active=True,
|
||||
).first()
|
||||
|
||||
if not site_integration:
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': 'No WordPress integration found',
|
||||
'error': 'no_integration'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Validate content items
|
||||
content_items = ContentPost.objects.filter(id__in=content_ids)
|
||||
|
||||
if content_items.count() != len(content_ids):
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': 'Some content items not found',
|
||||
'error': 'content_not_found'
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Queue bulk publishing task
|
||||
task_result = bulk_publish_content_to_wordpress.delay(
|
||||
content_ids,
|
||||
site_integration.id
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'success': True,
|
||||
'message': f'{len(content_ids)} content items queued for WordPress publishing',
|
||||
'data': {
|
||||
'content_count': len(content_ids),
|
||||
'site_integration_id': site_integration.id,
|
||||
'task_id': task_result.id,
|
||||
'status': 'queued'
|
||||
}
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': f'Error queuing bulk WordPress publishing: {str(e)}',
|
||||
'error': 'server_error'
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_wordpress_status(request, content_id: int) -> Response:
|
||||
"""
|
||||
Get WordPress publishing status for a content item
|
||||
|
||||
GET /api/v1/content/{content_id}/wordpress-status/
|
||||
"""
|
||||
try:
|
||||
content = get_object_or_404(ContentPost, id=content_id)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'success': True,
|
||||
'data': {
|
||||
'content_id': content.id,
|
||||
'wordpress_sync_status': content.wordpress_sync_status,
|
||||
'wordpress_post_id': content.wordpress_post_id,
|
||||
'wordpress_post_url': content.wordpress_post_url,
|
||||
'wordpress_sync_attempts': content.wordpress_sync_attempts,
|
||||
'last_wordpress_sync': content.last_wordpress_sync.isoformat() if content.last_wordpress_sync else None,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': f'Error getting WordPress status: {str(e)}',
|
||||
'error': 'server_error'
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_wordpress_integrations(request) -> Response:
|
||||
"""
|
||||
Get available WordPress integrations for publishing
|
||||
|
||||
GET /api/v1/wordpress-integrations/
|
||||
"""
|
||||
try:
|
||||
integrations = SiteIntegration.objects.filter(
|
||||
platform='wordpress',
|
||||
is_active=True,
|
||||
# Add organization filter if applicable
|
||||
).values(
|
||||
'id', 'site_name', 'site_url', 'is_active',
|
||||
'created_at', 'last_sync_at'
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'success': True,
|
||||
'data': list(integrations)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': f'Error getting WordPress integrations: {str(e)}',
|
||||
'error': 'server_error'
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def retry_failed_wordpress_sync(request, content_id: int) -> Response:
|
||||
"""
|
||||
Retry a failed WordPress sync
|
||||
|
||||
POST /api/v1/content/{content_id}/retry-wordpress-sync/
|
||||
"""
|
||||
try:
|
||||
content = get_object_or_404(ContentPost, id=content_id)
|
||||
|
||||
if content.wordpress_sync_status != 'failed':
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': 'Content is not in failed status',
|
||||
'error': 'invalid_status'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Get default WordPress integration
|
||||
site_integration = SiteIntegration.objects.filter(
|
||||
platform='wordpress',
|
||||
is_active=True,
|
||||
).first()
|
||||
|
||||
if not site_integration:
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': 'No WordPress integration found',
|
||||
'error': 'no_integration'
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Reset status and retry
|
||||
content.wordpress_sync_status = 'pending'
|
||||
content.save(update_fields=['wordpress_sync_status'])
|
||||
|
||||
# Get task_id if available
|
||||
task_id = None
|
||||
if hasattr(content, 'writer_task'):
|
||||
task_id = content.writer_task.id
|
||||
|
||||
# Queue the publishing task
|
||||
task_result = publish_content_to_wordpress.delay(
|
||||
content.id,
|
||||
site_integration.id,
|
||||
task_id
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'success': True,
|
||||
'message': 'WordPress sync retry queued',
|
||||
'data': {
|
||||
'content_id': content.id,
|
||||
'task_id': task_result.id,
|
||||
'status': 'queued'
|
||||
}
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{
|
||||
'success': False,
|
||||
'message': f'Error retrying WordPress sync: {str(e)}',
|
||||
'error': 'server_error'
|
||||
},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
@@ -19,21 +19,9 @@ class PlanAdmin(admin.ModelAdmin):
|
||||
('Plan Info', {
|
||||
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active')
|
||||
}),
|
||||
('User / Site Limits', {
|
||||
('Account Management Limits', {
|
||||
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles')
|
||||
}),
|
||||
('Planner Limits', {
|
||||
'fields': ('max_keywords', 'max_clusters', 'daily_cluster_limit', 'daily_keyword_import_limit', 'monthly_cluster_ai_credits')
|
||||
}),
|
||||
('Writer Limits', {
|
||||
'fields': ('daily_content_tasks', 'daily_ai_requests', 'monthly_word_count_limit', 'monthly_content_ai_credits')
|
||||
}),
|
||||
('Image Limits', {
|
||||
'fields': ('monthly_image_count', 'monthly_image_ai_credits', 'max_images_per_task', 'image_model_choices')
|
||||
}),
|
||||
('AI Controls', {
|
||||
'fields': ('daily_ai_request_limit', 'monthly_ai_credit_limit')
|
||||
}),
|
||||
('Billing & Credits', {
|
||||
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
|
||||
}),
|
||||
@@ -117,11 +105,66 @@ class SectorInline(admin.TabularInline):
|
||||
|
||||
@admin.register(Site)
|
||||
class SiteAdmin(AccountAdminMixin, admin.ModelAdmin):
|
||||
list_display = ['name', 'slug', 'account', 'industry', 'domain', 'status', 'is_active', 'get_sectors_count']
|
||||
list_filter = ['status', 'is_active', 'account', 'industry']
|
||||
list_display = ['name', 'slug', 'account', 'industry', 'domain', 'status', 'is_active', 'get_api_key_status', 'get_sectors_count']
|
||||
list_filter = ['status', 'is_active', 'account', 'industry', 'hosting_type']
|
||||
search_fields = ['name', 'slug', 'domain', 'industry__name']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
readonly_fields = ['created_at', 'updated_at', 'get_api_key_display']
|
||||
inlines = [SectorInline]
|
||||
actions = ['generate_api_keys']
|
||||
|
||||
fieldsets = (
|
||||
('Site Info', {
|
||||
'fields': ('name', 'slug', 'account', 'domain', 'description', 'industry', 'site_type', 'hosting_type', 'status', 'is_active')
|
||||
}),
|
||||
('WordPress Integration', {
|
||||
'fields': ('wp_url', 'wp_username', 'wp_app_password', 'get_api_key_display'),
|
||||
'description': 'Legacy WordPress integration fields. For WordPress sites using the IGNY8 WP Bridge plugin.'
|
||||
}),
|
||||
('SEO Metadata', {
|
||||
'fields': ('seo_metadata',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def get_api_key_display(self, obj):
|
||||
"""Display API key with copy button"""
|
||||
if obj.wp_api_key:
|
||||
from django.utils.html import format_html
|
||||
return format_html(
|
||||
'<div style="display:flex; align-items:center; gap:10px;">'
|
||||
'<code style="background:#f0f0f0; padding:5px 10px; border-radius:3px;">{}</code>'
|
||||
'<button type="button" onclick="navigator.clipboard.writeText(\'{}\'); alert(\'API Key copied to clipboard!\');" '
|
||||
'style="padding:5px 10px; cursor:pointer;">Copy</button>'
|
||||
'</div>',
|
||||
obj.wp_api_key,
|
||||
obj.wp_api_key
|
||||
)
|
||||
return format_html('<em>No API key generated</em>')
|
||||
get_api_key_display.short_description = 'WordPress API Key'
|
||||
|
||||
def get_api_key_status(self, obj):
|
||||
"""Show API key status in list view"""
|
||||
if obj.wp_api_key:
|
||||
from django.utils.html import format_html
|
||||
return format_html('<span style="color:green;">●</span> Active')
|
||||
return format_html('<span style="color:gray;">○</span> None')
|
||||
get_api_key_status.short_description = 'API Key'
|
||||
|
||||
def generate_api_keys(self, request, queryset):
|
||||
"""Generate API keys for selected sites"""
|
||||
import secrets
|
||||
updated_count = 0
|
||||
for site in queryset:
|
||||
if not site.wp_api_key:
|
||||
site.wp_api_key = f"igny8_{''.join(secrets.choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') for _ in range(40))}"
|
||||
site.save()
|
||||
updated_count += 1
|
||||
self.message_user(request, f'Generated API keys for {updated_count} site(s). Sites with existing keys were skipped.')
|
||||
generate_api_keys.short_description = 'Generate WordPress API Keys'
|
||||
|
||||
def get_sectors_count(self, obj):
|
||||
try:
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.db.models import Q
|
||||
from igny8_core.auth.models import Account, User, Site, Sector
|
||||
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
|
||||
from igny8_core.modules.writer.models import Tasks, Images, Content
|
||||
from igny8_core.modules.billing.models import CreditTransaction, CreditUsageLog
|
||||
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
|
||||
from igny8_core.modules.system.models import AIPrompt, IntegrationSettings, AuthorProfile, Strategy
|
||||
from igny8_core.modules.system.settings_models import AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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:
|
||||
@@ -41,14 +42,19 @@ class AccountContextMiddleware(MiddlewareMixin):
|
||||
request.user = user
|
||||
# Get account from refreshed user
|
||||
user_account = getattr(user, 'account', None)
|
||||
if user_account:
|
||||
request.account = user_account
|
||||
return 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):
|
||||
@@ -76,7 +82,6 @@ class AccountContextMiddleware(MiddlewareMixin):
|
||||
if not JWT_AVAILABLE:
|
||||
# JWT library not installed yet - skip for now
|
||||
request.account = None
|
||||
request.user = None
|
||||
return None
|
||||
|
||||
# Decode JWT token with signature verification
|
||||
@@ -94,42 +99,76 @@ class AccountContextMiddleware(MiddlewareMixin):
|
||||
if user_id:
|
||||
from .models import User, Account
|
||||
try:
|
||||
# Refresh user from DB with account and plan relationships to get latest data
|
||||
# This ensures changes to account/plan are reflected immediately without re-login
|
||||
# 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)
|
||||
request.user = user
|
||||
validation_error = self._validate_account_and_plan(request, user)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
if account_id:
|
||||
# Verify account still exists and matches user
|
||||
account = Account.objects.get(id=account_id)
|
||||
# If user's account changed, use the new one from user object
|
||||
if user.account and user.account.id != account_id:
|
||||
request.account = user.account
|
||||
else:
|
||||
request.account = account
|
||||
else:
|
||||
# Verify account still exists
|
||||
try:
|
||||
user_account = getattr(user, 'account', None)
|
||||
if user_account:
|
||||
request.account = user_account
|
||||
else:
|
||||
request.account = None
|
||||
except (AttributeError, Exception):
|
||||
# If account access fails (e.g., column mismatch), set to None
|
||||
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
|
||||
request.user = None
|
||||
else:
|
||||
request.account = None
|
||||
request.user = None
|
||||
|
||||
except jwt.InvalidTokenError:
|
||||
request.account = None
|
||||
request.user = None
|
||||
except Exception:
|
||||
# Fail silently for now - allow unauthenticated access
|
||||
request.account = None
|
||||
request.user = None
|
||||
|
||||
return None
|
||||
|
||||
def _validate_account_and_plan(self, request, user):
|
||||
"""
|
||||
Ensure the authenticated user has an account and an active plan.
|
||||
If not, logout the user (for session auth) and block the request.
|
||||
"""
|
||||
try:
|
||||
account = getattr(user, 'account', None)
|
||||
except Exception:
|
||||
account = None
|
||||
|
||||
if not account:
|
||||
return self._deny_request(
|
||||
request,
|
||||
error='Account not configured for this user. Please contact support.',
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
plan = getattr(account, 'plan', None)
|
||||
if plan is None or getattr(plan, 'is_active', False) is False:
|
||||
return self._deny_request(
|
||||
request,
|
||||
error='Active subscription required. Visit igny8.com/pricing to subscribe.',
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _deny_request(self, request, error, status_code):
|
||||
"""Logout session users (if any) and return a consistent JSON error."""
|
||||
try:
|
||||
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
|
||||
logout(request)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
'success': False,
|
||||
'error': error,
|
||||
},
|
||||
status=status_code,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-02 21:42
|
||||
# Generated by Django 5.2.8 on 2025-11-20 23:27
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
@@ -25,12 +25,22 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(max_length=255)),
|
||||
('slug', models.SlugField(max_length=255, unique=True)),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('credits_per_month', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('max_sites', models.IntegerField(default=1, help_text='Maximum number of sites allowed (1-10)', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(10)])),
|
||||
('features', models.JSONField(default=dict, help_text='Plan features as JSON')),
|
||||
('stripe_price_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('billing_cycle', models.CharField(choices=[('monthly', 'Monthly'), ('annual', 'Annual')], default='monthly', max_length=20)),
|
||||
('features', models.JSONField(blank=True, default=list, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])")),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('max_users', models.IntegerField(default=1, help_text='Total users allowed per account', validators=[django.core.validators.MinValueValidator(1)])),
|
||||
('max_sites', models.IntegerField(default=1, help_text='Maximum number of sites allowed', validators=[django.core.validators.MinValueValidator(1)])),
|
||||
('max_industries', models.IntegerField(blank=True, default=None, help_text='Optional limit for industries/sectors', null=True, validators=[django.core.validators.MinValueValidator(1)])),
|
||||
('max_author_profiles', models.IntegerField(default=5, help_text='Limit for saved writing styles', validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('included_credits', models.IntegerField(default=0, help_text='Monthly credits included', validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('extra_credit_price', models.DecimalField(decimal_places=2, default=0.01, help_text='Price per additional credit', max_digits=10)),
|
||||
('allow_credit_topup', models.BooleanField(default=True, help_text='Can user purchase more credits?')),
|
||||
('auto_credit_topup_threshold', models.IntegerField(blank=True, default=None, help_text='Auto top-up trigger point (optional)', null=True, validators=[django.core.validators.MinValueValidator(0)])),
|
||||
('auto_credit_topup_amount', models.IntegerField(blank=True, default=None, help_text='How many credits to auto-buy', null=True, validators=[django.core.validators.MinValueValidator(1)])),
|
||||
('stripe_product_id', models.CharField(blank=True, help_text='For Stripe plan sync', max_length=255, null=True)),
|
||||
('stripe_price_id', models.CharField(blank=True, help_text='Monthly price ID for Stripe', max_length=255, null=True)),
|
||||
('credits_per_month', models.IntegerField(default=0, help_text='DEPRECATED: Use included_credits instead', validators=[django.core.validators.MinValueValidator(0)])),
|
||||
],
|
||||
options={
|
||||
'db_table': 'igny8_plans',
|
||||
@@ -50,7 +60,7 @@ class Migration(migrations.Migration):
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('editor', 'Editor'), ('viewer', 'Viewer'), ('system_bot', 'System Bot')], default='viewer', max_length=20)),
|
||||
('role', models.CharField(choices=[('developer', 'Developer / Super Admin'), ('owner', 'Owner'), ('admin', 'Admin'), ('editor', 'Editor'), ('viewer', 'Viewer'), ('system_bot', 'System Bot')], default='viewer', max_length=20)),
|
||||
('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
@@ -65,7 +75,7 @@ class Migration(migrations.Migration):
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Tenant',
|
||||
name='Account',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
@@ -75,28 +85,93 @@ class Migration(migrations.Migration):
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('suspended', 'Suspended'), ('trial', 'Trial'), ('cancelled', 'Cancelled')], default='trial', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='owned_tenants', to=settings.AUTH_USER_MODEL)),
|
||||
('plan', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tenants', to='igny8_core_auth.plan')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='owned_accounts', to=settings.AUTH_USER_MODEL)),
|
||||
('plan', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accounts', to='igny8_core_auth.plan')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Account',
|
||||
'verbose_name_plural': 'Accounts',
|
||||
'db_table': 'igny8_tenants',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='account',
|
||||
field=models.ForeignKey(blank=True, db_column='tenant_id', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='users', to='igny8_core_auth.account'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Subscription',
|
||||
name='Industry',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('stripe_subscription_id', models.CharField(max_length=255, unique=True)),
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('past_due', 'Past Due'), ('canceled', 'Canceled'), ('trialing', 'Trialing')], max_length=20)),
|
||||
('current_period_start', models.DateTimeField()),
|
||||
('current_period_end', models.DateTimeField()),
|
||||
('cancel_at_period_end', models.BooleanField(default=False)),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('slug', models.SlugField(max_length=255, unique=True)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('tenant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='igny8_core_auth.tenant')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'igny8_subscriptions',
|
||||
'verbose_name': 'Industry',
|
||||
'verbose_name_plural': 'Industries',
|
||||
'db_table': 'igny8_industries',
|
||||
'ordering': ['name'],
|
||||
'indexes': [models.Index(fields=['slug'], name='igny8_indus_slug_2f8769_idx'), models.Index(fields=['is_active'], name='igny8_indus_is_acti_146d41_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IndustrySector',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('slug', models.SlugField(max_length=255)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('suggested_keywords', models.JSONField(default=list, help_text='List of suggested keywords for this sector template')),
|
||||
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('industry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sectors', to='igny8_core_auth.industry')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Industry Sector',
|
||||
'verbose_name_plural': 'Industry Sectors',
|
||||
'db_table': 'igny8_industry_sectors',
|
||||
'ordering': ['industry', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PasswordResetToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token', models.CharField(db_index=True, max_length=255, unique=True)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('used', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_reset_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'igny8_password_reset_tokens',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SeedKeyword',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('keyword', models.CharField(db_index=True, max_length=255)),
|
||||
('volume', models.IntegerField(default=0, help_text='Search volume estimate')),
|
||||
('difficulty', models.IntegerField(default=0, help_text='Keyword difficulty (0-100)', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])),
|
||||
('intent', models.CharField(choices=[('informational', 'Informational'), ('navigational', 'Navigational'), ('commercial', 'Commercial'), ('transactional', 'Transactional')], default='informational', max_length=50)),
|
||||
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('industry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seed_keywords', to='igny8_core_auth.industry')),
|
||||
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seed_keywords', to='igny8_core_auth.industrysector')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Seed Keyword',
|
||||
'verbose_name_plural': 'Seed Keywords',
|
||||
'db_table': 'igny8_seed_keywords',
|
||||
'ordering': ['keyword'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -111,13 +186,18 @@ class Migration(migrations.Migration):
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('suspended', 'Suspended')], default='active', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('wp_url', models.URLField(blank=True, help_text='WordPress site URL', null=True)),
|
||||
('wp_url', models.URLField(blank=True, help_text='WordPress site URL (legacy - use SiteIntegration)', null=True)),
|
||||
('wp_username', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('wp_app_password', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
|
||||
('site_type', models.CharField(choices=[('marketing', 'Marketing Site'), ('ecommerce', 'Ecommerce Site'), ('blog', 'Blog'), ('portfolio', 'Portfolio'), ('corporate', 'Corporate')], db_index=True, default='marketing', help_text='Type of site', max_length=50)),
|
||||
('hosting_type', models.CharField(choices=[('igny8_sites', 'IGNY8 Sites'), ('wordpress', 'WordPress'), ('shopify', 'Shopify'), ('multi', 'Multi-Destination')], db_index=True, default='igny8_sites', help_text='Target hosting platform', max_length=50)),
|
||||
('seo_metadata', models.JSONField(blank=True, default=dict, help_text='SEO metadata: meta tags, Open Graph, Schema.org')),
|
||||
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
|
||||
('industry', models.ForeignKey(blank=True, help_text='Industry this site belongs to', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='sites', to='igny8_core_auth.industry')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'igny8_sites',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -131,18 +211,14 @@ class Migration(migrations.Migration):
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive')], default='active', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
|
||||
('industry_sector', models.ForeignKey(blank=True, help_text='Reference to the industry sector template', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='site_sectors', to='igny8_core_auth.industrysector')),
|
||||
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sectors', to='igny8_core_auth.site')),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'igny8_sectors',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='tenant',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='users', to='igny8_core_auth.tenant'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SiteUserAccess',
|
||||
fields=[
|
||||
@@ -153,34 +229,111 @@ class Migration(migrations.Migration):
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='site_access', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Site User Access',
|
||||
'verbose_name_plural': 'Site User Access',
|
||||
'db_table': 'igny8_site_user_access',
|
||||
'indexes': [models.Index(fields=['user', 'site'], name='igny8_site__user_id_61951e_idx')],
|
||||
'unique_together': {('user', 'site')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Subscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('stripe_subscription_id', models.CharField(max_length=255, unique=True)),
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('past_due', 'Past Due'), ('canceled', 'Canceled'), ('trialing', 'Trialing')], max_length=20)),
|
||||
('current_period_start', models.DateTimeField()),
|
||||
('current_period_end', models.DateTimeField()),
|
||||
('cancel_at_period_end', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('account', models.OneToOneField(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='igny8_core_auth.account')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'igny8_subscriptions',
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='tenant',
|
||||
model_name='user',
|
||||
index=models.Index(fields=['account', 'role'], name='igny8_users_tenant__0ab02b_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='user',
|
||||
index=models.Index(fields=['email'], name='igny8_users_email_fd61ff_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='industrysector',
|
||||
index=models.Index(fields=['industry', 'is_active'], name='igny8_indus_industr_00b524_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='industrysector',
|
||||
index=models.Index(fields=['slug'], name='igny8_indus_slug_101d63_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='industrysector',
|
||||
unique_together={('industry', 'slug')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='passwordresettoken',
|
||||
index=models.Index(fields=['token'], name='igny8_passw_token_0eaf0c_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='passwordresettoken',
|
||||
index=models.Index(fields=['user', 'used'], name='igny8_passw_user_id_320c02_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='passwordresettoken',
|
||||
index=models.Index(fields=['expires_at'], name='igny8_passw_expires_c9aa03_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='account',
|
||||
index=models.Index(fields=['slug'], name='igny8_tenan_slug_f25e97_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='tenant',
|
||||
model_name='account',
|
||||
index=models.Index(fields=['status'], name='igny8_tenan_status_5dc02a_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='subscription',
|
||||
index=models.Index(fields=['status'], name='igny8_subsc_status_2fa897_idx'),
|
||||
model_name='seedkeyword',
|
||||
index=models.Index(fields=['keyword'], name='igny8_seed__keyword_efa089_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='seedkeyword',
|
||||
index=models.Index(fields=['industry', 'sector'], name='igny8_seed__industr_c41841_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='seedkeyword',
|
||||
index=models.Index(fields=['industry', 'sector', 'is_active'], name='igny8_seed__industr_da0030_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='seedkeyword',
|
||||
index=models.Index(fields=['intent'], name='igny8_seed__intent_15020d_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='seedkeyword',
|
||||
unique_together={('keyword', 'industry', 'sector')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='site',
|
||||
index=models.Index(fields=['tenant', 'is_active'], name='igny8_sites_tenant__e0f31d_idx'),
|
||||
index=models.Index(fields=['account', 'is_active'], name='igny8_sites_tenant__e0f31d_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='site',
|
||||
index=models.Index(fields=['tenant', 'status'], name='igny8_sites_tenant__a20275_idx'),
|
||||
index=models.Index(fields=['account', 'status'], name='igny8_sites_tenant__a20275_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='site',
|
||||
index=models.Index(fields=['industry'], name='igny8_sites_industr_66e004_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='site',
|
||||
index=models.Index(fields=['site_type'], name='igny8_sites_site_ty_0dfbc3_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='site',
|
||||
index=models.Index(fields=['hosting_type'], name='igny8_sites_hosting_c484c2_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='site',
|
||||
unique_together={('tenant', 'slug')},
|
||||
unique_together={('account', 'slug')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sector',
|
||||
@@ -188,18 +341,26 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sector',
|
||||
index=models.Index(fields=['tenant', 'site'], name='igny8_secto_tenant__af54ae_idx'),
|
||||
index=models.Index(fields=['account', 'site'], name='igny8_secto_tenant__af54ae_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sector',
|
||||
index=models.Index(fields=['industry_sector'], name='igny8_secto_industr_1cf990_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='sector',
|
||||
unique_together={('site', 'slug')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='user',
|
||||
index=models.Index(fields=['tenant', 'role'], name='igny8_users_tenant__0ab02b_idx'),
|
||||
model_name='siteuseraccess',
|
||||
index=models.Index(fields=['user', 'site'], name='igny8_site__user_id_61951e_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='siteuseraccess',
|
||||
unique_together={('user', 'site')},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='user',
|
||||
index=models.Index(fields=['email'], name='igny8_users_email_fd61ff_idx'),
|
||||
model_name='subscription',
|
||||
index=models.Index(fields=['status'], name='igny8_subsc_status_2fa897_idx'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-02 22:27
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated manually for adding wp_api_key to Site model
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='wp_api_key',
|
||||
field=models.CharField(blank=True, help_text='API key for WordPress integration via IGNY8 WP Bridge plugin', max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-03 13:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0002_add_developer_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('developer', 'Developer / Super Admin'), ('owner', 'Owner'), ('admin', 'Admin'), ('editor', 'Editor'), ('viewer', 'Viewer'), ('system_bot', 'System Bot')], default='viewer', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -1,75 +0,0 @@
|
||||
# Generated migration for Industry and IndustrySector models
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0003_alter_user_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Industry',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('slug', models.SlugField(db_index=True, max_length=255, unique=True)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'igny8_industries',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IndustrySector',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('slug', models.SlugField(db_index=True, max_length=255)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('suggested_keywords', models.JSONField(default=list, help_text='List of suggested keywords for this sector template')),
|
||||
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('industry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sectors', to='igny8_core_auth.industry')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'igny8_industry_sectors',
|
||||
'ordering': ['industry', 'name'],
|
||||
'unique_together': {('industry', 'slug')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sector',
|
||||
name='industry_sector',
|
||||
field=models.ForeignKey(blank=True, help_text='Reference to the industry sector template', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='site_sectors', to='igny8_core_auth.industrysector'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='industry',
|
||||
index=models.Index(fields=['slug'], name='igny8_indu_slug_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='industry',
|
||||
index=models.Index(fields=['is_active'], name='igny8_indu_is_acti_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='industrysector',
|
||||
index=models.Index(fields=['industry', 'is_active'], name='igny8_indu_industr_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='industrysector',
|
||||
index=models.Index(fields=['slug'], name='igny8_indu_slug_1_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sector',
|
||||
index=models.Index(fields=['industry_sector'], name='igny8_sect_industr_idx'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Migration to add industry field to Site model
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0004_add_industry_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='industry',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Industry this site belongs to',
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='sites',
|
||||
to='igny8_core_auth.industry'
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='site',
|
||||
index=models.Index(fields=['industry'], name='igny8_site_industr_idx'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
"""Add extended plan configuration fields"""
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0006_add_industry_to_site'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='ai_cost_per_request',
|
||||
field=models.JSONField(default=dict, help_text="Cost per request type (e.g., {'cluster': 2, 'idea': 3, 'content': 5, 'image': 1})"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='allow_credit_topup',
|
||||
field=models.BooleanField(default=True, help_text='Can user purchase more credits?'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='billing_cycle',
|
||||
field=models.CharField(choices=[('monthly', 'Monthly'), ('annual', 'Annual')], default='monthly', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='daily_ai_request_limit',
|
||||
field=models.IntegerField(default=100, help_text='Global daily AI request cap', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='daily_ai_requests',
|
||||
field=models.IntegerField(default=50, help_text='Total AI executions (content + idea + image) allowed per day', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='daily_cluster_limit',
|
||||
field=models.IntegerField(default=10, help_text='Max clusters that can be created per day', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='daily_content_tasks',
|
||||
field=models.IntegerField(default=10, help_text='Max number of content tasks (blogs) per day', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='daily_keyword_import_limit',
|
||||
field=models.IntegerField(default=100, help_text='SeedKeywords import limit per day', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='extra_credit_price',
|
||||
field=models.DecimalField(decimal_places=2, default=Decimal('0.01'), help_text='Price per additional credit', max_digits=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='image_model_choices',
|
||||
field=models.JSONField(default=list, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='included_credits',
|
||||
field=models.IntegerField(default=0, help_text='Monthly credits included', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_author_profiles',
|
||||
field=models.IntegerField(default=5, help_text='Limit for saved writing styles', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_clusters',
|
||||
field=models.IntegerField(default=100, help_text='Total clusters allowed (global)', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_images_per_task',
|
||||
field=models.IntegerField(default=4, help_text='Max images per content task', validators=[MinValueValidator(1)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_industries',
|
||||
field=models.IntegerField(blank=True, default=None, help_text='Optional limit for industries/sectors', null=True, validators=[MinValueValidator(1)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_keywords',
|
||||
field=models.IntegerField(default=1000, help_text='Total keywords allowed (global limit)', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_users',
|
||||
field=models.IntegerField(default=1, help_text='Total users allowed per account', validators=[MinValueValidator(1)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='monthly_ai_credit_limit',
|
||||
field=models.IntegerField(default=500, help_text='Unified credit ceiling per month (all AI functions)', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='monthly_cluster_ai_credits',
|
||||
field=models.IntegerField(default=50, help_text='AI credits allocated for clustering', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='monthly_content_ai_credits',
|
||||
field=models.IntegerField(default=200, help_text='AI credit pool for content generation', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='monthly_image_ai_credits',
|
||||
field=models.IntegerField(default=100, help_text='AI credit pool for image generation', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='monthly_image_count',
|
||||
field=models.IntegerField(default=100, help_text='Max images per month', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='monthly_word_count_limit',
|
||||
field=models.IntegerField(default=50000, help_text='Monthly word limit (for generated content)', validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='auto_credit_topup_threshold',
|
||||
field=models.IntegerField(blank=True, default=None, help_text='Auto top-up trigger point (optional)', null=True, validators=[MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='auto_credit_topup_amount',
|
||||
field=models.IntegerField(blank=True, default=None, help_text='How many credits to auto-buy', null=True, validators=[MinValueValidator(1)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='stripe_product_id',
|
||||
field=models.CharField(blank=True, help_text='For Stripe plan sync', max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='plan',
|
||||
name='features',
|
||||
field=models.JSONField(default=list, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])"),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-07 10:06
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0007_expand_plan_limits'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PasswordResetToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token', models.CharField(db_index=True, max_length=255, unique=True)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('used', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'igny8_password_reset_tokens',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='industry',
|
||||
options={'ordering': ['name'], 'verbose_name': 'Industry', 'verbose_name_plural': 'Industries'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='industrysector',
|
||||
options={'ordering': ['industry', 'name'], 'verbose_name': 'Industry Sector', 'verbose_name_plural': 'Industry Sectors'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='site',
|
||||
options={'ordering': ['-created_at']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='siteuseraccess',
|
||||
options={'verbose_name': 'Site User Access', 'verbose_name_plural': 'Site User Access'},
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='industry',
|
||||
new_name='igny8_indus_slug_2f8769_idx',
|
||||
old_name='igny8_indu_slug_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='industry',
|
||||
new_name='igny8_indus_is_acti_146d41_idx',
|
||||
old_name='igny8_indu_is_acti_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='industrysector',
|
||||
new_name='igny8_indus_industr_00b524_idx',
|
||||
old_name='igny8_indu_industr_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='industrysector',
|
||||
new_name='igny8_indus_slug_101d63_idx',
|
||||
old_name='igny8_indu_slug_1_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='sector',
|
||||
new_name='igny8_secto_industr_1cf990_idx',
|
||||
old_name='igny8_sect_industr_idx',
|
||||
),
|
||||
migrations.RenameIndex(
|
||||
model_name='site',
|
||||
new_name='igny8_sites_industr_66e004_idx',
|
||||
old_name='igny8_site_industr_idx',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='plan',
|
||||
name='credits_per_month',
|
||||
field=models.IntegerField(default=0, help_text='DEPRECATED: Use included_credits instead', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='plan',
|
||||
name='extra_credit_price',
|
||||
field=models.DecimalField(decimal_places=2, default=0.01, help_text='Price per additional credit', max_digits=10),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='plan',
|
||||
name='stripe_price_id',
|
||||
field=models.CharField(blank=True, help_text='Monthly price ID for Stripe', max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='passwordresettoken',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_reset_tokens', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='passwordresettoken',
|
||||
index=models.Index(fields=['token'], name='igny8_passw_token_0eaf0c_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='passwordresettoken',
|
||||
index=models.Index(fields=['user', 'used'], name='igny8_passw_user_id_320c02_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='passwordresettoken',
|
||||
index=models.Index(fields=['expires_at'], name='igny8_passw_expires_c9aa03_idx'),
|
||||
),
|
||||
]
|
||||
@@ -1,88 +0,0 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forward_fix_admin_log_fk(apps, schema_editor):
|
||||
if schema_editor.connection.vendor != "postgresql":
|
||||
return
|
||||
schema_editor.execute(
|
||||
"""
|
||||
ALTER TABLE django_admin_log
|
||||
DROP CONSTRAINT IF EXISTS django_admin_log_user_id_c564eba6_fk_auth_user_id;
|
||||
"""
|
||||
)
|
||||
schema_editor.execute(
|
||||
"""
|
||||
UPDATE django_admin_log
|
||||
SET user_id = sub.new_user_id
|
||||
FROM (
|
||||
SELECT id AS new_user_id
|
||||
FROM igny8_users
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
) AS sub
|
||||
WHERE django_admin_log.user_id NOT IN (
|
||||
SELECT id FROM igny8_users
|
||||
);
|
||||
"""
|
||||
)
|
||||
schema_editor.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'django_admin_log_user_id_c564eba6_fk_igny8_users_id'
|
||||
) THEN
|
||||
ALTER TABLE django_admin_log
|
||||
ADD CONSTRAINT django_admin_log_user_id_c564eba6_fk_igny8_users_id
|
||||
FOREIGN KEY (user_id) REFERENCES igny8_users(id) DEFERRABLE INITIALLY DEFERRED;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def reverse_fix_admin_log_fk(apps, schema_editor):
|
||||
if schema_editor.connection.vendor != "postgresql":
|
||||
return
|
||||
schema_editor.execute(
|
||||
"""
|
||||
ALTER TABLE django_admin_log
|
||||
DROP CONSTRAINT IF EXISTS django_admin_log_user_id_c564eba6_fk_igny8_users_id;
|
||||
"""
|
||||
)
|
||||
schema_editor.execute(
|
||||
"""
|
||||
UPDATE django_admin_log
|
||||
SET user_id = sub.old_user_id
|
||||
FROM (
|
||||
SELECT id AS old_user_id
|
||||
FROM auth_user
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
) AS sub
|
||||
WHERE django_admin_log.user_id NOT IN (
|
||||
SELECT id FROM auth_user
|
||||
);
|
||||
"""
|
||||
)
|
||||
schema_editor.execute(
|
||||
"""
|
||||
ALTER TABLE django_admin_log
|
||||
ADD CONSTRAINT django_admin_log_user_id_c564eba6_fk_auth_user_id
|
||||
FOREIGN KEY (user_id) REFERENCES auth_user(id) DEFERRABLE INITIALLY DEFERRED;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("igny8_core_auth", "0008_passwordresettoken_alter_industry_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward_fix_admin_log_fk, reverse_fix_admin_log_fk),
|
||||
]
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-07 11:34
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0009_fix_admin_log_user_fk'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SeedKeyword',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('keyword', models.CharField(db_index=True, max_length=255)),
|
||||
('volume', models.IntegerField(default=0, help_text='Search volume estimate')),
|
||||
('difficulty', models.IntegerField(default=0, help_text='Keyword difficulty (0-100)', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])),
|
||||
('intent', models.CharField(choices=[('informational', 'Informational'), ('navigational', 'Navigational'), ('commercial', 'Commercial'), ('transactional', 'Transactional')], default='informational', max_length=50)),
|
||||
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('industry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seed_keywords', to='igny8_core_auth.industry')),
|
||||
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seed_keywords', to='igny8_core_auth.industrysector')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Seed Keyword',
|
||||
'verbose_name_plural': 'Seed Keywords',
|
||||
'db_table': 'igny8_seed_keywords',
|
||||
'ordering': ['keyword'],
|
||||
'indexes': [models.Index(fields=['keyword'], name='igny8_seed__keyword_efa089_idx'), models.Index(fields=['industry', 'sector'], name='igny8_seed__industr_c41841_idx'), models.Index(fields=['industry', 'sector', 'is_active'], name='igny8_seed__industr_da0030_idx'), models.Index(fields=['intent'], name='igny8_seed__intent_15020d_idx')],
|
||||
'unique_together': {('keyword', 'industry', 'sector')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-07 11:45
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0010_add_seed_keyword'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='daily_image_generation_limit',
|
||||
field=models.IntegerField(default=25, help_text='Max images that can be generated per day', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='plan',
|
||||
name='max_content_ideas',
|
||||
field=models.IntegerField(default=300, help_text='Total content ideas allowed (global limit)', validators=[django.core.validators.MinValueValidator(0)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='plan',
|
||||
name='max_sites',
|
||||
field=models.IntegerField(default=1, help_text='Maximum number of sites allowed', validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-07 11:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0011_add_plan_fields_and_fix_constraints'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='plan',
|
||||
name='ai_cost_per_request',
|
||||
field=models.JSONField(blank=True, default=dict, help_text="Cost per request type (e.g., {'cluster': 2, 'idea': 3, 'content': 5, 'image': 1})"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='plan',
|
||||
name='features',
|
||||
field=models.JSONField(blank=True, default=list, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='plan',
|
||||
name='image_model_choices',
|
||||
field=models.JSONField(blank=True, default=list, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])"),
|
||||
),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-07 12:01
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0012_allow_blank_json_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='ai_cost_per_request',
|
||||
),
|
||||
]
|
||||
@@ -93,8 +93,8 @@ class Account(models.Model):
|
||||
|
||||
class Plan(models.Model):
|
||||
"""
|
||||
Subscription plan model with comprehensive limits and features.
|
||||
Plans define limits for users, sites, content generation, AI usage, and billing.
|
||||
Subscription plan model - Phase 0: Credit-only system.
|
||||
Plans define credits, billing, and account management limits only.
|
||||
"""
|
||||
BILLING_CYCLE_CHOICES = [
|
||||
('monthly', 'Monthly'),
|
||||
@@ -110,7 +110,7 @@ class Plan(models.Model):
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# User / Site / Scope Limits
|
||||
# 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,
|
||||
@@ -120,32 +120,7 @@ class Plan(models.Model):
|
||||
max_industries = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="Optional limit for industries/sectors")
|
||||
max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles")
|
||||
|
||||
# Planner Limits
|
||||
max_keywords = models.IntegerField(default=1000, validators=[MinValueValidator(0)], help_text="Total keywords allowed (global limit)")
|
||||
max_clusters = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Total clusters allowed (global)")
|
||||
max_content_ideas = models.IntegerField(default=300, validators=[MinValueValidator(0)], help_text="Total content ideas allowed (global limit)")
|
||||
daily_cluster_limit = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max clusters that can be created per day")
|
||||
daily_keyword_import_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="SeedKeywords import limit per day")
|
||||
monthly_cluster_ai_credits = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="AI credits allocated for clustering")
|
||||
|
||||
# Writer Limits
|
||||
daily_content_tasks = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max number of content tasks (blogs) per day")
|
||||
daily_ai_requests = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="Total AI executions (content + idea + image) allowed per day")
|
||||
monthly_word_count_limit = models.IntegerField(default=50000, validators=[MinValueValidator(0)], help_text="Monthly word limit (for generated content)")
|
||||
monthly_content_ai_credits = models.IntegerField(default=200, validators=[MinValueValidator(0)], help_text="AI credit pool for content generation")
|
||||
|
||||
# Image Generation Limits
|
||||
monthly_image_count = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Max images per month")
|
||||
daily_image_generation_limit = models.IntegerField(default=25, validators=[MinValueValidator(0)], help_text="Max images that can be generated per day")
|
||||
monthly_image_ai_credits = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="AI credit pool for image generation")
|
||||
max_images_per_task = models.IntegerField(default=4, validators=[MinValueValidator(1)], help_text="Max images per content task")
|
||||
image_model_choices = models.JSONField(default=list, blank=True, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])")
|
||||
|
||||
# AI Request Controls
|
||||
daily_ai_request_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Global daily AI request cap")
|
||||
monthly_ai_credit_limit = models.IntegerField(default=500, validators=[MinValueValidator(0)], help_text="Unified credit ceiling per month (all AI functions)")
|
||||
|
||||
# Billing & Add-ons
|
||||
# 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?")
|
||||
@@ -238,10 +213,50 @@ class Site(AccountBaseModel):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# WordPress integration fields
|
||||
wp_url = models.URLField(blank=True, null=True, help_text="WordPress site URL")
|
||||
# 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"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_sites'
|
||||
@@ -251,6 +266,8 @@ class Site(AccountBaseModel):
|
||||
models.Index(fields=['account', 'is_active']),
|
||||
models.Index(fields=['account', 'status']),
|
||||
models.Index(fields=['industry']),
|
||||
models.Index(fields=['site_type']),
|
||||
models.Index(fields=['hosting_type']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -11,10 +11,10 @@ class PlanSerializer(serializers.ModelSerializer):
|
||||
model = Plan
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'price', 'billing_cycle', 'features', 'is_active',
|
||||
'max_users', 'max_sites', 'max_keywords', 'max_clusters', 'max_content_ideas',
|
||||
'monthly_word_count_limit', 'monthly_ai_credit_limit', 'monthly_image_count',
|
||||
'daily_content_tasks', 'daily_ai_request_limit', 'daily_image_generation_limit',
|
||||
'included_credits', 'image_model_choices', 'credits_per_month'
|
||||
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
|
||||
'included_credits', 'extra_credit_price', 'allow_credit_topup',
|
||||
'auto_credit_topup_threshold', 'auto_credit_topup_amount',
|
||||
'stripe_product_id', 'stripe_price_id', 'credits_per_month'
|
||||
]
|
||||
|
||||
|
||||
@@ -68,7 +68,8 @@ class SiteSerializer(serializers.ModelSerializer):
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'domain', 'description',
|
||||
'industry', 'industry_name', 'industry_slug',
|
||||
'is_active', 'status', 'wp_url', 'wp_username',
|
||||
'is_active', 'status', 'wp_url', 'wp_username', 'wp_api_key',
|
||||
'site_type', 'hosting_type', 'seo_metadata',
|
||||
'sectors_count', 'active_sectors_count', 'selected_sectors',
|
||||
'can_add_sectors',
|
||||
'created_at', 'updated_at'
|
||||
|
||||
@@ -14,8 +14,10 @@ from .views import (
|
||||
SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet,
|
||||
IndustryViewSet, SeedKeywordViewSet
|
||||
)
|
||||
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer
|
||||
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer, RefreshTokenSerializer
|
||||
from .models import User
|
||||
from .utils import generate_access_token, get_token_expiry, decode_token
|
||||
import jwt
|
||||
|
||||
router = DefaultRouter()
|
||||
# Main structure: Groups, Users, Accounts, Subscriptions, Site User Access
|
||||
@@ -78,7 +80,7 @@ class LoginView(APIView):
|
||||
password = serializer.validated_data['password']
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
user = User.objects.select_related('account', 'account__plan').get(email=email)
|
||||
except User.DoesNotExist:
|
||||
return error_response(
|
||||
error='Invalid credentials',
|
||||
@@ -107,9 +109,17 @@ class LoginView(APIView):
|
||||
user_data = user_serializer.data
|
||||
except Exception as e:
|
||||
# Fallback if serializer fails (e.g., missing account_id column)
|
||||
# Log the error for debugging but don't fail the login
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"UserSerializer failed for user {user.id}: {e}", exc_info=True)
|
||||
|
||||
# Ensure username is properly set (use email prefix if username is empty/default)
|
||||
username = user.username if user.username and user.username != 'user' else user.email.split('@')[0]
|
||||
|
||||
user_data = {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'username': username,
|
||||
'email': user.email,
|
||||
'role': user.role,
|
||||
'account': None,
|
||||
@@ -119,12 +129,10 @@ class LoginView(APIView):
|
||||
return success_response(
|
||||
data={
|
||||
'user': user_data,
|
||||
'tokens': {
|
||||
'access': access_token,
|
||||
'refresh': refresh_token,
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
}
|
||||
'access': access_token,
|
||||
'refresh': refresh_token,
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
},
|
||||
message='Login successful',
|
||||
request=request
|
||||
@@ -180,6 +188,84 @@ class ChangePasswordView(APIView):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=['Authentication'],
|
||||
summary='Refresh Token',
|
||||
description='Refresh access token using refresh token'
|
||||
)
|
||||
class RefreshTokenView(APIView):
|
||||
"""Refresh access token endpoint."""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
serializer = RefreshTokenSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
refresh_token = serializer.validated_data['refresh']
|
||||
|
||||
try:
|
||||
# Decode and validate refresh token
|
||||
payload = decode_token(refresh_token)
|
||||
|
||||
# Verify it's a refresh token
|
||||
if payload.get('type') != 'refresh':
|
||||
return error_response(
|
||||
error='Invalid token type',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get user
|
||||
user_id = payload.get('user_id')
|
||||
account_id = payload.get('account_id')
|
||||
|
||||
try:
|
||||
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return error_response(
|
||||
error='User not found',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get account
|
||||
account = None
|
||||
if account_id:
|
||||
try:
|
||||
from .models import Account
|
||||
account = Account.objects.get(id=account_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not account:
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
# Generate new access token
|
||||
access_token = generate_access_token(user, account)
|
||||
access_expires_at = get_token_expiry('access')
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'access': access_token,
|
||||
'access_expires_at': access_expires_at.isoformat()
|
||||
},
|
||||
request=request
|
||||
)
|
||||
|
||||
except jwt.InvalidTokenError:
|
||||
return error_response(
|
||||
error='Invalid or expired refresh token',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(exclude=True) # Exclude from public API documentation - internal authenticated endpoint
|
||||
class MeView(APIView):
|
||||
"""Get current user information."""
|
||||
@@ -201,6 +287,7 @@ urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
|
||||
path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'),
|
||||
path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'),
|
||||
path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
|
||||
path('me/', MeView.as_view(), name='auth-me'),
|
||||
]
|
||||
|
||||
@@ -478,16 +478,26 @@ class SiteViewSet(AccountModelViewSet):
|
||||
|
||||
def get_permissions(self):
|
||||
"""Allow normal users (viewer) to create sites, but require editor+ for other operations."""
|
||||
# Allow public read access for list requests with slug filter (used by Sites Renderer)
|
||||
if self.action == 'list' and self.request.query_params.get('slug'):
|
||||
from rest_framework.permissions import AllowAny
|
||||
return [AllowAny()]
|
||||
if self.action == 'create':
|
||||
return [permissions.IsAuthenticated()]
|
||||
return [IsEditorOrAbove()]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return sites accessible to the current user."""
|
||||
user = self.request.user
|
||||
if not user or not user.is_authenticated:
|
||||
# If this is a public request (no auth) with slug filter, return site by slug
|
||||
if not self.request.user or not self.request.user.is_authenticated:
|
||||
slug = self.request.query_params.get('slug')
|
||||
if slug:
|
||||
# Return queryset directly from model (bypassing base class account filtering)
|
||||
return Site.objects.filter(slug=slug, is_active=True)
|
||||
return Site.objects.none()
|
||||
|
||||
user = self.request.user
|
||||
|
||||
# ADMIN/DEV OVERRIDE: Both admins and developers can see all sites
|
||||
if user.is_admin_or_developer():
|
||||
return Site.objects.all().distinct()
|
||||
@@ -916,13 +926,28 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
|
||||
if user.check_password(password):
|
||||
# Ensure user has an account
|
||||
account = getattr(user, 'account', None)
|
||||
if account is None:
|
||||
return error_response(
|
||||
error='Account not configured for this user. Please contact support.',
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
request=request,
|
||||
)
|
||||
|
||||
# Ensure account has an active plan
|
||||
plan = getattr(account, 'plan', None)
|
||||
if plan is None or getattr(plan, 'is_active', False) is False:
|
||||
return error_response(
|
||||
error='Active subscription required. Visit igny8.com/pricing to subscribe.',
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
request=request,
|
||||
)
|
||||
|
||||
# Log the user in (create session for session authentication)
|
||||
from django.contrib.auth import login
|
||||
login(request, user)
|
||||
|
||||
# Get account from user
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
# Generate JWT tokens
|
||||
access_token = generate_access_token(user, account)
|
||||
refresh_token = generate_refresh_token(user, account)
|
||||
@@ -933,12 +958,10 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
return success_response(
|
||||
data={
|
||||
'user': user_serializer.data,
|
||||
'tokens': {
|
||||
'access': access_token,
|
||||
'refresh': refresh_token,
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
}
|
||||
'access': access_token,
|
||||
'refresh': refresh_token,
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
},
|
||||
message='Login successful',
|
||||
request=request
|
||||
|
||||
5
backend/igny8_core/business/__init__.py
Normal file
5
backend/igny8_core/business/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Business logic layer - Models and Services
|
||||
Separated from API layer (modules/) for clean architecture
|
||||
"""
|
||||
|
||||
4
backend/igny8_core/business/automation/__init__.py
Normal file
4
backend/igny8_core/business/automation/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Automation business logic - AutomationRule, ScheduledTask models and services
|
||||
"""
|
||||
|
||||
143
backend/igny8_core/business/automation/models.py
Normal file
143
backend/igny8_core/business/automation/models.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Automation Models
|
||||
Phase 2: Automation System
|
||||
"""
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from igny8_core.auth.models import SiteSectorBaseModel, AccountBaseModel
|
||||
import json
|
||||
|
||||
|
||||
class AutomationRule(SiteSectorBaseModel):
|
||||
"""
|
||||
Automation Rule model for defining automated workflows.
|
||||
|
||||
Rules can be triggered by:
|
||||
- schedule: Time-based triggers (cron-like)
|
||||
- event: Event-based triggers (content created, keyword added, etc.)
|
||||
- manual: Manual execution only
|
||||
"""
|
||||
|
||||
TRIGGER_CHOICES = [
|
||||
('schedule', 'Schedule'),
|
||||
('event', 'Event'),
|
||||
('manual', 'Manual'),
|
||||
]
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('active', 'Active'),
|
||||
('inactive', 'Inactive'),
|
||||
('paused', 'Paused'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255, help_text="Rule name")
|
||||
description = models.TextField(blank=True, null=True, help_text="Rule description")
|
||||
|
||||
# Trigger configuration
|
||||
trigger = models.CharField(max_length=50, choices=TRIGGER_CHOICES, default='manual')
|
||||
|
||||
# Schedule configuration (for schedule triggers)
|
||||
# Stored as cron-like string: "0 0 * * *" (daily at midnight)
|
||||
schedule = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Cron-like schedule string (e.g., '0 0 * * *' for daily at midnight)"
|
||||
)
|
||||
|
||||
# Conditions (JSON field)
|
||||
# Format: [{"field": "content.status", "operator": "equals", "value": "draft"}, ...]
|
||||
conditions = models.JSONField(
|
||||
default=list,
|
||||
help_text="List of conditions that must be met for rule to execute"
|
||||
)
|
||||
|
||||
# Actions (JSON field)
|
||||
# Format: [{"type": "generate_content", "params": {...}}, ...]
|
||||
actions = models.JSONField(
|
||||
default=list,
|
||||
help_text="List of actions to execute when rule triggers"
|
||||
)
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(default=True, help_text="Whether rule is active")
|
||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='active')
|
||||
|
||||
# Execution tracking
|
||||
last_executed_at = models.DateTimeField(null=True, blank=True)
|
||||
execution_count = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||
|
||||
# Metadata
|
||||
metadata = models.JSONField(default=dict, help_text="Additional metadata")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'automation'
|
||||
db_table = 'igny8_automation_rules'
|
||||
ordering = ['-created_at']
|
||||
verbose_name = 'Automation Rule'
|
||||
verbose_name_plural = 'Automation Rules'
|
||||
indexes = [
|
||||
models.Index(fields=['trigger', 'is_active']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['site', 'sector']),
|
||||
models.Index(fields=['trigger', 'is_active', 'status']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_trigger_display()})"
|
||||
|
||||
|
||||
class ScheduledTask(AccountBaseModel):
|
||||
"""
|
||||
Scheduled Task model for tracking scheduled automation rule executions.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Pending'),
|
||||
('running', 'Running'),
|
||||
('completed', 'Completed'),
|
||||
('failed', 'Failed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
]
|
||||
|
||||
automation_rule = models.ForeignKey(
|
||||
AutomationRule,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='scheduled_tasks',
|
||||
help_text="The automation rule this task belongs to"
|
||||
)
|
||||
|
||||
scheduled_at = models.DateTimeField(help_text="When the task is scheduled to run")
|
||||
executed_at = models.DateTimeField(null=True, blank=True, help_text="When the task was actually executed")
|
||||
|
||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='pending')
|
||||
|
||||
# Execution results
|
||||
result = models.JSONField(default=dict, help_text="Execution result data")
|
||||
error_message = models.TextField(blank=True, null=True, help_text="Error message if execution failed")
|
||||
|
||||
# Metadata
|
||||
metadata = models.JSONField(default=dict, help_text="Additional metadata")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'automation'
|
||||
db_table = 'igny8_scheduled_tasks'
|
||||
ordering = ['-scheduled_at']
|
||||
verbose_name = 'Scheduled Task'
|
||||
verbose_name_plural = 'Scheduled Tasks'
|
||||
indexes = [
|
||||
models.Index(fields=['automation_rule', 'status']),
|
||||
models.Index(fields=['scheduled_at', 'status']),
|
||||
models.Index(fields=['account', 'status']),
|
||||
models.Index(fields=['status', 'scheduled_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Scheduled task for {self.automation_rule.name} at {self.scheduled_at}"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Automation services
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Action Executor
|
||||
Executes rule actions
|
||||
"""
|
||||
import logging
|
||||
from igny8_core.business.planning.services.clustering_service import ClusteringService
|
||||
from igny8_core.business.planning.services.ideas_service import IdeasService
|
||||
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ActionExecutor:
|
||||
"""Executes rule actions"""
|
||||
|
||||
def __init__(self):
|
||||
self.clustering_service = ClusteringService()
|
||||
self.ideas_service = IdeasService()
|
||||
self.content_service = ContentGenerationService()
|
||||
|
||||
def execute(self, action, context, rule):
|
||||
"""
|
||||
Execute a single action.
|
||||
|
||||
Args:
|
||||
action: Action dict with 'type' and 'params'
|
||||
context: Context dict
|
||||
rule: AutomationRule instance
|
||||
|
||||
Returns:
|
||||
dict: Action execution result
|
||||
"""
|
||||
action_type = action.get('type')
|
||||
params = action.get('params', {})
|
||||
|
||||
if action_type == 'cluster_keywords':
|
||||
return self._execute_cluster_keywords(params, rule)
|
||||
elif action_type == 'generate_ideas':
|
||||
return self._execute_generate_ideas(params, rule)
|
||||
elif action_type == 'generate_content':
|
||||
return self._execute_generate_content(params, rule)
|
||||
else:
|
||||
logger.warning(f"Unknown action type: {action_type}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Unknown action type: {action_type}'
|
||||
}
|
||||
|
||||
def _execute_cluster_keywords(self, params, rule):
|
||||
"""Execute cluster keywords action"""
|
||||
keyword_ids = params.get('keyword_ids', [])
|
||||
sector_id = params.get('sector_id') or (rule.sector.id if rule.sector else None)
|
||||
|
||||
try:
|
||||
result = self.clustering_service.cluster_keywords(
|
||||
keyword_ids=keyword_ids,
|
||||
account=rule.account,
|
||||
sector_id=sector_id
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error clustering keywords: {str(e)}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def _execute_generate_ideas(self, params, rule):
|
||||
"""Execute generate ideas action"""
|
||||
cluster_ids = params.get('cluster_ids', [])
|
||||
|
||||
try:
|
||||
result = self.ideas_service.generate_ideas(
|
||||
cluster_ids=cluster_ids,
|
||||
account=rule.account
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating ideas: {str(e)}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def _execute_generate_content(self, params, rule):
|
||||
"""Execute generate content action"""
|
||||
task_ids = params.get('task_ids', [])
|
||||
|
||||
try:
|
||||
result = self.content_service.generate_content(
|
||||
task_ids=task_ids,
|
||||
account=rule.account
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating content: {str(e)}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Automation Service
|
||||
Main service for executing automation rules
|
||||
"""
|
||||
import logging
|
||||
from django.utils import timezone
|
||||
from igny8_core.business.automation.models import AutomationRule, ScheduledTask
|
||||
from igny8_core.business.automation.services.rule_engine import RuleEngine
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AutomationService:
|
||||
"""Service for executing automation rules"""
|
||||
|
||||
def __init__(self):
|
||||
self.rule_engine = RuleEngine()
|
||||
self.credit_service = CreditService()
|
||||
|
||||
def execute_rule(self, rule, context=None):
|
||||
"""
|
||||
Execute an automation rule.
|
||||
|
||||
Args:
|
||||
rule: AutomationRule instance
|
||||
context: Optional context dict for condition evaluation
|
||||
|
||||
Returns:
|
||||
dict: Execution result with status and data
|
||||
"""
|
||||
if not rule.is_active or rule.status != 'active':
|
||||
return {
|
||||
'status': 'skipped',
|
||||
'reason': 'Rule is inactive',
|
||||
'rule_id': rule.id
|
||||
}
|
||||
|
||||
# Check credits (estimate based on actions)
|
||||
estimated_credits = self._estimate_credits(rule)
|
||||
try:
|
||||
self.credit_service.check_credits_legacy(rule.account, estimated_credits)
|
||||
except InsufficientCreditsError as e:
|
||||
logger.warning(f"Rule {rule.id} skipped: {str(e)}")
|
||||
return {
|
||||
'status': 'skipped',
|
||||
'reason': f'Insufficient credits: {str(e)}',
|
||||
'rule_id': rule.id
|
||||
}
|
||||
|
||||
# Execute via rule engine
|
||||
try:
|
||||
result = self.rule_engine.execute(rule, context or {})
|
||||
|
||||
# Update rule tracking
|
||||
rule.last_executed_at = timezone.now()
|
||||
rule.execution_count += 1
|
||||
rule.save(update_fields=['last_executed_at', 'execution_count'])
|
||||
|
||||
return {
|
||||
'status': 'completed',
|
||||
'rule_id': rule.id,
|
||||
'result': result
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing rule {rule.id}: {str(e)}", exc_info=True)
|
||||
return {
|
||||
'status': 'failed',
|
||||
'reason': str(e),
|
||||
'rule_id': rule.id
|
||||
}
|
||||
|
||||
def _estimate_credits(self, rule):
|
||||
"""Estimate credits needed for rule execution"""
|
||||
# Simple estimation based on action types
|
||||
estimated = 0
|
||||
for action in rule.actions:
|
||||
action_type = action.get('type', '')
|
||||
if 'cluster' in action_type:
|
||||
estimated += 10
|
||||
elif 'idea' in action_type:
|
||||
estimated += 15
|
||||
elif 'content' in action_type:
|
||||
estimated += 50 # Conservative estimate
|
||||
else:
|
||||
estimated += 5 # Default
|
||||
return max(estimated, 10) # Minimum 10 credits
|
||||
|
||||
def execute_scheduled_rules(self):
|
||||
"""
|
||||
Execute all scheduled rules that are due.
|
||||
Called by Celery Beat task.
|
||||
|
||||
Returns:
|
||||
dict: Summary of executions
|
||||
"""
|
||||
from django.utils import timezone
|
||||
now = timezone.now()
|
||||
|
||||
# Get active scheduled rules
|
||||
rules = AutomationRule.objects.filter(
|
||||
trigger='schedule',
|
||||
is_active=True,
|
||||
status='active'
|
||||
)
|
||||
|
||||
executed = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
for rule in rules:
|
||||
# Check if rule should execute based on schedule
|
||||
if self._should_execute_schedule(rule, now):
|
||||
result = self.execute_rule(rule)
|
||||
if result['status'] == 'completed':
|
||||
executed += 1
|
||||
elif result['status'] == 'skipped':
|
||||
skipped += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
return {
|
||||
'executed': executed,
|
||||
'skipped': skipped,
|
||||
'failed': failed,
|
||||
'total': len(rules)
|
||||
}
|
||||
|
||||
def _should_execute_schedule(self, rule, now):
|
||||
"""
|
||||
Check if a scheduled rule should execute now.
|
||||
Simple implementation - can be enhanced with proper cron parsing.
|
||||
"""
|
||||
if not rule.schedule:
|
||||
return False
|
||||
|
||||
# For now, simple check - can be enhanced with cron parser
|
||||
# This is a placeholder - proper implementation would parse cron string
|
||||
return True # Simplified for now
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Condition Evaluator
|
||||
Evaluates rule conditions
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConditionEvaluator:
|
||||
"""Evaluates rule conditions"""
|
||||
|
||||
OPERATORS = {
|
||||
'equals': lambda a, b: a == b,
|
||||
'not_equals': lambda a, b: a != b,
|
||||
'greater_than': lambda a, b: a > b,
|
||||
'greater_than_or_equal': lambda a, b: a >= b,
|
||||
'less_than': lambda a, b: a < b,
|
||||
'less_than_or_equal': lambda a, b: a <= b,
|
||||
'in': lambda a, b: a in b,
|
||||
'contains': lambda a, b: b in a if isinstance(a, str) else a in b,
|
||||
'is_empty': lambda a, b: not a or (isinstance(a, str) and not a.strip()),
|
||||
'is_not_empty': lambda a, b: a and (not isinstance(a, str) or a.strip()),
|
||||
}
|
||||
|
||||
def evaluate(self, conditions, context):
|
||||
"""
|
||||
Evaluate a list of conditions.
|
||||
|
||||
Args:
|
||||
conditions: List of condition dicts
|
||||
context: Context dict for field resolution
|
||||
|
||||
Returns:
|
||||
bool: True if all conditions are met
|
||||
"""
|
||||
if not conditions:
|
||||
return True
|
||||
|
||||
for condition in conditions:
|
||||
if not self._evaluate_condition(condition, context):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _evaluate_condition(self, condition, context):
|
||||
"""
|
||||
Evaluate a single condition.
|
||||
|
||||
Condition format:
|
||||
{
|
||||
"field": "content.status",
|
||||
"operator": "equals",
|
||||
"value": "draft"
|
||||
}
|
||||
"""
|
||||
field_path = condition.get('field')
|
||||
operator = condition.get('operator', 'equals')
|
||||
expected_value = condition.get('value')
|
||||
|
||||
if not field_path:
|
||||
logger.warning("Condition missing 'field'")
|
||||
return False
|
||||
|
||||
# Resolve field value from context
|
||||
actual_value = self._resolve_field(field_path, context)
|
||||
|
||||
# Get operator function
|
||||
op_func = self.OPERATORS.get(operator)
|
||||
if not op_func:
|
||||
logger.warning(f"Unknown operator: {operator}")
|
||||
return False
|
||||
|
||||
# Evaluate
|
||||
try:
|
||||
return op_func(actual_value, expected_value)
|
||||
except Exception as e:
|
||||
logger.error(f"Error evaluating condition: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _resolve_field(self, field_path, context):
|
||||
"""
|
||||
Resolve a field path from context.
|
||||
|
||||
Examples:
|
||||
- "content.status" -> context['content']['status']
|
||||
- "count" -> context['count']
|
||||
"""
|
||||
parts = field_path.split('.')
|
||||
value = context
|
||||
|
||||
for part in parts:
|
||||
if isinstance(value, dict):
|
||||
value = value.get(part)
|
||||
elif hasattr(value, part):
|
||||
value = getattr(value, part)
|
||||
else:
|
||||
return None
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Rule Engine
|
||||
Orchestrates rule execution
|
||||
"""
|
||||
import logging
|
||||
from igny8_core.business.automation.services.condition_evaluator import ConditionEvaluator
|
||||
from igny8_core.business.automation.services.action_executor import ActionExecutor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RuleEngine:
|
||||
"""Orchestrates rule execution"""
|
||||
|
||||
def __init__(self):
|
||||
self.condition_evaluator = ConditionEvaluator()
|
||||
self.action_executor = ActionExecutor()
|
||||
|
||||
def execute(self, rule, context):
|
||||
"""
|
||||
Execute a rule by evaluating conditions and executing actions.
|
||||
|
||||
Args:
|
||||
rule: AutomationRule instance
|
||||
context: Context dict for evaluation
|
||||
|
||||
Returns:
|
||||
dict: Execution results
|
||||
"""
|
||||
# Evaluate conditions
|
||||
if rule.conditions:
|
||||
conditions_met = self.condition_evaluator.evaluate(rule.conditions, context)
|
||||
if not conditions_met:
|
||||
return {
|
||||
'success': False,
|
||||
'reason': 'Conditions not met'
|
||||
}
|
||||
|
||||
# Execute actions
|
||||
action_results = []
|
||||
for action in rule.actions:
|
||||
try:
|
||||
result = self.action_executor.execute(action, context, rule)
|
||||
action_results.append({
|
||||
'action': action,
|
||||
'success': True,
|
||||
'result': result
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Action execution failed: {str(e)}", exc_info=True)
|
||||
action_results.append({
|
||||
'action': action,
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'actions': action_results
|
||||
}
|
||||
|
||||
28
backend/igny8_core/business/automation/tasks.py
Normal file
28
backend/igny8_core/business/automation/tasks.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Automation Celery Tasks
|
||||
"""
|
||||
from celery import shared_task
|
||||
import logging
|
||||
from igny8_core.business.automation.services.automation_service import AutomationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name='igny8_core.business.automation.tasks.execute_scheduled_automation_rules')
|
||||
def execute_scheduled_automation_rules():
|
||||
"""
|
||||
Execute all scheduled automation rules.
|
||||
Called by Celery Beat.
|
||||
"""
|
||||
try:
|
||||
service = AutomationService()
|
||||
result = service.execute_scheduled_rules()
|
||||
logger.info(f"Executed scheduled automation rules: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing scheduled automation rules: {str(e)}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
4
backend/igny8_core/business/billing/__init__.py
Normal file
4
backend/igny8_core/business/billing/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Billing business logic - CreditTransaction, CreditUsageLog models and services
|
||||
"""
|
||||
|
||||
21
backend/igny8_core/business/billing/constants.py
Normal file
21
backend/igny8_core/business/billing/constants.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Credit Cost Constants
|
||||
Phase 0: Credit-only system costs per operation
|
||||
"""
|
||||
CREDIT_COSTS = {
|
||||
'clustering': 10, # Per clustering request
|
||||
'idea_generation': 15, # Per cluster → ideas request
|
||||
'content_generation': 1, # Per 100 words
|
||||
'image_prompt_extraction': 2, # Per content piece
|
||||
'image_generation': 5, # Per image
|
||||
'linking': 8, # Per content piece (NEW)
|
||||
'optimization': 1, # Per 200 words (NEW)
|
||||
'site_structure_generation': 50, # Per site blueprint (NEW)
|
||||
'site_page_generation': 20, # Per page (NEW)
|
||||
# Legacy operation types (for backward compatibility)
|
||||
'ideas': 15, # Alias for idea_generation
|
||||
'content': 3, # Legacy: 3 credits per content piece
|
||||
'images': 5, # Alias for image_generation
|
||||
'reparse': 1, # Per reparse
|
||||
}
|
||||
|
||||
14
backend/igny8_core/business/billing/exceptions.py
Normal file
14
backend/igny8_core/business/billing/exceptions.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Billing Exceptions
|
||||
"""
|
||||
|
||||
|
||||
class InsufficientCreditsError(Exception):
|
||||
"""Raised when account doesn't have enough credits"""
|
||||
pass
|
||||
|
||||
|
||||
class CreditCalculationError(Exception):
|
||||
"""Raised when credit calculation fails"""
|
||||
pass
|
||||
|
||||
77
backend/igny8_core/business/billing/models.py
Normal file
77
backend/igny8_core/business/billing/models.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Billing Models for Credit System
|
||||
"""
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from igny8_core.auth.models import AccountBaseModel
|
||||
|
||||
|
||||
class CreditTransaction(AccountBaseModel):
|
||||
"""Track all credit transactions (additions, deductions)"""
|
||||
TRANSACTION_TYPE_CHOICES = [
|
||||
('purchase', 'Purchase'),
|
||||
('subscription', 'Subscription Renewal'),
|
||||
('refund', 'Refund'),
|
||||
('deduction', 'Usage Deduction'),
|
||||
('adjustment', 'Manual Adjustment'),
|
||||
]
|
||||
|
||||
transaction_type = models.CharField(max_length=20, choices=TRANSACTION_TYPE_CHOICES, db_index=True)
|
||||
amount = models.IntegerField(help_text="Positive for additions, negative for deductions")
|
||||
balance_after = models.IntegerField(help_text="Credit balance after this transaction")
|
||||
description = models.CharField(max_length=255)
|
||||
metadata = models.JSONField(default=dict, help_text="Additional context (AI call details, etc.)")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'billing'
|
||||
db_table = 'igny8_credit_transactions'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['account', 'transaction_type']),
|
||||
models.Index(fields=['account', 'created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
account = getattr(self, 'account', None)
|
||||
return f"{self.get_transaction_type_display()} - {self.amount} credits - {account.name if account else 'No Account'}"
|
||||
|
||||
|
||||
class CreditUsageLog(AccountBaseModel):
|
||||
"""Detailed log of credit usage per AI operation"""
|
||||
OPERATION_TYPE_CHOICES = [
|
||||
('clustering', 'Keyword Clustering'),
|
||||
('idea_generation', 'Content Ideas Generation'),
|
||||
('content_generation', 'Content Generation'),
|
||||
('image_generation', 'Image Generation'),
|
||||
('reparse', 'Content Reparse'),
|
||||
('ideas', 'Content Ideas Generation'), # Legacy
|
||||
('content', 'Content Generation'), # Legacy
|
||||
('images', 'Image Generation'), # Legacy
|
||||
]
|
||||
|
||||
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES, db_index=True)
|
||||
credits_used = models.IntegerField(validators=[MinValueValidator(0)])
|
||||
cost_usd = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
|
||||
model_used = models.CharField(max_length=100, blank=True)
|
||||
tokens_input = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
|
||||
tokens_output = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
|
||||
related_object_type = models.CharField(max_length=50, blank=True) # 'keyword', 'cluster', 'task'
|
||||
related_object_id = models.IntegerField(null=True, blank=True)
|
||||
metadata = models.JSONField(default=dict)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'billing'
|
||||
db_table = 'igny8_credit_usage_logs'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['account', 'operation_type']),
|
||||
models.Index(fields=['account', 'created_at']),
|
||||
models.Index(fields=['account', 'operation_type', 'created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
account = getattr(self, 'account', None)
|
||||
return f"{self.get_operation_type_display()} - {self.credits_used} credits - {account.name if account else 'No Account'}"
|
||||
|
||||
4
backend/igny8_core/business/billing/services/__init__.py
Normal file
4
backend/igny8_core/business/billing/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Billing services
|
||||
"""
|
||||
|
||||
264
backend/igny8_core/business/billing/services/credit_service.py
Normal file
264
backend/igny8_core/business/billing/services/credit_service.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Credit Service for managing credit transactions and deductions
|
||||
"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
|
||||
from igny8_core.business.billing.constants import CREDIT_COSTS
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError, CreditCalculationError
|
||||
from igny8_core.auth.models import Account
|
||||
|
||||
|
||||
class CreditService:
|
||||
"""Service for managing credits"""
|
||||
|
||||
@staticmethod
|
||||
def get_credit_cost(operation_type, amount=None):
|
||||
"""
|
||||
Get credit cost for operation.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation (from CREDIT_COSTS)
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
|
||||
Returns:
|
||||
int: Number of credits required
|
||||
|
||||
Raises:
|
||||
CreditCalculationError: If operation type is unknown
|
||||
"""
|
||||
base_cost = CREDIT_COSTS.get(operation_type, 0)
|
||||
if base_cost == 0:
|
||||
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
|
||||
|
||||
# Variable cost operations
|
||||
if operation_type == 'content_generation' and amount:
|
||||
# Per 100 words
|
||||
return max(1, int(base_cost * (amount / 100)))
|
||||
elif operation_type == 'optimization' and amount:
|
||||
# Per 200 words
|
||||
return max(1, int(base_cost * (amount / 200)))
|
||||
elif operation_type == 'image_generation' and amount:
|
||||
# Per image
|
||||
return base_cost * amount
|
||||
elif operation_type == 'idea_generation' and amount:
|
||||
# Per idea
|
||||
return base_cost * amount
|
||||
|
||||
# Fixed cost operations
|
||||
return base_cost
|
||||
|
||||
@staticmethod
|
||||
def check_credits(account, operation_type, amount=None):
|
||||
"""
|
||||
Check if account has sufficient credits for an operation.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
operation_type: Type of operation
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
required = CreditService.get_credit_cost(operation_type, amount)
|
||||
if account.credits < required:
|
||||
raise InsufficientCreditsError(
|
||||
f"Insufficient credits. Required: {required}, Available: {account.credits}"
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def check_credits_legacy(account, required_credits):
|
||||
"""
|
||||
Legacy method: Check if account has enough credits (for backward compatibility).
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
required_credits: Number of credits required
|
||||
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
if account.credits < required_credits:
|
||||
raise InsufficientCreditsError(
|
||||
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
|
||||
"""
|
||||
Deduct credits and log transaction.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
amount: Number of credits to deduct
|
||||
operation_type: Type of operation (from CreditUsageLog.OPERATION_TYPE_CHOICES)
|
||||
description: Description of the transaction
|
||||
metadata: Optional metadata dict
|
||||
cost_usd: Optional cost in USD
|
||||
model_used: Optional AI model used
|
||||
tokens_input: Optional input tokens
|
||||
tokens_output: Optional output tokens
|
||||
related_object_type: Optional related object type
|
||||
related_object_id: Optional related object ID
|
||||
|
||||
Returns:
|
||||
int: New credit balance
|
||||
"""
|
||||
# Check sufficient credits (legacy: amount is already calculated)
|
||||
CreditService.check_credits_legacy(account, amount)
|
||||
|
||||
# Deduct from account.credits
|
||||
account.credits -= amount
|
||||
account.save(update_fields=['credits'])
|
||||
|
||||
# Create CreditTransaction
|
||||
CreditTransaction.objects.create(
|
||||
account=account,
|
||||
transaction_type='deduction',
|
||||
amount=-amount, # Negative for deduction
|
||||
balance_after=account.credits,
|
||||
description=description,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
# Create CreditUsageLog
|
||||
CreditUsageLog.objects.create(
|
||||
account=account,
|
||||
operation_type=operation_type,
|
||||
credits_used=amount,
|
||||
cost_usd=cost_usd,
|
||||
model_used=model_used or '',
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
related_object_type=related_object_type or '',
|
||||
related_object_id=related_object_id,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
return account.credits
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def deduct_credits_for_operation(account, operation_type, amount=None, description=None, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
|
||||
"""
|
||||
Deduct credits for an operation (convenience method that calculates cost automatically).
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
operation_type: Type of operation
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
description: Optional description (auto-generated if not provided)
|
||||
metadata: Optional metadata dict
|
||||
cost_usd: Optional cost in USD
|
||||
model_used: Optional AI model used
|
||||
tokens_input: Optional input tokens
|
||||
tokens_output: Optional output tokens
|
||||
related_object_type: Optional related object type
|
||||
related_object_id: Optional related object ID
|
||||
|
||||
Returns:
|
||||
int: New credit balance
|
||||
"""
|
||||
# Calculate credit cost
|
||||
credits_required = CreditService.get_credit_cost(operation_type, amount)
|
||||
|
||||
# Check sufficient credits
|
||||
CreditService.check_credits(account, operation_type, amount)
|
||||
|
||||
# Auto-generate description if not provided
|
||||
if not description:
|
||||
if operation_type == 'clustering':
|
||||
description = f"Clustering operation"
|
||||
elif operation_type == 'idea_generation':
|
||||
description = f"Generated {amount or 1} idea(s)"
|
||||
elif operation_type == 'content_generation':
|
||||
description = f"Generated content ({amount or 0} words)"
|
||||
elif operation_type == 'image_generation':
|
||||
description = f"Generated {amount or 1} image(s)"
|
||||
else:
|
||||
description = f"{operation_type} operation"
|
||||
|
||||
return CreditService.deduct_credits(
|
||||
account=account,
|
||||
amount=credits_required,
|
||||
operation_type=operation_type,
|
||||
description=description,
|
||||
metadata=metadata,
|
||||
cost_usd=cost_usd,
|
||||
model_used=model_used,
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
related_object_type=related_object_type,
|
||||
related_object_id=related_object_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def add_credits(account, amount, transaction_type, description, metadata=None):
|
||||
"""
|
||||
Add credits (purchase, subscription, etc.).
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
amount: Number of credits to add
|
||||
transaction_type: Type of transaction (from CreditTransaction.TRANSACTION_TYPE_CHOICES)
|
||||
description: Description of the transaction
|
||||
metadata: Optional metadata dict
|
||||
|
||||
Returns:
|
||||
int: New credit balance
|
||||
"""
|
||||
# Add to account.credits
|
||||
account.credits += amount
|
||||
account.save(update_fields=['credits'])
|
||||
|
||||
# Create CreditTransaction
|
||||
CreditTransaction.objects.create(
|
||||
account=account,
|
||||
transaction_type=transaction_type,
|
||||
amount=amount, # Positive for addition
|
||||
balance_after=account.credits,
|
||||
description=description,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
return account.credits
|
||||
|
||||
@staticmethod
|
||||
def calculate_credits_for_operation(operation_type, **kwargs):
|
||||
"""
|
||||
Calculate credits needed for an operation.
|
||||
Legacy method - use get_credit_cost() instead.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation
|
||||
**kwargs: Operation-specific parameters
|
||||
|
||||
Returns:
|
||||
int: Number of credits required
|
||||
|
||||
Raises:
|
||||
CreditCalculationError: If calculation fails
|
||||
"""
|
||||
# Map legacy operation types
|
||||
if operation_type == 'ideas':
|
||||
operation_type = 'idea_generation'
|
||||
elif operation_type == 'content':
|
||||
operation_type = 'content_generation'
|
||||
elif operation_type == 'images':
|
||||
operation_type = 'image_generation'
|
||||
|
||||
# Extract amount from kwargs
|
||||
amount = None
|
||||
if 'word_count' in kwargs:
|
||||
amount = kwargs.get('word_count')
|
||||
elif 'image_count' in kwargs:
|
||||
amount = kwargs.get('image_count')
|
||||
elif 'idea_count' in kwargs:
|
||||
amount = kwargs.get('idea_count')
|
||||
|
||||
return CreditService.get_credit_cost(operation_type, amount)
|
||||
|
||||
2
backend/igny8_core/business/billing/tests/__init__.py
Normal file
2
backend/igny8_core/business/billing/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Billing tests
|
||||
|
||||
133
backend/igny8_core/business/billing/tests/test_phase4_credits.py
Normal file
133
backend/igny8_core/business/billing/tests/test_phase4_credits.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Tests for Phase 4 credit deduction
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
from igny8_core.business.billing.constants import CREDIT_COSTS
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class Phase4CreditTests(IntegrationTestBase):
|
||||
"""Tests for Phase 4 credit deduction"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Set initial credits
|
||||
self.account.credits = 1000
|
||||
self.account.save()
|
||||
|
||||
def test_linking_deducts_correct_credits(self):
|
||||
"""Test that linking deducts correct credits"""
|
||||
cost = CreditService.get_credit_cost('linking')
|
||||
expected_cost = CREDIT_COSTS.get('linking', 0)
|
||||
|
||||
self.assertEqual(cost, expected_cost)
|
||||
self.assertEqual(cost, 8) # From constants
|
||||
|
||||
def test_optimization_deducts_correct_credits(self):
|
||||
"""Test that optimization deducts correct credits based on word count"""
|
||||
word_count = 500
|
||||
cost = CreditService.get_credit_cost('optimization', word_count)
|
||||
|
||||
# Should be 1 credit per 200 words, so 500 words = 3 credits (max(1, 1 * 500/200) = 3)
|
||||
expected = max(1, int(CREDIT_COSTS.get('optimization', 1) * (word_count / 200)))
|
||||
self.assertEqual(cost, expected)
|
||||
|
||||
def test_optimization_credits_per_entry_point(self):
|
||||
"""Test that optimization credits are same regardless of entry point"""
|
||||
word_count = 400
|
||||
|
||||
# All entry points should use same credit calculation
|
||||
cost = CreditService.get_credit_cost('optimization', word_count)
|
||||
|
||||
# 400 words = 2 credits (1 * 400/200)
|
||||
self.assertEqual(cost, 2)
|
||||
|
||||
@patch('igny8_core.business.billing.services.credit_service.CreditService.deduct_credits')
|
||||
def test_pipeline_deducts_credits_at_each_stage(self, mock_deduct):
|
||||
"""Test that pipeline deducts credits at each stage"""
|
||||
from igny8_core.business.content.services.content_pipeline_service import ContentPipelineService
|
||||
from igny8_core.business.linking.services.linker_service import LinkerService
|
||||
from igny8_core.business.optimization.services.optimizer_service import OptimizerService
|
||||
|
||||
content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Test",
|
||||
word_count=400,
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
# Mock the services
|
||||
with patch.object(LinkerService, 'process') as mock_link, \
|
||||
patch.object(OptimizerService, 'optimize_from_writer') as mock_optimize:
|
||||
|
||||
mock_link.return_value = content
|
||||
mock_optimize.return_value = content
|
||||
|
||||
service = ContentPipelineService()
|
||||
service.process_writer_content(content.id)
|
||||
|
||||
# Should deduct credits for both linking and optimization
|
||||
self.assertGreater(mock_deduct.call_count, 0)
|
||||
|
||||
def test_insufficient_credits_blocks_linking(self):
|
||||
"""Test that insufficient credits blocks linking"""
|
||||
self.account.credits = 5 # Less than linking cost (8)
|
||||
self.account.save()
|
||||
|
||||
with self.assertRaises(InsufficientCreditsError):
|
||||
CreditService.check_credits(self.account, 'linking')
|
||||
|
||||
def test_insufficient_credits_blocks_optimization(self):
|
||||
"""Test that insufficient credits blocks optimization"""
|
||||
self.account.credits = 1 # Less than optimization cost for 500 words
|
||||
self.account.save()
|
||||
|
||||
with self.assertRaises(InsufficientCreditsError):
|
||||
CreditService.check_credits(self.account, 'optimization', 500)
|
||||
|
||||
def test_credit_deduction_logged(self):
|
||||
"""Test that credit deduction is logged"""
|
||||
from igny8_core.business.billing.models import CreditUsageLog
|
||||
|
||||
initial_credits = self.account.credits
|
||||
cost = CreditService.get_credit_cost('linking')
|
||||
|
||||
CreditService.deduct_credits_for_operation(
|
||||
account=self.account,
|
||||
operation_type='linking',
|
||||
description="Test linking"
|
||||
)
|
||||
|
||||
self.account.refresh_from_db()
|
||||
self.assertEqual(self.account.credits, initial_credits - cost)
|
||||
|
||||
# Check that usage log was created
|
||||
log = CreditUsageLog.objects.filter(
|
||||
account=self.account,
|
||||
operation_type='linking'
|
||||
).first()
|
||||
self.assertIsNotNone(log)
|
||||
|
||||
def test_batch_operations_deduct_multiple_credits(self):
|
||||
"""Test that batch operations deduct multiple credits"""
|
||||
initial_credits = self.account.credits
|
||||
linking_cost = CreditService.get_credit_cost('linking')
|
||||
|
||||
# Deduct for 3 linking operations
|
||||
for i in range(3):
|
||||
CreditService.deduct_credits_for_operation(
|
||||
account=self.account,
|
||||
operation_type='linking',
|
||||
description=f"Linking {i}"
|
||||
)
|
||||
|
||||
self.account.refresh_from_db()
|
||||
expected_credits = initial_credits - (linking_cost * 3)
|
||||
self.assertEqual(self.account.credits, expected_credits)
|
||||
|
||||
4
backend/igny8_core/business/content/__init__.py
Normal file
4
backend/igny8_core/business/content/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Content business logic - Content, Tasks, Images models and services
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
# Generated migration for Stage 1 - Task, Content, ContentTaxonomy models refactor
|
||||
#
|
||||
# Tasks: Remove cluster_role, add content_type, content_structure, taxonomy_term_id, simplify status
|
||||
# Content: Remove 25+ fields, add title, content_html, simplify M2M
|
||||
# ContentTaxonomy: Remove sync_status, description, parent, count, metadata, add 'cluster' type
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('content', '0001_initial'), # Adjust to your actual last migration
|
||||
('planning', '0002_stage1_remove_cluster_context_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# ============================================================
|
||||
# Tasks Model Changes
|
||||
# ============================================================
|
||||
|
||||
# Remove deprecated fields from Tasks
|
||||
migrations.RemoveField(
|
||||
model_name='tasks',
|
||||
name='cluster_role',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tasks',
|
||||
name='idea_id',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tasks',
|
||||
name='content_record',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tasks',
|
||||
name='entity_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tasks',
|
||||
name='cluster_context',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tasks',
|
||||
name='dimension_roles',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='tasks',
|
||||
name='metadata',
|
||||
),
|
||||
|
||||
# Add new fields to Tasks
|
||||
migrations.AddField(
|
||||
model_name='tasks',
|
||||
name='content_type',
|
||||
field=models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='WordPress content type (post, page, product, etc.)'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tasks',
|
||||
name='content_structure',
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='JSON structure template for content generation'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tasks',
|
||||
name='taxonomy_term_id',
|
||||
field=models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='Optional taxonomy term for categorization'
|
||||
),
|
||||
),
|
||||
|
||||
# Update status field choices for Tasks
|
||||
migrations.AlterField(
|
||||
model_name='tasks',
|
||||
name='status',
|
||||
field=models.CharField(
|
||||
max_length=20,
|
||||
default='queued',
|
||||
choices=[
|
||||
('queued', 'Queued'),
|
||||
('completed', 'Completed'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
# ============================================================
|
||||
# Content Model Changes
|
||||
# ============================================================
|
||||
|
||||
# Remove deprecated fields from Content
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='task',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='html_content',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='word_count',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='metadata',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='meta_title',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='meta_description',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='primary_keyword',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='secondary_keywords',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='entity_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='json_blocks',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='structure_data',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='content_format',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='cluster_role',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='sync_status',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='external_type',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='external_status',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='sync_data',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='last_synced_at',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='validation_errors',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='content',
|
||||
name='is_validated',
|
||||
),
|
||||
|
||||
# Rename generated_at to created_at for consistency
|
||||
migrations.RenameField(
|
||||
model_name='content',
|
||||
old_name='generated_at',
|
||||
new_name='created_at',
|
||||
),
|
||||
|
||||
# Add new fields to Content
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='title',
|
||||
field=models.CharField(max_length=500, blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='content_html',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='cluster_id',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='content_type',
|
||||
field=models.CharField(max_length=50, blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='content_structure',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
|
||||
# Update status field choices for Content
|
||||
migrations.AlterField(
|
||||
model_name='content',
|
||||
name='status',
|
||||
field=models.CharField(
|
||||
max_length=20,
|
||||
default='draft',
|
||||
choices=[
|
||||
('draft', 'Draft'),
|
||||
('published', 'Published'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
# Replace through model with direct M2M for taxonomy_terms
|
||||
migrations.AddField(
|
||||
model_name='content',
|
||||
name='taxonomy_terms',
|
||||
field=models.ManyToManyField(
|
||||
to='content.ContentTaxonomy',
|
||||
related_name='contents',
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
|
||||
# ============================================================
|
||||
# ContentTaxonomy Model Changes
|
||||
# ============================================================
|
||||
|
||||
# Remove deprecated fields from ContentTaxonomy
|
||||
migrations.RemoveField(
|
||||
model_name='contenttaxonomy',
|
||||
name='description',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='contenttaxonomy',
|
||||
name='parent',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='contenttaxonomy',
|
||||
name='sync_status',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='contenttaxonomy',
|
||||
name='count',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='contenttaxonomy',
|
||||
name='metadata',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='contenttaxonomy',
|
||||
name='clusters',
|
||||
),
|
||||
|
||||
# Update taxonomy_type to include 'cluster'
|
||||
migrations.AlterField(
|
||||
model_name='contenttaxonomy',
|
||||
name='taxonomy_type',
|
||||
field=models.CharField(
|
||||
max_length=50,
|
||||
default='category',
|
||||
choices=[
|
||||
('category', 'Category'),
|
||||
('post_tag', 'Tag'),
|
||||
('cluster', 'Cluster'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
# ============================================================
|
||||
# Remove Through Models and Relations
|
||||
# ============================================================
|
||||
|
||||
# Delete ContentTaxonomyRelation through model (if exists)
|
||||
migrations.DeleteModel(
|
||||
name='ContentTaxonomyRelation',
|
||||
),
|
||||
|
||||
# Delete ContentClusterMap through model (if exists)
|
||||
migrations.DeleteModel(
|
||||
name='ContentClusterMap',
|
||||
),
|
||||
|
||||
# Delete ContentTaxonomyMap through model (if exists)
|
||||
migrations.DeleteModel(
|
||||
name='ContentTaxonomyMap',
|
||||
),
|
||||
|
||||
# Delete ContentAttribute model (if exists)
|
||||
migrations.DeleteModel(
|
||||
name='ContentAttribute',
|
||||
),
|
||||
]
|
||||
612
backend/igny8_core/business/content/models.py
Normal file
612
backend/igny8_core/business/content/models.py
Normal file
@@ -0,0 +1,612 @@
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from igny8_core.auth.models import SiteSectorBaseModel
|
||||
|
||||
|
||||
class Tasks(SiteSectorBaseModel):
|
||||
"""Tasks model for content generation queue"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('queued', 'Queued'),
|
||||
('completed', 'Completed'),
|
||||
]
|
||||
|
||||
CONTENT_TYPE_CHOICES = [
|
||||
('post', 'Post'),
|
||||
('page', 'Page'),
|
||||
('product', 'Product'),
|
||||
('taxonomy', 'Taxonomy'),
|
||||
]
|
||||
|
||||
CONTENT_STRUCTURE_CHOICES = [
|
||||
# Post structures
|
||||
('article', 'Article'),
|
||||
('guide', 'Guide'),
|
||||
('comparison', 'Comparison'),
|
||||
('review', 'Review'),
|
||||
('listicle', 'Listicle'),
|
||||
# Page structures
|
||||
('landing_page', 'Landing Page'),
|
||||
('business_page', 'Business Page'),
|
||||
('service_page', 'Service Page'),
|
||||
('general', 'General'),
|
||||
('cluster_hub', 'Cluster Hub'),
|
||||
# Product structures
|
||||
('product_page', 'Product Page'),
|
||||
# Taxonomy structures
|
||||
('category_archive', 'Category Archive'),
|
||||
('tag_archive', 'Tag Archive'),
|
||||
('attribute_archive', 'Attribute Archive'),
|
||||
]
|
||||
|
||||
title = models.CharField(max_length=255, db_index=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
cluster = models.ForeignKey(
|
||||
'planner.Clusters',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=False,
|
||||
related_name='tasks',
|
||||
limit_choices_to={'sector': models.F('sector')},
|
||||
help_text="Parent cluster (required)"
|
||||
)
|
||||
idea = models.ForeignKey(
|
||||
'planner.ContentIdeas',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='tasks',
|
||||
help_text="Optional content idea reference",
|
||||
db_column='idea_id'
|
||||
)
|
||||
content_type = models.CharField(
|
||||
max_length=100,
|
||||
db_index=True,
|
||||
help_text="Content type: post, page, product, taxonomy",
|
||||
choices=CONTENT_TYPE_CHOICES,
|
||||
default='post',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
content_structure = models.CharField(
|
||||
max_length=100,
|
||||
db_index=True,
|
||||
help_text="Content structure: article, guide, comparison, review, listicle, landing_page, etc.",
|
||||
choices=CONTENT_STRUCTURE_CHOICES,
|
||||
default='article',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
taxonomy_term = models.ForeignKey(
|
||||
'ContentTaxonomy',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='tasks',
|
||||
help_text="Optional taxonomy term assignment",
|
||||
db_column='taxonomy_id'
|
||||
)
|
||||
keywords = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Comma-separated keywords for this task"
|
||||
)
|
||||
word_count = models.IntegerField(
|
||||
default=1000,
|
||||
validators=[MinValueValidator(100)],
|
||||
help_text="Target word count for content generation"
|
||||
)
|
||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='queued')
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'writer'
|
||||
db_table = 'igny8_tasks'
|
||||
ordering = ['-created_at']
|
||||
verbose_name = 'Task'
|
||||
verbose_name_plural = 'Tasks'
|
||||
indexes = [
|
||||
models.Index(fields=['title']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['cluster']),
|
||||
models.Index(fields=['content_type']),
|
||||
models.Index(fields=['content_structure']),
|
||||
models.Index(fields=['site', 'sector']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class ContentTaxonomyRelation(models.Model):
|
||||
"""Through model for Content-Taxonomy many-to-many relationship"""
|
||||
content = models.ForeignKey('Content', on_delete=models.CASCADE, db_column='content_id')
|
||||
taxonomy = models.ForeignKey('ContentTaxonomy', on_delete=models.CASCADE, db_column='taxonomy_id')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'writer'
|
||||
db_table = 'igny8_content_taxonomy_relations'
|
||||
unique_together = [['content', 'taxonomy']]
|
||||
|
||||
|
||||
class Content(SiteSectorBaseModel):
|
||||
"""
|
||||
Content model for AI-generated or WordPress-imported content.
|
||||
Final architecture: simplified content management.
|
||||
"""
|
||||
|
||||
CONTENT_TYPE_CHOICES = [
|
||||
('post', 'Post'),
|
||||
('page', 'Page'),
|
||||
('product', 'Product'),
|
||||
('taxonomy', 'Taxonomy'),
|
||||
]
|
||||
|
||||
CONTENT_STRUCTURE_CHOICES = [
|
||||
# Post structures
|
||||
('article', 'Article'),
|
||||
('guide', 'Guide'),
|
||||
('comparison', 'Comparison'),
|
||||
('review', 'Review'),
|
||||
('listicle', 'Listicle'),
|
||||
# Page structures
|
||||
('landing_page', 'Landing Page'),
|
||||
('business_page', 'Business Page'),
|
||||
('service_page', 'Service Page'),
|
||||
('general', 'General'),
|
||||
('cluster_hub', 'Cluster Hub'),
|
||||
# Product structures
|
||||
('product_page', 'Product Page'),
|
||||
# Taxonomy structures
|
||||
('category_archive', 'Category Archive'),
|
||||
('tag_archive', 'Tag Archive'),
|
||||
('attribute_archive', 'Attribute Archive'),
|
||||
]
|
||||
|
||||
# Core content fields
|
||||
title = models.CharField(max_length=255, db_index=True)
|
||||
content_html = models.TextField(help_text="Final HTML content")
|
||||
word_count = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Actual word count of content (calculated from HTML)"
|
||||
)
|
||||
|
||||
# SEO fields
|
||||
meta_title = models.CharField(max_length=255, blank=True, null=True, help_text="SEO meta title")
|
||||
meta_description = models.TextField(blank=True, null=True, help_text="SEO meta description")
|
||||
primary_keyword = models.CharField(max_length=255, blank=True, null=True, help_text="Primary SEO keyword")
|
||||
secondary_keywords = models.JSONField(default=list, blank=True, help_text="Secondary SEO keywords")
|
||||
|
||||
cluster = models.ForeignKey(
|
||||
'planner.Clusters',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=False,
|
||||
related_name='contents',
|
||||
help_text="Parent cluster (required)"
|
||||
)
|
||||
content_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=CONTENT_TYPE_CHOICES,
|
||||
default='post',
|
||||
db_index=True,
|
||||
help_text="Content type: post, page, product, taxonomy"
|
||||
)
|
||||
content_structure = models.CharField(
|
||||
max_length=50,
|
||||
choices=CONTENT_STRUCTURE_CHOICES,
|
||||
default='article',
|
||||
db_index=True,
|
||||
help_text="Content structure/format based on content type"
|
||||
)
|
||||
|
||||
# Taxonomy relationships
|
||||
taxonomy_terms = models.ManyToManyField(
|
||||
'ContentTaxonomy',
|
||||
through='ContentTaxonomyRelation',
|
||||
blank=True,
|
||||
related_name='contents',
|
||||
help_text="Associated taxonomy terms (categories, tags, attributes)"
|
||||
)
|
||||
|
||||
# External platform fields (WordPress integration)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True, db_index=True, help_text="WordPress/external platform post ID")
|
||||
external_url = models.URLField(blank=True, null=True, help_text="WordPress/external platform URL")
|
||||
external_type = models.CharField(max_length=100, blank=True, null=True, help_text="WordPress post type (post, page, product, etc.)")
|
||||
sync_status = models.CharField(max_length=50, blank=True, null=True, help_text="Sync status with WordPress")
|
||||
|
||||
# Source tracking
|
||||
SOURCE_CHOICES = [
|
||||
('igny8', 'IGNY8 Generated'),
|
||||
('wordpress', 'WordPress Imported'),
|
||||
]
|
||||
source = models.CharField(
|
||||
max_length=50,
|
||||
choices=SOURCE_CHOICES,
|
||||
default='igny8',
|
||||
db_index=True,
|
||||
help_text="Content source"
|
||||
)
|
||||
|
||||
# Status tracking
|
||||
STATUS_CHOICES = [
|
||||
('draft', 'Draft'),
|
||||
('published', 'Published'),
|
||||
]
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=STATUS_CHOICES,
|
||||
default='draft',
|
||||
db_index=True,
|
||||
help_text="Content status"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'writer'
|
||||
db_table = 'igny8_content'
|
||||
ordering = ['-created_at']
|
||||
verbose_name = 'Content'
|
||||
verbose_name_plural = 'Contents'
|
||||
indexes = [
|
||||
models.Index(fields=['title']),
|
||||
models.Index(fields=['cluster']),
|
||||
models.Index(fields=['content_type']),
|
||||
models.Index(fields=['content_structure']),
|
||||
models.Index(fields=['source']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['external_id']),
|
||||
models.Index(fields=['site', 'sector']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title or f"Content {self.id}"
|
||||
|
||||
|
||||
class ContentTaxonomy(SiteSectorBaseModel):
|
||||
"""
|
||||
Universal taxonomy model for WordPress and IGNY8 cluster-based taxonomies.
|
||||
Supports categories, tags, product attributes, and cluster mappings.
|
||||
"""
|
||||
|
||||
TAXONOMY_TYPE_CHOICES = [
|
||||
('category', 'Category'),
|
||||
('tag', 'Tag'),
|
||||
('product_category', 'Product Category'),
|
||||
('product_attribute', 'Product Attribute'),
|
||||
('cluster', 'Cluster Taxonomy'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255, db_index=True, help_text="Term name")
|
||||
slug = models.SlugField(max_length=255, db_index=True, help_text="URL slug")
|
||||
taxonomy_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=TAXONOMY_TYPE_CHOICES,
|
||||
db_index=True,
|
||||
help_text="Type of taxonomy"
|
||||
)
|
||||
|
||||
# WordPress/external platform sync fields
|
||||
external_taxonomy = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="WordPress taxonomy slug (category, post_tag, product_cat, pa_*) - null for cluster taxonomies"
|
||||
)
|
||||
external_id = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="WordPress term_id - null for cluster taxonomies"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'writer'
|
||||
db_table = 'igny8_content_taxonomy_terms'
|
||||
verbose_name = 'Content Taxonomy'
|
||||
verbose_name_plural = 'Content Taxonomies'
|
||||
unique_together = [
|
||||
['site', 'slug', 'taxonomy_type'],
|
||||
['site', 'external_id', 'external_taxonomy'],
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=['name']),
|
||||
models.Index(fields=['slug']),
|
||||
models.Index(fields=['taxonomy_type']),
|
||||
models.Index(fields=['external_id', 'external_taxonomy']),
|
||||
models.Index(fields=['site', 'taxonomy_type']),
|
||||
models.Index(fields=['site', 'sector']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_taxonomy_type_display()})"
|
||||
|
||||
|
||||
class Images(SiteSectorBaseModel):
|
||||
"""Images model for content-related images (featured, desktop, mobile, in-article)"""
|
||||
|
||||
IMAGE_TYPE_CHOICES = [
|
||||
('featured', 'Featured Image'),
|
||||
('desktop', 'Desktop Image'),
|
||||
('mobile', 'Mobile Image'),
|
||||
('in_article', 'In-Article Image'),
|
||||
]
|
||||
|
||||
content = models.ForeignKey(
|
||||
Content,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='images',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="The content this image belongs to (preferred)"
|
||||
)
|
||||
task = models.ForeignKey(
|
||||
Tasks,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='images',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="The task this image belongs to (legacy, use content instead)"
|
||||
)
|
||||
image_type = models.CharField(max_length=50, choices=IMAGE_TYPE_CHOICES, default='featured')
|
||||
image_url = models.CharField(max_length=500, blank=True, null=True, help_text="URL of the generated/stored image")
|
||||
image_path = models.CharField(max_length=500, blank=True, null=True, help_text="Local path if stored locally")
|
||||
prompt = models.TextField(blank=True, null=True, help_text="Image generation prompt used")
|
||||
status = models.CharField(max_length=50, default='pending', help_text="Status: pending, generated, failed")
|
||||
position = models.IntegerField(default=0, help_text="Position for in-article images ordering")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'writer'
|
||||
db_table = 'igny8_images'
|
||||
ordering = ['content', 'position', '-created_at']
|
||||
verbose_name = 'Image'
|
||||
verbose_name_plural = 'Images'
|
||||
indexes = [
|
||||
models.Index(fields=['content', 'image_type']),
|
||||
models.Index(fields=['task', 'image_type']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['content', 'position']),
|
||||
models.Index(fields=['task', 'position']),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Automatically set account, site, and sector from content or task"""
|
||||
# Prefer content over task
|
||||
if self.content:
|
||||
self.account = self.content.account
|
||||
self.site = self.content.site
|
||||
self.sector = self.content.sector
|
||||
elif self.task:
|
||||
self.account = self.task.account
|
||||
self.site = self.task.site
|
||||
self.sector = self.task.sector
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
content_title = self.content.title if self.content else None
|
||||
task_title = self.task.title if self.task else None
|
||||
title = content_title or task_title or 'Unknown'
|
||||
return f"{title} - {self.image_type}"
|
||||
|
||||
|
||||
class ContentClusterMap(SiteSectorBaseModel):
|
||||
"""Associates generated content with planner clusters + roles."""
|
||||
|
||||
ROLE_CHOICES = [
|
||||
('hub', 'Hub Page'),
|
||||
('supporting', 'Supporting Page'),
|
||||
('attribute', 'Attribute Page'),
|
||||
]
|
||||
|
||||
SOURCE_CHOICES = [
|
||||
('blueprint', 'Blueprint'),
|
||||
('manual', 'Manual'),
|
||||
('import', 'Import'),
|
||||
]
|
||||
|
||||
content = models.ForeignKey(
|
||||
Content,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='cluster_mappings',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
task = models.ForeignKey(
|
||||
Tasks,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='cluster_mappings',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
cluster = models.ForeignKey(
|
||||
'planner.Clusters',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='content_mappings',
|
||||
)
|
||||
role = models.CharField(max_length=50, choices=ROLE_CHOICES, default='hub')
|
||||
source = models.CharField(max_length=50, choices=SOURCE_CHOICES, default='blueprint')
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'writer'
|
||||
db_table = 'igny8_content_cluster_map'
|
||||
unique_together = [['content', 'cluster', 'role']]
|
||||
indexes = [
|
||||
models.Index(fields=['cluster', 'role']),
|
||||
models.Index(fields=['content', 'role']),
|
||||
models.Index(fields=['task', 'role']),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
provider = self.content or self.task
|
||||
if provider:
|
||||
self.account = provider.account
|
||||
self.site = provider.site
|
||||
self.sector = provider.sector
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.cluster.name} ({self.get_role_display()})"
|
||||
|
||||
|
||||
class ContentTaxonomyMap(SiteSectorBaseModel):
|
||||
"""Maps content entities to blueprint taxonomies for syncing/publishing."""
|
||||
|
||||
SOURCE_CHOICES = [
|
||||
('blueprint', 'Blueprint'),
|
||||
('manual', 'Manual'),
|
||||
('import', 'Import'),
|
||||
]
|
||||
|
||||
content = models.ForeignKey(
|
||||
Content,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='taxonomy_mappings',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
task = models.ForeignKey(
|
||||
Tasks,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='taxonomy_mappings',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
taxonomy = models.ForeignKey(
|
||||
'site_building.SiteBlueprintTaxonomy',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='content_mappings',
|
||||
)
|
||||
source = models.CharField(max_length=50, choices=SOURCE_CHOICES, default='blueprint')
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'writer'
|
||||
db_table = 'igny8_content_taxonomy_map'
|
||||
unique_together = [['content', 'taxonomy']]
|
||||
indexes = [
|
||||
models.Index(fields=['taxonomy']),
|
||||
models.Index(fields=['content', 'taxonomy']),
|
||||
models.Index(fields=['task', 'taxonomy']),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
provider = self.content or self.task
|
||||
if provider:
|
||||
self.account = provider.account
|
||||
self.site = provider.site
|
||||
self.sector = provider.sector
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.taxonomy.name}"
|
||||
|
||||
|
||||
class ContentAttribute(SiteSectorBaseModel):
|
||||
"""
|
||||
Unified attribute storage for products, services, and semantic facets.
|
||||
Replaces ContentAttributeMap with enhanced WP sync support.
|
||||
"""
|
||||
|
||||
ATTRIBUTE_TYPE_CHOICES = [
|
||||
('product_spec', 'Product Specification'),
|
||||
('service_modifier', 'Service Modifier'),
|
||||
('semantic_facet', 'Semantic Facet'),
|
||||
]
|
||||
|
||||
SOURCE_CHOICES = [
|
||||
('blueprint', 'Blueprint'),
|
||||
('manual', 'Manual'),
|
||||
('import', 'Import'),
|
||||
('wordpress', 'WordPress'),
|
||||
]
|
||||
|
||||
content = models.ForeignKey(
|
||||
Content,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='attributes',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
task = models.ForeignKey(
|
||||
Tasks,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='attribute_mappings',
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
cluster = models.ForeignKey(
|
||||
'planner.Clusters',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='attributes',
|
||||
help_text="Optional cluster association for semantic attributes"
|
||||
)
|
||||
|
||||
attribute_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=ATTRIBUTE_TYPE_CHOICES,
|
||||
default='product_spec',
|
||||
db_index=True,
|
||||
help_text="Type of attribute"
|
||||
)
|
||||
name = models.CharField(max_length=120, help_text="Attribute name (e.g., Color, Material)")
|
||||
value = models.CharField(max_length=255, blank=True, null=True, help_text="Attribute value (e.g., Blue, Cotton)")
|
||||
|
||||
# WordPress/WooCommerce sync fields
|
||||
external_id = models.IntegerField(null=True, blank=True, help_text="WP attribute term ID")
|
||||
external_attribute_name = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="WP attribute slug (e.g., pa_color, pa_size)"
|
||||
)
|
||||
|
||||
source = models.CharField(max_length=50, choices=SOURCE_CHOICES, default='manual')
|
||||
metadata = models.JSONField(default=dict, blank=True, help_text="Additional metadata")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'writer'
|
||||
db_table = 'igny8_content_attributes'
|
||||
verbose_name = 'Content Attribute'
|
||||
verbose_name_plural = 'Content Attributes'
|
||||
indexes = [
|
||||
models.Index(fields=['name']),
|
||||
models.Index(fields=['attribute_type']),
|
||||
models.Index(fields=['content', 'name']),
|
||||
models.Index(fields=['content', 'attribute_type']),
|
||||
models.Index(fields=['cluster', 'attribute_type']),
|
||||
models.Index(fields=['external_id']),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
provider = self.content or self.task
|
||||
if provider:
|
||||
self.account = provider.account
|
||||
self.site = provider.site
|
||||
self.sector = provider.sector
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}: {self.value}"
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
ContentAttributeMap = ContentAttribute
|
||||
8
backend/igny8_core/business/content/services/__init__.py
Normal file
8
backend/igny8_core/business/content/services/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Content Services
|
||||
"""
|
||||
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
||||
from igny8_core.business.content.services.content_pipeline_service import ContentPipelineService
|
||||
|
||||
__all__ = ['ContentGenerationService', 'ContentPipelineService']
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Content Generation Service
|
||||
Handles content generation business logic
|
||||
"""
|
||||
import logging
|
||||
from igny8_core.business.content.models import Tasks
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentGenerationService:
|
||||
"""Service for content generation operations"""
|
||||
|
||||
def __init__(self):
|
||||
self.credit_service = CreditService()
|
||||
|
||||
def generate_content(self, task_ids, account):
|
||||
"""
|
||||
Generate content for tasks.
|
||||
|
||||
Args:
|
||||
task_ids: List of task IDs
|
||||
account: Account instance
|
||||
|
||||
Returns:
|
||||
dict: Result with success status and data
|
||||
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
# Get tasks
|
||||
tasks = Tasks.objects.filter(id__in=task_ids, account=account)
|
||||
|
||||
# Calculate estimated credits needed based on word count
|
||||
total_word_count = sum(task.word_count or 1000 for task in tasks)
|
||||
|
||||
# Check credits
|
||||
try:
|
||||
self.credit_service.check_credits(account, 'content_generation', total_word_count)
|
||||
except InsufficientCreditsError:
|
||||
raise
|
||||
|
||||
# Delegate to AI task (actual generation happens in Celery)
|
||||
from igny8_core.ai.tasks import run_ai_task
|
||||
|
||||
try:
|
||||
if hasattr(run_ai_task, 'delay'):
|
||||
# Celery available - queue async
|
||||
task = run_ai_task.delay(
|
||||
function_name='generate_content',
|
||||
payload={'ids': task_ids},
|
||||
account_id=account.id
|
||||
)
|
||||
return {
|
||||
'success': True,
|
||||
'task_id': str(task.id),
|
||||
'message': 'Content generation started'
|
||||
}
|
||||
else:
|
||||
# Celery not available - execute synchronously
|
||||
result = run_ai_task(
|
||||
function_name='generate_content',
|
||||
payload={'ids': task_ids},
|
||||
account_id=account.id
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error in generate_content: {str(e)}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def generate_product_content(self, product_data, account, site=None, sector=None):
|
||||
"""
|
||||
Generate product content.
|
||||
|
||||
Args:
|
||||
product_data: Dict with product information (name, description, features, etc.)
|
||||
account: Account instance
|
||||
site: Site instance (optional)
|
||||
sector: Sector instance (optional)
|
||||
|
||||
Returns:
|
||||
dict: Result with success status and data
|
||||
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
# Calculate estimated credits needed (default 1500 words for product content)
|
||||
estimated_word_count = product_data.get('word_count', 1500)
|
||||
|
||||
# Check credits
|
||||
try:
|
||||
self.credit_service.check_credits(account, 'content_generation', estimated_word_count)
|
||||
except InsufficientCreditsError:
|
||||
raise
|
||||
|
||||
# Delegate to AI task
|
||||
from igny8_core.ai.tasks import run_ai_task
|
||||
|
||||
try:
|
||||
payload = {
|
||||
'product_name': product_data.get('name', ''),
|
||||
'product_description': product_data.get('description', ''),
|
||||
'product_features': product_data.get('features', []),
|
||||
'target_audience': product_data.get('target_audience', ''),
|
||||
'primary_keyword': product_data.get('primary_keyword', ''),
|
||||
'site_id': site.id if site else None,
|
||||
'sector_id': sector.id if sector else None,
|
||||
}
|
||||
|
||||
if hasattr(run_ai_task, 'delay'):
|
||||
# Celery available - queue async
|
||||
task = run_ai_task.delay(
|
||||
function_name='generate_product_content',
|
||||
payload=payload,
|
||||
account_id=account.id
|
||||
)
|
||||
return {
|
||||
'success': True,
|
||||
'task_id': str(task.id),
|
||||
'message': 'Product content generation started'
|
||||
}
|
||||
else:
|
||||
# Celery not available - execute synchronously
|
||||
result = run_ai_task(
|
||||
function_name='generate_product_content',
|
||||
payload=payload,
|
||||
account_id=account.id
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error in generate_product_content: {str(e)}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def generate_service_page(self, service_data, account, site=None, sector=None):
|
||||
"""
|
||||
Generate service page content.
|
||||
|
||||
Args:
|
||||
service_data: Dict with service information (name, description, benefits, etc.)
|
||||
account: Account instance
|
||||
site: Site instance (optional)
|
||||
sector: Sector instance (optional)
|
||||
|
||||
Returns:
|
||||
dict: Result with success status and data
|
||||
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
# Calculate estimated credits needed (default 1800 words for service page)
|
||||
estimated_word_count = service_data.get('word_count', 1800)
|
||||
|
||||
# Check credits
|
||||
try:
|
||||
self.credit_service.check_credits(account, 'content_generation', estimated_word_count)
|
||||
except InsufficientCreditsError:
|
||||
raise
|
||||
|
||||
# Delegate to AI task
|
||||
from igny8_core.ai.tasks import run_ai_task
|
||||
|
||||
try:
|
||||
payload = {
|
||||
'service_name': service_data.get('name', ''),
|
||||
'service_description': service_data.get('description', ''),
|
||||
'service_benefits': service_data.get('benefits', []),
|
||||
'target_audience': service_data.get('target_audience', ''),
|
||||
'primary_keyword': service_data.get('primary_keyword', ''),
|
||||
'site_id': site.id if site else None,
|
||||
'sector_id': sector.id if sector else None,
|
||||
}
|
||||
|
||||
if hasattr(run_ai_task, 'delay'):
|
||||
# Celery available - queue async
|
||||
task = run_ai_task.delay(
|
||||
function_name='generate_service_page',
|
||||
payload=payload,
|
||||
account_id=account.id
|
||||
)
|
||||
return {
|
||||
'success': True,
|
||||
'task_id': str(task.id),
|
||||
'message': 'Service page generation started'
|
||||
}
|
||||
else:
|
||||
# Celery not available - execute synchronously
|
||||
result = run_ai_task(
|
||||
function_name='generate_service_page',
|
||||
payload=payload,
|
||||
account_id=account.id
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error in generate_service_page: {str(e)}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def generate_taxonomy(self, taxonomy_data, account, site=None, sector=None):
|
||||
"""
|
||||
Generate taxonomy page content.
|
||||
|
||||
Args:
|
||||
taxonomy_data: Dict with taxonomy information (name, description, items, etc.)
|
||||
account: Account instance
|
||||
site: Site instance (optional)
|
||||
sector: Sector instance (optional)
|
||||
|
||||
Returns:
|
||||
dict: Result with success status and data
|
||||
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
# Calculate estimated credits needed (default 1200 words for taxonomy page)
|
||||
estimated_word_count = taxonomy_data.get('word_count', 1200)
|
||||
|
||||
# Check credits
|
||||
try:
|
||||
self.credit_service.check_credits(account, 'content_generation', estimated_word_count)
|
||||
except InsufficientCreditsError:
|
||||
raise
|
||||
|
||||
# Delegate to AI task
|
||||
from igny8_core.ai.tasks import run_ai_task
|
||||
|
||||
try:
|
||||
payload = {
|
||||
'taxonomy_name': taxonomy_data.get('name', ''),
|
||||
'taxonomy_description': taxonomy_data.get('description', ''),
|
||||
'taxonomy_items': taxonomy_data.get('items', []),
|
||||
'primary_keyword': taxonomy_data.get('primary_keyword', ''),
|
||||
'site_id': site.id if site else None,
|
||||
'sector_id': sector.id if sector else None,
|
||||
}
|
||||
|
||||
if hasattr(run_ai_task, 'delay'):
|
||||
# Celery available - queue async
|
||||
task = run_ai_task.delay(
|
||||
function_name='generate_taxonomy',
|
||||
payload=payload,
|
||||
account_id=account.id
|
||||
)
|
||||
return {
|
||||
'success': True,
|
||||
'task_id': str(task.id),
|
||||
'message': 'Taxonomy generation started'
|
||||
}
|
||||
else:
|
||||
# Celery not available - execute synchronously
|
||||
result = run_ai_task(
|
||||
function_name='generate_taxonomy',
|
||||
payload=payload,
|
||||
account_id=account.id
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error in generate_taxonomy: {str(e)}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Content Pipeline Service
|
||||
Orchestrates content processing pipeline: Writer → Linker → Optimizer
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.linking.services.linker_service import LinkerService
|
||||
from igny8_core.business.optimization.services.optimizer_service import OptimizerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentPipelineService:
|
||||
"""Orchestrates content processing pipeline"""
|
||||
|
||||
def __init__(self):
|
||||
self.linker_service = LinkerService()
|
||||
self.optimizer_service = OptimizerService()
|
||||
|
||||
def process_writer_content(
|
||||
self,
|
||||
content_id: int,
|
||||
stages: Optional[List[str]] = None
|
||||
) -> Content:
|
||||
"""
|
||||
Writer → Linker → Optimizer pipeline.
|
||||
|
||||
Args:
|
||||
content_id: Content ID from Writer
|
||||
stages: List of stages to run: ['linking', 'optimization'] (default: both)
|
||||
|
||||
Returns:
|
||||
Processed Content instance
|
||||
"""
|
||||
if stages is None:
|
||||
stages = ['linking', 'optimization']
|
||||
|
||||
try:
|
||||
content = Content.objects.get(id=content_id, source='igny8')
|
||||
except Content.DoesNotExist:
|
||||
raise ValueError(f"IGNY8 content with id {content_id} does not exist")
|
||||
|
||||
# Stage 1: Linking
|
||||
if 'linking' in stages:
|
||||
try:
|
||||
content = self.linker_service.process(content.id)
|
||||
logger.info(f"Linked content {content_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in linking stage for content {content_id}: {str(e)}", exc_info=True)
|
||||
# Continue to next stage even if linking fails
|
||||
pass
|
||||
|
||||
# Stage 2: Optimization
|
||||
if 'optimization' in stages:
|
||||
try:
|
||||
content = self.optimizer_service.optimize_from_writer(content.id)
|
||||
logger.info(f"Optimized content {content_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in optimization stage for content {content_id}: {str(e)}", exc_info=True)
|
||||
# Don't fail the whole pipeline
|
||||
pass
|
||||
|
||||
return content
|
||||
|
||||
def process_synced_content(
|
||||
self,
|
||||
content_id: int,
|
||||
stages: Optional[List[str]] = None
|
||||
) -> Content:
|
||||
"""
|
||||
Synced Content → Optimizer pipeline (skip linking if needed).
|
||||
|
||||
Args:
|
||||
content_id: Content ID from sync (WordPress, Shopify, etc.)
|
||||
stages: List of stages to run: ['optimization'] (default: optimization only)
|
||||
|
||||
Returns:
|
||||
Processed Content instance
|
||||
"""
|
||||
if stages is None:
|
||||
stages = ['optimization']
|
||||
|
||||
try:
|
||||
content = Content.objects.get(id=content_id)
|
||||
except Content.DoesNotExist:
|
||||
raise ValueError(f"Content with id {content_id} does not exist")
|
||||
|
||||
# Stage: Optimization (skip linking for synced content by default)
|
||||
if 'optimization' in stages:
|
||||
try:
|
||||
if content.source == 'wordpress':
|
||||
content = self.optimizer_service.optimize_from_wordpress_sync(content.id)
|
||||
elif content.source in ['shopify', 'custom']:
|
||||
content = self.optimizer_service.optimize_from_external_sync(content.id)
|
||||
else:
|
||||
content = self.optimizer_service.optimize_manual(content.id)
|
||||
|
||||
logger.info(f"Optimized synced content {content_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in optimization stage for content {content_id}: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
return content
|
||||
|
||||
def batch_process_writer_content(
|
||||
self,
|
||||
content_ids: List[int],
|
||||
stages: Optional[List[str]] = None
|
||||
) -> List[Content]:
|
||||
"""
|
||||
Batch process multiple Writer content items.
|
||||
|
||||
Args:
|
||||
content_ids: List of content IDs
|
||||
stages: List of stages to run
|
||||
|
||||
Returns:
|
||||
List of processed Content instances
|
||||
"""
|
||||
results = []
|
||||
for content_id in content_ids:
|
||||
try:
|
||||
result = self.process_writer_content(content_id, stages)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing content {content_id}: {str(e)}", exc_info=True)
|
||||
# Continue with other items
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Metadata Mapping Service
|
||||
Stage 3: Persists cluster/taxonomy/attribute mappings from Tasks to Content
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
from django.db import transaction
|
||||
|
||||
from igny8_core.business.content.models import (
|
||||
Tasks,
|
||||
Content,
|
||||
ContentClusterMap,
|
||||
ContentTaxonomyMap,
|
||||
ContentAttributeMap,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetadataMappingService:
|
||||
"""Service for persisting metadata mappings from Tasks to Content"""
|
||||
|
||||
@transaction.atomic
|
||||
def persist_task_metadata_to_content(self, content: Content) -> None:
|
||||
"""
|
||||
DEPRECATED: This method is deprecated as Content model no longer has task field.
|
||||
Metadata is now persisted directly on content model.
|
||||
|
||||
Args:
|
||||
content: Content instance
|
||||
"""
|
||||
logger.warning(f"[persist_task_metadata_to_content] Deprecated method called for content {content.id}")
|
||||
logger.warning(f"Content model no longer has task field - metadata should be set directly on content")
|
||||
return
|
||||
|
||||
@transaction.atomic
|
||||
def backfill_content_metadata(self, content: Content) -> None:
|
||||
"""
|
||||
Backfill metadata mappings for existing content that may be missing mappings.
|
||||
Note: task field was removed from Content model - only infers from content metadata.
|
||||
|
||||
Args:
|
||||
content: Content instance to backfill
|
||||
"""
|
||||
# If content already has mappings, skip
|
||||
if ContentClusterMap.objects.filter(content=content).exists():
|
||||
return
|
||||
|
||||
# Try to infer from content metadata or cluster field
|
||||
if hasattr(content, 'cluster') and content.cluster:
|
||||
ContentClusterMap.objects.get_or_create(
|
||||
content=content,
|
||||
cluster=content.cluster,
|
||||
role='hub', # Default
|
||||
defaults={
|
||||
'account': content.account,
|
||||
'site': content.site,
|
||||
'sector': content.sector,
|
||||
'source': 'manual',
|
||||
'metadata': {},
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Fallback: Try to infer from content metadata
|
||||
if hasattr(content, 'metadata') and content.metadata:
|
||||
cluster_id = content.metadata.get('cluster_id')
|
||||
if cluster_id:
|
||||
from igny8_core.business.planning.models import Clusters
|
||||
try:
|
||||
cluster = Clusters.objects.get(id=cluster_id)
|
||||
ContentClusterMap.objects.get_or_create(
|
||||
content=content,
|
||||
cluster=cluster,
|
||||
role='hub', # Default
|
||||
defaults={
|
||||
'account': content.account,
|
||||
'site': content.site,
|
||||
'sector': content.sector,
|
||||
'source': 'manual',
|
||||
'metadata': {},
|
||||
}
|
||||
)
|
||||
except Clusters.DoesNotExist:
|
||||
logger.warning(f"Cluster {cluster_id} not found for content {content.id}")
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Content Validation Service
|
||||
Stage 3: Validates content metadata before publish
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from igny8_core.business.content.models import Tasks, Content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentValidationService:
|
||||
"""Service for validating content metadata requirements"""
|
||||
|
||||
def validate_task(self, task: Tasks) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Validate a task has required metadata.
|
||||
|
||||
Args:
|
||||
task: Task instance to validate
|
||||
|
||||
Returns:
|
||||
List of validation errors (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Stage 3: Enforce "no cluster, no task" rule when feature flag enabled
|
||||
from django.conf import settings
|
||||
if getattr(settings, 'USE_SITE_BUILDER_REFACTOR', False):
|
||||
if not task.cluster:
|
||||
errors.append({
|
||||
'field': 'cluster',
|
||||
'code': 'missing_cluster',
|
||||
'message': 'Task must be associated with a cluster before content generation',
|
||||
})
|
||||
|
||||
# Stage 3: Validate entity_type is set
|
||||
if not task.content_type:
|
||||
errors.append({
|
||||
'field': 'content_type',
|
||||
'code': 'missing_content_type',
|
||||
'message': 'Task must have a content type specified',
|
||||
})
|
||||
|
||||
# Stage 3: Validate taxonomy for product/service entities
|
||||
if task.content_type in ['product', 'service']:
|
||||
if not task.taxonomy_term:
|
||||
errors.append({
|
||||
'field': 'taxonomy',
|
||||
'code': 'missing_taxonomy',
|
||||
'message': f'{task.content_type.title()} tasks require a taxonomy association',
|
||||
})
|
||||
|
||||
return errors
|
||||
|
||||
def validate_content(self, content: Content) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Validate content has required metadata before publish.
|
||||
|
||||
Args:
|
||||
content: Content instance to validate
|
||||
|
||||
Returns:
|
||||
List of validation errors (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Stage 3: Validate content_type
|
||||
if not content.content_type:
|
||||
errors.append({
|
||||
'field': 'content_type',
|
||||
'code': 'missing_content_type',
|
||||
'message': 'Content must have a content type specified',
|
||||
})
|
||||
|
||||
# Stage 3: Validate cluster mapping exists for IGNY8 content
|
||||
if content.source == 'igny8':
|
||||
from igny8_core.business.content.models import ContentClusterMap
|
||||
if not ContentClusterMap.objects.filter(content=content).exists():
|
||||
errors.append({
|
||||
'field': 'cluster_mapping',
|
||||
'code': 'missing_cluster_mapping',
|
||||
'message': 'Content must be mapped to at least one cluster',
|
||||
})
|
||||
|
||||
# Stage 3: Validate taxonomy for product/service content
|
||||
if content.content_type in ['product', 'service']:
|
||||
# Check if content has taxonomy terms assigned
|
||||
if not content.taxonomy_terms.exists():
|
||||
errors.append({
|
||||
'field': 'taxonomy_mapping',
|
||||
'code': 'missing_taxonomy_mapping',
|
||||
'message': f'{content.content_type.title()} content requires a taxonomy mapping',
|
||||
})
|
||||
|
||||
# Stage 3: Validate required attributes for products
|
||||
if content.content_type == 'product':
|
||||
# Product validation - currently simplified in Stage 1
|
||||
# TODO: Re-enable attribute validation when product attributes are implemented
|
||||
pass
|
||||
|
||||
return errors
|
||||
|
||||
def validate_for_publish(self, content: Content) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Comprehensive validation before publishing content.
|
||||
|
||||
Args:
|
||||
content: Content instance to validate
|
||||
|
||||
Returns:
|
||||
List of validation errors (empty if ready to publish)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Basic content validation
|
||||
errors.extend(self.validate_content(content))
|
||||
|
||||
# Additional publish requirements
|
||||
if not content.title:
|
||||
errors.append({
|
||||
'field': 'title',
|
||||
'code': 'missing_title',
|
||||
'message': 'Content must have a title before publishing',
|
||||
})
|
||||
|
||||
if not content.content_html or len(content.content_html.strip()) < 100:
|
||||
errors.append({
|
||||
'field': 'content_html',
|
||||
'code': 'insufficient_content',
|
||||
'message': 'Content must have at least 100 characters before publishing',
|
||||
})
|
||||
|
||||
return errors
|
||||
|
||||
def ensure_required_attributes(self, task: Tasks) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Check if task has required attributes based on entity type.
|
||||
|
||||
Args:
|
||||
task: Task instance to check
|
||||
|
||||
Returns:
|
||||
List of missing attribute errors
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if task.content_type == 'product':
|
||||
# Products should have taxonomy and cluster
|
||||
if not task.taxonomy_term:
|
||||
errors.append({
|
||||
'field': 'taxonomy',
|
||||
'code': 'missing_taxonomy',
|
||||
'message': 'Product tasks require a taxonomy (product category)',
|
||||
})
|
||||
|
||||
return errors
|
||||
|
||||
2
backend/igny8_core/business/content/tests/__init__.py
Normal file
2
backend/igny8_core/business/content/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Content tests
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Tests for ContentPipelineService
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.content.services.content_pipeline_service import ContentPipelineService
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class ContentPipelineServiceTests(IntegrationTestBase):
|
||||
"""Tests for ContentPipelineService"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = ContentPipelineService()
|
||||
|
||||
# Create writer content
|
||||
self.writer_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Writer Content",
|
||||
html_content="<p>Writer content.</p>",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
# Create synced content
|
||||
self.synced_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="WordPress Content",
|
||||
html_content="<p>WordPress content.</p>",
|
||||
word_count=500,
|
||||
status='draft',
|
||||
source='wordpress'
|
||||
)
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.LinkerService.process')
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_writer')
|
||||
def test_process_writer_content_full_pipeline(self, mock_optimize, mock_link):
|
||||
"""Test full pipeline for writer content (linking + optimization)"""
|
||||
mock_link.return_value = self.writer_content
|
||||
mock_optimize.return_value = self.writer_content
|
||||
|
||||
result = self.service.process_writer_content(self.writer_content.id)
|
||||
|
||||
self.assertEqual(result.id, self.writer_content.id)
|
||||
mock_link.assert_called_once()
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_writer')
|
||||
def test_process_writer_content_optimization_only(self, mock_optimize):
|
||||
"""Test writer content with optimization only"""
|
||||
mock_optimize.return_value = self.writer_content
|
||||
|
||||
result = self.service.process_writer_content(
|
||||
self.writer_content.id,
|
||||
stages=['optimization']
|
||||
)
|
||||
|
||||
self.assertEqual(result.id, self.writer_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.LinkerService.process')
|
||||
def test_process_writer_content_linking_only(self, mock_link):
|
||||
"""Test writer content with linking only"""
|
||||
mock_link.return_value = self.writer_content
|
||||
|
||||
result = self.service.process_writer_content(
|
||||
self.writer_content.id,
|
||||
stages=['linking']
|
||||
)
|
||||
|
||||
self.assertEqual(result.id, self.writer_content.id)
|
||||
mock_link.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.LinkerService.process')
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_writer')
|
||||
def test_process_writer_content_handles_linker_failure(self, mock_optimize, mock_link):
|
||||
"""Test that pipeline continues when linking fails"""
|
||||
mock_link.side_effect = Exception("Linking failed")
|
||||
mock_optimize.return_value = self.writer_content
|
||||
|
||||
# Should not raise exception, should continue to optimization
|
||||
result = self.service.process_writer_content(self.writer_content.id)
|
||||
|
||||
self.assertEqual(result.id, self.writer_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_wordpress_sync')
|
||||
def test_process_synced_content_wordpress(self, mock_optimize):
|
||||
"""Test synced content pipeline for WordPress"""
|
||||
mock_optimize.return_value = self.synced_content
|
||||
|
||||
result = self.service.process_synced_content(self.synced_content.id)
|
||||
|
||||
self.assertEqual(result.id, self.synced_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_from_external_sync')
|
||||
def test_process_synced_content_shopify(self, mock_optimize):
|
||||
"""Test synced content pipeline for Shopify"""
|
||||
shopify_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Shopify Content",
|
||||
word_count=100,
|
||||
source='shopify'
|
||||
)
|
||||
mock_optimize.return_value = shopify_content
|
||||
|
||||
result = self.service.process_synced_content(shopify_content.id)
|
||||
|
||||
self.assertEqual(result.id, shopify_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.OptimizerService.optimize_manual')
|
||||
def test_process_synced_content_custom(self, mock_optimize):
|
||||
"""Test synced content pipeline for custom source"""
|
||||
custom_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Custom Content",
|
||||
word_count=100,
|
||||
source='custom'
|
||||
)
|
||||
mock_optimize.return_value = custom_content
|
||||
|
||||
result = self.service.process_synced_content(custom_content.id)
|
||||
|
||||
self.assertEqual(result.id, custom_content.id)
|
||||
mock_optimize.assert_called_once()
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.ContentPipelineService.process_writer_content')
|
||||
def test_batch_process_writer_content(self, mock_process):
|
||||
"""Test batch processing writer content"""
|
||||
content2 = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title="Content 2",
|
||||
word_count=100,
|
||||
source='igny8'
|
||||
)
|
||||
|
||||
mock_process.side_effect = [self.writer_content, content2]
|
||||
|
||||
results = self.service.batch_process_writer_content([
|
||||
self.writer_content.id,
|
||||
content2.id
|
||||
])
|
||||
|
||||
self.assertEqual(len(results), 2)
|
||||
self.assertEqual(mock_process.call_count, 2)
|
||||
|
||||
@patch('igny8_core.business.content.services.content_pipeline_service.ContentPipelineService.process_writer_content')
|
||||
def test_batch_process_handles_partial_failure(self, mock_process):
|
||||
"""Test batch processing handles partial failures"""
|
||||
mock_process.side_effect = [self.writer_content, Exception("Failed")]
|
||||
|
||||
results = self.service.batch_process_writer_content([
|
||||
self.writer_content.id,
|
||||
99999
|
||||
])
|
||||
|
||||
# Should continue processing and return successful results
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].id, self.writer_content.id)
|
||||
|
||||
def test_process_writer_content_invalid_content(self):
|
||||
"""Test that ValueError is raised for invalid content"""
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.process_writer_content(99999)
|
||||
|
||||
def test_process_synced_content_invalid_content(self):
|
||||
"""Test that ValueError is raised for invalid synced content"""
|
||||
with self.assertRaises(ValueError):
|
||||
self.service.process_synced_content(99999)
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
Tests for Universal Content Types (Phase 8)
|
||||
Tests for product, service, and taxonomy content generation
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
|
||||
from igny8_core.api.tests.test_integration_base import IntegrationTestBase
|
||||
|
||||
|
||||
class UniversalContentTypesTests(IntegrationTestBase):
|
||||
"""Tests for Phase 8: Universal Content Types"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Add credits to account for testing
|
||||
self.account.credits = 10000
|
||||
self.account.save()
|
||||
self.service = ContentGenerationService()
|
||||
|
||||
@patch('igny8_core.ai.tasks.run_ai_task')
|
||||
def test_product_content_generates_correctly(self, mock_run_ai_task):
|
||||
"""
|
||||
Test: Product content generates correctly
|
||||
Task 17: Verify product generation creates content with correct entity_type and structure
|
||||
"""
|
||||
# Mock AI task response
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'test-task-123'
|
||||
mock_run_ai_task.delay.return_value = mock_task
|
||||
|
||||
product_data = {
|
||||
'name': 'Test Product',
|
||||
'description': 'A test product description',
|
||||
'features': ['Feature 1', 'Feature 2', 'Feature 3'],
|
||||
'target_audience': 'Small businesses',
|
||||
'primary_keyword': 'test product',
|
||||
'word_count': 1500
|
||||
}
|
||||
|
||||
# Generate product content
|
||||
result = self.service.generate_product_content(
|
||||
product_data=product_data,
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector
|
||||
)
|
||||
|
||||
# Verify result
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertIsNotNone(result.get('task_id'))
|
||||
self.assertEqual(result.get('message'), 'Product content generation started')
|
||||
|
||||
# Verify AI task was called with correct function name
|
||||
mock_run_ai_task.delay.assert_called_once()
|
||||
call_args = mock_run_ai_task.delay.call_args
|
||||
self.assertEqual(call_args[1]['function_name'], 'generate_product_content')
|
||||
self.assertEqual(call_args[1]['payload']['product_name'], 'Test Product')
|
||||
|
||||
@patch('igny8_core.ai.tasks.run_ai_task')
|
||||
def test_service_pages_work_correctly(self, mock_run_ai_task):
|
||||
"""
|
||||
Test: Service pages work correctly
|
||||
Task 18: Verify service page generation creates content with correct entity_type
|
||||
"""
|
||||
# Mock AI task response
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'test-task-456'
|
||||
mock_run_ai_task.delay.return_value = mock_task
|
||||
|
||||
service_data = {
|
||||
'name': 'Test Service',
|
||||
'description': 'A test service description',
|
||||
'benefits': ['Benefit 1', 'Benefit 2', 'Benefit 3'],
|
||||
'target_audience': 'Enterprise clients',
|
||||
'primary_keyword': 'test service',
|
||||
'word_count': 1800
|
||||
}
|
||||
|
||||
# Generate service page
|
||||
result = self.service.generate_service_page(
|
||||
service_data=service_data,
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector
|
||||
)
|
||||
|
||||
# Verify result
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertIsNotNone(result.get('task_id'))
|
||||
self.assertEqual(result.get('message'), 'Service page generation started')
|
||||
|
||||
# Verify AI task was called with correct function name
|
||||
mock_run_ai_task.delay.assert_called_once()
|
||||
call_args = mock_run_ai_task.delay.call_args
|
||||
self.assertEqual(call_args[1]['function_name'], 'generate_service_page')
|
||||
self.assertEqual(call_args[1]['payload']['service_name'], 'Test Service')
|
||||
|
||||
@patch('igny8_core.ai.tasks.run_ai_task')
|
||||
def test_taxonomy_pages_work_correctly(self, mock_run_ai_task):
|
||||
"""
|
||||
Test: Taxonomy pages work correctly
|
||||
Task 19: Verify taxonomy generation creates content with correct entity_type
|
||||
"""
|
||||
# Mock AI task response
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = 'test-task-789'
|
||||
mock_run_ai_task.delay.return_value = mock_task
|
||||
|
||||
taxonomy_data = {
|
||||
'name': 'Test Taxonomy',
|
||||
'description': 'A test taxonomy description',
|
||||
'items': ['Category 1', 'Category 2', 'Category 3'],
|
||||
'primary_keyword': 'test taxonomy',
|
||||
'word_count': 1200
|
||||
}
|
||||
|
||||
# Generate taxonomy
|
||||
result = self.service.generate_taxonomy(
|
||||
taxonomy_data=taxonomy_data,
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector
|
||||
)
|
||||
|
||||
# Verify result
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertIsNotNone(result.get('task_id'))
|
||||
self.assertEqual(result.get('message'), 'Taxonomy generation started')
|
||||
|
||||
# Verify AI task was called with correct function name
|
||||
mock_run_ai_task.delay.assert_called_once()
|
||||
call_args = mock_run_ai_task.delay.call_args
|
||||
self.assertEqual(call_args[1]['function_name'], 'generate_taxonomy')
|
||||
self.assertEqual(call_args[1]['payload']['taxonomy_name'], 'Test Taxonomy')
|
||||
|
||||
def test_product_content_has_correct_structure(self):
|
||||
"""
|
||||
Test: Product content generates correctly
|
||||
Task 17: Verify product content has correct entity_type, json_blocks, and structure_data
|
||||
"""
|
||||
# Create product content manually to test structure
|
||||
product_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title='Test Product',
|
||||
html_content='<p>Product content</p>',
|
||||
entity_type='product',
|
||||
json_blocks=[
|
||||
{
|
||||
'type': 'product_overview',
|
||||
'heading': 'Product Overview',
|
||||
'content': 'Product description'
|
||||
},
|
||||
{
|
||||
'type': 'features',
|
||||
'heading': 'Key Features',
|
||||
'items': ['Feature 1', 'Feature 2']
|
||||
},
|
||||
{
|
||||
'type': 'specifications',
|
||||
'heading': 'Specifications',
|
||||
'data': {'Spec 1': 'Value 1'}
|
||||
}
|
||||
],
|
||||
structure_data={
|
||||
'product_type': 'software',
|
||||
'price_range': '$99-$199',
|
||||
'target_market': 'SMB'
|
||||
},
|
||||
word_count=1500,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Verify structure
|
||||
self.assertEqual(product_content.entity_type, 'product')
|
||||
self.assertIsNotNone(product_content.json_blocks)
|
||||
self.assertEqual(len(product_content.json_blocks), 3)
|
||||
self.assertEqual(product_content.json_blocks[0]['type'], 'product_overview')
|
||||
self.assertIsNotNone(product_content.structure_data)
|
||||
self.assertEqual(product_content.structure_data['product_type'], 'software')
|
||||
|
||||
def test_service_content_has_correct_structure(self):
|
||||
"""
|
||||
Test: Service pages work correctly
|
||||
Task 18: Verify service content has correct entity_type and json_blocks
|
||||
"""
|
||||
# Create service content manually to test structure
|
||||
service_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title='Test Service',
|
||||
html_content='<p>Service content</p>',
|
||||
entity_type='service',
|
||||
json_blocks=[
|
||||
{
|
||||
'type': 'service_overview',
|
||||
'heading': 'Service Overview',
|
||||
'content': 'Service description'
|
||||
},
|
||||
{
|
||||
'type': 'benefits',
|
||||
'heading': 'Benefits',
|
||||
'items': ['Benefit 1', 'Benefit 2']
|
||||
},
|
||||
{
|
||||
'type': 'process',
|
||||
'heading': 'Our Process',
|
||||
'steps': ['Step 1', 'Step 2']
|
||||
}
|
||||
],
|
||||
structure_data={
|
||||
'service_type': 'consulting',
|
||||
'duration': '3-6 months',
|
||||
'target_market': 'Enterprise'
|
||||
},
|
||||
word_count=1800,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Verify structure
|
||||
self.assertEqual(service_content.entity_type, 'service')
|
||||
self.assertIsNotNone(service_content.json_blocks)
|
||||
self.assertEqual(len(service_content.json_blocks), 3)
|
||||
self.assertEqual(service_content.json_blocks[0]['type'], 'service_overview')
|
||||
self.assertIsNotNone(service_content.structure_data)
|
||||
self.assertEqual(service_content.structure_data['service_type'], 'consulting')
|
||||
|
||||
def test_taxonomy_content_has_correct_structure(self):
|
||||
"""
|
||||
Test: Taxonomy pages work correctly
|
||||
Task 19: Verify taxonomy content has correct entity_type and json_blocks
|
||||
"""
|
||||
# Create taxonomy content manually to test structure
|
||||
taxonomy_content = Content.objects.create(
|
||||
account=self.account,
|
||||
site=self.site,
|
||||
sector=self.sector,
|
||||
title='Test Taxonomy',
|
||||
html_content='<p>Taxonomy content</p>',
|
||||
entity_type='taxonomy',
|
||||
json_blocks=[
|
||||
{
|
||||
'type': 'taxonomy_overview',
|
||||
'heading': 'Taxonomy Overview',
|
||||
'content': 'Taxonomy description'
|
||||
},
|
||||
{
|
||||
'type': 'categories',
|
||||
'heading': 'Categories',
|
||||
'items': [
|
||||
{
|
||||
'name': 'Category 1',
|
||||
'description': 'Category description',
|
||||
'subcategories': ['Subcat 1', 'Subcat 2']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type': 'tags',
|
||||
'heading': 'Tags',
|
||||
'items': ['Tag 1', 'Tag 2', 'Tag 3']
|
||||
}
|
||||
],
|
||||
structure_data={
|
||||
'taxonomy_type': 'product_categories',
|
||||
'item_count': 10,
|
||||
'hierarchy_levels': 3
|
||||
},
|
||||
word_count=1200,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
# Verify structure
|
||||
self.assertEqual(taxonomy_content.entity_type, 'taxonomy')
|
||||
self.assertIsNotNone(taxonomy_content.json_blocks)
|
||||
self.assertEqual(len(taxonomy_content.json_blocks), 3)
|
||||
self.assertEqual(taxonomy_content.json_blocks[0]['type'], 'taxonomy_overview')
|
||||
self.assertIsNotNone(taxonomy_content.structure_data)
|
||||
self.assertEqual(taxonomy_content.structure_data['taxonomy_type'], 'product_categories')
|
||||
5
backend/igny8_core/business/integration/__init__.py
Normal file
5
backend/igny8_core/business/integration/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Integration Domain
|
||||
Phase 6: Site Integration & Multi-Destination Publishing
|
||||
"""
|
||||
|
||||
12
backend/igny8_core/business/integration/apps.py
Normal file
12
backend/igny8_core/business/integration/apps.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Integration App Configuration
|
||||
"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class IntegrationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'igny8_core.business.integration'
|
||||
label = 'integration'
|
||||
verbose_name = 'Integration'
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-20 23:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SiteIntegration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('platform', models.CharField(choices=[('wordpress', 'WordPress'), ('shopify', 'Shopify'), ('custom', 'Custom API')], db_index=True, help_text="Platform name: 'wordpress', 'shopify', 'custom'", max_length=50)),
|
||||
('platform_type', models.CharField(choices=[('cms', 'CMS'), ('ecommerce', 'Ecommerce'), ('custom_api', 'Custom API')], default='cms', help_text="Platform type: 'cms', 'ecommerce', 'custom_api'", max_length=50)),
|
||||
('config_json', models.JSONField(default=dict, help_text='Platform-specific configuration (URLs, endpoints, etc.)')),
|
||||
('credentials_json', models.JSONField(default=dict, help_text='Encrypted credentials (API keys, tokens, etc.)')),
|
||||
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this integration is active')),
|
||||
('sync_enabled', models.BooleanField(default=False, help_text='Whether two-way sync is enabled')),
|
||||
('last_sync_at', models.DateTimeField(blank=True, help_text='Last successful sync timestamp', null=True)),
|
||||
('sync_status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed'), ('pending', 'Pending'), ('syncing', 'Syncing')], db_index=True, default='pending', help_text='Current sync status', max_length=20)),
|
||||
('sync_error', models.TextField(blank=True, help_text='Last sync error message', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
|
||||
('site', models.ForeignKey(help_text='Site this integration belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='integrations', to='igny8_core_auth.site')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'igny8_site_integrations',
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['site', 'platform'], name='igny8_site__site_id_3901ba_idx'), models.Index(fields=['site', 'is_active'], name='igny8_site__site_id_71bc1a_idx'), models.Index(fields=['account', 'platform'], name='igny8_site__tenant__920542_idx'), models.Index(fields=['sync_status'], name='igny8_site__sync_st_e79021_idx')],
|
||||
'unique_together': {('site', 'platform')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Integration Migrations
|
||||
"""
|
||||
|
||||
131
backend/igny8_core/business/integration/models.py
Normal file
131
backend/igny8_core/business/integration/models.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Integration Models
|
||||
Phase 6: Site Integration & Multi-Destination Publishing
|
||||
"""
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from igny8_core.auth.models import AccountBaseModel
|
||||
|
||||
|
||||
class SiteIntegration(AccountBaseModel):
|
||||
"""
|
||||
Store integration configurations for sites.
|
||||
Each site can have multiple integrations (WordPress, Shopify, etc.).
|
||||
"""
|
||||
|
||||
PLATFORM_CHOICES = [
|
||||
('wordpress', 'WordPress'),
|
||||
('shopify', 'Shopify'),
|
||||
('custom', 'Custom API'),
|
||||
]
|
||||
|
||||
PLATFORM_TYPE_CHOICES = [
|
||||
('cms', 'CMS'),
|
||||
('ecommerce', 'Ecommerce'),
|
||||
('custom_api', 'Custom API'),
|
||||
]
|
||||
|
||||
SYNC_STATUS_CHOICES = [
|
||||
('success', 'Success'),
|
||||
('failed', 'Failed'),
|
||||
('pending', 'Pending'),
|
||||
('syncing', 'Syncing'),
|
||||
]
|
||||
|
||||
site = models.ForeignKey(
|
||||
'igny8_core_auth.Site',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='integrations',
|
||||
help_text="Site this integration belongs to"
|
||||
)
|
||||
|
||||
platform = models.CharField(
|
||||
max_length=50,
|
||||
choices=PLATFORM_CHOICES,
|
||||
db_index=True,
|
||||
help_text="Platform name: 'wordpress', 'shopify', 'custom'"
|
||||
)
|
||||
|
||||
platform_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=PLATFORM_TYPE_CHOICES,
|
||||
default='cms',
|
||||
help_text="Platform type: 'cms', 'ecommerce', 'custom_api'"
|
||||
)
|
||||
|
||||
config_json = models.JSONField(
|
||||
default=dict,
|
||||
help_text="Platform-specific configuration (URLs, endpoints, etc.)"
|
||||
)
|
||||
|
||||
# Credentials stored as JSON (encryption handled at application level)
|
||||
credentials_json = models.JSONField(
|
||||
default=dict,
|
||||
help_text="Encrypted credentials (API keys, tokens, etc.)"
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
db_index=True,
|
||||
help_text="Whether this integration is active"
|
||||
)
|
||||
|
||||
sync_enabled = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether two-way sync is enabled"
|
||||
)
|
||||
|
||||
last_sync_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Last successful sync timestamp"
|
||||
)
|
||||
|
||||
sync_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=SYNC_STATUS_CHOICES,
|
||||
default='pending',
|
||||
db_index=True,
|
||||
help_text="Current sync status"
|
||||
)
|
||||
|
||||
sync_error = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Last sync error message"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'integration'
|
||||
db_table = 'igny8_site_integrations'
|
||||
ordering = ['-created_at']
|
||||
unique_together = [['site', 'platform']]
|
||||
indexes = [
|
||||
models.Index(fields=['site', 'platform']),
|
||||
models.Index(fields=['site', 'is_active']),
|
||||
models.Index(fields=['account', 'platform']),
|
||||
models.Index(fields=['sync_status']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.site.name} - {self.get_platform_display()}"
|
||||
|
||||
def get_credentials(self) -> dict:
|
||||
"""
|
||||
Get decrypted credentials.
|
||||
In production, this should decrypt credentials_json.
|
||||
For now, return as-is (encryption to be implemented).
|
||||
"""
|
||||
return self.credentials_json or {}
|
||||
|
||||
def set_credentials(self, credentials: dict):
|
||||
"""
|
||||
Set encrypted credentials.
|
||||
In production, this should encrypt before storing.
|
||||
For now, store as-is (encryption to be implemented).
|
||||
"""
|
||||
self.credentials_json = credentials
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Integration Services
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,805 @@
|
||||
"""
|
||||
Content Sync Service
|
||||
Phase 6: Site Integration & Multi-Destination Publishing
|
||||
Stage 4: Enhanced with taxonomy and product sync
|
||||
|
||||
Syncs content between IGNY8 and external platforms.
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
from igny8_core.utils.wordpress import WordPressClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentSyncService:
|
||||
"""
|
||||
Service for syncing content to/from external platforms.
|
||||
"""
|
||||
|
||||
def sync_to_external(
|
||||
self,
|
||||
integration: SiteIntegration,
|
||||
content_types: Optional[List[str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync content from IGNY8 to external platform.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
content_types: List of content types to sync (optional)
|
||||
|
||||
Returns:
|
||||
dict: Sync result
|
||||
"""
|
||||
try:
|
||||
if integration.platform == 'wordpress':
|
||||
return self._sync_to_wordpress(integration, content_types)
|
||||
elif integration.platform == 'shopify':
|
||||
return self._sync_to_shopify(integration, content_types)
|
||||
else:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Sync to {integration.platform} not implemented',
|
||||
'synced_count': 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[ContentSyncService] Error syncing to {integration.platform}: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'synced_count': 0
|
||||
}
|
||||
|
||||
def sync_from_external(
|
||||
self,
|
||||
integration: SiteIntegration,
|
||||
content_types: Optional[List[str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync content from external platform to IGNY8.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
content_types: List of content types to sync (optional)
|
||||
|
||||
Returns:
|
||||
dict: Sync result
|
||||
"""
|
||||
try:
|
||||
if integration.platform == 'wordpress':
|
||||
return self._sync_from_wordpress(integration, content_types)
|
||||
elif integration.platform == 'shopify':
|
||||
return self._sync_from_shopify(integration, content_types)
|
||||
else:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Sync from {integration.platform} not implemented',
|
||||
'synced_count': 0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[ContentSyncService] Error syncing from {integration.platform}: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'synced_count': 0
|
||||
}
|
||||
|
||||
def _sync_to_wordpress(
|
||||
self,
|
||||
integration: SiteIntegration,
|
||||
content_types: Optional[List[str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync content from IGNY8 to WordPress.
|
||||
Stage 4: Enhanced to sync taxonomies before content.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
content_types: List of content types to sync
|
||||
|
||||
Returns:
|
||||
dict: Sync result
|
||||
"""
|
||||
try:
|
||||
# Get WordPress client
|
||||
credentials = integration.get_credentials()
|
||||
client = WordPressClient(
|
||||
site_url=integration.config_json.get('site_url', ''),
|
||||
username=credentials.get('username'),
|
||||
app_password=credentials.get('app_password')
|
||||
)
|
||||
|
||||
# Stage 4: Sync taxonomies first
|
||||
taxonomy_result = self._sync_taxonomies_to_wordpress(integration, client)
|
||||
|
||||
# Sync content (posts/products)
|
||||
from igny8_core.business.content.models import Content
|
||||
from igny8_core.business.publishing.services.adapters.wordpress_adapter import WordPressAdapter
|
||||
|
||||
content_query = Content.objects.filter(
|
||||
account=integration.account,
|
||||
site=integration.site,
|
||||
source='igny8',
|
||||
status='publish'
|
||||
)
|
||||
|
||||
if content_types:
|
||||
content_query = content_query.filter(content_type__in=content_types)
|
||||
|
||||
synced_count = 0
|
||||
adapter = WordPressAdapter()
|
||||
destination_config = {
|
||||
'site_url': integration.config_json.get('site_url', ''),
|
||||
'username': credentials.get('username'),
|
||||
'app_password': credentials.get('app_password'),
|
||||
'status': 'publish'
|
||||
}
|
||||
|
||||
for content in content_query[:100]: # Limit to 100 per sync
|
||||
result = adapter.publish(content, destination_config)
|
||||
if result.get('success'):
|
||||
synced_count += 1
|
||||
# Store external reference
|
||||
if not content.metadata:
|
||||
content.metadata = {}
|
||||
content.metadata['wordpress_id'] = result.get('external_id')
|
||||
content.save(update_fields=['metadata'])
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'synced_count': synced_count,
|
||||
'taxonomies_synced': taxonomy_result.get('synced_count', 0),
|
||||
'message': f'Synced {synced_count} content items and {taxonomy_result.get("synced_count", 0)} taxonomies'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[ContentSyncService] Error syncing to WordPress: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'synced_count': 0
|
||||
}
|
||||
|
||||
def sync_from_wordpress(
|
||||
self,
|
||||
integration: SiteIntegration
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
STAGE 3: Sync content from WordPress to IGNY8 using final Stage 1 schema.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
|
||||
Returns:
|
||||
dict: Sync result with synced_count
|
||||
"""
|
||||
try:
|
||||
posts = self._fetch_wordpress_posts(integration)
|
||||
synced_count = 0
|
||||
|
||||
from igny8_core.business.content.models import Content, ContentTaxonomy
|
||||
|
||||
# Get default cluster if available
|
||||
from igny8_core.business.planning.models import Clusters
|
||||
default_cluster = Clusters.objects.filter(
|
||||
site=integration.site,
|
||||
name__icontains='imported'
|
||||
).first()
|
||||
|
||||
for post in posts:
|
||||
# Map WP post type to content_type
|
||||
wp_type = post.get('type', 'post')
|
||||
content_type = self._map_wp_post_type(wp_type)
|
||||
|
||||
# Check if content already exists by external_id
|
||||
existing = Content.objects.filter(
|
||||
site=integration.site,
|
||||
external_id=str(post.get('id')),
|
||||
source='wordpress'
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Update existing content
|
||||
existing.title = post.get('title', {}).get('rendered', '') or post.get('title', '')
|
||||
existing.content_html = post.get('content', {}).get('rendered', '') or post.get('content', '')
|
||||
existing.external_url = post.get('link', '')
|
||||
existing.status = 'published' if post.get('status') == 'publish' else 'draft'
|
||||
existing.save()
|
||||
content = existing
|
||||
else:
|
||||
# Create new content
|
||||
content = Content.objects.create(
|
||||
account=integration.account,
|
||||
site=integration.site,
|
||||
sector=integration.site.sectors.first() if hasattr(integration.site, 'sectors') else None,
|
||||
title=post.get('title', {}).get('rendered', '') or post.get('title', ''),
|
||||
content_html=post.get('content', {}).get('rendered', '') or post.get('content', ''),
|
||||
cluster=default_cluster,
|
||||
content_type=content_type,
|
||||
content_structure='article', # Default, can be refined
|
||||
source='wordpress',
|
||||
status='published' if post.get('status') == 'publish' else 'draft',
|
||||
external_id=str(post.get('id')),
|
||||
external_url=post.get('link', ''),
|
||||
)
|
||||
|
||||
# Sync taxonomies (categories and tags)
|
||||
self._sync_post_taxonomies(content, post, integration)
|
||||
|
||||
synced_count += 1
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'synced_count': synced_count
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[ContentSyncService] Error syncing from WordPress: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'synced_count': 0
|
||||
}
|
||||
|
||||
def _sync_from_wordpress(
|
||||
self,
|
||||
integration: SiteIntegration,
|
||||
content_types: Optional[List[str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Internal method for syncing from WordPress (used by sync_from_external).
|
||||
Stage 4: Enhanced to sync taxonomies and products.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
content_types: List of content types to sync
|
||||
|
||||
Returns:
|
||||
dict: Sync result
|
||||
"""
|
||||
try:
|
||||
# Get WordPress client
|
||||
credentials = integration.get_credentials()
|
||||
client = WordPressClient(
|
||||
site_url=integration.config_json.get('site_url', ''),
|
||||
username=credentials.get('username'),
|
||||
app_password=credentials.get('app_password')
|
||||
)
|
||||
|
||||
# Stage 4: Sync taxonomies first
|
||||
taxonomy_result = self._sync_taxonomies_from_wordpress(integration, client)
|
||||
|
||||
# Sync posts
|
||||
posts_result = self.sync_from_wordpress(integration)
|
||||
|
||||
# Sync WooCommerce products if available
|
||||
products_result = self._sync_products_from_wordpress(integration, client)
|
||||
|
||||
total_synced = (
|
||||
posts_result.get('synced_count', 0) +
|
||||
products_result.get('synced_count', 0)
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'synced_count': total_synced,
|
||||
'taxonomies_synced': taxonomy_result.get('synced_count', 0),
|
||||
'posts_synced': posts_result.get('synced_count', 0),
|
||||
'products_synced': products_result.get('synced_count', 0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[ContentSyncService] Error syncing from WordPress: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'synced_count': 0
|
||||
}
|
||||
|
||||
def _fetch_wordpress_posts(
|
||||
self,
|
||||
integration: SiteIntegration
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch posts from WordPress.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
|
||||
Returns:
|
||||
List of post dictionaries
|
||||
"""
|
||||
try:
|
||||
credentials = integration.get_credentials()
|
||||
client = WordPressClient(
|
||||
site_url=integration.config_json.get('site_url', ''),
|
||||
username=credentials.get('username'),
|
||||
app_password=credentials.get('app_password')
|
||||
)
|
||||
|
||||
# Fetch posts via WordPress REST API
|
||||
import requests
|
||||
response = client.session.get(
|
||||
f"{client.api_base}/posts",
|
||||
params={'per_page': 100, 'status': 'publish'}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
posts = response.json()
|
||||
return [
|
||||
{
|
||||
'id': post.get('id'),
|
||||
'title': post.get('title', {}).get('rendered', ''),
|
||||
'content': post.get('content', {}).get('rendered', ''),
|
||||
'status': post.get('status', 'publish'),
|
||||
'categories': post.get('categories', []),
|
||||
'tags': post.get('tags', [])
|
||||
}
|
||||
for post in posts
|
||||
]
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching WordPress posts: {e}")
|
||||
return []
|
||||
|
||||
# Stage 4: Taxonomy Sync Methods
|
||||
|
||||
def _sync_taxonomies_from_wordpress(
|
||||
self,
|
||||
integration: SiteIntegration,
|
||||
client: WordPressClient
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync taxonomies from WordPress to IGNY8.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
client: WordPressClient instance
|
||||
|
||||
Returns:
|
||||
dict: Sync result with synced_count
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.site_building.models import SiteBlueprint
|
||||
from igny8_core.business.site_building.services.taxonomy_service import TaxonomyService
|
||||
|
||||
# Get or create site blueprint for this site
|
||||
blueprint = SiteBlueprint.objects.filter(
|
||||
account=integration.account,
|
||||
site=integration.site
|
||||
).first()
|
||||
|
||||
if not blueprint:
|
||||
logger.warning(f"No blueprint found for site {integration.site.id}, skipping taxonomy sync")
|
||||
return {'success': True, 'synced_count': 0}
|
||||
|
||||
taxonomy_service = TaxonomyService()
|
||||
synced_count = 0
|
||||
|
||||
# Sync WordPress categories
|
||||
categories = client.get_categories(per_page=100)
|
||||
category_records = [
|
||||
{
|
||||
'name': cat['name'],
|
||||
'slug': cat['slug'],
|
||||
'description': cat.get('description', ''),
|
||||
'taxonomy_type': 'blog_category',
|
||||
'external_reference': str(cat['id']),
|
||||
'metadata': {'parent': cat.get('parent', 0)}
|
||||
}
|
||||
for cat in categories
|
||||
]
|
||||
if category_records:
|
||||
taxonomy_service.import_from_external(
|
||||
blueprint,
|
||||
category_records,
|
||||
default_type='blog_category'
|
||||
)
|
||||
synced_count += len(category_records)
|
||||
|
||||
# Sync WordPress tags
|
||||
tags = client.get_tags(per_page=100)
|
||||
tag_records = [
|
||||
{
|
||||
'name': tag['name'],
|
||||
'slug': tag['slug'],
|
||||
'description': tag.get('description', ''),
|
||||
'taxonomy_type': 'blog_tag',
|
||||
'external_reference': str(tag['id'])
|
||||
}
|
||||
for tag in tags
|
||||
]
|
||||
if tag_records:
|
||||
taxonomy_service.import_from_external(
|
||||
blueprint,
|
||||
tag_records,
|
||||
default_type='blog_tag'
|
||||
)
|
||||
synced_count += len(tag_records)
|
||||
|
||||
# Sync WooCommerce product categories if available (401 is expected if WooCommerce not installed or credentials missing)
|
||||
try:
|
||||
product_categories = client.get_product_categories(per_page=100)
|
||||
product_category_records = [
|
||||
{
|
||||
'name': cat['name'],
|
||||
'slug': cat['slug'],
|
||||
'description': cat.get('description', ''),
|
||||
'taxonomy_type': 'product_category',
|
||||
'external_reference': f"wc_cat_{cat['id']}",
|
||||
'metadata': {'parent': cat.get('parent', 0)}
|
||||
}
|
||||
for cat in product_categories
|
||||
]
|
||||
if product_category_records:
|
||||
taxonomy_service.import_from_external(
|
||||
blueprint,
|
||||
product_category_records,
|
||||
default_type='product_category'
|
||||
)
|
||||
synced_count += len(product_category_records)
|
||||
except Exception as e:
|
||||
# Silently skip WooCommerce if not available (401 means no consumer key/secret configured or plugin not installed)
|
||||
logger.debug(f"WooCommerce product categories not available: {e}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'synced_count': synced_count
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing taxonomies from WordPress: {e}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'synced_count': 0
|
||||
}
|
||||
|
||||
def _sync_taxonomies_to_wordpress(
|
||||
self,
|
||||
integration: SiteIntegration,
|
||||
client: WordPressClient
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Ensure taxonomies exist in WordPress before publishing content.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
client: WordPressClient instance
|
||||
|
||||
Returns:
|
||||
dict: Sync result with synced_count
|
||||
"""
|
||||
try:
|
||||
from igny8_core.business.site_building.models import SiteBlueprint, SiteBlueprintTaxonomy
|
||||
|
||||
# Get site blueprint
|
||||
blueprint = SiteBlueprint.objects.filter(
|
||||
account=integration.account,
|
||||
site=integration.site
|
||||
).first()
|
||||
|
||||
if not blueprint:
|
||||
return {'success': True, 'synced_count': 0}
|
||||
|
||||
synced_count = 0
|
||||
|
||||
# Get taxonomies that don't have external_reference (not yet synced)
|
||||
taxonomies = SiteBlueprintTaxonomy.objects.filter(
|
||||
site_blueprint=blueprint,
|
||||
external_reference__isnull=True
|
||||
)
|
||||
|
||||
for taxonomy in taxonomies:
|
||||
try:
|
||||
if taxonomy.taxonomy_type in ['blog_category', 'product_category']:
|
||||
result = client.create_category(
|
||||
name=taxonomy.name,
|
||||
slug=taxonomy.slug,
|
||||
description=taxonomy.description
|
||||
)
|
||||
if result.get('success'):
|
||||
taxonomy.external_reference = str(result.get('category_id'))
|
||||
taxonomy.save(update_fields=['external_reference'])
|
||||
synced_count += 1
|
||||
elif taxonomy.taxonomy_type in ['blog_tag', 'product_tag']:
|
||||
result = client.create_tag(
|
||||
name=taxonomy.name,
|
||||
slug=taxonomy.slug,
|
||||
description=taxonomy.description
|
||||
)
|
||||
if result.get('success'):
|
||||
taxonomy.external_reference = str(result.get('tag_id'))
|
||||
taxonomy.save(update_fields=['external_reference'])
|
||||
synced_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Error syncing taxonomy {taxonomy.id} to WordPress: {e}")
|
||||
continue
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'synced_count': synced_count
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing taxonomies to WordPress: {e}", exc_info=True)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'synced_count': 0
|
||||
}
|
||||
|
||||
def _sync_products_from_wordpress(
|
||||
self,
|
||||
integration: SiteIntegration,
|
||||
client: WordPressClient
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync WooCommerce products from WordPress to IGNY8.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
client: WordPressClient instance
|
||||
|
||||
Returns:
|
||||
dict: Sync result with synced_count
|
||||
"""
|
||||
try:
|
||||
products = client.get_products(per_page=100)
|
||||
synced_count = 0
|
||||
|
||||
from igny8_core.business.content.models import Content
|
||||
|
||||
for product in products:
|
||||
content, created = Content.objects.get_or_create(
|
||||
account=integration.account,
|
||||
site=integration.site,
|
||||
sector=integration.site.sectors.first() if hasattr(integration.site, 'sectors') else None,
|
||||
title=product.get('name', ''),
|
||||
source='wordpress',
|
||||
defaults={
|
||||
'html_content': product.get('description', ''),
|
||||
'content_type': 'product',
|
||||
'status': 'published' if product.get('status') == 'publish' else 'draft',
|
||||
'metadata': {
|
||||
'wordpress_id': product.get('id'),
|
||||
'product_type': product.get('type'),
|
||||
'sku': product.get('sku'),
|
||||
'price': product.get('price'),
|
||||
'regular_price': product.get('regular_price'),
|
||||
'sale_price': product.get('sale_price'),
|
||||
'categories': product.get('categories', []),
|
||||
'tags': product.get('tags', []),
|
||||
'attributes': product.get('attributes', [])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if not created:
|
||||
content.html_content = product.get('description', '')
|
||||
if not content.metadata:
|
||||
content.metadata = {}
|
||||
content.metadata.update({
|
||||
'wordpress_id': product.get('id'),
|
||||
'product_type': product.get('type'),
|
||||
'sku': product.get('sku'),
|
||||
'price': product.get('price'),
|
||||
'regular_price': product.get('regular_price'),
|
||||
'sale_price': product.get('sale_price'),
|
||||
'categories': product.get('categories', []),
|
||||
'tags': product.get('tags', []),
|
||||
'attributes': product.get('attributes', [])
|
||||
})
|
||||
content.save()
|
||||
|
||||
synced_count += 1
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'synced_count': synced_count
|
||||
}
|
||||
except Exception as e:
|
||||
# Silently skip products if WooCommerce auth fails (expected if consumer key/secret not configured)
|
||||
logger.debug(f"WooCommerce products not synced: {e}")
|
||||
return {
|
||||
'success': True,
|
||||
'synced_count': 0
|
||||
}
|
||||
|
||||
def _sync_to_shopify(
|
||||
self,
|
||||
integration: SiteIntegration,
|
||||
content_types: Optional[List[str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync content from IGNY8 to Shopify.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
content_types: List of content types to sync
|
||||
|
||||
Returns:
|
||||
dict: Sync result
|
||||
"""
|
||||
# TODO: Implement Shopify sync
|
||||
logger.info(f"[ContentSyncService] Syncing to Shopify for integration {integration.id}")
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'synced_count': 0,
|
||||
'message': 'Shopify sync not yet implemented'
|
||||
}
|
||||
|
||||
def sync_from_shopify(
|
||||
self,
|
||||
integration: SiteIntegration
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Sync content from Shopify to IGNY8.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
|
||||
Returns:
|
||||
dict: Sync result with synced_count
|
||||
"""
|
||||
try:
|
||||
products = self._fetch_shopify_products(integration)
|
||||
synced_count = 0
|
||||
|
||||
from igny8_core.business.content.models import Content
|
||||
|
||||
for product in products:
|
||||
# Create or update content from product
|
||||
content, created = Content.objects.get_or_create(
|
||||
account=integration.account,
|
||||
site=integration.site,
|
||||
sector=integration.site.sectors.first() if hasattr(integration.site, 'sectors') else None,
|
||||
title=product.get('title', ''),
|
||||
source='shopify',
|
||||
defaults={
|
||||
'html_content': product.get('body_html', ''),
|
||||
'status': 'published',
|
||||
'metadata': {'shopify_id': product.get('id')}
|
||||
}
|
||||
)
|
||||
|
||||
if not created:
|
||||
content.html_content = product.get('body_html', '')
|
||||
if not content.metadata:
|
||||
content.metadata = {}
|
||||
content.metadata['shopify_id'] = product.get('id')
|
||||
content.save()
|
||||
|
||||
synced_count += 1
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'synced_count': synced_count
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[ContentSyncService] Error syncing from Shopify: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'synced_count': 0
|
||||
}
|
||||
|
||||
def _map_wp_post_type(self, wp_type: str) -> str:
|
||||
"""
|
||||
STAGE 3: Map WordPress post type to IGNY8 content_type.
|
||||
|
||||
Args:
|
||||
wp_type: WordPress post type (post, page, product, etc.)
|
||||
|
||||
Returns:
|
||||
str: Mapped content_type
|
||||
"""
|
||||
mapping = {
|
||||
'post': 'post',
|
||||
'page': 'page',
|
||||
'product': 'product',
|
||||
'service': 'service',
|
||||
# Add more mappings as needed
|
||||
}
|
||||
return mapping.get(wp_type, 'post')
|
||||
|
||||
def _sync_post_taxonomies(
|
||||
self,
|
||||
content,
|
||||
post: Dict[str, Any],
|
||||
integration: SiteIntegration
|
||||
) -> None:
|
||||
"""
|
||||
STAGE 3: Sync taxonomies (categories, tags) for a WordPress post.
|
||||
|
||||
Args:
|
||||
content: Content instance
|
||||
post: WordPress post data
|
||||
integration: SiteIntegration instance
|
||||
"""
|
||||
from igny8_core.business.content.models import ContentTaxonomy
|
||||
|
||||
# Sync categories
|
||||
for cat_id in post.get('categories', []):
|
||||
taxonomy, _ = ContentTaxonomy.objects.get_or_create(
|
||||
site=integration.site,
|
||||
external_id=cat_id,
|
||||
external_taxonomy='category',
|
||||
defaults={
|
||||
'name': f'Category {cat_id}', # Will be updated later
|
||||
'slug': f'category-{cat_id}',
|
||||
'taxonomy_type': 'category',
|
||||
'account': integration.account,
|
||||
'sector': integration.site.sectors.first() if hasattr(integration.site, 'sectors') else None,
|
||||
}
|
||||
)
|
||||
content.taxonomy_terms.add(taxonomy)
|
||||
|
||||
# Sync tags
|
||||
for tag_id in post.get('tags', []):
|
||||
taxonomy, _ = ContentTaxonomy.objects.get_or_create(
|
||||
site=integration.site,
|
||||
external_id=tag_id,
|
||||
external_taxonomy='post_tag',
|
||||
defaults={
|
||||
'name': f'Tag {tag_id}', # Will be updated later
|
||||
'slug': f'tag-{tag_id}',
|
||||
'taxonomy_type': 'tag',
|
||||
'account': integration.account,
|
||||
'sector': integration.site.sectors.first() if hasattr(integration.site, 'sectors') else None,
|
||||
}
|
||||
)
|
||||
content.taxonomy_terms.add(taxonomy)
|
||||
|
||||
def _sync_from_shopify(
|
||||
self,
|
||||
integration: SiteIntegration,
|
||||
content_types: Optional[List[str]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Internal method for syncing from Shopify (used by sync_from_external).
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
content_types: List of content types to sync
|
||||
|
||||
Returns:
|
||||
dict: Sync result
|
||||
"""
|
||||
return self.sync_from_shopify(integration)
|
||||
|
||||
def _fetch_shopify_products(
|
||||
self,
|
||||
integration: SiteIntegration
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch products from Shopify.
|
||||
|
||||
Args:
|
||||
integration: SiteIntegration instance
|
||||
|
||||
Returns:
|
||||
List of product dictionaries
|
||||
"""
|
||||
# TODO: Implement actual Shopify API call
|
||||
# For now, return empty list - tests will mock this
|
||||
logger.info(f"[ContentSyncService] Fetching Shopify products for integration {integration.id}")
|
||||
return []
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user