Initial commit: igny8 project

This commit is contained in:
igny8
2025-11-09 10:27:02 +00:00
commit 60b8188111
27265 changed files with 4360521 additions and 0 deletions

View File

View File

@@ -0,0 +1,4 @@
"""
Billing module for credit management and usage tracking
"""

View File

@@ -0,0 +1,43 @@
"""
Billing Module Admin
"""
from django.contrib import admin
from igny8_core.admin.base import AccountAdminMixin
from .models import CreditTransaction, CreditUsageLog
@admin.register(CreditTransaction)
class CreditTransactionAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ['id', 'account', 'transaction_type', 'amount', 'balance_after', 'description', 'created_at']
list_filter = ['transaction_type', 'created_at', 'account']
search_fields = ['description', 'account__name']
readonly_fields = ['created_at']
date_hierarchy = 'created_at'
def get_account_display(self, obj):
"""Safely get account name"""
try:
account = getattr(obj, 'account', None)
return account.name if account else '-'
except:
return '-'
get_account_display.short_description = 'Account'
@admin.register(CreditUsageLog)
class CreditUsageLogAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ['id', 'account', 'operation_type', 'credits_used', 'cost_usd', 'model_used', 'created_at']
list_filter = ['operation_type', 'created_at', 'account', 'model_used']
search_fields = ['account__name', 'model_used']
readonly_fields = ['created_at']
date_hierarchy = 'created_at'
def get_account_display(self, obj):
"""Safely get account name"""
try:
account = getattr(obj, 'account', None)
return account.name if account else '-'
except:
return '-'
get_account_display.short_description = 'Account'

View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.modules.billing'
verbose_name = 'Billing'

View File

@@ -0,0 +1,22 @@
"""
Credit Cost Constants
"""
CREDIT_COSTS = {
'clustering': {
'base': 1, # 1 credit per 30 keywords
'per_keyword': 1 / 30,
},
'ideas': {
'base': 1, # 1 credit per idea
},
'content': {
'base': 3, # 3 credits per full blog post
},
'images': {
'base': 1, # 1 credit per image
},
'reparse': {
'base': 1, # 1 credit per reparse
},
}

View File

@@ -0,0 +1,14 @@
"""
Billing Exceptions
"""
class InsufficientCreditsError(Exception):
"""Raised when account doesn't have enough credits"""
pass
class CreditCalculationError(Exception):
"""Raised when credit calculation fails"""
pass

View File

@@ -0,0 +1,2 @@
# Management commands for billing module

View File

@@ -0,0 +1,2 @@
# Management commands

View File

@@ -0,0 +1,147 @@
"""
Management command to get account plan limits
Usage: python manage.py get_account_limits dev@igny8.com scale@igny8.com
"""
from django.core.management.base import BaseCommand
from igny8_core.auth.models import Account, Plan, User
from django.db.models import Q
class Command(BaseCommand):
help = 'Get plan limits for specified accounts'
def add_arguments(self, parser):
parser.add_argument('emails', nargs='+', type=str, help='Email addresses of accounts to check')
def handle(self, *args, **options):
emails = options['emails']
# Table header
print("\n" + "="*120)
print("PLAN LIMITS COMPARISON TABLE")
print("="*120 + "\n")
# Get all limit fields from Plan model
limit_fields = {
'General': [
('max_users', 'Max Users'),
('max_sites', 'Max Sites'),
],
'Planner': [
('max_keywords', 'Max Keywords'),
('max_clusters', 'Max Clusters'),
('max_content_ideas', 'Max Content Ideas'),
('daily_cluster_limit', 'Daily Cluster Limit'),
],
'Writer': [
('monthly_word_count_limit', 'Monthly Word Count Limit'),
('daily_content_tasks', 'Daily Content Tasks'),
],
'Images': [
('monthly_image_count', 'Monthly Image Count'),
('daily_image_generation_limit', 'Daily Image Generation Limit'),
],
'AI Credits': [
('monthly_ai_credit_limit', 'Monthly AI Credit Limit'),
('monthly_cluster_ai_credits', 'Monthly Cluster AI Credits'),
('monthly_content_ai_credits', 'Monthly Content AI Credits'),
('monthly_image_ai_credits', 'Monthly Image AI Credits'),
],
}
accounts_data = []
for email in emails:
account = Account.objects.filter(users__email=email).first()
if not account:
self.stdout.write(self.style.WARNING(f'Account not found for {email}'))
accounts_data.append({
'email': email,
'account': None,
'plan': None,
'limits': {}
})
continue
plan = account.plan if account else None
limits = {}
if plan:
for category, fields in limit_fields.items():
limits[category] = {}
for field_name, field_display in fields:
value = getattr(plan, field_name, None)
limits[category][field_name] = value
# Add effective credits
limits['AI Credits']['credits_per_month'] = plan.get_effective_credits_per_month()
accounts_data.append({
'email': email,
'account': account,
'plan': plan,
'limits': limits
})
# Print table header
header = f"{'Limit Category':<20} {'Limit Name':<45} "
for acc_data in accounts_data:
email_short = acc_data['email'].split('@')[0].upper()
header += f"{email_short:<20} "
print(header)
print("-" * 120)
# Print table rows
for category, fields in limit_fields.items():
print(f"\n{self.style.BOLD(category.upper())}")
for field_name, field_display in fields:
row = f" {field_display:<43} "
for acc_data in accounts_data:
if acc_data['plan']:
value = acc_data['limits'].get(category, {}).get(field_name, 'N/A')
row += f"{str(value):<20} "
else:
row += f"{'NO PLAN':<20} "
print(row)
# Print effective credits
print(f"\n {'Credits Per Month (Effective)':<43} ", end="")
for acc_data in accounts_data:
if acc_data['plan']:
value = acc_data['limits'].get('AI Credits', {}).get('credits_per_month', 'N/A')
print(f"{str(value):<20} ", end="")
else:
print(f"{'NO PLAN':<20} ", end="")
print()
# Print account details
print("\n" + "="*120)
print("ACCOUNT DETAILS")
print("="*120 + "\n")
for acc_data in accounts_data:
print(f"Email: {acc_data['email']}")
if acc_data['account']:
print(f" Account Name: {acc_data['account'].name}")
print(f" Account Slug: {acc_data['account'].slug}")
if acc_data['plan']:
print(f" Plan Name: {acc_data['plan'].name}")
print(f" Plan Slug: {acc_data['plan'].slug}")
print(f" Plan Price: ${acc_data['plan'].price}")
print(f" Plan ID: {acc_data['plan'].id}")
else:
print(f" Plan: {self.style.ERROR('NO PLAN ASSIGNED')}")
else:
print(f" Account: {self.style.ERROR('NOT FOUND')}")
print()
# Summary
print("="*120)
print("SUMMARY")
print("="*120)
print(f"Total accounts checked: {len(emails)}")
accounts_with_plans = sum(1 for acc in accounts_data if acc['plan'])
print(f"Accounts with plans: {accounts_with_plans}")
print(f"Accounts without plans: {len(emails) - accounts_with_plans}")
print()

View File

@@ -0,0 +1,59 @@
# Generated by Django 5.2.8 on 2025-11-07 10:37
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'),
]
operations = [
migrations.CreateModel(
name='CreditTransaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('updated_at', models.DateTimeField(auto_now=True)),
('transaction_type', models.CharField(choices=[('purchase', 'Purchase'), ('subscription', 'Subscription Renewal'), ('refund', 'Refund'), ('deduction', 'Usage Deduction'), ('adjustment', 'Manual Adjustment')], db_index=True, max_length=20)),
('amount', models.IntegerField(help_text='Positive for additions, negative for deductions')),
('balance_after', models.IntegerField(help_text='Credit balance after this transaction')),
('description', models.CharField(max_length=255)),
('metadata', models.JSONField(default=dict, help_text='Additional context (AI call details, etc.)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'db_table': 'igny8_credit_transactions',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['tenant', 'transaction_type'], name='igny8_credi_tenant__c4281c_idx'), models.Index(fields=['tenant', 'created_at'], name='igny8_credi_tenant__69abd3_idx')],
},
),
migrations.CreateModel(
name='CreditUsageLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('updated_at', models.DateTimeField(auto_now=True)),
('operation_type', models.CharField(choices=[('clustering', 'Keyword Clustering'), ('ideas', 'Content Ideas Generation'), ('content', 'Content Generation'), ('images', 'Image Generation'), ('reparse', 'Content Reparse')], db_index=True, max_length=50)),
('credits_used', models.IntegerField(validators=[django.core.validators.MinValueValidator(0)])),
('cost_usd', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)),
('model_used', models.CharField(blank=True, max_length=100)),
('tokens_input', models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)])),
('tokens_output', models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)])),
('related_object_type', models.CharField(blank=True, max_length=50)),
('related_object_id', models.IntegerField(blank=True, null=True)),
('metadata', models.JSONField(default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'db_table': 'igny8_credit_usage_logs',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['tenant', 'operation_type'], name='igny8_credi_tenant__3545ed_idx'), models.Index(fields=['tenant', 'created_at'], name='igny8_credi_tenant__4aee99_idx'), models.Index(fields=['tenant', 'operation_type', 'created_at'], name='igny8_credi_tenant__a00d57_idx')],
},
),
]

View File

@@ -0,0 +1,72 @@
"""
Billing Models for Credit System
"""
from django.db import models
from django.core.validators import MinValueValidator
from igny8_core.auth.models import AccountBaseModel
class CreditTransaction(AccountBaseModel):
"""Track all credit transactions (additions, deductions)"""
TRANSACTION_TYPE_CHOICES = [
('purchase', 'Purchase'),
('subscription', 'Subscription Renewal'),
('refund', 'Refund'),
('deduction', 'Usage Deduction'),
('adjustment', 'Manual Adjustment'),
]
transaction_type = models.CharField(max_length=20, choices=TRANSACTION_TYPE_CHOICES, db_index=True)
amount = models.IntegerField(help_text="Positive for additions, negative for deductions")
balance_after = models.IntegerField(help_text="Credit balance after this transaction")
description = models.CharField(max_length=255)
metadata = models.JSONField(default=dict, help_text="Additional context (AI call details, etc.)")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'igny8_credit_transactions'
ordering = ['-created_at']
indexes = [
models.Index(fields=['account', 'transaction_type']),
models.Index(fields=['account', 'created_at']),
]
def __str__(self):
account = getattr(self, 'account', None)
return f"{self.get_transaction_type_display()} - {self.amount} credits - {account.name if account else 'No Account'}"
class CreditUsageLog(AccountBaseModel):
"""Detailed log of credit usage per AI operation"""
OPERATION_TYPE_CHOICES = [
('clustering', 'Keyword Clustering'),
('ideas', 'Content Ideas Generation'),
('content', 'Content Generation'),
('images', 'Image Generation'),
('reparse', 'Content Reparse'),
]
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES, db_index=True)
credits_used = models.IntegerField(validators=[MinValueValidator(0)])
cost_usd = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
model_used = models.CharField(max_length=100, blank=True)
tokens_input = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
tokens_output = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
related_object_type = models.CharField(max_length=50, blank=True) # 'keyword', 'cluster', 'task'
related_object_id = models.IntegerField(null=True, blank=True)
metadata = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'igny8_credit_usage_logs'
ordering = ['-created_at']
indexes = [
models.Index(fields=['account', 'operation_type']),
models.Index(fields=['account', 'created_at']),
models.Index(fields=['account', 'operation_type', 'created_at']),
]
def __str__(self):
account = getattr(self, 'account', None)
return f"{self.get_operation_type_display()} - {self.credits_used} credits - {account.name if account else 'No Account'}"

View File

