458 lines
16 KiB
Python
458 lines
16 KiB
Python
from django.db import models
|
||
from django.core.validators import MinValueValidator
|
||
from igny8_core.auth.models import SiteSectorBaseModel
|
||
|
||
|
||
class Tasks(SiteSectorBaseModel):
|
||
"""Tasks model for content generation queue"""
|
||
|
||
STATUS_CHOICES = [
|
||
('queued', 'Queued'),
|
||
('completed', 'Completed'),
|
||
]
|
||
|
||
CONTENT_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'),
|
||
]
|
||
|
||
title = models.CharField(max_length=255, db_index=True)
|
||
description = models.TextField(blank=True, null=True)
|
||
keywords = models.CharField(max_length=500, blank=True) # Comma-separated keywords (legacy)
|
||
cluster = models.ForeignKey(
|
||
'planner.Clusters',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='tasks',
|
||
limit_choices_to={'sector': models.F('sector')}
|
||
)
|
||
keyword_objects = models.ManyToManyField(
|
||
'planner.Keywords',
|
||
blank=True,
|
||
related_name='tasks',
|
||
help_text="Individual keywords linked to this task"
|
||
)
|
||
idea = models.ForeignKey(
|
||
'planner.ContentIdeas',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='tasks'
|
||
)
|
||
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')
|
||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='queued')
|
||
|
||
# Content fields
|
||
content = models.TextField(blank=True, null=True) # Generated content
|
||
word_count = models.IntegerField(default=0)
|
||
|
||
# SEO fields
|
||
meta_title = models.CharField(max_length=255, blank=True, null=True)
|
||
meta_description = models.TextField(blank=True, null=True)
|
||
# WordPress integration
|
||
assigned_post_id = models.IntegerField(null=True, blank=True) # WordPress post ID if published
|
||
post_url = models.URLField(blank=True, null=True) # WordPress post URL
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
app_label = 'writer'
|
||
db_table = 'igny8_tasks'
|
||
ordering = ['-created_at']
|
||
verbose_name = 'Task'
|
||
verbose_name_plural = 'Tasks'
|
||
indexes = [
|
||
models.Index(fields=['title']),
|
||
models.Index(fields=['status']),
|
||
models.Index(fields=['cluster']),
|
||
models.Index(fields=['content_type']),
|
||
models.Index(fields=['site', 'sector']),
|
||
]
|
||
|
||
def __str__(self):
|
||
return self.title
|
||
|
||
|
||
class Content(SiteSectorBaseModel):
|
||
"""
|
||
Content model for storing final AI-generated article content.
|
||
Separated from Task for content versioning and storage optimization.
|
||
"""
|
||
task = models.OneToOneField(
|
||
Tasks,
|
||
on_delete=models.CASCADE,
|
||
null=True,
|
||
blank=True,
|
||
related_name='content_record',
|
||
help_text="The task this content belongs to"
|
||
)
|
||
html_content = models.TextField(help_text="Final AI-generated HTML content")
|
||
word_count = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||
metadata = models.JSONField(default=dict, help_text="Additional metadata (SEO, structure, etc.)")
|
||
title = models.CharField(max_length=255, blank=True, null=True)
|
||
meta_title = models.CharField(max_length=255, blank=True, null=True)
|
||
meta_description = models.TextField(blank=True, null=True)
|
||
primary_keyword = models.CharField(max_length=255, blank=True, null=True)
|
||
secondary_keywords = models.JSONField(default=list, blank=True, help_text="List of secondary keywords")
|
||
tags = models.JSONField(default=list, blank=True, help_text="List of tags")
|
||
categories = models.JSONField(default=list, blank=True, help_text="List of categories")
|
||
STATUS_CHOICES = [
|
||
('draft', 'Draft'),
|
||
('review', 'Review'),
|
||
('publish', 'Publish'),
|
||
]
|
||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='draft', help_text="Content workflow status (draft, review, publish)")
|
||
generated_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
# Phase 4: Source tracking
|
||
SOURCE_CHOICES = [
|
||
('igny8', 'IGNY8 Generated'),
|
||
('wordpress', 'WordPress Synced'),
|
||
('shopify', 'Shopify Synced'),
|
||
('custom', 'Custom API Synced'),
|
||
]
|
||
source = models.CharField(
|
||
max_length=50,
|
||
choices=SOURCE_CHOICES,
|
||
default='igny8',
|
||
db_index=True,
|
||
help_text="Source of the content"
|
||
)
|
||
|
||
SYNC_STATUS_CHOICES = [
|
||
('native', 'Native IGNY8 Content'),
|
||
('imported', 'Imported from External'),
|
||
('synced', 'Synced from External'),
|
||
]
|
||
sync_status = models.CharField(
|
||
max_length=50,
|
||
choices=SYNC_STATUS_CHOICES,
|
||
default='native',
|
||
db_index=True,
|
||
help_text="Sync status of the content"
|
||
)
|
||
|
||
# External reference fields
|
||
external_id = models.CharField(max_length=255, blank=True, null=True, help_text="External platform ID")
|
||
external_url = models.URLField(blank=True, null=True, help_text="External platform URL")
|
||
sync_metadata = models.JSONField(default=dict, blank=True, help_text="Platform-specific sync metadata")
|
||
|
||
# Phase 4: Linking fields
|
||
internal_links = models.JSONField(default=list, blank=True, help_text="Internal links added by linker")
|
||
linker_version = models.IntegerField(default=0, help_text="Version of linker processing")
|
||
|
||
# Phase 4: Optimization fields
|
||
optimizer_version = models.IntegerField(default=0, help_text="Version of optimizer processing")
|
||
optimization_scores = models.JSONField(default=dict, blank=True, help_text="Optimization scores (SEO, readability, engagement)")
|
||
|
||
# Phase 8: Universal Content Types
|
||
ENTITY_TYPE_CHOICES = [
|
||
('blog_post', 'Blog Post'),
|
||
('article', 'Article'),
|
||
('product', 'Product'),
|
||
('service', 'Service Page'),
|
||
('taxonomy', 'Taxonomy Page'),
|
||
('page', 'Page'),
|
||
]
|
||
entity_type = models.CharField(
|
||
max_length=50,
|
||
choices=ENTITY_TYPE_CHOICES,
|
||
default='blog_post',
|
||
db_index=True,
|
||
help_text="Type of content entity"
|
||
)
|
||
|
||
# Phase 8: Structured content blocks
|
||
json_blocks = models.JSONField(
|
||
default=list,
|
||
blank=True,
|
||
help_text="Structured content blocks (for products, services, taxonomies)"
|
||
)
|
||
|
||
# Phase 8: Content structure data
|
||
structure_data = models.JSONField(
|
||
default=dict,
|
||
blank=True,
|
||
help_text="Content structure data (metadata, schema, etc.)"
|
||
)
|
||
|
||
class Meta:
|
||
app_label = 'writer'
|
||
db_table = 'igny8_content'
|
||
ordering = ['-generated_at']
|
||
verbose_name = 'Content'
|
||
verbose_name_plural = 'Contents'
|
||
indexes = [
|
||
models.Index(fields=['task']),
|
||
models.Index(fields=['generated_at']),
|
||
models.Index(fields=['source']),
|
||
models.Index(fields=['sync_status']),
|
||
models.Index(fields=['source', 'sync_status']),
|
||
models.Index(fields=['entity_type']), # Phase 8
|
||
]
|
||
|
||
def save(self, *args, **kwargs):
|
||
"""Automatically set account, site, and sector from task"""
|
||
if self.task_id: # Check task_id instead of accessing task to avoid RelatedObjectDoesNotExist
|
||
try:
|
||
self.account = self.task.account
|
||
self.site = self.task.site
|
||
self.sector = self.task.sector
|
||
except self.task.RelatedObjectDoesNotExist:
|
||
pass # Task doesn't exist, skip
|
||
super().save(*args, **kwargs)
|
||
|
||
def __str__(self):
|
||
return f"Content for {self.task.title}"
|
||
|
||
|
||
class Images(SiteSectorBaseModel):
|
||
"""Images model for content-related images (featured, desktop, mobile, in-article)"""
|
||
|
||
IMAGE_TYPE_CHOICES = [
|
||
('featured', 'Featured Image'),
|
||
('desktop', 'Desktop Image'),
|
||
('mobile', 'Mobile Image'),
|
||
('in_article', 'In-Article Image'),
|
||
]
|
||
|
||
content = models.ForeignKey(
|
||
Content,
|
||
on_delete=models.CASCADE,
|
||
related_name='images',
|
||
null=True,
|
||
blank=True,
|
||
help_text="The content this image belongs to (preferred)"
|
||
)
|
||
task = models.ForeignKey(
|
||
Tasks,
|
||
on_delete=models.CASCADE,
|
||
related_name='images',
|
||
null=True,
|
||
blank=True,
|
||
help_text="The task this image belongs to (legacy, use content instead)"
|
||
)
|
||
image_type = models.CharField(max_length=50, choices=IMAGE_TYPE_CHOICES, default='featured')
|
||
image_url = models.CharField(max_length=500, blank=True, null=True, help_text="URL of the generated/stored image")
|
||
image_path = models.CharField(max_length=500, blank=True, null=True, help_text="Local path if stored locally")
|
||
prompt = models.TextField(blank=True, null=True, help_text="Image generation prompt used")
|
||
status = models.CharField(max_length=50, default='pending', help_text="Status: pending, generated, failed")
|
||
position = models.IntegerField(default=0, help_text="Position for in-article images ordering")
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
app_label = 'writer'
|
||
db_table = 'igny8_images'
|
||
ordering = ['content', 'position', '-created_at']
|
||
verbose_name = 'Image'
|
||
verbose_name_plural = 'Images'
|
||
indexes = [
|
||
models.Index(fields=['content', 'image_type']),
|
||
models.Index(fields=['task', 'image_type']),
|
||
models.Index(fields=['status']),
|
||
models.Index(fields=['content', 'position']),
|
||
models.Index(fields=['task', 'position']),
|
||
]
|
||
|
||
def save(self, *args, **kwargs):
|
||
"""Automatically set account, site, and sector from content or task"""
|
||
# Prefer content over task
|
||
if self.content:
|
||
self.account = self.content.account
|
||
self.site = self.content.site
|
||
self.sector = self.content.sector
|
||
elif self.task:
|
||
self.account = self.task.account
|
||
self.site = self.task.site
|
||
self.sector = self.task.sector
|
||
super().save(*args, **kwargs)
|
||
|
||
def __str__(self):
|
||
content_title = self.content.title if self.content else None
|
||
task_title = self.task.title if self.task else None
|
||
title = content_title or task_title or 'Unknown'
|
||
return f"{title} - {self.image_type}"
|
||
|
||
|
||
class ContentClusterMap(SiteSectorBaseModel):
|
||
"""Associates generated content with planner clusters + roles."""
|
||
|
||
ROLE_CHOICES = [
|
||
('hub', 'Hub Page'),
|
||
('supporting', 'Supporting Page'),
|
||
('attribute', 'Attribute Page'),
|
||
]
|
||
|
||
SOURCE_CHOICES = [
|
||
('blueprint', 'Blueprint'),
|
||
('manual', 'Manual'),
|
||
('import', 'Import'),
|
||
]
|
||
|
||
content = models.ForeignKey(
|
||
Content,
|
||
on_delete=models.CASCADE,
|
||
related_name='cluster_mappings',
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
task = models.ForeignKey(
|
||
Tasks,
|
||
on_delete=models.CASCADE,
|
||
related_name='cluster_mappings',
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
cluster = models.ForeignKey(
|
||
'planner.Clusters',
|
||
on_delete=models.CASCADE,
|
||
related_name='content_mappings',
|
||
)
|
||
role = models.CharField(max_length=50, choices=ROLE_CHOICES, default='hub')
|
||
source = models.CharField(max_length=50, choices=SOURCE_CHOICES, default='blueprint')
|
||
metadata = models.JSONField(default=dict, blank=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
app_label = 'writer'
|
||
db_table = 'igny8_content_cluster_map'
|
||
unique_together = [['content', 'cluster', 'role']]
|
||
indexes = [
|
||
models.Index(fields=['cluster', 'role']),
|
||
models.Index(fields=['content', 'role']),
|
||
models.Index(fields=['task', 'role']),
|
||
]
|
||
|
||
def save(self, *args, **kwargs):
|
||
provider = self.content or self.task
|
||
if provider:
|
||
self.account = provider.account
|
||
self.site = provider.site
|
||
self.sector = provider.sector
|
||
super().save(*args, **kwargs)
|
||
|
||
def __str__(self):
|
||
return f"{self.cluster.name} ({self.get_role_display()})"
|
||
|
||
|
||
class ContentTaxonomyMap(SiteSectorBaseModel):
|
||
"""Maps content entities to blueprint taxonomies for syncing/publishing."""
|
||
|
||
SOURCE_CHOICES = [
|
||
('blueprint', 'Blueprint'),
|
||
('manual', 'Manual'),
|
||
('import', 'Import'),
|
||
]
|
||
|
||
content = models.ForeignKey(
|
||
Content,
|
||
on_delete=models.CASCADE,
|
||
related_name='taxonomy_mappings',
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
task = models.ForeignKey(
|
||
Tasks,
|
||
on_delete=models.CASCADE,
|
||
related_name='taxonomy_mappings',
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
taxonomy = models.ForeignKey(
|
||
'site_building.SiteBlueprintTaxonomy',
|
||
on_delete=models.CASCADE,
|
||
related_name='content_mappings',
|
||
)
|
||
source = models.CharField(max_length=50, choices=SOURCE_CHOICES, default='blueprint')
|
||
metadata = models.JSONField(default=dict, blank=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
app_label = 'writer'
|
||
db_table = 'igny8_content_taxonomy_map'
|
||
unique_together = [['content', 'taxonomy']]
|
||
indexes = [
|
||
models.Index(fields=['taxonomy']),
|
||
models.Index(fields=['content', 'taxonomy']),
|
||
models.Index(fields=['task', 'taxonomy']),
|
||
]
|
||
|
||
def save(self, *args, **kwargs):
|
||
provider = self.content or self.task
|
||
if provider:
|
||
self.account = provider.account
|
||
self.site = provider.site
|
||
self.sector = provider.sector
|
||
super().save(*args, **kwargs)
|
||
|
||
def __str__(self):
|
||
return f"{self.taxonomy.name}"
|
||
|
||
|
||
class ContentAttributeMap(SiteSectorBaseModel):
|
||
"""Stores structured attribute data tied to content/task records."""
|
||
|
||
SOURCE_CHOICES = [
|
||
('blueprint', 'Blueprint'),
|
||
('manual', 'Manual'),
|
||
('import', 'Import'),
|
||
]
|
||
|
||
content = models.ForeignKey(
|
||
Content,
|
||
on_delete=models.CASCADE,
|
||
related_name='attribute_mappings',
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
task = models.ForeignKey(
|
||
Tasks,
|
||
on_delete=models.CASCADE,
|
||
related_name='attribute_mappings',
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
name = models.CharField(max_length=120)
|
||
value = models.CharField(max_length=255, blank=True, null=True)
|
||
source = models.CharField(max_length=50, choices=SOURCE_CHOICES, default='blueprint')
|
||
metadata = models.JSONField(default=dict, blank=True)
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
app_label = 'writer'
|
||
db_table = 'igny8_content_attribute_map'
|
||
indexes = [
|
||
models.Index(fields=['name']),
|
||
models.Index(fields=['content', 'name']),
|
||
models.Index(fields=['task', 'name']),
|
||
]
|
||
|
||
def save(self, *args, **kwargs):
|
||
provider = self.content or self.task
|
||
if provider:
|
||
self.account = provider.account
|
||
self.site = provider.site
|
||
self.sector = provider.sector
|
||
super().save(*args, **kwargs)
|
||
|
||
def __str__(self):
|
||
target = self.content or self.task
|
||
return f"{target} – {self.name}"
|