master plan implemenattion
This commit is contained in:
@@ -1643,14 +1643,22 @@ class AutomationService:
|
||||
raise
|
||||
|
||||
def _get_credits_used(self) -> int:
|
||||
"""Get total credits used by this run so far"""
|
||||
"""
|
||||
Get total credits used by this run so far.
|
||||
Uses CreditUsageLog (same source as /account/usage/credits endpoint) for accuracy.
|
||||
"""
|
||||
if not self.run:
|
||||
return 0
|
||||
|
||||
total = AITaskLog.objects.filter(
|
||||
# FIXED: Use CreditUsageLog instead of counting AITaskLog records
|
||||
# This matches the source of truth used by /account/usage/credits endpoint
|
||||
from igny8_core.business.billing.models import CreditUsageLog
|
||||
from django.db.models import Sum
|
||||
|
||||
total = CreditUsageLog.objects.filter(
|
||||
account=self.account,
|
||||
created_at__gte=self.run.started_at
|
||||
).aggregate(total=Count('id'))['total'] or 0
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
return total
|
||||
|
||||
|
||||
@@ -833,11 +833,16 @@ class CreditPackageViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
|
||||
class AccountPaymentMethodViewSet(AccountModelViewSet):
|
||||
"""ViewSet for account payment methods"""
|
||||
"""ViewSet for account payment methods - Full CRUD support"""
|
||||
queryset = AccountPaymentMethod.objects.all()
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
pagination_class = CustomPageNumberPagination
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Return serializer class"""
|
||||
from igny8_core.modules.billing.serializers import AccountPaymentMethodSerializer
|
||||
return AccountPaymentMethodSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter payment methods by account"""
|
||||
queryset = super().get_queryset()
|
||||
@@ -845,6 +850,15 @@ class AccountPaymentMethodViewSet(AccountModelViewSet):
|
||||
queryset = queryset.filter(account=self.request.account)
|
||||
return queryset.order_by('-is_default', 'type')
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Add account to serializer context"""
|
||||
context = super().get_serializer_context()
|
||||
account = getattr(self.request, 'account', None)
|
||||
if not account and hasattr(self.request, 'user') and self.request.user:
|
||||
account = getattr(self.request.user, 'account', None)
|
||||
context['account'] = account
|
||||
return context
|
||||
|
||||
def list(self, request):
|
||||
"""List payment methods for current account"""
|
||||
queryset = self.get_queryset()
|
||||
@@ -854,18 +868,108 @@ class AccountPaymentMethodViewSet(AccountModelViewSet):
|
||||
results = []
|
||||
for method in (page if page is not None else []):
|
||||
results.append({
|
||||
'id': str(method.id),
|
||||
'id': method.id,
|
||||
'type': method.type,
|
||||
'display_name': method.display_name,
|
||||
'is_default': method.is_default,
|
||||
'is_enabled': method.is_enabled if hasattr(method, 'is_enabled') else True,
|
||||
'is_enabled': method.is_enabled,
|
||||
'is_verified': method.is_verified,
|
||||
'instructions': method.instructions,
|
||||
'metadata': method.metadata,
|
||||
'created_at': method.created_at.isoformat() if method.created_at else None,
|
||||
'updated_at': method.updated_at.isoformat() if method.updated_at else None,
|
||||
})
|
||||
|
||||
return paginated_response(
|
||||
{'count': paginator.page.paginator.count, 'next': paginator.get_next_link(), 'previous': paginator.get_previous_link(), 'results': results},
|
||||
request=request
|
||||
)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Create a new payment method"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
try:
|
||||
serializer.is_valid(raise_exception=True)
|
||||
instance = serializer.save()
|
||||
return success_response(
|
||||
data={
|
||||
'id': instance.id,
|
||||
'type': instance.type,
|
||||
'display_name': instance.display_name,
|
||||
'is_default': instance.is_default,
|
||||
'is_enabled': instance.is_enabled,
|
||||
'is_verified': instance.is_verified,
|
||||
'instructions': instance.instructions,
|
||||
},
|
||||
message='Payment method created successfully',
|
||||
request=request,
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
except Exception as e:
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Update a payment method"""
|
||||
partial = kwargs.pop('partial', False)
|
||||
instance = self.get_object()
|
||||
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||
try:
|
||||
serializer.is_valid(raise_exception=True)
|
||||
instance = serializer.save()
|
||||
return success_response(
|
||||
data={
|
||||
'id': instance.id,
|
||||
'type': instance.type,
|
||||
'display_name': instance.display_name,
|
||||
'is_default': instance.is_default,
|
||||
'is_enabled': instance.is_enabled,
|
||||
'is_verified': instance.is_verified,
|
||||
'instructions': instance.instructions,
|
||||
},
|
||||
message='Payment method updated successfully',
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Delete a payment method"""
|
||||
try:
|
||||
instance = self.get_object()
|
||||
|
||||
# Don't allow deleting the only default payment method
|
||||
if instance.is_default:
|
||||
other_methods = AccountPaymentMethod.objects.filter(
|
||||
account=instance.account
|
||||
).exclude(pk=instance.pk).count()
|
||||
if other_methods == 0:
|
||||
return error_response(
|
||||
error='Cannot delete the only payment method',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
instance.delete()
|
||||
return success_response(
|
||||
data=None,
|
||||
message='Payment method deleted successfully',
|
||||
request=request,
|
||||
status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
except Exception as e:
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""
|
||||
Planning business logic - Keywords, Clusters, ContentIdeas models and services
|
||||
"""
|
||||
|
||||
# Import signals to register cascade handlers
|
||||
from . import signals # noqa: F401
|
||||
|
||||
130
backend/igny8_core/business/planning/signals.py
Normal file
130
backend/igny8_core/business/planning/signals.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Cascade signals for Planning models
|
||||
Handles status updates and relationship cleanup when parent records are deleted
|
||||
"""
|
||||
import logging
|
||||
from django.db.models.signals import pre_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender='planner.Clusters')
|
||||
def handle_cluster_soft_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
When a Cluster is deleted:
|
||||
- Set Keywords.cluster = NULL
|
||||
- Reset Keywords.status to 'new'
|
||||
- Set ContentIdeas.keyword_cluster = NULL
|
||||
- Reset ContentIdeas.status to 'new'
|
||||
"""
|
||||
from igny8_core.business.planning.models import Keywords, ContentIdeas
|
||||
|
||||
# Check if this is a soft delete (is_deleted=True) vs hard delete
|
||||
# Soft deletes trigger delete() which calls soft_delete()
|
||||
if hasattr(instance, 'is_deleted') and instance.is_deleted:
|
||||
return # Skip if already soft-deleted
|
||||
|
||||
try:
|
||||
# Update related Keywords - clear cluster FK and reset status
|
||||
updated_keywords = Keywords.objects.filter(cluster=instance).update(
|
||||
cluster=None,
|
||||
status='new'
|
||||
)
|
||||
if updated_keywords:
|
||||
logger.info(
|
||||
f"[Cascade] Cluster '{instance.name}' (ID: {instance.id}) deleted: "
|
||||
f"Reset {updated_keywords} keywords to status='new', cluster=NULL"
|
||||
)
|
||||
|
||||
# Update related ContentIdeas - clear cluster FK and reset status
|
||||
updated_ideas = ContentIdeas.objects.filter(keyword_cluster=instance).update(
|
||||
keyword_cluster=None,
|
||||
status='new'
|
||||
)
|
||||
if updated_ideas:
|
||||
logger.info(
|
||||
f"[Cascade] Cluster '{instance.name}' (ID: {instance.id}) deleted: "
|
||||
f"Reset {updated_ideas} content ideas to status='new', keyword_cluster=NULL"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Cascade] Error handling cluster deletion cascade: {e}", exc_info=True)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender='planner.ContentIdeas')
|
||||
def handle_idea_soft_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
When a ContentIdea is deleted:
|
||||
- Set Tasks.idea = NULL (don't delete tasks, they may have content)
|
||||
- Log orphaned tasks
|
||||
"""
|
||||
from igny8_core.business.content.models import Tasks
|
||||
|
||||
if hasattr(instance, 'is_deleted') and instance.is_deleted:
|
||||
return
|
||||
|
||||
try:
|
||||
# Update related Tasks - clear idea FK
|
||||
updated_tasks = Tasks.objects.filter(idea=instance).update(idea=None)
|
||||
if updated_tasks:
|
||||
logger.info(
|
||||
f"[Cascade] ContentIdea '{instance.idea_title}' (ID: {instance.id}) deleted: "
|
||||
f"Cleared idea reference from {updated_tasks} tasks"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Cascade] Error handling content idea deletion cascade: {e}", exc_info=True)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender='writer.Tasks')
|
||||
def handle_task_soft_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
When a Task is deleted:
|
||||
- Set Content.task = NULL
|
||||
"""
|
||||
from igny8_core.business.content.models import Content
|
||||
|
||||
if hasattr(instance, 'is_deleted') and instance.is_deleted:
|
||||
return
|
||||
|
||||
try:
|
||||
# Update related Content - clear task FK
|
||||
updated_content = Content.objects.filter(task=instance).update(task=None)
|
||||
if updated_content:
|
||||
logger.info(
|
||||
f"[Cascade] Task '{instance.title}' (ID: {instance.id}) deleted: "
|
||||
f"Cleared task reference from {updated_content} content items"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Cascade] Error handling task deletion cascade: {e}", exc_info=True)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender='writer.Content')
|
||||
def handle_content_soft_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
When Content is deleted:
|
||||
- Soft delete related Images (cascade soft delete)
|
||||
- Clear PublishingRecord references
|
||||
"""
|
||||
from igny8_core.business.content.models import Images
|
||||
|
||||
if hasattr(instance, 'is_deleted') and instance.is_deleted:
|
||||
return
|
||||
|
||||
try:
|
||||
# Soft delete related Images
|
||||
related_images = Images.objects.filter(content=instance)
|
||||
for image in related_images:
|
||||
image.soft_delete(reason='cascade_from_content')
|
||||
|
||||
count = related_images.count()
|
||||
if count:
|
||||
logger.info(
|
||||
f"[Cascade] Content '{instance.title}' (ID: {instance.id}) deleted: "
|
||||
f"Soft deleted {count} related images"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Cascade] Error handling content deletion cascade: {e}", exc_info=True)
|
||||
Reference in New Issue
Block a user