Enhance Content Management with New Taxonomy and Attribute Models

- Introduced `ContentTaxonomy` and `ContentAttribute` models for improved content categorization and attribute management.
- Updated `Content` model to support new fields for content format, cluster role, and external type.
- Refactored serializers and views to accommodate new models, including `ContentTaxonomySerializer` and `ContentAttributeSerializer`.
- Added new API endpoints for managing taxonomies and attributes, enhancing the content management capabilities.
- Updated admin interfaces for `Content`, `ContentTaxonomy`, and `ContentAttribute` to reflect new structures and improve usability.
- Implemented backward compatibility for existing attribute mappings.
- Enhanced filtering and search capabilities in the API for better content retrieval.
This commit is contained in:
IGNY8 VPS (Salman)
2025-11-22 00:21:00 +00:00
parent a82be89d21
commit 55dfd5ad19
17 changed files with 2934 additions and 40 deletions

View File

@@ -202,21 +202,65 @@ class Content(SiteSectorBaseModel):
# Phase 8: Universal Content Types
ENTITY_TYPE_CHOICES = [
('blog_post', 'Blog Post'),
('article', 'Article'),
('post', 'Blog Post'),
('page', 'Page'),
('product', 'Product'),
('service', 'Service Page'),
('taxonomy', 'Taxonomy Page'),
('page', 'Page'),
('taxonomy_term', 'Taxonomy Term Page'),
# Legacy choices for backward compatibility
('blog_post', 'Blog Post (Legacy)'),
('article', 'Article (Legacy)'),
('taxonomy', 'Taxonomy Page (Legacy)'),
]
entity_type = models.CharField(
max_length=50,
choices=ENTITY_TYPE_CHOICES,
default='blog_post',
default='post',
db_index=True,
help_text="Type of content entity"
)
# Phase 9: Content format (for posts)
CONTENT_FORMAT_CHOICES = [
('article', 'Article'),
('listicle', 'Listicle'),
('guide', 'How-To Guide'),
('comparison', 'Comparison'),
('review', 'Review'),
('roundup', 'Roundup'),
]
content_format = models.CharField(
max_length=50,
choices=CONTENT_FORMAT_CHOICES,
blank=True,
null=True,
db_index=True,
help_text="Content format (only for entity_type=post)"
)
# Phase 9: Cluster role
CLUSTER_ROLE_CHOICES = [
('hub', 'Hub Page'),
('supporting', 'Supporting Content'),
('attribute', 'Attribute Page'),
]
cluster_role = models.CharField(
max_length=50,
choices=CLUSTER_ROLE_CHOICES,
default='supporting',
blank=True,
null=True,
db_index=True,
help_text="Role within cluster strategy"
)
# Phase 9: WordPress post type
external_type = models.CharField(
max_length=100,
blank=True,
help_text="WordPress post type (post, page, product, service)"
)
# Phase 8: Structured content blocks
json_blocks = models.JSONField(
default=list,
@@ -231,6 +275,25 @@ class Content(SiteSectorBaseModel):
help_text="Content structure data (metadata, schema, etc.)"
)
# Phase 9: Taxonomy relationships
taxonomies = models.ManyToManyField(
'ContentTaxonomy',
blank=True,
related_name='contents',
through='ContentTaxonomyRelation',
help_text="Associated taxonomy terms (categories, tags, attributes)"
)
# Phase 9: Direct cluster relationship
cluster = models.ForeignKey(
'planner.Clusters',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='contents',
help_text="Primary semantic cluster"
)
class Meta:
app_label = 'writer'
db_table = 'igny8_content'
@@ -243,7 +306,12 @@ class Content(SiteSectorBaseModel):
models.Index(fields=['source']),
models.Index(fields=['sync_status']),
models.Index(fields=['source', 'sync_status']),
models.Index(fields=['entity_type']), # Phase 8
models.Index(fields=['entity_type']),
models.Index(fields=['content_format']),
models.Index(fields=['cluster_role']),
models.Index(fields=['cluster']),
models.Index(fields=['external_type']),
models.Index(fields=['site', 'entity_type']),
]
def save(self, *args, **kwargs):
@@ -261,6 +329,134 @@ class Content(SiteSectorBaseModel):
return f"Content for {self.task.title}"
class ContentTaxonomy(SiteSectorBaseModel):
"""
Universal taxonomy model for categories, tags, and product attributes.
Syncs with WordPress taxonomies and stores terms.
"""
TAXONOMY_TYPE_CHOICES = [
('category', 'Category'),
('tag', 'Tag'),
('product_cat', 'Product Category'),
('product_tag', 'Product Tag'),
('product_attr', 'Product Attribute'),
('service_cat', 'Service Category'),
]
SYNC_STATUS_CHOICES = [
('native', 'Native IGNY8'),
('imported', 'Imported from External'),
('synced', 'Synced with External'),
]
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"
)
description = models.TextField(blank=True, help_text="Term description")
parent = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.CASCADE,
related_name='children',
help_text="Parent term for hierarchical taxonomies"
)
# WordPress/WooCommerce sync fields
external_id = models.IntegerField(
null=True,
blank=True,
db_index=True,
help_text="WordPress term ID"
)
external_taxonomy = models.CharField(
max_length=100,
blank=True,
help_text="WP taxonomy name (category, post_tag, product_cat, pa_color)"
)
sync_status = models.CharField(
max_length=50,
choices=SYNC_STATUS_CHOICES,
default='native',
db_index=True,
help_text="Sync status with external system"
)
# WordPress metadata
count = models.IntegerField(default=0, help_text="Post/product count from WordPress")
metadata = models.JSONField(default=dict, blank=True, help_text="Additional metadata")
# Cluster mapping
clusters = models.ManyToManyField(
'planner.Clusters',
blank=True,
related_name='taxonomy_terms',
help_text="Semantic clusters this term maps to"
)
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=['sync_status']),
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 ContentTaxonomyRelation(models.Model):
"""
Through model for Content-Taxonomy M2M relationship.
Simplified without SiteSectorBaseModel to avoid tenant_id issues.
"""
content = models.ForeignKey(
Content,
on_delete=models.CASCADE,
related_name='taxonomy_relations'
)
taxonomy = models.ForeignKey(
ContentTaxonomy,
on_delete=models.CASCADE,
related_name='content_relations'
)
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']]
indexes = [
models.Index(fields=['content']),
models.Index(fields=['taxonomy']),
]
def __str__(self):
return f"{self.content}{self.taxonomy}"
class Images(SiteSectorBaseModel):
"""Images model for content-related images (featured, desktop, mobile, in-article)"""
@@ -447,19 +643,29 @@ class ContentTaxonomyMap(SiteSectorBaseModel):
return f"{self.taxonomy.name}"
class ContentAttributeMap(SiteSectorBaseModel):
"""Stores structured attribute data tied to content/task records."""
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='attribute_mappings',
related_name='attributes',
null=True,
blank=True,
)
@@ -470,20 +676,50 @@ class ContentAttributeMap(SiteSectorBaseModel):
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)
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_attribute_map'
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=['task', 'name']),
models.Index(fields=['content', 'attribute_type']),
models.Index(fields=['cluster', 'attribute_type']),
models.Index(fields=['external_id']),
]
def save(self, *args, **kwargs):
@@ -495,5 +731,8 @@ class ContentAttributeMap(SiteSectorBaseModel):
super().save(*args, **kwargs)
def __str__(self):
target = self.content or self.task
return f"{target} {self.name}"
return f"{self.name}: {self.value}"
# Backward compatibility alias
ContentAttributeMap = ContentAttribute

