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:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user