stage 1
This commit is contained in:
@@ -8,71 +8,45 @@ class Tasks(SiteSectorBaseModel):
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('queued', 'Queued'),
|
||||
('in_progress', 'In Progress'),
|
||||
('completed', 'Completed'),
|
||||
('failed', 'Failed'),
|
||||
]
|
||||
|
||||
ENTITY_TYPE_CHOICES = [
|
||||
('post', 'Post'),
|
||||
('page', 'Page'),
|
||||
('product', 'Product'),
|
||||
('service', 'Service'),
|
||||
('taxonomy_term', 'Taxonomy Term'),
|
||||
]
|
||||
|
||||
CLUSTER_ROLE_CHOICES = [
|
||||
('hub', 'Hub'),
|
||||
('supporting', 'Supporting'),
|
||||
('attribute', 'Attribute'),
|
||||
]
|
||||
|
||||
title = models.CharField(max_length=255, db_index=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
keywords = models.CharField(max_length=500, blank=True) # Comma-separated keywords (legacy)
|
||||
cluster = models.ForeignKey(
|
||||
'planner.Clusters',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=False,
|
||||
related_name='tasks',
|
||||
limit_choices_to={'sector': models.F('sector')},
|
||||
help_text="Parent cluster (required)"
|
||||
)
|
||||
content_type = models.CharField(
|
||||
max_length=100,
|
||||
db_index=True,
|
||||
help_text="Content type: post, page, product, service, category, tag, etc."
|
||||
)
|
||||
content_structure = models.CharField(
|
||||
max_length=100,
|
||||
db_index=True,
|
||||
help_text="Content structure/format: article, listicle, guide, comparison, product_page, etc."
|
||||
)
|
||||
taxonomy_term = models.ForeignKey(
|
||||
'ContentTaxonomy',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='tasks',
|
||||
limit_choices_to={'sector': models.F('sector')}
|
||||
help_text="Optional taxonomy term assignment"
|
||||
)
|
||||
keyword_objects = models.ManyToManyField(
|
||||
keywords = models.ManyToManyField(
|
||||
'planner.Keywords',
|
||||
blank=True,
|
||||
related_name='tasks',
|
||||
help_text="Individual keywords linked to this task"
|
||||
)
|
||||
idea = models.ForeignKey(
|
||||
'planner.ContentIdeas',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='tasks'
|
||||
help_text="Keywords linked to this task"
|
||||
)
|
||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='queued')
|
||||
entity_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=ENTITY_TYPE_CHOICES,
|
||||
default='post',
|
||||
db_index=True,
|
||||
help_text="Type of content entity"
|
||||
)
|
||||
taxonomy = models.ForeignKey(
|
||||
'site_building.SiteBlueprintTaxonomy',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='tasks',
|
||||
help_text="Taxonomy association when derived from blueprint planning"
|
||||
)
|
||||
cluster_role = models.CharField(
|
||||
max_length=50,
|
||||
choices=CLUSTER_ROLE_CHOICES,
|
||||
default='hub',
|
||||
help_text="Role within the cluster-driven sitemap"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@@ -87,8 +61,8 @@ class Tasks(SiteSectorBaseModel):
|
||||
models.Index(fields=['title']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['cluster']),
|
||||
models.Index(fields=['entity_type']),
|
||||
models.Index(fields=['cluster_role']),
|
||||
models.Index(fields=['content_type']),
|
||||
models.Index(fields=['content_structure']),
|
||||
models.Index(fields=['site', 'sector']),
|
||||
]
|
||||
|
||||
@@ -98,224 +72,106 @@ class Tasks(SiteSectorBaseModel):
|
||||
|
||||
class Content(SiteSectorBaseModel):
|
||||
"""
|
||||
Content model for storing final AI-generated article content.
|
||||
Separated from Task for content versioning and storage optimization.
|
||||
Content model for AI-generated or WordPress-imported content.
|
||||
Final architecture: simplified content management.
|
||||
"""
|
||||
task = models.OneToOneField(
|
||||
Tasks,
|
||||
on_delete=models.CASCADE,
|
||||
|
||||
# Core content fields
|
||||
title = models.CharField(max_length=255, db_index=True)
|
||||
content_html = models.TextField(help_text="Final HTML content")
|
||||
cluster = models.ForeignKey(
|
||||
'planner.Clusters',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='content_record',
|
||||
help_text="The task this content belongs to"
|
||||
blank=False,
|
||||
related_name='contents',
|
||||
help_text="Parent cluster (required)"
|
||||
)
|
||||
content_type = models.CharField(
|
||||
max_length=100,
|
||||
db_index=True,
|
||||
help_text="Content type: post, page, product, service, category, tag, etc."
|
||||
)
|
||||
content_structure = models.CharField(
|
||||
max_length=100,
|
||||
db_index=True,
|
||||
help_text="Content structure/format: article, listicle, guide, comparison, product_page, etc."
|
||||
)
|
||||
html_content = models.TextField(help_text="Final AI-generated HTML content")
|
||||
word_count = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||
metadata = models.JSONField(default=dict, help_text="Additional metadata (SEO, structure, etc.)")
|
||||
title = models.CharField(max_length=255, blank=True, null=True)
|
||||
meta_title = models.CharField(max_length=255, blank=True, null=True)
|
||||
meta_description = models.TextField(blank=True, null=True)
|
||||
primary_keyword = models.CharField(max_length=255, blank=True, null=True)
|
||||
secondary_keywords = models.JSONField(default=list, blank=True, help_text="List of secondary keywords")
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('draft', 'Draft'),
|
||||
('review', 'Review'),
|
||||
('publish', 'Publish'),
|
||||
]
|
||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='draft', help_text="Content workflow status (draft, review, publish)")
|
||||
generated_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
# Taxonomy relationships
|
||||
taxonomy_terms = models.ManyToManyField(
|
||||
'ContentTaxonomy',
|
||||
blank=True,
|
||||
related_name='contents',
|
||||
help_text="Associated taxonomy terms (categories, tags, attributes)"
|
||||
)
|
||||
|
||||
# Phase 4: Source tracking
|
||||
# External platform fields (WordPress integration)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True, db_index=True, help_text="WordPress/external platform post ID")
|
||||
external_url = models.URLField(blank=True, null=True, help_text="WordPress/external platform URL")
|
||||
|
||||
# Source tracking
|
||||
SOURCE_CHOICES = [
|
||||
('igny8', 'IGNY8 Generated'),
|
||||
('wordpress', 'WordPress Synced'),
|
||||
('shopify', 'Shopify Synced'),
|
||||
('custom', 'Custom API Synced'),
|
||||
('wordpress', 'WordPress Imported'),
|
||||
]
|
||||
source = models.CharField(
|
||||
max_length=50,
|
||||
choices=SOURCE_CHOICES,
|
||||
default='igny8',
|
||||
db_index=True,
|
||||
help_text="Source of the content"
|
||||
help_text="Content source"
|
||||
)
|
||||
|
||||
SYNC_STATUS_CHOICES = [
|
||||
('native', 'Native IGNY8 Content'),
|
||||
('imported', 'Imported from External'),
|
||||
('synced', 'Synced from External'),
|
||||
# Status tracking
|
||||
STATUS_CHOICES = [
|
||||
('draft', 'Draft'),
|
||||
('published', 'Published'),
|
||||
]
|
||||
sync_status = models.CharField(
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=SYNC_STATUS_CHOICES,
|
||||
default='native',
|
||||
choices=STATUS_CHOICES,
|
||||
default='draft',
|
||||
db_index=True,
|
||||
help_text="Sync status of the content"
|
||||
help_text="Content status"
|
||||
)
|
||||
|
||||
# External reference fields
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True, help_text="External platform ID")
|
||||
external_url = models.URLField(blank=True, null=True, help_text="External platform URL")
|
||||
sync_metadata = models.JSONField(default=dict, blank=True, help_text="Platform-specific sync metadata")
|
||||
|
||||
# Phase 4: Linking fields
|
||||
internal_links = models.JSONField(default=list, blank=True, help_text="Internal links added by linker")
|
||||
linker_version = models.IntegerField(default=0, help_text="Version of linker processing")
|
||||
|
||||
# Phase 4: Optimization fields
|
||||
optimizer_version = models.IntegerField(default=0, help_text="Version of optimizer processing")
|
||||
optimization_scores = models.JSONField(default=dict, blank=True, help_text="Optimization scores (SEO, readability, engagement)")
|
||||
|
||||
# Phase 8: Universal Content Types
|
||||
ENTITY_TYPE_CHOICES = [
|
||||
('post', 'Blog Post'),
|
||||
('page', 'Page'),
|
||||
('product', 'Product'),
|
||||
('service', 'Service 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='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,
|
||||
blank=True,
|
||||
help_text="Structured content blocks (for products, services, taxonomies)"
|
||||
)
|
||||
|
||||
# Phase 8: Content structure data
|
||||
structure_data = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Content structure data (metadata, schema, etc.)"
|
||||
)
|
||||
|
||||
# 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"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'writer'
|
||||
db_table = 'igny8_content'
|
||||
ordering = ['-generated_at']
|
||||
ordering = ['-created_at']
|
||||
verbose_name = 'Content'
|
||||
verbose_name_plural = 'Contents'
|
||||
indexes = [
|
||||
models.Index(fields=['task']),
|
||||
models.Index(fields=['generated_at']),
|
||||
models.Index(fields=['source']),
|
||||
models.Index(fields=['sync_status']),
|
||||
models.Index(fields=['source', 'sync_status']),
|
||||
models.Index(fields=['entity_type']),
|
||||
models.Index(fields=['content_format']),
|
||||
models.Index(fields=['cluster_role']),
|
||||
models.Index(fields=['title']),
|
||||
models.Index(fields=['cluster']),
|
||||
models.Index(fields=['external_type']),
|
||||
models.Index(fields=['site', 'entity_type']),
|
||||
models.Index(fields=['content_type']),
|
||||
models.Index(fields=['content_structure']),
|
||||
models.Index(fields=['source']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['external_id']),
|
||||
models.Index(fields=['site', 'sector']),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Automatically set account, site, and sector from task"""
|
||||
if self.task_id: # Check task_id instead of accessing task to avoid RelatedObjectDoesNotExist
|
||||
try:
|
||||
self.account = self.task.account
|
||||
self.site = self.task.site
|
||||
self.sector = self.task.sector
|
||||
except self.task.RelatedObjectDoesNotExist:
|
||||
pass # Task doesn't exist, skip
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"Content for {self.task.title}"
|
||||
return self.title or f"Content {self.id}"
|
||||
|
||||
|
||||
class ContentTaxonomy(SiteSectorBaseModel):
|
||||
"""
|
||||
Universal taxonomy model for categories, tags, and product attributes.
|
||||
Syncs with WordPress taxonomies and stores terms.
|
||||
Universal taxonomy model for WordPress and IGNY8 cluster-based taxonomies.
|
||||
Supports categories, tags, product attributes, and cluster mappings.
|
||||
"""
|
||||
|
||||
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'),
|
||||
('product_category', 'Product Category'),
|
||||
('product_attribute', 'Product Attribute'),
|
||||
('cluster', 'Cluster Taxonomy'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255, db_index=True, help_text="Term name")
|
||||
@@ -326,46 +182,19 @@ class ContentTaxonomy(SiteSectorBaseModel):
|
||||
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
|
||||
# WordPress/external platform sync fields
|
||||
external_taxonomy = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="WordPress taxonomy slug (category, post_tag, product_cat, pa_*) - null for cluster taxonomies"
|
||||
)
|
||||
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"
|
||||
help_text="WordPress term_id - null for cluster taxonomies"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -384,7 +213,6 @@ class ContentTaxonomy(SiteSectorBaseModel):
|
||||
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']),
|
||||
@@ -394,37 +222,6 @@ class ContentTaxonomy(SiteSectorBaseModel):
|
||||
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)"""
|
||||
|
||||
|
||||
@@ -3,13 +3,7 @@ from igny8_core.auth.models import SiteSectorBaseModel, SeedKeyword
|
||||
|
||||
|
||||
class Clusters(SiteSectorBaseModel):
|
||||
"""Clusters model for keyword grouping"""
|
||||
|
||||
CONTEXT_TYPE_CHOICES = [
|
||||
('topic', 'Topic Cluster'),
|
||||
('attribute', 'Attribute Cluster'),
|
||||
('service_line', 'Service Line'),
|
||||
]
|
||||
"""Clusters model for keyword grouping - pure topic clusters"""
|
||||
|
||||
name = models.CharField(max_length=255, unique=True, db_index=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
@@ -17,17 +11,6 @@ class Clusters(SiteSectorBaseModel):
|
||||
volume = models.IntegerField(default=0)
|
||||
mapped_pages = models.IntegerField(default=0)
|
||||
status = models.CharField(max_length=50, default='active')
|
||||
context_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=CONTEXT_TYPE_CHOICES,
|
||||
default='topic',
|
||||
help_text="Primary dimension for this cluster (topic, attribute, service line)"
|
||||
)
|
||||
dimension_meta = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Extended metadata (taxonomy hints, attribute suggestions, coverage targets)"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -41,7 +24,6 @@ class Clusters(SiteSectorBaseModel):
|
||||
models.Index(fields=['name']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['site', 'sector']),
|
||||
models.Index(fields=['context_type']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -117,7 +117,7 @@ class KeywordSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class ClusterSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Clusters model"""
|
||||
"""Serializer for Clusters model - pure topic clusters"""
|
||||
sector_name = serializers.SerializerMethodField()
|
||||
site_id = serializers.IntegerField(write_only=True, required=False)
|
||||
sector_id = serializers.IntegerField(write_only=True, required=False)
|
||||
@@ -141,14 +141,6 @@ class ClusterSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id', 'keywords_count', 'volume', 'mapped_pages']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Only include Stage 1 fields when feature flag is enabled
|
||||
if getattr(settings, 'USE_SITE_BUILDER_REFACTOR', False):
|
||||
self.fields['context_type'] = serializers.CharField(read_only=True)
|
||||
self.fields['context_type_display'] = serializers.SerializerMethodField()
|
||||
self.fields['dimension_meta'] = serializers.JSONField(read_only=True)
|
||||
|
||||
def get_sector_name(self, obj):
|
||||
"""Get sector name from Sector model"""
|
||||
if obj.sector_id:
|
||||
@@ -159,12 +151,6 @@ class ClusterSerializer(serializers.ModelSerializer):
|
||||
except Sector.DoesNotExist:
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_context_type_display(self, obj):
|
||||
"""Get context type display name (only when feature flag enabled)"""
|
||||
if hasattr(obj, 'get_context_type_display'):
|
||||
return obj.get_context_type_display()
|
||||
return None
|
||||
|
||||
def validate_name(self, value):
|
||||
"""Ensure cluster name is unique within account"""
|
||||
|
||||
Reference in New Issue
Block a user