View File

@@ -57,6 +57,78 @@ class IntegrationViewSet(SiteSectorModelViewSet):
status.HTTP_400_BAD_REQUEST,
request
)
from rest_framework.permissions import AllowAny
@action(detail=False, methods=['post'], url_path='test-connection', permission_classes=[AllowAny])
def test_connection_collection(self, request):
"""
Collection-level test connection endpoint for frontend convenience.
POST /api/v1/integration/integrations/test-connection/
Body:
{
"site_id": 123,
"api_key": "...",
"site_url": "https://example.com"
}
"""
site_id = request.data.get('site_id')
api_key = request.data.get('api_key')
site_url = request.data.get('site_url')
if not site_id:
return error_response('site_id is required', status.HTTP_400_BAD_REQUEST, request)
# Verify site exists
from igny8_core.auth.models import Site
try:
site = Site.objects.get(id=int(site_id))
except (Site.DoesNotExist, ValueError, TypeError):
return error_response('Site not found or invalid', status.HTTP_404_NOT_FOUND, request)
# Authentication: accept either authenticated user OR matching API key in body
api_key = request.data.get('api_key') or api_key
authenticated = False
# If request has a valid user and belongs to same account, allow
if hasattr(request, 'user') and getattr(request.user, 'is_authenticated', False):
try:
# If user has account, ensure site belongs to user's account
if site.account == request.user.account:
authenticated = True
except Exception:
# Ignore and fallback to api_key check
pass
# If not authenticated via session, allow if provided api_key matches site's stored wp_api_key
if not authenticated:
stored_key = getattr(site, 'wp_api_key', None)
if stored_key and api_key and str(api_key) == str(stored_key):
authenticated = True
if not authenticated:
return error_response('Authentication credentials were not provided.', status.HTTP_403_FORBIDDEN, request)
# Try to find an existing integration for this site+platform
integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first()
# If not found, create a temporary in-memory integration object
if not integration:
integration = SiteIntegration(
site=site,
platform='wordpress',
config_json={'site_url': site_url} if site_url else {},
credentials_json={'api_key': api_key} if api_key else {},
is_active=False
)
service = IntegrationService()
result = service.test_connection(integration)
if result.get('success'):
return success_response(result, request=request)
else:
return error_response(result.get('message', 'Connection test failed'), status.HTTP_400_BAD_REQUEST, request)
@action(detail=True, methods=['post'])
def sync(self, request, pk=None):

View File

@@ -59,10 +59,28 @@ class KeywordsAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
@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']
list_display = ['idea_title', 'site', 'sector', 'description_preview', 'site_entity_type', 'cluster_role', 'status', 'keyword_cluster', 'estimated_word_count', 'created_at']
list_filter = ['status', 'site_entity_type', 'cluster_role', 'site', 'sector']
search_fields = ['idea_title', 'target_keywords', 'description']
ordering = ['-created_at']
readonly_fields = ['content_structure', 'content_type']
fieldsets = (
('Basic Info', {
'fields': ('idea_title', 'description', 'status', 'site', 'sector')
}),
('Content Planning', {
'fields': ('site_entity_type', 'cluster_role', 'estimated_word_count')
}),
('Keywords & Clustering', {
'fields': ('keyword_cluster', 'target_keywords', 'taxonomy')
}),
('Deprecated Fields (Read-Only)', {
'fields': ('content_structure', 'content_type'),
'classes': ('collapse',),
'description': 'These fields are deprecated. Use site_entity_type and cluster_role instead.'
}),
)
def description_preview(self, obj):
"""Show a truncated preview of the description"""

View File

@@ -926,8 +926,8 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
ordering_fields = ['idea_title', 'created_at', 'estimated_word_count']
ordering = ['-created_at'] # Default ordering (newest first)
# Filter configuration
filterset_fields = ['status', 'keyword_cluster_id', 'content_structure', 'content_type']
# Filter configuration (updated for new structure)
filterset_fields = ['status', 'keyword_cluster_id', 'site_entity_type', 'cluster_role']
def perform_create(self, serializer):
"""Require explicit site_id and sector_id - no defaults."""

View File

