Compare commits
447 Commits
phase-0-fo
...
68942410ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68942410ae | ||
|
|
9ec87ed932 | ||
|
|
c61cf7c39f | ||
|
|
cff00f87ff | ||
|
|
c23698f7f8 | ||
|
|
8162b6ae92 | ||
|
|
d9dbb1e4b8 | ||
|
|
125489df0f | ||
|
|
cda56f15ba | ||
|
|
aa48a55504 | ||
|
|
78f71558ed | ||
|
|
f637f700eb | ||
|
|
9150b60c2d | ||
|
|
93ecb5ceb8 | ||
|
|
9149281c1c | ||
|
|
293c1e9c0d | ||
|
|
985d7bc3e1 | ||
|
|
4b81ac07f5 | ||
|
|
a518997467 | ||
|
|
94b1ce8d8f | ||
|
|
f7f6a12e7b | ||
|
|
a6fab8784d | ||
|
|
cd2c84116b | ||
|
|
ade055c971 | ||
|
|
90aa99b2c1 | ||
|
|
eb88a0e12d | ||
|
|
d161378bd9 | ||
|
|
1acecd8639 | ||
|
|
60263b4682 | ||
|
|
0b24fe8c77 | ||
|
|
c51270a3be | ||
|
|
75706e8b05 | ||
|
|
410d2b33ec | ||
|
|
db1fd2fff8 | ||
|
|
ad895fcb3a | ||
|
|
33ac4be8df | ||
|
|
44ecd3fa7d | ||
|
|
9824e9a4dc | ||
|
|
a3f817a292 | ||
|
|
9cb0e05618 | ||
|
|
f163a2e07d | ||
|
|
6e2101d019 | ||
|
|
12956ec64a | ||
|
|
b2e8732a82 | ||
|
|
a736bc3d34 | ||
|
|
20fdd3b295 | ||
|
|
50aafd9ce3 | ||
|
|
6997702b12 | ||
|
|
87d1392b4c | ||
|
|
aba2c7da01 | ||
|
|
c665c44aba | ||
|
|
3f49a2599e | ||
|
|
6a056e3589 | ||
|
|
69363b9b31 | ||
|
|
c812da6742 | ||
|
|
7a35981038 | ||
|
|
5fb3687854 | ||
|
|
4dd129b863 | ||
|
|
6a4f95c35a | ||
|
|
4d13a57068 | ||
|
|
72d0b6b0fd | ||
|
|
92211f065b | ||
|
|
bfbade7624 | ||
|
|
c54db6c2d9 | ||
|
|
74e29380fe | ||
|
|
92d16c76a7 | ||
|
|
9f85ce4f52 | ||
|
|
33ad6768ec | ||
|
|
73d7a6953b | ||
|
|
7d3ecd7cc2 | ||
|
|
c09c6cf7eb | ||
|
|
144e955b92 | ||
|
|
da3b45d1c7 | ||
|
|
affa783a4f | ||
|
|
8231c499c2 | ||
|
|
3f2879d269 | ||
|
|
40b7aced14 | ||
|
|
42d04fb7f2 | ||
|
|
d144f5d19a | ||
|
|
7483de6aba | ||
|
|
9764a09a25 | ||
|
|
4e9d8af768 | ||
|
|
156742d679 | ||
|
|
191287829f | ||
|
|
69e88432c5 | ||
|
|
6dcbc651dd | ||
|
|
f0066b6e7d | ||
|
|
65fea95d33 | ||
|
|
3cbed65601 | ||
|
|
1dd2d53a8e | ||
|
|
c87bc7266c | ||
|
|
8aef9c7727 | ||
|
|
2420f1678d | ||
|
|
508b6b4220 | ||
|
|
46fc6dcf04 | ||
|
|
6c4415ab16 | ||
|
|
4e764e208d | ||
|
|
31c06d032c | ||
|
|
7a2b424237 | ||
|
|
dc9dba2c9e | ||
|
|
7877a245b4 | ||
|
|
bfb07947ea | ||
|
|
a0eee0df42 | ||
|
|
365dcfbbd2 | ||
|
|
c455a5ad83 | ||
|
|
4a16a6a402 | ||
|
|
ee4fa53987 | ||
|
|
57c89ec031 | ||
|
|
f986efde37 | ||
|
|
2622bf55a2 | ||
|
|
d473b9e767 | ||
|
|
e9ce2d2b27 | ||
|
|
3cd2cdafa9 | ||
|
|
bbc70751db | ||
|
|
f3d67e9f4a | ||
|
|
878ec612f8 | ||
|
|
5b9d1dcfb0 | ||
|
|
16134f858d | ||
|
|
1e718105f2 | ||
|
|
f91037b729 | ||
|
|
d92a99ecc3 | ||
|
|
6cf786b03f | ||
|
|
6b291671bd | ||
|
|
3a7ea1f4f3 | ||
|
|
1e3299a089 | ||
|
|
8b895dbdc7 | ||
|
|
1521f3ff8c | ||
|
|
40dfe20ead | ||
|
|
f8a9293196 | ||
|
|
1fc7d3717d | ||
|
|
ab4724cba4 | ||
|
|
32dae2a7d5 | ||
|
|
a8c572a996 | ||
|
|
c36b70f31f | ||
|
|
39df00e5ae | ||
|
|
30bbcb08a1 | ||
|
|
544741fbe6 | ||
|
|
316f48d024 | ||
|
|
a9788820fd | ||
|
|
de425e0e93 | ||
|
|
316cafab1b | ||
|
|
aa8b8a9756 | ||
|
|
291d8cc968 | ||
|
|
b9774aafa2 | ||
|
|
5d96e1a2bd | ||
|
|
b0522c2989 | ||
|
|
23e628079b | ||
|
|
c9f082cb12 | ||
|
|
7df6e190fc | ||
|
|
30b93e5715 | ||
|
|
1eb25d1c47 | ||
|
|
a38626ba67 | ||
|
|
a7eddd44b2 | ||
|
|
7631a77822 | ||
|
|
f860a20fa0 | ||
|
|
ca5451c795 | ||
|
|
b2012e9563 | ||
|
|
04f04af813 | ||
|
|
50af3501ac | ||
|
|
7357846527 | ||
|
|
0af40c0929 | ||
|
|
1a3b71ffd5 | ||
|
|
ba6d322954 | ||
|
|
aab6a07c07 | ||
|
|
54e1238f8a | ||
|
|
6439fc5a3a | ||
|
|
6f449c32c1 | ||
|
|
9f82a11c56 | ||
|
|
d97a96a7c4 | ||
|
|
71a38435b1 | ||
|
|
aeaac01990 | ||
|
|
55a00bf1ad | ||
|
|
861ca016aa | ||
|
|
a7a772a78c | ||
|
|
3f2385d4d9 | ||
|
|
42bc24f2c0 | ||
|
|
90b532d13b | ||
|
|
34d2b3abf9 | ||
|
|
a95aa8f17c | ||
|
|
87fdbce0e9 | ||
|
|
1c939acad5 | ||
|
|
c3c875c9b8 | ||
|
|
c7fefbadc5 | ||
|
|
2cf8eb2405 | ||
|
|
59e9cb4322 | ||
|
|
8d47d6a555 | ||
|
|
550a8f26a2 | ||
|
|
d2f3f3ef97 | ||
|
|
0100db62c0 | ||
|
|
83380848d5 | ||
|
|
2b9a29407f | ||
|
|
492a83ebcb | ||
|
|
302e14196c | ||
|
|
79618baede | ||
|
|
062d09d899 | ||
|
|
d7a49525f4 | ||
|
|
98396cb7b9 | ||
|
|
d412651875 | ||
|
|
3ce42202b2 | ||
|
|
dc024ae004 | ||
|
|
2a57509a1e | ||
|
|
ac8fa2ae9c | ||
|
|
9e6868fe69 | ||
|
|
0549dea124 | ||
|
|
8d096b383a | ||
|
|
fcfe261bb4 | ||
|
|
4237c203b4 | ||
|
|
4bea79a76d | ||
|
|
341650bddc | ||
|
|
e9e0de40d0 | ||
|
|
0b3830c891 | ||
|
|
0839455418 | ||
|
|
831b179c49 | ||
|
|
ef1a7f2dec | ||
|
|
d97b9962fd | ||
|
|
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 |
380
.cursorrules
Normal file
380
.cursorrules
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
# IGNY8 Development Rules & Standards
|
||||||
|
|
||||||
|
**Project:** IGNY8 - AI-Powered Content Platform
|
||||||
|
**Version:** v1.0.0
|
||||||
|
**Last Updated:** December 12, 2025
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 General Development Principles
|
||||||
|
|
||||||
|
### 1. **Always Read Documentation First**
|
||||||
|
Before making changes, consult these critical docs:
|
||||||
|
- `ARCHITECTURE-KNOWLEDGE-BASE.md` - System architecture and design patterns
|
||||||
|
- `CHANGELOG.md` - Recent changes and version history
|
||||||
|
- `IGNY8-COMPLETE-FEATURES-GUIDE.md` - Complete feature set and capabilities
|
||||||
|
- `docs/00-SYSTEM/` - Core system architecture
|
||||||
|
- `docs/10-BACKEND/` - Backend models, services, APIs
|
||||||
|
- `docs/20-API/` - API endpoint documentation
|
||||||
|
- `docs/30-FRONTEND/` - Frontend components and architecture
|
||||||
|
- `docs/40-WORKFLOWS/` - Business workflows and processes
|
||||||
|
|
||||||
|
### 2. **Maintain Consistency**
|
||||||
|
- **API Design:** Follow existing RESTful patterns in `backend/igny8_core/*/views.py`
|
||||||
|
- **Models:** Use existing base classes (`SoftDeletableModel`, `AccountBaseModel`, `SiteSectorBaseModel`)
|
||||||
|
- **Services:** Follow service pattern in `backend/igny8_core/business/*/services/`
|
||||||
|
- **AI Functions:** Use AI framework in `backend/igny8_core/ai/` (not legacy `utils/ai_processor.py`)
|
||||||
|
- **Frontend Components:** Use existing component library in `frontend/src/components/`
|
||||||
|
- **Styling:** Use TailwindCSS classes, follow existing design system in `frontend/DESIGN_SYSTEM.md`
|
||||||
|
- **State Management:** Use Zustand stores in `frontend/src/store/`
|
||||||
|
|
||||||
|
### 3. **Multi-Tenancy Rules**
|
||||||
|
- **ALWAYS scope by account:** Every query must filter by account
|
||||||
|
- **Site/Sector scoping:** Use `SiteSectorBaseModel` for site-specific data
|
||||||
|
- **Permissions:** Check permissions via `IsAuthenticatedAndActive`, `HasTenantAccess`, role-based permissions
|
||||||
|
- **No cross-tenant access:** Validate account ownership before operations
|
||||||
|
|
||||||
|
### 4. **API Endpoint Rules**
|
||||||
|
- **Use existing API structure:** All user-facing endpoints under `/api/v1/<module>/`, admin endpoints under `/api/v1/<module>/admin/`
|
||||||
|
- **No parallel API systems:** Register all endpoints in module's `urls.py`, test via Swagger at `/api/docs/` before documenting
|
||||||
|
- **Document in Swagger:** Ensure drf-spectacular auto-generates docs; verify endpoint appears at `/api/docs/` and `/api/schema/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Change Management & Versioning
|
||||||
|
|
||||||
|
alwys udpated changelog with incremental updates, as fixed aded or modified for each version update, dotn remove or modify teh exsitng version changes
|
||||||
|
### Versioning Scheme: `v<MAJOR>.<MINOR>.<PATCH>`
|
||||||
|
|
||||||
|
**Example:** v1.2.5
|
||||||
|
- `MAJOR when asked` (1.x.x): Breaking changes, major features, architecture changes
|
||||||
|
- `MAJOR` (x.2.x): New features, modules, significant enhancements
|
||||||
|
- `MINOR/PATCH` (x.x.5): Bug fixes, small improvements, refactors
|
||||||
|
|
||||||
|
### Changelog Update Rules
|
||||||
|
|
||||||
|
#### **For EVERY Change:**
|
||||||
|
1. **Update version number** in `CHANGELOG.md`
|
||||||
|
2. **Increment PATCH** (v1.0.x → v1.0.1) for:
|
||||||
|
- Bug fixes
|
||||||
|
- Small improvements
|
||||||
|
- Code refactors
|
||||||
|
- Documentation updates
|
||||||
|
- UI/UX tweaks
|
||||||
|
|
||||||
|
3. **Increment MINOR** (v1.x.0 → v1.1.0) for:
|
||||||
|
- New features
|
||||||
|
- New API endpoints
|
||||||
|
- New components
|
||||||
|
- New services
|
||||||
|
- Significant enhancements
|
||||||
|
|
||||||
|
4. **Increment MAJOR** (vx.0.0 → v2.0.0) for:
|
||||||
|
- Breaking API changes
|
||||||
|
- Database schema breaking changes
|
||||||
|
- Architecture overhauls
|
||||||
|
- Major refactors affecting multiple modules
|
||||||
|
|
||||||
|
#### **Changelog Entry Format:**
|
||||||
|
```markdown
|
||||||
|
## v1.2.5 - December 12, 2025
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- User logout issue when switching accounts
|
||||||
|
- Payment confirmation modal amount display
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated session storage from database to Redis
|
||||||
|
- Enhanced credit balance widget UI
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Plan limits enforcement system
|
||||||
|
- Monthly reset task for usage tracking
|
||||||
|
```
|
||||||
|
|
||||||
|
### **For Major Refactors:**
|
||||||
|
1. **Create detailed TODO list** before starting
|
||||||
|
2. **Document current state** in CHANGELOG
|
||||||
|
3. **Create implementation checklist** (markdown file in root or docs/)
|
||||||
|
4. **Track progress** with checklist updates
|
||||||
|
5. **Test thoroughly** before committing
|
||||||
|
6. **Update CHANGELOG** with all changes made
|
||||||
|
7. **Update version** to next MINOR or MAJOR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Code Organization Standards
|
||||||
|
|
||||||
|
### Backend Structure
|
||||||
|
```
|
||||||
|
backend/igny8_core/
|
||||||
|
├── auth/ # Authentication, users, accounts, plans
|
||||||
|
├── business/ # Business logic services
|
||||||
|
│ ├── automation/ # Automation pipeline
|
||||||
|
│ ├── billing/ # Billing, credits, invoices
|
||||||
|
│ ├── content/ # Content generation
|
||||||
|
│ ├── integration/ # External integrations
|
||||||
|
│ ├── linking/ # Internal linking
|
||||||
|
│ ├── optimization/ # Content optimization
|
||||||
|
│ ├── planning/ # Keywords, clusters, ideas
|
||||||
|
│ └── publishing/ # WordPress publishing
|
||||||
|
├── ai/ # AI framework (NEW - use this)
|
||||||
|
├── utils/ # Utility functions
|
||||||
|
├── tasks/ # Celery tasks
|
||||||
|
└── modules/ # Legacy modules (being phased out)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Structure
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── components/ # Reusable components
|
||||||
|
├── pages/ # Page components
|
||||||
|
├── store/ # Zustand state stores
|
||||||
|
├── services/ # API service layer
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
├── utils/ # Utility functions
|
||||||
|
├── types/ # TypeScript types
|
||||||
|
└── marketing/ # Marketing site
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Development Workflow
|
||||||
|
|
||||||
|
### 1. **Planning Phase**
|
||||||
|
- [ ] Read relevant documentation
|
||||||
|
- [ ] Understand existing patterns
|
||||||
|
- [ ] Create TODO list for complex changes
|
||||||
|
- [ ] Identify affected components/modules
|
||||||
|
- [ ] Plan database changes (if any)
|
||||||
|
|
||||||
|
### 2. **Implementation Phase**
|
||||||
|
- [ ] Follow existing code patterns
|
||||||
|
- [ ] Use proper base classes and mixins
|
||||||
|
- [ ] Add proper error handling
|
||||||
|
- [ ] Validate input data
|
||||||
|
- [ ] Check permissions and scope
|
||||||
|
- [ ] Write clean, documented code
|
||||||
|
- [ ] Use type hints (Python) and TypeScript types
|
||||||
|
|
||||||
|
### 3. **Testing Phase**
|
||||||
|
- [ ] Test locally with development data
|
||||||
|
- [ ] Test multi-tenancy isolation
|
||||||
|
- [ ] Test permissions and access control
|
||||||
|
- [ ] Test error cases
|
||||||
|
- [ ] Verify no breaking changes
|
||||||
|
- [ ] Check frontend-backend integration
|
||||||
|
|
||||||
|
### 4. **Documentation Phase**
|
||||||
|
- [ ] Update CHANGELOG.md
|
||||||
|
- [ ] Update version number
|
||||||
|
- [ ] Update relevant docs (if architecture/API changes)
|
||||||
|
- [ ] Add code comments for complex logic
|
||||||
|
- [ ] Update API documentation (if endpoints changed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Specific Development Rules
|
||||||
|
|
||||||
|
### Backend Development
|
||||||
|
|
||||||
|
#### **Models:**
|
||||||
|
```python
|
||||||
|
# ALWAYS inherit from proper base classes
|
||||||
|
from igny8_core.auth.models import SiteSectorBaseModel
|
||||||
|
|
||||||
|
class MyModel(SoftDeletableModel, SiteSectorBaseModel):
|
||||||
|
# Your fields here
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Services:**
|
||||||
|
```python
|
||||||
|
# Follow service pattern
|
||||||
|
class MyService:
|
||||||
|
def __init__(self):
|
||||||
|
self.credit_service = CreditService()
|
||||||
|
self.limit_service = LimitService()
|
||||||
|
|
||||||
|
def my_operation(self, account, site, **kwargs):
|
||||||
|
# 1. Validate permissions
|
||||||
|
# 2. Check limits/credits
|
||||||
|
# 3. Perform operation
|
||||||
|
# 4. Track usage
|
||||||
|
# 5. Return result
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **API Views:**
|
||||||
|
```python
|
||||||
|
# Use proper permission classes
|
||||||
|
class MyViewSet(viewsets.ModelViewSet):
|
||||||
|
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# ALWAYS scope by account
|
||||||
|
return MyModel.objects.filter(
|
||||||
|
site__account=self.request.user.account
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Migrations:**
|
||||||
|
- Run `python manage.py makemigrations` after model changes
|
||||||
|
- Test migrations: `python manage.py migrate --plan`
|
||||||
|
- Never edit existing migrations
|
||||||
|
- Use data migrations for complex data changes
|
||||||
|
|
||||||
|
### Frontend Development
|
||||||
|
|
||||||
|
#### **Components:**
|
||||||
|
```typescript
|
||||||
|
// Use existing component library
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import Button from '@/components/ui/button/Button';
|
||||||
|
|
||||||
|
// Follow naming conventions
|
||||||
|
export default function MyComponent() {
|
||||||
|
// Component logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **State Management:**
|
||||||
|
```typescript
|
||||||
|
// Use Zustand stores
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
|
const { user, account } = useAuthStore();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **API Calls:**
|
||||||
|
```typescript
|
||||||
|
// Use fetchAPI from services/api.ts
|
||||||
|
import { fetchAPI } from '@/services/api';
|
||||||
|
|
||||||
|
const data = await fetchAPI('/v1/my-endpoint/');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Styling:**
|
||||||
|
```typescript
|
||||||
|
// Use TailwindCSS classes
|
||||||
|
<div className="p-6 bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
My Heading
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚫 Common Pitfalls to Avoid
|
||||||
|
|
||||||
|
### **DON'T:**
|
||||||
|
- ❌ Skip account scoping in queries
|
||||||
|
- ❌ Use legacy AI processor (`utils/ai_processor.py`) - use `ai/` framework
|
||||||
|
- ❌ Hardcode values - use settings or constants
|
||||||
|
- ❌ Forget error handling
|
||||||
|
- ❌ Skip permission checks
|
||||||
|
- ❌ Create duplicate components - reuse existing
|
||||||
|
- ❌ Use inline styles - use TailwindCSS
|
||||||
|
- ❌ Forget to update CHANGELOG
|
||||||
|
- ❌ Use workarounds - fix the root cause
|
||||||
|
- ❌ Skip migrations after model changes
|
||||||
|
|
||||||
|
### **DO:**
|
||||||
|
- ✅ Read documentation before coding
|
||||||
|
- ✅ Follow existing patterns
|
||||||
|
- ✅ Use proper base classes
|
||||||
|
- ✅ Check permissions and limits
|
||||||
|
- ✅ Handle errors gracefully
|
||||||
|
- ✅ Return valid errors, not fallbacks
|
||||||
|
- ✅ Update CHANGELOG for every change
|
||||||
|
- ✅ Test multi-tenancy isolation
|
||||||
|
- ✅ Use TypeScript types
|
||||||
|
- ✅ Write clean, documented code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Code Review Checklist
|
||||||
|
|
||||||
|
Before committing code, verify:
|
||||||
|
- [ ] Follows existing code patterns
|
||||||
|
- [ ] Properly scoped by account/site
|
||||||
|
- [ ] Permissions checked
|
||||||
|
- [ ] Error handling implemented
|
||||||
|
- [ ] No breaking changes
|
||||||
|
- [ ] CHANGELOG.md updated
|
||||||
|
- [ ] Version number incremented
|
||||||
|
- [ ] Documentation updated (if needed)
|
||||||
|
- [ ] Tested locally
|
||||||
|
- [ ] No console errors or warnings
|
||||||
|
- [ ] TypeScript types added/updated
|
||||||
|
- [ ] Migrations created (if model changes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Key Architecture Concepts
|
||||||
|
|
||||||
|
### **Credit System:**
|
||||||
|
- All AI operations cost credits
|
||||||
|
- Check credits before operation: `CreditService.check_credits()`
|
||||||
|
- Deduct after operation: `CreditService.deduct_credits()`
|
||||||
|
- Track in `CreditUsageLog` table
|
||||||
|
|
||||||
|
### **Limit System:**
|
||||||
|
- Hard limits: Persistent (sites, users, keywords, clusters)
|
||||||
|
- Monthly limits: Reset on billing cycle (ideas, words, images)
|
||||||
|
- Track in `PlanLimitUsage` table
|
||||||
|
- Check before operation: `LimitService.check_limit()`
|
||||||
|
|
||||||
|
### **AI Framework:**
|
||||||
|
- Use `ai/engine.py` for AI operations
|
||||||
|
- Use `ai/functions/` for specific AI tasks
|
||||||
|
- Use `ai/models.py` for tracking
|
||||||
|
- Don't use legacy `utils/ai_processor.py`
|
||||||
|
|
||||||
|
### **Multi-Tenancy:**
|
||||||
|
- Every request has `request.user.account`
|
||||||
|
- All models scope by account directly or via site
|
||||||
|
- Use `AccountBaseModel` or `SiteSectorBaseModel`
|
||||||
|
- Validate ownership before mutations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Design System
|
||||||
|
|
||||||
|
### **Colors:**
|
||||||
|
- Primary: Blue (#0693e3)
|
||||||
|
- Success: Green (#0bbf87)
|
||||||
|
- Error: Red (#ef4444)
|
||||||
|
- Warning: Yellow (#f59e0b)
|
||||||
|
- Info: Blue (#3b82f6)
|
||||||
|
|
||||||
|
### **Typography:**
|
||||||
|
- Headings: font-bold
|
||||||
|
- Body: font-normal
|
||||||
|
- Small text: text-sm
|
||||||
|
- Large text: text-lg, text-xl, text-2xl
|
||||||
|
|
||||||
|
### **Spacing:**
|
||||||
|
- Padding: p-4, p-6 (standard)
|
||||||
|
- Margin: mt-4, mb-6 (standard)
|
||||||
|
- Gap: gap-4, gap-6 (standard)
|
||||||
|
|
||||||
|
### **Components:**
|
||||||
|
- Card: `<Card>` with padding and shadow
|
||||||
|
- Button: `<Button>` with variants (primary, secondary, danger)
|
||||||
|
- Input: `<Input>` with proper validation
|
||||||
|
- Badge: `<Badge>` with color variants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support & Questions
|
||||||
|
|
||||||
|
- Architecture questions → Check `ARCHITECTURE-KNOWLEDGE-BASE.md`
|
||||||
|
- Feature questions → Check `IGNY8-COMPLETE-FEATURES-GUIDE.md`
|
||||||
|
- API questions → Check `docs/20-API/`
|
||||||
|
- Recent changes → Check `CHANGELOG.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Remember:** Quality over speed. Take time to understand existing patterns before implementing new features.
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -45,6 +45,11 @@ backend/.venv/
|
|||||||
dist/
|
dist/
|
||||||
*.egg
|
*.egg
|
||||||
|
|
||||||
|
# Celery scheduler database (binary file, regenerated by celery beat)
|
||||||
|
celerybeat-schedule
|
||||||
|
**/celerybeat-schedule
|
||||||
|
backend/celerybeat-schedule
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
552
ARCHITECTURE-KNOWLEDGE-BASE.md
Normal file
552
ARCHITECTURE-KNOWLEDGE-BASE.md
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
# Architecture Knowledge Base
|
||||||
|
**Last Updated:** December 14, 2025
|
||||||
|
**Purpose:** Critical architectural patterns, common issues, and solutions reference
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔥 CRITICAL FIXES - December 2025
|
||||||
|
|
||||||
|
### PERMANENT FIX: Django Admin Custom Sidebar Not Showing on Subpages
|
||||||
|
**ROOT CAUSE**: Django's `ModelAdmin` view methods (`changelist_view`, `change_view`, etc.) do not call `AdminSite.each_context()`, so custom sidebar logic defined in `site.py` was bypassed on model list/detail/edit pages.
|
||||||
|
|
||||||
|
**SOLUTION IMPLEMENTED**:
|
||||||
|
1. ✅ Created `Igny8ModelAdmin` base class extending `UnfoldModelAdmin`
|
||||||
|
2. ✅ Overrides all view methods to inject `extra_context` with custom sidebar
|
||||||
|
3. ✅ Applied to 46+ admin classes across all modules
|
||||||
|
4. ✅ Sidebar now consistent on homepage, app index, and ALL model pages
|
||||||
|
|
||||||
|
**Files Modified**: `backend/igny8_core/admin/base.py`, all `*/admin.py` files
|
||||||
|
|
||||||
|
### PERMANENT FIX: User Swapping / Random Logout Issue
|
||||||
|
**ROOT CAUSE**: Django's database-backed sessions with in-memory user caching caused cross-request contamination at the process level.
|
||||||
|
|
||||||
|
**SOLUTION IMPLEMENTED**:
|
||||||
|
1. ✅ Redis-backed sessions (`SESSION_ENGINE = 'django.contrib.sessions.backends.cache'`)
|
||||||
|
2. ✅ Custom authentication backend without caching (`NoCacheModelBackend`)
|
||||||
|
3. ✅ Session integrity validation (stores and verifies account_id/user_id on every request)
|
||||||
|
4. ✅ Middleware never mutates `request.user` (uses Django's set value directly)
|
||||||
|
|
||||||
|
**See**: `CRITICAL-BUG-FIXES-DEC-2025.md` for complete details.
|
||||||
|
|
||||||
|
### PERMANENT FIX: useNavigate / useLocation Errors During HMR
|
||||||
|
**ROOT CAUSE**: Individual Suspense boundaries per route lost React Router context during Hot Module Replacement.
|
||||||
|
|
||||||
|
**SOLUTION IMPLEMENTED**:
|
||||||
|
1. ✅ Single top-level Suspense boundary around entire `<Routes>` component
|
||||||
|
2. ✅ Removed 100+ individual Suspense wrappers from route elements
|
||||||
|
3. ✅ Router context now persists through HMR automatically
|
||||||
|
|
||||||
|
**See**: `CRITICAL-BUG-FIXES-DEC-2025.md` for complete details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
1. [Authentication & Session Management](#authentication--session-management)
|
||||||
|
2. [Site/Sector Architecture](#sitesector-architecture)
|
||||||
|
3. [State Management & Race Conditions](#state-management--race-conditions)
|
||||||
|
4. [Permission System](#permission-system)
|
||||||
|
5. [Frontend Component Dependencies](#frontend-component-dependencies)
|
||||||
|
6. [Common Pitfalls & Solutions](#common-pitfalls--solutions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication & Session Management
|
||||||
|
|
||||||
|
### Token Persistence Architecture
|
||||||
|
|
||||||
|
**Problem Pattern:**
|
||||||
|
- Zustand persist middleware writes to localStorage asynchronously
|
||||||
|
- API calls can happen before tokens are persisted
|
||||||
|
- Results in 403 "Authentication credentials were not provided" errors
|
||||||
|
|
||||||
|
**Solution Implemented:**
|
||||||
|
```typescript
|
||||||
|
// In authStore.ts login/register functions
|
||||||
|
// CRITICAL: Immediately persist tokens synchronously after setting state
|
||||||
|
const authState = {
|
||||||
|
state: { user, token, refreshToken, isAuthenticated: true },
|
||||||
|
version: 0
|
||||||
|
};
|
||||||
|
localStorage.setItem('auth-storage', JSON.stringify(authState));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principle:** Always write tokens to localStorage synchronously in auth actions, don't rely solely on persist middleware.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Logout & State Cleanup
|
||||||
|
|
||||||
|
**WRONG APPROACH (causes race conditions):**
|
||||||
|
```typescript
|
||||||
|
logout: () => {
|
||||||
|
localStorage.clear(); // ❌ BREAKS EVERYTHING
|
||||||
|
set({ user: null, token: null });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CORRECT APPROACH:**
|
||||||
|
```typescript
|
||||||
|
logout: () => {
|
||||||
|
// ✅ Selective removal - only auth-related keys
|
||||||
|
const authKeys = ['auth-storage', 'site-storage', 'sector-storage', 'billing-storage'];
|
||||||
|
authKeys.forEach(key => localStorage.removeItem(key));
|
||||||
|
|
||||||
|
// ✅ Reset dependent stores explicitly
|
||||||
|
useSiteStore.setState({ activeSite: null });
|
||||||
|
useSectorStore.setState({ activeSector: null, sectors: [] });
|
||||||
|
|
||||||
|
set({ user: null, token: null, isAuthenticated: false });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principle:** Never use `localStorage.clear()` - it breaks Zustand persist middleware initialization. Always selectively remove keys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 403 Error Handling
|
||||||
|
|
||||||
|
**Problem Pattern:**
|
||||||
|
- 403 errors thrown before checking if it's an auth error
|
||||||
|
- Token validation code becomes unreachable
|
||||||
|
- Invalid tokens persist in localStorage
|
||||||
|
|
||||||
|
**WRONG ORDER:**
|
||||||
|
```typescript
|
||||||
|
// In api.ts
|
||||||
|
if (response.status === 403) {
|
||||||
|
throw new Error(response.statusText); // ❌ Thrown immediately
|
||||||
|
}
|
||||||
|
|
||||||
|
// This code NEVER runs (unreachable):
|
||||||
|
if (errorData?.detail?.includes('Authentication credentials')) {
|
||||||
|
logout(); // Never called!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CORRECT ORDER:**
|
||||||
|
```typescript
|
||||||
|
// Check for auth errors FIRST, then throw
|
||||||
|
if (response.status === 403) {
|
||||||
|
const errorData = JSON.parse(text);
|
||||||
|
|
||||||
|
// ✅ Check authentication BEFORE throwing
|
||||||
|
if (errorData?.detail?.includes('Authentication credentials')) {
|
||||||
|
const authState = useAuthStore.getState();
|
||||||
|
if (authState?.isAuthenticated) {
|
||||||
|
authState.logout();
|
||||||
|
window.location.href = '/signin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now throw the error
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principle:** Handle authentication errors before throwing. Order matters in error handling logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Site/Sector Architecture
|
||||||
|
|
||||||
|
### Data Hierarchy
|
||||||
|
```
|
||||||
|
Account (Tenant)
|
||||||
|
└── Site (e.g., myblog.com)
|
||||||
|
└── Sector (e.g., Technology, Health)
|
||||||
|
└── Keywords
|
||||||
|
└── Clusters
|
||||||
|
└── Ideas
|
||||||
|
└── Content
|
||||||
|
```
|
||||||
|
|
||||||
|
### Where Sectors Are Used (Global Context)
|
||||||
|
|
||||||
|
**USES SECTORS (requires site/sector selection):**
|
||||||
|
- ✅ Planner Module (Keywords, Clusters, Ideas)
|
||||||
|
- ✅ Writer Module (Tasks, Content, Drafts, Published)
|
||||||
|
- ✅ Linker Module (Internal linking)
|
||||||
|
- ✅ Optimizer Module (Content optimization)
|
||||||
|
- ✅ Setup/Add Keywords page
|
||||||
|
- ✅ Seed Keywords reference data
|
||||||
|
|
||||||
|
**DOES NOT USE SECTORS (account-level only):**
|
||||||
|
- ❌ Billing/Plans pages (`/account/*`)
|
||||||
|
- ❌ Account Settings
|
||||||
|
- ❌ Team Management
|
||||||
|
- ❌ User Profile
|
||||||
|
- ❌ Admin Dashboard
|
||||||
|
- ❌ System Settings
|
||||||
|
|
||||||
|
### Sector Loading Pattern
|
||||||
|
|
||||||
|
**Architecture Decision:**
|
||||||
|
- Sectors loaded by **PageHeader component** (not AppLayout)
|
||||||
|
- Only loads when `hideSiteSector={false}` prop is set
|
||||||
|
- Account/billing pages pass `hideSiteSector={true}` to skip loading
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```typescript
|
||||||
|
// PageHeader.tsx
|
||||||
|
useEffect(() => {
|
||||||
|
if (hideSiteSector) return; // Skip for account pages
|
||||||
|
|
||||||
|
const currentSiteId = activeSite?.id ?? null;
|
||||||
|
if (currentSiteId && activeSite?.is_active) {
|
||||||
|
loadSectorsForSite(currentSiteId);
|
||||||
|
}
|
||||||
|
}, [activeSite?.id, hideSiteSector]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principle:** Lazy-load sectors only when components need them. Don't load globally for all pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Site/Sector Store Persistence
|
||||||
|
|
||||||
|
**Storage Keys:**
|
||||||
|
- `site-storage` - Active site selection
|
||||||
|
- `sector-storage` - Active sector selection
|
||||||
|
|
||||||
|
**Reset Pattern:**
|
||||||
|
```typescript
|
||||||
|
// When site changes, reset sector if it belongs to different site
|
||||||
|
if (currentSector && currentSector.site_id !== newSiteId) {
|
||||||
|
set({ activeSector: null });
|
||||||
|
localStorage.setItem('sector-storage', JSON.stringify({
|
||||||
|
state: { activeSector: null },
|
||||||
|
version: 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principle:** Sector selection is site-scoped. Always validate sector belongs to active site.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Management & Race Conditions
|
||||||
|
|
||||||
|
### Common Race Condition Patterns
|
||||||
|
|
||||||
|
#### 1. User Switching
|
||||||
|
**Problem:** Rapid logout → login leaves stale state in stores
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```typescript
|
||||||
|
logout: () => {
|
||||||
|
// Reset ALL dependent stores explicitly
|
||||||
|
import('./siteStore').then(({ useSiteStore }) => {
|
||||||
|
useSiteStore.setState({ activeSite: null, loading: false, error: null });
|
||||||
|
});
|
||||||
|
import('./sectorStore').then(({ useSectorStore }) => {
|
||||||
|
useSectorStore.setState({ activeSector: null, sectors: [], loading: false, error: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. API Calls Before Token Persistence
|
||||||
|
**Problem:** API calls happen before Zustand persist writes token
|
||||||
|
|
||||||
|
**Solution:** Synchronous localStorage write immediately after state update (see Authentication section)
|
||||||
|
|
||||||
|
#### 3. Module Loading Failures
|
||||||
|
**Problem:** 404 errors during page navigation cause module loading to fail
|
||||||
|
|
||||||
|
**Solution:** Ensure API endpoints exist before pages try to load them. Use conditional rendering based on route.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Zustand Persist Middleware Gotchas
|
||||||
|
|
||||||
|
**Issue 1: Version Mismatch**
|
||||||
|
```typescript
|
||||||
|
// Stored format
|
||||||
|
{ state: { user, token }, version: 0 }
|
||||||
|
|
||||||
|
// If version changes, persist middleware clears state
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue 2: Async Hydration**
|
||||||
|
- State rehydration from localStorage is async
|
||||||
|
- Can cause brief flash of "no user" state
|
||||||
|
|
||||||
|
**Solution:** Use loading states or check both store AND localStorage:
|
||||||
|
```typescript
|
||||||
|
const getAuthToken = (): string | null => {
|
||||||
|
// Try Zustand store first
|
||||||
|
const authState = useAuthStore.getState();
|
||||||
|
if (authState?.token) return authState.token;
|
||||||
|
|
||||||
|
// Fallback to localStorage
|
||||||
|
const stored = localStorage.getItem('auth-storage');
|
||||||
|
return JSON.parse(stored)?.state?.token || null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permission System
|
||||||
|
|
||||||
|
### Superuser/Developer Bypass Pattern
|
||||||
|
|
||||||
|
**Critical Locations for Bypass:**
|
||||||
|
1. Middleware - `auth/middleware.py`
|
||||||
|
2. Permission Classes - `api/permissions.py`
|
||||||
|
3. ViewSet Querysets - `api/base.py`
|
||||||
|
4. Validation Functions - `auth/utils.py`
|
||||||
|
|
||||||
|
**Standard Bypass Check:**
|
||||||
|
```python
|
||||||
|
def check_bypass(user):
|
||||||
|
return (
|
||||||
|
user.is_superuser or
|
||||||
|
user.role == 'developer' or
|
||||||
|
is_system_account_user(user)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apply at ALL levels:**
|
||||||
|
- Middleware request validation
|
||||||
|
- DRF permission `has_permission()`
|
||||||
|
- ViewSet `get_queryset()` filtering
|
||||||
|
- Custom validation functions
|
||||||
|
|
||||||
|
**Key Principle:** Bypass checks must be consistent across all permission layers. Missing one layer breaks superuser access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### System Account Pattern
|
||||||
|
|
||||||
|
**Reserved Accounts:**
|
||||||
|
- `aws-admin` - System automation account
|
||||||
|
- `default-account` - Default tenant fallback
|
||||||
|
|
||||||
|
**Check Function:**
|
||||||
|
```python
|
||||||
|
def is_system_account_user(user):
|
||||||
|
if not user or not user.account:
|
||||||
|
return False
|
||||||
|
return user.account.slug in ['aws-admin', 'default-account']
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:** Always include in bypass checks alongside superuser/developer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Component Dependencies
|
||||||
|
|
||||||
|
### PageHeader Component
|
||||||
|
**Dependencies:**
|
||||||
|
- `useSiteStore` - Active site
|
||||||
|
- `useSectorStore` - Active sector
|
||||||
|
- `SiteAndSectorSelector` - Dropdown component
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `hideSiteSector: boolean` - Skip site/sector display and loading
|
||||||
|
- `title: string` - Page title
|
||||||
|
- `navigation: ReactNode` - Optional module tabs
|
||||||
|
|
||||||
|
**Used By:**
|
||||||
|
- All Planner pages
|
||||||
|
- All Writer pages
|
||||||
|
- All Optimizer pages
|
||||||
|
- Setup pages
|
||||||
|
- Seed Keywords page
|
||||||
|
|
||||||
|
**NOT Used By:**
|
||||||
|
- Account/billing pages (use plain headers instead)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Module Navigation Pattern
|
||||||
|
|
||||||
|
**Component:** `ModuleNavigationTabs.tsx`
|
||||||
|
|
||||||
|
**CRITICAL:** Must be wrapped in `<Router>` context
|
||||||
|
- Uses `useLocation()` and `useNavigate()` hooks
|
||||||
|
- Cannot be used outside `<Routes>` tree
|
||||||
|
|
||||||
|
**Common Error:**
|
||||||
|
```
|
||||||
|
Error: useLocation() may be used only in the context of a <Router> component
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** Component rendered outside React Router context
|
||||||
|
|
||||||
|
**Solution:** Ensure component is within `<Route>` element in App.tsx
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls & Solutions
|
||||||
|
|
||||||
|
### Pitfall 1: Frontend 403 Errors After User Switch
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- "Authentication credentials were not provided"
|
||||||
|
- User appears logged in but API calls fail
|
||||||
|
- Manually clearing cache fixes it
|
||||||
|
|
||||||
|
**Root Cause:** Invalid tokens persisting in localStorage after logout
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check 403 handler runs BEFORE throwing error
|
||||||
|
2. Ensure logout clears specific auth keys (not `localStorage.clear()`)
|
||||||
|
3. Add immediate token persistence after login
|
||||||
|
|
||||||
|
**Prevention:** See "Authentication & Session Management" section
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 2: Sector 404 Errors on Billing Pages
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- `GET /v1/auth/sites/{id}/sectors/` returns 404
|
||||||
|
- "Failed to fetch dynamically imported module" error
|
||||||
|
- Billing pages don't load
|
||||||
|
|
||||||
|
**Root Cause:** AppLayout loading sectors for ALL pages globally
|
||||||
|
|
||||||
|
**Solution:** Move sector loading to PageHeader component (lazy loading)
|
||||||
|
|
||||||
|
**Prevention:** Only load data when components that need it are mounted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 3: Module Loading Failures After Git Commits
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- React Router context errors
|
||||||
|
- "useLocation() may be used only in context of <Router>" errors
|
||||||
|
- Pages work after rebuild but fail after git push
|
||||||
|
|
||||||
|
**Root Cause:** Docker build cache not invalidated properly
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Force clean rebuild
|
||||||
|
docker compose -f docker-compose.app.yml down
|
||||||
|
docker compose -f docker-compose.app.yml build --no-cache igny8_frontend
|
||||||
|
docker compose -f docker-compose.app.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prevention:** Use `--no-cache` flag when rebuilding after major changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 4: Plan Selection Issues in Pricing Page
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- Monthly/Annual toggle missing
|
||||||
|
- Pre-selected plan not highlighted
|
||||||
|
- Discount calculation wrong
|
||||||
|
|
||||||
|
**Root Cause:**
|
||||||
|
1. PricingTable component missing `showToggle` prop
|
||||||
|
2. Backend missing `is_featured` and `annual_discount_percent` fields
|
||||||
|
3. Frontend not calculating annual price from discount
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Add fields to Plan model with migration
|
||||||
|
2. Pass `annualDiscountPercent` to PricingTable
|
||||||
|
3. Calculate: `annualPrice = monthlyPrice * 12 * (1 - discount/100)`
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `backend/igny8_core/auth/models.py`
|
||||||
|
- `backend/igny8_core/auth/serializers.py`
|
||||||
|
- `frontend/src/services/billing.api.ts`
|
||||||
|
- `frontend/src/components/ui/pricing-table/PricingTable.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pitfall 5: Adjacent JSX Elements Error
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- "Adjacent JSX elements must be wrapped in an enclosing tag"
|
||||||
|
- Build fails but line numbers don't help
|
||||||
|
|
||||||
|
**Root Cause:** Mismatched opening/closing tags (usually missing `</div>`)
|
||||||
|
|
||||||
|
**Debugging Strategy:**
|
||||||
|
1. Use TypeScript compiler: `npx tsc --noEmit <file>`
|
||||||
|
2. Count opening vs closing tags: `grep -c "<div" vs grep -c "</div>"`
|
||||||
|
3. Check conditionals have matching closing parens/braces
|
||||||
|
|
||||||
|
**Common Pattern:**
|
||||||
|
```tsx
|
||||||
|
{condition && (
|
||||||
|
<div>
|
||||||
|
{/* Content */}
|
||||||
|
</div>
|
||||||
|
{/* Missing closing parenthesis causes "adjacent elements" error */}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:** Ensure every opening bracket has matching close bracket
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices Summary
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
✅ **DO:** Immediately persist auth tokens synchronously
|
||||||
|
✅ **DO:** Selectively remove localStorage keys
|
||||||
|
✅ **DO:** Reset dependent stores on logout
|
||||||
|
❌ **DON'T:** Use `localStorage.clear()`
|
||||||
|
❌ **DON'T:** Rely solely on Zustand persist middleware timing
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
✅ **DO:** Check authentication errors BEFORE throwing
|
||||||
|
✅ **DO:** Force logout on invalid tokens
|
||||||
|
✅ **DO:** Redirect to login after logout
|
||||||
|
❌ **DON'T:** Throw errors before checking auth status
|
||||||
|
❌ **DON'T:** Leave invalid tokens in storage
|
||||||
|
|
||||||
|
### Component Architecture
|
||||||
|
✅ **DO:** Lazy-load data at component level
|
||||||
|
✅ **DO:** Skip unnecessary data loading (hideSiteSector pattern)
|
||||||
|
✅ **DO:** Keep components in Router context
|
||||||
|
❌ **DON'T:** Load data globally in AppLayout
|
||||||
|
❌ **DON'T:** Use Router hooks outside Router context
|
||||||
|
|
||||||
|
### Permission System
|
||||||
|
✅ **DO:** Implement bypass at ALL permission layers
|
||||||
|
✅ **DO:** Include system accounts in bypass checks
|
||||||
|
✅ **DO:** Use consistent bypass logic everywhere
|
||||||
|
❌ **DON'T:** Forget middleware layer bypass
|
||||||
|
❌ **DON'T:** Mix permission approaches
|
||||||
|
|
||||||
|
### Docker Builds
|
||||||
|
✅ **DO:** Use `--no-cache` after major changes
|
||||||
|
✅ **DO:** Restart containers after rebuilds
|
||||||
|
✅ **DO:** Check logs for module loading errors
|
||||||
|
❌ **DON'T:** Trust build cache after git commits
|
||||||
|
❌ **DON'T:** Deploy without testing fresh build
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference: File Locations
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- Token handling: `frontend/src/services/api.ts`
|
||||||
|
- Auth store: `frontend/src/store/authStore.ts`
|
||||||
|
- Middleware: `backend/igny8_core/auth/middleware.py`
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
- Permission classes: `backend/igny8_core/api/permissions.py`
|
||||||
|
- Base viewsets: `backend/igny8_core/api/base.py`
|
||||||
|
- Validation utils: `backend/igny8_core/auth/utils.py`
|
||||||
|
|
||||||
|
### Site/Sector
|
||||||
|
- Site store: `frontend/src/store/siteStore.ts`
|
||||||
|
- Sector store: `frontend/src/store/sectorStore.ts`
|
||||||
|
- PageHeader: `frontend/src/components/common/PageHeader.tsx`
|
||||||
|
|
||||||
|
### Billing
|
||||||
|
- Billing API: `frontend/src/services/billing.api.ts`
|
||||||
|
- Plans page: `frontend/src/pages/account/PlansAndBillingPage.tsx`
|
||||||
|
- Plan model: `backend/igny8_core/auth/models.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of Knowledge Base**
|
||||||
|
*Update this document when architectural patterns change or new common issues are discovered.*
|
||||||
1130
CHANGELOG.md
1130
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
174
CONTAINER-RESTART-DEBUGGING.md
Normal file
174
CONTAINER-RESTART-DEBUGGING.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Container Restart and Auto-Logout Debugging Setup
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Added comprehensive logging to track container restarts and automatic user logouts.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Container Lifecycle Logging
|
||||||
|
|
||||||
|
#### Backend Container (`backend/container_startup.sh`)
|
||||||
|
- Logs container startup time, hostname, and PID
|
||||||
|
- Detects and logs container restarts (by checking for previous PID file)
|
||||||
|
- Logs environment configuration (Python version, Django settings, DB host)
|
||||||
|
- Warns when restarts are detected and suggests checking Docker logs for SIGTERM signals
|
||||||
|
- Integrated into Dockerfile as ENTRYPOINT
|
||||||
|
|
||||||
|
#### Frontend Container (`frontend/container_startup.sh`)
|
||||||
|
- Logs container startup time, hostname, and PID
|
||||||
|
- Detects and logs container restarts
|
||||||
|
- Logs Node/NPM versions and Vite configuration
|
||||||
|
- Checks for git directory presence and warns about file watching
|
||||||
|
- Shows last git commit when detected
|
||||||
|
- Integrated into Dockerfile.dev as ENTRYPOINT
|
||||||
|
|
||||||
|
### 2. Vite File Watching Fix (`frontend/vite.config.ts`)
|
||||||
|
|
||||||
|
**ROOT CAUSE IDENTIFIED:** `usePolling: true` was watching ALL files including `.git` directory, causing container restarts on git commits.
|
||||||
|
|
||||||
|
**Fix Applied:**
|
||||||
|
```typescript
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
ignored: [
|
||||||
|
'**/node_modules/**',
|
||||||
|
'**/.git/**', // CRITICAL: Ignore git directory
|
||||||
|
'**/dist/**',
|
||||||
|
'**/build/**',
|
||||||
|
'**/.vscode/**',
|
||||||
|
'**/.idea/**',
|
||||||
|
],
|
||||||
|
interval: 1000, // Poll every 1 second instead of default
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Auto-Logout Logging (`backend/igny8_core/auth/middleware.py`)
|
||||||
|
|
||||||
|
Added detailed logging for all automatic logout scenarios:
|
||||||
|
|
||||||
|
#### Session Contamination - Account ID Mismatch
|
||||||
|
```python
|
||||||
|
logger.warning(
|
||||||
|
f"[AUTO-LOGOUT] Session contamination: account_id mismatch. "
|
||||||
|
f"Session={stored_account_id}, Current={request.account.id}, "
|
||||||
|
f"User={request.user.id}, Path={request.path}, IP={request.META.get('REMOTE_ADDR')}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Session Contamination - User ID Mismatch
|
||||||
|
```python
|
||||||
|
logger.warning(
|
||||||
|
f"[AUTO-LOGOUT] Session contamination: user_id mismatch. "
|
||||||
|
f"Session={stored_user_id}, Current={request.user.id}, "
|
||||||
|
f"Account={request.account.id if request.account else None}, "
|
||||||
|
f"Path={request.path}, IP={request.META.get('REMOTE_ADDR')}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Account/Plan Validation Failures
|
||||||
|
```python
|
||||||
|
logger.warning(
|
||||||
|
f"[AUTO-LOGOUT] Account/plan validation failed: {error}. "
|
||||||
|
f"User={request.user.id}, Account={getattr(request, 'account', None)}, "
|
||||||
|
f"Path={request.path}, IP={request.META.get('REMOTE_ADDR')}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Logging Configuration (`backend/igny8_core/settings.py`)
|
||||||
|
|
||||||
|
Added two new loggers:
|
||||||
|
- `auth.middleware` - Captures all authentication and auto-logout events
|
||||||
|
- `container.lifecycle` - Captures container startup/restart events
|
||||||
|
|
||||||
|
Both log to console (captured by Docker logs).
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
### Viewing Container Restart Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check backend container logs for restart events
|
||||||
|
docker logs igny8_backend 2>&1 | grep "CONTAINER-STARTUP\|RESTART"
|
||||||
|
|
||||||
|
# Check frontend container logs for restart events
|
||||||
|
docker logs igny8_frontend 2>&1 | grep "CONTAINER-STARTUP\|RESTART"
|
||||||
|
|
||||||
|
# Monitor in real-time
|
||||||
|
docker logs -f igny8_backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Viewing Auto-Logout Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for automatic logout events
|
||||||
|
docker logs igny8_backend 2>&1 | grep "AUTO-LOGOUT"
|
||||||
|
|
||||||
|
# Filter by specific logout reason
|
||||||
|
docker logs igny8_backend 2>&1 | grep "AUTO-LOGOUT.*contamination"
|
||||||
|
docker logs igny8_backend 2>&1 | grep "AUTO-LOGOUT.*validation failed"
|
||||||
|
|
||||||
|
# See recent logout events with context
|
||||||
|
docker logs --since 1h igny8_backend 2>&1 | grep -A2 -B2 "AUTO-LOGOUT"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Correlating Events
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# See both container restarts and logouts together
|
||||||
|
docker logs --since 2h igny8_backend 2>&1 | grep -E "CONTAINER-STARTUP|AUTO-LOGOUT|SIGTERM"
|
||||||
|
|
||||||
|
# Check if git commits correlate with restarts
|
||||||
|
git log --since="2 hours ago" --format="%ai %s" && \
|
||||||
|
docker logs --since 2h igny8_frontend 2>&1 | grep "CONTAINER-STARTUP"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps to Deploy
|
||||||
|
|
||||||
|
1. **Rebuild Docker images:**
|
||||||
|
```bash
|
||||||
|
cd /data/app/igny8/backend
|
||||||
|
docker build -t igny8-backend:latest -f Dockerfile .
|
||||||
|
|
||||||
|
cd /data/app/igny8/frontend
|
||||||
|
docker build -t igny8-frontend-dev:latest -f Dockerfile.dev .
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Restart containers:**
|
||||||
|
```bash
|
||||||
|
cd /data/app/igny8
|
||||||
|
docker compose -f docker-compose.app.yml down
|
||||||
|
docker compose -f docker-compose.app.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verify logging is working:**
|
||||||
|
```bash
|
||||||
|
docker logs igny8_backend 2>&1 | head -30
|
||||||
|
docker logs igny8_frontend 2>&1 | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Test git commit trigger (should NOT restart now):**
|
||||||
|
```bash
|
||||||
|
cd /data/app/igny8
|
||||||
|
echo "test" >> README.md
|
||||||
|
git add README.md
|
||||||
|
git commit -m "test commit"
|
||||||
|
|
||||||
|
# Wait 5 seconds and check - containers should NOT restart
|
||||||
|
sleep 5
|
||||||
|
docker ps --filter "name=igny8" --format "{{.Names}}: {{.Status}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected Outcomes
|
||||||
|
|
||||||
|
1. **Git commits should NO LONGER trigger container restarts** because `.git` is now ignored by Vite's file watcher
|
||||||
|
2. **Every container restart will be logged** with timestamp and reason
|
||||||
|
3. **Every automatic logout will be logged** with user ID, account ID, reason, path, and IP address
|
||||||
|
4. **You can correlate restarts with git operations** to verify the fix is working
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If containers still restart after git commits:
|
||||||
|
1. Check if the new images were built and deployed
|
||||||
|
2. Verify the vite.config.ts changes are present in the running container
|
||||||
|
3. Check Docker logs to see what triggered the restart
|
||||||
|
4. Look for HMR messages in frontend logs mentioning `.git` files
|
||||||
1771
IGNY8-COMPLETE-FEATURES-GUIDE.md
Normal file
1771
IGNY8-COMPLETE-FEATURES-GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
11
PENDING-ISSUES.md
Normal file
11
PENDING-ISSUES.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
## 🔴 AI FUunctions progress modals texts and counts to be fixed
|
||||||
|
|
||||||
|
## 🔴 AUTOAMTION queue when run manualy completed count to be fixed, and progress abr to be imrpoved and fixed based on actual stage and all other data have bugs
|
||||||
|
|
||||||
|
## 🔴 Align prompts with teh strategy
|
||||||
|
|
||||||
|
## 🔴 user randomly logs out often
|
||||||
|
|
||||||
|
## 🔴 MArketing site cotnetn
|
||||||
|
|
||||||
|
## 🔴 docuementation adn help update
|
||||||
620
README.md
620
README.md
@@ -1,385 +1,365 @@
|
|||||||
# 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.
|
**Version:** 1.0.0
|
||||||
|
**License:** Proprietary
|
||||||
|
**Website:** https://igny8.com
|
||||||
|
|
||||||
**Last Updated:** 2025-01-XX
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## What is IGNY8?
|
||||||
|
|
||||||
- **Backend**: Django 5.2+ with Django REST Framework (Port 8010/8011)
|
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.
|
||||||
- **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
|
|
||||||
|
|
||||||
## 📁 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 and documentation:
|
||||||
|
|
||||||
```
|
```
|
||||||
igny8/
|
igny8/
|
||||||
├── backend/ # Django backend
|
├── backend/ # Django REST API + Celery
|
||||||
│ ├── igny8_core/ # Django project
|
├── frontend/ # React + Vite SPA
|
||||||
│ │ ├── modules/ # Feature modules (Planner, Writer, System, Billing, Auth)
|
├── docs/ # Documentation index and topic folders
|
||||||
│ │ ├── ai/ # AI framework
|
└── docker-compose.app.yml # Docker deployment config
|
||||||
│ │ ├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Separate Repository:**
|
||||||
|
- [igny8-wp-integration](https://github.com/alorig/igny8-wp-integration) - WordPress bridge plugin
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Docker & Docker Compose
|
- **Python 3.11+**
|
||||||
- Node.js 18+ (for local development)
|
- **Node.js 18+**
|
||||||
- Python 3.11+ (for local development)
|
- **PostgreSQL 14+**
|
||||||
|
- **Redis 7+**
|
||||||
|
- **Docker** (optional, recommended for local development)
|
||||||
|
|
||||||
### Development Setup
|
### Local Development with Docker
|
||||||
|
|
||||||
1. **Navigate to the project directory:**
|
1. **Clone the repository**
|
||||||
```bash
|
```powershell
|
||||||
cd /data/app/igny8
|
git clone https://github.com/alorig/igny8-app.git
|
||||||
|
cd igny8
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Backend Setup:**
|
2. **Set environment variables**
|
||||||
```bash
|
|
||||||
cd backend
|
Create `.env` file in `backend/` directory:
|
||||||
pip install -r requirements.txt
|
```env
|
||||||
python manage.py migrate
|
SECRET_KEY=your-secret-key-here
|
||||||
python manage.py createsuperuser
|
DEBUG=True
|
||||||
python manage.py runserver
|
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:**
|
3. **Start services**
|
||||||
```bash
|
```powershell
|
||||||
cd frontend
|
docker-compose -f docker-compose.app.yml up --build
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Access:**
|
4. **Access applications**
|
||||||
- Frontend: http://localhost:5173
|
- Frontend: http://localhost:5173
|
||||||
- Backend API: http://localhost:8011/api/
|
- Backend API: http://localhost:8000
|
||||||
- Admin: http://localhost:8011/admin/
|
- API Docs: http://localhost:8000/api/docs/
|
||||||
|
- Django Admin: http://localhost:8000/admin/
|
||||||
|
|
||||||
### Docker Setup
|
### Manual Setup (Without Docker)
|
||||||
|
|
||||||
```bash
|
#### Backend Setup
|
||||||
# Build images
|
|
||||||
docker build -f backend/Dockerfile -t igny8-backend ./backend
|
|
||||||
docker build -f frontend/Dockerfile.dev -t igny8-frontend-dev ./frontend
|
|
||||||
|
|
||||||
# Run with docker-compose
|
```powershell
|
||||||
docker-compose -f docker-compose.app.yml up
|
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
|
# Celery beat (scheduled tasks)
|
||||||
|
celery -A igny8_core beat -l info
|
||||||
### ✅ 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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Additional API Guides
|
#### Frontend Setup
|
||||||
|
|
||||||
- **[Authentication Guide](docs/AUTHENTICATION-GUIDE.md)** - Detailed JWT authentication guide
|
```powershell
|
||||||
- **[Error Codes Reference](docs/ERROR-CODES.md)** - Complete error code reference
|
cd frontend
|
||||||
- **[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
|
|
||||||
|
|
||||||
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
|
User Interface (React)
|
||||||
2. **[CHANGELOG.md](CHANGELOG.md)** - Current version and change history
|
↓
|
||||||
|
REST API (Django)
|
||||||
|
↓
|
||||||
|
┌───────┴────────┐
|
||||||
|
│ │
|
||||||
|
Database AI Engine
|
||||||
|
(PostgreSQL) (Celery + OpenAI)
|
||||||
|
│
|
||||||
|
WordPress Plugin
|
||||||
|
(Bidirectional Sync)
|
||||||
|
```
|
||||||
|
|
||||||
### Core Documentation
|
### Tech Stack
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
**Backend:**
|
**Backend:**
|
||||||
- Django 5.2+
|
- Django 5.2+ (Python web framework)
|
||||||
- Django REST Framework
|
- Django REST Framework (API)
|
||||||
- PostgreSQL 15
|
- PostgreSQL (Database)
|
||||||
- Celery 5.3+
|
- Celery (Async task queue)
|
||||||
- Redis 7
|
- Redis (Message broker)
|
||||||
|
- OpenAI API (Content generation)
|
||||||
|
|
||||||
**Frontend:**
|
**Frontend:**
|
||||||
- React 19
|
- React 19 (UI library)
|
||||||
- TypeScript 5.7+
|
- Vite 6 (Build tool)
|
||||||
- Vite 6.1+
|
- Zustand (State management)
|
||||||
- Tailwind CSS 4.0+
|
- React Router v7 (Routing)
|
||||||
- Zustand 5.0+
|
- Tailwind CSS 4 (Styling)
|
||||||
|
|
||||||
**Infrastructure:**
|
**WordPress Plugin:**
|
||||||
- Docker & Docker Compose
|
- PHP 7.4+ (WordPress compatibility)
|
||||||
- Caddy (Reverse Proxy)
|
- REST API integration
|
||||||
- Portainer (Container Management)
|
- Bidirectional sync
|
||||||
|
|
||||||
### 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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
---
|
## 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)
|
### WordPress Integration
|
||||||
- **Current Version**: `1.0.0`
|
|
||||||
- **Location**: `CHANGELOG.md` (root directory)
|
|
||||||
- **Rules**: Only updated after user confirmation that fix/feature is complete
|
|
||||||
|
|
||||||
### Changelog Management
|
The WordPress bridge plugin (`igny8-wp-integration`) creates a bidirectional connection:
|
||||||
|
|
||||||
- **Location**: `CHANGELOG.md` (root directory)
|
- **IGNY8 → WordPress:** Publish AI-generated content to WordPress
|
||||||
- **Rules**: Only updated after user confirmation
|
- **WordPress → IGNY8:** Sync post status updates back to IGNY8
|
||||||
- **Structure**: Added, Changed, Fixed, Deprecated, Removed, Security
|
|
||||||
- **For Details**: See [00-DOCUMENTATION-MANAGEMENT.md](docs/00-DOCUMENTATION-MANAGEMENT.md)
|
|
||||||
|
|
||||||
### DRY Principles
|
**Setup:**
|
||||||
|
1. Install WordPress plugin on your site
|
||||||
**Core Principle**: Always use existing, predefined, standardized components, utilities, functions, and configurations.
|
2. Generate API key in IGNY8 app
|
||||||
|
3. Connect plugin using email, password, and API key
|
||||||
**Frontend**: Use existing templates, components, stores, contexts, utilities, and Tailwind CSS
|
4. Plugin syncs automatically
|
||||||
**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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 License
|
## Documentation
|
||||||
|
|
||||||
[Add license information]
|
Start here: [docs/README.md](./docs/README.md) (index of all topics).
|
||||||
|
|
||||||
|
Common entry points:
|
||||||
|
- App architecture: `docs/igny8-app/IGNY8-APP-ARCHITECTURE.md`
|
||||||
|
- Backend architecture: `docs/backend/IGNY8-BACKEND-ARCHITECTURE.md`
|
||||||
|
- Planner backend detail: `docs/backend/IGNY8-PLANNER-BACKEND.md`
|
||||||
|
- Writer backend detail: `docs/backend/IGNY8-WRITER-BACKEND.md`
|
||||||
|
- Automation: `docs/automation/AUTOMATION-REFERENCE.md`
|
||||||
|
- Tech stack: `docs/tech-stack/00-SYSTEM-ARCHITECTURE-MASTER-REFERENCE.md`
|
||||||
|
- API: `docs/API/API-COMPLETE-REFERENCE-LATEST.md`
|
||||||
|
- Billing & Credits: `docs/billing/billing-account-final-plan-2025-12-05.md`
|
||||||
|
- App guides: `docs/igny8-app/` (planner/writer workflows, taxonomy, feature modification)
|
||||||
|
- WordPress: `docs/wp/` (plugin integration and sync)
|
||||||
|
- Docs changelog: `docs/CHANGELOG.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📞 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**
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -22,6 +22,10 @@ RUN pip install --upgrade pip \
|
|||||||
# Copy full project
|
# Copy full project
|
||||||
COPY . /app/
|
COPY . /app/
|
||||||
|
|
||||||
|
# Copy startup script
|
||||||
|
COPY container_startup.sh /app/
|
||||||
|
RUN chmod +x /app/container_startup.sh
|
||||||
|
|
||||||
# Collect static files for WhiteNoise (skip during build if DB not available)
|
# Collect static files for WhiteNoise (skip during build if DB not available)
|
||||||
# Will be run during container startup if needed
|
# Will be run during container startup if needed
|
||||||
RUN python manage.py collectstatic --noinput || echo "Skipping collectstatic during build"
|
RUN python manage.py collectstatic --noinput || echo "Skipping collectstatic during build"
|
||||||
@@ -32,5 +36,7 @@ ENV DJANGO_SETTINGS_MODULE=igny8_core.settings
|
|||||||
# Expose port for Gunicorn (matches Portainer docker-compose config)
|
# Expose port for Gunicorn (matches Portainer docker-compose config)
|
||||||
EXPOSE 8010
|
EXPOSE 8010
|
||||||
|
|
||||||
|
# Use startup script as entrypoint to log container lifecycle
|
||||||
# Start using Gunicorn (matches Portainer docker-compose config)
|
# Start using Gunicorn (matches Portainer docker-compose config)
|
||||||
|
ENTRYPOINT ["/app/container_startup.sh"]
|
||||||
CMD ["gunicorn", "igny8_core.wsgi:application", "--bind", "0.0.0.0:8010"]
|
CMD ["gunicorn", "igny8_core.wsgi:application", "--bind", "0.0.0.0:8010"]
|
||||||
|
|||||||
Binary file not shown.
47
backend/container_startup.sh
Normal file
47
backend/container_startup.sh
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Container Startup Logger
|
||||||
|
# Logs container lifecycle events for debugging restarts
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "[CONTAINER-STARTUP] $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo "Container: igny8_backend"
|
||||||
|
echo "Hostname: $(hostname)"
|
||||||
|
echo "PID: $$"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# Log environment info
|
||||||
|
echo "[INFO] Python version: $(python --version 2>&1)"
|
||||||
|
echo "[INFO] Django settings: ${DJANGO_SETTINGS_MODULE:-igny8_core.settings}"
|
||||||
|
echo "[INFO] Debug mode: ${DEBUG:-False}"
|
||||||
|
echo "[INFO] Database host: ${DB_HOST:-not set}"
|
||||||
|
|
||||||
|
# Check if this is a restart (look for previous process artifacts)
|
||||||
|
if [ -f /tmp/container_pid ]; then
|
||||||
|
PREV_PID=$(cat /tmp/container_pid)
|
||||||
|
echo "[WARNING] Previous container PID found: $PREV_PID"
|
||||||
|
echo "[WARNING] This appears to be a RESTART event"
|
||||||
|
echo "[WARNING] Check Docker logs for SIGTERM/SIGKILL signals"
|
||||||
|
else
|
||||||
|
echo "[INFO] First startup (no previous PID file found)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save current PID
|
||||||
|
echo $$ > /tmp/container_pid
|
||||||
|
|
||||||
|
# Run database migrations (will skip if up to date)
|
||||||
|
echo "[INFO] Running database migrations..."
|
||||||
|
python manage.py migrate --noinput || echo "[WARNING] Migration failed or skipped"
|
||||||
|
|
||||||
|
# Collect static files (skip if already done)
|
||||||
|
echo "[INFO] Collecting static files..."
|
||||||
|
python manage.py collectstatic --noinput || echo "[WARNING] Collectstatic failed or skipped"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "[CONTAINER-STARTUP] Initialization complete"
|
||||||
|
echo "[CONTAINER-STARTUP] Starting Gunicorn..."
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# Execute the CMD passed to the script (Gunicorn command)
|
||||||
|
exec "$@"
|
||||||
61
backend/create_groups.py
Normal file
61
backend/create_groups.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Script to create admin permission groups"""
|
||||||
|
import os
|
||||||
|
import django
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Group, Permission
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
|
groups_permissions = {
|
||||||
|
'Content Manager': {
|
||||||
|
'models': [
|
||||||
|
('writer', 'content'), ('writer', 'tasks'), ('writer', 'images'),
|
||||||
|
('planner', 'keywords'), ('planner', 'clusters'), ('planner', 'contentideas'),
|
||||||
|
],
|
||||||
|
'permissions': ['add', 'change', 'view'],
|
||||||
|
},
|
||||||
|
'Billing Admin': {
|
||||||
|
'models': [
|
||||||
|
('billing', 'payment'), ('billing', 'invoice'), ('billing', 'credittransaction'),
|
||||||
|
('billing', 'creditusagelog'), ('igny8_core_auth', 'account'),
|
||||||
|
],
|
||||||
|
'permissions': ['add', 'change', 'view', 'delete'],
|
||||||
|
},
|
||||||
|
'Support Agent': {
|
||||||
|
'models': [
|
||||||
|
('writer', 'content'), ('writer', 'tasks'),
|
||||||
|
('igny8_core_auth', 'account'), ('igny8_core_auth', 'site'),
|
||||||
|
],
|
||||||
|
'permissions': ['view'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
print('Creating admin permission groups...\n')
|
||||||
|
|
||||||
|
for group_name, config in groups_permissions.items():
|
||||||
|
group, created = Group.objects.get_or_create(name=group_name)
|
||||||
|
status = 'Created' if created else 'Updated'
|
||||||
|
print(f'✓ {status} group: {group_name}')
|
||||||
|
|
||||||
|
group.permissions.clear()
|
||||||
|
added = 0
|
||||||
|
|
||||||
|
for app_label, model_name in config['models']:
|
||||||
|
try:
|
||||||
|
ct = ContentType.objects.get(app_label=app_label, model=model_name)
|
||||||
|
for perm_type in config['permissions']:
|
||||||
|
try:
|
||||||
|
perm = Permission.objects.get(content_type=ct, codename=f'{perm_type}_{model_name}')
|
||||||
|
group.permissions.add(perm)
|
||||||
|
added += 1
|
||||||
|
except Permission.DoesNotExist:
|
||||||
|
print(f' ! Permission not found: {perm_type}_{model_name}')
|
||||||
|
except ContentType.DoesNotExist:
|
||||||
|
print(f' ! ContentType not found: {app_label}.{model_name}')
|
||||||
|
|
||||||
|
print(f' Added {added} permissions')
|
||||||
|
|
||||||
|
print('\n✓ Permission groups created successfully!')
|
||||||
@@ -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.
Binary file not shown.
|
Before Width: | Height: | Size: 164 KiB |
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Admin module for IGNY8
|
Admin module for IGNY8
|
||||||
"""
|
"""
|
||||||
from .base import AccountAdminMixin, SiteSectorAdminMixin
|
# Note: Igny8ModelAdmin is imported by individual admin modules as needed to avoid circular imports
|
||||||
|
|
||||||
__all__ = ['AccountAdminMixin', 'SiteSectorAdminMixin']
|
__all__ = []
|
||||||
|
|
||||||
|
|||||||
122
backend/igny8_core/admin/alerts.py
Normal file
122
backend/igny8_core/admin/alerts.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
Admin Alert System
|
||||||
|
"""
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAlerts:
|
||||||
|
"""System for admin alerts and notifications"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_alerts():
|
||||||
|
"""Get all active alerts"""
|
||||||
|
alerts = []
|
||||||
|
today = timezone.now().date()
|
||||||
|
|
||||||
|
# Check for pending payments
|
||||||
|
from igny8_core.business.billing.models import Payment
|
||||||
|
pending_payments = Payment.objects.filter(status='pending_approval').count()
|
||||||
|
if pending_payments > 0:
|
||||||
|
alerts.append({
|
||||||
|
'level': 'warning',
|
||||||
|
'icon': '⚠️',
|
||||||
|
'message': f'{pending_payments} payment(s) awaiting approval',
|
||||||
|
'url': '/admin/billing/payment/?status=pending_approval',
|
||||||
|
'action': 'Review Payments'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for low credit accounts
|
||||||
|
from igny8_core.auth.models import Account
|
||||||
|
low_credit_accounts = Account.objects.filter(
|
||||||
|
status='active',
|
||||||
|
credits__lt=100
|
||||||
|
).count()
|
||||||
|
if low_credit_accounts > 0:
|
||||||
|
alerts.append({
|
||||||
|
'level': 'info',
|
||||||
|
'icon': 'ℹ️',
|
||||||
|
'message': f'{low_credit_accounts} account(s) with low credits',
|
||||||
|
'url': '/admin/igny8_core_auth/account/?credits__lt=100',
|
||||||
|
'action': 'View Accounts'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for very low credits (critical)
|
||||||
|
critical_credit_accounts = Account.objects.filter(
|
||||||
|
status='active',
|
||||||
|
credits__lt=10
|
||||||
|
).count()
|
||||||
|
if critical_credit_accounts > 0:
|
||||||
|
alerts.append({
|
||||||
|
'level': 'error',
|
||||||
|
'icon': '🔴',
|
||||||
|
'message': f'{critical_credit_accounts} account(s) with critical low credits (< 10)',
|
||||||
|
'url': '/admin/igny8_core_auth/account/?credits__lt=10',
|
||||||
|
'action': 'Urgent Review'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for failed automations
|
||||||
|
from igny8_core.business.automation.models import AutomationRun
|
||||||
|
failed_today = AutomationRun.objects.filter(
|
||||||
|
status='failed',
|
||||||
|
started_at__date=today
|
||||||
|
).count()
|
||||||
|
if failed_today > 0:
|
||||||
|
alerts.append({
|
||||||
|
'level': 'error',
|
||||||
|
'icon': '🔴',
|
||||||
|
'message': f'{failed_today} automation(s) failed today',
|
||||||
|
'url': '/admin/automation/automationrun/?status=failed',
|
||||||
|
'action': 'Review Failures'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for failed syncs
|
||||||
|
from igny8_core.business.integration.models import SyncEvent
|
||||||
|
failed_syncs = SyncEvent.objects.filter(
|
||||||
|
success=False,
|
||||||
|
created_at__date=today
|
||||||
|
).count()
|
||||||
|
if failed_syncs > 5: # Only alert if more than 5
|
||||||
|
alerts.append({
|
||||||
|
'level': 'warning',
|
||||||
|
'icon': '⚠️',
|
||||||
|
'message': f'{failed_syncs} WordPress sync failures today',
|
||||||
|
'url': '/admin/integration/syncevent/?success=False',
|
||||||
|
'action': 'Review Syncs'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for failed Celery tasks
|
||||||
|
try:
|
||||||
|
from django_celery_results.models import TaskResult
|
||||||
|
celery_failed = TaskResult.objects.filter(
|
||||||
|
status='FAILURE',
|
||||||
|
date_created__date=today
|
||||||
|
).count()
|
||||||
|
if celery_failed > 0:
|
||||||
|
alerts.append({
|
||||||
|
'level': 'error',
|
||||||
|
'icon': '🔴',
|
||||||
|
'message': f'{celery_failed} Celery task(s) failed today',
|
||||||
|
'url': '/admin/django_celery_results/taskresult/?status=FAILURE',
|
||||||
|
'action': 'Review Tasks'
|
||||||
|
})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for stale pending tasks (older than 24 hours)
|
||||||
|
from igny8_core.modules.writer.models import Tasks
|
||||||
|
yesterday = today - timedelta(days=1)
|
||||||
|
stale_tasks = Tasks.objects.filter(
|
||||||
|
status='pending',
|
||||||
|
created_at__date__lte=yesterday
|
||||||
|
).count()
|
||||||
|
if stale_tasks > 10:
|
||||||
|
alerts.append({
|
||||||
|
'level': 'info',
|
||||||
|
'icon': 'ℹ️',
|
||||||
|
'message': f'{stale_tasks} tasks pending for more than 24 hours',
|
||||||
|
'url': '/admin/writer/tasks/?status=pending',
|
||||||
|
'action': 'Review Tasks'
|
||||||
|
})
|
||||||
|
|
||||||
|
return alerts
|
||||||
@@ -1,8 +1,93 @@
|
|||||||
|
from django.contrib import admin
|
||||||
from django.contrib.admin.apps import AdminConfig
|
from django.contrib.admin.apps import AdminConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ReadOnlyAdmin(admin.ModelAdmin):
|
||||||
|
"""Generic read-only admin for system tables."""
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_register(model, model_admin):
|
||||||
|
try:
|
||||||
|
admin.site.register(model, model_admin)
|
||||||
|
except admin.sites.AlreadyRegistered:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Igny8AdminConfig(AdminConfig):
|
class Igny8AdminConfig(AdminConfig):
|
||||||
default_site = 'igny8_core.admin.site.Igny8AdminSite'
|
default_site = 'igny8_core.admin.site.Igny8AdminSite'
|
||||||
name = 'django.contrib.admin'
|
name = 'django.contrib.admin'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
super().ready()
|
||||||
|
|
||||||
|
# Replace default admin.site with our custom Igny8AdminSite
|
||||||
|
# IMPORTANT: Must copy all registrations from old site to new site
|
||||||
|
# because models register themselves before ready() is called
|
||||||
|
from igny8_core.admin.site import admin_site
|
||||||
|
import django.contrib.admin as admin_module
|
||||||
|
|
||||||
|
# Copy all model registrations from the default site to our custom site
|
||||||
|
old_site = admin_module.site
|
||||||
|
admin_site._registry = old_site._registry.copy()
|
||||||
|
admin_site._actions = old_site._actions.copy()
|
||||||
|
admin_site._global_actions = old_site._global_actions.copy()
|
||||||
|
|
||||||
|
# Now replace the default site
|
||||||
|
admin_module.site = admin_site
|
||||||
|
admin_module.sites.site = admin_site
|
||||||
|
|
||||||
|
# Import Unfold AFTER apps are ready
|
||||||
|
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
||||||
|
|
||||||
|
# Register Django internals in admin (read-only where appropriate)
|
||||||
|
from django.contrib.admin.models import LogEntry
|
||||||
|
from django.contrib.auth.models import Group, Permission
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.contrib.sessions.models import Session
|
||||||
|
|
||||||
|
_safe_register(LogEntry, ReadOnlyAdmin)
|
||||||
|
_safe_register(Permission, UnfoldModelAdmin)
|
||||||
|
_safe_register(Group, UnfoldModelAdmin)
|
||||||
|
_safe_register(ContentType, ReadOnlyAdmin)
|
||||||
|
_safe_register(Session, ReadOnlyAdmin)
|
||||||
|
|
||||||
|
# Import and setup enhanced Celery task monitoring
|
||||||
|
self._setup_celery_admin()
|
||||||
|
|
||||||
|
def _setup_celery_admin(self):
|
||||||
|
"""Setup enhanced Celery admin with proper unregister/register"""
|
||||||
|
try:
|
||||||
|
from django_celery_results.models import TaskResult, GroupResult
|
||||||
|
from igny8_core.admin.celery_admin import CeleryTaskResultAdmin, CeleryGroupResultAdmin
|
||||||
|
|
||||||
|
# Unregister the default TaskResult admin
|
||||||
|
try:
|
||||||
|
admin.site.unregister(TaskResult)
|
||||||
|
except admin.sites.NotRegistered:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Unregister the default GroupResult admin
|
||||||
|
try:
|
||||||
|
admin.site.unregister(GroupResult)
|
||||||
|
except admin.sites.NotRegistered:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Register our enhanced versions
|
||||||
|
admin.site.register(TaskResult, CeleryTaskResultAdmin)
|
||||||
|
admin.site.register(GroupResult, CeleryGroupResultAdmin)
|
||||||
|
except Exception as e:
|
||||||
|
# Log the error but don't crash the app
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.warning(f"Could not setup enhanced Celery admin: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -107,3 +107,77 @@ class SiteSectorAdminMixin:
|
|||||||
return obj.site in accessible_sites
|
return obj.site in accessible_sites
|
||||||
return super().has_delete_permission(request, obj)
|
return super().has_delete_permission(request, obj)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Custom ModelAdmin for Sidebar Fix
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
||||||
|
|
||||||
|
|
||||||
|
class Igny8ModelAdmin(UnfoldModelAdmin):
|
||||||
|
"""
|
||||||
|
Custom ModelAdmin that ensures sidebar_navigation is set correctly on ALL pages
|
||||||
|
|
||||||
|
Django's ModelAdmin views don't call AdminSite.each_context(),
|
||||||
|
so we override them to inject our custom sidebar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _inject_sidebar_context(self, request, extra_context=None):
|
||||||
|
"""Helper to inject custom sidebar into context"""
|
||||||
|
if extra_context is None:
|
||||||
|
extra_context = {}
|
||||||
|
|
||||||
|
# Get our custom sidebar from the admin site
|
||||||
|
from igny8_core.admin.site import admin_site
|
||||||
|
|
||||||
|
# CRITICAL: Get the full Unfold context (includes all branding, form classes, etc.)
|
||||||
|
# This is what makes the logo/title appear properly
|
||||||
|
unfold_context = admin_site.each_context(request)
|
||||||
|
|
||||||
|
# Get the current path to detect active group
|
||||||
|
current_path = request.path
|
||||||
|
|
||||||
|
sidebar_navigation = admin_site.get_sidebar_list(request)
|
||||||
|
|
||||||
|
# Detect active group and expand it by setting collapsible=False
|
||||||
|
for group in sidebar_navigation:
|
||||||
|
group_is_active = False
|
||||||
|
for item in group.get('items', []):
|
||||||
|
item_link = item.get('link', '')
|
||||||
|
# Check if current path matches this item's link
|
||||||
|
if item_link and current_path.startswith(item_link):
|
||||||
|
item['active'] = True
|
||||||
|
group_is_active = True
|
||||||
|
|
||||||
|
# If any item in this group is active, expand the group
|
||||||
|
if group_is_active:
|
||||||
|
group['collapsible'] = False # Expanded state
|
||||||
|
else:
|
||||||
|
group['collapsible'] = True # Collapsed state
|
||||||
|
|
||||||
|
# Merge Unfold context with our custom sidebar
|
||||||
|
unfold_context['sidebar_navigation'] = sidebar_navigation
|
||||||
|
unfold_context['available_apps'] = admin_site.get_app_list(request, app_label=None)
|
||||||
|
unfold_context['app_list'] = unfold_context['available_apps']
|
||||||
|
|
||||||
|
# Merge with any existing extra_context
|
||||||
|
unfold_context.update(extra_context)
|
||||||
|
|
||||||
|
return unfold_context
|
||||||
|
|
||||||
|
def changelist_view(self, request, extra_context=None):
|
||||||
|
"""Override to inject custom sidebar"""
|
||||||
|
extra_context = self._inject_sidebar_context(request, extra_context)
|
||||||
|
return super().changelist_view(request, extra_context)
|
||||||
|
|
||||||
|
def change_view(self, request, object_id, form_url='', extra_context=None):
|
||||||
|
"""Override to inject custom sidebar"""
|
||||||
|
extra_context = self._inject_sidebar_context(request, extra_context)
|
||||||
|
return super().change_view(request, object_id, form_url, extra_context)
|
||||||
|
|
||||||
|
def add_view(self, request, form_url='', extra_context=None):
|
||||||
|
"""Override to inject custom sidebar"""
|
||||||
|
extra_context = self._inject_sidebar_context(request, extra_context)
|
||||||
|
return super().add_view(request, form_url, extra_context)
|
||||||
|
|||||||
213
backend/igny8_core/admin/celery_admin.py
Normal file
213
backend/igny8_core/admin/celery_admin.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
Celery Task Monitoring Admin - Unfold Style
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.contrib import messages
|
||||||
|
from django_celery_results.models import TaskResult, GroupResult
|
||||||
|
from unfold.admin import ModelAdmin
|
||||||
|
from unfold.contrib.filters.admin import RangeDateFilter
|
||||||
|
from celery import current_app
|
||||||
|
|
||||||
|
|
||||||
|
class CeleryTaskResultAdmin(ModelAdmin):
|
||||||
|
"""Admin interface for monitoring Celery tasks with Unfold styling"""
|
||||||
|
|
||||||
|
list_display = [
|
||||||
|
'task_id',
|
||||||
|
'task_name',
|
||||||
|
'colored_status',
|
||||||
|
'date_created',
|
||||||
|
'date_done',
|
||||||
|
'execution_time',
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'status',
|
||||||
|
'task_name',
|
||||||
|
('date_created', RangeDateFilter),
|
||||||
|
('date_done', RangeDateFilter),
|
||||||
|
]
|
||||||
|
search_fields = ['task_id', 'task_name', 'task_args']
|
||||||
|
readonly_fields = [
|
||||||
|
'task_id', 'task_name', 'task_args', 'task_kwargs',
|
||||||
|
'result', 'traceback', 'date_created', 'date_done',
|
||||||
|
'colored_status', 'execution_time'
|
||||||
|
]
|
||||||
|
date_hierarchy = 'date_created'
|
||||||
|
ordering = ['-date_created']
|
||||||
|
|
||||||
|
actions = ['retry_failed_tasks', 'clear_old_tasks']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Task Information', {
|
||||||
|
'fields': ('task_id', 'task_name', 'colored_status')
|
||||||
|
}),
|
||||||
|
('Execution Details', {
|
||||||
|
'fields': ('date_created', 'date_done', 'execution_time')
|
||||||
|
}),
|
||||||
|
('Task Arguments', {
|
||||||
|
'fields': ('task_args', 'task_kwargs'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Result & Errors', {
|
||||||
|
'fields': ('result', 'traceback'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def colored_status(self, obj):
|
||||||
|
"""Display status with color coding"""
|
||||||
|
colors = {
|
||||||
|
'SUCCESS': '#0bbf87', # IGNY8 success green
|
||||||
|
'FAILURE': '#ef4444', # IGNY8 danger red
|
||||||
|
'PENDING': '#ff7a00', # IGNY8 warning orange
|
||||||
|
'STARTED': '#0693e3', # IGNY8 primary blue
|
||||||
|
'RETRY': '#5d4ae3', # IGNY8 purple
|
||||||
|
}
|
||||||
|
color = colors.get(obj.status, '#64748b') # Default gray
|
||||||
|
|
||||||
|
return format_html(
|
||||||
|
'<span style="color: {}; font-weight: bold; font-size: 14px;">{}</span>',
|
||||||
|
color,
|
||||||
|
obj.status
|
||||||
|
)
|
||||||
|
colored_status.short_description = 'Status'
|
||||||
|
|
||||||
|
def execution_time(self, obj):
|
||||||
|
"""Calculate and display execution time"""
|
||||||
|
if obj.date_done and obj.date_created:
|
||||||
|
duration = obj.date_done - obj.date_created
|
||||||
|
seconds = duration.total_seconds()
|
||||||
|
|
||||||
|
if seconds < 1:
|
||||||
|
time_str = f'{seconds * 1000:.2f}ms'
|
||||||
|
return format_html('<span style="color: #0bbf87;">{}</span>', time_str)
|
||||||
|
elif seconds < 60:
|
||||||
|
time_str = f'{seconds:.2f}s'
|
||||||
|
return format_html('<span style="color: #0693e3;">{}</span>', time_str)
|
||||||
|
else:
|
||||||
|
minutes = seconds / 60
|
||||||
|
time_str = f'{minutes:.1f}m'
|
||||||
|
return format_html('<span style="color: #ff7a00;">{}</span>', time_str)
|
||||||
|
return '-'
|
||||||
|
execution_time.short_description = 'Duration'
|
||||||
|
|
||||||
|
def retry_failed_tasks(self, request, queryset):
|
||||||
|
"""Retry failed celery tasks by re-queuing them"""
|
||||||
|
from igny8_core.celery import app
|
||||||
|
import json
|
||||||
|
|
||||||
|
failed_tasks = queryset.filter(status='FAILURE')
|
||||||
|
count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for task in failed_tasks:
|
||||||
|
try:
|
||||||
|
# Get task function
|
||||||
|
task_func = current_app.tasks.get(task.task_name)
|
||||||
|
if task_func:
|
||||||
|
# Parse task args and kwargs
|
||||||
|
import ast
|
||||||
|
try:
|
||||||
|
args = ast.literal_eval(task.task_args) if task.task_args else []
|
||||||
|
kwargs = ast.literal_eval(task.task_kwargs) if task.task_kwargs else {}
|
||||||
|
except:
|
||||||
|
args = []
|
||||||
|
kwargs = {}
|
||||||
|
|
||||||
|
# Retry the task
|
||||||
|
task_func.apply_async(args=args, kwargs=kwargs)
|
||||||
|
count += 1
|
||||||
|
else:
|
||||||
|
errors.append(f'Task function not found: {task.task_name}')
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f'Error retrying {task.task_id}: {str(e)}')
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
self.message_user(request, f'Successfully queued {count} task(s) for retry.', 'SUCCESS')
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for error in errors[:5]: # Show max 5 errors
|
||||||
|
self.message_user(request, f'Error: {error}', 'WARNING')
|
||||||
|
|
||||||
|
retry_failed_tasks.short_description = 'Retry Failed Tasks'
|
||||||
|
|
||||||
|
def clear_old_tasks(self, request, queryset):
|
||||||
|
"""Clear old completed tasks"""
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Delete tasks older than 30 days
|
||||||
|
cutoff_date = timezone.now() - timedelta(days=30)
|
||||||
|
old_tasks = queryset.filter(
|
||||||
|
date_created__lt=cutoff_date,
|
||||||
|
status__in=['SUCCESS', 'FAILURE']
|
||||||
|
)
|
||||||
|
|
||||||
|
count = old_tasks.count()
|
||||||
|
old_tasks.delete()
|
||||||
|
|
||||||
|
self.message_user(request, f'Cleared {count} old task(s)', messages.SUCCESS)
|
||||||
|
|
||||||
|
clear_old_tasks.short_description = 'Clear Old Tasks (30+ days)'
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
"""Disable manual task creation"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
"""Make read-only"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class CeleryGroupResultAdmin(ModelAdmin):
|
||||||
|
"""Admin interface for monitoring Celery group results with Unfold styling"""
|
||||||
|
|
||||||
|
list_display = [
|
||||||
|
'group_id',
|
||||||
|
'date_created',
|
||||||
|
'date_done',
|
||||||
|
'result_count',
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
('date_created', RangeDateFilter),
|
||||||
|
('date_done', RangeDateFilter),
|
||||||
|
]
|
||||||
|
search_fields = ['group_id', 'result']
|
||||||
|
readonly_fields = [
|
||||||
|
'group_id', 'date_created', 'date_done', 'content_type',
|
||||||
|
'content_encoding', 'result'
|
||||||
|
]
|
||||||
|
date_hierarchy = 'date_created'
|
||||||
|
ordering = ['-date_created']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Group Information', {
|
||||||
|
'fields': ('group_id', 'date_created', 'date_done')
|
||||||
|
}),
|
||||||
|
('Result Details', {
|
||||||
|
'fields': ('content_type', 'content_encoding', 'result'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def result_count(self, obj):
|
||||||
|
"""Count tasks in the group"""
|
||||||
|
if obj.result:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
result_data = json.loads(obj.result) if isinstance(obj.result, str) else obj.result
|
||||||
|
if isinstance(result_data, list):
|
||||||
|
return len(result_data)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return '-'
|
||||||
|
result_count.short_description = 'Task Count'
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
"""Disable manual group result creation"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
"""Make read-only"""
|
||||||
|
return False
|
||||||
189
backend/igny8_core/admin/dashboard.py
Normal file
189
backend/igny8_core/admin/dashboard.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"""
|
||||||
|
Custom Admin Dashboard with Key Metrics
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.db.models import Count, Sum, Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def admin_dashboard(request):
|
||||||
|
"""Custom admin dashboard with operational metrics"""
|
||||||
|
|
||||||
|
# Date ranges
|
||||||
|
today = timezone.now().date()
|
||||||
|
week_ago = today - timedelta(days=7)
|
||||||
|
month_ago = today - timedelta(days=30)
|
||||||
|
|
||||||
|
# Account metrics
|
||||||
|
from igny8_core.auth.models import Account, Site
|
||||||
|
total_accounts = Account.objects.count()
|
||||||
|
active_accounts = Account.objects.filter(status='active').count()
|
||||||
|
low_credit_accounts = Account.objects.filter(
|
||||||
|
status='active',
|
||||||
|
credits__lt=100
|
||||||
|
).count()
|
||||||
|
critical_credit_accounts = Account.objects.filter(
|
||||||
|
status='active',
|
||||||
|
credits__lt=10
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Site metrics
|
||||||
|
total_sites = Site.objects.count()
|
||||||
|
active_sites = Site.objects.filter(is_active=True, status='active').count()
|
||||||
|
|
||||||
|
# Content metrics
|
||||||
|
from igny8_core.modules.writer.models import Content, Tasks
|
||||||
|
content_this_week = Content.objects.filter(created_at__gte=week_ago).count()
|
||||||
|
content_this_month = Content.objects.filter(created_at__gte=month_ago).count()
|
||||||
|
tasks_pending = Tasks.objects.filter(status='pending').count()
|
||||||
|
tasks_in_progress = Tasks.objects.filter(status='in_progress').count()
|
||||||
|
|
||||||
|
# Billing metrics
|
||||||
|
from igny8_core.business.billing.models import Payment, CreditTransaction
|
||||||
|
pending_payments = Payment.objects.filter(status='pending_approval').count()
|
||||||
|
payments_this_month = Payment.objects.filter(
|
||||||
|
created_at__gte=month_ago,
|
||||||
|
status='succeeded'
|
||||||
|
).aggregate(total=Sum('amount'))['total'] or 0
|
||||||
|
|
||||||
|
credit_usage_this_month = CreditTransaction.objects.filter(
|
||||||
|
created_at__gte=month_ago,
|
||||||
|
transaction_type='deduction'
|
||||||
|
).aggregate(total=Sum('amount'))['total'] or 0
|
||||||
|
|
||||||
|
# Automation metrics
|
||||||
|
from igny8_core.business.automation.models import AutomationRun
|
||||||
|
automation_running = AutomationRun.objects.filter(status='running').count()
|
||||||
|
automation_failed = AutomationRun.objects.filter(
|
||||||
|
status='failed',
|
||||||
|
started_at__gte=week_ago
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Calculate success rate
|
||||||
|
total_runs = AutomationRun.objects.filter(started_at__gte=week_ago).count()
|
||||||
|
if total_runs > 0:
|
||||||
|
success_runs = AutomationRun.objects.filter(
|
||||||
|
started_at__gte=week_ago,
|
||||||
|
status='completed'
|
||||||
|
).count()
|
||||||
|
automation_success_rate = round((success_runs / total_runs) * 100, 1)
|
||||||
|
else:
|
||||||
|
automation_success_rate = 0
|
||||||
|
|
||||||
|
# WordPress sync metrics
|
||||||
|
from igny8_core.business.integration.models import SyncEvent
|
||||||
|
sync_failed_today = SyncEvent.objects.filter(
|
||||||
|
success=False,
|
||||||
|
created_at__date=today
|
||||||
|
).count()
|
||||||
|
sync_success_today = SyncEvent.objects.filter(
|
||||||
|
success=True,
|
||||||
|
created_at__date=today
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Celery task metrics
|
||||||
|
try:
|
||||||
|
from django_celery_results.models import TaskResult
|
||||||
|
celery_failed = TaskResult.objects.filter(
|
||||||
|
status='FAILURE',
|
||||||
|
date_created__gte=week_ago
|
||||||
|
).count()
|
||||||
|
celery_pending = TaskResult.objects.filter(status='PENDING').count()
|
||||||
|
except:
|
||||||
|
celery_failed = 0
|
||||||
|
celery_pending = 0
|
||||||
|
|
||||||
|
# Generate alerts
|
||||||
|
alerts = []
|
||||||
|
|
||||||
|
if critical_credit_accounts > 0:
|
||||||
|
alerts.append({
|
||||||
|
'level': 'error',
|
||||||
|
'message': f'{critical_credit_accounts} account(s) have CRITICAL low credits (< 10)',
|
||||||
|
'action': 'Review Accounts',
|
||||||
|
'url': '/admin/igny8_core_auth/account/?credits__lt=10'
|
||||||
|
})
|
||||||
|
|
||||||
|
if low_credit_accounts > 0:
|
||||||
|
alerts.append({
|
||||||
|
'level': 'warning',
|
||||||
|
'message': f'{low_credit_accounts} account(s) have low credits (< 100)',
|
||||||
|
'action': 'Review Accounts',
|
||||||
|
'url': '/admin/igny8_core_auth/account/?credits__lt=100'
|
||||||
|
})
|
||||||
|
|
||||||
|
if pending_payments > 0:
|
||||||
|
alerts.append({
|
||||||
|
'level': 'warning',
|
||||||
|
'message': f'{pending_payments} payment(s) awaiting approval',
|
||||||
|
'action': 'Approve Payments',
|
||||||
|
'url': '/admin/billing/payment/?status__exact=pending_approval'
|
||||||
|
})
|
||||||
|
|
||||||
|
if automation_failed > 5:
|
||||||
|
alerts.append({
|
||||||
|
'level': 'error',
|
||||||
|
'message': f'{automation_failed} automation runs failed this week',
|
||||||
|
'action': 'View Failed Runs',
|
||||||
|
'url': '/admin/automation/automationrun/?status__exact=failed'
|
||||||
|
})
|
||||||
|
|
||||||
|
if sync_failed_today > 0:
|
||||||
|
alerts.append({
|
||||||
|
'level': 'warning',
|
||||||
|
'message': f'{sync_failed_today} WordPress sync failure(s) today',
|
||||||
|
'action': 'View Sync Events',
|
||||||
|
'url': '/admin/integration/syncevent/?success__exact=0'
|
||||||
|
})
|
||||||
|
|
||||||
|
if celery_failed > 10:
|
||||||
|
alerts.append({
|
||||||
|
'level': 'error',
|
||||||
|
'message': f'{celery_failed} Celery tasks failed this week',
|
||||||
|
'action': 'View Failed Tasks',
|
||||||
|
'url': '/admin/django_celery_results/taskresult/?status__exact=FAILURE'
|
||||||
|
})
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'title': 'IGNY8 Dashboard',
|
||||||
|
'site_title': 'IGNY8 Admin',
|
||||||
|
'site_header': 'IGNY8 Administration',
|
||||||
|
# Account metrics
|
||||||
|
'total_accounts': total_accounts,
|
||||||
|
'active_accounts': active_accounts,
|
||||||
|
'low_credit_accounts': low_credit_accounts,
|
||||||
|
'critical_credit_accounts': critical_credit_accounts,
|
||||||
|
# Site metrics
|
||||||
|
'total_sites': total_sites,
|
||||||
|
'active_sites': active_sites,
|
||||||
|
# Content metrics
|
||||||
|
'content_this_week': content_this_week,
|
||||||
|
'content_this_month': content_this_month,
|
||||||
|
'tasks_pending': tasks_pending,
|
||||||
|
'tasks_in_progress': tasks_in_progress,
|
||||||
|
# Billing metrics
|
||||||
|
'pending_payments': pending_payments,
|
||||||
|
'payments_this_month': float(payments_this_month),
|
||||||
|
'credit_usage_this_month': abs(float(credit_usage_this_month)),
|
||||||
|
# Automation metrics
|
||||||
|
'automation_running': automation_running,
|
||||||
|
'automation_failed': automation_failed,
|
||||||
|
'automation_success_rate': automation_success_rate,
|
||||||
|
# Integration metrics
|
||||||
|
'sync_failed_today': sync_failed_today,
|
||||||
|
'sync_success_today': sync_success_today,
|
||||||
|
# Celery metrics
|
||||||
|
'celery_failed': celery_failed,
|
||||||
|
'celery_pending': celery_pending,
|
||||||
|
# Alerts
|
||||||
|
'alerts': alerts,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merge with admin context to get sidebar and header
|
||||||
|
from igny8_core.admin.site import admin_site
|
||||||
|
admin_context = admin_site.each_context(request)
|
||||||
|
context.update(admin_context)
|
||||||
|
|
||||||
|
return render(request, 'admin/dashboard.html', context)
|
||||||
253
backend/igny8_core/admin/reports.py
Normal file
253
backend/igny8_core/admin/reports.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"""
|
||||||
|
Analytics & Reporting Views for IGNY8 Admin
|
||||||
|
"""
|
||||||
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.db.models import Count, Sum, Avg, Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def revenue_report(request):
|
||||||
|
"""Revenue and billing analytics"""
|
||||||
|
from igny8_core.business.billing.models import Payment
|
||||||
|
from igny8_core.auth.models import Plan
|
||||||
|
|
||||||
|
# Date ranges
|
||||||
|
today = timezone.now()
|
||||||
|
months = []
|
||||||
|
monthly_revenue = []
|
||||||
|
|
||||||
|
for i in range(6):
|
||||||
|
month_start = today.replace(day=1) - timedelta(days=30*i)
|
||||||
|
month_end = month_start.replace(day=28) + timedelta(days=4)
|
||||||
|
|
||||||
|
revenue = Payment.objects.filter(
|
||||||
|
status='succeeded',
|
||||||
|
processed_at__gte=month_start,
|
||||||
|
processed_at__lt=month_end
|
||||||
|
).aggregate(total=Sum('amount'))['total'] or 0
|
||||||
|
|
||||||
|
months.insert(0, month_start.strftime('%b %Y'))
|
||||||
|
monthly_revenue.insert(0, float(revenue))
|
||||||
|
|
||||||
|
# Plan distribution
|
||||||
|
plan_distribution = Plan.objects.annotate(
|
||||||
|
account_count=Count('accounts')
|
||||||
|
).values('name', 'account_count')
|
||||||
|
|
||||||
|
# Payment method breakdown
|
||||||
|
payment_methods = Payment.objects.filter(
|
||||||
|
status='succeeded'
|
||||||
|
).values('payment_method').annotate(
|
||||||
|
count=Count('id'),
|
||||||
|
total=Sum('amount')
|
||||||
|
).order_by('-total')
|
||||||
|
|
||||||
|
# Total revenue all time
|
||||||
|
total_revenue = Payment.objects.filter(
|
||||||
|
status='succeeded'
|
||||||
|
).aggregate(total=Sum('amount'))['total'] or 0
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'title': 'Revenue Report',
|
||||||
|
'months': json.dumps(months),
|
||||||
|
'monthly_revenue': json.dumps(monthly_revenue),
|
||||||
|
'plan_distribution': list(plan_distribution),
|
||||||
|
'payment_methods': list(payment_methods),
|
||||||
|
'total_revenue': float(total_revenue),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merge with admin context
|
||||||
|
from igny8_core.admin.site import admin_site
|
||||||
|
admin_context = admin_site.each_context(request)
|
||||||
|
context.update(admin_context)
|
||||||
|
|
||||||
|
return render(request, 'admin/reports/revenue.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def usage_report(request):
|
||||||
|
"""Credit usage and AI operations analytics"""
|
||||||
|
from igny8_core.business.billing.models import CreditUsageLog
|
||||||
|
|
||||||
|
# Usage by operation type
|
||||||
|
usage_by_operation = CreditUsageLog.objects.values(
|
||||||
|
'operation_type'
|
||||||
|
).annotate(
|
||||||
|
total_credits=Sum('credits_used'),
|
||||||
|
total_cost=Sum('cost_usd'),
|
||||||
|
operation_count=Count('id')
|
||||||
|
).order_by('-total_credits')
|
||||||
|
|
||||||
|
# Top credit consumers
|
||||||
|
top_consumers = CreditUsageLog.objects.values(
|
||||||
|
'account__name'
|
||||||
|
).annotate(
|
||||||
|
total_credits=Sum('credits_used'),
|
||||||
|
operation_count=Count('id')
|
||||||
|
).order_by('-total_credits')[:10]
|
||||||
|
|
||||||
|
# Model usage distribution
|
||||||
|
model_usage = CreditUsageLog.objects.values(
|
||||||
|
'model_used'
|
||||||
|
).annotate(
|
||||||
|
usage_count=Count('id')
|
||||||
|
).order_by('-usage_count')
|
||||||
|
|
||||||
|
# Total credits used
|
||||||
|
total_credits = CreditUsageLog.objects.aggregate(
|
||||||
|
total=Sum('credits_used')
|
||||||
|
)['total'] or 0
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'title': 'Usage Report',
|
||||||
|
'usage_by_operation': list(usage_by_operation),
|
||||||
|
'top_consumers': list(top_consumers),
|
||||||
|
'model_usage': list(model_usage),
|
||||||
|
'total_credits': int(total_credits),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merge with admin context
|
||||||
|
from igny8_core.admin.site import admin_site
|
||||||
|
admin_context = admin_site.each_context(request)
|
||||||
|
context.update(admin_context)
|
||||||
|
|
||||||
|
return render(request, 'admin/reports/usage.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def content_report(request):
|
||||||
|
"""Content production analytics"""
|
||||||
|
from igny8_core.modules.writer.models import Content, Tasks
|
||||||
|
|
||||||
|
# Content by type
|
||||||
|
content_by_type = Content.objects.values(
|
||||||
|
'content_type'
|
||||||
|
).annotate(count=Count('id')).order_by('-count')
|
||||||
|
|
||||||
|
# Production timeline (last 30 days)
|
||||||
|
days = []
|
||||||
|
daily_counts = []
|
||||||
|
for i in range(30):
|
||||||
|
day = timezone.now().date() - timedelta(days=i)
|
||||||
|
count = Content.objects.filter(created_at__date=day).count()
|
||||||
|
days.insert(0, day.strftime('%m/%d'))
|
||||||
|
daily_counts.insert(0, count)
|
||||||
|
|
||||||
|
# Average word count by content type
|
||||||
|
avg_words = Content.objects.values('content_type').annotate(
|
||||||
|
avg_words=Avg('word_count')
|
||||||
|
).order_by('-avg_words')
|
||||||
|
|
||||||
|
# Task completion rate
|
||||||
|
total_tasks = Tasks.objects.count()
|
||||||
|
completed_tasks = Tasks.objects.filter(status='completed').count()
|
||||||
|
completion_rate = (completed_tasks / total_tasks * 100) if total_tasks > 0 else 0
|
||||||
|
|
||||||
|
# Total content produced
|
||||||
|
total_content = Content.objects.count()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'title': 'Content Production Report',
|
||||||
|
'content_by_type': list(content_by_type),
|
||||||
|
'days': json.dumps(days),
|
||||||
|
'daily_counts': json.dumps(daily_counts),
|
||||||
|
'avg_words': list(avg_words),
|
||||||
|
'completion_rate': round(completion_rate, 1),
|
||||||
|
'total_content': total_content,
|
||||||
|
'total_tasks': total_tasks,
|
||||||
|
'completed_tasks': completed_tasks,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merge with admin context
|
||||||
|
from igny8_core.admin.site import admin_site
|
||||||
|
admin_context = admin_site.each_context(request)
|
||||||
|
context.update(admin_context)
|
||||||
|
|
||||||
|
return render(request, 'admin/reports/content.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
def data_quality_report(request):
|
||||||
|
"""Check data quality and integrity"""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# Orphaned content (no site)
|
||||||
|
from igny8_core.modules.writer.models import Content
|
||||||
|
orphaned_content = Content.objects.filter(site__isnull=True).count()
|
||||||
|
if orphaned_content > 0:
|
||||||
|
issues.append({
|
||||||
|
'severity': 'warning',
|
||||||
|
'type': 'Orphaned Records',
|
||||||
|
'count': orphaned_content,
|
||||||
|
'description': 'Content items without assigned site',
|
||||||
|
'action_url': '/admin/writer/content/?site__isnull=True'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Tasks without clusters
|
||||||
|
from igny8_core.modules.writer.models import Tasks
|
||||||
|
tasks_no_cluster = Tasks.objects.filter(cluster__isnull=True).count()
|
||||||
|
if tasks_no_cluster > 0:
|
||||||
|
issues.append({
|
||||||
|
'severity': 'info',
|
||||||
|
'type': 'Missing Relationships',
|
||||||
|
'count': tasks_no_cluster,
|
||||||
|
'description': 'Tasks without assigned cluster',
|
||||||
|
'action_url': '/admin/writer/tasks/?cluster__isnull=True'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Accounts with negative credits
|
||||||
|
from igny8_core.auth.models import Account
|
||||||
|
negative_credits = Account.objects.filter(credits__lt=0).count()
|
||||||
|
if negative_credits > 0:
|
||||||
|
issues.append({
|
||||||
|
'severity': 'error',
|
||||||
|
'type': 'Data Integrity',
|
||||||
|
'count': negative_credits,
|
||||||
|
'description': 'Accounts with negative credit balance',
|
||||||
|
'action_url': '/admin/igny8_core_auth/account/?credits__lt=0'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Duplicate keywords
|
||||||
|
from igny8_core.modules.planner.models import Keywords
|
||||||
|
duplicates = Keywords.objects.values('seed_keyword', 'site', 'sector').annotate(
|
||||||
|
count=Count('id')
|
||||||
|
).filter(count__gt=1).count()
|
||||||
|
if duplicates > 0:
|
||||||
|
issues.append({
|
||||||
|
'severity': 'warning',
|
||||||
|
'type': 'Duplicates',
|
||||||
|
'count': duplicates,
|
||||||
|
'description': 'Duplicate keywords for same site/sector',
|
||||||
|
'action_url': '/admin/planner/keywords/'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Content without SEO data
|
||||||
|
no_seo = Content.objects.filter(
|
||||||
|
Q(meta_title__isnull=True) | Q(meta_title='') |
|
||||||
|
Q(meta_description__isnull=True) | Q(meta_description='')
|
||||||
|
).count()
|
||||||
|
if no_seo > 0:
|
||||||
|
issues.append({
|
||||||
|
'severity': 'info',
|
||||||
|
'type': 'Incomplete Data',
|
||||||
|
'count': no_seo,
|
||||||
|
'description': 'Content missing SEO metadata',
|
||||||
|
'action_url': '/admin/writer/content/'
|
||||||
|
})
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'title': 'Data Quality Report',
|
||||||
|
'issues': issues,
|
||||||
|
'total_issues': len(issues),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merge with admin context
|
||||||
|
from igny8_core.admin.site import admin_site
|
||||||
|
admin_context = admin_site.each_context(request)
|
||||||
|
context.update(admin_context)
|
||||||
|
|
||||||
|
return render(request, 'admin/reports/data_quality.html', context)
|
||||||
@@ -1,134 +1,325 @@
|
|||||||
"""
|
"""
|
||||||
Custom AdminSite for IGNY8 to organize models into proper groups
|
Custom AdminSite for IGNY8 to organize models into proper groups using Unfold
|
||||||
|
NO EMOJIS - Unfold handles all icons via Material Design
|
||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.admin.apps import AdminConfig
|
from django.contrib.admin.apps import AdminConfig
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
from django.urls import path, reverse_lazy
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.contrib.admin import sites
|
||||||
|
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
||||||
|
from unfold.sites import UnfoldAdminSite
|
||||||
|
|
||||||
|
|
||||||
class Igny8AdminSite(admin.AdminSite):
|
class Igny8AdminSite(UnfoldAdminSite):
|
||||||
"""
|
"""
|
||||||
Custom AdminSite that organizes models into the planned groups:
|
Custom AdminSite based on Unfold that organizes models into the planned groups
|
||||||
1. Billing & Tenancy
|
|
||||||
2. Sites & Users
|
|
||||||
3. Global Reference Data
|
|
||||||
4. Planner
|
|
||||||
5. Writer Module
|
|
||||||
6. Thinker Module
|
|
||||||
7. System Configuration
|
|
||||||
"""
|
"""
|
||||||
site_header = 'IGNY8 Administration'
|
site_header = 'IGNY8 Administration'
|
||||||
site_title = 'IGNY8 Admin'
|
site_title = 'IGNY8 Admin'
|
||||||
index_title = 'IGNY8 Administration'
|
index_title = 'IGNY8 Administration'
|
||||||
|
|
||||||
def get_app_list(self, request):
|
def get_urls(self):
|
||||||
|
"""Get admin URLs with dashboard and reports available"""
|
||||||
|
from django.urls import path
|
||||||
|
from .dashboard import admin_dashboard
|
||||||
|
from .reports import revenue_report, usage_report, content_report, data_quality_report
|
||||||
|
|
||||||
|
urls = super().get_urls()
|
||||||
|
custom_urls = [
|
||||||
|
path('dashboard/', self.admin_view(admin_dashboard), name='dashboard'),
|
||||||
|
path('reports/revenue/', self.admin_view(revenue_report), name='report_revenue'),
|
||||||
|
path('reports/usage/', self.admin_view(usage_report), name='report_usage'),
|
||||||
|
path('reports/content/', self.admin_view(content_report), name='report_content'),
|
||||||
|
path('reports/data-quality/', self.admin_view(data_quality_report), name='report_data_quality'),
|
||||||
|
]
|
||||||
|
return custom_urls + urls
|
||||||
|
|
||||||
|
def index(self, request, extra_context=None):
|
||||||
|
"""Redirect to custom dashboard"""
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
return redirect('admin:dashboard')
|
||||||
|
|
||||||
|
def get_sidebar_list(self, request):
|
||||||
"""
|
"""
|
||||||
Customize the app list to organize models into proper groups
|
Override Unfold's get_sidebar_list to return our custom app groups
|
||||||
|
Convert Django app_list format to Unfold sidebar navigation format
|
||||||
"""
|
"""
|
||||||
# Get the default app list
|
# Get our custom Django app list
|
||||||
app_dict = self._build_app_dict(request)
|
django_apps = self.get_app_list(request, app_label=None)
|
||||||
|
|
||||||
|
# Convert to Unfold navigation format: {title, items: [{title, link, icon}]}
|
||||||
|
sidebar_groups = []
|
||||||
|
|
||||||
|
for app in django_apps:
|
||||||
|
group = {
|
||||||
|
'title': app['name'],
|
||||||
|
'collapsible': True,
|
||||||
|
'items': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert each model to navigation item
|
||||||
|
for model in app.get('models', []):
|
||||||
|
if model.get('perms', {}).get('view', False) or model.get('perms', {}).get('change', False):
|
||||||
|
item = {
|
||||||
|
'title': model['name'],
|
||||||
|
'link': model['admin_url'],
|
||||||
|
'icon': None, # Unfold will use default
|
||||||
|
'has_permission': True, # CRITICAL: Template checks this
|
||||||
|
}
|
||||||
|
group['items'].append(item)
|
||||||
|
|
||||||
|
# Only add groups that have items
|
||||||
|
if group['items']:
|
||||||
|
sidebar_groups.append(group)
|
||||||
|
|
||||||
|
return sidebar_groups
|
||||||
|
|
||||||
|
def each_context(self, request):
|
||||||
|
"""
|
||||||
|
Override context to ensure our custom app_list is always used
|
||||||
|
This is called by all admin templates for sidebar rendering
|
||||||
|
|
||||||
|
CRITICAL FIX: Force custom sidebar on ALL pages including model detail/list views
|
||||||
|
"""
|
||||||
|
# CRITICAL: Must call parent to get sidebar_navigation set
|
||||||
|
context = super().each_context(request)
|
||||||
|
|
||||||
|
# DEBUGGING: Print to console what parent returned
|
||||||
|
print(f"\n=== DEBUG each_context for {request.path} ===")
|
||||||
|
print(f"sidebar_navigation length from parent: {len(context.get('sidebar_navigation', []))}")
|
||||||
|
if context.get('sidebar_navigation'):
|
||||||
|
print(f"First sidebar group: {context['sidebar_navigation'][0].get('title', 'NO TITLE')}")
|
||||||
|
|
||||||
|
# Force our custom app list to be used everywhere - IGNORE app_label parameter
|
||||||
|
custom_apps = self.get_app_list(request, app_label=None)
|
||||||
|
context['available_apps'] = custom_apps
|
||||||
|
context['app_list'] = custom_apps # Also set app_list for compatibility
|
||||||
|
|
||||||
|
# CRITICAL FIX: Ensure sidebar_navigation is using our custom sidebar
|
||||||
|
# Parent's each_context already called get_sidebar_list(), which returns our custom sidebar
|
||||||
|
# So sidebar_navigation should already be correct, but let's verify
|
||||||
|
if not context.get('sidebar_navigation') or len(context.get('sidebar_navigation', [])) == 0:
|
||||||
|
# If sidebar_navigation is empty, force it
|
||||||
|
print("WARNING: sidebar_navigation was empty, forcing it!")
|
||||||
|
context['sidebar_navigation'] = self.get_sidebar_list(request)
|
||||||
|
|
||||||
|
print(f"Final sidebar_navigation length: {len(context['sidebar_navigation'])}")
|
||||||
|
print("=== END DEBUG ===\n")
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_app_list(self, request, app_label=None):
|
||||||
|
"""
|
||||||
|
Customize the app list to organize models into logical groups
|
||||||
|
NO EMOJIS - Unfold handles all icons via Material Design
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The HTTP request
|
||||||
|
app_label: IGNORED - Always return full custom sidebar for consistency
|
||||||
|
"""
|
||||||
|
# CRITICAL: Always build full app_dict (ignore app_label) for consistent sidebar
|
||||||
|
app_dict = self._build_app_dict(request, None)
|
||||||
|
|
||||||
# Define our custom groups with their models (using object_name)
|
# Define our custom groups with their models (using object_name)
|
||||||
|
# Organized by business function - Material icons configured in Unfold
|
||||||
custom_groups = {
|
custom_groups = {
|
||||||
'Billing & Tenancy': {
|
'Accounts & Users': {
|
||||||
'models': [
|
'models': [
|
||||||
('igny8_core_auth', 'Plan'),
|
|
||||||
('igny8_core_auth', 'Account'),
|
('igny8_core_auth', 'Account'),
|
||||||
('igny8_core_auth', 'Subscription'),
|
|
||||||
('billing', 'CreditTransaction'),
|
|
||||||
('billing', 'CreditUsageLog'),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'Sites & Users': {
|
|
||||||
'models': [
|
|
||||||
('igny8_core_auth', 'Site'),
|
|
||||||
('igny8_core_auth', 'User'),
|
('igny8_core_auth', 'User'),
|
||||||
|
('igny8_core_auth', 'Site'),
|
||||||
|
('igny8_core_auth', 'Sector'),
|
||||||
('igny8_core_auth', 'SiteUserAccess'),
|
('igny8_core_auth', 'SiteUserAccess'),
|
||||||
|
('igny8_core_auth', 'Plan'),
|
||||||
|
('igny8_core_auth', 'Subscription'),
|
||||||
('igny8_core_auth', 'PasswordResetToken'),
|
('igny8_core_auth', 'PasswordResetToken'),
|
||||||
],
|
|
||||||
},
|
|
||||||
'Global Reference Data': {
|
|
||||||
'models': [
|
|
||||||
('igny8_core_auth', 'Industry'),
|
('igny8_core_auth', 'Industry'),
|
||||||
('igny8_core_auth', 'IndustrySector'),
|
('igny8_core_auth', 'IndustrySector'),
|
||||||
('igny8_core_auth', 'SeedKeyword'),
|
('igny8_core_auth', 'SeedKeyword'),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'Planner': {
|
'Billing & Tenancy': {
|
||||||
'models': [
|
'models': [
|
||||||
('planner', 'Keywords'),
|
('billing', 'Invoice'),
|
||||||
('planner', 'Clusters'),
|
('billing', 'Payment'),
|
||||||
('planner', 'ContentIdeas'),
|
('billing', 'CreditTransaction'),
|
||||||
|
('billing', 'CreditUsageLog'),
|
||||||
|
('billing', 'CreditPackage'),
|
||||||
|
('billing', 'PaymentMethodConfig'),
|
||||||
|
('billing', 'AccountPaymentMethod'),
|
||||||
|
('billing', 'CreditCostConfig'),
|
||||||
|
('billing', 'PlanLimitUsage'),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'Writer Module': {
|
'Writer Module': {
|
||||||
'models': [
|
'models': [
|
||||||
('writer', 'Tasks'),
|
|
||||||
('writer', 'Content'),
|
('writer', 'Content'),
|
||||||
|
('writer', 'Tasks'),
|
||||||
('writer', 'Images'),
|
('writer', 'Images'),
|
||||||
|
('writer', 'ContentTaxonomy'),
|
||||||
|
('writer', 'ContentAttribute'),
|
||||||
|
('writer', 'ContentTaxonomyRelation'),
|
||||||
|
('writer', 'ContentClusterMap'),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'Thinker Module': {
|
'Planner': {
|
||||||
'models': [
|
'models': [
|
||||||
('system', 'AIPrompt'),
|
('planner', 'Clusters'),
|
||||||
('system', 'AuthorProfile'),
|
('planner', 'Keywords'),
|
||||||
('system', 'Strategy'),
|
('planner', 'ContentIdeas'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Publishing': {
|
||||||
|
'models': [
|
||||||
|
('publishing', 'PublishingRecord'),
|
||||||
|
('publishing', 'DeploymentRecord'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Optimization': {
|
||||||
|
'models': [
|
||||||
|
('optimization', 'OptimizationTask'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Automation': {
|
||||||
|
'models': [
|
||||||
|
('automation', 'AutomationConfig'),
|
||||||
|
('automation', 'AutomationRun'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Integration': {
|
||||||
|
'models': [
|
||||||
|
('integration', 'SiteIntegration'),
|
||||||
|
('integration', 'SyncEvent'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'AI Framework': {
|
||||||
|
'models': [
|
||||||
|
('ai', 'AITaskLog'),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'System Configuration': {
|
'System Configuration': {
|
||||||
'models': [
|
'models': [
|
||||||
('system', 'IntegrationSettings'),
|
('system', 'AIPrompt'),
|
||||||
('system', 'SystemLog'),
|
('system', 'Strategy'),
|
||||||
('system', 'SystemStatus'),
|
('system', 'AuthorProfile'),
|
||||||
('system', 'SystemSettings'),
|
('system', 'ContentTemplate'),
|
||||||
('system', 'AccountSettings'),
|
('system', 'TaxonomyConfig'),
|
||||||
('system', 'UserSettings'),
|
('system', 'SystemSetting'),
|
||||||
('system', 'ModuleSettings'),
|
('system', 'ContentTypeConfig'),
|
||||||
('system', 'AISettings'),
|
('system', 'PublishingChannel'),
|
||||||
|
('system', 'APIKey'),
|
||||||
|
('system', 'WebhookConfig'),
|
||||||
|
('system', 'NotificationConfig'),
|
||||||
|
('system', 'AuditLog'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Celery Results': {
|
||||||
|
'models': [
|
||||||
|
('django_celery_results', 'TaskResult'),
|
||||||
|
('django_celery_results', 'GroupResult'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Content Types': {
|
||||||
|
'models': [
|
||||||
|
('contenttypes', 'ContentType'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Administration': {
|
||||||
|
'models': [
|
||||||
|
('admin', 'LogEntry'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Authentication and Authorization': {
|
||||||
|
'models': [
|
||||||
|
('auth', 'Group'),
|
||||||
|
('auth', 'Permission'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Sessions': {
|
||||||
|
'models': [
|
||||||
|
('sessions', 'Session'),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build the custom app list
|
# ALWAYS build and return our custom organized app list
|
||||||
app_list = []
|
# regardless of app_label parameter (for consistent sidebar on all pages)
|
||||||
|
organized_apps = []
|
||||||
|
|
||||||
|
# Add Dashboard link as first item
|
||||||
|
organized_apps.append({
|
||||||
|
'name': '📊 Dashboard',
|
||||||
|
'app_label': '_dashboard',
|
||||||
|
'app_url': '/admin/dashboard/',
|
||||||
|
'has_module_perms': True,
|
||||||
|
'models': [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add Reports section with links to all reports
|
||||||
|
organized_apps.append({
|
||||||
|
'name': 'Reports & Analytics',
|
||||||
|
'app_label': '_reports',
|
||||||
|
'app_url': '#',
|
||||||
|
'has_module_perms': True,
|
||||||
|
'models': [
|
||||||
|
{
|
||||||
|
'name': 'Revenue Report',
|
||||||
|
'object_name': 'RevenueReport',
|
||||||
|
'admin_url': '/admin/reports/revenue/',
|
||||||
|
'view_only': True,
|
||||||
|
'perms': {'view': True},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Usage Report',
|
||||||
|
'object_name': 'UsageReport',
|
||||||
|
'admin_url': '/admin/reports/usage/',
|
||||||
|
'view_only': True,
|
||||||
|
'perms': {'view': True},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Content Report',
|
||||||
|
'object_name': 'ContentReport',
|
||||||
|
'admin_url': '/admin/reports/content/',
|
||||||
|
'view_only': True,
|
||||||
|
'perms': {'view': True},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Data Quality Report',
|
||||||
|
'object_name': 'DataQualityReport',
|
||||||
|
'admin_url': '/admin/reports/data-quality/',
|
||||||
|
'view_only': True,
|
||||||
|
'perms': {'view': True},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
for group_name, group_config in custom_groups.items():
|
for group_name, group_config in custom_groups.items():
|
||||||
group_models = []
|
group_models = []
|
||||||
|
|
||||||
for app_label, model_name in group_config['models']:
|
for app_label, model_name in group_config['models']:
|
||||||
# Find the model in app_dict
|
# Find the model in app_dict
|
||||||
if app_label in app_dict:
|
for app in app_dict.values():
|
||||||
app_data = app_dict[app_label]
|
if app['app_label'] == app_label:
|
||||||
# Look for the model in the app's models
|
for model in app.get('models', []):
|
||||||
for model in app_data.get('models', []):
|
|
||||||
if model['object_name'] == model_name:
|
if model['object_name'] == model_name:
|
||||||
group_models.append(model)
|
group_models.append(model)
|
||||||
break
|
break
|
||||||
|
|
||||||
# Only add the group if it has models
|
|
||||||
if group_models:
|
if group_models:
|
||||||
app_list.append({
|
# Get the first model's app_label to use as the real app_label
|
||||||
|
first_model_app_label = group_config['models'][0][0]
|
||||||
|
organized_apps.append({
|
||||||
'name': group_name,
|
'name': group_name,
|
||||||
'app_label': group_name.lower().replace(' ', '_').replace('&', ''),
|
'app_label': first_model_app_label, # Use real app_label, not fake one
|
||||||
'app_url': None,
|
'app_url': f'/admin/{first_model_app_label}/', # Real URL, not '#'
|
||||||
'has_module_perms': True,
|
'has_module_perms': True,
|
||||||
'models': group_models,
|
'models': group_models,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort the app list by our custom order
|
return organized_apps
|
||||||
order = [
|
|
||||||
'Billing & Tenancy',
|
|
||||||
'Sites & Users',
|
|
||||||
'Global Reference Data',
|
|
||||||
'Planner',
|
|
||||||
'Writer Module',
|
|
||||||
'Thinker Module',
|
|
||||||
'System Configuration',
|
|
||||||
]
|
|
||||||
|
|
||||||
app_list.sort(key=lambda x: order.index(x['name']) if x['name'] in order else 999)
|
|
||||||
|
|
||||||
return app_list
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Instantiate custom admin site
|
||||||
|
admin_site = Igny8AdminSite(name='admin')
|
||||||
|
|||||||
179
backend/igny8_core/admin/site_backup.py
Normal file
179
backend/igny8_core/admin/site_backup.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
Custom AdminSite for IGNY8 to organize models into proper groups using Unfold
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.admin.apps import AdminConfig
|
||||||
|
from django.apps import apps
|
||||||
|
from django.urls import path, reverse_lazy
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
||||||
|
from unfold.sites import UnfoldAdminSite
|
||||||
|
|
||||||
|
|
||||||
|
class Igny8AdminSite(UnfoldAdminSite):
|
||||||
|
"""
|
||||||
|
Custom AdminSite based on Unfold that organizes models into the planned groups
|
||||||
|
"""
|
||||||
|
site_header = 'IGNY8 Administration'
|
||||||
|
site_title = 'IGNY8 Admin'
|
||||||
|
index_title = 'IGNY8 Administration'
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
"""Get admin URLs without custom dashboard"""
|
||||||
|
urls = super().get_urls()
|
||||||
|
return urls
|
||||||
|
|
||||||
|
def get_app_list(self, request):
|
||||||
|
"""
|
||||||
|
Customize the app list to organize models into logical groups
|
||||||
|
"""
|
||||||
|
# Get the default app list
|
||||||
|
app_dict = self._build_app_dict(request)
|
||||||
|
|
||||||
|
# Define our custom groups with their models (using object_name)
|
||||||
|
# Organized by business function with emoji icons for visual recognition
|
||||||
|
custom_groups = {
|
||||||
|
'💰 Billing & Accounts': {
|
||||||
|
'models': [
|
||||||
|
('igny8_core_auth', 'Plan'),
|
||||||
|
('billing', 'PlanLimitUsage'),
|
||||||
|
('igny8_core_auth', 'Account'),
|
||||||
|
('igny8_core_auth', 'Subscription'),
|
||||||
|
('billing', 'Invoice'),
|
||||||
|
('billing', 'Payment'),
|
||||||
|
('billing', 'CreditTransaction'),
|
||||||
|
('billing', 'CreditUsageLog'),
|
||||||
|
('billing', 'CreditPackage'),
|
||||||
|
('billing', 'PaymentMethodConfig'),
|
||||||
|
('billing', 'AccountPaymentMethod'),
|
||||||
|
('billing', 'CreditCostConfig'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'👥 Sites & Users': {
|
||||||
|
'models': [
|
||||||
|
('igny8_core_auth', 'Site'),
|
||||||
|
('igny8_core_auth', 'Sector'),
|
||||||
|
('igny8_core_auth', 'User'),
|
||||||
|
('igny8_core_auth', 'SiteUserAccess'),
|
||||||
|
('igny8_core_auth', 'PasswordResetToken'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'📚 Content Management': {
|
||||||
|
'models': [
|
||||||
|
('writer', 'Content'),
|
||||||
|
('writer', 'Tasks'),
|
||||||
|
('writer', 'Images'),
|
||||||
|
('writer', 'ContentTaxonomy'),
|
||||||
|
('writer', 'ContentAttribute'),
|
||||||
|
('writer', 'ContentTaxonomyRelation'),
|
||||||
|
('writer', 'ContentClusterMap'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'🎯 Planning & Strategy': {
|
||||||
|
'models': [
|
||||||
|
('planner', 'Clusters'),
|
||||||
|
('planner', 'Keywords'),
|
||||||
|
('planner', 'ContentIdeas'),
|
||||||
|
('system', 'Strategy'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'🔗 Integrations & Publishing': {
|
||||||
|
'models': [
|
||||||
|
('integration', 'SiteIntegration'),
|
||||||
|
('integration', 'SyncEvent'),
|
||||||
|
('publishing', 'PublishingRecord'),
|
||||||
|
('publishing', 'DeploymentRecord'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'🤖 AI & Automation': {
|
||||||
|
'models': [
|
||||||
|
('ai', 'AITaskLog'),
|
||||||
|
('system', 'AIPrompt'),
|
||||||
|
('automation', 'AutomationConfig'),
|
||||||
|
('automation', 'AutomationRun'),
|
||||||
|
('optimization', 'OptimizationTask'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'🌍 Global Reference Data': {
|
||||||
|
'models': [
|
||||||
|
('igny8_core_auth', 'Industry'),
|
||||||
|
('igny8_core_auth', 'IndustrySector'),
|
||||||
|
('igny8_core_auth', 'SeedKeyword'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'⚙️ System Configuration': {
|
||||||
|
'models': [
|
||||||
|
('system', 'IntegrationSettings'),
|
||||||
|
('system', 'AuthorProfile'),
|
||||||
|
('system', 'SystemSettings'),
|
||||||
|
('system', 'AccountSettings'),
|
||||||
|
('system', 'UserSettings'),
|
||||||
|
('system', 'ModuleSettings'),
|
||||||
|
('system', 'AISettings'),
|
||||||
|
('system', 'ModuleEnableSettings'),
|
||||||
|
('system', 'SystemLog'),
|
||||||
|
('system', 'SystemStatus'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'<EFBFBD> Monitoring & Tasks': {
|
||||||
|
'models': [
|
||||||
|
('django_celery_results', 'TaskResult'),
|
||||||
|
('django_celery_results', 'GroupResult'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'<EFBFBD>🔧 Django System': {
|
||||||
|
'models': [
|
||||||
|
('admin', 'LogEntry'),
|
||||||
|
('auth', 'Group'),
|
||||||
|
('auth', 'Permission'),
|
||||||
|
('contenttypes', 'ContentType'),
|
||||||
|
('sessions', 'Session'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the custom app list
|
||||||
|
app_list = []
|
||||||
|
|
||||||
|
for group_name, group_config in custom_groups.items():
|
||||||
|
group_models = []
|
||||||
|
|
||||||
|
for app_label, model_name in group_config['models']:
|
||||||
|
# Find the model in app_dict
|
||||||
|
if app_label in app_dict:
|
||||||
|
app_data = app_dict[app_label]
|
||||||
|
# Look for the model in the app's models
|
||||||
|
for model in app_data.get('models', []):
|
||||||
|
if model['object_name'] == model_name:
|
||||||
|
group_models.append(model)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Only add the group if it has models
|
||||||
|
if group_models:
|
||||||
|
app_list.append({
|
||||||
|
'name': group_name,
|
||||||
|
'app_label': group_name.lower().replace(' ', '_').replace('&', '').replace('emoji', ''),
|
||||||
|
'app_url': None,
|
||||||
|
'has_module_perms': True,
|
||||||
|
'models': group_models,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort the app list by our custom order
|
||||||
|
order = [
|
||||||
|
'💰 Billing & Accounts',
|
||||||
|
'👥 Sites & Users',
|
||||||
|
'📚 Content Management',
|
||||||
|
'🎯 Planning & Strategy',
|
||||||
|
'🔗 Integrations & Publishing',
|
||||||
|
'🤖 AI & Automation',
|
||||||
|
'🌍 Global Reference Data',
|
||||||
|
'⚙️ System Configuration',
|
||||||
|
'🔧 Django System',
|
||||||
|
]
|
||||||
|
|
||||||
|
app_list.sort(key=lambda x: order.index(x['name']) if x['name'] in order else 999)
|
||||||
|
|
||||||
|
return app_list
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
179
backend/igny8_core/admin/site_old.py
Normal file
179
backend/igny8_core/admin/site_old.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
Custom AdminSite for IGNY8 to organize models into proper groups using Unfold
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.admin.apps import AdminConfig
|
||||||
|
from django.apps import apps
|
||||||
|
from django.urls import path, reverse_lazy
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
||||||
|
from unfold.sites import UnfoldAdminSite
|
||||||
|
|
||||||
|
|
||||||
|
class Igny8AdminSite(UnfoldAdminSite):
|
||||||
|
"""
|
||||||
|
Custom AdminSite based on Unfold that organizes models into the planned groups
|
||||||
|
"""
|
||||||
|
site_header = 'IGNY8 Administration'
|
||||||
|
site_title = 'IGNY8 Admin'
|
||||||
|
index_title = 'IGNY8 Administration'
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
"""Get admin URLs without custom dashboard"""
|
||||||
|
urls = super().get_urls()
|
||||||
|
return urls
|
||||||
|
|
||||||
|
def get_app_list(self, request):
|
||||||
|
"""
|
||||||
|
Customize the app list to organize models into logical groups
|
||||||
|
"""
|
||||||
|
# Get the default app list
|
||||||
|
app_dict = self._build_app_dict(request)
|
||||||
|
|
||||||
|
# Define our custom groups with their models (using object_name)
|
||||||
|
# Organized by business function with emoji icons for visual recognition
|
||||||
|
custom_groups = {
|
||||||
|
'💰 Billing & Accounts': {
|
||||||
|
'models': [
|
||||||
|
('igny8_core_auth', 'Plan'),
|
||||||
|
('billing', 'PlanLimitUsage'),
|
||||||
|
('igny8_core_auth', 'Account'),
|
||||||
|
('igny8_core_auth', 'Subscription'),
|
||||||
|
('billing', 'Invoice'),
|
||||||
|
('billing', 'Payment'),
|
||||||
|
('billing', 'CreditTransaction'),
|
||||||
|
('billing', 'CreditUsageLog'),
|
||||||
|
('billing', 'CreditPackage'),
|
||||||
|
('billing', 'PaymentMethodConfig'),
|
||||||
|
('billing', 'AccountPaymentMethod'),
|
||||||
|
('billing', 'CreditCostConfig'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'👥 Sites & Users': {
|
||||||
|
'models': [
|
||||||
|
('igny8_core_auth', 'Site'),
|
||||||
|
('igny8_core_auth', 'Sector'),
|
||||||
|
('igny8_core_auth', 'User'),
|
||||||
|
('igny8_core_auth', 'SiteUserAccess'),
|
||||||
|
('igny8_core_auth', 'PasswordResetToken'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'📚 Content Management': {
|
||||||
|
'models': [
|
||||||
|
('writer', 'Content'),
|
||||||
|
('writer', 'Tasks'),
|
||||||
|
('writer', 'Images'),
|
||||||
|
('writer', 'ContentTaxonomy'),
|
||||||
|
('writer', 'ContentAttribute'),
|
||||||
|
('writer', 'ContentTaxonomyRelation'),
|
||||||
|
('writer', 'ContentClusterMap'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'🎯 Planning & Strategy': {
|
||||||
|
'models': [
|
||||||
|
('planner', 'Clusters'),
|
||||||
|
('planner', 'Keywords'),
|
||||||
|
('planner', 'ContentIdeas'),
|
||||||
|
('system', 'Strategy'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'🔗 Integrations & Publishing': {
|
||||||
|
'models': [
|
||||||
|
('integration', 'SiteIntegration'),
|
||||||
|
('integration', 'SyncEvent'),
|
||||||
|
('publishing', 'PublishingRecord'),
|
||||||
|
('publishing', 'DeploymentRecord'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'🤖 AI & Automation': {
|
||||||
|
'models': [
|
||||||
|
('ai', 'AITaskLog'),
|
||||||
|
('system', 'AIPrompt'),
|
||||||
|
('automation', 'AutomationConfig'),
|
||||||
|
('automation', 'AutomationRun'),
|
||||||
|
('optimization', 'OptimizationTask'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'🌍 Global Reference Data': {
|
||||||
|
'models': [
|
||||||
|
('igny8_core_auth', 'Industry'),
|
||||||
|
('igny8_core_auth', 'IndustrySector'),
|
||||||
|
('igny8_core_auth', 'SeedKeyword'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'⚙️ System Configuration': {
|
||||||
|
'models': [
|
||||||
|
('system', 'IntegrationSettings'),
|
||||||
|
('system', 'AuthorProfile'),
|
||||||
|
('system', 'SystemSettings'),
|
||||||
|
('system', 'AccountSettings'),
|
||||||
|
('system', 'UserSettings'),
|
||||||
|
('system', 'ModuleSettings'),
|
||||||
|
('system', 'AISettings'),
|
||||||
|
('system', 'ModuleEnableSettings'),
|
||||||
|
('system', 'SystemLog'),
|
||||||
|
('system', 'SystemStatus'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'<EFBFBD> Monitoring & Tasks': {
|
||||||
|
'models': [
|
||||||
|
('django_celery_results', 'TaskResult'),
|
||||||
|
('django_celery_results', 'GroupResult'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'<EFBFBD>🔧 Django System': {
|
||||||
|
'models': [
|
||||||
|
('admin', 'LogEntry'),
|
||||||
|
('auth', 'Group'),
|
||||||
|
('auth', 'Permission'),
|
||||||
|
('contenttypes', 'ContentType'),
|
||||||
|
('sessions', 'Session'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the custom app list
|
||||||
|
app_list = []
|
||||||
|
|
||||||
|
for group_name, group_config in custom_groups.items():
|
||||||
|
group_models = []
|
||||||
|
|
||||||
|
for app_label, model_name in group_config['models']:
|
||||||
|
# Find the model in app_dict
|
||||||
|
if app_label in app_dict:
|
||||||
|
app_data = app_dict[app_label]
|
||||||
|
# Look for the model in the app's models
|
||||||
|
for model in app_data.get('models', []):
|
||||||
|
if model['object_name'] == model_name:
|
||||||
|
group_models.append(model)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Only add the group if it has models
|
||||||
|
if group_models:
|
||||||
|
app_list.append({
|
||||||
|
'name': group_name,
|
||||||
|
'app_label': group_name.lower().replace(' ', '_').replace('&', '').replace('emoji', ''),
|
||||||
|
'app_url': None,
|
||||||
|
'has_module_perms': True,
|
||||||
|
'models': group_models,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort the app list by our custom order
|
||||||
|
order = [
|
||||||
|
'💰 Billing & Accounts',
|
||||||
|
'👥 Sites & Users',
|
||||||
|
'📚 Content Management',
|
||||||
|
'🎯 Planning & Strategy',
|
||||||
|
'🔗 Integrations & Publishing',
|
||||||
|
'🤖 AI & Automation',
|
||||||
|
'🌍 Global Reference Data',
|
||||||
|
'⚙️ System Configuration',
|
||||||
|
'🔧 Django System',
|
||||||
|
]
|
||||||
|
|
||||||
|
app_list.sort(key=lambda x: order.index(x['name']) if x['name'] in order else 999)
|
||||||
|
|
||||||
|
return app_list
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
Admin configuration for AI models
|
Admin configuration for AI models
|
||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from unfold.admin import ModelAdmin
|
||||||
|
from igny8_core.admin.base import Igny8ModelAdmin
|
||||||
from igny8_core.ai.models import AITaskLog
|
from igny8_core.ai.models import AITaskLog
|
||||||
|
|
||||||
|
|
||||||
@admin.register(AITaskLog)
|
@admin.register(AITaskLog)
|
||||||
class AITaskLogAdmin(admin.ModelAdmin):
|
class AITaskLogAdmin(Igny8ModelAdmin):
|
||||||
"""Admin interface for AI task logs"""
|
"""Admin interface for AI task logs"""
|
||||||
list_display = [
|
list_display = [
|
||||||
'function_name',
|
'function_name',
|
||||||
|
|||||||
@@ -43,32 +43,48 @@ class AICore:
|
|||||||
self._load_account_settings()
|
self._load_account_settings()
|
||||||
|
|
||||||
def _load_account_settings(self):
|
def _load_account_settings(self):
|
||||||
"""Load API keys and model from IntegrationSettings or Django settings"""
|
"""Load API keys from IntegrationSettings with fallbacks (account -> system account -> Django settings)"""
|
||||||
if self.account:
|
def get_system_account():
|
||||||
|
try:
|
||||||
|
from igny8_core.auth.models import Account
|
||||||
|
for slug in ['aws-admin', 'default-account', 'default']:
|
||||||
|
acct = Account.objects.filter(slug=slug).first()
|
||||||
|
if acct:
|
||||||
|
return acct
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_integration_key(integration_type: str, account):
|
||||||
|
if not account:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
from igny8_core.modules.system.models import IntegrationSettings
|
from igny8_core.modules.system.models import IntegrationSettings
|
||||||
|
settings_obj = IntegrationSettings.objects.filter(
|
||||||
# Load OpenAI settings
|
integration_type=integration_type,
|
||||||
openai_settings = IntegrationSettings.objects.filter(
|
account=account,
|
||||||
integration_type='openai',
|
|
||||||
account=self.account,
|
|
||||||
is_active=True
|
is_active=True
|
||||||
).first()
|
).first()
|
||||||
if openai_settings and openai_settings.config:
|
if settings_obj and settings_obj.config:
|
||||||
self._openai_api_key = openai_settings.config.get('apiKey')
|
return settings_obj.config.get('apiKey')
|
||||||
|
|
||||||
# Load Runware settings
|
|
||||||
runware_settings = IntegrationSettings.objects.filter(
|
|
||||||
integration_type='runware',
|
|
||||||
account=self.account,
|
|
||||||
is_active=True
|
|
||||||
).first()
|
|
||||||
if runware_settings and runware_settings.config:
|
|
||||||
self._runware_api_key = runware_settings.config.get('apiKey')
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not load account settings: {e}", exc_info=True)
|
logger.warning(f"Could not load {integration_type} settings for account {getattr(account, 'id', None)}: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
# Fallback to Django settings for API keys only (no model fallback)
|
# 1) Account-specific keys
|
||||||
|
if self.account:
|
||||||
|
self._openai_api_key = get_integration_key('openai', self.account)
|
||||||
|
self._runware_api_key = get_integration_key('runware', self.account)
|
||||||
|
|
||||||
|
# 2) Fallback to system account keys (shared across tenants)
|
||||||
|
if not self._openai_api_key or not self._runware_api_key:
|
||||||
|
system_account = get_system_account()
|
||||||
|
if not self._openai_api_key:
|
||||||
|
self._openai_api_key = get_integration_key('openai', system_account)
|
||||||
|
if not self._runware_api_key:
|
||||||
|
self._runware_api_key = get_integration_key('runware', system_account)
|
||||||
|
|
||||||
|
# 3) Fallback to Django settings
|
||||||
if not self._openai_api_key:
|
if not self._openai_api_key:
|
||||||
self._openai_api_key = getattr(settings, 'OPENAI_API_KEY', None)
|
self._openai_api_key = getattr(settings, 'OPENAI_API_KEY', None)
|
||||||
if not self._runware_api_key:
|
if not self._runware_api_key:
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ class AIEngine:
|
|||||||
return f"{count} task{'s' if count != 1 else ''}"
|
return f"{count} task{'s' if count != 1 else ''}"
|
||||||
elif function_name == 'generate_images':
|
elif function_name == 'generate_images':
|
||||||
return f"{count} task{'s' if count != 1 else ''}"
|
return f"{count} task{'s' if count != 1 else ''}"
|
||||||
|
elif function_name == 'generate_site_structure':
|
||||||
|
return "1 site blueprint"
|
||||||
return f"{count} item{'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:
|
def _build_validation_message(self, function_name: str, payload: dict, count: int, input_description: str) -> str:
|
||||||
@@ -80,6 +82,13 @@ class AIEngine:
|
|||||||
total_images = 1 + max_images
|
total_images = 1 + max_images
|
||||||
return f"Mapping Content for {total_images} Image Prompts"
|
return f"Mapping Content for {total_images} Image Prompts"
|
||||||
return f"Mapping Content for 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()
|
||||||
return f"Preparing {count} item{'s' if count != 1 else ''}"
|
return f"Preparing {count} item{'s' if count != 1 else ''}"
|
||||||
|
|
||||||
def _get_ai_call_message(self, function_name: str, count: int) -> str:
|
def _get_ai_call_message(self, function_name: str, count: int) -> str:
|
||||||
@@ -92,6 +101,8 @@ class AIEngine:
|
|||||||
return f"Writing article{'s' if count != 1 else ''} with AI"
|
return f"Writing article{'s' if count != 1 else ''} with AI"
|
||||||
elif function_name == 'generate_images':
|
elif function_name == 'generate_images':
|
||||||
return f"Creating image{'s' if count != 1 else ''} with AI"
|
return f"Creating image{'s' if count != 1 else ''} with AI"
|
||||||
|
elif function_name == 'generate_site_structure':
|
||||||
|
return "Designing complete site architecture"
|
||||||
return f"Processing with AI"
|
return f"Processing with AI"
|
||||||
|
|
||||||
def _get_parse_message(self, function_name: str) -> str:
|
def _get_parse_message(self, function_name: str) -> str:
|
||||||
@@ -104,6 +115,8 @@ class AIEngine:
|
|||||||
return "Formatting content"
|
return "Formatting content"
|
||||||
elif function_name == 'generate_images':
|
elif function_name == 'generate_images':
|
||||||
return "Processing images"
|
return "Processing images"
|
||||||
|
elif function_name == 'generate_site_structure':
|
||||||
|
return "Compiling site map"
|
||||||
return "Processing results"
|
return "Processing results"
|
||||||
|
|
||||||
def _get_parse_message_with_count(self, function_name: str, count: int) -> str:
|
def _get_parse_message_with_count(self, function_name: str, count: int) -> str:
|
||||||
@@ -122,6 +135,8 @@ class AIEngine:
|
|||||||
if in_article_count > 0:
|
if in_article_count > 0:
|
||||||
return f"Writing {in_article_count} In‑article Image Prompts"
|
return f"Writing {in_article_count} In‑article Image Prompts"
|
||||||
return "Writing 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"
|
||||||
return f"{count} item{'s' if count != 1 else ''} processed"
|
return f"{count} item{'s' if count != 1 else ''} processed"
|
||||||
|
|
||||||
def _get_save_message(self, function_name: str, count: int) -> str:
|
def _get_save_message(self, function_name: str, count: int) -> str:
|
||||||
@@ -137,6 +152,8 @@ class AIEngine:
|
|||||||
elif function_name == 'generate_image_prompts':
|
elif function_name == 'generate_image_prompts':
|
||||||
# Count is total prompts created
|
# Count is total prompts created
|
||||||
return f"Assigning {count} Prompts to Dedicated Slots"
|
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 ''}"
|
||||||
return f"Saving {count} item{'s' if count != 1 else ''}"
|
return f"Saving {count} item{'s' if count != 1 else ''}"
|
||||||
|
|
||||||
def execute(self, fn: BaseAIFunction, payload: dict) -> dict:
|
def execute(self, fn: BaseAIFunction, payload: dict) -> dict:
|
||||||
@@ -192,6 +209,31 @@ class AIEngine:
|
|||||||
self.step_tracker.add_request_step("PREP", "success", prep_message)
|
self.step_tracker.add_request_step("PREP", "success", prep_message)
|
||||||
self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta())
|
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%)
|
# Phase 3: AI_CALL - Provider API Call (25-70%)
|
||||||
# Validate account exists before proceeding
|
# Validate account exists before proceeding
|
||||||
if not self.account:
|
if not self.account:
|
||||||
@@ -325,37 +367,45 @@ class AIEngine:
|
|||||||
# Store save_msg for use in DONE phase
|
# Store save_msg for use in DONE phase
|
||||||
final_save_msg = save_msg
|
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:
|
if self.account and raw_response:
|
||||||
try:
|
try:
|
||||||
from igny8_core.modules.billing.services import CreditService
|
from igny8_core.business.billing.services.credit_service import CreditService
|
||||||
from igny8_core.modules.billing.models import CreditUsageLog
|
from igny8_core.business.billing.exceptions import InsufficientCreditsError
|
||||||
|
|
||||||
# Calculate credits used (based on tokens or fixed cost)
|
# Map function name to operation type
|
||||||
credits_used = self._calculate_credits_for_clustering(
|
operation_type = self._get_operation_type(function_name)
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log credit usage (don't deduct from account.credits, just log)
|
# Calculate actual amount based on results
|
||||||
CreditUsageLog.objects.create(
|
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,
|
account=self.account,
|
||||||
operation_type='clustering',
|
operation_type=operation_type,
|
||||||
credits_used=credits_used,
|
amount=actual_amount,
|
||||||
cost_usd=raw_response.get('cost'),
|
cost_usd=raw_response.get('cost'),
|
||||||
model_used=raw_response.get('model', ''),
|
model_used=raw_response.get('model', ''),
|
||||||
tokens_input=raw_response.get('tokens_input', 0),
|
tokens_input=raw_response.get('tokens_input', 0),
|
||||||
tokens_output=raw_response.get('tokens_output', 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={
|
metadata={
|
||||||
|
'function_name': function_name,
|
||||||
'clusters_created': clusters_created,
|
'clusters_created': clusters_created,
|
||||||
'keywords_updated': keywords_updated,
|
'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:
|
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%)
|
# Phase 6: DONE - Finalization (98-100%)
|
||||||
success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully"
|
success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully"
|
||||||
@@ -453,18 +503,86 @@ class AIEngine:
|
|||||||
# Don't fail the task if logging fails
|
# Don't fail the task if logging fails
|
||||||
logger.warning(f"Failed to log to database: {e}")
|
logger.warning(f"Failed to log to database: {e}")
|
||||||
|
|
||||||
def _calculate_credits_for_clustering(self, keyword_count, tokens, cost):
|
def _get_operation_type(self, function_name):
|
||||||
"""Calculate credits used for clustering operation"""
|
"""Map function name to operation type for credit system"""
|
||||||
# Use plan's cost per request if available, otherwise calculate from tokens
|
mapping = {
|
||||||
if self.account and hasattr(self.account, 'plan') and self.account.plan:
|
'auto_cluster': 'clustering',
|
||||||
plan = self.account.plan
|
'generate_ideas': 'idea_generation',
|
||||||
# Check if plan has ai_cost_per_request config
|
'generate_content': 'content_generation',
|
||||||
if hasattr(plan, 'ai_cost_per_request') and plan.ai_cost_per_request:
|
'generate_image_prompts': 'image_prompt_extraction',
|
||||||
cluster_cost = plan.ai_cost_per_request.get('cluster', 0)
|
'generate_images': 'image_generation',
|
||||||
if cluster_cost:
|
'generate_site_structure': 'site_structure_generation',
|
||||||
return int(cluster_cost)
|
}
|
||||||
|
return mapping.get(function_name, function_name)
|
||||||
|
|
||||||
# Fallback: 1 credit per 30 keywords (minimum 1)
|
def _get_estimated_amount(self, function_name, data, payload):
|
||||||
credits = max(1, int(keyword_count / 30))
|
"""Get estimated amount for credit calculation (before operation)"""
|
||||||
return credits
|
if function_name == 'generate_content':
|
||||||
|
# Estimate word count - tasks don't have word_count field, use default
|
||||||
|
# data is a list of Task objects
|
||||||
|
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':
|
||||||
|
# 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')
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class AutoClusterFunction(BaseAIFunction):
|
|||||||
def validate(self, payload: dict, account=None) -> Dict:
|
def validate(self, payload: dict, account=None) -> Dict:
|
||||||
"""Custom validation for clustering"""
|
"""Custom validation for clustering"""
|
||||||
from igny8_core.ai.validators import validate_ids, validate_keywords_exist
|
from igny8_core.ai.validators import validate_ids, validate_keywords_exist
|
||||||
|
from igny8_core.ai.validators.cluster_validators import validate_minimum_keywords
|
||||||
|
|
||||||
# Base validation (no max_items limit)
|
# Base validation (no max_items limit)
|
||||||
result = validate_ids(payload, max_items=None)
|
result = validate_ids(payload, max_items=None)
|
||||||
@@ -52,6 +53,21 @@ class AutoClusterFunction(BaseAIFunction):
|
|||||||
if not keywords_result['valid']:
|
if not keywords_result['valid']:
|
||||||
return keywords_result
|
return keywords_result
|
||||||
|
|
||||||
|
# NEW: Validate minimum keywords (5 required for meaningful clustering)
|
||||||
|
min_validation = validate_minimum_keywords(
|
||||||
|
keyword_ids=ids,
|
||||||
|
account=account,
|
||||||
|
min_required=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if not min_validation['valid']:
|
||||||
|
logger.warning(f"[AutoCluster] Validation failed: {min_validation['error']}")
|
||||||
|
return min_validation
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AutoCluster] Validation passed: {min_validation['count']} keywords available (min: {min_validation['required']})"
|
||||||
|
)
|
||||||
|
|
||||||
# Removed plan limits check
|
# Removed plan limits check
|
||||||
|
|
||||||
return {'valid': True}
|
return {'valid': True}
|
||||||
@@ -249,7 +265,7 @@ class AutoClusterFunction(BaseAIFunction):
|
|||||||
sector=sector,
|
sector=sector,
|
||||||
defaults={
|
defaults={
|
||||||
'description': cluster_data.get('description', ''),
|
'description': cluster_data.get('description', ''),
|
||||||
'status': 'active',
|
'status': 'new', # FIXED: Changed from 'active' to 'new'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -260,7 +276,7 @@ class AutoClusterFunction(BaseAIFunction):
|
|||||||
sector__isnull=True,
|
sector__isnull=True,
|
||||||
defaults={
|
defaults={
|
||||||
'description': cluster_data.get('description', ''),
|
'description': cluster_data.get('description', ''),
|
||||||
'status': 'active',
|
'status': 'new', # FIXED: Changed from 'active' to 'new'
|
||||||
'sector': None,
|
'sector': None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -292,9 +308,10 @@ class AutoClusterFunction(BaseAIFunction):
|
|||||||
else:
|
else:
|
||||||
keyword_filter = keyword_filter.filter(sector__isnull=True)
|
keyword_filter = keyword_filter.filter(sector__isnull=True)
|
||||||
|
|
||||||
|
# FIXED: Ensure keywords status updates from 'new' to 'mapped'
|
||||||
updated_count = keyword_filter.update(
|
updated_count = keyword_filter.update(
|
||||||
cluster=cluster,
|
cluster=cluster,
|
||||||
status='mapped'
|
status='mapped' # Status changes from 'new' to 'mapped'
|
||||||
)
|
)
|
||||||
keywords_updated += updated_count
|
keywords_updated += updated_count
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Generate Content AI Function
|
Generate Content AI Function
|
||||||
Extracted from modules/writer/tasks.py
|
STAGE 3: Updated to use final Stage 1 Content schema
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from igny8_core.ai.base import BaseAIFunction
|
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.business.content.models import ContentTaxonomy
|
||||||
from igny8_core.ai.ai_core import AICore
|
from igny8_core.ai.ai_core import AICore
|
||||||
from igny8_core.ai.validators import validate_tasks_exist
|
from igny8_core.ai.validators import validate_tasks_exist
|
||||||
from igny8_core.ai.prompts import PromptRegistry
|
from igny8_core.ai.prompts import PromptRegistry
|
||||||
@@ -62,9 +63,9 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
if account:
|
if account:
|
||||||
queryset = queryset.filter(account=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(
|
tasks = list(queryset.select_related(
|
||||||
'account', 'site', 'sector', 'cluster', 'idea'
|
'account', 'site', 'sector', 'cluster', 'taxonomy_term'
|
||||||
))
|
))
|
||||||
|
|
||||||
if not tasks:
|
if not tasks:
|
||||||
@@ -73,9 +74,8 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
return tasks
|
return tasks
|
||||||
|
|
||||||
def build_prompt(self, data: Any, account=None) -> str:
|
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):
|
if isinstance(data, list):
|
||||||
# For now, handle single task (will be called per task)
|
|
||||||
if not data:
|
if not data:
|
||||||
raise ValueError("No tasks provided")
|
raise ValueError("No tasks provided")
|
||||||
task = data[0]
|
task = data[0]
|
||||||
@@ -89,33 +89,9 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
if task.description:
|
if task.description:
|
||||||
idea_data += f"Description: {task.description}\n"
|
idea_data += f"Description: {task.description}\n"
|
||||||
|
|
||||||
# Handle idea description (might be JSON or plain text)
|
# Add content type and structure from task
|
||||||
if task.idea and task.idea.description:
|
idea_data += f"Content Type: {task.content_type or 'post'}\n"
|
||||||
description = task.idea.description
|
idea_data += f"Content Structure: {task.content_structure or 'article'}\n"
|
||||||
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"
|
|
||||||
|
|
||||||
# Build cluster data string
|
# Build cluster data string
|
||||||
cluster_data = ''
|
cluster_data = ''
|
||||||
@@ -123,12 +99,18 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
cluster_data = f"Cluster Name: {task.cluster.name or ''}\n"
|
cluster_data = f"Cluster Name: {task.cluster.name or ''}\n"
|
||||||
if task.cluster.description:
|
if task.cluster.description:
|
||||||
cluster_data += f"Description: {task.cluster.description}\n"
|
cluster_data += f"Description: {task.cluster.description}\n"
|
||||||
cluster_data += f"Status: {task.cluster.status or 'active'}\n"
|
|
||||||
|
|
||||||
# Build keywords string
|
# STAGE 3: Build taxonomy context (from taxonomy_term FK)
|
||||||
keywords_data = task.keywords or ''
|
taxonomy_data = ''
|
||||||
if not keywords_data and task.idea:
|
if task.taxonomy_term:
|
||||||
keywords_data = task.idea.target_keywords or ''
|
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
|
# Get prompt from registry with context
|
||||||
prompt = PromptRegistry.get_prompt(
|
prompt = PromptRegistry.get_prompt(
|
||||||
@@ -138,6 +120,7 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
context={
|
context={
|
||||||
'IDEA': idea_data,
|
'IDEA': idea_data,
|
||||||
'CLUSTER': cluster_data,
|
'CLUSTER': cluster_data,
|
||||||
|
'TAXONOMY': taxonomy_data,
|
||||||
'KEYWORDS': keywords_data,
|
'KEYWORDS': keywords_data,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -176,7 +159,11 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
progress_tracker=None,
|
progress_tracker=None,
|
||||||
step_tracker=None
|
step_tracker=None
|
||||||
) -> Dict:
|
) -> 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).
|
||||||
|
Handles tags and categories from AI response.
|
||||||
|
"""
|
||||||
if isinstance(original_data, list):
|
if isinstance(original_data, list):
|
||||||
task = original_data[0] if original_data else None
|
task = original_data[0] if original_data else None
|
||||||
else:
|
else:
|
||||||
@@ -190,113 +177,158 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
# JSON response with structured fields
|
# JSON response with structured fields
|
||||||
content_html = parsed.get('content', '')
|
content_html = parsed.get('content', '')
|
||||||
title = parsed.get('title') or task.title
|
title = parsed.get('title') or task.title
|
||||||
meta_title = parsed.get('meta_title') or title or task.title
|
meta_title = parsed.get('meta_title') or parsed.get('seo_title') or title
|
||||||
meta_description = parsed.get('meta_description', '')
|
meta_description = parsed.get('meta_description') or parsed.get('seo_description')
|
||||||
word_count = parsed.get('word_count', 0)
|
primary_keyword = parsed.get('primary_keyword') or parsed.get('focus_keyword')
|
||||||
primary_keyword = parsed.get('primary_keyword', '')
|
secondary_keywords = parsed.get('secondary_keywords') or parsed.get('keywords', [])
|
||||||
secondary_keywords = parsed.get('secondary_keywords', [])
|
# Extract tags and categories from AI response
|
||||||
tags = parsed.get('tags', [])
|
tags_from_response = parsed.get('tags', [])
|
||||||
categories = parsed.get('categories', [])
|
categories_from_response = parsed.get('categories', [])
|
||||||
# Content status should always be 'draft' for newly generated content
|
|
||||||
# Status can only be changed manually to 'review' or 'publish'
|
# DEBUG: Log the full parsed response to see what we're getting
|
||||||
content_status = 'draft'
|
logger.info(f"===== GENERATE CONTENT DEBUG =====")
|
||||||
|
logger.info(f"Full parsed response keys: {list(parsed.keys())}")
|
||||||
|
logger.info(f"Tags from response (type: {type(tags_from_response)}): {tags_from_response}")
|
||||||
|
logger.info(f"Categories from response (type: {type(categories_from_response)}): {categories_from_response}")
|
||||||
|
logger.info(f"==================================")
|
||||||
else:
|
else:
|
||||||
# Plain text response (legacy)
|
# Plain text response
|
||||||
content_html = str(parsed)
|
content_html = str(parsed)
|
||||||
title = task.title
|
title = task.title
|
||||||
meta_title = task.meta_title or task.title
|
meta_title = title
|
||||||
meta_description = task.meta_description or (task.description or '')[:160] if task.description else ''
|
meta_description = None
|
||||||
word_count = 0
|
primary_keyword = None
|
||||||
primary_keyword = ''
|
|
||||||
secondary_keywords = []
|
secondary_keywords = []
|
||||||
tags = []
|
tags_from_response = []
|
||||||
categories = []
|
categories_from_response = []
|
||||||
content_status = 'draft'
|
|
||||||
|
|
||||||
# Calculate word count if not provided
|
# Calculate word count
|
||||||
if not word_count and content_html:
|
word_count = 0
|
||||||
|
if content_html:
|
||||||
text_for_counting = re.sub(r'<[^>]+>', '', content_html)
|
text_for_counting = re.sub(r'<[^>]+>', '', content_html)
|
||||||
word_count = len(text_for_counting.split())
|
word_count = len(text_for_counting.split())
|
||||||
|
|
||||||
# Ensure related content record exists
|
# STAGE 3: Create independent Content record using final schema
|
||||||
content_record, _created = TaskContent.objects.get_or_create(
|
content_record = Content.objects.create(
|
||||||
task=task,
|
# Core fields
|
||||||
defaults={
|
title=title,
|
||||||
'account': task.account,
|
content_html=content_html or '',
|
||||||
'site': task.site,
|
word_count=word_count,
|
||||||
'sector': task.sector,
|
# SEO fields
|
||||||
'html_content': content_html or '',
|
meta_title=meta_title,
|
||||||
'word_count': word_count or 0,
|
meta_description=meta_description,
|
||||||
'status': 'draft',
|
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
|
logger.info(f"Created content record ID: {content_record.id}")
|
||||||
if content_html:
|
logger.info(f"Processing taxonomies - Tags: {len(tags_from_response) if tags_from_response else 0}, Categories: {len(categories_from_response) if categories_from_response else 0}")
|
||||||
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
|
# Link taxonomy terms from task if available
|
||||||
# Status can only be: draft, review, published (changed manually)
|
if task.taxonomy_term:
|
||||||
content_record.status = 'draft'
|
content_record.taxonomy_terms.add(task.taxonomy_term)
|
||||||
|
logger.info(f"Added task taxonomy term: {task.taxonomy_term.name}")
|
||||||
|
|
||||||
# Merge any extra fields into metadata (non-standard keys)
|
# Process tags from AI response
|
||||||
if isinstance(parsed, dict):
|
logger.info(f"Starting tag processing: {tags_from_response}")
|
||||||
excluded_keys = {
|
if tags_from_response and isinstance(tags_from_response, list):
|
||||||
'content',
|
from django.utils.text import slugify
|
||||||
'title',
|
for tag_name in tags_from_response:
|
||||||
'meta_title',
|
logger.info(f"Processing tag: '{tag_name}' (type: {type(tag_name)})")
|
||||||
'meta_description',
|
if tag_name and isinstance(tag_name, str):
|
||||||
'primary_keyword',
|
tag_name = tag_name.strip()
|
||||||
'secondary_keywords',
|
if tag_name:
|
||||||
'tags',
|
try:
|
||||||
'categories',
|
tag_slug = slugify(tag_name)
|
||||||
'word_count',
|
logger.info(f"Creating/finding tag: name='{tag_name}', slug='{tag_slug}'")
|
||||||
'status',
|
# Get or create tag taxonomy term using site + slug + type for uniqueness
|
||||||
|
tag_obj, created = ContentTaxonomy.objects.get_or_create(
|
||||||
|
site=task.site,
|
||||||
|
slug=tag_slug,
|
||||||
|
taxonomy_type='tag',
|
||||||
|
defaults={
|
||||||
|
'name': tag_name,
|
||||||
|
'sector': task.sector,
|
||||||
|
'account': task.account,
|
||||||
|
'description': '',
|
||||||
|
'external_taxonomy': '',
|
||||||
|
'sync_status': '',
|
||||||
|
'count': 0,
|
||||||
|
'metadata': {},
|
||||||
}
|
}
|
||||||
extra_meta = {k: v for k, v in parsed.items() if k not in excluded_keys}
|
)
|
||||||
existing_meta = content_record.metadata or {}
|
content_record.taxonomy_terms.add(tag_obj)
|
||||||
existing_meta.update(extra_meta)
|
logger.info(f"✅ {'Created' if created else 'Found'} and linked tag: {tag_name} (ID: {tag_obj.id}, Slug: {tag_slug})")
|
||||||
content_record.metadata = existing_meta
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to add tag '{tag_name}': {e}", exc_info=True)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Skipping invalid tag: '{tag_name}' (type: {type(tag_name)})")
|
||||||
|
else:
|
||||||
|
logger.info(f"No tags to process or tags_from_response is not a list: {type(tags_from_response)}")
|
||||||
|
|
||||||
# Align foreign keys to ensure consistency
|
# Process categories from AI response
|
||||||
content_record.account = task.account
|
logger.info(f"Starting category processing: {categories_from_response}")
|
||||||
content_record.site = task.site
|
if categories_from_response and isinstance(categories_from_response, list):
|
||||||
content_record.sector = task.sector
|
from django.utils.text import slugify
|
||||||
content_record.task = task
|
for category_name in categories_from_response:
|
||||||
|
logger.info(f"Processing category: '{category_name}' (type: {type(category_name)})")
|
||||||
|
if category_name and isinstance(category_name, str):
|
||||||
|
category_name = category_name.strip()
|
||||||
|
if category_name:
|
||||||
|
try:
|
||||||
|
category_slug = slugify(category_name)
|
||||||
|
logger.info(f"Creating/finding category: name='{category_name}', slug='{category_slug}'")
|
||||||
|
# Get or create category taxonomy term using site + slug + type for uniqueness
|
||||||
|
category_obj, created = ContentTaxonomy.objects.get_or_create(
|
||||||
|
site=task.site,
|
||||||
|
slug=category_slug,
|
||||||
|
taxonomy_type='category',
|
||||||
|
defaults={
|
||||||
|
'name': category_name,
|
||||||
|
'sector': task.sector,
|
||||||
|
'account': task.account,
|
||||||
|
'description': '',
|
||||||
|
'external_taxonomy': '',
|
||||||
|
'sync_status': '',
|
||||||
|
'count': 0,
|
||||||
|
'metadata': {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
content_record.taxonomy_terms.add(category_obj)
|
||||||
|
logger.info(f"✅ {'Created' if created else 'Found'} and linked category: {category_name} (ID: {category_obj.id}, Slug: {category_slug})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to add category '{category_name}': {e}", exc_info=True)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Skipping invalid category: '{category_name}' (type: {type(category_name)})")
|
||||||
|
else:
|
||||||
|
logger.info(f"No categories to process or categories_from_response is not a list: {type(categories_from_response)}")
|
||||||
|
|
||||||
content_record.save()
|
# STAGE 3: Update task status to completed
|
||||||
|
|
||||||
# Update task status - keep task data intact but mark as completed
|
|
||||||
task.status = 'completed'
|
task.status = 'completed'
|
||||||
task.save(update_fields=['status', 'updated_at'])
|
task.save(update_fields=['status', 'updated_at'])
|
||||||
|
|
||||||
|
# NEW: Auto-sync idea status from task status
|
||||||
|
if hasattr(task, 'idea') and task.idea:
|
||||||
|
task.idea.status = 'completed'
|
||||||
|
task.idea.save(update_fields=['status', 'updated_at'])
|
||||||
|
logger.info(f"Updated related idea ID {task.idea.id} to completed")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'count': 1,
|
'count': 1,
|
||||||
'tasks_updated': 1,
|
'content_id': content_record.id,
|
||||||
'word_count': content_record.word_count,
|
'task_id': task.id,
|
||||||
|
'word_count': word_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -208,12 +208,16 @@ class GenerateIdeasFunction(BaseAIFunction):
|
|||||||
# Handle target_keywords
|
# Handle target_keywords
|
||||||
target_keywords = idea_data.get('covered_keywords', '') or idea_data.get('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
|
# Create ContentIdeas record
|
||||||
ContentIdeas.objects.create(
|
ContentIdeas.objects.create(
|
||||||
idea_title=idea_data.get('title', 'Untitled Idea'),
|
idea_title=idea_data.get('title', 'Untitled Idea'),
|
||||||
description=description,
|
description=description, # Stored as JSON string
|
||||||
content_type=idea_data.get('content_type', 'blog_post'),
|
content_type=content_type,
|
||||||
content_structure=idea_data.get('content_structure', 'supporting_page'),
|
content_structure=content_structure,
|
||||||
target_keywords=target_keywords,
|
target_keywords=target_keywords,
|
||||||
keyword_cluster=cluster,
|
keyword_cluster=cluster,
|
||||||
estimated_word_count=idea_data.get('estimated_word_count', 1500),
|
estimated_word_count=idea_data.get('estimated_word_count', 1500),
|
||||||
@@ -224,6 +228,11 @@ class GenerateIdeasFunction(BaseAIFunction):
|
|||||||
)
|
)
|
||||||
ideas_created += 1
|
ideas_created += 1
|
||||||
|
|
||||||
|
# Update cluster status to 'mapped' after ideas are generated
|
||||||
|
if cluster and cluster.status == 'new':
|
||||||
|
cluster.status = 'mapped'
|
||||||
|
cluster.save()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'count': ideas_created,
|
'count': ideas_created,
|
||||||
'ideas_created': ideas_created
|
'ideas_created': ideas_created
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class GenerateImagePromptsFunction(BaseAIFunction):
|
|||||||
if account:
|
if account:
|
||||||
queryset = queryset.filter(account=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:
|
if not contents:
|
||||||
raise ValueError("No content records found")
|
raise ValueError("No content records found")
|
||||||
@@ -203,11 +203,12 @@ class GenerateImagePromptsFunction(BaseAIFunction):
|
|||||||
"""Extract title, intro paragraphs, and H2 headings from content HTML"""
|
"""Extract title, intro paragraphs, and H2 headings from content HTML"""
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
html_content = content.html_content or ''
|
html_content = content.content_html or ''
|
||||||
soup = BeautifulSoup(html_content, 'html.parser')
|
soup = BeautifulSoup(html_content, 'html.parser')
|
||||||
|
|
||||||
# Extract title
|
# 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)
|
# Extract first 1-2 intro paragraphs (skip italic hook if present)
|
||||||
paragraphs = soup.find_all('p')
|
paragraphs = soup.find_all('p')
|
||||||
|
|||||||
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": {
|
"introduction": {
|
||||||
"hook": "Transform your sleep with organic cotton that blends comfort and sustainability.",
|
"hook": "Transform your sleep with organic cotton that blends comfort and sustainability.",
|
||||||
"paragraphs": [
|
"paragraphs": [
|
||||||
{"content_type": "paragraph", "details": "Overview of organic cotton's rise in bedding industry."},
|
{"format": "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": "Why consumers prefer organic bedding over synthetic alternatives."}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"H2": [
|
"H2": [
|
||||||
{
|
{
|
||||||
"heading": "Why Choose Organic Cotton for Bedding?",
|
"heading": "Why Choose Organic Cotton for Bedding?",
|
||||||
"subsections": [
|
"subsections": [
|
||||||
{"subheading": "Health and Skin Benefits", "content_type": "paragraph", "details": "Discuss hypoallergenic and chemical-free aspects."},
|
{"subheading": "Health and Skin Benefits", "format": "paragraph", "details": "Discuss hypoallergenic and chemical-free aspects."},
|
||||||
{"subheading": "Environmental Sustainability", "content_type": "list", "details": "Eco benefits like low water use, no pesticides."},
|
{"subheading": "Environmental Sustainability", "format": "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": "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"
|
"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.
|
Valid content_type values: post, page, product, taxonomy
|
||||||
|
|
||||||
Only the `content` field should contain HTML inside JSON object.
|
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:
|
Generate a complete JSON response object matching this structure:
|
||||||
==================
|
==================
|
||||||
|
|
||||||
{
|
{
|
||||||
"title": "[Blog title using the primary keyword — full sentence case]",
|
"title": "[Article title using target keywords — full sentence case]",
|
||||||
"meta_title": "[Meta title under 60 characters — natural, optimized, and compelling]",
|
"content": "[HTML content — full editorial structure with <p>, <h2>, <h3>, <ul>, <ol>, <table>]"
|
||||||
"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]"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
===========================
|
===========================
|
||||||
@@ -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
|
- Never begin any section or sub-section with a list or table
|
||||||
|
|
||||||
===========================
|
===========================
|
||||||
KEYWORD & SEO RULES
|
STYLE & QUALITY RULES
|
||||||
===========================
|
===========================
|
||||||
|
|
||||||
- **Primary keyword** must appear in:
|
- **Keyword Usage:**
|
||||||
- The title
|
- Use keywords naturally in title, introduction, and headings
|
||||||
- First paragraph of the introduction
|
- Prioritize readability over keyword density
|
||||||
- At least 2 H2 headings
|
|
||||||
|
|
||||||
- **Secondary keywords** must be used naturally, not forced
|
|
||||||
|
|
||||||
- **Tone & style guidelines:**
|
- **Tone & style guidelines:**
|
||||||
- No robotic or passive voice
|
- No robotic or passive voice
|
||||||
@@ -217,7 +200,28 @@ KEYWORD & SEO RULES
|
|||||||
- Don't repeat heading in opening sentence
|
- Don't repeat heading in opening sentence
|
||||||
- Vary sentence structure and length
|
- 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
|
INPUT VARIABLES
|
||||||
@@ -239,6 +243,73 @@ OUTPUT FORMAT
|
|||||||
Return ONLY the final JSON object.
|
Return ONLY the final JSON object.
|
||||||
Do NOT include any comments, formatting, or explanations.""",
|
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.
|
'image_prompt_extraction': """Extract image prompts from the following article content.
|
||||||
|
|
||||||
ARTICLE TITLE: {title}
|
ARTICLE TITLE: {title}
|
||||||
@@ -265,6 +336,260 @@ 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.**',
|
'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',
|
'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
|
||||||
|
""",
|
||||||
|
|
||||||
|
'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
|
# Mapping from function names to prompt types
|
||||||
@@ -275,6 +600,12 @@ Make sure each prompt is detailed enough for image generation, describing the vi
|
|||||||
'generate_images': 'image_prompt_extraction',
|
'generate_images': 'image_prompt_extraction',
|
||||||
'extract_image_prompts': 'image_prompt_extraction',
|
'extract_image_prompts': 'image_prompt_extraction',
|
||||||
'generate_image_prompts': 'image_prompt_extraction',
|
'generate_image_prompts': 'image_prompt_extraction',
|
||||||
|
'generate_site_structure': 'site_structure_generation',
|
||||||
|
'optimize_content': 'optimize_content',
|
||||||
|
# Phase 8: Universal Content Types
|
||||||
|
'generate_product_content': 'product_generation',
|
||||||
|
'generate_service_page': 'service_generation',
|
||||||
|
'generate_taxonomy': 'taxonomy_generation',
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -370,7 +701,7 @@ Make sure each prompt is detailed enough for image generation, describing the vi
|
|||||||
if '{' in rendered and '}' in rendered:
|
if '{' in rendered and '}' in rendered:
|
||||||
try:
|
try:
|
||||||
rendered = rendered.format(**normalized_context)
|
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
|
# If .format() fails, log warning but keep the [IGNY8_*] replacements
|
||||||
logger.warning(f"Failed to format prompt with .format(): {e}. Using [IGNY8_*] replacements only.")
|
logger.warning(f"Failed to format prompt with .format(): {e}. Using [IGNY8_*] replacements only.")
|
||||||
|
|
||||||
|
|||||||
@@ -94,9 +94,15 @@ def _load_generate_image_prompts():
|
|||||||
from igny8_core.ai.functions.generate_image_prompts import GenerateImagePromptsFunction
|
from igny8_core.ai.functions.generate_image_prompts import GenerateImagePromptsFunction
|
||||||
return GenerateImagePromptsFunction
|
return GenerateImagePromptsFunction
|
||||||
|
|
||||||
|
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('auto_cluster', _load_auto_cluster)
|
||||||
register_lazy_function('generate_ideas', _load_generate_ideas)
|
register_lazy_function('generate_ideas', _load_generate_ideas)
|
||||||
register_lazy_function('generate_content', _load_generate_content)
|
register_lazy_function('generate_content', _load_generate_content)
|
||||||
register_lazy_function('generate_images', _load_generate_images)
|
register_lazy_function('generate_images', _load_generate_images)
|
||||||
register_lazy_function('generate_image_prompts', _load_generate_image_prompts)
|
register_lazy_function('generate_image_prompts', _load_generate_image_prompts)
|
||||||
|
register_lazy_function('optimize_content', _load_optimize_content)
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ FUNCTION_ALIASES = {
|
|||||||
|
|
||||||
def get_model_config(function_name: str, account) -> Dict[str, Any]:
|
def get_model_config(function_name: str, account) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get model configuration from IntegrationSettings only.
|
Get model configuration from IntegrationSettings.
|
||||||
No fallbacks - account must have IntegrationSettings configured.
|
Falls back to system account (aws-admin) if user account doesn't have settings.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
function_name: Name of the AI function
|
function_name: Name of the AI function
|
||||||
@@ -38,17 +38,42 @@ def get_model_config(function_name: str, account) -> Dict[str, Any]:
|
|||||||
# Resolve function alias
|
# Resolve function alias
|
||||||
actual_name = FUNCTION_ALIASES.get(function_name, function_name)
|
actual_name = FUNCTION_ALIASES.get(function_name, function_name)
|
||||||
|
|
||||||
# Get IntegrationSettings for OpenAI
|
# Get IntegrationSettings for OpenAI - try user account first
|
||||||
|
integration_settings = None
|
||||||
try:
|
try:
|
||||||
from igny8_core.modules.system.models import IntegrationSettings
|
from igny8_core.modules.system.models import IntegrationSettings
|
||||||
integration_settings = IntegrationSettings.objects.get(
|
integration_settings = IntegrationSettings.objects.filter(
|
||||||
integration_type='openai',
|
integration_type='openai',
|
||||||
account=account,
|
account=account,
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
).first()
|
||||||
except IntegrationSettings.DoesNotExist:
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not load OpenAI settings for account {account.id}: {e}")
|
||||||
|
|
||||||
|
# Fallback to system account (aws-admin, default-account, or default)
|
||||||
|
if not integration_settings:
|
||||||
|
logger.info(f"No OpenAI settings for account {account.id}, falling back to system account")
|
||||||
|
try:
|
||||||
|
from igny8_core.auth.models import Account
|
||||||
|
from igny8_core.modules.system.models import IntegrationSettings
|
||||||
|
for slug in ['aws-admin', 'default-account', 'default']:
|
||||||
|
system_account = Account.objects.filter(slug=slug).first()
|
||||||
|
if system_account:
|
||||||
|
integration_settings = IntegrationSettings.objects.filter(
|
||||||
|
integration_type='openai',
|
||||||
|
account=system_account,
|
||||||
|
is_active=True
|
||||||
|
).first()
|
||||||
|
if integration_settings:
|
||||||
|
logger.info(f"Using OpenAI settings from system account: {slug}")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not load system account OpenAI settings: {e}")
|
||||||
|
|
||||||
|
# If still no settings found, raise error
|
||||||
|
if not integration_settings:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"OpenAI IntegrationSettings not configured for account {account.id}. "
|
f"OpenAI IntegrationSettings not configured for account {account.id} or system account. "
|
||||||
f"Please configure OpenAI settings in the integration page."
|
f"Please configure OpenAI settings in the integration page."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Get image generation settings from IntegrationSettings
|
# Get image generation settings from IntegrationSettings
|
||||||
|
# Normal users use system account settings (aws-admin) via fallback
|
||||||
logger.info("[process_image_generation_queue] Step 1: Loading image generation settings")
|
logger.info("[process_image_generation_queue] Step 1: Loading image generation settings")
|
||||||
try:
|
try:
|
||||||
image_settings = IntegrationSettings.objects.get(
|
image_settings = IntegrationSettings.objects.get(
|
||||||
@@ -189,8 +190,29 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
integration_type='image_generation',
|
integration_type='image_generation',
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
|
logger.info(f"[process_image_generation_queue] Image generation settings found for account {account.id}")
|
||||||
config = image_settings.config or {}
|
config = image_settings.config or {}
|
||||||
logger.info(f"[process_image_generation_queue] Image generation settings found. Config keys: {list(config.keys())}")
|
except IntegrationSettings.DoesNotExist:
|
||||||
|
# Fallback to system account (aws-admin) settings
|
||||||
|
logger.info(f"[process_image_generation_queue] No settings for account {account.id}, falling back to system account")
|
||||||
|
from igny8_core.auth.models import Account
|
||||||
|
try:
|
||||||
|
system_account = Account.objects.get(slug='aws-admin')
|
||||||
|
image_settings = IntegrationSettings.objects.get(
|
||||||
|
account=system_account,
|
||||||
|
integration_type='image_generation',
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
logger.info(f"[process_image_generation_queue] Using system account (aws-admin) settings")
|
||||||
|
config = image_settings.config or {}
|
||||||
|
except (Account.DoesNotExist, IntegrationSettings.DoesNotExist):
|
||||||
|
logger.error("[process_image_generation_queue] ERROR: Image generation settings not found in system account either")
|
||||||
|
return {'success': False, 'error': 'Image generation settings not found'}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[process_image_generation_queue] ERROR loading image generation settings: {e}", exc_info=True)
|
||||||
|
return {'success': False, 'error': f'Error loading image generation settings: {str(e)}'}
|
||||||
|
|
||||||
|
logger.info(f"[process_image_generation_queue] Image generation settings loaded. Config keys: {list(config.keys())}")
|
||||||
logger.info(f"[process_image_generation_queue] Full config: {config}")
|
logger.info(f"[process_image_generation_queue] Full config: {config}")
|
||||||
|
|
||||||
# Get provider and model from config (respect user settings)
|
# Get provider and model from config (respect user settings)
|
||||||
@@ -214,16 +236,10 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
logger.info(f" - Image format: {image_format}")
|
logger.info(f" - Image format: {image_format}")
|
||||||
logger.info(f" - Desktop enabled: {desktop_enabled}")
|
logger.info(f" - Desktop enabled: {desktop_enabled}")
|
||||||
logger.info(f" - Mobile enabled: {mobile_enabled}")
|
logger.info(f" - Mobile enabled: {mobile_enabled}")
|
||||||
except IntegrationSettings.DoesNotExist:
|
|
||||||
logger.error("[process_image_generation_queue] ERROR: Image generation settings not found")
|
|
||||||
logger.error(f"[process_image_generation_queue] Account: {account.id if account else 'None'}, integration_type: 'image_generation'")
|
|
||||||
return {'success': False, 'error': 'Image generation settings not found'}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[process_image_generation_queue] ERROR loading image generation settings: {e}", exc_info=True)
|
|
||||||
return {'success': False, 'error': f'Error loading image generation settings: {str(e)}'}
|
|
||||||
|
|
||||||
# Get provider API key (using same approach as test image generation)
|
# Get provider API key (using same approach as test image generation)
|
||||||
# Note: API key is stored as 'apiKey' (camelCase) in IntegrationSettings.config
|
# Note: API key is stored as 'apiKey' (camelCase) in IntegrationSettings.config
|
||||||
|
# Normal users use system account settings (aws-admin) via fallback
|
||||||
logger.info(f"[process_image_generation_queue] Step 2: Loading {provider.upper()} API key")
|
logger.info(f"[process_image_generation_queue] Step 2: Loading {provider.upper()} API key")
|
||||||
try:
|
try:
|
||||||
provider_settings = IntegrationSettings.objects.get(
|
provider_settings = IntegrationSettings.objects.get(
|
||||||
@@ -231,7 +247,27 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
integration_type=provider, # Use the provider from settings
|
integration_type=provider, # Use the provider from settings
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
logger.info(f"[process_image_generation_queue] {provider.upper()} integration settings found")
|
logger.info(f"[process_image_generation_queue] {provider.upper()} integration settings found for account {account.id}")
|
||||||
|
except IntegrationSettings.DoesNotExist:
|
||||||
|
# Fallback to system account (aws-admin) settings
|
||||||
|
logger.info(f"[process_image_generation_queue] No {provider.upper()} settings for account {account.id}, falling back to system account")
|
||||||
|
from igny8_core.auth.models import Account
|
||||||
|
try:
|
||||||
|
system_account = Account.objects.get(slug='aws-admin')
|
||||||
|
provider_settings = IntegrationSettings.objects.get(
|
||||||
|
account=system_account,
|
||||||
|
integration_type=provider,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
logger.info(f"[process_image_generation_queue] Using system account (aws-admin) {provider.upper()} settings")
|
||||||
|
except (Account.DoesNotExist, IntegrationSettings.DoesNotExist):
|
||||||
|
logger.error(f"[process_image_generation_queue] ERROR: {provider.upper()} integration settings not found in system account either")
|
||||||
|
return {'success': False, 'error': f'{provider.upper()} integration not found or not active'}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[process_image_generation_queue] ERROR getting {provider.upper()} API key: {e}", exc_info=True)
|
||||||
|
return {'success': False, 'error': f'Error retrieving {provider.upper()} API key: {str(e)}'}
|
||||||
|
|
||||||
|
# Extract API key from provider settings
|
||||||
logger.info(f"[process_image_generation_queue] {provider.upper()} config keys: {list(provider_settings.config.keys()) if provider_settings.config else 'None'}")
|
logger.info(f"[process_image_generation_queue] {provider.upper()} config keys: {list(provider_settings.config.keys()) if provider_settings.config else 'None'}")
|
||||||
|
|
||||||
api_key = provider_settings.config.get('apiKey') if provider_settings.config else None
|
api_key = provider_settings.config.get('apiKey') if provider_settings.config else None
|
||||||
@@ -243,13 +279,6 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
# Log API key presence (but not the actual key for security)
|
# Log API key presence (but not the actual key for security)
|
||||||
api_key_preview = f"{api_key[:10]}...{api_key[-4:]}" if len(api_key) > 14 else "***"
|
api_key_preview = f"{api_key[:10]}...{api_key[-4:]}" if len(api_key) > 14 else "***"
|
||||||
logger.info(f"[process_image_generation_queue] {provider.upper()} API key retrieved successfully (length: {len(api_key)}, preview: {api_key_preview})")
|
logger.info(f"[process_image_generation_queue] {provider.upper()} API key retrieved successfully (length: {len(api_key)}, preview: {api_key_preview})")
|
||||||
except IntegrationSettings.DoesNotExist:
|
|
||||||
logger.error(f"[process_image_generation_queue] ERROR: {provider.upper()} integration settings not found")
|
|
||||||
logger.error(f"[process_image_generation_queue] Account: {account.id if account else 'None'}, integration_type: '{provider}'")
|
|
||||||
return {'success': False, 'error': f'{provider.upper()} integration not found or not active'}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[process_image_generation_queue] ERROR getting {provider.upper()} API key: {e}", exc_info=True)
|
|
||||||
return {'success': False, 'error': f'Error retrieving {provider.upper()} API key: {str(e)}'}
|
|
||||||
|
|
||||||
# Get image prompt template (has placeholders: {image_type}, {post_title}, {image_prompt})
|
# Get image prompt template (has placeholders: {image_type}, {post_title}, {image_prompt})
|
||||||
try:
|
try:
|
||||||
@@ -707,6 +736,25 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
})
|
})
|
||||||
failed += 1
|
failed += 1
|
||||||
|
|
||||||
|
# Check if all images for the content are generated and update status to 'review'
|
||||||
|
if content_id and completed > 0:
|
||||||
|
try:
|
||||||
|
from igny8_core.business.content.models import Content, Images
|
||||||
|
|
||||||
|
content = Content.objects.get(id=content_id)
|
||||||
|
|
||||||
|
# Check if all images for this content are now generated
|
||||||
|
all_images = Images.objects.filter(content=content)
|
||||||
|
pending_images = all_images.filter(status='pending').count()
|
||||||
|
|
||||||
|
# If no pending images and content is still in draft, move to review
|
||||||
|
if pending_images == 0 and content.status == 'draft':
|
||||||
|
content.status = 'review'
|
||||||
|
content.save(update_fields=['status'])
|
||||||
|
logger.info(f"[process_image_generation_queue] Content #{content_id} status updated to 'review' (all images generated)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[process_image_generation_queue] Error updating content status: {str(e)}", exc_info=True)
|
||||||
|
|
||||||
# Final state
|
# Final state
|
||||||
logger.info("=" * 80)
|
logger.info("=" * 80)
|
||||||
logger.info(f"process_image_generation_queue COMPLETED")
|
logger.info(f"process_image_generation_queue COMPLETED")
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|
||||||
52
backend/igny8_core/ai/validators/__init__.py
Normal file
52
backend/igny8_core/ai/validators/__init__.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
AI Validators Package
|
||||||
|
Shared validation logic for AI functions
|
||||||
|
"""
|
||||||
|
from .cluster_validators import validate_minimum_keywords, validate_keyword_selection
|
||||||
|
|
||||||
|
# The codebase also contains a module-level file `ai/validators.py` which defines
|
||||||
|
# common validator helpers (e.g. `validate_ids`). Because there is both a
|
||||||
|
# package directory `ai/validators/` and a module file `ai/validators.py`, Python
|
||||||
|
# will resolve `igny8_core.ai.validators` to the package and not the module file.
|
||||||
|
# To avoid changing many imports across the project, load the module file here
|
||||||
|
# and re-export the commonly used functions.
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
|
||||||
|
_module_path = os.path.normpath(os.path.join(os.path.dirname(__file__), '..', 'validators.py'))
|
||||||
|
if os.path.exists(_module_path):
|
||||||
|
spec = importlib.util.spec_from_file_location('igny8_core.ai._validators_module', _module_path)
|
||||||
|
_validators_mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(_validators_mod)
|
||||||
|
# Re-export commonly used functions from the module file
|
||||||
|
validate_ids = getattr(_validators_mod, 'validate_ids', None)
|
||||||
|
validate_keywords_exist = getattr(_validators_mod, 'validate_keywords_exist', None)
|
||||||
|
validate_cluster_limits = getattr(_validators_mod, 'validate_cluster_limits', None)
|
||||||
|
validate_cluster_exists = getattr(_validators_mod, 'validate_cluster_exists', None)
|
||||||
|
validate_tasks_exist = getattr(_validators_mod, 'validate_tasks_exist', None)
|
||||||
|
validate_api_key = getattr(_validators_mod, 'validate_api_key', None)
|
||||||
|
validate_model = getattr(_validators_mod, 'validate_model', None)
|
||||||
|
validate_image_size = getattr(_validators_mod, 'validate_image_size', None)
|
||||||
|
else:
|
||||||
|
# Module file missing - keep names defined if cluster validators provide them
|
||||||
|
validate_ids = None
|
||||||
|
validate_keywords_exist = None
|
||||||
|
validate_cluster_limits = None
|
||||||
|
validate_cluster_exists = None
|
||||||
|
validate_tasks_exist = None
|
||||||
|
validate_api_key = None
|
||||||
|
validate_model = None
|
||||||
|
validate_image_size = None
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'validate_minimum_keywords',
|
||||||
|
'validate_keyword_selection',
|
||||||
|
'validate_ids',
|
||||||
|
'validate_keywords_exist',
|
||||||
|
'validate_cluster_limits',
|
||||||
|
'validate_cluster_exists',
|
||||||
|
'validate_tasks_exist',
|
||||||
|
'validate_api_key',
|
||||||
|
'validate_model',
|
||||||
|
'validate_image_size',
|
||||||
|
]
|
||||||
105
backend/igny8_core/ai/validators/cluster_validators.py
Normal file
105
backend/igny8_core/ai/validators/cluster_validators.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""
|
||||||
|
Cluster-specific validators
|
||||||
|
Shared between auto-cluster function and automation pipeline
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_minimum_keywords(
|
||||||
|
keyword_ids: List[int],
|
||||||
|
account=None,
|
||||||
|
min_required: int = 5
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Validate that sufficient keywords are available for clustering
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keyword_ids: List of keyword IDs to cluster
|
||||||
|
account: Account object for filtering
|
||||||
|
min_required: Minimum number of keywords required (default: 5)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'valid' (bool) and 'error' (str) or 'count' (int)
|
||||||
|
"""
|
||||||
|
from igny8_core.modules.planner.models import Keywords
|
||||||
|
|
||||||
|
# Build queryset
|
||||||
|
queryset = Keywords.objects.filter(id__in=keyword_ids, status='new')
|
||||||
|
|
||||||
|
if account:
|
||||||
|
queryset = queryset.filter(account=account)
|
||||||
|
|
||||||
|
# Count available keywords
|
||||||
|
count = queryset.count()
|
||||||
|
|
||||||
|
# Validate minimum
|
||||||
|
if count < min_required:
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'error': f'Insufficient keywords for clustering. Need at least {min_required} keywords, but only {count} available.',
|
||||||
|
'count': count,
|
||||||
|
'required': min_required
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'valid': True,
|
||||||
|
'count': count,
|
||||||
|
'required': min_required
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_keyword_selection(
|
||||||
|
selected_ids: List[int],
|
||||||
|
available_count: int,
|
||||||
|
min_required: int = 5
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Validate keyword selection (for frontend validation)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
selected_ids: List of selected keyword IDs
|
||||||
|
available_count: Total count of available keywords
|
||||||
|
min_required: Minimum required
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with validation result
|
||||||
|
"""
|
||||||
|
selected_count = len(selected_ids)
|
||||||
|
|
||||||
|
# Check if any keywords selected
|
||||||
|
if selected_count == 0:
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'error': 'No keywords selected',
|
||||||
|
'type': 'NO_SELECTION'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if enough selected
|
||||||
|
if selected_count < min_required:
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'error': f'Please select at least {min_required} keywords. Currently selected: {selected_count}',
|
||||||
|
'type': 'INSUFFICIENT_SELECTION',
|
||||||
|
'selected': selected_count,
|
||||||
|
'required': min_required
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if enough available (even if not all selected)
|
||||||
|
if available_count < min_required:
|
||||||
|
return {
|
||||||
|
'valid': False,
|
||||||
|
'error': f'Not enough keywords available. Need at least {min_required} keywords, but only {available_count} exist.',
|
||||||
|
'type': 'INSUFFICIENT_AVAILABLE',
|
||||||
|
'available': available_count,
|
||||||
|
'required': min_required
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'valid': True,
|
||||||
|
'selected': selected_count,
|
||||||
|
'available': available_count,
|
||||||
|
'required': min_required
|
||||||
|
}
|
||||||
31
backend/igny8_core/api/account_urls.py
Normal file
31
backend/igny8_core/api/account_urls.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Account API URLs
|
||||||
|
"""
|
||||||
|
from django.urls import path
|
||||||
|
from igny8_core.api.account_views import (
|
||||||
|
AccountSettingsViewSet,
|
||||||
|
TeamManagementViewSet,
|
||||||
|
UsageAnalyticsViewSet
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Account Settings
|
||||||
|
path('settings/', AccountSettingsViewSet.as_view({
|
||||||
|
'get': 'retrieve',
|
||||||
|
'patch': 'partial_update'
|
||||||
|
}), name='account-settings'),
|
||||||
|
|
||||||
|
# Team Management
|
||||||
|
path('team/', TeamManagementViewSet.as_view({
|
||||||
|
'get': 'list',
|
||||||
|
'post': 'create'
|
||||||
|
}), name='team-list'),
|
||||||
|
path('team/<int:pk>/', TeamManagementViewSet.as_view({
|
||||||
|
'delete': 'destroy'
|
||||||
|
}), name='team-detail'),
|
||||||
|
|
||||||
|
# Usage Analytics
|
||||||
|
path('usage/analytics/', UsageAnalyticsViewSet.as_view({
|
||||||
|
'get': 'overview'
|
||||||
|
}), name='usage-analytics'),
|
||||||
|
]
|
||||||
244
backend/igny8_core/api/account_views.py
Normal file
244
backend/igny8_core/api/account_views.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"""
|
||||||
|
Account Management API Views
|
||||||
|
Handles account settings, team management, and usage analytics
|
||||||
|
"""
|
||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Q, Count, Sum
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
|
|
||||||
|
from igny8_core.auth.models import Account
|
||||||
|
from igny8_core.business.billing.models import CreditTransaction
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
retrieve=extend_schema(tags=['Account']),
|
||||||
|
partial_update=extend_schema(tags=['Account']),
|
||||||
|
)
|
||||||
|
class AccountSettingsViewSet(viewsets.ViewSet):
|
||||||
|
"""Account settings management"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def retrieve(self, request):
|
||||||
|
"""Get account settings"""
|
||||||
|
account = request.user.account
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'id': account.id,
|
||||||
|
'name': account.name,
|
||||||
|
'slug': account.slug,
|
||||||
|
'billing_address_line1': account.billing_address_line1 or '',
|
||||||
|
'billing_address_line2': account.billing_address_line2 or '',
|
||||||
|
'billing_city': account.billing_city or '',
|
||||||
|
'billing_state': account.billing_state or '',
|
||||||
|
'billing_postal_code': account.billing_postal_code or '',
|
||||||
|
'billing_country': account.billing_country or '',
|
||||||
|
'tax_id': account.tax_id or '',
|
||||||
|
'billing_email': account.billing_email or '',
|
||||||
|
'credits': account.credits,
|
||||||
|
'created_at': account.created_at.isoformat(),
|
||||||
|
'updated_at': account.updated_at.isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def partial_update(self, request):
|
||||||
|
"""Update account settings"""
|
||||||
|
account = request.user.account
|
||||||
|
|
||||||
|
# Update allowed fields
|
||||||
|
allowed_fields = [
|
||||||
|
'name', 'billing_address_line1', 'billing_address_line2',
|
||||||
|
'billing_city', 'billing_state', 'billing_postal_code',
|
||||||
|
'billing_country', 'tax_id', 'billing_email'
|
||||||
|
]
|
||||||
|
|
||||||
|
for field in allowed_fields:
|
||||||
|
if field in request.data:
|
||||||
|
setattr(account, field, request.data[field])
|
||||||
|
|
||||||
|
account.save()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Account settings updated successfully',
|
||||||
|
'account': {
|
||||||
|
'id': account.id,
|
||||||
|
'name': account.name,
|
||||||
|
'slug': account.slug,
|
||||||
|
'billing_address_line1': account.billing_address_line1,
|
||||||
|
'billing_address_line2': account.billing_address_line2,
|
||||||
|
'billing_city': account.billing_city,
|
||||||
|
'billing_state': account.billing_state,
|
||||||
|
'billing_postal_code': account.billing_postal_code,
|
||||||
|
'billing_country': account.billing_country,
|
||||||
|
'tax_id': account.tax_id,
|
||||||
|
'billing_email': account.billing_email,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
list=extend_schema(tags=['Account']),
|
||||||
|
create=extend_schema(tags=['Account']),
|
||||||
|
destroy=extend_schema(tags=['Account']),
|
||||||
|
)
|
||||||
|
class TeamManagementViewSet(viewsets.ViewSet):
|
||||||
|
"""Team members management"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def list(self, request):
|
||||||
|
"""List team members"""
|
||||||
|
account = request.user.account
|
||||||
|
users = User.objects.filter(account=account)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'results': [
|
||||||
|
{
|
||||||
|
'id': user.id,
|
||||||
|
'email': user.email,
|
||||||
|
'first_name': user.first_name,
|
||||||
|
'last_name': user.last_name,
|
||||||
|
'is_active': user.is_active,
|
||||||
|
'is_staff': user.is_staff,
|
||||||
|
'date_joined': user.date_joined.isoformat(),
|
||||||
|
'last_login': user.last_login.isoformat() if user.last_login else None,
|
||||||
|
}
|
||||||
|
for user in users
|
||||||
|
],
|
||||||
|
'count': users.count()
|
||||||
|
})
|
||||||
|
|
||||||
|
def create(self, request):
|
||||||
|
"""Invite new team member"""
|
||||||
|
account = request.user.account
|
||||||
|
email = request.data.get('email')
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Email is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user already exists
|
||||||
|
if User.objects.filter(email=email).exists():
|
||||||
|
return Response(
|
||||||
|
{'error': 'User with this email already exists'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create user (simplified - in production, send invitation email)
|
||||||
|
user = User.objects.create_user(
|
||||||
|
email=email,
|
||||||
|
first_name=request.data.get('first_name', ''),
|
||||||
|
last_name=request.data.get('last_name', ''),
|
||||||
|
account=account
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Team member invited successfully',
|
||||||
|
'user': {
|
||||||
|
'id': user.id,
|
||||||
|
'email': user.email,
|
||||||
|
'first_name': user.first_name,
|
||||||
|
'last_name': user.last_name,
|
||||||
|
}
|
||||||
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def destroy(self, request, pk=None):
|
||||||
|
"""Remove team member"""
|
||||||
|
account = request.user.account
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.objects.get(id=pk, account=account)
|
||||||
|
|
||||||
|
# Prevent removing yourself
|
||||||
|
if user.id == request.user.id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Cannot remove yourself'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
user.is_active = False
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Team member removed successfully'
|
||||||
|
})
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': 'User not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
overview=extend_schema(tags=['Account']),
|
||||||
|
)
|
||||||
|
class UsageAnalyticsViewSet(viewsets.ViewSet):
|
||||||
|
"""Usage analytics and statistics"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def overview(self, request):
|
||||||
|
"""Get usage analytics overview"""
|
||||||
|
account = request.user.account
|
||||||
|
|
||||||
|
# Get date range (default: last 30 days)
|
||||||
|
days = int(request.query_params.get('days', 30))
|
||||||
|
start_date = timezone.now() - timedelta(days=days)
|
||||||
|
|
||||||
|
# Get transactions in period
|
||||||
|
transactions = CreditTransaction.objects.filter(
|
||||||
|
account=account,
|
||||||
|
created_at__gte=start_date
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate totals by type
|
||||||
|
usage_by_type = transactions.filter(
|
||||||
|
amount__lt=0
|
||||||
|
).values('transaction_type').annotate(
|
||||||
|
total=Sum('amount'),
|
||||||
|
count=Count('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
purchases_by_type = transactions.filter(
|
||||||
|
amount__gt=0
|
||||||
|
).values('transaction_type').annotate(
|
||||||
|
total=Sum('amount'),
|
||||||
|
count=Count('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Daily usage
|
||||||
|
daily_usage = []
|
||||||
|
for i in range(days):
|
||||||
|
date = start_date + timedelta(days=i)
|
||||||
|
day_txns = transactions.filter(
|
||||||
|
created_at__date=date.date()
|
||||||
|
)
|
||||||
|
|
||||||
|
usage = day_txns.filter(amount__lt=0).aggregate(Sum('amount'))['amount__sum'] or 0
|
||||||
|
purchases = day_txns.filter(amount__gt=0).aggregate(Sum('amount'))['amount__sum'] or 0
|
||||||
|
|
||||||
|
daily_usage.append({
|
||||||
|
'date': date.date().isoformat(),
|
||||||
|
'usage': abs(usage),
|
||||||
|
'purchases': purchases,
|
||||||
|
'net': purchases + usage
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'period_days': days,
|
||||||
|
'start_date': start_date.isoformat(),
|
||||||
|
'end_date': timezone.now().isoformat(),
|
||||||
|
'current_balance': account.credits,
|
||||||
|
'usage_by_type': list(usage_by_type),
|
||||||
|
'purchases_by_type': list(purchases_by_type),
|
||||||
|
'daily_usage': daily_usage,
|
||||||
|
'total_usage': abs(transactions.filter(amount__lt=0).aggregate(Sum('amount'))['amount__sum'] or 0),
|
||||||
|
'total_purchases': transactions.filter(amount__gt=0).aggregate(Sum('amount'))['amount__sum'] or 0,
|
||||||
|
})
|
||||||
@@ -67,16 +67,10 @@ class JWTAuthentication(BaseAuthentication):
|
|||||||
try:
|
try:
|
||||||
account = Account.objects.get(id=account_id)
|
account = Account.objects.get(id=account_id)
|
||||||
except Account.DoesNotExist:
|
except Account.DoesNotExist:
|
||||||
pass
|
# Account from token doesn't exist - don't fallback, set to None
|
||||||
|
|
||||||
if not account:
|
|
||||||
try:
|
|
||||||
account = getattr(user, 'account', None)
|
|
||||||
except (AttributeError, Exception):
|
|
||||||
# If account access fails, set to None
|
|
||||||
account = None
|
account = None
|
||||||
|
|
||||||
# Set account on request
|
# Set account on request (only if account_id was in token and account exists)
|
||||||
request.account = account
|
request.account = account
|
||||||
|
|
||||||
return (user, token)
|
return (user, token)
|
||||||
@@ -89,3 +83,79 @@ class JWTAuthentication(BaseAuthentication):
|
|||||||
# This allows session authentication to work if JWT fails
|
# This allows session authentication to work if JWT fails
|
||||||
return None
|
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
|
||||||
|
from igny8_core.auth.utils import validate_account_and_plan
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
# Find site by API key
|
||||||
|
site = Site.objects.select_related('account', 'account__owner', 'account__plan').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 validate it
|
||||||
|
account = site.account
|
||||||
|
if not account:
|
||||||
|
raise AuthenticationFailed('No account associated with this API key.')
|
||||||
|
|
||||||
|
# CRITICAL FIX: Validate account and plan status
|
||||||
|
is_valid, error_message, http_status = validate_account_and_plan(account)
|
||||||
|
if not is_valid:
|
||||||
|
raise AuthenticationFailed(error_message)
|
||||||
|
|
||||||
|
# Get user (prefer owner but gracefully fall back)
|
||||||
|
user = account.owner
|
||||||
|
if not user or not getattr(user, 'is_active', False):
|
||||||
|
# Fall back to any active developer/owner/admin in the account
|
||||||
|
user = account.users.filter(
|
||||||
|
is_active=True,
|
||||||
|
role__in=['developer', 'owner', 'admin']
|
||||||
|
).order_by('role').first() or account.users.filter(is_active=True).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise AuthenticationFailed('No active user available for this account.')
|
||||||
|
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
|
||||||
|
|||||||
@@ -20,33 +20,35 @@ class AccountModelViewSet(viewsets.ModelViewSet):
|
|||||||
if hasattr(queryset.model, 'account'):
|
if hasattr(queryset.model, 'account'):
|
||||||
user = getattr(self.request, 'user', None)
|
user = getattr(self.request, 'user', None)
|
||||||
|
|
||||||
# ADMIN/DEV/SYSTEM ACCOUNT OVERRIDE: Skip account filtering for:
|
|
||||||
# - Admins and developers (by role)
|
|
||||||
# - Users in system accounts (aws-admin, default-account)
|
|
||||||
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
|
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
|
||||||
try:
|
# Bypass filtering for superusers - they can see everything
|
||||||
# Check if user has admin/developer privileges
|
if getattr(user, 'is_superuser', False):
|
||||||
is_admin_or_dev = (hasattr(user, 'is_admin_or_developer') and user.is_admin_or_developer()) if user else False
|
return queryset
|
||||||
is_system_user = (hasattr(user, 'is_system_account_user') and user.is_system_account_user()) if user else False
|
|
||||||
|
|
||||||
if is_admin_or_dev or is_system_user:
|
# Bypass filtering for developers
|
||||||
# Skip account filtering - allow all accounts
|
if hasattr(user, 'role') and user.role == 'developer':
|
||||||
pass
|
return queryset
|
||||||
else:
|
|
||||||
# Get account from request (set by middleware)
|
# Bypass filtering for system account users
|
||||||
account = getattr(self.request, 'account', None)
|
|
||||||
if account:
|
|
||||||
queryset = queryset.filter(account=account)
|
|
||||||
elif hasattr(self.request, 'user') and self.request.user and hasattr(self.request.user, 'is_authenticated') and self.request.user.is_authenticated:
|
|
||||||
# Fallback to user's account
|
|
||||||
try:
|
try:
|
||||||
|
if hasattr(user, 'is_system_account_user') and user.is_system_account_user():
|
||||||
|
return queryset
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
account = getattr(self.request, 'account', None)
|
||||||
|
if not account and hasattr(self.request, 'user') and self.request.user and hasattr(self.request.user, 'is_authenticated') and self.request.user.is_authenticated:
|
||||||
user_account = getattr(self.request.user, 'account', None)
|
user_account = getattr(self.request.user, 'account', None)
|
||||||
if user_account:
|
if user_account:
|
||||||
queryset = queryset.filter(account=user_account)
|
account = user_account
|
||||||
except (AttributeError, Exception):
|
|
||||||
# If account access fails (e.g., column mismatch), skip account filtering
|
if account:
|
||||||
pass
|
queryset = queryset.filter(account=account)
|
||||||
except (AttributeError, TypeError) as e:
|
else:
|
||||||
|
# No account context -> block access
|
||||||
|
return queryset.none()
|
||||||
|
except (AttributeError, TypeError):
|
||||||
# If there's an error accessing user attributes, return empty queryset
|
# If there's an error accessing user attributes, return empty queryset
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
else:
|
else:
|
||||||
@@ -61,11 +63,11 @@ class AccountModelViewSet(viewsets.ModelViewSet):
|
|||||||
try:
|
try:
|
||||||
account = getattr(self.request.user, 'account', None)
|
account = getattr(self.request.user, 'account', None)
|
||||||
except (AttributeError, Exception):
|
except (AttributeError, Exception):
|
||||||
# If account access fails (e.g., column mismatch), set to None
|
|
||||||
account = None
|
account = None
|
||||||
|
|
||||||
# If model has account field, set it
|
if hasattr(serializer.Meta.model, 'account'):
|
||||||
if account and hasattr(serializer.Meta.model, 'account'):
|
if not account:
|
||||||
|
raise PermissionDenied("Account context is required to create this object.")
|
||||||
serializer.save(account=account)
|
serializer.save(account=account)
|
||||||
else:
|
else:
|
||||||
serializer.save()
|
serializer.save()
|
||||||
@@ -181,6 +183,25 @@ class AccountModelViewSet(viewsets.ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
|
# Protect system account
|
||||||
|
if hasattr(instance, 'slug') and getattr(instance, 'slug', '') == 'aws-admin':
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
raise PermissionDenied("System account cannot be deleted.")
|
||||||
|
|
||||||
|
if hasattr(instance, 'soft_delete'):
|
||||||
|
user = getattr(request, 'user', None)
|
||||||
|
retention_days = None
|
||||||
|
account = getattr(instance, 'account', None)
|
||||||
|
if account and hasattr(account, 'deletion_retention_days'):
|
||||||
|
retention_days = account.deletion_retention_days
|
||||||
|
elif hasattr(instance, 'deletion_retention_days'):
|
||||||
|
retention_days = getattr(instance, 'deletion_retention_days', None)
|
||||||
|
instance.soft_delete(
|
||||||
|
user=user if getattr(user, 'is_authenticated', False) else None,
|
||||||
|
retention_days=retention_days,
|
||||||
|
reason='api_delete'
|
||||||
|
)
|
||||||
|
else:
|
||||||
self.perform_destroy(instance)
|
self.perform_destroy(instance)
|
||||||
return success_response(
|
return success_response(
|
||||||
data=None,
|
data=None,
|
||||||
@@ -233,25 +254,40 @@ class SiteSectorModelViewSet(AccountModelViewSet):
|
|||||||
|
|
||||||
# Check if user is authenticated and is a proper User instance (not AnonymousUser)
|
# Check if user is authenticated and is a proper User instance (not AnonymousUser)
|
||||||
if user and hasattr(user, 'is_authenticated') and user.is_authenticated and hasattr(user, 'get_accessible_sites'):
|
if user and hasattr(user, 'is_authenticated') and user.is_authenticated and hasattr(user, 'get_accessible_sites'):
|
||||||
|
# Bypass site filtering for superusers and developers
|
||||||
|
# They already got unfiltered queryset from parent AccountModelViewSet
|
||||||
|
if getattr(user, 'is_superuser', False) or (hasattr(user, 'role') and user.role == 'developer'):
|
||||||
|
# No site filtering for superuser/developer
|
||||||
|
# But still apply query param filters if provided
|
||||||
try:
|
try:
|
||||||
# ADMIN/DEV/SYSTEM ACCOUNT OVERRIDE: Developers, admins, and system account users
|
query_params = getattr(self.request, 'query_params', None)
|
||||||
# can see all data regardless of site/sector
|
if query_params is None:
|
||||||
if (hasattr(user, 'is_admin_or_developer') and user.is_admin_or_developer()) or \
|
query_params = getattr(self.request, 'GET', {})
|
||||||
(hasattr(user, 'is_system_account_user') and user.is_system_account_user()):
|
site_id = query_params.get('site_id') or query_params.get('site')
|
||||||
# Skip site/sector filtering for admins, developers, and system account users
|
except AttributeError:
|
||||||
# But still respect optional query params if provided
|
site_id = None
|
||||||
|
|
||||||
|
if site_id:
|
||||||
|
try:
|
||||||
|
site_id_int = int(site_id) if site_id else None
|
||||||
|
if site_id_int:
|
||||||
|
queryset = queryset.filter(site_id=site_id_int)
|
||||||
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
else:
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
try:
|
||||||
# Get user's accessible sites
|
# Get user's accessible sites
|
||||||
accessible_sites = user.get_accessible_sites()
|
accessible_sites = user.get_accessible_sites()
|
||||||
|
|
||||||
# If no accessible sites, return empty queryset (unless admin/developer/system account)
|
# If no accessible sites, return empty queryset
|
||||||
if not accessible_sites.exists():
|
if not accessible_sites.exists():
|
||||||
queryset = queryset.none()
|
queryset = queryset.none()
|
||||||
else:
|
else:
|
||||||
# Filter by accessible sites
|
# Filter by accessible sites
|
||||||
queryset = queryset.filter(site__in=accessible_sites)
|
queryset = queryset.filter(site__in=accessible_sites)
|
||||||
except (AttributeError, TypeError) as e:
|
except (AttributeError, TypeError):
|
||||||
# If there's an error accessing user attributes, return empty queryset
|
# If there's an error accessing user attributes, return empty queryset
|
||||||
queryset = queryset.none()
|
queryset = queryset.none()
|
||||||
else:
|
else:
|
||||||
@@ -265,9 +301,9 @@ class SiteSectorModelViewSet(AccountModelViewSet):
|
|||||||
if query_params is None:
|
if query_params is None:
|
||||||
# Fallback for non-DRF requests
|
# Fallback for non-DRF requests
|
||||||
query_params = getattr(self.request, 'GET', {})
|
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:
|
else:
|
||||||
site_id = query_params.get('site_id')
|
site_id = query_params.get('site_id') or query_params.get('site')
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
site_id = None
|
site_id = None
|
||||||
|
|
||||||
@@ -276,21 +312,14 @@ class SiteSectorModelViewSet(AccountModelViewSet):
|
|||||||
# Convert site_id to int if it's a string
|
# Convert site_id to int if it's a string
|
||||||
site_id_int = int(site_id) if site_id else None
|
site_id_int = int(site_id) if site_id else None
|
||||||
if site_id_int:
|
if site_id_int:
|
||||||
# ADMIN/DEV/SYSTEM ACCOUNT OVERRIDE: Admins, developers, and system account users
|
|
||||||
# can filter by any site, others must verify access
|
|
||||||
if user and hasattr(user, 'is_authenticated') and user.is_authenticated and hasattr(user, 'get_accessible_sites'):
|
if user and hasattr(user, 'is_authenticated') and user.is_authenticated and hasattr(user, 'get_accessible_sites'):
|
||||||
try:
|
try:
|
||||||
if (hasattr(user, 'is_admin_or_developer') and user.is_admin_or_developer()) or \
|
|
||||||
(hasattr(user, 'is_system_account_user') and user.is_system_account_user()):
|
|
||||||
# Admin/Developer/System Account User can filter by any site
|
|
||||||
queryset = queryset.filter(site_id=site_id_int)
|
|
||||||
else:
|
|
||||||
accessible_sites = user.get_accessible_sites()
|
accessible_sites = user.get_accessible_sites()
|
||||||
if accessible_sites.filter(id=site_id_int).exists():
|
if accessible_sites.filter(id=site_id_int).exists():
|
||||||
queryset = queryset.filter(site_id=site_id_int)
|
queryset = queryset.filter(site_id=site_id_int)
|
||||||
else:
|
else:
|
||||||
queryset = queryset.none() # Site not accessible
|
queryset = queryset.none() # Site not accessible
|
||||||
except (AttributeError, TypeError) as e:
|
except (AttributeError, TypeError):
|
||||||
# If there's an error accessing user attributes, return empty queryset
|
# If there's an error accessing user attributes, return empty queryset
|
||||||
queryset = queryset.none()
|
queryset = queryset.none()
|
||||||
else:
|
else:
|
||||||
@@ -350,10 +379,6 @@ class SiteSectorModelViewSet(AccountModelViewSet):
|
|||||||
|
|
||||||
if user and hasattr(user, 'is_authenticated') and user.is_authenticated and site:
|
if user and hasattr(user, 'is_authenticated') and user.is_authenticated and site:
|
||||||
try:
|
try:
|
||||||
# ADMIN/DEV/SYSTEM ACCOUNT OVERRIDE: Admins, developers, and system account users
|
|
||||||
# can create in any site, others must verify access
|
|
||||||
if not ((hasattr(user, 'is_admin_or_developer') and user.is_admin_or_developer()) or
|
|
||||||
(hasattr(user, 'is_system_account_user') and user.is_system_account_user())):
|
|
||||||
if hasattr(user, 'get_accessible_sites'):
|
if hasattr(user, 'get_accessible_sites'):
|
||||||
accessible_sites = user.get_accessible_sites()
|
accessible_sites = user.get_accessible_sites()
|
||||||
if not accessible_sites.filter(id=site.id).exists():
|
if not accessible_sites.filter(id=site.id).exists():
|
||||||
|
|||||||
@@ -12,13 +12,23 @@ class IsAuthenticatedAndActive(permissions.BasePermission):
|
|||||||
Base permission for most endpoints
|
Base permission for most endpoints
|
||||||
"""
|
"""
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if not request.user or not request.user.is_authenticated:
|
if not request.user or not request.user.is_authenticated:
|
||||||
|
logger.warning(f"[IsAuthenticatedAndActive] DENIED: User not authenticated")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if user is active
|
# Check if user is active
|
||||||
if hasattr(request.user, 'is_active'):
|
if hasattr(request.user, 'is_active'):
|
||||||
return request.user.is_active
|
is_active = request.user.is_active
|
||||||
|
if is_active:
|
||||||
|
logger.info(f"[IsAuthenticatedAndActive] ALLOWED: User {request.user.email} is active")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[IsAuthenticatedAndActive] DENIED: User {request.user.email} is inactive")
|
||||||
|
return is_active
|
||||||
|
|
||||||
|
logger.info(f"[IsAuthenticatedAndActive] ALLOWED: User {request.user.email} (no is_active check)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -26,44 +36,58 @@ class HasTenantAccess(permissions.BasePermission):
|
|||||||
"""
|
"""
|
||||||
Permission class that requires user to belong to the tenant/account
|
Permission class that requires user to belong to the tenant/account
|
||||||
Ensures tenant isolation
|
Ensures tenant isolation
|
||||||
|
Superusers, developers, and system account users bypass this check.
|
||||||
|
|
||||||
|
CRITICAL: Every authenticated user MUST have an account.
|
||||||
|
The middleware sets request.account from request.user.account.
|
||||||
|
If a user doesn't have an account, it's a data integrity issue.
|
||||||
"""
|
"""
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if not request.user or not request.user.is_authenticated:
|
if not request.user or not request.user.is_authenticated:
|
||||||
|
logger.warning(f"[HasTenantAccess] DENIED: User not authenticated")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Get account from request (set by middleware)
|
# Bypass for superusers
|
||||||
account = getattr(request, 'account', None)
|
if getattr(request.user, 'is_superuser', False):
|
||||||
|
logger.info(f"[HasTenantAccess] ALLOWED: User {request.user.email} is superuser")
|
||||||
# If no account in request, try to get from user
|
|
||||||
if not account and hasattr(request.user, 'account'):
|
|
||||||
try:
|
|
||||||
account = request.user.account
|
|
||||||
except (AttributeError, Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Admin/Developer/System account users bypass tenant check
|
|
||||||
if request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated:
|
|
||||||
try:
|
|
||||||
is_admin_or_dev = (hasattr(request.user, 'is_admin_or_developer') and
|
|
||||||
request.user.is_admin_or_developer()) if request.user else False
|
|
||||||
is_system_user = (hasattr(request.user, 'is_system_account_user') and
|
|
||||||
request.user.is_system_account_user()) if request.user else False
|
|
||||||
|
|
||||||
if is_admin_or_dev or is_system_user:
|
|
||||||
return True
|
return True
|
||||||
except (AttributeError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Regular users must have account access
|
# Bypass for developers
|
||||||
if account:
|
if hasattr(request.user, 'role') and request.user.role == 'developer':
|
||||||
# Check if user belongs to this account
|
logger.info(f"[HasTenantAccess] ALLOWED: User {request.user.email} is developer")
|
||||||
if hasattr(request.user, 'account'):
|
return True
|
||||||
|
|
||||||
|
# Bypass for system account users
|
||||||
try:
|
try:
|
||||||
user_account = request.user.account
|
if hasattr(request.user, 'is_system_account_user') and request.user.is_system_account_user():
|
||||||
return user_account == account or user_account.id == account.id
|
logger.info(f"[HasTenantAccess] ALLOWED: User {request.user.email} is system account user")
|
||||||
except (AttributeError, Exception):
|
return True
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# SIMPLIFIED LOGIC: Every authenticated user MUST have an account
|
||||||
|
# Middleware already set request.account from request.user.account
|
||||||
|
# Just verify it exists
|
||||||
|
if not hasattr(request.user, 'account'):
|
||||||
|
logger.warning(f"[HasTenantAccess] DENIED: User {request.user.email} has no account attribute")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Access the account to trigger any lazy loading
|
||||||
|
user_account = request.user.account
|
||||||
|
if not user_account:
|
||||||
|
logger.warning(f"[HasTenantAccess] DENIED: User {request.user.email} has NULL account")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Success - user has a valid account
|
||||||
|
logger.info(f"[HasTenantAccess] ALLOWED: User {request.user.email} has account {user_account.name} (ID: {user_account.id})")
|
||||||
|
return True
|
||||||
|
except (AttributeError, Exception) as e:
|
||||||
|
# User doesn't have account relationship - data integrity issue
|
||||||
|
logger.warning(f"[HasTenantAccess] DENIED: User {request.user.email} account access failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -71,30 +95,39 @@ class IsViewerOrAbove(permissions.BasePermission):
|
|||||||
"""
|
"""
|
||||||
Permission class that requires viewer, editor, admin, or owner role
|
Permission class that requires viewer, editor, admin, or owner role
|
||||||
For read-only operations
|
For read-only operations
|
||||||
|
Superusers and developers bypass this check.
|
||||||
"""
|
"""
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if not request.user or not request.user.is_authenticated:
|
if not request.user or not request.user.is_authenticated:
|
||||||
|
logger.warning(f"[IsViewerOrAbove] DENIED: User not authenticated")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Admin/Developer/System account users always have access
|
# Bypass for superusers
|
||||||
try:
|
if getattr(request.user, 'is_superuser', False):
|
||||||
is_admin_or_dev = (hasattr(request.user, 'is_admin_or_developer') and
|
logger.info(f"[IsViewerOrAbove] ALLOWED: User {request.user.email} is superuser")
|
||||||
request.user.is_admin_or_developer()) if request.user else False
|
return True
|
||||||
is_system_user = (hasattr(request.user, 'is_system_account_user') and
|
|
||||||
request.user.is_system_account_user()) if request.user else False
|
# Bypass for developers
|
||||||
|
if hasattr(request.user, 'role') and request.user.role == 'developer':
|
||||||
if is_admin_or_dev or is_system_user:
|
logger.info(f"[IsViewerOrAbove] ALLOWED: User {request.user.email} is developer")
|
||||||
return True
|
return True
|
||||||
except (AttributeError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check user role
|
# Check user role
|
||||||
if hasattr(request.user, 'role'):
|
if hasattr(request.user, 'role'):
|
||||||
role = request.user.role
|
role = request.user.role
|
||||||
# viewer, editor, admin, owner all have access
|
# viewer, editor, admin, owner all have access
|
||||||
return role in ['viewer', 'editor', 'admin', 'owner']
|
allowed = role in ['viewer', 'editor', 'admin', 'owner']
|
||||||
|
if allowed:
|
||||||
|
logger.info(f"[IsViewerOrAbove] ALLOWED: User {request.user.email} has role {role}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[IsViewerOrAbove] DENIED: User {request.user.email} has invalid role {role}")
|
||||||
|
return allowed
|
||||||
|
|
||||||
# If no role system, allow authenticated users
|
# If no role system, allow authenticated users
|
||||||
|
logger.info(f"[IsViewerOrAbove] ALLOWED: User {request.user.email} (no role system)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -102,22 +135,19 @@ class IsEditorOrAbove(permissions.BasePermission):
|
|||||||
"""
|
"""
|
||||||
Permission class that requires editor, admin, or owner role
|
Permission class that requires editor, admin, or owner role
|
||||||
For content operations
|
For content operations
|
||||||
|
Superusers and developers bypass this check.
|
||||||
"""
|
"""
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
if not request.user or not request.user.is_authenticated:
|
if not request.user or not request.user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Admin/Developer/System account users always have access
|
# Bypass for superusers
|
||||||
try:
|
if getattr(request.user, 'is_superuser', False):
|
||||||
is_admin_or_dev = (hasattr(request.user, 'is_admin_or_developer') and
|
return True
|
||||||
request.user.is_admin_or_developer()) if request.user else False
|
|
||||||
is_system_user = (hasattr(request.user, 'is_system_account_user') and
|
# Bypass for developers
|
||||||
request.user.is_system_account_user()) if request.user else False
|
if hasattr(request.user, 'role') and request.user.role == 'developer':
|
||||||
|
|
||||||
if is_admin_or_dev or is_system_user:
|
|
||||||
return True
|
return True
|
||||||
except (AttributeError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check user role
|
# Check user role
|
||||||
if hasattr(request.user, 'role'):
|
if hasattr(request.user, 'role'):
|
||||||
@@ -133,22 +163,19 @@ class IsAdminOrOwner(permissions.BasePermission):
|
|||||||
"""
|
"""
|
||||||
Permission class that requires admin or owner role only
|
Permission class that requires admin or owner role only
|
||||||
For settings, keys, billing operations
|
For settings, keys, billing operations
|
||||||
|
Superusers and developers bypass this check.
|
||||||
"""
|
"""
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
if not request.user or not request.user.is_authenticated:
|
if not request.user or not request.user.is_authenticated:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Admin/Developer/System account users always have access
|
# Bypass for superusers
|
||||||
try:
|
if getattr(request.user, 'is_superuser', False):
|
||||||
is_admin_or_dev = (hasattr(request.user, 'is_admin_or_developer') and
|
return True
|
||||||
request.user.is_admin_or_developer()) if request.user else False
|
|
||||||
is_system_user = (hasattr(request.user, 'is_system_account_user') and
|
# Bypass for developers
|
||||||
request.user.is_system_account_user()) if request.user else False
|
if hasattr(request.user, 'role') and request.user.role == 'developer':
|
||||||
|
|
||||||
if is_admin_or_dev or is_system_user:
|
|
||||||
return True
|
return True
|
||||||
except (AttributeError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check user role
|
# Check user role
|
||||||
if hasattr(request.user, 'role'):
|
if hasattr(request.user, 'role'):
|
||||||
@@ -160,3 +187,21 @@ class IsAdminOrOwner(permissions.BasePermission):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class IsSystemAccountOrDeveloper(permissions.BasePermission):
|
||||||
|
"""
|
||||||
|
Allow only system accounts (aws-admin/default-account/default) or developer role.
|
||||||
|
Use for sensitive, globally-scoped settings like integration API keys.
|
||||||
|
"""
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
user = getattr(request, "user", None)
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
account_slug = getattr(getattr(user, "account", None), "slug", None)
|
||||||
|
if user.role == "developer":
|
||||||
|
return True
|
||||||
|
if account_slug in ["aws-admin", "default-account", "default"]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Provides consistent response format across all endpoints
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
|
|
||||||
def get_request_id(request):
|
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,
|
'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:
|
if error:
|
||||||
response_data['error'] = error
|
response_data['error'] = error
|
||||||
elif status_code == status.HTTP_400_BAD_REQUEST:
|
elif status_code == status.HTTP_400_BAD_REQUEST:
|
||||||
|
|||||||
@@ -8,7 +8,20 @@ from drf_spectacular.utils import extend_schema, OpenApiResponse
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
# Explicit tags we want to keep (from SPECTACULAR_SETTINGS)
|
# Explicit tags we want to keep (from SPECTACULAR_SETTINGS)
|
||||||
EXPLICIT_TAGS = {'Authentication', 'Planner', 'Writer', 'System', 'Billing'}
|
EXPLICIT_TAGS = {
|
||||||
|
'Authentication',
|
||||||
|
'Planner',
|
||||||
|
'Writer',
|
||||||
|
'System',
|
||||||
|
'Billing',
|
||||||
|
'Account',
|
||||||
|
'Automation',
|
||||||
|
'Linker',
|
||||||
|
'Optimizer',
|
||||||
|
'Publisher',
|
||||||
|
'Integration',
|
||||||
|
'Admin Billing',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def postprocess_schema_filter_tags(result, generator, request, public):
|
def postprocess_schema_filter_tags(result, generator, request, public):
|
||||||
@@ -21,6 +34,11 @@ def postprocess_schema_filter_tags(result, generator, request, public):
|
|||||||
for path, methods in result['paths'].items():
|
for path, methods in result['paths'].items():
|
||||||
for method, operation in methods.items():
|
for method, operation in methods.items():
|
||||||
if isinstance(operation, dict) and 'tags' in operation:
|
if isinstance(operation, dict) and 'tags' in operation:
|
||||||
|
# Explicitly exclude system webhook from tagging/docs grouping
|
||||||
|
if '/system/webhook' in path:
|
||||||
|
operation['tags'] = []
|
||||||
|
continue
|
||||||
|
|
||||||
# Keep only explicit tags from the operation
|
# Keep only explicit tags from the operation
|
||||||
filtered_tags = [
|
filtered_tags = [
|
||||||
tag for tag in operation['tags']
|
tag for tag in operation['tags']
|
||||||
@@ -41,6 +59,20 @@ def postprocess_schema_filter_tags(result, generator, request, public):
|
|||||||
filtered_tags = ['System']
|
filtered_tags = ['System']
|
||||||
elif '/billing/' in path or '/api/v1/billing/' in path:
|
elif '/billing/' in path or '/api/v1/billing/' in path:
|
||||||
filtered_tags = ['Billing']
|
filtered_tags = ['Billing']
|
||||||
|
elif '/account/' in path or '/api/v1/account/' in path:
|
||||||
|
filtered_tags = ['Account']
|
||||||
|
elif '/automation/' in path or '/api/v1/automation/' in path:
|
||||||
|
filtered_tags = ['Automation']
|
||||||
|
elif '/linker/' in path or '/api/v1/linker/' in path:
|
||||||
|
filtered_tags = ['Linker']
|
||||||
|
elif '/optimizer/' in path or '/api/v1/optimizer/' in path:
|
||||||
|
filtered_tags = ['Optimizer']
|
||||||
|
elif '/publisher/' in path or '/api/v1/publisher/' in path:
|
||||||
|
filtered_tags = ['Publisher']
|
||||||
|
elif '/integration/' in path or '/api/v1/integration/' in path:
|
||||||
|
filtered_tags = ['Integration']
|
||||||
|
elif '/admin/' in path or '/api/v1/admin/' in path:
|
||||||
|
filtered_tags = ['Admin Billing']
|
||||||
|
|
||||||
operation['tags'] = filtered_tags
|
operation['tags'] = filtered_tags
|
||||||
|
|
||||||
|
|||||||
@@ -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'])
|
|
||||||
|
|
||||||
@@ -21,33 +21,37 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
|
|||||||
|
|
||||||
def allow_request(self, request, view):
|
def allow_request(self, request, view):
|
||||||
"""
|
"""
|
||||||
Check if request should be throttled
|
Check if request should be throttled.
|
||||||
|
DISABLED - Always allow all requests.
|
||||||
Bypasses throttling if:
|
|
||||||
- DEBUG mode is True
|
|
||||||
- IGNY8_DEBUG_THROTTLE environment variable is True
|
|
||||||
- User belongs to aws-admin or other system accounts
|
|
||||||
- User is admin/developer role
|
|
||||||
"""
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
# OLD CODE BELOW (DISABLED)
|
||||||
|
# Bypass for superusers and developers
|
||||||
|
if request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated:
|
||||||
|
if getattr(request.user, 'is_superuser', False):
|
||||||
|
return True
|
||||||
|
if hasattr(request.user, 'role') and request.user.role == 'developer':
|
||||||
|
return True
|
||||||
|
# Bypass for system account users
|
||||||
|
try:
|
||||||
|
if hasattr(request.user, 'is_system_account_user') and request.user.is_system_account_user():
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Check if throttling should be bypassed
|
# Check if throttling should be bypassed
|
||||||
debug_bypass = getattr(settings, 'DEBUG', False)
|
debug_bypass = getattr(settings, 'DEBUG', False)
|
||||||
env_bypass = getattr(settings, 'IGNY8_DEBUG_THROTTLE', False)
|
env_bypass = getattr(settings, 'IGNY8_DEBUG_THROTTLE', False)
|
||||||
|
|
||||||
# Bypass for system account users (aws-admin, default-account, etc.)
|
# Bypass for public blueprint list requests (Sites Renderer fallback)
|
||||||
system_account_bypass = False
|
public_blueprint_bypass = False
|
||||||
if hasattr(request, 'user') and request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated:
|
if hasattr(view, 'action') and view.action == 'list':
|
||||||
try:
|
if hasattr(request, 'query_params') and request.query_params.get('site'):
|
||||||
# Check if user is in system account (aws-admin, default-account, default)
|
if not request.user or not hasattr(request.user, 'is_authenticated') or not request.user.is_authenticated:
|
||||||
if hasattr(request.user, 'is_system_account_user') and request.user.is_system_account_user():
|
public_blueprint_bypass = True
|
||||||
system_account_bypass = True
|
|
||||||
# Also bypass for admin/developer roles
|
|
||||||
elif hasattr(request.user, 'is_admin_or_developer') and request.user.is_admin_or_developer():
|
|
||||||
system_account_bypass = True
|
|
||||||
except (AttributeError, Exception):
|
|
||||||
# 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 public_blueprint_bypass:
|
||||||
# In debug mode or for system accounts, still set throttle headers but don't actually throttle
|
# In debug mode or for system accounts, still set throttle headers but don't actually throttle
|
||||||
# This allows testing throttle headers without blocking requests
|
# This allows testing throttle headers without blocking requests
|
||||||
if hasattr(self, 'get_rate'):
|
if hasattr(self, 'get_rate'):
|
||||||
@@ -68,9 +72,27 @@ class DebugScopedRateThrottle(ScopedRateThrottle):
|
|||||||
}
|
}
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Normal throttling behavior
|
# Normal throttling with per-account keying
|
||||||
return super().allow_request(request, view)
|
return super().allow_request(request, view)
|
||||||
|
|
||||||
|
def get_cache_key(self, request, view):
|
||||||
|
"""
|
||||||
|
Override to add account-based throttle keying.
|
||||||
|
Keys by (scope, account.id) instead of just user.
|
||||||
|
"""
|
||||||
|
if not self.scope:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get account from request
|
||||||
|
account = getattr(request, 'account', None)
|
||||||
|
if not account and hasattr(request, 'user') and request.user and request.user.is_authenticated:
|
||||||
|
account = getattr(request.user, 'account', None)
|
||||||
|
|
||||||
|
account_id = account.id if account else 'anon'
|
||||||
|
|
||||||
|
# Build throttle key: scope:account_id
|
||||||
|
return f'{self.scope}:{account_id}'
|
||||||
|
|
||||||
def get_rate(self):
|
def get_rate(self):
|
||||||
"""
|
"""
|
||||||
Get rate for the current scope
|
Get rate for the current scope
|
||||||
|
|||||||
26
backend/igny8_core/api/urls.py
Normal file
26
backend/igny8_core/api/urls.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""
|
||||||
|
URL patterns for account management API
|
||||||
|
"""
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .account_views import (
|
||||||
|
AccountSettingsViewSet,
|
||||||
|
TeamManagementViewSet,
|
||||||
|
UsageAnalyticsViewSet
|
||||||
|
)
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Account settings (non-router endpoints for simplified access)
|
||||||
|
path('settings/', AccountSettingsViewSet.as_view({'get': 'retrieve', 'patch': 'partial_update'}), name='account-settings'),
|
||||||
|
|
||||||
|
# Team management
|
||||||
|
path('team/', TeamManagementViewSet.as_view({'get': 'list', 'post': 'create'}), name='team-list'),
|
||||||
|
path('team/<int:pk>/', TeamManagementViewSet.as_view({'delete': 'destroy'}), name='team-detail'),
|
||||||
|
|
||||||
|
# Usage analytics
|
||||||
|
path('usage/analytics/', UsageAnalyticsViewSet.as_view({'get': 'overview'}), name='usage-analytics'),
|
||||||
|
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
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
|
||||||
|
)
|
||||||
@@ -1,38 +1,141 @@
|
|||||||
"""
|
"""
|
||||||
Admin interface for auth models
|
Admin interface for auth models
|
||||||
"""
|
"""
|
||||||
|
from django import forms
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
from igny8_core.admin.base import AccountAdminMixin
|
from unfold.admin import ModelAdmin, TabularInline
|
||||||
|
from simple_history.admin import SimpleHistoryAdmin
|
||||||
|
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
|
||||||
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword, PasswordResetToken
|
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword, PasswordResetToken
|
||||||
|
from import_export.admin import ExportMixin
|
||||||
|
from import_export import resources
|
||||||
|
|
||||||
|
|
||||||
|
class AccountAdminForm(forms.ModelForm):
|
||||||
|
"""Custom form for Account admin with dynamic payment method choices from PaymentMethodConfig"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Account
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
from igny8_core.business.billing.models import PaymentMethodConfig, AccountPaymentMethod
|
||||||
|
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
# Get country from billing_country, fallback to wildcard '*' for global
|
||||||
|
country = self.instance.billing_country or '*'
|
||||||
|
|
||||||
|
# Get enabled payment methods for this country OR global (*)
|
||||||
|
available_methods = PaymentMethodConfig.objects.filter(
|
||||||
|
country_code__in=[country, '*'],
|
||||||
|
is_enabled=True
|
||||||
|
).order_by('country_code', 'sort_order').values_list('payment_method', 'display_name')
|
||||||
|
|
||||||
|
if available_methods:
|
||||||
|
# Build choices from PaymentMethodConfig
|
||||||
|
choices = []
|
||||||
|
seen = set()
|
||||||
|
for method_type, display_name in available_methods:
|
||||||
|
if method_type not in seen:
|
||||||
|
choices.append((method_type, display_name or method_type.replace('_', ' ').title()))
|
||||||
|
seen.add(method_type)
|
||||||
|
else:
|
||||||
|
# Fallback to model choices if no configs
|
||||||
|
choices = Account.PAYMENT_METHOD_CHOICES
|
||||||
|
|
||||||
|
self.fields['payment_method'].widget = forms.Select(choices=choices)
|
||||||
|
|
||||||
|
# Get current default from AccountPaymentMethod
|
||||||
|
default_method = AccountPaymentMethod.objects.filter(
|
||||||
|
account=self.instance,
|
||||||
|
is_default=True,
|
||||||
|
is_enabled=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if default_method:
|
||||||
|
self.fields['payment_method'].initial = default_method.type
|
||||||
|
self.fields['payment_method'].help_text = f'✓ Current: {default_method.display_name} ({default_method.get_type_display()})'
|
||||||
|
else:
|
||||||
|
self.fields['payment_method'].help_text = 'Select from available payment methods based on country'
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
"""When payment_method changes, update/create AccountPaymentMethod"""
|
||||||
|
from igny8_core.business.billing.models import AccountPaymentMethod, PaymentMethodConfig
|
||||||
|
|
||||||
|
instance = super().save(commit=False)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
# Get selected payment method
|
||||||
|
selected_type = self.cleaned_data.get('payment_method')
|
||||||
|
|
||||||
|
if selected_type:
|
||||||
|
# Get config for display name and instructions
|
||||||
|
country = instance.billing_country or '*'
|
||||||
|
config = PaymentMethodConfig.objects.filter(
|
||||||
|
country_code__in=[country, '*'],
|
||||||
|
payment_method=selected_type,
|
||||||
|
is_enabled=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# Create or update AccountPaymentMethod
|
||||||
|
account_method, created = AccountPaymentMethod.objects.get_or_create(
|
||||||
|
account=instance,
|
||||||
|
type=selected_type,
|
||||||
|
defaults={
|
||||||
|
'display_name': config.display_name if config else selected_type.replace('_', ' ').title(),
|
||||||
|
'is_default': True,
|
||||||
|
'is_enabled': True,
|
||||||
|
'instructions': config.instructions if config else '',
|
||||||
|
'country_code': instance.billing_country or '',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
# Update existing and set as default
|
||||||
|
account_method.is_default = True
|
||||||
|
account_method.is_enabled = True
|
||||||
|
if config:
|
||||||
|
account_method.display_name = config.display_name
|
||||||
|
account_method.instructions = config.instructions
|
||||||
|
account_method.save()
|
||||||
|
|
||||||
|
# Unset other methods as default
|
||||||
|
AccountPaymentMethod.objects.filter(
|
||||||
|
account=instance
|
||||||
|
).exclude(id=account_method.id).update(is_default=False)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Plan)
|
@admin.register(Plan)
|
||||||
class PlanAdmin(admin.ModelAdmin):
|
class PlanAdmin(Igny8ModelAdmin):
|
||||||
"""Plan admin - Global, no account filtering needed"""
|
"""Plan admin - Global, no account filtering needed"""
|
||||||
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'included_credits', 'is_active']
|
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_content_words', 'included_credits', 'is_active', 'is_featured']
|
||||||
list_filter = ['is_active', 'billing_cycle']
|
list_filter = ['is_active', 'billing_cycle', 'is_internal', 'is_featured']
|
||||||
search_fields = ['name', 'slug']
|
search_fields = ['name', 'slug']
|
||||||
readonly_fields = ['created_at']
|
readonly_fields = ['created_at']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Plan Info', {
|
('Plan Info', {
|
||||||
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active')
|
'fields': ('name', 'slug', 'price', 'original_price', 'annual_discount_percent', 'billing_cycle', 'features', 'is_active', 'is_featured', 'is_internal'),
|
||||||
|
'description': 'Price: Current price | Original Price: Crossed-out price (optional) | Annual Discount %: For annual billing | Is Featured: Show as popular/recommended plan'
|
||||||
}),
|
}),
|
||||||
('User / Site Limits', {
|
('Account Management Limits', {
|
||||||
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles')
|
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles'),
|
||||||
|
'description': 'Persistent limits for account-level resources'
|
||||||
}),
|
}),
|
||||||
('Planner Limits', {
|
('Hard Limits (Persistent)', {
|
||||||
'fields': ('max_keywords', 'max_clusters', 'daily_cluster_limit', 'daily_keyword_import_limit', 'monthly_cluster_ai_credits')
|
'fields': ('max_keywords', 'max_clusters'),
|
||||||
|
'description': 'Total allowed - never reset'
|
||||||
}),
|
}),
|
||||||
('Writer Limits', {
|
('Monthly Limits (Reset on Billing Cycle)', {
|
||||||
'fields': ('daily_content_tasks', 'daily_ai_requests', 'monthly_word_count_limit', 'monthly_content_ai_credits')
|
'fields': ('max_content_ideas', 'max_content_words', 'max_images_basic', 'max_images_premium', 'max_image_prompts'),
|
||||||
}),
|
'description': 'Monthly allowances - reset at billing cycle'
|
||||||
('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', {
|
('Billing & Credits', {
|
||||||
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
|
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
|
||||||
@@ -43,12 +146,23 @@ class PlanAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountResource(resources.ModelResource):
|
||||||
|
"""Resource class for exporting Accounts"""
|
||||||
|
class Meta:
|
||||||
|
model = Account
|
||||||
|
fields = ('id', 'name', 'slug', 'owner__email', 'plan__name', 'status',
|
||||||
|
'credits', 'billing_country', 'created_at', 'updated_at')
|
||||||
|
export_order = fields
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Account)
|
@admin.register(Account)
|
||||||
class AccountAdmin(AccountAdminMixin, admin.ModelAdmin):
|
class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8ModelAdmin):
|
||||||
list_display = ['name', 'slug', 'owner', 'plan', 'status', 'credits', 'created_at']
|
resource_class = AccountResource
|
||||||
|
form = AccountAdminForm
|
||||||
|
list_display = ['name', 'slug', 'owner', 'plan', 'status', 'health_indicator', 'credits', 'created_at']
|
||||||
list_filter = ['status', 'plan']
|
list_filter = ['status', 'plan']
|
||||||
search_fields = ['name', 'slug']
|
search_fields = ['name', 'slug']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at', 'health_indicator', 'health_details']
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
"""Override to filter by account for non-superusers"""
|
"""Override to filter by account for non-superusers"""
|
||||||
@@ -68,9 +182,145 @@ class AccountAdmin(AccountAdminMixin, admin.ModelAdmin):
|
|||||||
pass
|
pass
|
||||||
return qs.none()
|
return qs.none()
|
||||||
|
|
||||||
|
def health_indicator(self, obj):
|
||||||
|
"""Display health status with visual indicator"""
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
# Check credits
|
||||||
|
if obj.credits < 10:
|
||||||
|
status = 'critical'
|
||||||
|
message = 'Critical: Very low credits'
|
||||||
|
elif obj.credits < 100:
|
||||||
|
status = 'warning'
|
||||||
|
message = 'Warning: Low credits'
|
||||||
|
else:
|
||||||
|
status = 'good'
|
||||||
|
message = 'Good'
|
||||||
|
|
||||||
|
# Check for recent failed automations
|
||||||
|
try:
|
||||||
|
from igny8_core.business.automation.models import AutomationRun
|
||||||
|
week_ago = timezone.now() - timedelta(days=7)
|
||||||
|
failed_runs = AutomationRun.objects.filter(
|
||||||
|
account=obj,
|
||||||
|
status='failed',
|
||||||
|
created_at__gte=week_ago
|
||||||
|
).count()
|
||||||
|
|
||||||
|
if failed_runs > 5:
|
||||||
|
status = 'critical'
|
||||||
|
message = f'Critical: {failed_runs} automation failures'
|
||||||
|
elif failed_runs > 0:
|
||||||
|
if status == 'good':
|
||||||
|
status = 'warning'
|
||||||
|
message = f'Warning: {failed_runs} automation failures'
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check account status
|
||||||
|
if obj.status != 'active':
|
||||||
|
status = 'critical'
|
||||||
|
message = f'Critical: Account {obj.status}'
|
||||||
|
|
||||||
|
colors = {
|
||||||
|
'good': '#0bbf87',
|
||||||
|
'warning': '#ff7a00',
|
||||||
|
'critical': '#ef4444'
|
||||||
|
}
|
||||||
|
|
||||||
|
return format_html(
|
||||||
|
'<span style="display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 6px; background-color: {}; color: white; font-weight: 500; font-size: 13px;">{}</span>',
|
||||||
|
colors[status], message
|
||||||
|
)
|
||||||
|
health_indicator.short_description = 'Health'
|
||||||
|
|
||||||
|
def health_details(self, obj):
|
||||||
|
"""Detailed health information"""
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
details = []
|
||||||
|
|
||||||
|
# Credits status
|
||||||
|
colors = {
|
||||||
|
'critical': '#ef4444',
|
||||||
|
'warning': '#ff7a00',
|
||||||
|
'good': '#0bbf87'
|
||||||
|
}
|
||||||
|
|
||||||
|
if obj.credits < 10:
|
||||||
|
details.append(f'<span style="color: {colors["critical"]}; font-weight: 600;">Critical:</span> Only {obj.credits} credits remaining')
|
||||||
|
elif obj.credits < 100:
|
||||||
|
details.append(f'<span style="color: {colors["warning"]}; font-weight: 600;">Warning:</span> Only {obj.credits} credits remaining')
|
||||||
|
else:
|
||||||
|
details.append(f'<span style="color: {colors["good"]}; font-weight: 600;">Credits:</span> {obj.credits} available')
|
||||||
|
|
||||||
|
# Recent activity
|
||||||
|
try:
|
||||||
|
from igny8_core.modules.writer.models import Content
|
||||||
|
week_ago = timezone.now() - timedelta(days=7)
|
||||||
|
recent_content = Content.objects.filter(
|
||||||
|
site__account=obj,
|
||||||
|
created_at__gte=week_ago
|
||||||
|
).count()
|
||||||
|
details.append(f'📚 <b>Activity:</b> {recent_content} content pieces created this week')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Failed automations
|
||||||
|
try:
|
||||||
|
from igny8_core.business.automation.models import AutomationRun
|
||||||
|
week_ago = timezone.now() - timedelta(days=7)
|
||||||
|
failed_runs = AutomationRun.objects.filter(
|
||||||
|
account=obj,
|
||||||
|
status='failed',
|
||||||
|
created_at__gte=week_ago
|
||||||
|
).count()
|
||||||
|
|
||||||
|
if failed_runs > 0:
|
||||||
|
details.append(f'🔴 <b>Automations:</b> {failed_runs} failures this week')
|
||||||
|
else:
|
||||||
|
details.append(f'✅ <b>Automations:</b> No failures this week')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Failed syncs
|
||||||
|
try:
|
||||||
|
from igny8_core.business.integration.models import SyncEvent
|
||||||
|
today = timezone.now().date()
|
||||||
|
failed_syncs = SyncEvent.objects.filter(
|
||||||
|
site__account=obj,
|
||||||
|
success=False,
|
||||||
|
created_at__date=today
|
||||||
|
).count()
|
||||||
|
|
||||||
|
if failed_syncs > 0:
|
||||||
|
details.append(f'⚠️ <b>Syncs:</b> {failed_syncs} failures today')
|
||||||
|
else:
|
||||||
|
details.append(f'✅ <b>Syncs:</b> No failures today')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Account status
|
||||||
|
if obj.status == 'active':
|
||||||
|
details.append(f'✅ <b>Status:</b> Active')
|
||||||
|
else:
|
||||||
|
details.append(f'🔴 <b>Status:</b> {obj.status.title()}')
|
||||||
|
|
||||||
|
return format_html('<br>'.join(details))
|
||||||
|
health_details.short_description = 'Health Details'
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
if obj and getattr(obj, 'slug', '') == 'aws-admin':
|
||||||
|
return False
|
||||||
|
return super().has_delete_permission(request, obj)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Subscription)
|
@admin.register(Subscription)
|
||||||
class SubscriptionAdmin(AccountAdminMixin, admin.ModelAdmin):
|
class SubscriptionAdmin(AccountAdminMixin, Igny8ModelAdmin):
|
||||||
list_display = ['account', 'status', 'current_period_start', 'current_period_end']
|
list_display = ['account', 'status', 'current_period_start', 'current_period_end']
|
||||||
list_filter = ['status']
|
list_filter = ['status']
|
||||||
search_fields = ['account__name', 'stripe_subscription_id']
|
search_fields = ['account__name', 'stripe_subscription_id']
|
||||||
@@ -78,7 +328,7 @@ class SubscriptionAdmin(AccountAdminMixin, admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(PasswordResetToken)
|
@admin.register(PasswordResetToken)
|
||||||
class PasswordResetTokenAdmin(admin.ModelAdmin):
|
class PasswordResetTokenAdmin(Igny8ModelAdmin):
|
||||||
list_display = ['user', 'token', 'used', 'expires_at', 'created_at']
|
list_display = ['user', 'token', 'used', 'expires_at', 'created_at']
|
||||||
list_filter = ['used', 'expires_at', 'created_at']
|
list_filter = ['used', 'expires_at', 'created_at']
|
||||||
search_fields = ['user__email', 'token']
|
search_fields = ['user__email', 'token']
|
||||||
@@ -95,7 +345,7 @@ class PasswordResetTokenAdmin(admin.ModelAdmin):
|
|||||||
return qs.none()
|
return qs.none()
|
||||||
|
|
||||||
|
|
||||||
class SectorInline(admin.TabularInline):
|
class SectorInline(TabularInline):
|
||||||
"""Inline admin for sectors within Site admin."""
|
"""Inline admin for sectors within Site admin."""
|
||||||
model = Sector
|
model = Sector
|
||||||
extra = 0
|
extra = 0
|
||||||
@@ -115,13 +365,78 @@ class SectorInline(admin.TabularInline):
|
|||||||
get_clusters_count.short_description = 'Clusters'
|
get_clusters_count.short_description = 'Clusters'
|
||||||
|
|
||||||
|
|
||||||
|
class SiteResource(resources.ModelResource):
|
||||||
|
"""Resource class for exporting Sites"""
|
||||||
|
class Meta:
|
||||||
|
model = Site
|
||||||
|
fields = ('id', 'name', 'slug', 'account__name', 'industry__name', 'domain',
|
||||||
|
'status', 'is_active', 'site_type', 'hosting_type', 'created_at')
|
||||||
|
export_order = fields
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Site)
|
@admin.register(Site)
|
||||||
class SiteAdmin(AccountAdminMixin, admin.ModelAdmin):
|
class SiteAdmin(ExportMixin, AccountAdminMixin, Igny8ModelAdmin):
|
||||||
list_display = ['name', 'slug', 'account', 'industry', 'domain', 'status', 'is_active', 'get_sectors_count']
|
resource_class = SiteResource
|
||||||
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']
|
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]
|
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': ('get_api_key_display',),
|
||||||
|
'description': 'WordPress integration API key. Use SiteIntegration model for full integration settings.'
|
||||||
|
}),
|
||||||
|
('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"""
|
||||||
|
from django.utils.html import format_html
|
||||||
|
if obj.wp_api_key:
|
||||||
|
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):
|
def get_sectors_count(self, obj):
|
||||||
try:
|
try:
|
||||||
@@ -140,7 +455,7 @@ class SiteAdmin(AccountAdminMixin, admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Sector)
|
@admin.register(Sector)
|
||||||
class SectorAdmin(AccountAdminMixin, admin.ModelAdmin):
|
class SectorAdmin(AccountAdminMixin, Igny8ModelAdmin):
|
||||||
list_display = ['name', 'slug', 'site', 'industry_sector', 'get_industry', 'status', 'is_active', 'get_keywords_count', 'get_clusters_count']
|
list_display = ['name', 'slug', 'site', 'industry_sector', 'get_industry', 'status', 'is_active', 'get_keywords_count', 'get_clusters_count']
|
||||||
list_filter = ['status', 'is_active', 'site', 'industry_sector__industry']
|
list_filter = ['status', 'is_active', 'site', 'industry_sector__industry']
|
||||||
search_fields = ['name', 'slug', 'site__name', 'industry_sector__name']
|
search_fields = ['name', 'slug', 'site__name', 'industry_sector__name']
|
||||||
@@ -178,14 +493,14 @@ class SectorAdmin(AccountAdminMixin, admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(SiteUserAccess)
|
@admin.register(SiteUserAccess)
|
||||||
class SiteUserAccessAdmin(admin.ModelAdmin):
|
class SiteUserAccessAdmin(Igny8ModelAdmin):
|
||||||
list_display = ['user', 'site', 'granted_at', 'granted_by']
|
list_display = ['user', 'site', 'granted_at', 'granted_by']
|
||||||
list_filter = ['granted_at']
|
list_filter = ['granted_at']
|
||||||
search_fields = ['user__email', 'site__name']
|
search_fields = ['user__email', 'site__name']
|
||||||
readonly_fields = ['granted_at']
|
readonly_fields = ['granted_at']
|
||||||
|
|
||||||
|
|
||||||
class IndustrySectorInline(admin.TabularInline):
|
class IndustrySectorInline(TabularInline):
|
||||||
"""Inline admin for industry sectors within Industry admin."""
|
"""Inline admin for industry sectors within Industry admin."""
|
||||||
model = IndustrySector
|
model = IndustrySector
|
||||||
extra = 0
|
extra = 0
|
||||||
@@ -194,33 +509,44 @@ class IndustrySectorInline(admin.TabularInline):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Industry)
|
@admin.register(Industry)
|
||||||
class IndustryAdmin(admin.ModelAdmin):
|
class IndustryAdmin(Igny8ModelAdmin):
|
||||||
list_display = ['name', 'slug', 'is_active', 'get_sectors_count', 'created_at']
|
list_display = ['name', 'slug', 'is_active', 'get_sectors_count', 'created_at']
|
||||||
list_filter = ['is_active']
|
list_filter = ['is_active']
|
||||||
search_fields = ['name', 'slug', 'description']
|
search_fields = ['name', 'slug', 'description']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
inlines = [IndustrySectorInline]
|
inlines = [IndustrySectorInline]
|
||||||
|
actions = ['delete_selected'] # Enable bulk delete
|
||||||
|
|
||||||
def get_sectors_count(self, obj):
|
def get_sectors_count(self, obj):
|
||||||
return obj.sectors.filter(is_active=True).count()
|
return obj.sectors.filter(is_active=True).count()
|
||||||
get_sectors_count.short_description = 'Active Sectors'
|
get_sectors_count.short_description = 'Active Sectors'
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
"""Allow deletion for superusers and developers"""
|
||||||
|
return request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer())
|
||||||
|
|
||||||
|
|
||||||
@admin.register(IndustrySector)
|
@admin.register(IndustrySector)
|
||||||
class IndustrySectorAdmin(admin.ModelAdmin):
|
class IndustrySectorAdmin(Igny8ModelAdmin):
|
||||||
list_display = ['name', 'slug', 'industry', 'is_active']
|
list_display = ['name', 'slug', 'industry', 'is_active']
|
||||||
list_filter = ['is_active', 'industry']
|
list_filter = ['is_active', 'industry']
|
||||||
search_fields = ['name', 'slug', 'description']
|
search_fields = ['name', 'slug', 'description']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
actions = ['delete_selected'] # Enable bulk delete
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
"""Allow deletion for superusers and developers"""
|
||||||
|
return request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer())
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SeedKeyword)
|
@admin.register(SeedKeyword)
|
||||||
class SeedKeywordAdmin(admin.ModelAdmin):
|
class SeedKeywordAdmin(Igny8ModelAdmin):
|
||||||
"""SeedKeyword admin - Global reference data, no account filtering"""
|
"""SeedKeyword admin - Global reference data, no account filtering"""
|
||||||
list_display = ['keyword', 'industry', 'sector', 'volume', 'difficulty', 'intent', 'is_active', 'created_at']
|
list_display = ['keyword', 'industry', 'sector', 'volume', 'difficulty', 'intent', 'is_active', 'created_at']
|
||||||
list_filter = ['is_active', 'industry', 'sector', 'intent']
|
list_filter = ['is_active', 'industry', 'sector', 'intent']
|
||||||
search_fields = ['keyword']
|
search_fields = ['keyword']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
actions = ['delete_selected'] # Enable bulk delete
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Keyword Info', {
|
('Keyword Info', {
|
||||||
@@ -234,9 +560,27 @@ class SeedKeywordAdmin(admin.ModelAdmin):
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
"""Allow deletion for superusers and developers"""
|
||||||
|
return request.user.is_superuser or (hasattr(request.user, 'is_developer') and request.user.is_developer())
|
||||||
|
|
||||||
|
|
||||||
|
class UserResource(resources.ModelResource):
|
||||||
|
"""Resource class for exporting Users"""
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ('id', 'email', 'username', 'account__name', 'role',
|
||||||
|
'is_active', 'is_staff', 'created_at', 'last_login')
|
||||||
|
export_order = fields
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
class UserAdmin(BaseUserAdmin):
|
class UserAdmin(ExportMixin, BaseUserAdmin, Igny8ModelAdmin):
|
||||||
|
"""
|
||||||
|
User admin using both Django's BaseUserAdmin (for user-specific functionality)
|
||||||
|
and Unfold's ModelAdmin (for modern UI and styling including popups)
|
||||||
|
"""
|
||||||
|
resource_class = UserResource
|
||||||
list_display = ['email', 'username', 'account', 'role', 'is_active', 'is_staff', 'created_at']
|
list_display = ['email', 'username', 'account', 'role', 'is_active', 'is_staff', 'created_at']
|
||||||
list_filter = ['role', 'account', 'is_active', 'is_staff']
|
list_filter = ['role', 'account', 'is_active', 'is_staff']
|
||||||
search_fields = ['email', 'username']
|
search_fields = ['email', 'username']
|
||||||
|
|||||||
35
backend/igny8_core/auth/backends.py
Normal file
35
backend/igny8_core/auth/backends.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Custom Authentication Backend - No Caching
|
||||||
|
Prevents cross-request user contamination by disabling Django's default user caching
|
||||||
|
"""
|
||||||
|
from django.contrib.auth.backends import ModelBackend
|
||||||
|
|
||||||
|
|
||||||
|
class NoCacheModelBackend(ModelBackend):
|
||||||
|
"""
|
||||||
|
Custom authentication backend that disables user object caching.
|
||||||
|
|
||||||
|
Django's default ModelBackend caches the user object in thread-local storage,
|
||||||
|
which can cause cross-request contamination when the same worker process
|
||||||
|
handles requests from different users.
|
||||||
|
|
||||||
|
This backend forces a fresh DB query on EVERY request to prevent user swapping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_user(self, user_id):
|
||||||
|
"""
|
||||||
|
Get user from database WITHOUT caching.
|
||||||
|
|
||||||
|
This overrides the default behavior which caches user objects
|
||||||
|
at the process level, causing session contamination.
|
||||||
|
"""
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
UserModel = get_user_model()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# CRITICAL: Use select_related to load account/plan in ONE query
|
||||||
|
# But do NOT cache the result - return fresh object every time
|
||||||
|
user = UserModel.objects.select_related('account', 'account__plan').get(pk=user_id)
|
||||||
|
return user
|
||||||
|
except UserModel.DoesNotExist:
|
||||||
|
return None
|
||||||
@@ -8,7 +8,7 @@ from django.db.models import Q
|
|||||||
from igny8_core.auth.models import Account, User, Site, Sector
|
from igny8_core.auth.models import Account, User, Site, Sector
|
||||||
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
|
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
|
||||||
from igny8_core.modules.writer.models import Tasks, Images, Content
|
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.models import AIPrompt, IntegrationSettings, AuthorProfile, Strategy
|
||||||
from igny8_core.modules.system.settings_models import AccountSettings, UserSettings, ModuleSettings, AISettings
|
from igny8_core.modules.system.settings_models import AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Management command to clean up expired and orphaned sessions
|
||||||
|
Helps prevent session contamination and reduces DB bloat
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.contrib.sessions.models import Session
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Clean up expired sessions and detect session contamination'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run',
|
||||||
|
action='store_true',
|
||||||
|
help='Show what would be deleted without actually deleting',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--days',
|
||||||
|
type=int,
|
||||||
|
default=7,
|
||||||
|
help='Delete sessions older than X days (default: 7)',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = options['dry_run']
|
||||||
|
days = options['days']
|
||||||
|
cutoff_date = datetime.now() - timedelta(days=days)
|
||||||
|
|
||||||
|
# Get all sessions
|
||||||
|
all_sessions = Session.objects.all()
|
||||||
|
expired_sessions = Session.objects.filter(expire_date__lt=datetime.now())
|
||||||
|
old_sessions = Session.objects.filter(expire_date__lt=cutoff_date)
|
||||||
|
|
||||||
|
self.stdout.write(f"\n📊 Session Statistics:")
|
||||||
|
self.stdout.write(f" Total sessions: {all_sessions.count()}")
|
||||||
|
self.stdout.write(f" Expired sessions: {expired_sessions.count()}")
|
||||||
|
self.stdout.write(f" Sessions older than {days} days: {old_sessions.count()}")
|
||||||
|
|
||||||
|
# Count sessions by user
|
||||||
|
user_sessions = {}
|
||||||
|
for session in all_sessions:
|
||||||
|
try:
|
||||||
|
data = session.get_decoded()
|
||||||
|
user_id = data.get('_auth_user_id')
|
||||||
|
if user_id:
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
key = f"{user.username} ({user.account.slug if user.account else 'no-account'})"
|
||||||
|
user_sessions[key] = user_sessions.get(key, 0) + 1
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if user_sessions:
|
||||||
|
self.stdout.write(f"\n📈 Active sessions by user:")
|
||||||
|
for user_key, count in sorted(user_sessions.items(), key=lambda x: x[1], reverse=True)[:10]:
|
||||||
|
indicator = "⚠️ " if count > 20 else " "
|
||||||
|
self.stdout.write(f"{indicator}{user_key}: {count} sessions")
|
||||||
|
|
||||||
|
# Delete expired sessions
|
||||||
|
if expired_sessions.exists():
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING(f"\n[DRY RUN] Would delete {expired_sessions.count()} expired sessions"))
|
||||||
|
else:
|
||||||
|
count = expired_sessions.delete()[0]
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"\n✓ Deleted {count} expired sessions"))
|
||||||
|
else:
|
||||||
|
self.stdout.write(f"\n✓ No expired sessions to clean")
|
||||||
|
|
||||||
|
# Detect potential contamination
|
||||||
|
warnings = []
|
||||||
|
for user_key, count in user_sessions.items():
|
||||||
|
if count > 50:
|
||||||
|
warnings.append(f"User '{user_key}' has {count} active sessions (potential proliferation)")
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
self.stdout.write(self.style.WARNING(f"\n⚠️ Contamination Warnings:"))
|
||||||
|
for warning in warnings:
|
||||||
|
self.stdout.write(self.style.WARNING(f" {warning}"))
|
||||||
|
self.stdout.write(f"\n💡 Consider running: python manage.py clearsessions")
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Management command to create or update the Free Trial plan
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from igny8_core.auth.models import Plan
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Create or update the Free Trial plan for signup'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write('Creating/updating Free Trial plan...')
|
||||||
|
|
||||||
|
plan, created = Plan.objects.update_or_create(
|
||||||
|
slug='free-trial',
|
||||||
|
defaults={
|
||||||
|
'name': 'Free Trial',
|
||||||
|
'price': 0.00,
|
||||||
|
'billing_cycle': 'monthly',
|
||||||
|
'included_credits': 2000, # 2000 credits for trial
|
||||||
|
'credits_per_month': 2000, # Legacy field
|
||||||
|
'max_sites': 1,
|
||||||
|
'max_users': 1,
|
||||||
|
'max_industries': 3, # 3 sectors per site
|
||||||
|
'max_author_profiles': 2,
|
||||||
|
'is_active': True,
|
||||||
|
'features': ['ai_writer', 'planner', 'basic_support'],
|
||||||
|
'allow_credit_topup': False, # No top-up during trial
|
||||||
|
'extra_credit_price': 0.00,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'✓ Created Free Trial plan (ID: {plan.id})'
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'✓ Updated Free Trial plan (ID: {plan.id})'
|
||||||
|
))
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f' - Credits: {plan.included_credits}'
|
||||||
|
))
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f' - Max Sites: {plan.max_sites}'
|
||||||
|
))
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f' - Max Sectors: {plan.max_industries}'
|
||||||
|
))
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f' - Status: {"Active" if plan.is_active else "Inactive"}'
|
||||||
|
))
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
'\nFree Trial plan is ready for signup!'
|
||||||
|
))
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from igny8_core.auth.models import Account, Site, Sector
|
||||||
|
from igny8_core.business.planning.models import Clusters, Keywords, ContentIdeas
|
||||||
|
from igny8_core.business.content.models import Tasks, Content, Images
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Permanently delete soft-deleted records whose retention window has expired."
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
now = timezone.now()
|
||||||
|
total_deleted = 0
|
||||||
|
|
||||||
|
models = [
|
||||||
|
Account,
|
||||||
|
Site,
|
||||||
|
Sector,
|
||||||
|
Clusters,
|
||||||
|
Keywords,
|
||||||
|
ContentIdeas,
|
||||||
|
Tasks,
|
||||||
|
Content,
|
||||||
|
Images,
|
||||||
|
]
|
||||||
|
|
||||||
|
for model in models:
|
||||||
|
qs = model.all_objects.filter(is_deleted=True, restore_until__lt=now)
|
||||||
|
if model is Account:
|
||||||
|
qs = qs.exclude(slug='aws-admin')
|
||||||
|
count = qs.count()
|
||||||
|
if count:
|
||||||
|
qs.delete()
|
||||||
|
total_deleted += count
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Purged {count} {model.__name__} record(s)."))
|
||||||
|
|
||||||
|
if total_deleted == 0:
|
||||||
|
self.stdout.write("No expired soft-deleted records to purge.")
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Total purged: {total_deleted}"))
|
||||||
|
|
||||||
@@ -2,10 +2,14 @@
|
|||||||
Multi-Account Middleware
|
Multi-Account Middleware
|
||||||
Extracts account from JWT token and injects into request context
|
Extracts account from JWT token and injects into request context
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from django.utils.deprecation import MiddlewareMixin
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
|
from django.contrib.auth import logout
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
|
logger = logging.getLogger('auth.middleware')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import jwt
|
import jwt
|
||||||
JWT_AVAILABLE = True
|
JWT_AVAILABLE = True
|
||||||
@@ -30,30 +34,56 @@ class AccountContextMiddleware(MiddlewareMixin):
|
|||||||
# First, try to get user from Django session (cookie-based auth)
|
# First, try to get user from Django session (cookie-based auth)
|
||||||
# This handles cases where frontend uses credentials: 'include' with session cookies
|
# This handles cases where frontend uses credentials: 'include' with session cookies
|
||||||
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
|
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
|
||||||
# User is authenticated via session - refresh from DB to get latest account/plan data
|
# CRITICAL FIX: Never query DB again or mutate request.user
|
||||||
# This ensures changes to account/plan are reflected immediately without re-login
|
# Django's AuthenticationMiddleware already loaded the user correctly
|
||||||
|
# Just use it directly and set request.account from the ALREADY LOADED relationship
|
||||||
try:
|
try:
|
||||||
from .models import User as UserModel
|
# Validate account/plan - but use the user object already set by Django
|
||||||
# Refresh user from DB with account and plan relationships to get latest data
|
validation_error = self._validate_account_and_plan(request, request.user)
|
||||||
# This is important so account/plan changes are reflected immediately
|
if validation_error:
|
||||||
user = UserModel.objects.select_related('account', 'account__plan').get(id=request.user.id)
|
return validation_error
|
||||||
# Update request.user with fresh data
|
|
||||||
request.user = user
|
# Set request.account from the user's account relationship
|
||||||
# Get account from refreshed user
|
# This is already loaded, no need to query DB again
|
||||||
user_account = getattr(user, 'account', None)
|
request.account = getattr(request.user, 'account', None)
|
||||||
if user_account:
|
|
||||||
request.account = user_account
|
# CRITICAL: Add account ID to session to prevent cross-contamination
|
||||||
return None
|
# This ensures each session is tied to a specific account
|
||||||
except (AttributeError, UserModel.DoesNotExist, Exception):
|
if request.account:
|
||||||
# If refresh fails, fallback to cached account
|
request.session['_account_id'] = request.account.id
|
||||||
try:
|
request.session['_user_id'] = request.user.id
|
||||||
user_account = getattr(request.user, 'account', None)
|
# Verify session integrity - if stored IDs don't match, logout
|
||||||
if user_account:
|
stored_account_id = request.session.get('_account_id')
|
||||||
request.account = user_account
|
stored_user_id = request.session.get('_user_id')
|
||||||
|
if stored_account_id and stored_account_id != request.account.id:
|
||||||
|
# Session contamination detected - force logout
|
||||||
|
logger.warning(
|
||||||
|
f"[AUTO-LOGOUT] Session contamination: account_id mismatch. "
|
||||||
|
f"Session={stored_account_id}, Current={request.account.id}, "
|
||||||
|
f"User={request.user.id}, Path={request.path}, IP={request.META.get('REMOTE_ADDR')}"
|
||||||
|
)
|
||||||
|
logout(request)
|
||||||
|
return JsonResponse(
|
||||||
|
{'success': False, 'error': 'Session integrity violation detected. Please login again.'},
|
||||||
|
status=status.HTTP_401_UNAUTHORIZED
|
||||||
|
)
|
||||||
|
if stored_user_id and stored_user_id != request.user.id:
|
||||||
|
# Session contamination detected - force logout
|
||||||
|
logger.warning(
|
||||||
|
f"[AUTO-LOGOUT] Session contamination: user_id mismatch. "
|
||||||
|
f"Session={stored_user_id}, Current={request.user.id}, "
|
||||||
|
f"Account={request.account.id if request.account else None}, "
|
||||||
|
f"Path={request.path}, IP={request.META.get('REMOTE_ADDR')}"
|
||||||
|
)
|
||||||
|
logout(request)
|
||||||
|
return JsonResponse(
|
||||||
|
{'success': False, 'error': 'Session integrity violation detected. Please login again.'},
|
||||||
|
status=status.HTTP_401_UNAUTHORIZED
|
||||||
|
)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
except (AttributeError, Exception):
|
except (AttributeError, Exception):
|
||||||
pass
|
# If anything fails, just set account to None and continue
|
||||||
# If account access fails (e.g., column mismatch), set to None
|
|
||||||
request.account = None
|
request.account = None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -76,7 +106,6 @@ class AccountContextMiddleware(MiddlewareMixin):
|
|||||||
if not JWT_AVAILABLE:
|
if not JWT_AVAILABLE:
|
||||||
# JWT library not installed yet - skip for now
|
# JWT library not installed yet - skip for now
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Decode JWT token with signature verification
|
# Decode JWT token with signature verification
|
||||||
@@ -94,42 +123,84 @@ class AccountContextMiddleware(MiddlewareMixin):
|
|||||||
if user_id:
|
if user_id:
|
||||||
from .models import User, Account
|
from .models import User, Account
|
||||||
try:
|
try:
|
||||||
# Refresh user from DB with account and plan relationships to get latest data
|
# Get user from DB (but don't set request.user - let DRF authentication handle that)
|
||||||
# This ensures changes to account/plan are reflected immediately without re-login
|
# Only set request.account for account context
|
||||||
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
|
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:
|
if account_id:
|
||||||
# Verify account still exists and matches user
|
# Verify account still exists
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
user_account = getattr(user, 'account', None)
|
account = Account.objects.get(id=account_id)
|
||||||
if user_account:
|
request.account = account
|
||||||
request.account = user_account
|
except Account.DoesNotExist:
|
||||||
else:
|
# Account from token doesn't exist - don't fallback, set to None
|
||||||
request.account = None
|
request.account = None
|
||||||
except (AttributeError, Exception):
|
else:
|
||||||
# If account access fails (e.g., column mismatch), set to None
|
# No account_id in token - set to None (don't fallback to user.account)
|
||||||
request.account = None
|
request.account = None
|
||||||
except (User.DoesNotExist, Account.DoesNotExist):
|
except (User.DoesNotExist, Account.DoesNotExist):
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
else:
|
else:
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
|
|
||||||
except jwt.InvalidTokenError:
|
except jwt.InvalidTokenError:
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fail silently for now - allow unauthenticated access
|
# Fail silently for now - allow unauthenticated access
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _validate_account_and_plan(self, request, user):
|
||||||
|
"""
|
||||||
|
Ensure the authenticated user has an account and an active plan.
|
||||||
|
Uses shared validation helper for consistency.
|
||||||
|
Bypasses validation for superusers, developers, and system accounts.
|
||||||
|
"""
|
||||||
|
# Bypass validation for superusers
|
||||||
|
if getattr(user, 'is_superuser', False):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Bypass validation for developers
|
||||||
|
if hasattr(user, 'role') and user.role == 'developer':
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Bypass validation for system account users
|
||||||
|
try:
|
||||||
|
if hasattr(user, 'is_system_account_user') and user.is_system_account_user():
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from .utils import validate_account_and_plan
|
||||||
|
|
||||||
|
is_valid, error_message, http_status = validate_account_and_plan(user)
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
|
return self._deny_request(request, error_message, http_status)
|
||||||
|
|
||||||
|
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:
|
||||||
|
logger.warning(
|
||||||
|
f"[AUTO-LOGOUT] Account/plan validation failed: {error}. "
|
||||||
|
f"User={request.user.id}, Account={getattr(request, 'account', None)}, "
|
||||||
|
f"Path={request.path}, IP={request.META.get('REMOTE_ADDR')}"
|
||||||
|
)
|
||||||
|
logout(request)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[AUTO-LOGOUT] Error during logout: {e}")
|
||||||
|
|
||||||
|
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.models
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
@@ -25,12 +25,22 @@ class Migration(migrations.Migration):
|
|||||||
('name', models.CharField(max_length=255)),
|
('name', models.CharField(max_length=255)),
|
||||||
('slug', models.SlugField(max_length=255, unique=True)),
|
('slug', models.SlugField(max_length=255, unique=True)),
|
||||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
('credits_per_month', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
|
('billing_cycle', models.CharField(choices=[('monthly', 'Monthly'), ('annual', 'Annual')], default='monthly', max_length=20)),
|
||||||
('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(blank=True, default=list, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])")),
|
||||||
('features', models.JSONField(default=dict, help_text='Plan features as JSON')),
|
|
||||||
('stripe_price_id', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
('is_active', models.BooleanField(default=True)),
|
('is_active', models.BooleanField(default=True)),
|
||||||
('created_at', models.DateTimeField(auto_now_add=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={
|
options={
|
||||||
'db_table': 'igny8_plans',
|
'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_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')),
|
('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')),
|
('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')),
|
('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
@@ -65,7 +75,7 @@ class Migration(migrations.Migration):
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Tenant',
|
name='Account',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('name', models.CharField(max_length=255)),
|
('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)),
|
('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)),
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
('updated_at', models.DateTimeField(auto_now=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)),
|
('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='tenants', to='igny8_core_auth.plan')),
|
('plan', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accounts', to='igny8_core_auth.plan')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
'verbose_name': 'Account',
|
||||||
|
'verbose_name_plural': 'Accounts',
|
||||||
'db_table': 'igny8_tenants',
|
'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(
|
migrations.CreateModel(
|
||||||
name='Subscription',
|
name='Industry',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('stripe_subscription_id', models.CharField(max_length=255, unique=True)),
|
('name', models.CharField(max_length=255, unique=True)),
|
||||||
('status', models.CharField(choices=[('active', 'Active'), ('past_due', 'Past Due'), ('canceled', 'Canceled'), ('trialing', 'Trialing')], max_length=20)),
|
('slug', models.SlugField(max_length=255, unique=True)),
|
||||||
('current_period_start', models.DateTimeField()),
|
('description', models.TextField(blank=True, null=True)),
|
||||||
('current_period_end', models.DateTimeField()),
|
('is_active', models.BooleanField(db_index=True, default=True)),
|
||||||
('cancel_at_period_end', models.BooleanField(default=False)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
('updated_at', models.DateTimeField(auto_now=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={
|
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(
|
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)),
|
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('suspended', 'Suspended')], default='active', max_length=20)),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
('updated_at', models.DateTimeField(auto_now=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_username', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
('wp_app_password', 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={
|
options={
|
||||||
'db_table': 'igny8_sites',
|
'db_table': 'igny8_sites',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
@@ -131,18 +211,14 @@ class Migration(migrations.Migration):
|
|||||||
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive')], default='active', max_length=20)),
|
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive')], default='active', max_length=20)),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
('updated_at', models.DateTimeField(auto_now=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')),
|
('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={
|
options={
|
||||||
'db_table': 'igny8_sectors',
|
'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(
|
migrations.CreateModel(
|
||||||
name='SiteUserAccess',
|
name='SiteUserAccess',
|
||||||
fields=[
|
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)),
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='site_access', to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
'verbose_name': 'Site User Access',
|
||||||
|
'verbose_name_plural': 'Site User Access',
|
||||||
'db_table': 'igny8_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(
|
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'),
|
index=models.Index(fields=['slug'], name='igny8_tenan_slug_f25e97_idx'),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='tenant',
|
model_name='account',
|
||||||
index=models.Index(fields=['status'], name='igny8_tenan_status_5dc02a_idx'),
|
index=models.Index(fields=['status'], name='igny8_tenan_status_5dc02a_idx'),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='subscription',
|
model_name='seedkeyword',
|
||||||
index=models.Index(fields=['status'], name='igny8_subsc_status_2fa897_idx'),
|
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(
|
migrations.AddIndex(
|
||||||
model_name='site',
|
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(
|
migrations.AddIndex(
|
||||||
model_name='site',
|
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(
|
migrations.AlterUniqueTogether(
|
||||||
name='site',
|
name='site',
|
||||||
unique_together={('tenant', 'slug')},
|
unique_together={('account', 'slug')},
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='sector',
|
model_name='sector',
|
||||||
@@ -188,18 +341,26 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='sector',
|
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(
|
migrations.AlterUniqueTogether(
|
||||||
name='sector',
|
name='sector',
|
||||||
unique_together={('site', 'slug')},
|
unique_together={('site', 'slug')},
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='user',
|
model_name='siteuseraccess',
|
||||||
index=models.Index(fields=['tenant', 'role'], name='igny8_users_tenant__0ab02b_idx'),
|
index=models.Index(fields=['user', 'site'], name='igny8_site__user_id_61951e_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='siteuseraccess',
|
||||||
|
unique_together={('user', 'site')},
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='user',
|
model_name='subscription',
|
||||||
index=models.Index(fields=['email'], name='igny8_users_email_fd61ff_idx'),
|
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-01 00:05
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0002_add_wp_api_key_to_site'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='seedkeyword',
|
||||||
|
options={'ordering': ['keyword'], 'verbose_name': 'Seed Keyword', 'verbose_name_plural': 'Global Keywords Database'},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-04 23:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0003_add_sync_event_model'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='billing_address_line1',
|
||||||
|
field=models.CharField(blank=True, help_text='Street address', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='billing_address_line2',
|
||||||
|
field=models.CharField(blank=True, help_text='Apt, suite, etc.', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='billing_city',
|
||||||
|
field=models.CharField(blank=True, max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='billing_country',
|
||||||
|
field=models.CharField(blank=True, help_text='ISO 2-letter country code', max_length=2),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='billing_email',
|
||||||
|
field=models.EmailField(blank=True, help_text='Email for billing notifications', max_length=254, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='billing_postal_code',
|
||||||
|
field=models.CharField(blank=True, max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='billing_state',
|
||||||
|
field=models.CharField(blank=True, help_text='State/Province/Region', max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='tax_id',
|
||||||
|
field=models.CharField(blank=True, help_text='VAT/Tax ID number', max_length=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0004_add_invoice_payment_models'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='owned_accounts',
|
||||||
|
to='igny8_core_auth.user',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0005_account_owner_nullable'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='delete_reason',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='deleted_at',
|
||||||
|
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='deleted_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='deletion_retention_days',
|
||||||
|
field=models.PositiveIntegerField(default=14, help_text='Retention window (days) before soft-deleted items are purged', validators=[MinValueValidator(1), MaxValueValidator(365)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='is_deleted',
|
||||||
|
field=models.BooleanField(db_index=True, default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='restore_until',
|
||||||
|
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sector',
|
||||||
|
name='delete_reason',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sector',
|
||||||
|
name='deleted_at',
|
||||||
|
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sector',
|
||||||
|
name='deleted_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sector',
|
||||||
|
name='is_deleted',
|
||||||
|
field=models.BooleanField(db_index=True, default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sector',
|
||||||
|
name='restore_until',
|
||||||
|
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='site',
|
||||||
|
name='delete_reason',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='site',
|
||||||
|
name='deleted_at',
|
||||||
|
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='site',
|
||||||
|
name='deleted_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='igny8_core_auth.user'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='site',
|
||||||
|
name='is_deleted',
|
||||||
|
field=models.BooleanField(db_index=True, default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='site',
|
||||||
|
name='restore_until',
|
||||||
|
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Generated manually based on FINAL-IMPLEMENTATION-REQUIREMENTS.md
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0006_soft_delete_and_retention'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Add payment_method to Account
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='payment_method',
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=[
|
||||||
|
('stripe', 'Stripe'),
|
||||||
|
('paypal', 'PayPal'),
|
||||||
|
('bank_transfer', 'Bank Transfer'),
|
||||||
|
],
|
||||||
|
default='stripe',
|
||||||
|
help_text='Payment method used for this account'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Add payment_method to Subscription
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='payment_method',
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=[
|
||||||
|
('stripe', 'Stripe'),
|
||||||
|
('paypal', 'PayPal'),
|
||||||
|
('bank_transfer', 'Bank Transfer'),
|
||||||
|
],
|
||||||
|
default='stripe',
|
||||||
|
help_text='Payment method for this subscription'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Add external_payment_id to Subscription
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='external_payment_id',
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='External payment reference (bank transfer ref, PayPal transaction ID)'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Make stripe_subscription_id nullable
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='stripe_subscription_id',
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text='Stripe subscription ID (when using Stripe)'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Add pending_payment status to Account
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='account',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
('active', 'Active'),
|
||||||
|
('suspended', 'Suspended'),
|
||||||
|
('trial', 'Trial'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
('pending_payment', 'Pending Payment'),
|
||||||
|
],
|
||||||
|
default='trial'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Add pending_payment status to Subscription
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
('active', 'Active'),
|
||||||
|
('past_due', 'Past Due'),
|
||||||
|
('canceled', 'Canceled'),
|
||||||
|
('trialing', 'Trialing'),
|
||||||
|
('pending_payment', 'Pending Payment'),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Add index on payment_method
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='account',
|
||||||
|
index=models.Index(fields=['payment_method'], name='auth_acc_payment_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='subscription',
|
||||||
|
index=models.Index(fields=['payment_method'], name='auth_sub_payment_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'])"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-08 13:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0007_add_payment_method_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name='account',
|
||||||
|
name='auth_acc_payment_idx',
|
||||||
|
),
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name='subscription',
|
||||||
|
name='auth_sub_payment_idx',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='is_internal',
|
||||||
|
field=models.BooleanField(default=False, help_text='Internal-only plan (Free/Internal) - hidden from public plan listings'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated manually
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.core.validators
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0008_add_plan_is_internal'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='annual_discount_percent',
|
||||||
|
field=models.DecimalField(
|
||||||
|
decimal_places=2,
|
||||||
|
default=15.0,
|
||||||
|
help_text='Annual subscription discount percentage (default 15%)',
|
||||||
|
max_digits=5,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(0),
|
||||||
|
django.core.validators.MaxValueValidator(100)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='is_featured',
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text='Highlight this plan as popular/recommended'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-08 22:42
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0009_add_plan_annual_discount_and_featured'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='plan',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Subscription plan (tracks historical plan even if account changes plan)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='igny8_core_auth.plan'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='site',
|
||||||
|
name='industry',
|
||||||
|
field=models.ForeignKey(default=21, help_text='Industry this site belongs to (required for sector creation)', on_delete=django.db.models.deletion.PROTECT, related_name='sites', to='igny8_core_auth.industry'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)]),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-08 22:52
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0010_add_subscription_plan_and_require_site_industry'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='payment_method',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'])"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Generated migration to fix subscription constraints
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0011_remove_subscription_payment_method'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Add unique constraint on tenant_id at database level
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql="""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS igny8_subscriptions_tenant_id_unique
|
||||||
|
ON igny8_subscriptions(tenant_id);
|
||||||
|
""",
|
||||||
|
reverse_sql="""
|
||||||
|
DROP INDEX IF EXISTS igny8_subscriptions_tenant_id_unique;
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
|
||||||
|
# Make plan field required (non-nullable)
|
||||||
|
# First set default plan (ID 1 - Free Plan) for any null values
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql="""
|
||||||
|
UPDATE igny8_subscriptions
|
||||||
|
SET plan_id = 1
|
||||||
|
WHERE plan_id IS NULL;
|
||||||
|
""",
|
||||||
|
reverse_sql=migrations.RunSQL.noop
|
||||||
|
),
|
||||||
|
|
||||||
|
# Now alter the field to be non-nullable
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='subscription',
|
||||||
|
name='plan',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name='subscriptions',
|
||||||
|
to='igny8_core_auth.plan',
|
||||||
|
help_text='Subscription plan (tracks historical plan even if account changes plan)'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-12 11:26
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0012_fix_subscription_constraints'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_clusters',
|
||||||
|
field=models.IntegerField(default=100, help_text='Maximum AI keyword clusters allowed (hard limit)', validators=[django.core.validators.MinValueValidator(1)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_content_ideas',
|
||||||
|
field=models.IntegerField(default=300, help_text='Maximum AI content ideas per month', validators=[django.core.validators.MinValueValidator(1)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_content_words',
|
||||||
|
field=models.IntegerField(default=100000, help_text='Maximum content words per month (e.g., 100000 = 100K words)', validators=[django.core.validators.MinValueValidator(1)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_image_prompts',
|
||||||
|
field=models.IntegerField(default=300, help_text='Maximum image prompts per month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_images_basic',
|
||||||
|
field=models.IntegerField(default=300, help_text='Maximum basic AI images per month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_images_premium',
|
||||||
|
field=models.IntegerField(default=60, help_text='Maximum premium AI images per month (DALL-E)', validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='max_keywords',
|
||||||
|
field=models.IntegerField(default=1000, help_text='Maximum total keywords allowed (hard limit)', validators=[django.core.validators.MinValueValidator(1)]),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-12 12:24
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0013_plan_max_clusters_plan_max_content_ideas_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_content_ideas',
|
||||||
|
field=models.IntegerField(default=0, help_text='Content ideas generated this month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_content_words',
|
||||||
|
field=models.IntegerField(default=0, help_text='Content words generated this month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_image_prompts',
|
||||||
|
field=models.IntegerField(default=0, help_text='Image prompts this month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_images_basic',
|
||||||
|
field=models.IntegerField(default=0, help_text='Basic AI images this month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_images_premium',
|
||||||
|
field=models.IntegerField(default=0, help_text='Premium AI images this month', validators=[django.core.validators.MinValueValidator(0)]),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_period_end',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Current billing period end', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='usage_period_start',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Current billing period start', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated manually
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0014_add_usage_tracking_to_account'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='original_price',
|
||||||
|
field=models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=2,
|
||||||
|
help_text='Original price (before discount) - shows as crossed out price. Leave empty if no discount.',
|
||||||
|
max_digits=10,
|
||||||
|
null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2025-12-13 20:31
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0015_add_plan_original_price'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='plan',
|
||||||
|
name='annual_discount_percent',
|
||||||
|
field=models.IntegerField(default=15, help_text='Annual subscription discount percentage (default 15%)', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)]),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2025-12-15 01:28
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import simple_history.models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0016_alter_plan_annual_discount_percent'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='HistoricalAccount',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
|
||||||
|
('is_deleted', models.BooleanField(db_index=True, default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||||
|
('restore_until', models.DateTimeField(blank=True, db_index=True, null=True)),
|
||||||
|
('delete_reason', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('slug', models.SlugField(max_length=255)),
|
||||||
|
('stripe_customer_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('credits', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])),
|
||||||
|
('status', models.CharField(choices=[('active', 'Active'), ('suspended', 'Suspended'), ('trial', 'Trial'), ('cancelled', 'Cancelled'), ('pending_payment', 'Pending Payment')], default='trial', max_length=20)),
|
||||||
|
('payment_method', models.CharField(choices=[('stripe', 'Stripe'), ('paypal', 'PayPal'), ('bank_transfer', 'Bank Transfer')], default='stripe', help_text='Payment method used for this account', max_length=30)),
|
||||||
|
('deletion_retention_days', models.PositiveIntegerField(default=14, help_text='Retention window (days) before soft-deleted items are purged', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(365)])),
|
||||||
|
('billing_email', models.EmailField(blank=True, help_text='Email for billing notifications', max_length=254, null=True)),
|
||||||
|
('billing_address_line1', models.CharField(blank=True, help_text='Street address', max_length=255)),
|
||||||
|
('billing_address_line2', models.CharField(blank=True, help_text='Apt, suite, etc.', max_length=255)),
|
||||||
|
('billing_city', models.CharField(blank=True, max_length=100)),
|
||||||
|
('billing_state', models.CharField(blank=True, help_text='State/Province/Region', max_length=100)),
|
||||||
|
('billing_postal_code', models.CharField(blank=True, max_length=20)),
|
||||||
|
('billing_country', models.CharField(blank=True, help_text='ISO 2-letter country code', max_length=2)),
|
||||||
|
('tax_id', models.CharField(blank=True, help_text='VAT/Tax ID number', max_length=100)),
|
||||||
|
('usage_content_ideas', models.IntegerField(default=0, help_text='Content ideas generated this month', validators=[django.core.validators.MinValueValidator(0)])),
|
||||||
|
('usage_content_words', models.IntegerField(default=0, help_text='Content words generated this month', validators=[django.core.validators.MinValueValidator(0)])),
|
||||||
|
('usage_images_basic', models.IntegerField(default=0, help_text='Basic AI images this month', validators=[django.core.validators.MinValueValidator(0)])),
|
||||||
|
('usage_images_premium', models.IntegerField(default=0, help_text='Premium AI images this month', validators=[django.core.validators.MinValueValidator(0)])),
|
||||||
|
('usage_image_prompts', models.IntegerField(default=0, help_text='Image prompts this month', validators=[django.core.validators.MinValueValidator(0)])),
|
||||||
|
('usage_period_start', models.DateTimeField(blank=True, help_text='Current billing period start', null=True)),
|
||||||
|
('usage_period_end', models.DateTimeField(blank=True, help_text='Current billing period end', null=True)),
|
||||||
|
('created_at', models.DateTimeField(blank=True, editable=False)),
|
||||||
|
('updated_at', models.DateTimeField(blank=True, editable=False)),
|
||||||
|
('history_id', models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
('history_date', models.DateTimeField(db_index=True)),
|
||||||
|
('history_change_reason', models.CharField(max_length=100, null=True)),
|
||||||
|
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
|
||||||
|
('deleted_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('owner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('plan', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='igny8_core_auth.plan')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'historical Account',
|
||||||
|
'verbose_name_plural': 'historical Accounts',
|
||||||
|
'ordering': ('-history_date', '-history_id'),
|
||||||
|
'get_latest_by': ('history_date', 'history_id'),
|
||||||
|
},
|
||||||
|
bases=(simple_history.models.HistoricalChanges, models.Model),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -5,6 +5,8 @@ from django.db import models
|
|||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
|
from igny8_core.common.soft_delete import SoftDeletableModel, SoftDeleteManager
|
||||||
|
from simple_history.models import HistoricalRecords
|
||||||
|
|
||||||
|
|
||||||
class AccountBaseModel(models.Model):
|
class AccountBaseModel(models.Model):
|
||||||
@@ -52,7 +54,7 @@ class SiteSectorBaseModel(AccountBaseModel):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Account(models.Model):
|
class Account(SoftDeletableModel):
|
||||||
"""
|
"""
|
||||||
Account/Organization model for multi-account support.
|
Account/Organization model for multi-account support.
|
||||||
"""
|
"""
|
||||||
@@ -61,18 +63,65 @@ class Account(models.Model):
|
|||||||
('suspended', 'Suspended'),
|
('suspended', 'Suspended'),
|
||||||
('trial', 'Trial'),
|
('trial', 'Trial'),
|
||||||
('cancelled', 'Cancelled'),
|
('cancelled', 'Cancelled'),
|
||||||
|
('pending_payment', 'Pending Payment'),
|
||||||
|
]
|
||||||
|
|
||||||
|
PAYMENT_METHOD_CHOICES = [
|
||||||
|
('stripe', 'Stripe'),
|
||||||
|
('paypal', 'PayPal'),
|
||||||
|
('bank_transfer', 'Bank Transfer'),
|
||||||
]
|
]
|
||||||
|
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(unique=True, max_length=255)
|
slug = models.SlugField(unique=True, max_length=255)
|
||||||
owner = models.ForeignKey('igny8_core_auth.User', on_delete=models.PROTECT, related_name='owned_accounts')
|
owner = models.ForeignKey(
|
||||||
|
'igny8_core_auth.User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='owned_accounts',
|
||||||
|
)
|
||||||
stripe_customer_id = models.CharField(max_length=255, blank=True, null=True)
|
stripe_customer_id = models.CharField(max_length=255, blank=True, null=True)
|
||||||
plan = models.ForeignKey('igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='accounts')
|
plan = models.ForeignKey('igny8_core_auth.Plan', on_delete=models.PROTECT, related_name='accounts')
|
||||||
credits = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
credits = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial')
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trial')
|
||||||
|
payment_method = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=PAYMENT_METHOD_CHOICES,
|
||||||
|
default='stripe',
|
||||||
|
help_text='Payment method used for this account'
|
||||||
|
)
|
||||||
|
deletion_retention_days = models.PositiveIntegerField(
|
||||||
|
default=14,
|
||||||
|
validators=[MinValueValidator(1), MaxValueValidator(365)],
|
||||||
|
help_text="Retention window (days) before soft-deleted items are purged",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Billing information
|
||||||
|
billing_email = models.EmailField(blank=True, null=True, help_text="Email for billing notifications")
|
||||||
|
billing_address_line1 = models.CharField(max_length=255, blank=True, help_text="Street address")
|
||||||
|
billing_address_line2 = models.CharField(max_length=255, blank=True, help_text="Apt, suite, etc.")
|
||||||
|
billing_city = models.CharField(max_length=100, blank=True)
|
||||||
|
billing_state = models.CharField(max_length=100, blank=True, help_text="State/Province/Region")
|
||||||
|
billing_postal_code = models.CharField(max_length=20, blank=True)
|
||||||
|
billing_country = models.CharField(max_length=2, blank=True, help_text="ISO 2-letter country code")
|
||||||
|
tax_id = models.CharField(max_length=100, blank=True, help_text="VAT/Tax ID number")
|
||||||
|
|
||||||
|
# Monthly usage tracking (reset on billing cycle)
|
||||||
|
usage_content_ideas = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Content ideas generated this month")
|
||||||
|
usage_content_words = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Content words generated this month")
|
||||||
|
usage_images_basic = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Basic AI images this month")
|
||||||
|
usage_images_premium = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Premium AI images this month")
|
||||||
|
usage_image_prompts = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Image prompts this month")
|
||||||
|
usage_period_start = models.DateTimeField(null=True, blank=True, help_text="Current billing period start")
|
||||||
|
usage_period_end = models.DateTimeField(null=True, blank=True, help_text="Current billing period end")
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
# History tracking
|
||||||
|
history = HistoricalRecords()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'igny8_tenants'
|
db_table = 'igny8_tenants'
|
||||||
verbose_name = 'Account'
|
verbose_name = 'Account'
|
||||||
@@ -82,19 +131,46 @@ class Account(models.Model):
|
|||||||
models.Index(fields=['status']),
|
models.Index(fields=['status']),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
objects = SoftDeleteManager()
|
||||||
|
all_objects = models.Manager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_payment_method(self):
|
||||||
|
"""Get default payment method from AccountPaymentMethod table"""
|
||||||
|
try:
|
||||||
|
from igny8_core.business.billing.models import AccountPaymentMethod
|
||||||
|
method = AccountPaymentMethod.objects.filter(
|
||||||
|
account=self,
|
||||||
|
is_default=True,
|
||||||
|
is_enabled=True
|
||||||
|
).first()
|
||||||
|
return method.type if method else self.payment_method
|
||||||
|
except Exception:
|
||||||
|
# Fallback to field if table doesn't exist or error
|
||||||
|
return self.payment_method
|
||||||
|
|
||||||
def is_system_account(self):
|
def is_system_account(self):
|
||||||
"""Check if this account is a system account with highest access level."""
|
"""Check if this account is a system account with highest access level."""
|
||||||
# System accounts bypass all filtering restrictions
|
# System accounts bypass all filtering restrictions
|
||||||
return self.slug in ['aws-admin', 'default-account', 'default']
|
return self.slug in ['aws-admin', 'default-account', 'default']
|
||||||
|
|
||||||
|
def soft_delete(self, user=None, reason=None, retention_days=None):
|
||||||
|
if self.is_system_account():
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
raise PermissionDenied("System account cannot be deleted.")
|
||||||
|
return super().soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||||
|
|
||||||
|
def delete(self, using=None, keep_parents=False):
|
||||||
|
return self.soft_delete()
|
||||||
|
|
||||||
|
|
||||||
class Plan(models.Model):
|
class Plan(models.Model):
|
||||||
"""
|
"""
|
||||||
Subscription plan model with comprehensive limits and features.
|
Subscription plan model - Phase 0: Credit-only system.
|
||||||
Plans define limits for users, sites, content generation, AI usage, and billing.
|
Plans define credits, billing, and account management limits only.
|
||||||
"""
|
"""
|
||||||
BILLING_CYCLE_CHOICES = [
|
BILLING_CYCLE_CHOICES = [
|
||||||
('monthly', 'Monthly'),
|
('monthly', 'Monthly'),
|
||||||
@@ -105,12 +181,26 @@ class Plan(models.Model):
|
|||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(unique=True, max_length=255)
|
slug = models.SlugField(unique=True, max_length=255)
|
||||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
original_price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Original price (before discount) - shows as crossed out price. Leave empty if no discount."
|
||||||
|
)
|
||||||
billing_cycle = models.CharField(max_length=20, choices=BILLING_CYCLE_CHOICES, default='monthly')
|
billing_cycle = models.CharField(max_length=20, choices=BILLING_CYCLE_CHOICES, default='monthly')
|
||||||
|
annual_discount_percent = models.IntegerField(
|
||||||
|
default=15,
|
||||||
|
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
||||||
|
help_text="Annual subscription discount percentage (default 15%)"
|
||||||
|
)
|
||||||
|
is_featured = models.BooleanField(default=False, help_text="Highlight this plan as popular/recommended")
|
||||||
features = models.JSONField(default=list, blank=True, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])")
|
features = models.JSONField(default=list, blank=True, help_text="Plan features as JSON array (e.g., ['ai_writer', 'image_gen', 'auto_publish'])")
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
is_internal = models.BooleanField(default=False, help_text="Internal-only plan (Free/Internal) - hidden from public plan listings")
|
||||||
created_at = models.DateTimeField(auto_now_add=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_users = models.IntegerField(default=1, validators=[MinValueValidator(1)], help_text="Total users allowed per account")
|
||||||
max_sites = models.IntegerField(
|
max_sites = models.IntegerField(
|
||||||
default=1,
|
default=1,
|
||||||
@@ -120,32 +210,46 @@ 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_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")
|
max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles")
|
||||||
|
|
||||||
# Planner Limits
|
# Hard Limits (Persistent - user manages within limit)
|
||||||
max_keywords = models.IntegerField(default=1000, validators=[MinValueValidator(0)], help_text="Total keywords allowed (global limit)")
|
max_keywords = models.IntegerField(
|
||||||
max_clusters = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Total clusters allowed (global)")
|
default=1000,
|
||||||
max_content_ideas = models.IntegerField(default=300, validators=[MinValueValidator(0)], help_text="Total content ideas allowed (global limit)")
|
validators=[MinValueValidator(1)],
|
||||||
daily_cluster_limit = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max clusters that can be created per day")
|
help_text="Maximum total keywords allowed (hard limit)"
|
||||||
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")
|
max_clusters = models.IntegerField(
|
||||||
|
default=100,
|
||||||
|
validators=[MinValueValidator(1)],
|
||||||
|
help_text="Maximum AI keyword clusters allowed (hard limit)"
|
||||||
|
)
|
||||||
|
|
||||||
# Writer Limits
|
# Monthly Limits (Reset on billing cycle)
|
||||||
daily_content_tasks = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max number of content tasks (blogs) per day")
|
max_content_ideas = models.IntegerField(
|
||||||
daily_ai_requests = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="Total AI executions (content + idea + image) allowed per day")
|
default=300,
|
||||||
monthly_word_count_limit = models.IntegerField(default=50000, validators=[MinValueValidator(0)], help_text="Monthly word limit (for generated content)")
|
validators=[MinValueValidator(1)],
|
||||||
monthly_content_ai_credits = models.IntegerField(default=200, validators=[MinValueValidator(0)], help_text="AI credit pool for content generation")
|
help_text="Maximum AI content ideas per month"
|
||||||
|
)
|
||||||
|
max_content_words = models.IntegerField(
|
||||||
|
default=100000,
|
||||||
|
validators=[MinValueValidator(1)],
|
||||||
|
help_text="Maximum content words per month (e.g., 100000 = 100K words)"
|
||||||
|
)
|
||||||
|
max_images_basic = models.IntegerField(
|
||||||
|
default=300,
|
||||||
|
validators=[MinValueValidator(0)],
|
||||||
|
help_text="Maximum basic AI images per month"
|
||||||
|
)
|
||||||
|
max_images_premium = models.IntegerField(
|
||||||
|
default=60,
|
||||||
|
validators=[MinValueValidator(0)],
|
||||||
|
help_text="Maximum premium AI images per month (DALL-E)"
|
||||||
|
)
|
||||||
|
max_image_prompts = models.IntegerField(
|
||||||
|
default=300,
|
||||||
|
validators=[MinValueValidator(0)],
|
||||||
|
help_text="Maximum image prompts per month"
|
||||||
|
)
|
||||||
|
|
||||||
# Image Generation Limits
|
# Billing & Credits (Phase 0: Credit-only system)
|
||||||
monthly_image_count = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Max images per month")
|
|
||||||
daily_image_generation_limit = models.IntegerField(default=25, validators=[MinValueValidator(0)], help_text="Max images that can be generated per day")
|
|
||||||
monthly_image_ai_credits = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="AI credit pool for image generation")
|
|
||||||
max_images_per_task = models.IntegerField(default=4, validators=[MinValueValidator(1)], help_text="Max images per content task")
|
|
||||||
image_model_choices = models.JSONField(default=list, blank=True, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])")
|
|
||||||
|
|
||||||
# AI Request Controls
|
|
||||||
daily_ai_request_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Global daily AI request cap")
|
|
||||||
monthly_ai_credit_limit = models.IntegerField(default=500, validators=[MinValueValidator(0)], help_text="Unified credit ceiling per month (all AI functions)")
|
|
||||||
|
|
||||||
# Billing & Add-ons
|
|
||||||
included_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Monthly credits included")
|
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")
|
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?")
|
allow_credit_topup = models.BooleanField(default=True, help_text="Can user purchase more credits?")
|
||||||
@@ -181,17 +285,42 @@ class Plan(models.Model):
|
|||||||
|
|
||||||
class Subscription(models.Model):
|
class Subscription(models.Model):
|
||||||
"""
|
"""
|
||||||
Account subscription model linking to Stripe.
|
Account subscription model supporting multiple payment methods.
|
||||||
"""
|
"""
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('active', 'Active'),
|
('active', 'Active'),
|
||||||
('past_due', 'Past Due'),
|
('past_due', 'Past Due'),
|
||||||
('canceled', 'Canceled'),
|
('canceled', 'Canceled'),
|
||||||
('trialing', 'Trialing'),
|
('trialing', 'Trialing'),
|
||||||
|
('pending_payment', 'Pending Payment'),
|
||||||
|
]
|
||||||
|
|
||||||
|
PAYMENT_METHOD_CHOICES = [
|
||||||
|
('stripe', 'Stripe'),
|
||||||
|
('paypal', 'PayPal'),
|
||||||
|
('bank_transfer', 'Bank Transfer'),
|
||||||
]
|
]
|
||||||
|
|
||||||
account = models.OneToOneField('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='subscription', db_column='tenant_id')
|
account = models.OneToOneField('igny8_core_auth.Account', on_delete=models.CASCADE, related_name='subscription', db_column='tenant_id')
|
||||||
stripe_subscription_id = models.CharField(max_length=255, unique=True)
|
plan = models.ForeignKey(
|
||||||
|
'igny8_core_auth.Plan',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='subscriptions',
|
||||||
|
help_text='Subscription plan (tracks historical plan even if account changes plan)'
|
||||||
|
)
|
||||||
|
stripe_subscription_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text='Stripe subscription ID (when using Stripe)'
|
||||||
|
)
|
||||||
|
external_payment_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text='External payment reference (bank transfer ref, PayPal transaction ID)'
|
||||||
|
)
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES)
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES)
|
||||||
current_period_start = models.DateTimeField()
|
current_period_start = models.DateTimeField()
|
||||||
current_period_end = models.DateTimeField()
|
current_period_end = models.DateTimeField()
|
||||||
@@ -199,6 +328,14 @@ class Subscription(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def payment_method(self):
|
||||||
|
"""Get payment method from account's default payment method"""
|
||||||
|
if hasattr(self.account, 'default_payment_method'):
|
||||||
|
return self.account.default_payment_method
|
||||||
|
# Fallback to account.payment_method field if property doesn't exist yet
|
||||||
|
return getattr(self.account, 'payment_method', 'stripe')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'igny8_subscriptions'
|
db_table = 'igny8_subscriptions'
|
||||||
indexes = [
|
indexes = [
|
||||||
@@ -210,7 +347,7 @@ class Subscription(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Site(AccountBaseModel):
|
class Site(SoftDeletableModel, AccountBaseModel):
|
||||||
"""
|
"""
|
||||||
Site model - Each account can have multiple sites based on their plan.
|
Site model - Each account can have multiple sites based on their plan.
|
||||||
Each site belongs to ONE industry and can have 1-5 sectors from that industry.
|
Each site belongs to ONE industry and can have 1-5 sectors from that industry.
|
||||||
@@ -229,19 +366,60 @@ class Site(AccountBaseModel):
|
|||||||
'igny8_core_auth.Industry',
|
'igny8_core_auth.Industry',
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='sites',
|
related_name='sites',
|
||||||
null=True,
|
help_text="Industry this site belongs to (required for sector creation)"
|
||||||
blank=True,
|
|
||||||
help_text="Industry this site belongs to"
|
|
||||||
)
|
)
|
||||||
is_active = models.BooleanField(default=True, db_index=True)
|
is_active = models.BooleanField(default=True, db_index=True)
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
# WordPress integration fields
|
# WordPress integration fields (legacy - use SiteIntegration instead)
|
||||||
wp_url = models.URLField(blank=True, null=True, help_text="WordPress site URL")
|
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_username = models.CharField(max_length=255, blank=True, null=True)
|
||||||
wp_app_password = models.CharField(max_length=255, blank=True, null=True)
|
wp_app_password = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
wp_api_key = models.CharField(max_length=255, blank=True, null=True, help_text="API key for WordPress integration via IGNY8 WP Bridge plugin")
|
||||||
|
|
||||||
|
# Site type and hosting (Phase 6)
|
||||||
|
SITE_TYPE_CHOICES = [
|
||||||
|
('marketing', 'Marketing Site'),
|
||||||
|
('ecommerce', 'Ecommerce Site'),
|
||||||
|
('blog', 'Blog'),
|
||||||
|
('portfolio', 'Portfolio'),
|
||||||
|
('corporate', 'Corporate'),
|
||||||
|
]
|
||||||
|
|
||||||
|
HOSTING_TYPE_CHOICES = [
|
||||||
|
('igny8_sites', 'IGNY8 Sites'),
|
||||||
|
('wordpress', 'WordPress'),
|
||||||
|
('shopify', 'Shopify'),
|
||||||
|
('multi', 'Multi-Destination'),
|
||||||
|
]
|
||||||
|
|
||||||
|
site_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=SITE_TYPE_CHOICES,
|
||||||
|
default='marketing',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Type of site"
|
||||||
|
)
|
||||||
|
|
||||||
|
hosting_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=HOSTING_TYPE_CHOICES,
|
||||||
|
default='igny8_sites',
|
||||||
|
db_index=True,
|
||||||
|
help_text="Target hosting platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
# SEO metadata (Phase 7)
|
||||||
|
seo_metadata = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
help_text="SEO metadata: meta tags, Open Graph, Schema.org"
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = SoftDeleteManager()
|
||||||
|
all_objects = models.Manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'igny8_sites'
|
db_table = 'igny8_sites'
|
||||||
@@ -251,6 +429,8 @@ class Site(AccountBaseModel):
|
|||||||
models.Index(fields=['account', 'is_active']),
|
models.Index(fields=['account', 'is_active']),
|
||||||
models.Index(fields=['account', 'status']),
|
models.Index(fields=['account', 'status']),
|
||||||
models.Index(fields=['industry']),
|
models.Index(fields=['industry']),
|
||||||
|
models.Index(fields=['site_type']),
|
||||||
|
models.Index(fields=['hosting_type']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -362,7 +542,7 @@ class SeedKeyword(models.Model):
|
|||||||
db_table = 'igny8_seed_keywords'
|
db_table = 'igny8_seed_keywords'
|
||||||
unique_together = [['keyword', 'industry', 'sector']]
|
unique_together = [['keyword', 'industry', 'sector']]
|
||||||
verbose_name = 'Seed Keyword'
|
verbose_name = 'Seed Keyword'
|
||||||
verbose_name_plural = 'Seed Keywords'
|
verbose_name_plural = 'Global Keywords Database'
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['keyword']),
|
models.Index(fields=['keyword']),
|
||||||
models.Index(fields=['industry', 'sector']),
|
models.Index(fields=['industry', 'sector']),
|
||||||
@@ -375,7 +555,7 @@ class SeedKeyword(models.Model):
|
|||||||
return f"{self.keyword} ({self.industry.name} - {self.sector.name})"
|
return f"{self.keyword} ({self.industry.name} - {self.sector.name})"
|
||||||
|
|
||||||
|
|
||||||
class Sector(AccountBaseModel):
|
class Sector(SoftDeletableModel, AccountBaseModel):
|
||||||
"""
|
"""
|
||||||
Sector model - Each site can have 1-5 sectors.
|
Sector model - Each site can have 1-5 sectors.
|
||||||
Sectors are site-specific instances that reference an IndustrySector template.
|
Sectors are site-specific instances that reference an IndustrySector template.
|
||||||
@@ -403,6 +583,9 @@ class Sector(AccountBaseModel):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
objects = SoftDeleteManager()
|
||||||
|
all_objects = models.Manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'igny8_sectors'
|
db_table = 'igny8_sectors'
|
||||||
unique_together = [['site', 'slug']] # Slug unique per site
|
unique_together = [['site', 'slug']] # Slug unique per site
|
||||||
@@ -546,8 +729,7 @@ class User(AbstractUser):
|
|||||||
return self.role == 'developer' or self.is_superuser
|
return self.role == 'developer' or self.is_superuser
|
||||||
|
|
||||||
def is_admin_or_developer(self):
|
def is_admin_or_developer(self):
|
||||||
"""Check if user is admin or developer with override privileges."""
|
"""Check if user is admin or developer."""
|
||||||
# ADMIN/DEV OVERRIDE: Both admin and developer roles bypass account/site/sector restrictions
|
|
||||||
return self.role in ['admin', 'developer'] or self.is_superuser
|
return self.role in ['admin', 'developer'] or self.is_superuser
|
||||||
|
|
||||||
def is_system_account_user(self):
|
def is_system_account_user(self):
|
||||||
@@ -560,29 +742,17 @@ class User(AbstractUser):
|
|||||||
|
|
||||||
def get_accessible_sites(self):
|
def get_accessible_sites(self):
|
||||||
"""Get all sites the user can access."""
|
"""Get all sites the user can access."""
|
||||||
# System account users can access all sites across all accounts
|
|
||||||
if self.is_system_account_user():
|
|
||||||
return Site.objects.filter(is_active=True).distinct()
|
|
||||||
|
|
||||||
# Developers/super admins can access all sites across all accounts
|
|
||||||
# ADMIN/DEV OVERRIDE: Admins also bypass account restrictions (see is_admin_or_developer)
|
|
||||||
if self.is_developer():
|
|
||||||
return Site.objects.filter(is_active=True).distinct()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self.account:
|
if not self.account:
|
||||||
return Site.objects.none()
|
return Site.objects.none()
|
||||||
|
|
||||||
# Owners and admins can access all sites in their account
|
base_sites = Site.objects.filter(account=self.account, is_active=True)
|
||||||
if self.role in ['owner', 'admin']:
|
|
||||||
return Site.objects.filter(account=self.account, is_active=True)
|
if self.role in ['owner', 'admin', 'developer'] or self.is_superuser or self.is_system_account_user():
|
||||||
|
return base_sites
|
||||||
|
|
||||||
# Other users can only access sites explicitly granted via SiteUserAccess
|
# Other users can only access sites explicitly granted via SiteUserAccess
|
||||||
return Site.objects.filter(
|
return base_sites.filter(user_access__user=self).distinct()
|
||||||
account=self.account,
|
|
||||||
is_active=True,
|
|
||||||
user_access__user=self
|
|
||||||
).distinct()
|
|
||||||
except (AttributeError, Exception):
|
except (AttributeError, Exception):
|
||||||
# If account access fails (e.g., column mismatch), return empty queryset
|
# If account access fails (e.g., column mismatch), return empty queryset
|
||||||
return Site.objects.none()
|
return Site.objects.none()
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ class PlanSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Plan
|
model = Plan
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'price', 'billing_cycle', 'features', 'is_active',
|
'id', 'name', 'slug', 'price', 'original_price', 'billing_cycle', 'annual_discount_percent',
|
||||||
'max_users', 'max_sites', 'max_keywords', 'max_clusters', 'max_content_ideas',
|
'is_featured', 'features', 'is_active',
|
||||||
'monthly_word_count_limit', 'monthly_ai_credit_limit', 'monthly_image_count',
|
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
|
||||||
'daily_content_tasks', 'daily_ai_request_limit', 'daily_image_generation_limit',
|
'max_keywords', 'max_clusters',
|
||||||
'included_credits', 'image_model_choices', 'credits_per_month'
|
'max_content_ideas', 'max_content_words',
|
||||||
|
'max_images_basic', 'max_images_premium', 'max_image_prompts',
|
||||||
|
'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'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -27,8 +31,8 @@ class SubscriptionSerializer(serializers.ModelSerializer):
|
|||||||
model = Subscription
|
model = Subscription
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'account', 'account_name', 'account_slug',
|
'id', 'account', 'account_name', 'account_slug',
|
||||||
'stripe_subscription_id', 'status',
|
'stripe_subscription_id', 'payment_method', 'external_payment_id',
|
||||||
'current_period_start', 'current_period_end',
|
'status', 'current_period_start', 'current_period_end',
|
||||||
'cancel_at_period_end',
|
'cancel_at_period_end',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
@@ -48,7 +52,11 @@ class AccountSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Account
|
model = Account
|
||||||
fields = ['id', 'name', 'slug', 'owner', 'plan', 'plan_id', 'credits', 'status', 'subscription', 'created_at']
|
fields = [
|
||||||
|
'id', 'name', 'slug', 'owner', 'plan', 'plan_id',
|
||||||
|
'credits', 'status', 'payment_method',
|
||||||
|
'subscription', 'created_at'
|
||||||
|
]
|
||||||
read_only_fields = ['owner', 'created_at']
|
read_only_fields = ['owner', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
@@ -68,12 +76,17 @@ class SiteSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'domain', 'description',
|
'id', 'name', 'slug', 'domain', 'description',
|
||||||
'industry', 'industry_name', 'industry_slug',
|
'industry', 'industry_name', 'industry_slug',
|
||||||
'is_active', 'status', 'wp_url', 'wp_username',
|
'is_active', 'status',
|
||||||
|
'site_type', 'hosting_type', 'seo_metadata',
|
||||||
'sectors_count', 'active_sectors_count', 'selected_sectors',
|
'sectors_count', 'active_sectors_count', 'selected_sectors',
|
||||||
'can_add_sectors',
|
'can_add_sectors',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = ['created_at', 'updated_at', 'account']
|
read_only_fields = ['created_at', 'updated_at', 'account']
|
||||||
|
# Explicitly specify required fields for clarity
|
||||||
|
extra_kwargs = {
|
||||||
|
'industry': {'required': True, 'error_messages': {'required': 'Industry is required when creating a site.'}},
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Allow partial updates for PATCH requests."""
|
"""Allow partial updates for PATCH requests."""
|
||||||
@@ -81,10 +94,12 @@ class SiteSerializer(serializers.ModelSerializer):
|
|||||||
# Make slug optional - it will be auto-generated from name if not provided
|
# Make slug optional - it will be auto-generated from name if not provided
|
||||||
if 'slug' in self.fields:
|
if 'slug' in self.fields:
|
||||||
self.fields['slug'].required = False
|
self.fields['slug'].required = False
|
||||||
# For partial updates (PATCH), make name optional
|
# For partial updates (PATCH), make name and industry optional
|
||||||
if self.partial:
|
if self.partial:
|
||||||
if 'name' in self.fields:
|
if 'name' in self.fields:
|
||||||
self.fields['name'].required = False
|
self.fields['name'].required = False
|
||||||
|
if 'industry' in self.fields:
|
||||||
|
self.fields['industry'].required = False
|
||||||
|
|
||||||
def validate_domain(self, value):
|
def validate_domain(self, value):
|
||||||
"""Ensure domain has https:// protocol.
|
"""Ensure domain has https:// protocol.
|
||||||
@@ -93,8 +108,9 @@ class SiteSerializer(serializers.ModelSerializer):
|
|||||||
- If domain has no protocol, add https://
|
- If domain has no protocol, add https://
|
||||||
- Validates that the final URL is valid
|
- Validates that the final URL is valid
|
||||||
"""
|
"""
|
||||||
if not value:
|
# Allow empty/None values
|
||||||
return value
|
if not value or value.strip() == '':
|
||||||
|
return None
|
||||||
|
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
|
|
||||||
@@ -229,6 +245,9 @@ class SiteUserAccessSerializer(serializers.ModelSerializer):
|
|||||||
read_only_fields = ['granted_at']
|
read_only_fields = ['granted_at']
|
||||||
|
|
||||||
|
|
||||||
|
from igny8_core.business.billing.models import PAYMENT_METHOD_CHOICES
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
account = AccountSerializer(read_only=True)
|
account = AccountSerializer(read_only=True)
|
||||||
accessible_sites = serializers.SerializerMethodField()
|
accessible_sites = serializers.SerializerMethodField()
|
||||||
@@ -259,6 +278,21 @@ class RegisterSerializer(serializers.Serializer):
|
|||||||
allow_null=True,
|
allow_null=True,
|
||||||
default=None
|
default=None
|
||||||
)
|
)
|
||||||
|
plan_slug = serializers.CharField(max_length=50, required=False)
|
||||||
|
payment_method = serializers.ChoiceField(
|
||||||
|
choices=[choice[0] for choice in PAYMENT_METHOD_CHOICES],
|
||||||
|
default='bank_transfer',
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
# Billing information fields
|
||||||
|
billing_email = serializers.EmailField(required=False, allow_blank=True)
|
||||||
|
billing_address_line1 = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||||
|
billing_address_line2 = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||||
|
billing_city = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||||||
|
billing_state = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||||||
|
billing_postal_code = serializers.CharField(max_length=20, required=False, allow_blank=True)
|
||||||
|
billing_country = serializers.CharField(max_length=2, required=False, allow_blank=True)
|
||||||
|
tax_id = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
if attrs['password'] != attrs['password_confirm']:
|
if attrs['password'] != attrs['password_confirm']:
|
||||||
@@ -270,23 +304,59 @@ class RegisterSerializer(serializers.Serializer):
|
|||||||
if 'plan_id' in attrs and attrs.get('plan_id') == '':
|
if 'plan_id' in attrs and attrs.get('plan_id') == '':
|
||||||
attrs['plan_id'] = None
|
attrs['plan_id'] = None
|
||||||
|
|
||||||
|
# Validate billing fields for paid plans
|
||||||
|
plan_slug = attrs.get('plan_slug')
|
||||||
|
paid_plans = ['starter', 'growth', 'scale']
|
||||||
|
if plan_slug and plan_slug in paid_plans:
|
||||||
|
# Require billing_country for paid plans
|
||||||
|
if not attrs.get('billing_country'):
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
"billing_country": "Billing country is required for paid plans."
|
||||||
|
})
|
||||||
|
# Require payment_method for paid plans
|
||||||
|
if not attrs.get('payment_method'):
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
"payment_method": "Payment method is required for paid plans."
|
||||||
|
})
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from igny8_core.business.billing.models import CreditTransaction
|
||||||
|
from igny8_core.auth.models import Subscription
|
||||||
|
from igny8_core.business.billing.models import AccountPaymentMethod
|
||||||
|
from igny8_core.business.billing.services.invoice_service import InvoiceService
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Get or assign free plan
|
plan_slug = validated_data.get('plan_slug')
|
||||||
plan = validated_data.get('plan_id')
|
paid_plans = ['starter', 'growth', 'scale']
|
||||||
if not plan:
|
|
||||||
# Auto-assign free plan
|
if plan_slug and plan_slug in paid_plans:
|
||||||
|
try:
|
||||||
|
plan = Plan.objects.get(slug=plan_slug, is_active=True)
|
||||||
|
except Plan.DoesNotExist:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
"plan": f"Plan '{plan_slug}' not available. Please contact support."
|
||||||
|
})
|
||||||
|
account_status = 'pending_payment'
|
||||||
|
initial_credits = 0
|
||||||
|
billing_period_start = timezone.now()
|
||||||
|
# simple monthly cycle; if annual needed, extend here
|
||||||
|
billing_period_end = billing_period_start + timedelta(days=30)
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
plan = Plan.objects.get(slug='free', is_active=True)
|
plan = Plan.objects.get(slug='free', is_active=True)
|
||||||
except Plan.DoesNotExist:
|
except Plan.DoesNotExist:
|
||||||
# Fallback: get first active plan ordered by price (cheapest)
|
raise serializers.ValidationError({
|
||||||
plan = Plan.objects.filter(is_active=True).order_by('price').first()
|
"plan": "Free plan not configured. Please contact support."
|
||||||
if not plan:
|
})
|
||||||
raise serializers.ValidationError({"plan": "No active plans available"})
|
account_status = 'trial'
|
||||||
|
initial_credits = plan.get_effective_credits_per_month()
|
||||||
|
billing_period_start = None
|
||||||
|
billing_period_end = None
|
||||||
|
|
||||||
# Generate account name if not provided
|
# Generate account name if not provided
|
||||||
account_name = validated_data.get('account_name')
|
account_name = validated_data.get('account_name')
|
||||||
@@ -294,7 +364,8 @@ class RegisterSerializer(serializers.Serializer):
|
|||||||
first_name = validated_data.get('first_name', '')
|
first_name = validated_data.get('first_name', '')
|
||||||
last_name = validated_data.get('last_name', '')
|
last_name = validated_data.get('last_name', '')
|
||||||
if first_name or last_name:
|
if first_name or last_name:
|
||||||
account_name = f"{first_name} {last_name}".strip() or validated_data['email'].split('@')[0]
|
account_name = f"{first_name} {last_name}".strip() or \
|
||||||
|
validated_data['email'].split('@')[0]
|
||||||
else:
|
else:
|
||||||
account_name = validated_data['email'].split('@')[0]
|
account_name = validated_data['email'].split('@')[0]
|
||||||
|
|
||||||
@@ -320,18 +391,89 @@ class RegisterSerializer(serializers.Serializer):
|
|||||||
role='owner'
|
role='owner'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Now create account with user as owner
|
# Generate unique slug for account
|
||||||
|
base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account'
|
||||||
|
slug = base_slug
|
||||||
|
counter = 1
|
||||||
|
while Account.objects.filter(slug=slug).exists():
|
||||||
|
slug = f"{base_slug}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Create account with status and credits seeded (0 for paid pending)
|
||||||
account = Account.objects.create(
|
account = Account.objects.create(
|
||||||
name=account_name,
|
name=account_name,
|
||||||
slug=account_name.lower().replace(' ', '-').replace('_', '-')[:50],
|
slug=slug,
|
||||||
owner=user,
|
owner=user,
|
||||||
plan=plan
|
plan=plan,
|
||||||
|
credits=initial_credits,
|
||||||
|
status=account_status,
|
||||||
|
payment_method=validated_data.get('payment_method') or 'bank_transfer',
|
||||||
|
# Save billing information
|
||||||
|
billing_email=validated_data.get('billing_email', '') or validated_data.get('email', ''),
|
||||||
|
billing_address_line1=validated_data.get('billing_address_line1', ''),
|
||||||
|
billing_address_line2=validated_data.get('billing_address_line2', ''),
|
||||||
|
billing_city=validated_data.get('billing_city', ''),
|
||||||
|
billing_state=validated_data.get('billing_state', ''),
|
||||||
|
billing_postal_code=validated_data.get('billing_postal_code', ''),
|
||||||
|
billing_country=validated_data.get('billing_country', ''),
|
||||||
|
tax_id=validated_data.get('tax_id', ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log initial credit transaction only for free/trial accounts with credits
|
||||||
|
if initial_credits > 0:
|
||||||
|
CreditTransaction.objects.create(
|
||||||
|
account=account,
|
||||||
|
transaction_type='subscription',
|
||||||
|
amount=initial_credits,
|
||||||
|
balance_after=initial_credits,
|
||||||
|
description=f'Free plan credits from {plan.name}',
|
||||||
|
metadata={
|
||||||
|
'plan_slug': plan.slug,
|
||||||
|
'registration': True,
|
||||||
|
'trial': True
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update user to reference the new account
|
# Update user to reference the new account
|
||||||
user.account = account
|
user.account = account
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
|
# For paid plans, create subscription, invoice, and default payment method
|
||||||
|
if plan_slug and plan_slug in paid_plans:
|
||||||
|
payment_method = validated_data.get('payment_method', 'bank_transfer')
|
||||||
|
|
||||||
|
subscription = Subscription.objects.create(
|
||||||
|
account=account,
|
||||||
|
plan=plan,
|
||||||
|
status='pending_payment',
|
||||||
|
external_payment_id=None,
|
||||||
|
current_period_start=billing_period_start,
|
||||||
|
current_period_end=billing_period_end,
|
||||||
|
cancel_at_period_end=False,
|
||||||
|
)
|
||||||
|
# Create pending invoice for the first period
|
||||||
|
InvoiceService.create_subscription_invoice(
|
||||||
|
subscription=subscription,
|
||||||
|
billing_period_start=billing_period_start,
|
||||||
|
billing_period_end=billing_period_end,
|
||||||
|
)
|
||||||
|
# Create AccountPaymentMethod with selected payment method
|
||||||
|
payment_method_display_names = {
|
||||||
|
'stripe': 'Credit/Debit Card (Stripe)',
|
||||||
|
'paypal': 'PayPal',
|
||||||
|
'bank_transfer': 'Bank Transfer (Manual)',
|
||||||
|
'local_wallet': 'Mobile Wallet (Manual)',
|
||||||
|
}
|
||||||
|
AccountPaymentMethod.objects.create(
|
||||||
|
account=account,
|
||||||
|
type=payment_method,
|
||||||
|
display_name=payment_method_display_names.get(payment_method, payment_method.title()),
|
||||||
|
is_default=True,
|
||||||
|
is_enabled=True,
|
||||||
|
is_verified=False,
|
||||||
|
instructions='Please complete payment and confirm with your transaction reference.',
|
||||||
|
)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ from .views import (
|
|||||||
SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet,
|
SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet,
|
||||||
IndustryViewSet, SeedKeywordViewSet
|
IndustryViewSet, SeedKeywordViewSet
|
||||||
)
|
)
|
||||||
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer
|
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer, RefreshTokenSerializer
|
||||||
from .models import User
|
from .models import User
|
||||||
|
from .utils import generate_access_token, get_token_expiry, decode_token
|
||||||
|
import jwt
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
# Main structure: Groups, Users, Accounts, Subscriptions, Site User Access
|
# Main structure: Groups, Users, Accounts, Subscriptions, Site User Access
|
||||||
@@ -44,12 +46,36 @@ class RegisterView(APIView):
|
|||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
|
from .utils import generate_access_token, generate_refresh_token, get_token_expiry
|
||||||
|
from django.contrib.auth import login
|
||||||
|
|
||||||
serializer = RegisterSerializer(data=request.data)
|
serializer = RegisterSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
|
|
||||||
|
# Log the user in (create session for session authentication)
|
||||||
|
login(request, user)
|
||||||
|
|
||||||
|
# Get account from user
|
||||||
|
account = getattr(user, 'account', None)
|
||||||
|
|
||||||
|
# Generate JWT tokens
|
||||||
|
access_token = generate_access_token(user, account)
|
||||||
|
refresh_token = generate_refresh_token(user, account)
|
||||||
|
access_expires_at = get_token_expiry('access')
|
||||||
|
refresh_expires_at = get_token_expiry('refresh')
|
||||||
|
|
||||||
user_serializer = UserSerializer(user)
|
user_serializer = UserSerializer(user)
|
||||||
return success_response(
|
return success_response(
|
||||||
data={'user': user_serializer.data},
|
data={
|
||||||
|
'user': user_serializer.data,
|
||||||
|
'tokens': {
|
||||||
|
'access': access_token,
|
||||||
|
'refresh': refresh_token,
|
||||||
|
'access_expires_at': access_expires_at.isoformat(),
|
||||||
|
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||||
|
}
|
||||||
|
},
|
||||||
message='Registration successful',
|
message='Registration successful',
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
request=request
|
request=request
|
||||||
@@ -78,7 +104,7 @@ class LoginView(APIView):
|
|||||||
password = serializer.validated_data['password']
|
password = serializer.validated_data['password']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(email=email)
|
user = User.objects.select_related('account', 'account__plan').get(email=email)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return error_response(
|
return error_response(
|
||||||
error='Invalid credentials',
|
error='Invalid credentials',
|
||||||
@@ -107,9 +133,17 @@ class LoginView(APIView):
|
|||||||
user_data = user_serializer.data
|
user_data = user_serializer.data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Fallback if serializer fails (e.g., missing account_id column)
|
# 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 = {
|
user_data = {
|
||||||
'id': user.id,
|
'id': user.id,
|
||||||
'username': user.username,
|
'username': username,
|
||||||
'email': user.email,
|
'email': user.email,
|
||||||
'role': user.role,
|
'role': user.role,
|
||||||
'account': None,
|
'account': None,
|
||||||
@@ -119,12 +153,10 @@ class LoginView(APIView):
|
|||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
'user': user_data,
|
'user': user_data,
|
||||||
'tokens': {
|
|
||||||
'access': access_token,
|
'access': access_token,
|
||||||
'refresh': refresh_token,
|
'refresh': refresh_token,
|
||||||
'access_expires_at': access_expires_at.isoformat(),
|
'access_expires_at': access_expires_at.isoformat(),
|
||||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||||
}
|
|
||||||
},
|
},
|
||||||
message='Login successful',
|
message='Login successful',
|
||||||
request=request
|
request=request
|
||||||
@@ -180,6 +212,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
|
@extend_schema(exclude=True) # Exclude from public API documentation - internal authenticated endpoint
|
||||||
class MeView(APIView):
|
class MeView(APIView):
|
||||||
"""Get current user information."""
|
"""Get current user information."""
|
||||||
@@ -201,6 +311,7 @@ urlpatterns = [
|
|||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
|
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
|
||||||
path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'),
|
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('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
|
||||||
path('me/', MeView.as_view(), name='auth-me'),
|
path('me/', MeView.as_view(), name='auth-me'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -128,3 +128,88 @@ def get_token_expiry(token_type='access'):
|
|||||||
return now + get_refresh_token_expiry()
|
return now + get_refresh_token_expiry()
|
||||||
return now + get_access_token_expiry()
|
return now + get_access_token_expiry()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def validate_account_and_plan(user_or_account):
|
||||||
|
"""
|
||||||
|
Validate account exists and has active plan.
|
||||||
|
Allows trial, active, and pending_payment statuses.
|
||||||
|
Bypasses validation for superusers, developers, and system accounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_or_account: User or Account instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (is_valid: bool, error_msg: str or None, http_status: int or None)
|
||||||
|
"""
|
||||||
|
from rest_framework import status
|
||||||
|
from .models import User, Account
|
||||||
|
|
||||||
|
# Bypass validation for superusers
|
||||||
|
if isinstance(user_or_account, User):
|
||||||
|
if getattr(user_or_account, 'is_superuser', False):
|
||||||
|
return (True, None, None)
|
||||||
|
|
||||||
|
# Bypass validation for developers
|
||||||
|
if hasattr(user_or_account, 'role') and user_or_account.role == 'developer':
|
||||||
|
return (True, None, None)
|
||||||
|
|
||||||
|
# Bypass validation for system account users
|
||||||
|
try:
|
||||||
|
if hasattr(user_or_account, 'is_system_account_user') and user_or_account.is_system_account_user():
|
||||||
|
return (True, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Extract account from user or use directly
|
||||||
|
if isinstance(user_or_account, User):
|
||||||
|
try:
|
||||||
|
account = getattr(user_or_account, 'account', None)
|
||||||
|
except Exception:
|
||||||
|
account = None
|
||||||
|
elif isinstance(user_or_account, Account):
|
||||||
|
account = user_or_account
|
||||||
|
# Check if account is a system account
|
||||||
|
try:
|
||||||
|
if hasattr(account, 'is_system_account') and account.is_system_account():
|
||||||
|
return (True, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return (False, 'Invalid object type', status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Check account exists
|
||||||
|
if not account:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
'Account not configured for this user. Please contact support.',
|
||||||
|
status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check account status - allow trial, active, pending_payment
|
||||||
|
# Block only suspended and cancelled
|
||||||
|
if hasattr(account, 'status') and account.status in ['suspended', 'cancelled']:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f'Account is {account.status}. Please contact support.',
|
||||||
|
status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check plan exists and is active
|
||||||
|
plan = getattr(account, 'plan', None)
|
||||||
|
if not plan:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
'No subscription plan assigned. Visit igny8.com/pricing to subscribe.',
|
||||||
|
status.HTTP_402_PAYMENT_REQUIRED
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(plan, 'is_active') and not plan.is_active:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
'Active subscription required. Visit igny8.com/pricing to subscribe.',
|
||||||
|
status.HTTP_402_PAYMENT_REQUIRED
|
||||||
|
)
|
||||||
|
|
||||||
|
return (True, None, None)
|
||||||
|
|||||||
@@ -341,7 +341,8 @@ class SubscriptionsViewSet(AccountModelViewSet):
|
|||||||
queryset = Subscription.objects.all()
|
queryset = Subscription.objects.all()
|
||||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsOwnerOrAdmin]
|
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsOwnerOrAdmin]
|
||||||
pagination_class = CustomPageNumberPagination
|
pagination_class = CustomPageNumberPagination
|
||||||
throttle_scope = 'auth'
|
# Use relaxed auth throttle to avoid 429s during onboarding plan fetches
|
||||||
|
throttle_scope = 'auth_read'
|
||||||
throttle_classes = [DebugScopedRateThrottle]
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@@ -439,14 +440,26 @@ class SiteUserAccessViewSet(AccountModelViewSet):
|
|||||||
class PlanViewSet(viewsets.ReadOnlyModelViewSet):
|
class PlanViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for listing active subscription plans.
|
ViewSet for listing active subscription plans.
|
||||||
|
Excludes internal-only plans (Free/Internal) from public listings.
|
||||||
Unified API Standard v1.0 compliant
|
Unified API Standard v1.0 compliant
|
||||||
"""
|
"""
|
||||||
queryset = Plan.objects.filter(is_active=True)
|
queryset = Plan.objects.filter(is_active=True, is_internal=False)
|
||||||
serializer_class = PlanSerializer
|
serializer_class = PlanSerializer
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
pagination_class = CustomPageNumberPagination
|
pagination_class = CustomPageNumberPagination
|
||||||
throttle_scope = 'auth'
|
# Plans are public and should not throttle aggressively to avoid blocking signup/onboarding
|
||||||
throttle_classes = [DebugScopedRateThrottle]
|
throttle_scope = None
|
||||||
|
throttle_classes: list = []
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
"""Override list to return paginated response with unified format"""
|
||||||
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
|
page = self.paginate_queryset(queryset)
|
||||||
|
if page is not None:
|
||||||
|
serializer = self.get_serializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
|
return success_response(data={'results': serializer.data}, request=request)
|
||||||
|
|
||||||
def retrieve(self, request, *args, **kwargs):
|
def retrieve(self, request, *args, **kwargs):
|
||||||
"""Override retrieve to return unified format"""
|
"""Override retrieve to return unified format"""
|
||||||
@@ -474,47 +487,69 @@ class SiteViewSet(AccountModelViewSet):
|
|||||||
"""ViewSet for managing Sites."""
|
"""ViewSet for managing Sites."""
|
||||||
serializer_class = SiteSerializer
|
serializer_class = SiteSerializer
|
||||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsEditorOrAbove]
|
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsEditorOrAbove]
|
||||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
authentication_classes = [JWTAuthentication]
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
"""Allow normal users (viewer) to create sites, but require editor+ for other operations."""
|
"""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':
|
if self.action == 'create':
|
||||||
|
# For create, only require authentication - not active account status
|
||||||
return [permissions.IsAuthenticated()]
|
return [permissions.IsAuthenticated()]
|
||||||
return [IsEditorOrAbove()]
|
return [IsAuthenticatedAndActive(), HasTenantAccess(), IsEditorOrAbove()]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return sites accessible to the current user."""
|
"""Return sites accessible to the current user."""
|
||||||
user = self.request.user
|
# If this is a public request (no auth) with slug filter, return site by slug
|
||||||
if not user or not user.is_authenticated:
|
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()
|
return Site.objects.none()
|
||||||
|
|
||||||
# ADMIN/DEV OVERRIDE: Both admins and developers can see all sites
|
user = self.request.user
|
||||||
if user.is_admin_or_developer():
|
|
||||||
return Site.objects.all().distinct()
|
|
||||||
|
|
||||||
# Get account from user
|
|
||||||
account = getattr(user, 'account', None)
|
account = getattr(user, 'account', None)
|
||||||
if not account:
|
if not account:
|
||||||
return Site.objects.none()
|
return Site.objects.none()
|
||||||
|
|
||||||
if user.role in ['owner', 'admin']:
|
if hasattr(user, 'get_accessible_sites'):
|
||||||
|
return user.get_accessible_sites()
|
||||||
|
|
||||||
return Site.objects.filter(account=account)
|
return Site.objects.filter(account=account)
|
||||||
|
|
||||||
return Site.objects.filter(
|
|
||||||
account=account,
|
|
||||||
user_access__user=user
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Create site with account."""
|
"""Create site with account and auto-grant access to creator."""
|
||||||
account = getattr(self.request, 'account', None)
|
account = getattr(self.request, 'account', None)
|
||||||
if not account:
|
if not account:
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
if user and user.is_authenticated:
|
if user and user.is_authenticated:
|
||||||
account = getattr(user, 'account', None)
|
account = getattr(user, 'account', None)
|
||||||
|
|
||||||
|
# Check hard limit for sites
|
||||||
|
from igny8_core.business.billing.services.limit_service import LimitService, HardLimitExceededError
|
||||||
|
try:
|
||||||
|
LimitService.check_hard_limit(account, 'sites', additional_count=1)
|
||||||
|
except HardLimitExceededError as e:
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
raise PermissionDenied(str(e))
|
||||||
|
|
||||||
# Multiple sites can be active simultaneously - no constraint
|
# Multiple sites can be active simultaneously - no constraint
|
||||||
serializer.save(account=account)
|
site = serializer.save(account=account)
|
||||||
|
|
||||||
|
# Auto-create SiteUserAccess for owner/admin who creates the site
|
||||||
|
user = self.request.user
|
||||||
|
if user and user.is_authenticated and hasattr(user, 'role'):
|
||||||
|
if user.role in ['owner', 'admin']:
|
||||||
|
from igny8_core.auth.models import SiteUserAccess
|
||||||
|
SiteUserAccess.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
site=site,
|
||||||
|
defaults={'granted_by': user}
|
||||||
|
)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
"""Update site."""
|
"""Update site."""
|
||||||
@@ -717,18 +752,13 @@ class SectorViewSet(AccountModelViewSet):
|
|||||||
"""ViewSet for managing Sectors."""
|
"""ViewSet for managing Sectors."""
|
||||||
serializer_class = SectorSerializer
|
serializer_class = SectorSerializer
|
||||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsEditorOrAbove]
|
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsEditorOrAbove]
|
||||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
authentication_classes = [JWTAuthentication]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return sectors from sites accessible to the current user."""
|
"""Return sectors from sites accessible to the current user."""
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
if not user or not user.is_authenticated:
|
if not user or not user.is_authenticated:
|
||||||
return Sector.objects.none()
|
return Sector.objects.none()
|
||||||
|
|
||||||
# ADMIN/DEV OVERRIDE: Both admins and developers can see all sectors across all sites
|
|
||||||
if user.is_admin_or_developer():
|
|
||||||
return Sector.objects.all().distinct()
|
|
||||||
|
|
||||||
accessible_sites = user.get_accessible_sites()
|
accessible_sites = user.get_accessible_sites()
|
||||||
return Sector.objects.filter(site__in=accessible_sites)
|
return Sector.objects.filter(site__in=accessible_sites)
|
||||||
|
|
||||||
@@ -828,15 +858,134 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
"""Filter by industry and sector if provided."""
|
"""Filter by industry and sector if provided."""
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
industry_id = self.request.query_params.get('industry_id')
|
industry_id = self.request.query_params.get('industry_id')
|
||||||
|
industry_name = self.request.query_params.get('industry_name')
|
||||||
sector_id = self.request.query_params.get('sector_id')
|
sector_id = self.request.query_params.get('sector_id')
|
||||||
|
sector_name = self.request.query_params.get('sector_name')
|
||||||
|
|
||||||
if industry_id:
|
if industry_id:
|
||||||
queryset = queryset.filter(industry_id=industry_id)
|
queryset = queryset.filter(industry_id=industry_id)
|
||||||
|
if industry_name:
|
||||||
|
queryset = queryset.filter(industry__name__icontains=industry_name)
|
||||||
if sector_id:
|
if sector_id:
|
||||||
queryset = queryset.filter(sector_id=sector_id)
|
queryset = queryset.filter(sector_id=sector_id)
|
||||||
|
if sector_name:
|
||||||
|
queryset = queryset.filter(sector__name__icontains=sector_name)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'], url_path='import_seed_keywords', url_name='import_seed_keywords')
|
||||||
|
def import_seed_keywords(self, request):
|
||||||
|
"""
|
||||||
|
Import seed keywords from CSV (Admin/Superuser only).
|
||||||
|
Expected columns: keyword, industry_name, sector_name, volume, difficulty, intent
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
# Check admin/superuser permission
|
||||||
|
if not (request.user.is_staff or request.user.is_superuser):
|
||||||
|
return error_response(
|
||||||
|
error='Admin or superuser access required',
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'file' not in request.FILES:
|
||||||
|
return error_response(
|
||||||
|
error='No file provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
file = request.FILES['file']
|
||||||
|
if not file.name.endswith('.csv'):
|
||||||
|
return error_response(
|
||||||
|
error='File must be a CSV',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse CSV
|
||||||
|
decoded_file = file.read().decode('utf-8')
|
||||||
|
csv_reader = csv.DictReader(decoded_file.splitlines())
|
||||||
|
|
||||||
|
imported_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 (header is row 1)
|
||||||
|
try:
|
||||||
|
keyword_text = row.get('keyword', '').strip()
|
||||||
|
industry_name = row.get('industry_name', '').strip()
|
||||||
|
sector_name = row.get('sector_name', '').strip()
|
||||||
|
|
||||||
|
if not all([keyword_text, industry_name, sector_name]):
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get or create industry
|
||||||
|
industry = Industry.objects.filter(name=industry_name).first()
|
||||||
|
if not industry:
|
||||||
|
errors.append(f"Row {row_num}: Industry '{industry_name}' not found")
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get or create industry sector
|
||||||
|
sector = IndustrySector.objects.filter(
|
||||||
|
industry=industry,
|
||||||
|
name=sector_name
|
||||||
|
).first()
|
||||||
|
if not sector:
|
||||||
|
errors.append(f"Row {row_num}: Sector '{sector_name}' not found for industry '{industry_name}'")
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if keyword already exists
|
||||||
|
existing = SeedKeyword.objects.filter(
|
||||||
|
keyword=keyword_text,
|
||||||
|
industry=industry,
|
||||||
|
sector=sector
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create seed keyword
|
||||||
|
SeedKeyword.objects.create(
|
||||||
|
keyword=keyword_text,
|
||||||
|
industry=industry,
|
||||||
|
sector=sector,
|
||||||
|
volume=int(row.get('volume', 0) or 0),
|
||||||
|
difficulty=int(row.get('difficulty', 0) or 0),
|
||||||
|
intent=row.get('intent', 'informational') or 'informational',
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
imported_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Row {row_num}: {str(e)}")
|
||||||
|
skipped_count += 1
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data={
|
||||||
|
'imported': imported_count,
|
||||||
|
'skipped': skipped_count,
|
||||||
|
'errors': errors[:10] if errors else [] # Limit errors to first 10
|
||||||
|
},
|
||||||
|
message=f'Import completed: {imported_count} keywords imported, {skipped_count} skipped',
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return error_response(
|
||||||
|
error=f'Failed to import keywords: {str(e)}',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# AUTHENTICATION ENDPOINTS (Register, Login, Change Password, Me)
|
# AUTHENTICATION ENDPOINTS (Register, Login, Change Password, Me)
|
||||||
@@ -916,13 +1065,28 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if user.check_password(password):
|
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)
|
# Log the user in (create session for session authentication)
|
||||||
from django.contrib.auth import login
|
from django.contrib.auth import login
|
||||||
login(request, user)
|
login(request, user)
|
||||||
|
|
||||||
# Get account from user
|
|
||||||
account = getattr(user, 'account', None)
|
|
||||||
|
|
||||||
# Generate JWT tokens
|
# Generate JWT tokens
|
||||||
access_token = generate_access_token(user, account)
|
access_token = generate_access_token(user, account)
|
||||||
refresh_token = generate_refresh_token(user, account)
|
refresh_token = generate_refresh_token(user, account)
|
||||||
@@ -933,12 +1097,10 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
'user': user_serializer.data,
|
'user': user_serializer.data,
|
||||||
'tokens': {
|
|
||||||
'access': access_token,
|
'access': access_token,
|
||||||
'refresh': refresh_token,
|
'refresh': refresh_token,
|
||||||
'access_expires_at': access_expires_at.isoformat(),
|
'access_expires_at': access_expires_at.isoformat(),
|
||||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||||
}
|
|
||||||
},
|
},
|
||||||
message='Login successful',
|
message='Login successful',
|
||||||
request=request
|
request=request
|
||||||
@@ -1174,3 +1336,219 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
message='Password has been reset successfully',
|
message='Password has been reset successfully',
|
||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CSV Import/Export Views for Admin
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
from django.http import HttpResponse, JsonResponse
|
||||||
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def industry_csv_template(request):
|
||||||
|
"""Download CSV template for Industry import"""
|
||||||
|
response = HttpResponse(content_type='text/csv')
|
||||||
|
response['Content-Disposition'] = 'attachment; filename="industry_template.csv"'
|
||||||
|
|
||||||
|
writer = csv.writer(response)
|
||||||
|
writer.writerow(['name', 'description', 'is_active'])
|
||||||
|
writer.writerow(['Technology', 'Technology industry', 'true'])
|
||||||
|
writer.writerow(['Healthcare', 'Healthcare and medical services', 'true'])
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def industry_csv_import(request):
|
||||||
|
"""Import industries from CSV"""
|
||||||
|
if not request.FILES.get('csv_file'):
|
||||||
|
return JsonResponse({'success': False, 'error': 'No CSV file provided'}, status=400)
|
||||||
|
|
||||||
|
csv_file = request.FILES['csv_file']
|
||||||
|
decoded_file = csv_file.read().decode('utf-8')
|
||||||
|
io_string = io.StringIO(decoded_file)
|
||||||
|
reader = csv.DictReader(io_string)
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
for row_num, row in enumerate(reader, start=2):
|
||||||
|
try:
|
||||||
|
is_active = row.get('is_active', 'true').lower() in ['true', '1', 'yes']
|
||||||
|
slug = slugify(row['name'])
|
||||||
|
|
||||||
|
industry, created_flag = Industry.objects.update_or_create(
|
||||||
|
name=row['name'],
|
||||||
|
defaults={
|
||||||
|
'slug': slug,
|
||||||
|
'description': row.get('description', ''),
|
||||||
|
'is_active': is_active
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if created_flag:
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Row {row_num}: {str(e)}")
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'created': created,
|
||||||
|
'updated': updated,
|
||||||
|
'errors': errors
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def industrysector_csv_template(request):
|
||||||
|
"""Download CSV template for IndustrySector import"""
|
||||||
|
response = HttpResponse(content_type='text/csv')
|
||||||
|
response['Content-Disposition'] = 'attachment; filename="industrysector_template.csv"'
|
||||||
|
|
||||||
|
writer = csv.writer(response)
|
||||||
|
writer.writerow(['name', 'industry', 'description', 'is_active'])
|
||||||
|
writer.writerow(['Software Development', 'Technology', 'Software and app development', 'true'])
|
||||||
|
writer.writerow(['Healthcare IT', 'Healthcare', 'Healthcare information technology', 'true'])
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def industrysector_csv_import(request):
|
||||||
|
"""Import industry sectors from CSV"""
|
||||||
|
if not request.FILES.get('csv_file'):
|
||||||
|
return JsonResponse({'success': False, 'error': 'No CSV file provided'}, status=400)
|
||||||
|
|
||||||
|
csv_file = request.FILES['csv_file']
|
||||||
|
decoded_file = csv_file.read().decode('utf-8')
|
||||||
|
io_string = io.StringIO(decoded_file)
|
||||||
|
reader = csv.DictReader(io_string)
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
for row_num, row in enumerate(reader, start=2):
|
||||||
|
try:
|
||||||
|
is_active = row.get('is_active', 'true').lower() in ['true', '1', 'yes']
|
||||||
|
slug = slugify(row['name'])
|
||||||
|
|
||||||
|
# Find industry by name
|
||||||
|
try:
|
||||||
|
industry = Industry.objects.get(name=row['industry'])
|
||||||
|
except Industry.DoesNotExist:
|
||||||
|
errors.append(f"Row {row_num}: Industry '{row['industry']}' not found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
sector, created_flag = IndustrySector.objects.update_or_create(
|
||||||
|
name=row['name'],
|
||||||
|
industry=industry,
|
||||||
|
defaults={
|
||||||
|
'slug': slug,
|
||||||
|
'description': row.get('description', ''),
|
||||||
|
'is_active': is_active
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if created_flag:
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Row {row_num}: {str(e)}")
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'created': created,
|
||||||
|
'updated': updated,
|
||||||
|
'errors': errors
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def seedkeyword_csv_template(request):
|
||||||
|
"""Download CSV template for SeedKeyword import"""
|
||||||
|
response = HttpResponse(content_type='text/csv')
|
||||||
|
response['Content-Disposition'] = 'attachment; filename="seedkeyword_template.csv"'
|
||||||
|
|
||||||
|
writer = csv.writer(response)
|
||||||
|
writer.writerow(['keyword', 'industry', 'sector', 'volume', 'difficulty', 'intent', 'is_active'])
|
||||||
|
writer.writerow(['python programming', 'Technology', 'Software Development', '10000', '45', 'Informational', 'true'])
|
||||||
|
writer.writerow(['medical software', 'Healthcare', 'Healthcare IT', '5000', '60', 'Commercial', 'true'])
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@staff_member_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def seedkeyword_csv_import(request):
|
||||||
|
"""Import seed keywords from CSV"""
|
||||||
|
if not request.FILES.get('csv_file'):
|
||||||
|
return JsonResponse({'success': False, 'error': 'No CSV file provided'}, status=400)
|
||||||
|
|
||||||
|
csv_file = request.FILES['csv_file']
|
||||||
|
decoded_file = csv_file.read().decode('utf-8')
|
||||||
|
io_string = io.StringIO(decoded_file)
|
||||||
|
reader = csv.DictReader(io_string)
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for row_num, row in enumerate(reader, start=2):
|
||||||
|
try:
|
||||||
|
is_active = row.get('is_active', 'true').lower() in ['true', '1', 'yes']
|
||||||
|
|
||||||
|
# Find industry and sector by name
|
||||||
|
try:
|
||||||
|
industry = Industry.objects.get(name=row['industry'])
|
||||||
|
except Industry.DoesNotExist:
|
||||||
|
errors.append(f"Row {row_num}: Industry '{row['industry']}' not found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
sector = IndustrySector.objects.get(name=row['sector'], industry=industry)
|
||||||
|
except IndustrySector.DoesNotExist:
|
||||||
|
errors.append(f"Row {row_num}: Sector '{row['sector']}' not found in industry '{row['industry']}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
keyword, created_flag = SeedKeyword.objects.update_or_create(
|
||||||
|
keyword=row['keyword'],
|
||||||
|
industry=industry,
|
||||||
|
sector=sector,
|
||||||
|
defaults={
|
||||||
|
'volume': int(row.get('volume', 0)),
|
||||||
|
'difficulty': int(row.get('difficulty', 0)),
|
||||||
|
'intent': row.get('intent', 'Informational'),
|
||||||
|
'is_active': is_active
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if created_flag:
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Row {row_num}: {str(e)}")
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'created': created,
|
||||||
|
'updated': updated,
|
||||||
|
'errors': errors
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
Orchestrates AI functions into automated pipelines
|
||||||
|
"""
|
||||||
35
backend/igny8_core/business/automation/admin.py
Normal file
35
backend/igny8_core/business/automation/admin.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Admin registration for Automation models
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib import messages
|
||||||
|
from unfold.admin import ModelAdmin
|
||||||
|
from igny8_core.admin.base import AccountAdminMixin, Igny8ModelAdmin
|
||||||
|
from .models import AutomationConfig, AutomationRun
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AutomationConfig)
|
||||||
|
class AutomationConfigAdmin(AccountAdminMixin, Igny8ModelAdmin):
|
||||||
|
list_display = ('site', 'is_enabled', 'frequency', 'scheduled_time', 'within_stage_delay', 'between_stage_delay', 'last_run_at')
|
||||||
|
list_filter = ('is_enabled', 'frequency')
|
||||||
|
search_fields = ('site__domain',)
|
||||||
|
actions = ['bulk_enable', 'bulk_disable']
|
||||||
|
|
||||||
|
def bulk_enable(self, request, queryset):
|
||||||
|
"""Enable selected automation configs"""
|
||||||
|
updated = queryset.update(is_enabled=True)
|
||||||
|
self.message_user(request, f'{updated} automation config(s) enabled.', messages.SUCCESS)
|
||||||
|
bulk_enable.short_description = 'Enable selected automations'
|
||||||
|
|
||||||
|
def bulk_disable(self, request, queryset):
|
||||||
|
"""Disable selected automation configs"""
|
||||||
|
updated = queryset.update(is_enabled=False)
|
||||||
|
self.message_user(request, f'{updated} automation config(s) disabled.', messages.SUCCESS)
|
||||||
|
bulk_disable.short_description = 'Disable selected automations'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AutomationRun)
|
||||||
|
class AutomationRunAdmin(AccountAdminMixin, Igny8ModelAdmin):
|
||||||
|
list_display = ('run_id', 'site', 'status', 'current_stage', 'started_at', 'completed_at')
|
||||||
|
list_filter = ('status', 'current_stage')
|
||||||
|
search_fields = ('run_id', 'site__domain')
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user