@@ -0,0 +1,65 @@
"""
Serializers for Billing Models
"""
from rest_framework import serializers
from .models import CreditTransaction, CreditUsageLog
from igny8_core.auth.models import Account
class CreditTransactionSerializer(serializers.ModelSerializer):
transaction_type_display = serializers.CharField(source='get_transaction_type_display', read_only=True)
class Meta:
model = CreditTransaction
fields = [
'id', 'transaction_type', 'transaction_type_display', 'amount',
'balance_after', 'description', 'metadata', 'created_at'
]
read_only_fields = ['created_at', 'account']
class CreditUsageLogSerializer(serializers.ModelSerializer):
operation_type_display = serializers.CharField(source='get_operation_type_display', read_only=True)
class Meta:
model = CreditUsageLog
fields = [
'id', 'operation_type', 'operation_type_display', 'credits_used',
'cost_usd', 'model_used', 'tokens_input', 'tokens_output',
'related_object_type', 'related_object_id', 'metadata', 'created_at'
]
read_only_fields = ['created_at', 'account']
class CreditBalanceSerializer(serializers.Serializer):
"""Serializer for credit balance response"""
credits = serializers.IntegerField()
plan_credits_per_month = serializers.IntegerField()
credits_used_this_month = serializers.IntegerField()
credits_remaining = serializers.IntegerField()
class UsageSummarySerializer(serializers.Serializer):
"""Serializer for usage summary response"""
period = serializers.DictField()
total_credits_used = serializers.IntegerField()
total_cost_usd = serializers.DecimalField(max_digits=10, decimal_places=2)
by_operation = serializers.DictField()
by_model = serializers.DictField()
class LimitCardSerializer(serializers.Serializer):
"""Serializer for individual limit card"""
title = serializers.CharField()
limit = serializers.IntegerField()
used = serializers.IntegerField()
available = serializers.IntegerField()
unit = serializers.CharField()
category = serializers.CharField()
percentage = serializers.FloatField()
class UsageLimitsSerializer(serializers.Serializer):
"""Serializer for usage limits response"""
limits = LimitCardSerializer(many=True)

View File

@@ -0,0 +1,161 @@
"""
Credit Service for managing credit transactions and deductions
"""
from django.db import transaction
from django.utils import timezone
from .models import CreditTransaction, CreditUsageLog
from .constants import CREDIT_COSTS
from .exceptions import InsufficientCreditsError, CreditCalculationError
from igny8_core.auth.models import Account
class CreditService:
"""Service for managing credits"""
@staticmethod
def check_credits(account, required_credits):
"""
Check if account has enough credits.
Args:
account: Account instance
required_credits: Number of credits required
Raises:
InsufficientCreditsError: If account doesn't have enough credits
"""
if account.credits < required_credits:
raise InsufficientCreditsError(
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
)
@staticmethod
@transaction.atomic
def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
"""
Deduct credits and log transaction.
Args:
account: Account instance
amount: Number of credits to deduct
operation_type: Type of operation (from CreditUsageLog.OPERATION_TYPE_CHOICES)
description: Description of the transaction
metadata: Optional metadata dict
cost_usd: Optional cost in USD
model_used: Optional AI model used
tokens_input: Optional input tokens
tokens_output: Optional output tokens
related_object_type: Optional related object type
related_object_id: Optional related object ID
Returns:
int: New credit balance
"""
# Check sufficient credits
CreditService.check_credits(account, amount)
# Deduct from account.credits
account.credits -= amount
account.save(update_fields=['credits'])
# Create CreditTransaction
CreditTransaction.objects.create(
account=account,
transaction_type='deduction',
amount=-amount, # Negative for deduction
balance_after=account.credits,
description=description,
metadata=metadata or {}
)
# Create CreditUsageLog
CreditUsageLog.objects.create(
account=account,
operation_type=operation_type,
credits_used=amount,
cost_usd=cost_usd,
model_used=model_used or '',
tokens_input=tokens_input,
tokens_output=tokens_output,
related_object_type=related_object_type or '',
related_object_id=related_object_id,
metadata=metadata or {}
)
return account.credits
@staticmethod
@transaction.atomic
def add_credits(account, amount, transaction_type, description, metadata=None):
"""
Add credits (purchase, subscription, etc.).
Args:
account: Account instance
amount: Number of credits to add
transaction_type: Type of transaction (from CreditTransaction.TRANSACTION_TYPE_CHOICES)
description: Description of the transaction
metadata: Optional metadata dict
Returns:
int: New credit balance
"""
# Add to account.credits
account.credits += amount
account.save(update_fields=['credits'])
# Create CreditTransaction
CreditTransaction.objects.create(
account=account,
transaction_type=transaction_type,
amount=amount, # Positive for addition
balance_after=account.credits,
description=description,
metadata=metadata or {}
)
return account.credits
@staticmethod
def calculate_credits_for_operation(operation_type, **kwargs):
"""
Calculate credits needed for an operation.
Args:
operation_type: Type of operation
**kwargs: Operation-specific parameters
Returns:
int: Number of credits required
Raises:
CreditCalculationError: If calculation fails
"""
if operation_type not in CREDIT_COSTS:
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
cost_config = CREDIT_COSTS[operation_type]
if operation_type == 'clustering':
# 1 credit per 30 keywords
keyword_count = kwargs.get('keyword_count', 0)
credits = max(1, int(keyword_count * cost_config['per_keyword']))
return credits
elif operation_type == 'ideas':
# 1 credit per idea
idea_count = kwargs.get('idea_count', 1)
return cost_config['base'] * idea_count
elif operation_type == 'content':
# 3 credits per content piece
content_count = kwargs.get('content_count', 1)
return cost_config['base'] * content_count
elif operation_type == 'images':
# 1 credit per image
image_count = kwargs.get('image_count', 1)
return cost_config['base'] * image_count
elif operation_type == 'reparse':
# 1 credit per reparse
return cost_config['base']
return cost_config['base']

View File