@@ -1,14 +1,30 @@
from django.contrib import admin
from igny8_core.admin.base import SiteSectorAdminMixin
from .models import Tasks, Images, Content
from igny8_core.business.content.models import ContentTaxonomy, ContentAttribute
@admin.register(Tasks)
class TasksAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
list_display = ['title', 'site', 'sector', 'status', 'cluster', 'content_type', 'word_count', 'created_at']
list_filter = ['status', 'content_type', 'content_structure', 'site', 'sector']
search_fields = ['title', 'keywords']
list_display = ['title', 'site', 'sector', 'status', 'cluster', 'created_at']
list_filter = ['status', 'site', 'sector', 'cluster']
search_fields = ['title', 'description', 'keywords']
ordering = ['-created_at']
readonly_fields = ['content_type', 'content_structure', 'entity_type', 'cluster_role', 'assigned_post_id', 'post_url']
fieldsets = (
('Basic Info', {
'fields': ('title', 'description', 'status', 'site', 'sector')
}),
('Planning', {
'fields': ('cluster', 'idea', 'keywords')
}),
('Deprecated Fields (Read-Only)', {
'fields': ('content_type', 'content_structure', 'entity_type', 'cluster_role', 'assigned_post_id', 'post_url'),
'classes': ('collapse',),
'description': 'These fields are deprecated. Use Content model instead.'
}),
)
def get_site_display(self, obj):
"""Safely get site name"""
@@ -68,10 +84,39 @@ class ImagesAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
@admin.register(Content)
class ContentAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
list_display = ['task', 'site', 'sector', 'word_count', 'generated_at', 'updated_at']
list_filter = ['generated_at', 'site', 'sector']
search_fields = ['task__title']
list_display = ['title', 'entity_type', 'content_format', 'cluster_role', 'site', 'sector', 'source', 'sync_status', 'word_count', 'generated_at']
list_filter = ['entity_type', 'content_format', 'cluster_role', 'source', 'sync_status', 'status', 'site', 'sector', 'generated_at']
search_fields = ['title', 'meta_title', 'primary_keyword', 'task__title', 'external_url']
ordering = ['-generated_at']
readonly_fields = ['categories', 'tags']
fieldsets = (
('Basic Info', {
'fields': ('title', 'task', 'site', 'sector', 'cluster', 'status')
}),
('Content Classification', {
'fields': ('entity_type', 'content_format', 'cluster_role', 'external_type')
}),
('Content', {
'fields': ('html_content', 'word_count', 'json_blocks', 'structure_data')
}),
('SEO', {
'fields': ('meta_title', 'meta_description', 'primary_keyword', 'secondary_keywords')
}),
('WordPress Sync', {
'fields': ('source', 'sync_status', 'external_id', 'external_url', 'sync_metadata'),
'classes': ('collapse',)
}),
('Optimization', {
'fields': ('linker_version', 'optimizer_version', 'optimization_scores', 'internal_links'),
'classes': ('collapse',)
}),
('Deprecated Fields (Read-Only)', {
'fields': ('categories', 'tags'),
'classes': ('collapse',),
'description': 'These fields are deprecated. Use taxonomies M2M instead.'
}),
)
def get_site_display(self, obj):
"""Safely get site name"""
@@ -88,3 +133,58 @@ class ContentAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
except:
return '-'
@admin.register(ContentTaxonomy)
class ContentTaxonomyAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
list_display = ['name', 'taxonomy_type', 'slug', 'parent', 'external_id', 'external_taxonomy', 'sync_status', 'count', 'site', 'sector']
list_filter = ['taxonomy_type', 'sync_status', 'site', 'sector', 'parent']
search_fields = ['name', 'slug', 'description', 'external_taxonomy']
ordering = ['taxonomy_type', 'name']
filter_horizontal = ['clusters']
fieldsets = (
('Basic Info', {
'fields': ('name', 'slug', 'taxonomy_type', 'description', 'site', 'sector')
}),
('Hierarchy', {
'fields': ('parent',),
'description': 'Set parent for hierarchical taxonomies (categories).'
}),
('WordPress Sync', {
'fields': ('external_id', 'external_taxonomy', 'sync_status', 'count', 'metadata')
}),
('Semantic Mapping', {
'fields': ('clusters',),
'description': 'Map this taxonomy to semantic clusters for AI optimization.'
}),
)
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('parent', 'site', 'sector').prefetch_related('clusters')
@admin.register(ContentAttribute)
class ContentAttributeAdmin(SiteSectorAdminMixin, admin.ModelAdmin):
list_display = ['name', 'value', 'attribute_type', 'content', 'cluster', 'external_id', 'source', 'site', 'sector']
list_filter = ['attribute_type', 'source', 'site', 'sector']
search_fields = ['name', 'value', 'external_attribute_name', 'content__title']
ordering = ['attribute_type', 'name']
fieldsets = (
('Basic Info', {
'fields': ('attribute_type', 'name', 'value', 'site', 'sector')
}),
('Relationships', {
'fields': ('content', 'cluster'),
'description': 'Link to content (products/services) or cluster (semantic attributes).'
}),
('WordPress/WooCommerce Sync', {
'fields': ('external_id', 'external_attribute_name', 'source', 'metadata')
}),
)
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related('content', 'cluster', 'site', 'sector')

View File

