Add scheduled automation task and update URL routing
- Introduced a new scheduled task for executing automation rules every 5 minutes in the Celery beat schedule. - Updated URL routing to include a new endpoint for automation-related functionalities. - Refactored imports in various modules to align with the new business layer structure, ensuring backward compatibility for billing models, exceptions, and services.
This commit is contained in:
5
backend/igny8_core/modules/automation/__init__.py
Normal file
5
backend/igny8_core/modules/automation/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Automation Module - API Layer
|
||||
Business logic is in business/automation/
|
||||
"""
|
||||
|
||||
36
backend/igny8_core/modules/automation/serializers.py
Normal file
36
backend/igny8_core/modules/automation/serializers.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Serializers for Automation Models
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from igny8_core.business.automation.models import AutomationRule, ScheduledTask
|
||||
|
||||
|
||||
class AutomationRuleSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for AutomationRule model"""
|
||||
|
||||
class Meta:
|
||||
model = AutomationRule
|
||||
fields = [
|
||||
'id', 'name', 'description', 'trigger', 'schedule',
|
||||
'conditions', 'actions', 'is_active', 'status',
|
||||
'last_executed_at', 'execution_count',
|
||||
'metadata', 'created_at', 'updated_at',
|
||||
'account', 'site', 'sector'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'last_executed_at', 'execution_count']
|
||||
|
||||
|
||||
class ScheduledTaskSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for ScheduledTask model"""
|
||||
automation_rule_name = serializers.CharField(source='automation_rule.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ScheduledTask
|
||||
fields = [
|
||||
'id', 'automation_rule', 'automation_rule_name',
|
||||
'scheduled_at', 'executed_at', 'status',
|
||||
'result', 'error_message', 'metadata',
|
||||
'created_at', 'updated_at', 'account'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'executed_at']
|
||||
|
||||
15
backend/igny8_core/modules/automation/urls.py
Normal file
15
backend/igny8_core/modules/automation/urls.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
URL patterns for automation module.
|
||||
"""
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import AutomationRuleViewSet, ScheduledTaskViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'rules', AutomationRuleViewSet, basename='automation-rule')
|
||||
router.register(r'scheduled-tasks', ScheduledTaskViewSet, basename='scheduled-task')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
92
backend/igny8_core/modules/automation/views.py
Normal file
92
backend/igny8_core/modules/automation/views.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
ViewSets for Automation Models
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from igny8_core.api.base import SiteSectorModelViewSet, AccountModelViewSet
|
||||
from igny8_core.api.pagination import CustomPageNumberPagination
|
||||
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
|
||||
from igny8_core.business.automation.models import AutomationRule, ScheduledTask
|
||||
from igny8_core.business.automation.services.automation_service import AutomationService
|
||||
from .serializers import AutomationRuleSerializer, ScheduledTaskSerializer
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['Automation']),
|
||||
create=extend_schema(tags=['Automation']),
|
||||
retrieve=extend_schema(tags=['Automation']),
|
||||
update=extend_schema(tags=['Automation']),
|
||||
partial_update=extend_schema(tags=['Automation']),
|
||||
destroy=extend_schema(tags=['Automation']),
|
||||
)
|
||||
class AutomationRuleViewSet(SiteSectorModelViewSet):
|
||||
"""
|
||||
ViewSet for managing automation rules
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = AutomationRule.objects.all()
|
||||
serializer_class = AutomationRuleSerializer
|
||||
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
|
||||
pagination_class = CustomPageNumberPagination
|
||||
throttle_scope = 'automation'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['name', 'created_at', 'last_executed_at', 'execution_count']
|
||||
ordering = ['-created_at']
|
||||
filterset_fields = ['trigger', 'is_active', 'status']
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='execute', url_name='execute')
|
||||
def execute(self, request, pk=None):
|
||||
"""Manually execute an automation rule"""
|
||||
rule = self.get_object()
|
||||
service = AutomationService()
|
||||
|
||||
try:
|
||||
result = service.execute_rule(rule, context=request.data.get('context', {}))
|
||||
return success_response(
|
||||
data=result,
|
||||
message='Rule executed successfully',
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['Automation']),
|
||||
create=extend_schema(tags=['Automation']),
|
||||
retrieve=extend_schema(tags=['Automation']),
|
||||
update=extend_schema(tags=['Automation']),
|
||||
partial_update=extend_schema(tags=['Automation']),
|
||||
destroy=extend_schema(tags=['Automation']),
|
||||
)
|
||||
class ScheduledTaskViewSet(AccountModelViewSet):
|
||||
"""
|
||||
ViewSet for managing scheduled tasks
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = ScheduledTask.objects.select_related('automation_rule')
|
||||
serializer_class = ScheduledTaskSerializer
|
||||
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
|
||||
pagination_class = CustomPageNumberPagination
|
||||
throttle_scope = 'automation'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
ordering_fields = ['scheduled_at', 'executed_at', 'status', 'created_at']
|
||||
ordering = ['-scheduled_at']
|
||||
filterset_fields = ['automation_rule', 'status']
|
||||
|
||||
@@ -1,21 +1,4 @@
|
||||
"""
|
||||
Credit Cost Constants
|
||||
Phase 0: Credit-only system costs per operation
|
||||
"""
|
||||
CREDIT_COSTS = {
|
||||
'clustering': 10, # Per clustering request
|
||||
'idea_generation': 15, # Per cluster → ideas request
|
||||
'content_generation': 1, # Per 100 words
|
||||
'image_prompt_extraction': 2, # Per content piece
|
||||
'image_generation': 5, # Per image
|
||||
'linking': 8, # Per content piece (NEW)
|
||||
'optimization': 1, # Per 200 words (NEW)
|
||||
'site_structure_generation': 50, # Per site blueprint (NEW)
|
||||
'site_page_generation': 20, # Per page (NEW)
|
||||
# Legacy operation types (for backward compatibility)
|
||||
'ideas': 15, # Alias for idea_generation
|
||||
'content': 3, # Legacy: 3 credits per content piece
|
||||
'images': 5, # Alias for image_generation
|
||||
'reparse': 1, # Per reparse
|
||||
}
|
||||
# Backward compatibility alias - constants moved to business/billing/
|
||||
from igny8_core.business.billing.constants import CREDIT_COSTS
|
||||
|
||||
__all__ = ['CREDIT_COSTS']
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
"""
|
||||
Billing Exceptions
|
||||
"""
|
||||
|
||||
|
||||
class InsufficientCreditsError(Exception):
|
||||
"""Raised when account doesn't have enough credits"""
|
||||
pass
|
||||
|
||||
|
||||
class CreditCalculationError(Exception):
|
||||
"""Raised when credit calculation fails"""
|
||||
pass
|
||||
# Backward compatibility aliases - exceptions moved to business/billing/
|
||||
from igny8_core.business.billing.exceptions import InsufficientCreditsError, CreditCalculationError
|
||||
|
||||
__all__ = ['InsufficientCreditsError', 'CreditCalculationError']
|
||||
|
||||
@@ -1,72 +1,4 @@
|
||||
"""
|
||||
Billing Models for Credit System
|
||||
"""
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from igny8_core.auth.models import AccountBaseModel
|
||||
|
||||
|
||||
class CreditTransaction(AccountBaseModel):
|
||||
"""Track all credit transactions (additions, deductions)"""
|
||||
TRANSACTION_TYPE_CHOICES = [
|
||||
('purchase', 'Purchase'),
|
||||
('subscription', 'Subscription Renewal'),
|
||||
('refund', 'Refund'),
|
||||
('deduction', 'Usage Deduction'),
|
||||
('adjustment', 'Manual Adjustment'),
|
||||
]
|
||||
|
||||
transaction_type = models.CharField(max_length=20, choices=TRANSACTION_TYPE_CHOICES, db_index=True)
|
||||
amount = models.IntegerField(help_text="Positive for additions, negative for deductions")
|
||||
balance_after = models.IntegerField(help_text="Credit balance after this transaction")
|
||||
description = models.CharField(max_length=255)
|
||||
metadata = models.JSONField(default=dict, help_text="Additional context (AI call details, etc.)")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_credit_transactions'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['account', 'transaction_type']),
|
||||
models.Index(fields=['account', 'created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
account = getattr(self, 'account', None)
|
||||
return f"{self.get_transaction_type_display()} - {self.amount} credits - {account.name if account else 'No Account'}"
|
||||
|
||||
|
||||
class CreditUsageLog(AccountBaseModel):
|
||||
"""Detailed log of credit usage per AI operation"""
|
||||
OPERATION_TYPE_CHOICES = [
|
||||
('clustering', 'Keyword Clustering'),
|
||||
('ideas', 'Content Ideas Generation'),
|
||||
('content', 'Content Generation'),
|
||||
('images', 'Image Generation'),
|
||||
('reparse', 'Content Reparse'),
|
||||
]
|
||||
|
||||
operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES, db_index=True)
|
||||
credits_used = models.IntegerField(validators=[MinValueValidator(0)])
|
||||
cost_usd = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
|
||||
model_used = models.CharField(max_length=100, blank=True)
|
||||
tokens_input = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
|
||||
tokens_output = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(0)])
|
||||
related_object_type = models.CharField(max_length=50, blank=True) # 'keyword', 'cluster', 'task'
|
||||
related_object_id = models.IntegerField(null=True, blank=True)
|
||||
metadata = models.JSONField(default=dict)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_credit_usage_logs'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['account', 'operation_type']),
|
||||
models.Index(fields=['account', 'created_at']),
|
||||
models.Index(fields=['account', 'operation_type', 'created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
account = getattr(self, 'account', None)
|
||||
return f"{self.get_operation_type_display()} - {self.credits_used} credits - {account.name if account else 'No Account'}"
|
||||
# Backward compatibility aliases - models moved to business/billing/
|
||||
from igny8_core.business.billing.models import CreditTransaction, CreditUsageLog
|
||||
|
||||
__all__ = ['CreditTransaction', 'CreditUsageLog']
|
||||
|
||||
@@ -1,264 +1,4 @@
|
||||
"""
|
||||
Credit Service for managing credit transactions and deductions
|
||||
"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from .models import CreditTransaction, CreditUsageLog
|
||||
from .constants import CREDIT_COSTS
|
||||
from .exceptions import InsufficientCreditsError, CreditCalculationError
|
||||
from igny8_core.auth.models import Account
|
||||
|
||||
|
||||
class CreditService:
|
||||
"""Service for managing credits"""
|
||||
|
||||
@staticmethod
|
||||
def get_credit_cost(operation_type, amount=None):
|
||||
"""
|
||||
Get credit cost for operation.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation (from CREDIT_COSTS)
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
|
||||
Returns:
|
||||
int: Number of credits required
|
||||
|
||||
Raises:
|
||||
CreditCalculationError: If operation type is unknown
|
||||
"""
|
||||
base_cost = CREDIT_COSTS.get(operation_type, 0)
|
||||
if base_cost == 0:
|
||||
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
|
||||
|
||||
# Variable cost operations
|
||||
if operation_type == 'content_generation' and amount:
|
||||
# Per 100 words
|
||||
return max(1, int(base_cost * (amount / 100)))
|
||||
elif operation_type == 'optimization' and amount:
|
||||
# Per 200 words
|
||||
return max(1, int(base_cost * (amount / 200)))
|
||||
elif operation_type == 'image_generation' and amount:
|
||||
# Per image
|
||||
return base_cost * amount
|
||||
elif operation_type == 'idea_generation' and amount:
|
||||
# Per idea
|
||||
return base_cost * amount
|
||||
|
||||
# Fixed cost operations
|
||||
return base_cost
|
||||
|
||||
@staticmethod
|
||||
def check_credits(account, operation_type, amount=None):
|
||||
"""
|
||||
Check if account has sufficient credits for an operation.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
operation_type: Type of operation
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
required = CreditService.get_credit_cost(operation_type, amount)
|
||||
if account.credits < required:
|
||||
raise InsufficientCreditsError(
|
||||
f"Insufficient credits. Required: {required}, Available: {account.credits}"
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def check_credits_legacy(account, required_credits):
|
||||
"""
|
||||
Legacy method: Check if account has enough credits (for backward compatibility).
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
required_credits: Number of credits required
|
||||
|
||||
Raises:
|
||||
InsufficientCreditsError: If account doesn't have enough credits
|
||||
"""
|
||||
if account.credits < required_credits:
|
||||
raise InsufficientCreditsError(
|
||||
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def deduct_credits(account, amount, operation_type, description, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
|
||||
"""
|
||||
Deduct credits and log transaction.
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
amount: Number of credits to deduct
|
||||
operation_type: Type of operation (from CreditUsageLog.OPERATION_TYPE_CHOICES)
|
||||
description: Description of the transaction
|
||||
metadata: Optional metadata dict
|
||||
cost_usd: Optional cost in USD
|
||||
model_used: Optional AI model used
|
||||
tokens_input: Optional input tokens
|
||||
tokens_output: Optional output tokens
|
||||
related_object_type: Optional related object type
|
||||
related_object_id: Optional related object ID
|
||||
|
||||
Returns:
|
||||
int: New credit balance
|
||||
"""
|
||||
# Check sufficient credits (legacy: amount is already calculated)
|
||||
CreditService.check_credits_legacy(account, amount)
|
||||
|
||||
# Deduct from account.credits
|
||||
account.credits -= amount
|
||||
account.save(update_fields=['credits'])
|
||||
|
||||
# Create CreditTransaction
|
||||
CreditTransaction.objects.create(
|
||||
account=account,
|
||||
transaction_type='deduction',
|
||||
amount=-amount, # Negative for deduction
|
||||
balance_after=account.credits,
|
||||
description=description,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
# Create CreditUsageLog
|
||||
CreditUsageLog.objects.create(
|
||||
account=account,
|
||||
operation_type=operation_type,
|
||||
credits_used=amount,
|
||||
cost_usd=cost_usd,
|
||||
model_used=model_used or '',
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
related_object_type=related_object_type or '',
|
||||
related_object_id=related_object_id,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
return account.credits
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def deduct_credits_for_operation(account, operation_type, amount=None, description=None, metadata=None, cost_usd=None, model_used=None, tokens_input=None, tokens_output=None, related_object_type=None, related_object_id=None):
|
||||
"""
|
||||
Deduct credits for an operation (convenience method that calculates cost automatically).
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
operation_type: Type of operation
|
||||
amount: Optional amount (word count, image count, etc.)
|
||||
description: Optional description (auto-generated if not provided)
|
||||
metadata: Optional metadata dict
|
||||
cost_usd: Optional cost in USD
|
||||
model_used: Optional AI model used
|
||||
tokens_input: Optional input tokens
|
||||
tokens_output: Optional output tokens
|
||||
related_object_type: Optional related object type
|
||||
related_object_id: Optional related object ID
|
||||
|
||||
Returns:
|
||||
int: New credit balance
|
||||
"""
|
||||
# Calculate credit cost
|
||||
credits_required = CreditService.get_credit_cost(operation_type, amount)
|
||||
|
||||
# Check sufficient credits
|
||||
CreditService.check_credits(account, operation_type, amount)
|
||||
|
||||
# Auto-generate description if not provided
|
||||
if not description:
|
||||
if operation_type == 'clustering':
|
||||
description = f"Clustering operation"
|
||||
elif operation_type == 'idea_generation':
|
||||
description = f"Generated {amount or 1} idea(s)"
|
||||
elif operation_type == 'content_generation':
|
||||
description = f"Generated content ({amount or 0} words)"
|
||||
elif operation_type == 'image_generation':
|
||||
description = f"Generated {amount or 1} image(s)"
|
||||
else:
|
||||
description = f"{operation_type} operation"
|
||||
|
||||
return CreditService.deduct_credits(
|
||||
account=account,
|
||||
amount=credits_required,
|
||||
operation_type=operation_type,
|
||||
description=description,
|
||||
metadata=metadata,
|
||||
cost_usd=cost_usd,
|
||||
model_used=model_used,
|
||||
tokens_input=tokens_input,
|
||||
tokens_output=tokens_output,
|
||||
related_object_type=related_object_type,
|
||||
related_object_id=related_object_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def add_credits(account, amount, transaction_type, description, metadata=None):
|
||||
"""
|
||||
Add credits (purchase, subscription, etc.).
|
||||
|
||||
Args:
|
||||
account: Account instance
|
||||
amount: Number of credits to add
|
||||
transaction_type: Type of transaction (from CreditTransaction.TRANSACTION_TYPE_CHOICES)
|
||||
description: Description of the transaction
|
||||
metadata: Optional metadata dict
|
||||
|
||||
Returns:
|
||||
int: New credit balance
|
||||
"""
|
||||
# Add to account.credits
|
||||
account.credits += amount
|
||||
account.save(update_fields=['credits'])
|
||||
|
||||
# Create CreditTransaction
|
||||
CreditTransaction.objects.create(
|
||||
account=account,
|
||||
transaction_type=transaction_type,
|
||||
amount=amount, # Positive for addition
|
||||
balance_after=account.credits,
|
||||
description=description,
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
return account.credits
|
||||
|
||||
@staticmethod
|
||||
def calculate_credits_for_operation(operation_type, **kwargs):
|
||||
"""
|
||||
Calculate credits needed for an operation.
|
||||
Legacy method - use get_credit_cost() instead.
|
||||
|
||||
Args:
|
||||
operation_type: Type of operation
|
||||
**kwargs: Operation-specific parameters
|
||||
|
||||
Returns:
|
||||
int: Number of credits required
|
||||
|
||||
Raises:
|
||||
CreditCalculationError: If calculation fails
|
||||
"""
|
||||
# Map legacy operation types
|
||||
if operation_type == 'ideas':
|
||||
operation_type = 'idea_generation'
|
||||
elif operation_type == 'content':
|
||||
operation_type = 'content_generation'
|
||||
elif operation_type == 'images':
|
||||
operation_type = 'image_generation'
|
||||
|
||||
# Extract amount from kwargs
|
||||
amount = None
|
||||
if 'word_count' in kwargs:
|
||||
amount = kwargs.get('word_count')
|
||||
elif 'image_count' in kwargs:
|
||||
amount = kwargs.get('image_count')
|
||||
elif 'idea_count' in kwargs:
|
||||
amount = kwargs.get('idea_count')
|
||||
|
||||
return CreditService.get_credit_cost(operation_type, amount)
|
||||
# Backward compatibility alias - service moved to business/billing/services/
|
||||
from igny8_core.business.billing.services.credit_service import CreditService
|
||||
|
||||
__all__ = ['CreditService']
|
||||
|
||||
@@ -1,194 +1,4 @@
|
||||
from django.db import models
|
||||
from igny8_core.auth.models import SiteSectorBaseModel, SeedKeyword
|
||||
# Backward compatibility aliases - models moved to business/planning/
|
||||
from igny8_core.business.planning.models import Keywords, Clusters, ContentIdeas
|
||||
|
||||
|
||||
class Clusters(SiteSectorBaseModel):
|
||||
"""Clusters model for keyword grouping"""
|
||||
|
||||
name = models.CharField(max_length=255, unique=True, db_index=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
keywords_count = models.IntegerField(default=0)
|
||||
volume = models.IntegerField(default=0)
|
||||
mapped_pages = models.IntegerField(default=0)
|
||||
status = models.CharField(max_length=50, default='active')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_clusters'
|
||||
ordering = ['name']
|
||||
verbose_name = 'Cluster'
|
||||
verbose_name_plural = 'Clusters'
|
||||
indexes = [
|
||||
models.Index(fields=['name']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['site', 'sector']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Keywords(SiteSectorBaseModel):
|
||||
"""
|
||||
Keywords model for SEO keyword management.
|
||||
Site-specific instances that reference global SeedKeywords.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('active', 'Active'),
|
||||
('pending', 'Pending'),
|
||||
('archived', 'Archived'),
|
||||
]
|
||||
|
||||
# Required: Link to global SeedKeyword
|
||||
seed_keyword = models.ForeignKey(
|
||||
SeedKeyword,
|
||||
on_delete=models.PROTECT, # Prevent deletion if Keywords reference it
|
||||
related_name='site_keywords',
|
||||
help_text="Reference to the global seed keyword"
|
||||
)
|
||||
|
||||
# Site-specific overrides (optional)
|
||||
volume_override = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Site-specific volume override (uses seed_keyword.volume if not set)"
|
||||
)
|
||||
difficulty_override = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Site-specific difficulty override (uses seed_keyword.difficulty if not set)"
|
||||
)
|
||||
|
||||
cluster = models.ForeignKey(
|
||||
Clusters,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='keywords',
|
||||
limit_choices_to={'sector': models.F('sector')} # Cluster must be in same sector
|
||||
)
|
||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='pending')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_keywords'
|
||||
ordering = ['-created_at']
|
||||
verbose_name = 'Keyword'
|
||||
verbose_name_plural = 'Keywords'
|
||||
unique_together = [['seed_keyword', 'site', 'sector']] # One keyword per site/sector
|
||||
indexes = [
|
||||
models.Index(fields=['seed_keyword']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['cluster']),
|
||||
models.Index(fields=['site', 'sector']),
|
||||
models.Index(fields=['seed_keyword', 'site', 'sector']),
|
||||
]
|
||||
|
||||
@property
|
||||
def keyword(self):
|
||||
"""Get keyword text from seed_keyword"""
|
||||
return self.seed_keyword.keyword if self.seed_keyword else ''
|
||||
|
||||
@property
|
||||
def volume(self):
|
||||
"""Get volume from override or seed_keyword"""
|
||||
return self.volume_override if self.volume_override is not None else (self.seed_keyword.volume if self.seed_keyword else 0)
|
||||
|
||||
@property
|
||||
def difficulty(self):
|
||||
"""Get difficulty from override or seed_keyword"""
|
||||
return self.difficulty_override if self.difficulty_override is not None else (self.seed_keyword.difficulty if self.seed_keyword else 0)
|
||||
|
||||
@property
|
||||
def intent(self):
|
||||
"""Get intent from seed_keyword"""
|
||||
return self.seed_keyword.intent if self.seed_keyword else 'informational'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Validate that seed_keyword's industry/sector matches site's industry/sector"""
|
||||
if self.seed_keyword and self.site and self.sector:
|
||||
# Validate industry match
|
||||
if self.site.industry != self.seed_keyword.industry:
|
||||
from django.core.exceptions import ValidationError
|
||||
raise ValidationError(
|
||||
f"SeedKeyword industry ({self.seed_keyword.industry.name}) must match site industry ({self.site.industry.name})"
|
||||
)
|
||||
|
||||
# Validate sector match (site sector's industry_sector must match seed_keyword's sector)
|
||||
if self.sector.industry_sector != self.seed_keyword.sector:
|
||||
from django.core.exceptions import ValidationError
|
||||
raise ValidationError(
|
||||
f"SeedKeyword sector ({self.seed_keyword.sector.name}) must match site sector's industry sector ({self.sector.industry_sector.name if self.sector.industry_sector else 'None'})"
|
||||
)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.keyword
|
||||
|
||||
|
||||
class ContentIdeas(SiteSectorBaseModel):
|
||||
"""Content Ideas model for planning content based on keyword clusters"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('new', 'New'),
|
||||
('scheduled', 'Scheduled'),
|
||||
('published', 'Published'),
|
||||
]
|
||||
|
||||
CONTENT_STRUCTURE_CHOICES = [
|
||||
('cluster_hub', 'Cluster Hub'),
|
||||
('landing_page', 'Landing Page'),
|
||||
('pillar_page', 'Pillar Page'),
|
||||
('supporting_page', 'Supporting Page'),
|
||||
]
|
||||
|
||||
CONTENT_TYPE_CHOICES = [
|
||||
('blog_post', 'Blog Post'),
|
||||
('article', 'Article'),
|
||||
('guide', 'Guide'),
|
||||
('tutorial', 'Tutorial'),
|
||||
]
|
||||
|
||||
idea_title = models.CharField(max_length=255, db_index=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
content_structure = models.CharField(max_length=50, choices=CONTENT_STRUCTURE_CHOICES, default='blog_post')
|
||||
content_type = models.CharField(max_length=50, choices=CONTENT_TYPE_CHOICES, default='blog_post')
|
||||
target_keywords = models.CharField(max_length=500, blank=True) # Comma-separated keywords (legacy)
|
||||
keyword_objects = models.ManyToManyField(
|
||||
'Keywords',
|
||||
blank=True,
|
||||
related_name='content_ideas',
|
||||
help_text="Individual keywords linked to this content idea"
|
||||
)
|
||||
keyword_cluster = models.ForeignKey(
|
||||
Clusters,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='ideas',
|
||||
limit_choices_to={'sector': models.F('sector')}
|
||||
)
|
||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='new')
|
||||
estimated_word_count = models.IntegerField(default=1000)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_content_ideas'
|
||||
ordering = ['-created_at']
|
||||
verbose_name = 'Content Idea'
|
||||
verbose_name_plural = 'Content Ideas'
|
||||
indexes = [
|
||||
models.Index(fields=['idea_title']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['keyword_cluster']),
|
||||
models.Index(fields=['content_structure']),
|
||||
models.Index(fields=['site', 'sector']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.idea_title
|
||||
__all__ = ['Keywords', 'Clusters', 'ContentIdeas']
|
||||
|
||||
@@ -1,206 +1,4 @@
|
||||
from django.db import models
|
||||
from django.core.validators import MinValueValidator
|
||||
from igny8_core.auth.models import SiteSectorBaseModel
|
||||
from igny8_core.modules.planner.models import Clusters, ContentIdeas, Keywords
|
||||
|
||||
|
||||
class Tasks(SiteSectorBaseModel):
|
||||
"""Tasks model for content generation queue"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('queued', 'Queued'),
|
||||
('completed', 'Completed'),
|
||||
]
|
||||
|
||||
CONTENT_STRUCTURE_CHOICES = [
|
||||
('cluster_hub', 'Cluster Hub'),
|
||||
('landing_page', 'Landing Page'),
|
||||
('pillar_page', 'Pillar Page'),
|
||||
('supporting_page', 'Supporting Page'),
|
||||
]
|
||||
|
||||
CONTENT_TYPE_CHOICES = [
|
||||
('blog_post', 'Blog Post'),
|
||||
('article', 'Article'),
|
||||
('guide', 'Guide'),
|
||||
('tutorial', 'Tutorial'),
|
||||
]
|
||||
|
||||
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(
|
||||
Clusters,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='tasks',
|
||||
limit_choices_to={'sector': models.F('sector')}
|
||||
)
|
||||
keyword_objects = models.ManyToManyField(
|
||||
Keywords,
|
||||
blank=True,
|
||||
related_name='tasks',
|
||||
help_text="Individual keywords linked to this task"
|
||||
)
|
||||
idea = models.ForeignKey(
|
||||
ContentIdeas,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='tasks'
|
||||
)
|
||||
content_structure = models.CharField(max_length=50, choices=CONTENT_STRUCTURE_CHOICES, default='blog_post')
|
||||
content_type = models.CharField(max_length=50, choices=CONTENT_TYPE_CHOICES, default='blog_post')
|
||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='queued')
|
||||
|
||||
# Content fields
|
||||
content = models.TextField(blank=True, null=True) # Generated content
|
||||
word_count = models.IntegerField(default=0)
|
||||
|
||||
# SEO fields
|
||||
meta_title = models.CharField(max_length=255, blank=True, null=True)
|
||||
meta_description = models.TextField(blank=True, null=True)
|
||||
# WordPress integration
|
||||
assigned_post_id = models.IntegerField(null=True, blank=True) # WordPress post ID if published
|
||||
post_url = models.URLField(blank=True, null=True) # WordPress post URL
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_tasks'
|
||||
ordering = ['-created_at']
|
||||
verbose_name = 'Task'
|
||||
verbose_name_plural = 'Tasks'
|
||||
indexes = [
|
||||
models.Index(fields=['title']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['cluster']),
|
||||
models.Index(fields=['content_type']),
|
||||
models.Index(fields=['site', 'sector']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Content(SiteSectorBaseModel):
|
||||
"""
|
||||
Content model for storing final AI-generated article content.
|
||||
Separated from Task for content versioning and storage optimization.
|
||||
"""
|
||||
task = models.OneToOneField(
|
||||
Tasks,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='content_record',
|
||||
help_text="The task this content belongs to"
|
||||
)
|
||||
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")
|
||||
tags = models.JSONField(default=list, blank=True, help_text="List of tags")
|
||||
categories = models.JSONField(default=list, blank=True, help_text="List of categories")
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_content'
|
||||
ordering = ['-generated_at']
|
||||
verbose_name = 'Content'
|
||||
verbose_name_plural = 'Contents'
|
||||
indexes = [
|
||||
models.Index(fields=['task']),
|
||||
models.Index(fields=['generated_at']),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Automatically set account, site, and sector from task"""
|
||||
if self.task:
|
||||
self.account = self.task.account
|
||||
self.site = self.task.site
|
||||
self.sector = self.task.sector
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"Content for {self.task.title}"
|
||||
|
||||
|
||||
class Images(SiteSectorBaseModel):
|
||||
"""Images model for content-related images (featured, desktop, mobile, in-article)"""
|
||||
|
||||
IMAGE_TYPE_CHOICES = [
|
||||
('featured', 'Featured Image'),
|
||||
('desktop', 'Desktop Image'),
|
||||
('mobile', 'Mobile Image'),
|
||||
('in_article', 'In-Article Image'),
|
||||
]
|
||||
|
||||
content = models.ForeignKey(
|
||||
Content,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='images',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="The content this image belongs to (preferred)"
|
||||
)
|
||||
task = models.ForeignKey(
|
||||
Tasks,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='images',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="The task this image belongs to (legacy, use content instead)"
|
||||
)
|
||||
image_type = models.CharField(max_length=50, choices=IMAGE_TYPE_CHOICES, default='featured')
|
||||
image_url = models.CharField(max_length=500, blank=True, null=True, help_text="URL of the generated/stored image")
|
||||
image_path = models.CharField(max_length=500, blank=True, null=True, help_text="Local path if stored locally")
|
||||
prompt = models.TextField(blank=True, null=True, help_text="Image generation prompt used")
|
||||
status = models.CharField(max_length=50, default='pending', help_text="Status: pending, generated, failed")
|
||||
position = models.IntegerField(default=0, help_text="Position for in-article images ordering")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_images'
|
||||
ordering = ['content', 'position', '-created_at']
|
||||
verbose_name = 'Image'
|
||||
verbose_name_plural = 'Images'
|
||||
indexes = [
|
||||
models.Index(fields=['content', 'image_type']),
|
||||
models.Index(fields=['task', 'image_type']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['content', 'position']),
|
||||
models.Index(fields=['task', 'position']),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Automatically set account, site, and sector from content or task"""
|
||||
# Prefer content over task
|
||||
if self.content:
|
||||
self.account = self.content.account
|
||||
self.site = self.content.site
|
||||
self.sector = self.content.sector
|
||||
elif self.task:
|
||||
self.account = self.task.account
|
||||
self.site = self.task.site
|
||||
self.sector = self.task.sector
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
content_title = self.content.title if self.content else None
|
||||
task_title = self.task.title if self.task else None
|
||||
title = content_title or task_title or 'Unknown'
|
||||
return f"{title} - {self.image_type}"
|
||||
# Backward compatibility aliases - models moved to business/content/
|
||||
from igny8_core.business.content.models import Tasks, Content, Images
|
||||
|
||||
__all__ = ['Tasks', 'Content', 'Images']
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
from django.db import models
|
||||
from .models import Tasks, Images, Content
|
||||
from igny8_core.modules.planner.models import Clusters, ContentIdeas
|
||||
from igny8_core.business.planning.models import Clusters, ContentIdeas
|
||||
|
||||
|
||||
class TasksSerializer(serializers.ModelSerializer):
|
||||
|
||||
Reference in New Issue
Block a user