Files
igny8/backend/igny8_core/business/content/models.py
IGNY8 VPS (Salman) 6e2101d019 feat: add Usage Limits Panel component with usage tracking and visual indicators for limits
style: implement custom color schemes and gradients for account section, enhancing visual hierarchy
2025-12-12 13:15:15 +00:00

638 lines
22 KiB
Python

from django.db import models
from django.core.validators import MinValueValidator
from igny8_core.auth.models import SiteSectorBaseModel
from igny8_core.common.soft_delete import SoftDeletableModel, SoftDeleteManager
class Tasks(SoftDeletableModel, SiteSectorBaseModel):
"""Tasks model for content generation queue"""
STATUS_CHOICES = [
('queued', 'Queued'),
('completed', 'Completed'),
]
CONTENT_TYPE_CHOICES = [
('post', 'Post'),
('page', 'Page'),
('product', 'Product'),
('taxonomy', 'Taxonomy'),
]
CONTENT_STRUCTURE_CHOICES = [
# Post structures
('article', 'Article'),
('guide', 'Guide'),
('comparison', 'Comparison'),
('review', 'Review'),
('listicle', 'Listicle'),
# Page structures
('landing_page', 'Landing Page'),
('business_page', 'Business Page'),
('service_page', 'Service Page'),
('general', 'General'),
('cluster_hub', 'Cluster Hub'),
# Product structures
('product_page', 'Product Page'),
# Taxonomy structures
('category_archive', 'Category Archive'),
('tag_archive', 'Tag Archive'),
('attribute_archive', 'Attribute Archive'),
]
title = models.CharField(max_length=255, db_index=True)
description = models.TextField(blank=True, null=True)
cluster = models.ForeignKey(
'planner.Clusters',
on_delete=models.SET_NULL,
null=True,
blank=False,
related_name='tasks',
limit_choices_to={'sector': models.F('sector')},
help_text="Parent cluster (required)"
)
idea = models.ForeignKey(
'planner.ContentIdeas',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='tasks',
help_text="Optional content idea reference",
db_column='idea_id'
)
content_type = models.CharField(
max_length=100,
db_index=True,
help_text="Content type: post, page, product, taxonomy",
choices=CONTENT_TYPE_CHOICES,
default='post',
blank=True,
null=True
)
content_structure = models.CharField(
max_length=100,
db_index=True,
help_text="Content structure: article, guide, comparison, review, listicle, landing_page, etc.",
choices=CONTENT_STRUCTURE_CHOICES,
default='article',
blank=True,
null=True
)
taxonomy_term = models.ForeignKey(
'ContentTaxonomy',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='tasks',
help_text="Optional taxonomy term assignment",
db_column='taxonomy_id'
)
keywords = models.TextField(
blank=True,
null=True,
help_text="Comma-separated keywords for this task"
)
word_count = models.IntegerField(
default=1000,
validators=[MinValueValidator(100)],
help_text="Target word count for content generation"
)
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='queued')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_tasks'
ordering = ['-created_at']
verbose_name = 'Task'
verbose_name_plural = 'Tasks'
indexes = [
models.Index(fields=['title']),
models.Index(fields=['status']),
models.Index(fields=['cluster']),
models.Index(fields=['content_type']),
models.Index(fields=['content_structure']),
models.Index(fields=['site', 'sector']),
]
objects = SoftDeleteManager()
all_objects = models.Manager()
def __str__(self):
return self.title
class ContentTaxonomyRelation(models.Model):
"""Through model for Content-Taxonomy many-to-many relationship"""
content = models.ForeignKey('Content', on_delete=models.CASCADE, db_column='content_id')
taxonomy = models.ForeignKey('ContentTaxonomy', on_delete=models.CASCADE, db_column='taxonomy_id')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_content_taxonomy_relations'
unique_together = [['content', 'taxonomy']]
class Content(SoftDeletableModel, SiteSectorBaseModel):
"""
Content model for AI-generated or WordPress-imported content.
Final architecture: simplified content management.
"""
CONTENT_TYPE_CHOICES = [
('post', 'Post'),
('page', 'Page'),
('product', 'Product'),
('taxonomy', 'Taxonomy'),
]
CONTENT_STRUCTURE_CHOICES = [
# Post structures
('article', 'Article'),
('guide', 'Guide'),
('comparison', 'Comparison'),
('review', 'Review'),
('listicle', 'Listicle'),
# Page structures
('landing_page', 'Landing Page'),
('business_page', 'Business Page'),
('service_page', 'Service Page'),
('general', 'General'),
('cluster_hub', 'Cluster Hub'),
# Product structures
('product_page', 'Product Page'),
# Taxonomy structures
('category_archive', 'Category Archive'),
('tag_archive', 'Tag Archive'),
('attribute_archive', 'Attribute Archive'),
]
# Core content fields
title = models.CharField(max_length=255, db_index=True)
content_html = models.TextField(help_text="Final HTML content")
word_count = models.IntegerField(
default=0,
help_text="Actual word count of content (calculated from HTML)"
)
# SEO fields
meta_title = models.CharField(max_length=255, blank=True, null=True, help_text="SEO meta title")
meta_description = models.TextField(blank=True, null=True, help_text="SEO meta description")
primary_keyword = models.CharField(max_length=255, blank=True, null=True, help_text="Primary SEO keyword")
secondary_keywords = models.JSONField(default=list, blank=True, help_text="Secondary SEO keywords")
cluster = models.ForeignKey(
'planner.Clusters',
on_delete=models.SET_NULL,
null=True,
blank=False,
related_name='contents',
help_text="Parent cluster (required)"
)
content_type = models.CharField(
max_length=50,
choices=CONTENT_TYPE_CHOICES,
default='post',
db_index=True,
help_text="Content type: post, page, product, taxonomy"
)
content_structure = models.CharField(
max_length=50,
choices=CONTENT_STRUCTURE_CHOICES,
default='article',
db_index=True,
help_text="Content structure/format based on content type"
)
# Taxonomy relationships
taxonomy_terms = models.ManyToManyField(
'ContentTaxonomy',
through='ContentTaxonomyRelation',
blank=True,
related_name='contents',
help_text="Associated taxonomy terms (categories, tags, attributes)"
)
# External platform fields (WordPress integration)
external_id = models.CharField(max_length=255, blank=True, null=True, db_index=True, help_text="WordPress/external platform post ID")
external_url = models.URLField(blank=True, null=True, help_text="WordPress/external platform URL")
external_type = models.CharField(max_length=100, blank=True, null=True, help_text="WordPress post type (post, page, product, etc.)")
external_metadata = models.JSONField(blank=True, null=True, default=dict, help_text="External platform metadata (WordPress term IDs, etc.)")
sync_status = models.CharField(max_length=50, blank=True, null=True, help_text="Sync status with WordPress")
# Source tracking
SOURCE_CHOICES = [
('igny8', 'IGNY8 Generated'),
('wordpress', 'WordPress Imported'),
]
source = models.CharField(
max_length=50,
choices=SOURCE_CHOICES,
default='igny8',
db_index=True,
help_text="Content source"
)
# Status tracking
STATUS_CHOICES = [
('draft', 'Draft'),
('review', 'Review'),
('published', 'Published'),
]
status = models.CharField(
max_length=50,
choices=STATUS_CHOICES,
default='draft',
db_index=True,
help_text="Content status"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_content'
ordering = ['-created_at']
verbose_name = 'Content'
verbose_name_plural = 'Contents'
indexes = [
models.Index(fields=['title']),
models.Index(fields=['cluster']),
models.Index(fields=['content_type']),
models.Index(fields=['content_structure']),
models.Index(fields=['source']),
models.Index(fields=['status']),
models.Index(fields=['external_id']),
models.Index(fields=['site', 'sector']),
]
objects = SoftDeleteManager()
all_objects = models.Manager()
def __str__(self):
return self.title or f"Content {self.id}"
def save(self, *args, **kwargs):
"""Override save to auto-calculate word_count from content_html"""
is_new = self.pk is None
old_word_count = 0
# Get old word count if updating
if not is_new and self.content_html:
try:
old_instance = Content.objects.get(pk=self.pk)
old_word_count = old_instance.word_count or 0
except Content.DoesNotExist:
pass
# Auto-calculate word count if content_html has changed
if self.content_html:
from igny8_core.utils.word_counter import calculate_word_count
calculated_count = calculate_word_count(self.content_html)
# Only update if different to avoid unnecessary saves
if self.word_count != calculated_count:
self.word_count = calculated_count
super().save(*args, **kwargs)
# Increment usage for new content or if word count increased
if self.content_html and self.word_count:
# Only count newly generated words
new_words = self.word_count - old_word_count if not is_new else self.word_count
if new_words > 0:
from igny8_core.business.billing.services.limit_service import LimitService
try:
# Get account from site
account = self.site.account if self.site else None
if account:
LimitService.increment_usage(
account=account,
limit_type='content_words',
amount=new_words,
metadata={
'content_id': self.id,
'content_title': self.title,
'site_id': self.site.id if self.site else None,
}
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error incrementing word usage for content {self.id}: {str(e)}")
class ContentTaxonomy(SiteSectorBaseModel):
"""
Simplified taxonomy model for AI-generated categories and tags.
Directly linked to Content via many-to-many relationship.
"""
TAXONOMY_TYPE_CHOICES = [
('category', 'Category'),
('tag', 'Tag'),
]
name = models.CharField(max_length=255, db_index=True, help_text="Term name")
slug = models.SlugField(max_length=255, db_index=True, help_text="URL slug")
taxonomy_type = models.CharField(
max_length=50,
choices=TAXONOMY_TYPE_CHOICES,
db_index=True,
help_text="Type of taxonomy (category or tag)"
)
# WordPress/external platform sync fields
external_taxonomy = models.CharField(
max_length=100,
blank=True,
default='',
help_text="WordPress taxonomy slug (category, post_tag)"
)
external_id = models.IntegerField(
null=True,
blank=True,
db_index=True,
help_text="WordPress term_id for sync"
)
sync_status = models.CharField(
max_length=50,
blank=True,
default='',
help_text="Sync status with external platform"
)
description = models.TextField(
blank=True,
default='',
help_text="Taxonomy term description"
)
count = models.IntegerField(
default=0,
help_text="Number of times this term is used"
)
metadata = models.JSONField(
default=dict,
blank=True,
help_text="Additional metadata (AI generation details, etc.)"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_content_taxonomy_terms'
verbose_name = 'Content Taxonomy'
verbose_name_plural = 'Content Taxonomies'
unique_together = [
['site', 'slug', 'taxonomy_type'],
['site', 'external_id', 'external_taxonomy'],
]
indexes = [
models.Index(fields=['name']),
models.Index(fields=['slug']),
models.Index(fields=['taxonomy_type']),
models.Index(fields=['external_id', 'external_taxonomy']),
models.Index(fields=['site', 'taxonomy_type']),
models.Index(fields=['site', 'sector']),
]
def __str__(self):
return f"{self.name} ({self.get_taxonomy_type_display()})"
class Images(SoftDeletableModel, 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']),
]
objects = SoftDeleteManager()
all_objects = models.Manager()
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 ContentAttribute(SiteSectorBaseModel):
"""
Unified attribute storage for products, services, and semantic facets.
Replaces ContentAttributeMap with enhanced WP sync support.
"""
ATTRIBUTE_TYPE_CHOICES = [
('product_spec', 'Product Specification'),
('service_modifier', 'Service Modifier'),
('semantic_facet', 'Semantic Facet'),
]
SOURCE_CHOICES = [
('blueprint', 'Blueprint'),
('manual', 'Manual'),
('import', 'Import'),
('wordpress', 'WordPress'),
]
content = models.ForeignKey(
Content,
on_delete=models.CASCADE,
related_name='attributes',
null=True,
blank=True,
)
task = models.ForeignKey(
Tasks,
on_delete=models.CASCADE,
related_name='attribute_mappings',
null=True,
blank=True,
)
cluster = models.ForeignKey(
'planner.Clusters',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='attributes',
help_text="Optional cluster association for semantic attributes"
)
attribute_type = models.CharField(
max_length=50,
choices=ATTRIBUTE_TYPE_CHOICES,
default='product_spec',
db_index=True,
help_text="Type of attribute"
)
name = models.CharField(max_length=120, help_text="Attribute name (e.g., Color, Material)")
value = models.CharField(max_length=255, blank=True, null=True, help_text="Attribute value (e.g., Blue, Cotton)")
# WordPress/WooCommerce sync fields
external_id = models.IntegerField(null=True, blank=True, help_text="WP attribute term ID")
external_attribute_name = models.CharField(
max_length=100,
blank=True,
help_text="WP attribute slug (e.g., pa_color, pa_size)"
)
source = models.CharField(max_length=50, choices=SOURCE_CHOICES, default='manual')
metadata = models.JSONField(default=dict, blank=True, help_text="Additional metadata")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
app_label = 'writer'
db_table = 'igny8_content_attributes'
verbose_name = 'Content Attribute'
verbose_name_plural = 'Content Attributes'
indexes = [
models.Index(fields=['name']),
models.Index(fields=['attribute_type']),
models.Index(fields=['content', 'name']),
models.Index(fields=['content', 'attribute_type']),
models.Index(fields=['cluster', 'attribute_type']),
models.Index(fields=['external_id']),
]
def save(self, *args, **kwargs):
provider = self.content or self.task
if provider:
self.account = provider.account
self.site = provider.site
self.sector = provider.sector
super().save(*args, **kwargs)
def __str__(self):
return f"{self.name}: {self.value}"
# Backward compatibility alias
ContentAttributeMap = ContentAttribute