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