@@ -0,0 +1,226 @@
# Generated by Django 5.2.8 on 2025-11-21 17:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('igny8_core_auth', '0002_add_wp_api_key_to_site'),
('planner', '0002_initial'),
('writer', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='contentattributemap',
name='account',
),
migrations.RemoveField(
model_name='contentattributemap',
name='content',
),
migrations.RemoveField(
model_name='contentattributemap',
name='sector',
),
migrations.RemoveField(
model_name='contentattributemap',
name='site',
),
migrations.RemoveField(
model_name='contentattributemap',
name='task',
),
migrations.AddField(
model_name='content',
name='cluster',
field=models.ForeignKey(blank=True, help_text='Primary semantic cluster', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contents', to='planner.clusters'),
),
migrations.AddField(
model_name='content',
name='cluster_role',
field=models.CharField(blank=True, choices=[('hub', 'Hub Page'), ('supporting', 'Supporting Content'), ('attribute', 'Attribute Page')], db_index=True, default='supporting', help_text='Role within cluster strategy', max_length=50, null=True),
),
migrations.AddField(
model_name='content',
name='content_format',
field=models.CharField(blank=True, choices=[('article', 'Article'), ('listicle', 'Listicle'), ('guide', 'How-To Guide'), ('comparison', 'Comparison'), ('review', 'Review'), ('roundup', 'Roundup')], db_index=True, help_text='Content format (only for entity_type=post)', max_length=50, null=True),
),
migrations.AddField(
model_name='content',
name='external_type',
field=models.CharField(blank=True, help_text='WordPress post type (post, page, product, service)', max_length=100),
),
migrations.AlterField(
model_name='content',
name='entity_type',
field=models.CharField(choices=[('post', 'Blog Post'), ('page', 'Page'), ('product', 'Product'), ('service', 'Service Page'), ('taxonomy_term', 'Taxonomy Term Page'), ('blog_post', 'Blog Post (Legacy)'), ('article', 'Article (Legacy)'), ('taxonomy', 'Taxonomy Page (Legacy)')], db_index=True, default='post', help_text='Type of content entity', max_length=50),
),
migrations.CreateModel(
name='ContentAttribute',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attribute_type', models.CharField(choices=[('product_spec', 'Product Specification'), ('service_modifier', 'Service Modifier'), ('semantic_facet', 'Semantic Facet')], db_index=True, default='product_spec', help_text='Type of attribute', max_length=50)),
('name', models.CharField(help_text='Attribute name (e.g., Color, Material)', max_length=120)),
('value', models.CharField(blank=True, help_text='Attribute value (e.g., Blue, Cotton)', max_length=255, null=True)),
('external_id', models.IntegerField(blank=True, help_text='WP attribute term ID', null=True)),
('external_attribute_name', models.CharField(blank=True, help_text='WP attribute slug (e.g., pa_color, pa_size)', max_length=100)),
('source', models.CharField(choices=[('blueprint', 'Blueprint'), ('manual', 'Manual'), ('import', 'Import'), ('wordpress', 'WordPress')], default='manual', max_length=50)),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional metadata')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
('cluster', models.ForeignKey(blank=True, help_text='Optional cluster association for semantic attributes', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='attributes', to='planner.clusters')),
('content', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attributes', to='writer.content')),
('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')),
('task', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attribute_mappings', to='writer.tasks')),
],
options={
'verbose_name': 'Content Attribute',
'verbose_name_plural': 'Content Attributes',
'db_table': 'igny8_content_attributes',
},
),
migrations.CreateModel(
name='ContentTaxonomy',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(db_index=True, help_text='Term name', max_length=255)),
('slug', models.SlugField(help_text='URL slug', max_length=255)),
('taxonomy_type', models.CharField(choices=[('category', 'Category'), ('tag', 'Tag'), ('product_cat', 'Product Category'), ('product_tag', 'Product Tag'), ('product_attr', 'Product Attribute'), ('service_cat', 'Service Category')], db_index=True, help_text='Type of taxonomy', max_length=50)),
('description', models.TextField(blank=True, help_text='Term description')),
('external_id', models.IntegerField(blank=True, db_index=True, help_text='WordPress term ID', null=True)),
('external_taxonomy', models.CharField(blank=True, help_text='WP taxonomy name (category, post_tag, product_cat, pa_color)', max_length=100)),
('sync_status', models.CharField(choices=[('native', 'Native IGNY8'), ('imported', 'Imported from External'), ('synced', 'Synced with External')], db_index=True, default='native', help_text='Sync status with external system', max_length=50)),
('count', models.IntegerField(default=0, help_text='Post/product count from WordPress')),
('metadata', models.JSONField(blank=True, default=dict, help_text='Additional metadata')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
('clusters', models.ManyToManyField(blank=True, help_text='Semantic clusters this term maps to', related_name='taxonomy_terms', to='planner.clusters')),
('parent', models.ForeignKey(blank=True, help_text='Parent term for hierarchical taxonomies', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='writer.contenttaxonomy')),
('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')),
],
options={
'verbose_name': 'Content Taxonomy',
'verbose_name_plural': 'Content Taxonomies',
'db_table': 'igny8_content_taxonomy_terms',
},
),
migrations.CreateModel(
name='ContentTaxonomyRelation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('updated_at', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('account', models.ForeignKey(db_column='tenant_id', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='igny8_core_auth.account')),
('content', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='taxonomy_relations', to='writer.content')),
('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')),
('taxonomy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content_relations', to='writer.contenttaxonomy')),
],
options={
'db_table': 'igny8_content_taxonomy_relations',
},
),
migrations.AddField(
model_name='content',
name='taxonomies',
field=models.ManyToManyField(blank=True, help_text='Associated taxonomy terms (categories, tags, attributes)', related_name='contents', through='writer.ContentTaxonomyRelation', to='writer.contenttaxonomy'),
),
migrations.AddIndex(
model_name='content',
index=models.Index(fields=['content_format'], name='igny8_conte_content_b538ee_idx'),
),
migrations.AddIndex(
model_name='content',
index=models.Index(fields=['cluster_role'], name='igny8_conte_cluster_32e22a_idx'),
),
migrations.AddIndex(
model_name='content',
index=models.Index(fields=['cluster'], name='igny8_conte_cluster_e545d1_idx'),
),
migrations.AddIndex(
model_name='content',
index=models.Index(fields=['external_type'], name='igny8_conte_externa_a26125_idx'),
),
migrations.AddIndex(
model_name='content',
index=models.Index(fields=['site', 'entity_type'], name='igny8_conte_site_id_e559d5_idx'),
),
migrations.DeleteModel(
name='ContentAttributeMap',
),
migrations.AddIndex(
model_name='contentattribute',
index=models.Index(fields=['name'], name='igny8_conte_name_bacaae_idx'),
),
migrations.AddIndex(
model_name='contentattribute',
index=models.Index(fields=['attribute_type'], name='igny8_conte_attribu_5d6f12_idx'),
),
migrations.AddIndex(
model_name='contentattribute',
index=models.Index(fields=['content', 'name'], name='igny8_conte_content_6c7c68_idx'),
),
migrations.AddIndex(
model_name='contentattribute',
index=models.Index(fields=['content', 'attribute_type'], name='igny8_conte_content_91df40_idx'),
),
migrations.AddIndex(
model_name='contentattribute',
index=models.Index(fields=['cluster', 'attribute_type'], name='igny8_conte_cluster_1f91b7_idx'),
),
migrations.AddIndex(
model_name='contentattribute',
index=models.Index(fields=['external_id'], name='igny8_conte_externa_0bf0e8_idx'),
),
migrations.AddIndex(
model_name='contenttaxonomy',
index=models.Index(fields=['name'], name='igny8_conte_name_f35eea_idx'),
),
migrations.AddIndex(
model_name='contenttaxonomy',
index=models.Index(fields=['slug'], name='igny8_conte_slug_65c0a2_idx'),
),
migrations.AddIndex(
model_name='contenttaxonomy',
index=models.Index(fields=['taxonomy_type'], name='igny8_conte_taxonom_04e1c2_idx'),
),
migrations.AddIndex(
model_name='contenttaxonomy',
index=models.Index(fields=['sync_status'], name='igny8_conte_sync_st_307b43_idx'),
),
migrations.AddIndex(
model_name='contenttaxonomy',
index=models.Index(fields=['external_id', 'external_taxonomy'], name='igny8_conte_externa_15861e_idx'),
),
migrations.AddIndex(
model_name='contenttaxonomy',
index=models.Index(fields=['site', 'taxonomy_type'], name='igny8_conte_site_id_6f84b7_idx'),
),
migrations.AddIndex(
model_name='contenttaxonomy',
index=models.Index(fields=['site', 'sector'], name='igny8_conte_site_id_9dddc7_idx'),
),
migrations.AlterUniqueTogether(
name='contenttaxonomy',
unique_together={('site', 'external_id', 'external_taxonomy'), ('site', 'slug', 'taxonomy_type')},
),
migrations.AddIndex(
model_name='contenttaxonomyrelation',
index=models.Index(fields=['content'], name='igny8_conte_content_a897e5_idx'),
),
migrations.AddIndex(
model_name='contenttaxonomyrelation',
index=models.Index(fields=['taxonomy'], name='igny8_conte_taxonom_7091e0_idx'),
),
migrations.AlterUniqueTogether(
name='contenttaxonomyrelation',
unique_together={('content', 'taxonomy')},
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.2.8 on 2025-11-21 17:50
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('writer', '0002_phase1_add_unified_taxonomy_and_attributes'),
]
operations = [
migrations.RemoveField(
model_name='contenttaxonomyrelation',
name='account',
),
migrations.RemoveField(
model_name='contenttaxonomyrelation',
name='sector',
),
migrations.RemoveField(
model_name='contenttaxonomyrelation',
name='site',
),
]