@@ -0,0 +1,16 @@
"""
URL patterns for billing module
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import CreditBalanceViewSet, CreditUsageViewSet, CreditTransactionViewSet
router = DefaultRouter()
router.register(r'credits/balance', CreditBalanceViewSet, basename='credit-balance')
router.register(r'credits/usage', CreditUsageViewSet, basename='credit-usage')
router.register(r'credits/transactions', CreditTransactionViewSet, basename='credit-transactions')
urlpatterns = [
path('', include(router.urls)),
]

View File

@@ -0,0 +1,465 @@
"""
ViewSets for Billing API
"""
from rest_framework import viewsets, status, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db.models import Sum, Count, Q
from django.utils import timezone
from datetime import timedelta
from decimal import Decimal
from igny8_core.api.base import AccountModelViewSet
from igny8_core.api.pagination import CustomPageNumberPagination
from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication
from .models import CreditTransaction, CreditUsageLog
from .serializers import (
CreditTransactionSerializer, CreditUsageLogSerializer,
CreditBalanceSerializer, UsageSummarySerializer, UsageLimitsSerializer
)
from .services import CreditService
from .exceptions import InsufficientCreditsError
class CreditBalanceViewSet(viewsets.ViewSet):
"""
ViewSet for credit balance operations
"""
permission_classes = [permissions.IsAuthenticated]
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
@action(detail=False, methods=['get'])
def balance(self, request):
"""Get current credit balance and usage"""
account = getattr(request, 'account', None)
if not account:
user = getattr(request, 'user', None)
if user:
account = getattr(user, 'account', None)
if not account:
return Response(
{'error': 'Account not found'},
status=status.HTTP_400_BAD_REQUEST
)
# Get plan credits per month
plan_credits_per_month = account.plan.credits_per_month if account.plan else 0
# Calculate credits used this month
now = timezone.now()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
credits_used_this_month = CreditUsageLog.objects.filter(
account=account,
created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0
credits_remaining = account.credits
data = {
'credits': account.credits,
'plan_credits_per_month': plan_credits_per_month,
'credits_used_this_month': credits_used_this_month,
'credits_remaining': credits_remaining,
}
serializer = CreditBalanceSerializer(data)
return Response(serializer.data)
class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for credit usage logs
"""
queryset = CreditUsageLog.objects.all()
serializer_class = CreditUsageLogSerializer
permission_classes = [permissions.IsAuthenticated]
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
pagination_class = CustomPageNumberPagination
filter_backends = []
def get_queryset(self):
"""Get usage logs for current account"""
account = getattr(self.request, 'account', None)
if not account:
user = getattr(self.request, 'user', None)
if user:
account = getattr(user, 'account', None)
if not account:
return CreditUsageLog.objects.none()
queryset = CreditUsageLog.objects.filter(account=account)
# Filter by operation type
operation_type = self.request.query_params.get('operation_type')
if operation_type:
queryset = queryset.filter(operation_type=operation_type)
# Filter by date range
start_date = self.request.query_params.get('start_date')
end_date = self.request.query_params.get('end_date')
if start_date:
queryset = queryset.filter(created_at__gte=start_date)
if end_date:
queryset = queryset.filter(created_at__lte=end_date)
return queryset.order_by('-created_at')
@action(detail=False, methods=['get'])
def summary(self, request):
"""Get usage summary for date range"""
account = getattr(request, 'account', None)
if not account:
user = getattr(request, 'user', None)
if user:
account = getattr(user, 'account', None)
if not account:
return Response(
{'error': 'Account not found'},
status=status.HTTP_400_BAD_REQUEST
)
# Get date range from query params
start_date = request.query_params.get('start_date')
end_date = request.query_params.get('end_date')
# Default to current month if not provided
now = timezone.now()
if not start_date:
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
else:
from django.utils.dateparse import parse_datetime
start_date = parse_datetime(start_date) or start_date
if not end_date:
end_date = now
else:
from django.utils.dateparse import parse_datetime
end_date = parse_datetime(end_date) or end_date
# Get usage logs in date range
usage_logs = CreditUsageLog.objects.filter(
account=account,
created_at__gte=start_date,
created_at__lte=end_date
)
# Calculate totals
total_credits_used = usage_logs.aggregate(total=Sum('credits_used'))['total'] or 0
total_cost_usd = usage_logs.aggregate(total=Sum('cost_usd'))['total'] or Decimal('0.00')
# Group by operation type
by_operation = {}
for operation_type, _ in CreditUsageLog.OPERATION_TYPE_CHOICES:
operation_logs = usage_logs.filter(operation_type=operation_type)
credits = operation_logs.aggregate(total=Sum('credits_used'))['total'] or 0
cost = operation_logs.aggregate(total=Sum('cost_usd'))['total'] or Decimal('0.00')
count = operation_logs.count()
if credits > 0 or count > 0:
by_operation[operation_type] = {
'credits': credits,
'cost': float(cost),
'count': count
}
# Group by model
by_model = {}
model_stats = usage_logs.values('model_used').annotate(
credits=Sum('credits_used'),
cost=Sum('cost_usd'),
count=Count('id')
).filter(model_used__isnull=False).exclude(model_used='')
for stat in model_stats:
model = stat['model_used']
by_model[model] = {
'credits': stat['credits'] or 0,
'cost': float(stat['cost'] or Decimal('0.00'))
}
data = {
'period': {
'start': start_date.isoformat() if hasattr(start_date, 'isoformat') else str(start_date),
'end': end_date.isoformat() if hasattr(end_date, 'isoformat') else str(end_date),
},
'total_credits_used': total_credits_used,
'total_cost_usd': float(total_cost_usd),
'by_operation': by_operation,
'by_model': by_model,
}
serializer = UsageSummarySerializer(data)
return Response(serializer.data)
@action(detail=False, methods=['get'], url_path='limits', url_name='limits')
def limits(self, request):
"""Get plan limits and current usage statistics"""
# Try multiple ways to get account
account = getattr(request, 'account', None)
if not account:
user = getattr(request, 'user', None)
if user and user.is_authenticated:
# Try to get account from user - refresh from DB to ensure we have latest
try:
from igny8_core.auth.models import User as UserModel
# Refresh user from DB to get account relationship
user = UserModel.objects.select_related('account', 'account__plan').get(id=user.id)
account = user.account
# Also set it on request for future use
request.account = account
except (AttributeError, UserModel.DoesNotExist, Exception) as e:
account = None
# Debug logging
import logging
logger = logging.getLogger(__name__)
logger.info(f'Limits endpoint - User: {getattr(request, "user", None)}, Account: {account}, Account has plan: {account.plan if account else False}')
if not account:
logger.warning(f'No account found in limits endpoint')
# Return empty limits instead of error - frontend will show "no data" message
return Response({'limits': []})
plan = account.plan
if not plan:
# Return empty limits instead of error - allows frontend to show "no plan" message
return Response({'limits': []})
# Import models
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
from igny8_core.modules.writer.models import Tasks, Images
from igny8_core.auth.models import User, Site
# Get current month boundaries
now = timezone.now()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
# Calculate usage statistics
limits_data = []
# Planner Limits
keywords_count = Keywords.objects.filter(account=account).count()
clusters_count = Clusters.objects.filter(account=account).count()
content_ideas_count = ContentIdeas.objects.filter(account=account).count()
clusters_today = Clusters.objects.filter(account=account, created_at__gte=start_of_day).count()
limits_data.extend([
{
'title': 'Keywords',
'limit': plan.max_keywords or 0,
'used': keywords_count,
'available': max(0, (plan.max_keywords or 0) - keywords_count),
'unit': 'keywords',
'category': 'planner',
'percentage': (keywords_count / (plan.max_keywords or 1)) * 100 if plan.max_keywords else 0
},
{
'title': 'Clusters',
'limit': plan.max_clusters or 0,
'used': clusters_count,
'available': max(0, (plan.max_clusters or 0) - clusters_count),
'unit': 'clusters',
'category': 'planner',
'percentage': (clusters_count / (plan.max_clusters or 1)) * 100 if plan.max_clusters else 0
},
{
'title': 'Content Ideas',
'limit': plan.max_content_ideas or 0,
'used': content_ideas_count,
'available': max(0, (plan.max_content_ideas or 0) - content_ideas_count),
'unit': 'ideas',
'category': 'planner',
'percentage': (content_ideas_count / (plan.max_content_ideas or 1)) * 100 if plan.max_content_ideas else 0
},
{
'title': 'Daily Cluster Limit',
'limit': plan.daily_cluster_limit or 0,
'used': clusters_today,
'available': max(0, (plan.daily_cluster_limit or 0) - clusters_today),
'unit': 'per day',
'category': 'planner',
'percentage': (clusters_today / (plan.daily_cluster_limit or 1)) * 100 if plan.daily_cluster_limit else 0
},
])
# Writer Limits
tasks_today = Tasks.objects.filter(account=account, created_at__gte=start_of_day).count()
tasks_month = Tasks.objects.filter(account=account, created_at__gte=start_of_month)
word_count_month = tasks_month.aggregate(total=Sum('word_count'))['total'] or 0
limits_data.extend([
{
'title': 'Monthly Word Count',
'limit': plan.monthly_word_count_limit or 0,
'used': word_count_month,
'available': max(0, (plan.monthly_word_count_limit or 0) - word_count_month),
'unit': 'words',
'category': 'writer',
'percentage': (word_count_month / (plan.monthly_word_count_limit or 1)) * 100 if plan.monthly_word_count_limit else 0
},
{
'title': 'Daily Content Tasks',
'limit': plan.daily_content_tasks or 0,
'used': tasks_today,
'available': max(0, (plan.daily_content_tasks or 0) - tasks_today),
'unit': 'per day',
'category': 'writer',
'percentage': (tasks_today / (plan.daily_content_tasks or 1)) * 100 if plan.daily_content_tasks else 0
},
])
# Image Limits
images_month = Images.objects.filter(account=account, created_at__gte=start_of_month).count()
images_today = Images.objects.filter(account=account, created_at__gte=start_of_day).count()
limits_data.extend([
{
'title': 'Monthly Images',
'limit': plan.monthly_image_count or 0,
'used': images_month,
'available': max(0, (plan.monthly_image_count or 0) - images_month),
'unit': 'images',
'category': 'images',
'percentage': (images_month / (plan.monthly_image_count or 1)) * 100 if plan.monthly_image_count else 0
},
{
'title': 'Daily Image Generation',
'limit': plan.daily_image_generation_limit or 0,
'used': images_today,
'available': max(0, (plan.daily_image_generation_limit or 0) - images_today),
'unit': 'per day',
'category': 'images',
'percentage': (images_today / (plan.daily_image_generation_limit or 1)) * 100 if plan.daily_image_generation_limit else 0
},
])
# AI Credits
credits_used_month = CreditUsageLog.objects.filter(
account=account,
created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0
# Get credits by operation type
cluster_credits = CreditUsageLog.objects.filter(
account=account,
operation_type='clustering',
created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0
content_credits = CreditUsageLog.objects.filter(
account=account,
operation_type='content',
created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0
image_credits = CreditUsageLog.objects.filter(
account=account,
operation_type='image',
created_at__gte=start_of_month
).aggregate(total=Sum('credits_used'))['total'] or 0
plan_credits = plan.monthly_ai_credit_limit or plan.credits_per_month or 0
limits_data.extend([
{
'title': 'Monthly AI Credits',
'limit': plan_credits,
'used': credits_used_month,
'available': max(0, plan_credits - credits_used_month),
'unit': 'credits',
'category': 'ai',
'percentage': (credits_used_month / plan_credits * 100) if plan_credits else 0
},
{
'title': 'Content AI Credits',
'limit': plan.monthly_content_ai_credits or 0,
'used': content_credits,
'available': max(0, (plan.monthly_content_ai_credits or 0) - content_credits),
'unit': 'credits',
'category': 'ai',
'percentage': (content_credits / (plan.monthly_content_ai_credits or 1)) * 100 if plan.monthly_content_ai_credits else 0
},
{
'title': 'Image AI Credits',
'limit': plan.monthly_image_ai_credits or 0,
'used': image_credits,
'available': max(0, (plan.monthly_image_ai_credits or 0) - image_credits),
'unit': 'credits',
'category': 'ai',
'percentage': (image_credits / (plan.monthly_image_ai_credits or 1)) * 100 if plan.monthly_image_ai_credits else 0
},
{
'title': 'Cluster AI Credits',
'limit': plan.monthly_cluster_ai_credits or 0,
'used': cluster_credits,
'available': max(0, (plan.monthly_cluster_ai_credits or 0) - cluster_credits),
'unit': 'credits',
'category': 'ai',
'percentage': (cluster_credits / (plan.monthly_cluster_ai_credits or 1)) * 100 if plan.monthly_cluster_ai_credits else 0
},
])
# General Limits
users_count = User.objects.filter(account=account).count()
sites_count = Site.objects.filter(account=account).count()
limits_data.extend([
{
'title': 'Users',
'limit': plan.max_users or 0,
'used': users_count,
'available': max(0, (plan.max_users or 0) - users_count),
'unit': 'users',
'category': 'general',
'percentage': (users_count / (plan.max_users or 1)) * 100 if plan.max_users else 0
},
{
'title': 'Sites',
'limit': plan.max_sites or 0,
'used': sites_count,
'available': max(0, (plan.max_sites or 0) - sites_count),
'unit': 'sites',
'category': 'general',
'percentage': (sites_count / (plan.max_sites or 1)) * 100 if plan.max_sites else 0
},
])
# Return data directly - serializer validation not needed for read-only endpoint
return Response({'limits': limits_data})
class CreditTransactionViewSet(viewsets.ReadOnlyModelViewSet):
"""
ViewSet for credit transaction history
"""
queryset = CreditTransaction.objects.all()
serializer_class = CreditTransactionSerializer
permission_classes = [permissions.IsAuthenticated]
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
pagination_class = CustomPageNumberPagination
def get_queryset(self):
"""Get transactions for current account"""
account = getattr(self.request, 'account', None)
if not account:
user = getattr(self.request, 'user', None)
if user:
account = getattr(user, 'account', None)
if not account:
return CreditTransaction.objects.none()
queryset = CreditTransaction.objects.filter(account=account)
# Filter by transaction type
transaction_type = self.request.query_params.get('transaction_type')
if transaction_type:
queryset = queryset.filter(transaction_type=transaction_type)
return queryset.order_by('-created_at')

View File

@@ -0,0 +1,100 @@
from django.contrib import admin
from igny8_core.admin.base import SiteSectorAdminMixin
from .models import Keywords, Clusters, ContentIdeas
@admin.register(Clusters)
class ClustersAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
list_display = ['name', 'site', 'sector', 'keywords_count', 'volume', 'status', 'created_at']
list_filter = ['status', 'site', 'sector']
search_fields = ['name']
ordering = ['name']
def get_site_display(self, obj):
"""Safely get site name"""
try:
return obj.site.name if obj.site else '-'
except:
return '-'
get_site_display.short_description = 'Site'
def get_sector_display(self, obj):
"""Safely get sector name"""
try:
return obj.sector.name if obj.sector else '-'
except:
return '-'
@admin.register(Keywords)
class KeywordsAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
list_display = ['keyword', 'seed_keyword', 'site', 'sector', 'cluster', 'volume', 'difficulty', 'intent', 'status', 'created_at']
list_filter = ['status', 'seed_keyword__intent', 'site', 'sector', 'seed_keyword__industry', 'seed_keyword__sector']
search_fields = ['seed_keyword__keyword']
ordering = ['-created_at']
def get_site_display(self, obj):
"""Safely get site name"""
try:
return obj.site.name if obj.site else '-'
except:
return '-'
get_site_display.short_description = 'Site'
def get_sector_display(self, obj):
"""Safely get sector name"""
try:
return obj.sector.name if obj.sector else '-'
except:
return '-'
def get_cluster_display(self, obj):
"""Safely get cluster name"""
try:
return obj.cluster.name if obj.cluster else '-'
except:
return '-'
get_cluster_display.short_description = 'Cluster'
@admin.register(ContentIdeas)
class ContentIdeasAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
list_display = ['idea_title', 'site', 'sector', 'description_preview', 'content_structure', 'content_type', 'status', 'keyword_cluster', 'estimated_word_count', 'created_at']
list_filter = ['status', 'content_structure', 'content_type', 'site', 'sector']
search_fields = ['idea_title', 'target_keywords', 'description']
ordering = ['-created_at']
def description_preview(self, obj):
"""Show a truncated preview of the description"""
if not obj.description:
return '-'
# Truncate to 100 characters
preview = obj.description[:100]
if len(obj.description) > 100:
preview += '...'
return preview
description_preview.short_description = 'Description'
def get_site_display(self, obj):
"""Safely get site name"""
try:
return obj.site.name if obj.site else '-'
except:
return '-'
get_site_display.short_description = 'Site'
def get_sector_display(self, obj):
"""Safely get sector name"""
try:
return obj.sector.name if obj.sector else '-'
except:
return '-'
def get_keyword_cluster_display(self, obj):
"""Safely get cluster name"""
try:
return obj.keyword_cluster.name if obj.keyword_cluster else '-'
except:
return '-'
get_keyword_cluster_display.short_description = 'Cluster'

View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class PlannerConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.modules.planner'
verbose_name = 'Planner'

View File

@@ -0,0 +1,148 @@
from rest_framework import serializers
from .models import Clusters, Keywords
from django.db.models import Count, Sum, Avg
class ClusterSerializer(serializers.ModelSerializer):
"""Serializer for Clusters model with dynamically calculated keywords_count, volume, and difficulty"""
keywords_count = serializers.SerializerMethodField()
volume = serializers.SerializerMethodField()
difficulty = serializers.SerializerMethodField()
sector_name = serializers.SerializerMethodField()
site_id = serializers.IntegerField(write_only=True, required=False)
sector_id = serializers.IntegerField(write_only=True, required=False)
class Meta:
model = Clusters
fields = [
'id',
'name',
'description',
'keywords_count',
'volume',
'difficulty',
'mapped_pages',
'status',
'sector_name',
'site_id',
'sector_id',
'created_at',
]
read_only_fields = ['id', 'created_at']
def get_sector_name(self, obj):
"""Get sector name from Sector model"""
if obj.sector_id:
try:
from igny8_core.auth.models import Sector
sector = Sector.objects.get(id=obj.sector_id)
return sector.name
except Sector.DoesNotExist:
return None
return None
def get_keywords_count(self, obj):
"""Calculate actual count of keywords associated with this cluster"""
# Get cached data if available (set by viewset)
if hasattr(obj, '_keywords_count'):
return obj._keywords_count
# Fallback: calculate on the fly (single query per cluster)
return Keywords.objects.filter(cluster_id=obj.id).count()
def get_volume(self, obj):
"""Calculate total volume of keywords associated with this cluster"""
# Get cached data if available (set by viewset)
if hasattr(obj, '_volume'):
return obj._volume
# Fallback: calculate on the fly (single query per cluster)
# Since volume is a property, we need to use COALESCE to check override first
from django.db.models import Sum, Case, When, F, IntegerField
result = Keywords.objects.filter(cluster_id=obj.id).aggregate(
total=Sum(
Case(
When(volume_override__isnull=False, then=F('volume_override')),
default=F('seed_keyword__volume'),
output_field=IntegerField()
)
)
)
return result['total'] or 0
def get_difficulty(self, obj):
"""Calculate average difficulty of keywords associated with this cluster"""
# Get cached data if available (set by viewset)
if hasattr(obj, '_difficulty'):
return obj._difficulty
# Fallback: calculate on the fly (single query per cluster)
# Since difficulty is a property, we need to use COALESCE to check override first
from django.db.models import Avg, Case, When, F, IntegerField
result = Keywords.objects.filter(cluster_id=obj.id).aggregate(
avg_difficulty=Avg(
Case(
When(difficulty_override__isnull=False, then=F('difficulty_override')),
default=F('seed_keyword__difficulty'),
output_field=IntegerField()
)
)
)
return round(result['avg_difficulty'] or 0, 1) # Round to 1 decimal place
@classmethod
def prefetch_keyword_stats(cls, clusters):
"""
Optimize keyword count, volume, and difficulty calculation by fetching all stats in bulk
This prevents N+1 queries by doing a single aggregate query
"""
if not clusters:
return clusters
# Get all cluster IDs
cluster_ids = [cluster.id for cluster in clusters]
# Aggregate keyword counts, volumes, and difficulties for all clusters in one query
from django.db.models import Count, Sum, Avg, Case, When, F, IntegerField
keyword_stats = (
Keywords.objects
.filter(cluster_id__in=cluster_ids)
.values('cluster_id')
.annotate(
count=Count('id'),
total_volume=Sum(
Case(
When(volume_override__isnull=False, then=F('volume_override')),
default=F('seed_keyword__volume'),
output_field=IntegerField()
)
),
avg_difficulty=Avg(
Case(
When(difficulty_override__isnull=False, then=F('difficulty_override')),
default=F('seed_keyword__difficulty'),
output_field=IntegerField()
)
)
)
)
# Create a dictionary mapping cluster_id to stats
stats_dict = {
stat['cluster_id']: {
'count': stat['count'],
'volume': stat['total_volume'] or 0,
'difficulty': round(stat['avg_difficulty'] or 0, 1) # Round to 1 decimal place
}
for stat in keyword_stats
}
# Attach stats to each cluster object
for cluster in clusters:
cluster_stats = stats_dict.get(cluster.id, {'count': 0, 'volume': 0, 'difficulty': 0})
cluster._keywords_count = cluster_stats['count']
cluster._volume = cluster_stats['volume']
cluster._difficulty = cluster_stats['difficulty']
return clusters

View File

@@ -0,0 +1,142 @@
"""
Django management command to add keywords to sectors
Usage: python manage.py add_keywords_to_sectors --site "Test Site" --keywords-per-sector 5
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from igny8_core.auth.models import Site, Sector
from igny8_core.modules.planner.models import Keywords
class Command(BaseCommand):
help = 'Add keywords to each sector of a specified site'
def add_arguments(self, parser):
parser.add_argument(
'--site',
type=str,
default='Test Site',
help='Name of the site to add keywords to (default: "Test Site")'
)
parser.add_argument(
'--keywords-per-sector',
type=int,
default=5,
help='Number of keywords to add to each sector (default: 5)'
)
def handle(self, *args, **options):
site_name = options['site']
keywords_per_sector = options['keywords_per_sector']
try:
# Find the site
site = Site.objects.get(name=site_name)
self.stdout.write(self.style.SUCCESS(f'Found site: {site.name} (ID: {site.id})'))
# Find all sectors for this site
sectors = Sector.objects.filter(site=site, is_active=True)
if not sectors.exists():
self.stdout.write(self.style.WARNING(f'No active sectors found for site "{site_name}"'))
return
self.stdout.write(f'Found {sectors.count()} active sector(s) for site "{site_name}"')
# Technology-related keywords for technology sectors
tech_keywords = [
'artificial intelligence',
'machine learning',
'cloud computing',
'data analytics',
'cybersecurity',
'blockchain technology',
'web development',
'mobile app development',
'software engineering',
'digital transformation',
'IoT solutions',
'API development',
'microservices architecture',
'devops practices',
'automated testing',
'agile methodology',
'scrum framework',
'version control systems',
'container orchestration',
'serverless computing',
]
total_keywords_created = 0
with transaction.atomic():
for sector in sectors:
self.stdout.write(f'\nProcessing sector: {sector.name} (ID: {sector.id})')
# Get existing keywords for this sector to avoid duplicates
existing_keywords = set(
Keywords.objects.filter(sector=sector)
.values_list('keyword', flat=True)
)
# Select keywords that don't already exist
available_keywords = [kw for kw in tech_keywords if kw.lower() not in existing_keywords]
if len(available_keywords) < keywords_per_sector:
self.stdout.write(
self.style.WARNING(
f'Only {len(available_keywords)} unique keywords available. '
f'Creating {len(available_keywords)} keywords instead of {keywords_per_sector}.'
)
)
keywords_to_create = available_keywords[:keywords_per_sector]
if not keywords_to_create:
self.stdout.write(
self.style.WARNING(f'All keywords already exist for sector "{sector.name}". Skipping.')
)
continue
# Create keywords
created_count = 0
for keyword_text in keywords_to_create:
keyword, created = Keywords.objects.get_or_create(
site=site,
sector=sector,
keyword=keyword_text,
defaults={
'account': site.account,
'volume': 1000 + (created_count * 100), # Varying volumes
'difficulty': 30 + (created_count * 10), # Varying difficulty (0-100 scale)
'intent': 'informational' if created_count % 2 == 0 else 'commercial',
'status': 'active',
}
)
if created:
created_count += 1
total_keywords_created += 1
self.stdout.write(f' ✓ Created: "{keyword_text}"')
self.stdout.write(
self.style.SUCCESS(
f'Created {created_count} keyword(s) for sector "{sector.name}"'
)
)
self.stdout.write(
self.style.SUCCESS(
f'\n✅ Successfully created {total_keywords_created} keyword(s) across {sectors.count()} sector(s)'
)
)
except Site.DoesNotExist:
self.stdout.write(
self.style.ERROR(f'Site "{site_name}" not found. Available sites:')
)
for site in Site.objects.all():
self.stdout.write(f' - {site.name} (ID: {site.id})')
except Exception as e:
self.stdout.write(self.style.ERROR(f'Error: {str(e)}'))
raise

View File

@@ -0,0 +1,124 @@
"""
Management command to find and remove duplicate Keywords records.
Duplicates are defined as records with the same (seed_keyword, site, sector) combination.
The unique_together constraint should prevent new duplicates, but this cleans up any existing ones.
"""
from django.core.management.base import BaseCommand
from django.db.models import Count
from igny8_core.modules.planner.models import Keywords
class Command(BaseCommand):
help = 'Find and remove duplicate Keywords records (same seed_keyword, site, sector)'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be deleted without actually deleting',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN MODE - No records will be deleted'))
# Find duplicates by grouping on (seed_keyword, site, sector)
duplicates = (
Keywords.objects
.values('seed_keyword', 'site', 'sector')
.annotate(count=Count('id'))
.filter(count__gt=1)
.order_by('-count')
)
total_duplicate_groups = duplicates.count()
if total_duplicate_groups == 0:
self.stdout.write(self.style.SUCCESS('✓ No duplicate records found. Database is clean.'))
return
self.stdout.write(
self.style.WARNING(
f'Found {total_duplicate_groups} duplicate group(s) (same seed_keyword, site, sector)'
)
)
total_to_delete = 0
deleted_count = 0
for dup_group in duplicates:
seed_keyword_id = dup_group['seed_keyword']
site_id = dup_group['site']
sector_id = dup_group['sector']
count = dup_group['count']
# Get all records in this duplicate group, ordered by created_at (keep the oldest)
duplicate_records = Keywords.objects.filter(
seed_keyword_id=seed_keyword_id,
site_id=site_id,
sector_id=sector_id
).order_by('created_at')
# Keep the first (oldest) record, delete the rest
records_to_delete = duplicate_records[1:]
to_delete_count = records_to_delete.count()
total_to_delete += to_delete_count
# Get keyword text for display
keyword_text = duplicate_records.first().keyword if duplicate_records.exists() else 'Unknown'
self.stdout.write(
f' Group: seed_keyword={seed_keyword_id}, site={site_id}, sector={sector_id} '
f'({count} records, will keep 1, delete {to_delete_count})'
)
self.stdout.write(f' Keyword: "{keyword_text}"')
if not dry_run:
# Delete duplicates, keeping the oldest
deleted = records_to_delete.delete()[0]
deleted_count += deleted
self.stdout.write(
self.style.SUCCESS(f' ✓ Deleted {deleted} duplicate record(s)')
)
else:
for record in records_to_delete:
self.stdout.write(
f' Would delete: ID={record.id}, created={record.created_at}'
)
if dry_run:
self.stdout.write(
self.style.WARNING(
f'\nDRY RUN: Would delete {total_to_delete} duplicate record(s)'
)
)
self.stdout.write('Run without --dry-run to actually delete these records.')
else:
self.stdout.write(
self.style.SUCCESS(
f'\n✓ Successfully deleted {deleted_count} duplicate record(s)'
)
)
# Verify no duplicates remain
remaining_duplicates = (
Keywords.objects
.values('seed_keyword', 'site', 'sector')
.annotate(count=Count('id'))
.filter(count__gt=1)
.count()
)
if remaining_duplicates == 0:
self.stdout.write(self.style.SUCCESS('✓ Verified: No duplicates remain in database'))
else:
self.stdout.write(
self.style.WARNING(
f'⚠ Warning: {remaining_duplicates} duplicate group(s) still remain. '
'This may indicate a database constraint issue.'
)
)

View File

@@ -0,0 +1,273 @@
"""
Django management command to create Home & Garden industry with sectors and keywords
Usage: python manage.py create_home_garden_industry
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils.text import slugify
from igny8_core.auth.models import Industry, IndustrySector, Site, Sector, Account
from igny8_core.modules.planner.models import Keywords
class Command(BaseCommand):
help = 'Create Home & Garden industry with sectors and add keywords to each sector'
def handle(self, *args, **options):
with transaction.atomic():
# Step 1: Create or get Home & Garden industry
industry, created = Industry.objects.get_or_create(
slug='home-garden',
defaults={
'name': 'Home & Garden',
'description': 'Home improvement, gardening, landscaping, and interior design',
'is_active': True,
}
)
if created:
self.stdout.write(self.style.SUCCESS(f'✅ Created industry: {industry.name}'))
else:
self.stdout.write(f'Industry "{industry.name}" already exists, using existing one.')
# Step 2: Define Home & Garden sectors with their keywords (max 5 per site)
sectors_data = [
{
'name': 'Gardening',
'slug': 'gardening',
'description': 'Plants, flowers, vegetables, and garden maintenance',
'keywords': [
'organic gardening',
'vegetable gardening',
'flower garden design',
'garden tools',
'plant care tips',
'composting guide',
'garden pest control',
'herb garden ideas',
'garden irrigation systems',
'seasonal planting guide',
]
},
{
'name': 'Home Improvement',
'slug': 'home-improvement',
'description': 'DIY projects, renovations, and home repairs',
'keywords': [
'home renovation ideas',
'diy home projects',
'kitchen remodeling',
'bathroom renovation',
'flooring installation',
'painting tips',
'home repair guide',
'power tools review',
'home maintenance checklist',
'interior design trends',
]
},
{
'name': 'Landscaping',
'slug': 'landscaping',
'description': 'Outdoor design, lawn care, and hardscaping',
'keywords': [
'landscape design ideas',
'lawn care tips',
'outdoor patio design',
'deck building guide',
'garden pathways',
'outdoor lighting ideas',
'lawn mowing tips',
'tree planting guide',
'outdoor kitchen design',
'garden edging ideas',
]
},
{
'name': 'Interior Design',
'slug': 'interior-design',
'description': 'Home decoration, furniture, and interior styling',
'keywords': [
'interior design styles',
'home decor ideas',
'furniture arrangement',
'color scheme ideas',
'small space design',
'home staging tips',
'decoration trends',
'room makeover ideas',
'interior lighting design',
'home organization tips',
]
},
{
'name': 'Home Decor',
'slug': 'home-decor',
'description': 'Decorative items, accessories, and home styling',
'keywords': [
'home decor accessories',
'wall art ideas',
'curtain design tips',
'pillow arrangement',
'vase decoration ideas',
'home fragrance tips',
'decorative mirrors',
'rug selection guide',
'home accent pieces',
'seasonal home decor',
]
},
]
# Step 3: Create IndustrySector templates
industry_sectors = []
for sector_data in sectors_data:
# Create IndustrySector with suggested_keywords (required field in DB)
industry_sector, created = IndustrySector.objects.get_or_create(
industry=industry,
slug=sector_data['slug'],
defaults={
'name': sector_data['name'],
'description': sector_data['description'],
'suggested_keywords': sector_data['keywords'], # JSON array of keywords
'is_active': True,
}
)
industry_sectors.append((industry_sector, sector_data['keywords'], created))
if created:
self.stdout.write(f' ✓ Created sector template: {industry_sector.name}')
else:
self.stdout.write(f' Sector template "{industry_sector.name}" already exists')
self.stdout.write(self.style.SUCCESS(f'\n✅ Created {len([s for s, _, c in industry_sectors if c])} new sector templates'))
# Step 4: Find or create a site for Home & Garden industry
# Try to find an existing site, or create one
site = None
try:
# Try to find a site with this industry
site = Site.objects.filter(industry=industry, is_active=True).first()
if site:
self.stdout.write(f'Found existing site: {site.name} (ID: {site.id})')
except:
pass
if not site:
# Get the first account or create a test account
account = Account.objects.first()
if not account:
self.stdout.write(self.style.ERROR('No account found. Please create an account first.'))
return
# Create a new site
site, created = Site.objects.get_or_create(
account=account,
slug='home-garden-site',
defaults={
'name': 'Home & Garden Site',
'industry': industry,
'is_active': True,
'status': 'active',
}
)
if created:
self.stdout.write(self.style.SUCCESS(f'✅ Created site: {site.name} (ID: {site.id})'))
else:
self.stdout.write(f'Using existing site: {site.name}')
# Step 5: Create actual Sector instances from IndustrySector templates
created_sectors = []
for industry_sector, keywords_list, _ in industry_sectors:
# Check if sector already exists for this site
sector, sector_created = Sector.objects.get_or_create(
site=site,
slug=industry_sector.slug,
defaults={
'industry_sector': industry_sector,
'name': industry_sector.name,
'description': industry_sector.description,
'is_active': True,
'status': 'active',
'account': site.account,
}
)
created_sectors.append((sector, keywords_list, sector_created))
if sector_created:
self.stdout.write(f' ✓ Created sector: {sector.name} (ID: {sector.id})')
else:
self.stdout.write(f' Sector "{sector.name}" already exists for this site')
# Step 6: Add 10 keywords to each sector
total_keywords_created = 0
for sector, keywords_list, sector_created in created_sectors:
self.stdout.write(f'\nProcessing sector: {sector.name} (ID: {sector.id})')
# Get existing keywords to avoid duplicates
existing_keywords = set(
Keywords.objects.filter(sector=sector)
.values_list('keyword', flat=True)
)
keywords_to_create = []
for keyword_text in keywords_list:
if keyword_text.lower() not in existing_keywords:
keywords_to_create.append(keyword_text)
if len(keywords_to_create) < 10:
# If we have fewer than 10 unique keywords, add some generic ones
generic_keywords = [
f'{sector.name.lower()} tips',
f'{sector.name.lower()} guide',
f'{sector.name.lower()} ideas',
f'best {sector.name.lower()}',
f'{sector.name.lower()} products',
f'{sector.name.lower()} services',
f'{sector.name.lower()} solutions',
f'{sector.name.lower()} advice',
f'{sector.name.lower()} techniques',
f'{sector.name.lower()} trends',
]
for gen_kw in generic_keywords:
if len(keywords_to_create) >= 10:
break
if gen_kw.lower() not in existing_keywords and gen_kw.lower() not in [k.lower() for k in keywords_to_create]:
keywords_to_create.append(gen_kw)
# Create keywords (limit to 10)
created_count = 0
for keyword_text in keywords_to_create[:10]:
keyword, kw_created = Keywords.objects.get_or_create(
site=site,
sector=sector,
keyword=keyword_text.lower(),
defaults={
'account': site.account,
'volume': 500 + (created_count * 50), # Varying volumes
'difficulty': 20 + (created_count * 8), # Varying difficulty (0-100 scale)
'intent': 'informational' if created_count % 2 == 0 else 'commercial',
'status': 'active',
}
)
if kw_created:
created_count += 1
total_keywords_created += 1
self.stdout.write(f' ✓ Created keyword: "{keyword_text}"')
if created_count == 0:
self.stdout.write(self.style.WARNING(f' No new keywords created (all already exist)'))
else:
self.stdout.write(
self.style.SUCCESS(
f' Created {created_count} keyword(s) for sector "{sector.name}"'
)
)
self.stdout.write(
self.style.SUCCESS(
f'\n✅ Successfully created:\n'
f' - Industry: {industry.name}\n'
f' - Sector templates: {len(industry_sectors)}\n'
f' - Site sectors: {len(created_sectors)}\n'
f' - Total keywords: {total_keywords_created}\n'
)
)

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python
"""
Django management command to:
1. Migrate existing Keywords to SeedKeywords
2. Delete all planner and writer module data
3. Clean up relationships
Usage: python manage.py migrate_keywords_to_seed
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
from igny8_core.modules.writer.models import Tasks, Content, Images
from igny8_core.auth.models import SeedKeyword, Industry, IndustrySector, Site, Sector
class Command(BaseCommand):
help = 'Migrate Keywords to SeedKeywords and clean up planner/writer data'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be done without making changes',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN MODE - No changes will be made'))
with transaction.atomic():
# Step 1: Migrate Keywords to SeedKeywords
self.stdout.write('=' * 60)
self.stdout.write('Step 1: Migrating Keywords to SeedKeywords')
self.stdout.write('=' * 60)
# Get all keywords with valid industry and industry_sector relationships
keywords = Keywords.objects.select_related(
'site', 'sector', 'sector__industry_sector', 'site__industry'
).filter(
site__industry__isnull=False,
sector__industry_sector__isnull=False
).distinct()
total_keywords = keywords.count()
self.stdout.write(f'Found {total_keywords} keywords to migrate')
created_count = 0
skipped_count = 0
errors = []
# Group by unique (keyword, industry, sector) to avoid duplicates
seen_combinations = set()
for keyword in keywords:
try:
industry = keyword.site.industry
industry_sector = keyword.sector.industry_sector
if not industry or not industry_sector:
skipped_count += 1
continue
# Create unique key for deduplication
unique_key = (keyword.keyword.lower().strip(), industry.id, industry_sector.id)
if unique_key in seen_combinations:
skipped_count += 1
continue
seen_combinations.add(unique_key)
# Check if SeedKeyword already exists
existing = SeedKeyword.objects.filter(
keyword__iexact=keyword.keyword.strip(),
industry=industry,
sector=industry_sector
).first()
if existing:
skipped_count += 1
self.stdout.write(f' ⏭️ Skipped (exists): "{keyword.keyword}" for {industry.name} - {industry_sector.name}')
continue
if not dry_run:
# Create SeedKeyword
seed_keyword = SeedKeyword.objects.create(
keyword=keyword.keyword.strip(),
industry=industry,
sector=industry_sector,
volume=keyword.volume or 0,
difficulty=keyword.difficulty or 0,
intent=keyword.intent or 'informational',
is_active=True
)
created_count += 1
self.stdout.write(
self.style.SUCCESS(
f' ✅ Created: "{seed_keyword.keyword}" for {industry.name} - {industry_sector.name}'
)
)
else:
created_count += 1
self.stdout.write(
f' [DRY RUN] Would create: "{keyword.keyword}" for {industry.name} - {industry_sector.name}'
)
except Exception as e:
error_msg = f'Error migrating keyword "{keyword.keyword}" (ID: {keyword.id}): {str(e)}'
errors.append(error_msg)
self.stdout.write(self.style.ERROR(f'{error_msg}'))
self.stdout.write('')
self.stdout.write(f' Created: {created_count}')
self.stdout.write(f' Skipped: {skipped_count}')
if errors:
self.stdout.write(self.style.ERROR(f' Errors: {len(errors)}'))
if not dry_run:
# Step 2: Delete all planner and writer module data
self.stdout.write('')
self.stdout.write('=' * 60)
self.stdout.write('Step 2: Deleting planner and writer module data')
self.stdout.write('=' * 60)
# Delete in order to respect foreign key constraints
# 1. Delete M2M relationships first
self.stdout.write('Deleting M2M relationships...')
tasks_keywords_count = Tasks.objects.filter(keyword_objects__isnull=False).count()
content_ideas_keywords_count = ContentIdeas.objects.filter(keyword_objects__isnull=False).count()
# Clear M2M relationships
for task in Tasks.objects.all():
task.keyword_objects.clear()
for idea in ContentIdeas.objects.all():
idea.keyword_objects.clear()
self.stdout.write(f' ✅ Cleared {tasks_keywords_count} task-keyword relationships')
self.stdout.write(f' ✅ Cleared {content_ideas_keywords_count} content-idea-keyword relationships')
# 2. Delete writer module data
self.stdout.write('Deleting writer module data...')
images_count = Images.objects.count()
content_count = Content.objects.count()
tasks_count = Tasks.objects.count()
Images.objects.all().delete()
Content.objects.all().delete()
Tasks.objects.all().delete()
self.stdout.write(f' ✅ Deleted {images_count} images')
self.stdout.write(f' ✅ Deleted {content_count} content records')
self.stdout.write(f' ✅ Deleted {tasks_count} tasks')
# 3. Delete planner module data
self.stdout.write('Deleting planner module data...')
content_ideas_count = ContentIdeas.objects.count()
clusters_count = Clusters.objects.count()
keywords_count = Keywords.objects.count()
ContentIdeas.objects.all().delete()
Clusters.objects.all().delete()
Keywords.objects.all().delete()
self.stdout.write(f' ✅ Deleted {content_ideas_count} content ideas')
self.stdout.write(f' ✅ Deleted {clusters_count} clusters')
self.stdout.write(f' ✅ Deleted {keywords_count} keywords')
else:
# Dry run - just show counts
self.stdout.write('')
self.stdout.write('=' * 60)
self.stdout.write('Step 2: Would delete planner and writer module data')
self.stdout.write('=' * 60)
images_count = Images.objects.count()
content_count = Content.objects.count()
tasks_count = Tasks.objects.count()
content_ideas_count = ContentIdeas.objects.count()
clusters_count = Clusters.objects.count()
keywords_count = Keywords.objects.count()
self.stdout.write(f' [DRY RUN] Would delete {images_count} images')
self.stdout.write(f' [DRY RUN] Would delete {content_count} content records')
self.stdout.write(f' [DRY RUN] Would delete {tasks_count} tasks')
self.stdout.write(f' [DRY RUN] Would delete {content_ideas_count} content ideas')
self.stdout.write(f' [DRY RUN] Would delete {clusters_count} clusters')
self.stdout.write(f' [DRY RUN] Would delete {keywords_count} keywords')
# Summary
self.stdout.write('')
self.stdout.write('=' * 60)
self.stdout.write('Summary:')
self.stdout.write('=' * 60)
if not dry_run:
self.stdout.write(self.style.SUCCESS(f'✅ Migrated {created_count} keywords to SeedKeywords'))
self.stdout.write(self.style.SUCCESS('✅ Deleted all planner and writer module data'))
else:
self.stdout.write(f'[DRY RUN] Would migrate {created_count} keywords to SeedKeywords')
self.stdout.write('[DRY RUN] Would delete all planner and writer module data')
if errors:
self.stdout.write('')
self.stdout.write(self.style.ERROR('Errors encountered:'))
for error in errors[:10]: # Show first 10 errors
self.stdout.write(self.style.ERROR(f' - {error}'))
if len(errors) > 10:
self.stdout.write(self.style.ERROR(f' ... and {len(errors) - 10} more errors'))
if dry_run:
self.stdout.write('')
self.stdout.write(self.style.WARNING('This was a DRY RUN. Run without --dry-run to apply changes.'))

View File

@@ -0,0 +1,86 @@
# Generated by Django 5.2.7 on 2025-11-02 21:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('igny8_core_auth', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Clusters',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(db_index=True, max_length=255, unique=True)),
('description', models.TextField(blank=True, null=True)),
('keywords_count', models.IntegerField(default=0)),
('volume', models.IntegerField(default=0)),
('mapped_pages', models.IntegerField(default=0)),
('status', models.CharField(default='active', max_length=50)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.site')),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'db_table': 'igny8_clusters',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Keywords',
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)),
('difficulty', models.IntegerField(default=0)),
('intent', models.CharField(choices=[('informational', 'Informational'), ('navigational', 'Navigational'), ('commercial', 'Commercial'), ('transactional', 'Transactional')], default='informational', max_length=50)),
('status', models.CharField(choices=[('active', 'Active'), ('pending', 'Pending'), ('archived', 'Archived')], default='pending', max_length=50)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cluster', models.ForeignKey(blank=True, limit_choices_to={'sector': models.F('sector')}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='keywords', to='planner.clusters')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.site')),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'db_table': 'igny8_keywords',
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='clusters',
index=models.Index(fields=['name'], name='igny8_clust_name_0f98bb_idx'),
),
migrations.AddIndex(
model_name='clusters',
index=models.Index(fields=['status'], name='igny8_clust_status_c50486_idx'),
),
migrations.AddIndex(
model_name='clusters',
index=models.Index(fields=['site', 'sector'], name='igny8_clust_site_id_a9aee3_idx'),
),
migrations.AddIndex(
model_name='keywords',
index=models.Index(fields=['keyword'], name='igny8_keywo_keyword_462bff_idx'),
),
migrations.AddIndex(
model_name='keywords',
index=models.Index(fields=['status'], name='igny8_keywo_status_9a0dd6_idx'),
),
migrations.AddIndex(
model_name='keywords',
index=models.Index(fields=['cluster'], name='igny8_keywo_cluster_d1ea95_idx'),
),
migrations.AddIndex(
model_name='keywords',
index=models.Index(fields=['site', 'sector'], name='igny8_keywo_site_id_7bcb63_idx'),
),
]

View File

@@ -0,0 +1,15 @@
# Legacy migration kept for compatibility; database schema already includes
# the account/site/sector fields as part of the initial state.
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0001_initial'),
('planner', '0001_initial'),
]
operations = []

View File

@@ -0,0 +1,59 @@
# Generated by Django 5.2.7 on 2025-11-03 13:22
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0003_alter_user_role'),
('planner', '0002_add_site_sector_tenant'),
]
operations = [
migrations.AlterField(
model_name='clusters',
name='sector',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.sector'),
),
migrations.AlterField(
model_name='clusters',
name='site',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.site'),
),
migrations.AlterField(
model_name='keywords',
name='sector',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.sector'),
),
migrations.AlterField(
model_name='keywords',
name='site',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.site'),
),
migrations.CreateModel(
name='ContentIdeas',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('idea_title', models.CharField(db_index=True, max_length=255)),
('description', models.TextField(blank=True, null=True)),
('content_structure', models.CharField(choices=[('cluster_hub', 'Cluster Hub'), ('landing_page', 'Landing Page'), ('pillar_page', 'Pillar Page'), ('supporting_page', 'Supporting Page')], default='blog_post', max_length=50)),
('content_type', models.CharField(choices=[('blog_post', 'Blog Post'), ('article', 'Article'), ('guide', 'Guide'), ('tutorial', 'Tutorial')], default='blog_post', max_length=50)),
('target_keywords', models.CharField(blank=True, max_length=500)),
('status', models.CharField(choices=[('new', 'New'), ('scheduled', 'Scheduled'), ('published', 'Published')], default='new', max_length=50)),
('estimated_word_count', models.IntegerField(default=1000)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('keyword_cluster', models.ForeignKey(blank=True, limit_choices_to={'sector': models.F('sector')}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ideas', to='planner.clusters')),
('sector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.sector')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.site')),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'db_table': 'igny8_content_ideas',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['idea_title'], name='igny8_conte_idea_ti_1e15a5_idx'), models.Index(fields=['status'], name='igny8_conte_status_6be5bc_idx'), models.Index(fields=['keyword_cluster'], name='igny8_conte_keyword_4d2151_idx'), models.Index(fields=['content_structure'], name='igny8_conte_content_3eede7_idx'), models.Index(fields=['site', 'sector'], name='igny8_conte_site_id_03b520_idx')],
},
),
]

View File

@@ -0,0 +1,24 @@
# Generated manually to fix missing ManyToMany table
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('planner', '0003_alter_clusters_sector_alter_clusters_site_and_more'),
]
operations = [
migrations.AddField(
model_name='contentideas',
name='keyword_objects',
field=models.ManyToManyField(
blank=True,
help_text='Individual keywords linked to this content idea',
related_name='content_ideas',
to='planner.keywords'
),
),
]

View File

@@ -0,0 +1,86 @@
# Generated manually for adding seed_keyword relationship to Keywords
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0010_add_seed_keyword'),
('planner', '0003_alter_clusters_sector_alter_clusters_site_and_more'),
]
operations = [
# Remove old fields (keyword, volume, difficulty, intent)
migrations.RemoveField(
model_name='keywords',
name='keyword',
),
migrations.RemoveField(
model_name='keywords',
name='volume',
),
migrations.RemoveField(
model_name='keywords',
name='difficulty',
),
migrations.RemoveField(
model_name='keywords',
name='intent',
),
# Add seed_keyword FK
migrations.AddField(
model_name='keywords',
name='seed_keyword',
field=models.ForeignKey(
help_text='Reference to the global seed keyword',
on_delete=django.db.models.deletion.PROTECT,
related_name='site_keywords',
to='igny8_core_auth.seedkeyword',
null=True # Temporarily nullable for migration
),
),
# Add override fields
migrations.AddField(
model_name='keywords',
name='volume_override',
field=models.IntegerField(blank=True, help_text='Site-specific volume override (uses seed_keyword.volume if not set)', null=True),
),
migrations.AddField(
model_name='keywords',
name='difficulty_override',
field=models.IntegerField(blank=True, help_text='Site-specific difficulty override (uses seed_keyword.difficulty if not set)', null=True),
),
# Make seed_keyword required (after data migration if needed)
migrations.AlterField(
model_name='keywords',
name='seed_keyword',
field=models.ForeignKey(
help_text='Reference to the global seed keyword',
on_delete=django.db.models.deletion.PROTECT,
related_name='site_keywords',
to='igny8_core_auth.seedkeyword'
),
),
# Add unique constraint
migrations.AlterUniqueTogether(
name='keywords',
unique_together={('seed_keyword', 'site', 'sector')},
),
# Update indexes
migrations.AlterIndexTogether(
name='keywords',
index_together=set(),
),
# Add new indexes
migrations.AddIndex(
model_name='keywords',
index=models.Index(fields=['seed_keyword'], name='igny8_keyw_seed_k_12345_idx'),
),
migrations.AddIndex(
model_name='keywords',
index=models.Index(fields=['seed_keyword', 'site', 'sector'], name='igny8_keyw_seed_si_67890_idx'),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.2.8 on 2025-11-07 10:06
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('planner', '0004_add_keyword_objects_to_contentideas'),
]
operations = [
migrations.AlterModelOptions(
name='clusters',
options={'ordering': ['name'], 'verbose_name': 'Cluster', 'verbose_name_plural': 'Clusters'},
),
migrations.AlterModelOptions(
name='contentideas',
options={'ordering': ['-created_at'], 'verbose_name': 'Content Idea', 'verbose_name_plural': 'Content Ideas'},
),
migrations.AlterModelOptions(
name='keywords',
options={'ordering': ['-created_at'], 'verbose_name': 'Keyword', 'verbose_name_plural': 'Keywords'},
),
]

View File

@@ -0,0 +1,86 @@
# Generated manually for adding seed_keyword relationship to Keywords
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0010_add_seed_keyword'),
('planner', '0005_alter_clusters_options_alter_contentideas_options_and_more'),
]
operations = [
# Remove old fields (keyword, volume, difficulty, intent)
migrations.RemoveField(
model_name='keywords',
name='keyword',
),
migrations.RemoveField(
model_name='keywords',
name='volume',
),
migrations.RemoveField(
model_name='keywords',
name='difficulty',
),
migrations.RemoveField(
model_name='keywords',
name='intent',
),
# Add seed_keyword FK
migrations.AddField(
model_name='keywords',
name='seed_keyword',
field=models.ForeignKey(
help_text='Reference to the global seed keyword',
on_delete=django.db.models.deletion.PROTECT,
related_name='site_keywords',
to='igny8_core_auth.seedkeyword',
null=True # Temporarily nullable for migration
),
),
# Add override fields
migrations.AddField(
model_name='keywords',
name='volume_override',
field=models.IntegerField(blank=True, help_text='Site-specific volume override (uses seed_keyword.volume if not set)', null=True),
),
migrations.AddField(
model_name='keywords',
name='difficulty_override',
field=models.IntegerField(blank=True, help_text='Site-specific difficulty override (uses seed_keyword.difficulty if not set)', null=True),
),
# Make seed_keyword required (after data migration if needed)
migrations.AlterField(
model_name='keywords',
name='seed_keyword',
field=models.ForeignKey(
help_text='Reference to the global seed keyword',
on_delete=django.db.models.deletion.PROTECT,
related_name='site_keywords',
to='igny8_core_auth.seedkeyword'
),
),
# Add unique constraint
migrations.AlterUniqueTogether(
name='keywords',
unique_together={('seed_keyword', 'site', 'sector')},
),
# Update indexes
migrations.AlterIndexTogether(
name='keywords',
index_together=set(),
),
# Add new indexes
migrations.AddIndex(
model_name='keywords',
index=models.Index(fields=['seed_keyword'], name='igny8_keyw_seed_k_12345_idx'),
),
migrations.AddIndex(
model_name='keywords',
index=models.Index(fields=['seed_keyword', 'site', 'sector'], name='igny8_keyw_seed_si_67890_idx'),
),
]

View File

@@ -0,0 +1,194 @@
from django.db import models
from igny8_core.auth.models import SiteSectorBaseModel, SeedKeyword
class Clusters(SiteSectorBaseModel):
"""Clusters model for keyword grouping"""
name = models.CharField(max_length=255, unique=True, db_index=True)
description = models.TextField(blank=True, null=True)
keywords_count = models.IntegerField(default=0)
volume = models.IntegerField(default=0)
mapped_pages = models.IntegerField(default=0)
status = models.CharField(max_length=50, default='active')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'igny8_clusters'
ordering = ['name']
verbose_name = 'Cluster'
verbose_name_plural = 'Clusters'
indexes = [
models.Index(fields=['name']),
models.Index(fields=['status']),
models.Index(fields=['site', 'sector']),
]
def __str__(self):
return self.name
class Keywords(SiteSectorBaseModel):
"""
Keywords model for SEO keyword management.
Site-specific instances that reference global SeedKeywords.
"""
STATUS_CHOICES = [
('active', 'Active'),
('pending', 'Pending'),
('archived', 'Archived'),
]
# Required: Link to global SeedKeyword
seed_keyword = models.ForeignKey(
SeedKeyword,
on_delete=models.PROTECT, # Prevent deletion if Keywords reference it
related_name='site_keywords',
help_text="Reference to the global seed keyword"
)
# Site-specific overrides (optional)
volume_override = models.IntegerField(
null=True,
blank=True,
help_text="Site-specific volume override (uses seed_keyword.volume if not set)"
)
difficulty_override = models.IntegerField(
null=True,
blank=True,
help_text="Site-specific difficulty override (uses seed_keyword.difficulty if not set)"
)
cluster = models.ForeignKey(
Clusters,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='keywords',
limit_choices_to={'sector': models.F('sector')} # Cluster must be in same sector
)
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='pending')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'igny8_keywords'
ordering = ['-created_at']
verbose_name = 'Keyword'
verbose_name_plural = 'Keywords'
unique_together = [['seed_keyword', 'site', 'sector']] # One keyword per site/sector
indexes = [
models.Index(fields=['seed_keyword']),
models.Index(fields=['status']),
models.Index(fields=['cluster']),
models.Index(fields=['site', 'sector']),
models.Index(fields=['seed_keyword', 'site', 'sector']),
]
@property
def keyword(self):
"""Get keyword text from seed_keyword"""
return self.seed_keyword.keyword if self.seed_keyword else ''
@property
def volume(self):
"""Get volume from override or seed_keyword"""
return self.volume_override if self.volume_override is not None else (self.seed_keyword.volume if self.seed_keyword else 0)
@property
def difficulty(self):
"""Get difficulty from override or seed_keyword"""
return self.difficulty_override if self.difficulty_override is not None else (self.seed_keyword.difficulty if self.seed_keyword else 0)
@property
def intent(self):
"""Get intent from seed_keyword"""
return self.seed_keyword.intent if self.seed_keyword else 'informational'
def save(self, *args, **kwargs):
"""Validate that seed_keyword's industry/sector matches site's industry/sector"""
if self.seed_keyword and self.site and self.sector:
# Validate industry match
if self.site.industry != self.seed_keyword.industry:
from django.core.exceptions import ValidationError
raise ValidationError(
f"SeedKeyword industry ({self.seed_keyword.industry.name}) must match site industry ({self.site.industry.name})"
)
# Validate sector match (site sector's industry_sector must match seed_keyword's sector)
if self.sector.industry_sector != self.seed_keyword.sector:
from django.core.exceptions import ValidationError
raise ValidationError(
f"SeedKeyword sector ({self.seed_keyword.sector.name}) must match site sector's industry sector ({self.sector.industry_sector.name if self.sector.industry_sector else 'None'})"
)
super().save(*args, **kwargs)
def __str__(self):
return self.keyword
class ContentIdeas(SiteSectorBaseModel):
"""Content Ideas model for planning content based on keyword clusters"""
STATUS_CHOICES = [
('new', 'New'),
('scheduled', 'Scheduled'),
('published', 'Published'),
]
CONTENT_STRUCTURE_CHOICES = [
('cluster_hub', 'Cluster Hub'),
('landing_page', 'Landing Page'),
('pillar_page', 'Pillar Page'),
('supporting_page', 'Supporting Page'),
]
CONTENT_TYPE_CHOICES = [
('blog_post', 'Blog Post'),
('article', 'Article'),
('guide', 'Guide'),
('tutorial', 'Tutorial'),
]
idea_title = models.CharField(max_length=255, db_index=True)
description = models.TextField(blank=True, null=True)
content_structure = models.CharField(max_length=50, choices=CONTENT_STRUCTURE_CHOICES, default='blog_post')
content_type = models.CharField(max_length=50, choices=CONTENT_TYPE_CHOICES, default='blog_post')
target_keywords = models.CharField(max_length=500, blank=True) # Comma-separated keywords (legacy)
keyword_objects = models.ManyToManyField(
'Keywords',
blank=True,
related_name='content_ideas',
help_text="Individual keywords linked to this content idea"
)
keyword_cluster = models.ForeignKey(
Clusters,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='ideas',
limit_choices_to={'sector': models.F('sector')}
)
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='new')
estimated_word_count = models.IntegerField(default=1000)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'igny8_content_ideas'
ordering = ['-created_at']
verbose_name = 'Content Idea'
verbose_name_plural = 'Content Ideas'
indexes = [
models.Index(fields=['idea_title']),
models.Index(fields=['status']),
models.Index(fields=['keyword_cluster']),
models.Index(fields=['content_structure']),
models.Index(fields=['site', 'sector']),
]
def __str__(self):
return self.idea_title

View File

@@ -0,0 +1,188 @@
from rest_framework import serializers
from .models import Keywords, Clusters, ContentIdeas
from igny8_core.auth.models import SeedKeyword
from igny8_core.auth.serializers import SeedKeywordSerializer
class KeywordSerializer(serializers.ModelSerializer):
"""Serializer for Keywords model with SeedKeyword relationship"""
# Read-only properties from seed_keyword
keyword = serializers.CharField(read_only=True) # From seed_keyword.keyword
volume = serializers.IntegerField(read_only=True) # From seed_keyword.volume or volume_override
difficulty = serializers.IntegerField(read_only=True) # From seed_keyword.difficulty or difficulty_override
intent = serializers.CharField(read_only=True) # From seed_keyword.intent
# SeedKeyword relationship
seed_keyword_id = serializers.IntegerField(write_only=True, required=True)
seed_keyword = SeedKeywordSerializer(read_only=True)
# Overrides
volume_override = serializers.IntegerField(required=False, allow_null=True)
difficulty_override = serializers.IntegerField(required=False, allow_null=True)
# Related fields
cluster_name = serializers.SerializerMethodField()
sector_name = serializers.SerializerMethodField()
site_id = serializers.IntegerField(write_only=True, required=False)
sector_id = serializers.IntegerField(write_only=True, required=False)
class Meta:
model = Keywords
fields = [
'id',
'seed_keyword_id',
'seed_keyword',
'keyword',
'volume',
'difficulty',
'intent',
'volume_override',
'difficulty_override',
'cluster_id',
'cluster_name',
'sector_name',
'status',
'created_at',
'updated_at',
'site_id',
'sector_id',
'account_id',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id', 'keyword', 'volume', 'difficulty', 'intent']
def create(self, validated_data):
"""Create Keywords instance with seed_keyword"""
seed_keyword_id = validated_data.pop('seed_keyword_id')
try:
seed_keyword = SeedKeyword.objects.get(id=seed_keyword_id)
except SeedKeyword.DoesNotExist:
raise serializers.ValidationError({'seed_keyword_id': f'SeedKeyword with id {seed_keyword_id} does not exist'})
validated_data['seed_keyword'] = seed_keyword
return super().create(validated_data)
def update(self, instance, validated_data):
"""Update Keywords instance with seed_keyword"""
if 'seed_keyword_id' in validated_data:
seed_keyword_id = validated_data.pop('seed_keyword_id')
try:
seed_keyword = SeedKeyword.objects.get(id=seed_keyword_id)
validated_data['seed_keyword'] = seed_keyword
except SeedKeyword.DoesNotExist:
raise serializers.ValidationError({'seed_keyword_id': f'SeedKeyword with id {seed_keyword_id} does not exist'})
return super().update(instance, validated_data)
def get_cluster_name(self, obj):
"""Get cluster name from Clusters model"""
if obj.cluster_id:
try:
cluster = Clusters.objects.get(id=obj.cluster_id)
return cluster.name
except Clusters.DoesNotExist:
return None
return None
def get_sector_name(self, obj):
"""Get sector name from Sector model"""
if obj.sector_id:
try:
from igny8_core.auth.models import Sector
sector = Sector.objects.get(id=obj.sector_id)
return sector.name
except Sector.DoesNotExist:
return None
return None
class ClusterSerializer(serializers.ModelSerializer):
"""Serializer for Clusters model"""
sector_name = serializers.SerializerMethodField()
site_id = serializers.IntegerField(write_only=True, required=False)
sector_id = serializers.IntegerField(write_only=True, required=False)
class Meta:
model = Clusters
fields = [
'id',
'name',
'description',
'keywords_count',
'volume',
'mapped_pages',
'status',
'sector_name',
'created_at',
'updated_at',
'site_id',
'sector_id',
'account_id',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id', 'keywords_count', 'volume', 'mapped_pages']
def get_sector_name(self, obj):
"""Get sector name from Sector model"""
if obj.sector_id:
try:
from igny8_core.auth.models import Sector
sector = Sector.objects.get(id=obj.sector_id)
return sector.name
except Sector.DoesNotExist:
return None
return None
def validate_name(self, value):
"""Ensure cluster name is unique within account"""
# Uniqueness is handled at model level, but we can add additional validation here
return value
class ContentIdeasSerializer(serializers.ModelSerializer):
"""Serializer for ContentIdeas model"""
keyword_cluster_name = serializers.SerializerMethodField()
sector_name = serializers.SerializerMethodField()
site_id = serializers.IntegerField(write_only=True, required=False)
sector_id = serializers.IntegerField(write_only=True, required=False)
class Meta:
model = ContentIdeas
fields = [
'id',
'idea_title',
'description',
'content_structure',
'content_type',
'target_keywords',
'keyword_cluster_id',
'keyword_cluster_name',
'sector_name',
'status',
'estimated_word_count',
'created_at',
'updated_at',
'site_id',
'sector_id',
'account_id',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id']
def get_keyword_cluster_name(self, obj):
"""Get cluster name from Clusters model"""
if obj.keyword_cluster_id:
try:
cluster = Clusters.objects.get(id=obj.keyword_cluster_id)
return cluster.name
except Clusters.DoesNotExist:
return None
return None
def get_sector_name(self, obj):
"""Get sector name from Sector model"""
if obj.sector_id:
try:
from igny8_core.auth.models import Sector
sector = Sector.objects.get(id=obj.sector_id)
return sector.name
except Sector.DoesNotExist:
return None
return None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import KeywordViewSet, ClusterViewSet, ContentIdeasViewSet
router = DefaultRouter()
router.register(r'keywords', KeywordViewSet, basename='keyword')
router.register(r'clusters', ClusterViewSet, basename='cluster')
router.register(r'ideas', ContentIdeasViewSet, basename='idea')
urlpatterns = [
path('', include(router.urls)),
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
"""
IGNY8 System Module
"""

View File

@@ -0,0 +1,164 @@
"""
System Module Admin
"""
from django.contrib import admin
from igny8_core.admin.base import AccountAdminMixin
from .models import AIPrompt, IntegrationSettings, AuthorProfile, Strategy
# Import settings admin
from .settings_admin import (
SystemSettingsAdmin, AccountSettingsAdmin, UserSettingsAdmin,
ModuleSettingsAdmin, AISettingsAdmin
)
try:
from .models import SystemLog, SystemStatus
@admin.register(SystemLog)
class SystemLogAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ['id', 'account', 'module', 'level', 'action', 'message', 'created_at']
list_filter = ['module', 'level', 'created_at', 'account']
search_fields = ['message', 'action']
readonly_fields = ['created_at', 'updated_at']
date_hierarchy = 'created_at'
@admin.register(SystemStatus)
class SystemStatusAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ['component', 'account', 'status', 'message', 'last_check']
list_filter = ['status', 'component', 'account']
search_fields = ['component', 'message']
readonly_fields = ['last_check']
except ImportError:
pass
@admin.register(AIPrompt)
class AIPromptAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ['id', 'prompt_type', 'account', 'is_active', 'updated_at']
list_filter = ['prompt_type', 'is_active', 'account']
search_fields = ['prompt_type']
readonly_fields = ['created_at', 'updated_at', 'default_prompt']
fieldsets = (
('Basic Info', {
'fields': ('account', 'prompt_type', 'is_active')
}),
('Prompt Content', {
'fields': ('prompt_value', 'default_prompt')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at')
}),
)
def get_account_display(self, obj):
"""Safely get account name"""
try:
account = getattr(obj, 'account', None)
return account.name if account else '-'
except:
return '-'
get_account_display.short_description = 'Account'
@admin.register(IntegrationSettings)
class IntegrationSettingsAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ['id', 'integration_type', 'account', 'is_active', 'updated_at']
list_filter = ['integration_type', 'is_active', 'account']
search_fields = ['integration_type']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Basic Info', {
'fields': ('account', 'integration_type', 'is_active')
}),
('Configuration', {
'fields': ('config',),
'description': 'JSON configuration containing API keys and settings. Example: {"apiKey": "sk-...", "model": "gpt-4.1", "enabled": true}'
}),
('Timestamps', {
'fields': ('created_at', 'updated_at')
}),
)
def get_readonly_fields(self, request, obj=None):
"""Make config readonly when viewing to prevent accidental exposure"""
if obj: # Editing existing object
return self.readonly_fields + ['config']
return self.readonly_fields
def get_account_display(self, obj):
"""Safely get account name"""
try:
account = getattr(obj, 'account', None)
return account.name if account else '-'
except:
return '-'
get_account_display.short_description = 'Account'
@admin.register(AuthorProfile)
class AuthorProfileAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ['name', 'account', 'tone', 'language', 'is_active', 'created_at']
list_filter = ['is_active', 'tone', 'language', 'account']
search_fields = ['name', 'description', 'tone']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Basic Info', {
'fields': ('account', 'name', 'description', 'is_active')
}),
('Writing Style', {
'fields': ('tone', 'language', 'structure_template')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at')
}),
)
def get_account_display(self, obj):
"""Safely get account name"""
try:
account = getattr(obj, 'account', None)
return account.name if account else '-'
except:
return '-'
get_account_display.short_description = 'Account'
@admin.register(Strategy)
class StrategyAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ['name', 'account', 'sector', 'is_active', 'created_at']
list_filter = ['is_active', 'account']
search_fields = ['name', 'description']
readonly_fields = ['created_at', 'updated_at']
fieldsets = (
('Basic Info', {
'fields': ('account', 'name', 'description', 'sector', 'is_active')
}),
('Strategy Configuration', {
'fields': ('prompt_types', 'section_logic')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at')
}),
)
def get_account_display(self, obj):
"""Safely get account name"""
try:
account = getattr(obj, 'account', None)
return account.name if account else '-'
except:
return '-'
get_account_display.short_description = 'Account'
def get_sector_display(self, obj):
"""Safely get sector name"""
try:
return obj.sector.name if obj.sector else 'Global'
except:
return 'Global'
get_sector_display.short_description = 'Sector'

View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class SystemConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'igny8_core.modules.system'
verbose_name = 'System Configuration'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
# Generated by Django 5.2.7 on 2025-11-02 21:42
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('igny8_core_auth', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SystemLog',
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)),
('module', models.CharField(choices=[('planner', 'Planner'), ('writer', 'Writer'), ('thinker', 'Thinker'), ('ai', 'AI Pipeline'), ('wp_bridge', 'WordPress Bridge'), ('system', 'System')], db_index=True, max_length=50)),
('level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('success', 'Success')], db_index=True, default='info', max_length=20)),
('action', models.CharField(max_length=255)),
('message', models.TextField()),
('metadata', models.JSONField(blank=True, default=dict)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'igny8_system_logs',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['tenant', 'module', 'level', '-created_at'], name='igny8_syste_tenant__1a6cda_idx')],
},
),
migrations.CreateModel(
name='SystemStatus',
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)),
('component', models.CharField(db_index=True, max_length=100)),
('status', models.CharField(choices=[('healthy', 'Healthy'), ('warning', 'Warning'), ('error', 'Error'), ('maintenance', 'Maintenance')], default='healthy', max_length=20)),
('message', models.TextField(blank=True)),
('last_check', models.DateTimeField(auto_now=True)),
('metadata', models.JSONField(blank=True, default=dict)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'db_table': 'igny8_system_status',
'indexes': [models.Index(fields=['tenant', 'status'], name='igny8_syste_tenant__0e1889_idx')],
'unique_together': {('tenant', 'component')},
},
),
]

View File

@@ -0,0 +1,61 @@
# Generated migration for IntegrationSettings and AIPrompt models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0001_initial'),
('system', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AIPrompt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('prompt_type', models.CharField(choices=[('clustering', 'Clustering'), ('ideas', 'Ideas Generation'), ('content_generation', 'Content Generation'), ('image_prompt_template', 'Image Prompt Template'), ('negative_prompt', 'Negative Prompt')], db_index=True, max_length=50)),
('prompt_value', models.TextField(help_text='The prompt template text')),
('default_prompt', models.TextField(help_text='Default prompt value (for reset)')),
('is_active', models.BooleanField(default=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'db_table': 'igny8_ai_prompts',
'ordering': ['prompt_type'],
'unique_together': {('tenant', 'prompt_type')},
'indexes': [
models.Index(fields=['prompt_type'], name='igny8_ai_pr_prompt__idx'),
models.Index(fields=['tenant', 'prompt_type'], name='igny8_ai_pr_tenant__idx'),
],
},
),
migrations.CreateModel(
name='IntegrationSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('integration_type', models.CharField(choices=[('openai', 'OpenAI'), ('runware', 'Runware'), ('gsc', 'Google Search Console')], db_index=True, max_length=50)),
('config', models.JSONField(default=dict, help_text='Integration configuration (API keys, settings, etc.)')),
('is_active', models.BooleanField(default=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'db_table': 'igny8_integration_settings',
'ordering': ['integration_type'],
'unique_together': {('tenant', 'integration_type')},
'indexes': [
models.Index(fields=['integration_type'], name='igny8_integ_integra_idx'),
models.Index(fields=['tenant', 'integration_type'], name='igny8_integ_tenant__idx'),
],
},
),
]

View File

@@ -0,0 +1,28 @@
# Generated migration to add image_generation integration type
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('system', '0002_integration_settings_ai_prompts'),
]
operations = [
migrations.AlterField(
model_name='integrationsettings',
name='integration_type',
field=models.CharField(
choices=[
('openai', 'OpenAI'),
('runware', 'Runware'),
('gsc', 'Google Search Console'),
('image_generation', 'Image Generation Service'),
],
db_index=True,
max_length=50
),
),
]

View File

@@ -0,0 +1,200 @@
# Generated by Django 5.2.8 on 2025-11-07 10:06
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'),
('system', '0003_add_image_generation_integration_type'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AISettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('integration_type', models.CharField(db_index=True, help_text="Integration type (e.g., 'openai', 'runware')", max_length=50)),
('config', models.JSONField(default=dict, help_text='Integration configuration (API keys, settings, etc.)')),
('model_preferences', models.JSONField(default=dict, help_text='Model preferences per operation type')),
('cost_limits', models.JSONField(default=dict, help_text='Cost limits and budgets')),
('is_active', models.BooleanField(default=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'igny8_ai_settings',
'ordering': ['integration_type'],
},
),
migrations.CreateModel(
name='ModuleSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('config', models.JSONField(default=dict, help_text='Settings configuration as JSON')),
('is_active', models.BooleanField(default=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('module_name', models.CharField(db_index=True, help_text="Module name (e.g., 'planner', 'writer')", max_length=100)),
('key', models.CharField(db_index=True, help_text='Settings key identifier', max_length=255)),
],
options={
'db_table': 'igny8_module_settings',
'ordering': ['module_name', 'key'],
},
),
migrations.CreateModel(
name='SystemSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(db_index=True, help_text='Settings key identifier', max_length=255, unique=True)),
('value', models.JSONField(default=dict, help_text='Settings value as JSON')),
('description', models.TextField(blank=True, help_text='Description of this setting')),
('updated_at', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'igny8_system_settings',
'ordering': ['key'],
},
),
migrations.CreateModel(
name='TenantSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('config', models.JSONField(default=dict, help_text='Settings configuration as JSON')),
('is_active', models.BooleanField(default=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('key', models.CharField(db_index=True, help_text='Settings key identifier', max_length=255)),
],
options={
'db_table': 'igny8_tenant_settings',
'ordering': ['key'],
},
),
migrations.CreateModel(
name='UserSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(db_index=True, help_text='Settings key identifier', max_length=255)),
('value', models.JSONField(default=dict, help_text='Settings value as JSON')),
('updated_at', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'igny8_user_settings',
'ordering': ['key'],
},
),
migrations.RenameIndex(
model_name='aiprompt',
new_name='igny8_ai_pr_prompt__4b2dbe_idx',
old_name='igny8_ai_pr_prompt__idx',
),
migrations.RenameIndex(
model_name='aiprompt',
new_name='igny8_ai_pr_tenant__9e7b95_idx',
old_name='igny8_ai_pr_tenant__idx',
),
migrations.RenameIndex(
model_name='integrationsettings',
new_name='igny8_integ_integra_5e382e_idx',
old_name='igny8_integ_integra_idx',
),
migrations.RenameIndex(
model_name='integrationsettings',
new_name='igny8_integ_tenant__5da472_idx',
old_name='igny8_integ_tenant__idx',
),
migrations.AlterField(
model_name='aiprompt',
name='prompt_type',
field=models.CharField(choices=[('clustering', 'Clustering'), ('ideas', 'Ideas Generation'), ('content_generation', 'Content Generation'), ('image_prompt_extraction', 'Image Prompt Extraction'), ('image_prompt_template', 'Image Prompt Template'), ('negative_prompt', 'Negative Prompt')], db_index=True, max_length=50),
),
migrations.AddField(
model_name='aisettings',
name='tenant',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant'),
),
migrations.AddField(
model_name='modulesettings',
name='tenant',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant'),
),
migrations.AddIndex(
model_name='systemsettings',
index=models.Index(fields=['key'], name='igny8_syste_key_20500b_idx'),
),
migrations.AddField(
model_name='tenantsettings',
name='tenant',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant'),
),
migrations.AddField(
model_name='usersettings',
name='tenant',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_settings', to='igny8_core_auth.tenant'),
),
migrations.AddField(
model_name='usersettings',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_settings', to=settings.AUTH_USER_MODEL),
),
migrations.RunSQL(
sql="DROP TABLE IF EXISTS igny8_system_logs CASCADE",
reverse_sql=migrations.RunSQL.noop,
),
migrations.RunSQL(
sql="DROP TABLE IF EXISTS igny8_system_status CASCADE",
reverse_sql=migrations.RunSQL.noop,
),
migrations.AddIndex(
model_name='aisettings',
index=models.Index(fields=['integration_type'], name='igny8_ai_se_integra_4f0b21_idx'),
),
migrations.AddIndex(
model_name='aisettings',
index=models.Index(fields=['tenant', 'integration_type'], name='igny8_ai_se_tenant__05ae98_idx'),
),
migrations.AlterUniqueTogether(
name='aisettings',
unique_together={('tenant', 'integration_type')},
),
migrations.AddIndex(
model_name='modulesettings',
index=models.Index(fields=['tenant', 'module_name', 'key'], name='igny8_modul_tenant__21ee25_idx'),
),
migrations.AddIndex(
model_name='modulesettings',
index=models.Index(fields=['module_name', 'key'], name='igny8_modul_module__95373a_idx'),
),
migrations.AlterUniqueTogether(
name='modulesettings',
unique_together={('tenant', 'module_name', 'key')},
),
migrations.AddIndex(
model_name='tenantsettings',
index=models.Index(fields=['tenant', 'key'], name='igny8_tenan_tenant__8ce0b3_idx'),
),
migrations.AlterUniqueTogether(
name='tenantsettings',
unique_together={('tenant', 'key')},
),
migrations.AddIndex(
model_name='usersettings',
index=models.Index(fields=['user', 'tenant', 'key'], name='igny8_user__user_id_ac09d9_idx'),
),
migrations.AddIndex(
model_name='usersettings',
index=models.Index(fields=['tenant', 'key'], name='igny8_user__tenant__01033d_idx'),
),
migrations.AlterUniqueTogether(
name='usersettings',
unique_together={('user', 'tenant', 'key')},
),
]

View File

@@ -0,0 +1,77 @@
# Generated by Django 5.2.8 on 2025-11-07 11:34
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0010_add_seed_keyword'),
('system', '0004_aisettings_modulesettings_systemsettings_and_more'),
]
operations = [
migrations.CreateModel(
name='AuthorProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text="Profile name (e.g., 'SaaS B2B Informative')", max_length=255)),
('description', models.TextField(blank=True, help_text='Description of the writing style')),
('tone', models.CharField(help_text="Writing tone (e.g., 'Professional', 'Casual', 'Technical', 'Conversational')", max_length=100)),
('language', models.CharField(default='en', help_text="Language code (e.g., 'en', 'es', 'fr')", max_length=50)),
('structure_template', models.JSONField(default=dict, help_text='Structure template defining content sections and their order')),
('is_active', models.BooleanField(db_index=True, default=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'verbose_name': 'Author Profile',
'verbose_name_plural': 'Author Profiles',
'db_table': 'igny8_author_profiles',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Strategy',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Strategy name', max_length=255)),
('description', models.TextField(blank=True, help_text='Description of the content strategy')),
('prompt_types', models.JSONField(default=list, help_text="List of prompt types to use (e.g., ['clustering', 'ideas', 'content_generation'])")),
('section_logic', models.JSONField(default=dict, help_text='Section logic configuration defining content structure and flow')),
('is_active', models.BooleanField(db_index=True, default=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('sector', models.ForeignKey(blank=True, help_text='Optional: Link strategy to a specific sector', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='strategies', to='igny8_core_auth.sector')),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.tenant')),
],
options={
'verbose_name': 'Strategy',
'verbose_name_plural': 'Strategies',
'db_table': 'igny8_strategies',
'ordering': ['name'],
},
),
migrations.AddIndex(
model_name='authorprofile',
index=models.Index(fields=['tenant', 'is_active'], name='igny8_autho_tenant__97d2c2_idx'),
),
migrations.AddIndex(
model_name='authorprofile',
index=models.Index(fields=['name'], name='igny8_autho_name_8295f3_idx'),
),
migrations.AddIndex(
model_name='strategy',
index=models.Index(fields=['tenant', 'is_active'], name='igny8_strat_tenant__344de9_idx'),
),
migrations.AddIndex(
model_name='strategy',
index=models.Index(fields=['tenant', 'sector'], name='igny8_strat_tenant__279cfa_idx'),
),
migrations.AddIndex(
model_name='strategy',
index=models.Index(fields=['name'], name='igny8_strat_name_8fe823_idx'),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.2.8 on 2025-11-07 14:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('system', '0005_add_author_profile_strategy'),
]
operations = [
migrations.AlterUniqueTogether(
name='systemstatus',
unique_together=None,
),
migrations.RemoveField(
model_name='systemstatus',
name='tenant',
),
migrations.DeleteModel(
name='SystemLog',
),
migrations.DeleteModel(
name='SystemStatus',
),
]

Some files were not shown because too many files have changed in this diff Show More