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

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