View File

@@ -0,0 +1,181 @@
# Generated migration for Phase 2 data migration
from django.db import migrations
def migrate_content_entity_types(apps, schema_editor):
"""Migrate legacy entity_type values to new unified structure"""
Content = apps.get_model('writer', 'Content')
# Map legacy entity types to new structure
legacy_mapping = {
'blog_post': ('post', 'article'),
'article': ('post', 'article'),
'taxonomy': ('taxonomy_term', None),
}
for content in Content.objects.all():
old_type = content.entity_type
if old_type in legacy_mapping:
new_type, format_type = legacy_mapping[old_type]
content.entity_type = new_type
if format_type and new_type == 'post':
content.content_format = format_type
content.save(update_fields=['entity_type', 'content_format'])
def migrate_task_entity_types(apps, schema_editor):
"""Migrate task entity_type to content when task has associated content"""
Content = apps.get_model('writer', 'Content')
Tasks = apps.get_model('writer', 'Tasks')
for task in Tasks.objects.filter(entity_type__isnull=False):
try:
content = Content.objects.get(task=task)
# Map task entity_type to content
if task.entity_type == 'blog_post':
content.entity_type = 'post'
content.content_format = 'article'
elif task.entity_type == 'article':
content.entity_type = 'post'
content.content_format = 'article'
elif task.entity_type == 'product':
content.entity_type = 'product'
elif task.entity_type == 'service':
content.entity_type = 'service'
elif task.entity_type == 'taxonomy':
content.entity_type = 'taxonomy_term'
elif task.entity_type == 'page':
content.entity_type = 'page'
# Migrate cluster_role from task
if task.cluster_role:
content.cluster_role = task.cluster_role
# Migrate cluster relationship
if task.cluster_id:
content.cluster_id = task.cluster_id
content.save()
except Content.DoesNotExist:
pass
def migrate_content_categories_tags_to_taxonomy(apps, schema_editor):
"""Migrate JSON categories and tags to ContentTaxonomy M2M"""
Content = apps.get_model('writer', 'Content')
ContentTaxonomy = apps.get_model('writer', 'ContentTaxonomy')
ContentTaxonomyRelation = apps.get_model('writer', 'ContentTaxonomyRelation')
for content in Content.objects.all():
# Skip if no categories or tags
if not content.categories and not content.tags:
continue
# Migrate categories (stored as JSON list)
if content.categories:
for category_name in content.categories:
if isinstance(category_name, str) and category_name.strip():
# Get or create taxonomy term
taxonomy, created = ContentTaxonomy.objects.get_or_create(
site=content.site,
slug=category_name.lower().replace(' ', '-')[:255],
taxonomy_type='category',
defaults={
'name': category_name[:255],
'account': content.account,
'sector': content.sector,
'sync_status': 'native',
}
)
# Create relation manually
ContentTaxonomyRelation.objects.get_or_create(
content=content,
taxonomy=taxonomy
)
# Migrate tags (stored as JSON list)
if content.tags:
for tag_name in content.tags:
if isinstance(tag_name, str) and tag_name.strip():
taxonomy, created = ContentTaxonomy.objects.get_or_create(
site=content.site,
slug=tag_name.lower().replace(' ', '-')[:255],
taxonomy_type='tag',
defaults={
'name': tag_name[:255],
'account': content.account,
'sector': content.sector,
'sync_status': 'native',
}
)
# Create relation manually
ContentTaxonomyRelation.objects.get_or_create(
content=content,
taxonomy=taxonomy
)
def migrate_blueprint_taxonomies(apps, schema_editor):
"""Migrate SiteBlueprintTaxonomy to ContentTaxonomy"""
try:
SiteBlueprintTaxonomy = apps.get_model('site_building', 'SiteBlueprintTaxonomy')
ContentTaxonomy = apps.get_model('writer', 'ContentTaxonomy')
taxonomy_type_mapping = {
'blog_category': 'category',
'blog_tag': 'tag',
'product_category': 'product_cat',
'product_tag': 'product_tag',
'product_attribute': 'product_attr',
'service_category': 'service_cat',
}
for bp_tax in SiteBlueprintTaxonomy.objects.all():
new_type = taxonomy_type_mapping.get(bp_tax.taxonomy_type, 'category')
# Create or update ContentTaxonomy
taxonomy, created = ContentTaxonomy.objects.update_or_create(
site=bp_tax.site,
slug=bp_tax.slug,
taxonomy_type=new_type,
defaults={
'name': bp_tax.name,
'description': bp_tax.description or '',
'account': bp_tax.account,
'sector': bp_tax.sector,
'external_id': int(bp_tax.external_reference) if bp_tax.external_reference and bp_tax.external_reference.isdigit() else None,
'sync_status': 'imported' if bp_tax.external_reference else 'native',
'metadata': bp_tax.metadata if hasattr(bp_tax, 'metadata') else {},
}
)
# Migrate cluster relationships
if hasattr(bp_tax, 'clusters'):
for cluster in bp_tax.clusters.all():
taxonomy.clusters.add(cluster)
except LookupError:
# SiteBlueprintTaxonomy model doesn't exist, skip
pass
def reverse_migrations(apps, schema_editor):
"""Reverse migration - not implemented as data loss is acceptable"""
pass
class Migration(migrations.Migration):
dependencies = [
('writer', '0003_phase1b_fix_taxonomy_relation'),
]
operations = [
migrations.RunPython(migrate_content_entity_types, reverse_migrations),
migrations.RunPython(migrate_task_entity_types, reverse_migrations),
migrations.RunPython(migrate_content_categories_tags_to_taxonomy, reverse_migrations),
migrations.RunPython(migrate_blueprint_taxonomies, reverse_migrations),
]

View File

@@ -0,0 +1,131 @@
# Generated migration for Phase 3 - Mark deprecated fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('writer', '0004_phase2_migrate_data_to_unified_structure'),
]
operations = [
# Keep deprecated fields for backward compatibility but mark them
# We'll remove them in a future migration after ensuring no dependencies
# Mark old Task fields as deprecated with help_text
migrations.AlterField(
model_name='tasks',
name='content',
field=models.TextField(
blank=True,
null=True,
help_text="DEPRECATED: Use Content model instead"
),
),
migrations.AlterField(
model_name='tasks',
name='word_count',
field=models.IntegerField(
default=0,
help_text="DEPRECATED: Use Content.word_count instead"
),
),
migrations.AlterField(
model_name='tasks',
name='meta_title',
field=models.CharField(
max_length=255,
blank=True,
null=True,
help_text="DEPRECATED: Use Content.meta_title instead"
),
),
migrations.AlterField(
model_name='tasks',
name='meta_description',
field=models.TextField(
blank=True,
null=True,
help_text="DEPRECATED: Use Content.meta_description instead"
),
),
migrations.AlterField(
model_name='tasks',
name='assigned_post_id',
field=models.IntegerField(
null=True,
blank=True,
help_text="DEPRECATED: Use Content.external_id instead"
),
),
migrations.AlterField(
model_name='tasks',
name='post_url',
field=models.URLField(
blank=True,
null=True,
help_text="DEPRECATED: Use Content.external_url instead"
),
),
migrations.AlterField(
model_name='tasks',
name='entity_type',
field=models.CharField(
max_length=50,
blank=True,
null=True,
db_index=True,
help_text="DEPRECATED: Use Content.entity_type instead"
),
),
migrations.AlterField(
model_name='tasks',
name='cluster_role',
field=models.CharField(
max_length=50,
blank=True,
null=True,
help_text="DEPRECATED: Use Content.cluster_role instead"
),
),
migrations.AlterField(
model_name='tasks',
name='content_structure',
field=models.CharField(
max_length=50,
default='blog_post',
help_text="DEPRECATED: Merged into Content.content_format"
),
),
migrations.AlterField(
model_name='tasks',
name='content_type',
field=models.CharField(
max_length=50,
default='blog_post',
help_text="DEPRECATED: Merged into Content.entity_type + content_format"
),
),
# Mark old Content fields as deprecated
migrations.AlterField(
model_name='content',
name='categories',
field=models.JSONField(
default=list,
blank=True,
help_text="DEPRECATED: Use Content.taxonomies M2M instead"
),
),
migrations.AlterField(
model_name='content',
name='tags',
field=models.JSONField(
default=list,
blank=True,
help_text="DEPRECATED: Use Content.taxonomies M2M instead"
),
),
]

View File

@@ -7,8 +7,12 @@ from igny8_core.business.planning.models import Clusters, ContentIdeas
from igny8_core.business.content.models import (
ContentClusterMap,
ContentTaxonomyMap,
ContentAttributeMap,
ContentAttribute,
ContentTaxonomy,
ContentTaxonomyRelation,
)
# Backward compatibility
ContentAttributeMap = ContentAttribute
class TasksSerializer(serializers.ModelSerializer):
@@ -351,13 +355,120 @@ class ContentSerializer(serializers.ModelSerializer):
return results
def get_attribute_mappings(self, obj):
mappings = ContentAttributeMap.objects.filter(content=obj)
mappings = ContentAttribute.objects.filter(content=obj)
results = []
for mapping in mappings:
results.append({
'name': mapping.name,
'value': mapping.value,
'attribute_type': mapping.attribute_type,
'source': mapping.source,
'external_id': mapping.external_id,
})
return results
class ContentTaxonomySerializer(serializers.ModelSerializer):
"""Serializer for ContentTaxonomy model"""
parent_name = serializers.SerializerMethodField()
cluster_names = serializers.SerializerMethodField()
content_count = serializers.SerializerMethodField()
class Meta:
model = ContentTaxonomy
fields = [
'id',
'name',
'slug',
'taxonomy_type',
'description',
'parent',
'parent_name',
'external_id',
'external_taxonomy',
'sync_status',
'count',
'metadata',
'cluster_names',
'content_count',
'site_id',
'sector_id',
'account_id',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id']
def get_parent_name(self, obj):
return obj.parent.name if obj.parent else None
def get_cluster_names(self, obj):
return [cluster.name for cluster in obj.clusters.all()]
def get_content_count(self, obj):
return obj.contents.count()
class ContentAttributeSerializer(serializers.ModelSerializer):
"""Serializer for ContentAttribute model"""
content_title = serializers.SerializerMethodField()
cluster_name = serializers.SerializerMethodField()
class Meta:
model = ContentAttribute
fields = [
'id',
'content',
'content_title',
'cluster',
'cluster_name',
'attribute_type',
'name',
'value',
'external_id',
'external_attribute_name',
'source',
'metadata',
'site_id',
'sector_id',
'account_id',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id']
def get_content_title(self, obj):
return obj.content.title if obj.content else None
def get_cluster_name(self, obj):
return obj.cluster.name if obj.cluster else None
class ContentTaxonomyRelationSerializer(serializers.ModelSerializer):
"""Serializer for ContentTaxonomyRelation (M2M through model)"""
content_title = serializers.SerializerMethodField()
taxonomy_name = serializers.SerializerMethodField()
taxonomy_type = serializers.SerializerMethodField()
class Meta:
model = ContentTaxonomyRelation
fields = [
'id',
'content',
'content_title',
'taxonomy',
'taxonomy_name',
'taxonomy_type',
'created_at',
]
read_only_fields = ['id', 'created_at']
def get_content_title(self, obj):
return obj.content.title if obj.content else None
def get_taxonomy_name(self, obj):
return obj.taxonomy.name if obj.taxonomy else None
def get_taxonomy_type(self, obj):
return obj.taxonomy.taxonomy_type if obj.taxonomy else None

View File

@@ -1,11 +1,19 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import TasksViewSet, ImagesViewSet, ContentViewSet
from .views import (
TasksViewSet,
ImagesViewSet,
ContentViewSet,
ContentTaxonomyViewSet,
ContentAttributeViewSet,
)
router = DefaultRouter()
router.register(r'tasks', TasksViewSet, basename='task')
router.register(r'images', ImagesViewSet, basename='image')
router.register(r'content', ContentViewSet, basename='content')
router.register(r'taxonomies', ContentTaxonomyViewSet, basename='taxonomy')
router.register(r'attributes', ContentAttributeViewSet, basename='attribute')
urlpatterns = [
path('', include(router.urls)),

View File

@@ -11,7 +11,14 @@ from igny8_core.api.response import success_response, error_response
from igny8_core.api.throttles import DebugScopedRateThrottle
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsViewerOrAbove, IsEditorOrAbove
from .models import Tasks, Images, Content
from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer
from .serializers import (
TasksSerializer,
ImagesSerializer,
ContentSerializer,
ContentTaxonomySerializer,
ContentAttributeSerializer,
)
from igny8_core.business.content.models import ContentTaxonomy, ContentAttribute
from igny8_core.business.content.services.content_generation_service import ContentGenerationService
from igny8_core.business.content.services.validation_service import ContentValidationService
from igny8_core.business.content.services.metadata_mapping_service import MetadataMappingService
@@ -48,8 +55,8 @@ class TasksViewSet(SiteSectorModelViewSet):
ordering_fields = ['title', 'created_at', 'word_count', 'status']
ordering = ['-created_at'] # Default ordering (newest first)
# Filter configuration
filterset_fields = ['status', 'cluster_id', 'content_type', 'content_structure']
# Filter configuration (removed deprecated fields)
filterset_fields = ['status', 'cluster_id']
def perform_create(self, serializer):
"""Require explicit site_id and sector_id - no defaults."""
@@ -748,10 +755,10 @@ class ImagesViewSet(SiteSectorModelViewSet):
)
class ContentViewSet(SiteSectorModelViewSet):
"""
ViewSet for managing task content
ViewSet for managing content with new unified structure
Unified API Standard v1.0 compliant
"""
queryset = Content.objects.all()
queryset = Content.objects.select_related('task', 'site', 'sector', 'cluster').prefetch_related('taxonomies', 'attributes')
serializer_class = ContentSerializer
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
pagination_class = CustomPageNumberPagination
@@ -759,10 +766,20 @@ class ContentViewSet(SiteSectorModelViewSet):
throttle_classes = [DebugScopedRateThrottle]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
search_fields = ['title', 'meta_title', 'primary_keyword']
ordering_fields = ['generated_at', 'updated_at', 'word_count', 'status']
search_fields = ['title', 'meta_title', 'primary_keyword', 'external_url']
ordering_fields = ['generated_at', 'updated_at', 'word_count', 'status', 'entity_type', 'content_format']
ordering = ['-generated_at']
filterset_fields = ['task_id', 'status']
filterset_fields = [
'task_id',
'status',
'entity_type',
'content_format',
'cluster_role',
'source',
'sync_status',
'cluster',
'external_type',
]
def perform_create(self, serializer):
"""Override to automatically set account"""
@@ -1345,6 +1362,210 @@ class ContentViewSet(SiteSectorModelViewSet):
def _has_taxonomy_mapping(self, content):
"""Helper to check if content has taxonomy mapping"""
from igny8_core.business.content.models import ContentTaxonomyMap
return ContentTaxonomyMap.objects.filter(content=content).exists()
# Check new M2M relationship
return content.taxonomies.exists()
@extend_schema_view(
list=extend_schema(tags=['Writer - Taxonomies']),
create=extend_schema(tags=['Writer - Taxonomies']),
retrieve=extend_schema(tags=['Writer - Taxonomies']),
update=extend_schema(tags=['Writer - Taxonomies']),
partial_update=extend_schema(tags=['Writer - Taxonomies']),
destroy=extend_schema(tags=['Writer - Taxonomies']),
)
class ContentTaxonomyViewSet(SiteSectorModelViewSet):
"""
ViewSet for managing content taxonomies (categories, tags, product attributes)
Unified API Standard v1.0 compliant
"""
queryset = ContentTaxonomy.objects.select_related('parent', 'site', 'sector').prefetch_related('clusters', 'contents')
serializer_class = ContentTaxonomySerializer
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
pagination_class = CustomPageNumberPagination
throttle_scope = 'writer'
throttle_classes = [DebugScopedRateThrottle]
# DRF filtering configuration
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
# Search configuration
search_fields = ['name', 'slug', 'description', 'external_taxonomy']
# Ordering configuration
ordering_fields = ['name', 'taxonomy_type', 'count', 'created_at']
ordering = ['taxonomy_type', 'name']
# Filter configuration
filterset_fields = ['taxonomy_type', 'sync_status', 'parent', 'external_id', 'external_taxonomy']
def perform_create(self, serializer):
"""Create taxonomy with site/sector context"""
user = getattr(self.request, 'user', None)
try:
query_params = getattr(self.request, 'query_params', None)
if query_params is None:
query_params = getattr(self.request, 'GET', {})
except AttributeError:
query_params = {}
site_id = serializer.validated_data.get('site_id') or query_params.get('site_id')
sector_id = serializer.validated_data.get('sector_id') or query_params.get('sector_id')
from igny8_core.auth.models import Site, Sector
from rest_framework.exceptions import ValidationError
if not site_id:
raise ValidationError("site_id is required")
try:
site = Site.objects.get(id=site_id)
except Site.DoesNotExist:
raise ValidationError(f"Site with id {site_id} does not exist")
if not sector_id:
raise ValidationError("sector_id is required")
try:
sector = Sector.objects.get(id=sector_id)
if sector.site_id != site_id:
raise ValidationError(f"Sector does not belong to the selected site")
except Sector.DoesNotExist:
raise ValidationError(f"Sector with id {sector_id} does not exist")
serializer.validated_data.pop('site_id', None)
serializer.validated_data.pop('sector_id', None)
account = getattr(self.request, 'account', None)
if not account and user and user.is_authenticated and user.account:
account = user.account
if not account:
account = site.account
serializer.save(account=account, site=site, sector=sector)
@action(detail=True, methods=['post'], permission_classes=[IsAuthenticatedAndActive, IsEditorOrAbove])
def map_to_cluster(self, request, pk=None):
"""Map taxonomy to semantic cluster"""
taxonomy = self.get_object()
cluster_id = request.data.get('cluster_id')
if not cluster_id:
return error_response(
error="cluster_id is required",
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
from igny8_core.business.planning.models import Clusters
try:
cluster = Clusters.objects.get(id=cluster_id, site=taxonomy.site)
taxonomy.clusters.add(cluster)
return success_response(
data={'message': f'Taxonomy "{taxonomy.name}" mapped to cluster "{cluster.name}"'},
message="Taxonomy mapped to cluster successfully",
request=request
)
except Clusters.DoesNotExist:
return error_response(
error=f"Cluster with id {cluster_id} not found",
status_code=status.HTTP_404_NOT_FOUND,
request=request
)
@action(detail=True, methods=['get'])
def contents(self, request, pk=None):
"""Get all content associated with this taxonomy"""
taxonomy = self.get_object()
contents = taxonomy.contents.all()
serializer = ContentSerializer(contents, many=True, context={'request': request})
return success_response(
data=serializer.data,
message=f"Found {contents.count()} content items for taxonomy '{taxonomy.name}'",
request=request
)
@extend_schema_view(
list=extend_schema(tags=['Writer - Attributes']),
create=extend_schema(tags=['Writer - Attributes']),
retrieve=extend_schema(tags=['Writer - Attributes']),
update=extend_schema(tags=['Writer - Attributes']),
partial_update=extend_schema(tags=['Writer - Attributes']),
destroy=extend_schema(tags=['Writer - Attributes']),
)
class ContentAttributeViewSet(SiteSectorModelViewSet):
"""
ViewSet for managing content attributes (product specs, service modifiers, semantic facets)
Unified API Standard v1.0 compliant
"""
queryset = ContentAttribute.objects.select_related('content', 'cluster', 'site', 'sector')
serializer_class = ContentAttributeSerializer
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
pagination_class = CustomPageNumberPagination
throttle_scope = 'writer'
throttle_classes = [DebugScopedRateThrottle]
# DRF filtering configuration
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
# Search configuration
search_fields = ['name', 'value', 'external_attribute_name', 'content__title']
# Ordering configuration
ordering_fields = ['name', 'attribute_type', 'created_at']
ordering = ['attribute_type', 'name']
# Filter configuration
filterset_fields = ['attribute_type', 'source', 'content', 'cluster', 'external_id']
def perform_create(self, serializer):
"""Create attribute with site/sector context"""
user = getattr(self.request, 'user', None)
try:
query_params = getattr(self.request, 'query_params', None)
if query_params is None:
query_params = getattr(self.request, 'GET', {})
except AttributeError:
query_params = {}
site_id = serializer.validated_data.get('site_id') or query_params.get('site_id')
sector_id = serializer.validated_data.get('sector_id') or query_params.get('sector_id')
from igny8_core.auth.models import Site, Sector
from rest_framework.exceptions import ValidationError
if not site_id:
raise ValidationError("site_id is required")
try:
site = Site.objects.get(id=site_id)
except Site.DoesNotExist:
raise ValidationError(f"Site with id {site_id} does not exist")
if not sector_id:
raise ValidationError("sector_id is required")
try:
sector = Sector.objects.get(id=sector_id)
if sector.site_id != site_id:
raise ValidationError(f"Sector does not belong to the selected site")
except Sector.DoesNotExist:
raise ValidationError(f"Sector with id {sector_id} does not exist")
serializer.validated_data.pop('site_id', None)
serializer.validated_data.pop('sector_id', None)
account = getattr(self.request, 'account', None)
if not account and user and user.is_authenticated and user.account:
account = user.account
if not account:
account = site.account
serializer.save(account=account, site=site, sector=sector)