fixes
This commit is contained in:
@@ -117,7 +117,7 @@ class AutomationLogger:
|
|||||||
Returns:
|
Returns:
|
||||||
List of log lines (newest first)
|
List of log lines (newest first)
|
||||||
"""
|
"""
|
||||||
log_file = os.path.join(self._get_run_dir(account_id, site_id, run_id), 'automation_run.log')
|
log_file = os.path.join(self._get_run_dir(account_id, site_id, str(run_id)), 'automation_run.log')
|
||||||
|
|
||||||
if not os.path.exists(log_file):
|
if not os.path.exists(log_file):
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -218,6 +218,29 @@ class AutomationService:
|
|||||||
keyword_ids = list(pending_keywords.values_list('id', flat=True))
|
keyword_ids = list(pending_keywords.values_list('id', flat=True))
|
||||||
|
|
||||||
for i in range(0, len(keyword_ids), actual_batch_size):
|
for i in range(0, len(keyword_ids), actual_batch_size):
|
||||||
|
# Check if automation should stop (paused or cancelled)
|
||||||
|
should_stop, reason = self._check_should_stop()
|
||||||
|
if should_stop:
|
||||||
|
self.logger.log_stage_progress(
|
||||||
|
self.run.run_id, self.account.id, self.site.id,
|
||||||
|
stage_number, f"Stage {reason} - saving progress ({keywords_processed} keywords processed)"
|
||||||
|
)
|
||||||
|
# Save current progress
|
||||||
|
credits_used = self._get_credits_used() - credits_before
|
||||||
|
time_elapsed = self._format_time_elapsed(start_time)
|
||||||
|
self.run.stage_1_result = {
|
||||||
|
'keywords_processed': keywords_processed,
|
||||||
|
'clusters_created': clusters_created,
|
||||||
|
'batches_run': batches_run,
|
||||||
|
'credits_used': credits_used,
|
||||||
|
'time_elapsed': time_elapsed,
|
||||||
|
'partial': True,
|
||||||
|
'stopped_reason': reason
|
||||||
|
}
|
||||||
|
self.run.total_credits_used += credits_used
|
||||||
|
self.run.save()
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
batch = keyword_ids[i:i + actual_batch_size]
|
batch = keyword_ids[i:i + actual_batch_size]
|
||||||
batch_num = (i // actual_batch_size) + 1
|
batch_num = (i // actual_batch_size) + 1
|
||||||
@@ -354,13 +377,11 @@ class AutomationService:
|
|||||||
stage_number, "Pre-stage validation passed: 0 keywords pending from Stage 1"
|
stage_number, "Pre-stage validation passed: 0 keywords pending from Stage 1"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Query clusters without ideas
|
# Query clusters with status='new'
|
||||||
pending_clusters = Clusters.objects.filter(
|
pending_clusters = Clusters.objects.filter(
|
||||||
site=self.site,
|
site=self.site,
|
||||||
status='new',
|
status='new',
|
||||||
disabled=False
|
disabled=False
|
||||||
).exclude(
|
|
||||||
ideas__isnull=False
|
|
||||||
)
|
)
|
||||||
|
|
||||||
total_count = pending_clusters.count()
|
total_count = pending_clusters.count()
|
||||||
@@ -386,6 +407,33 @@ class AutomationService:
|
|||||||
credits_before = self._get_credits_used()
|
credits_before = self._get_credits_used()
|
||||||
|
|
||||||
for cluster in pending_clusters:
|
for cluster in pending_clusters:
|
||||||
|
# Check if automation should stop (paused or cancelled)
|
||||||
|
should_stop, reason = self._check_should_stop()
|
||||||
|
if should_stop:
|
||||||
|
self.logger.log_stage_progress(
|
||||||
|
self.run.run_id, self.account.id, self.site.id,
|
||||||
|
stage_number, f"Stage {reason} - saving progress ({clusters_processed} clusters processed)"
|
||||||
|
)
|
||||||
|
# Save current progress
|
||||||
|
ideas_created = ContentIdeas.objects.filter(
|
||||||
|
site=self.site,
|
||||||
|
created_at__gte=self.run.started_at
|
||||||
|
).count()
|
||||||
|
credits_used = self._get_credits_used() - credits_before
|
||||||
|
time_elapsed = self._format_time_elapsed(start_time)
|
||||||
|
from django.utils import timezone
|
||||||
|
self.run.stage_2_result = {
|
||||||
|
'clusters_processed': clusters_processed,
|
||||||
|
'ideas_created': ideas_created,
|
||||||
|
'credits_used': credits_used,
|
||||||
|
'time_elapsed': time_elapsed,
|
||||||
|
'partial': True,
|
||||||
|
'stopped_reason': reason
|
||||||
|
}
|
||||||
|
self.run.total_credits_used += credits_used
|
||||||
|
self.run.save()
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.logger.log_stage_progress(
|
self.logger.log_stage_progress(
|
||||||
self.run.run_id, self.account.id, self.site.id,
|
self.run.run_id, self.account.id, self.site.id,
|
||||||
@@ -523,6 +571,29 @@ class AutomationService:
|
|||||||
idea_list = list(pending_ideas)
|
idea_list = list(pending_ideas)
|
||||||
|
|
||||||
for i in range(0, len(idea_list), batch_size):
|
for i in range(0, len(idea_list), batch_size):
|
||||||
|
# Check if automation should stop (paused or cancelled)
|
||||||
|
should_stop, reason = self._check_should_stop()
|
||||||
|
if should_stop:
|
||||||
|
self.logger.log_stage_progress(
|
||||||
|
self.run.run_id, self.account.id, self.site.id,
|
||||||
|
stage_number, f"Stage {reason} - saving progress ({ideas_processed} ideas processed, {tasks_created} tasks created)"
|
||||||
|
)
|
||||||
|
# Save current progress
|
||||||
|
time_elapsed = self._format_time_elapsed(start_time)
|
||||||
|
from django.utils import timezone
|
||||||
|
self.run.stage_3_result = {
|
||||||
|
'ideas_processed': ideas_processed,
|
||||||
|
'tasks_created': tasks_created,
|
||||||
|
'batches_run': batches_run,
|
||||||
|
'started_at': self.run.started_at.isoformat(),
|
||||||
|
'completed_at': timezone.now().isoformat(),
|
||||||
|
'time_elapsed': time_elapsed,
|
||||||
|
'partial': True,
|
||||||
|
'stopped_reason': reason
|
||||||
|
}
|
||||||
|
self.run.save()
|
||||||
|
return
|
||||||
|
|
||||||
batch = idea_list[i:i + batch_size]
|
batch = idea_list[i:i + batch_size]
|
||||||
batch_num = (i // batch_size) + 1
|
batch_num = (i // batch_size) + 1
|
||||||
total_batches = (len(idea_list) + batch_size - 1) // batch_size
|
total_batches = (len(idea_list) + batch_size - 1) // batch_size
|
||||||
@@ -658,6 +729,33 @@ class AutomationService:
|
|||||||
total_tasks = len(task_list)
|
total_tasks = len(task_list)
|
||||||
|
|
||||||
for idx, task in enumerate(task_list, 1):
|
for idx, task in enumerate(task_list, 1):
|
||||||
|
# Check if automation should stop (paused or cancelled)
|
||||||
|
should_stop, reason = self._check_should_stop()
|
||||||
|
if should_stop:
|
||||||
|
self.logger.log_stage_progress(
|
||||||
|
self.run.run_id, self.account.id, self.site.id,
|
||||||
|
stage_number, f"Stage {reason} - saving progress ({tasks_processed} tasks processed)"
|
||||||
|
)
|
||||||
|
# Save current progress
|
||||||
|
content_created = Content.objects.filter(
|
||||||
|
site=self.site,
|
||||||
|
created_at__gte=self.run.started_at
|
||||||
|
).count()
|
||||||
|
credits_used = self._get_credits_used() - credits_before
|
||||||
|
time_elapsed = self._format_time_elapsed(start_time)
|
||||||
|
from django.utils import timezone
|
||||||
|
self.run.stage_4_result = {
|
||||||
|
'tasks_processed': tasks_processed,
|
||||||
|
'content_created': content_created,
|
||||||
|
'credits_used': credits_used,
|
||||||
|
'time_elapsed': time_elapsed,
|
||||||
|
'partial': True,
|
||||||
|
'stopped_reason': reason
|
||||||
|
}
|
||||||
|
self.run.total_credits_used += credits_used
|
||||||
|
self.run.save()
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.logger.log_stage_progress(
|
self.logger.log_stage_progress(
|
||||||
self.run.run_id, self.account.id, self.site.id,
|
self.run.run_id, self.account.id, self.site.id,
|
||||||
@@ -834,6 +932,33 @@ class AutomationService:
|
|||||||
total_content = len(content_list)
|
total_content = len(content_list)
|
||||||
|
|
||||||
for idx, content in enumerate(content_list, 1):
|
for idx, content in enumerate(content_list, 1):
|
||||||
|
# Check if automation should stop (paused or cancelled)
|
||||||
|
should_stop, reason = self._check_should_stop()
|
||||||
|
if should_stop:
|
||||||
|
self.logger.log_stage_progress(
|
||||||
|
self.run.run_id, self.account.id, self.site.id,
|
||||||
|
stage_number, f"Stage {reason} - saving progress ({content_processed} content processed)"
|
||||||
|
)
|
||||||
|
# Save current progress
|
||||||
|
prompts_created = Images.objects.filter(
|
||||||
|
site=self.site,
|
||||||
|
created_at__gte=self.run.started_at
|
||||||
|
).count()
|
||||||
|
credits_used = self._get_credits_used() - credits_before
|
||||||
|
time_elapsed = self._format_time_elapsed(start_time)
|
||||||
|
from django.utils import timezone
|
||||||
|
self.run.stage_5_result = {
|
||||||
|
'content_processed': content_processed,
|
||||||
|
'prompts_created': prompts_created,
|
||||||
|
'credits_used': credits_used,
|
||||||
|
'time_elapsed': time_elapsed,
|
||||||
|
'partial': True,
|
||||||
|
'stopped_reason': reason
|
||||||
|
}
|
||||||
|
self.run.total_credits_used += credits_used
|
||||||
|
self.run.save()
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.logger.log_stage_progress(
|
self.logger.log_stage_progress(
|
||||||
self.run.run_id, self.account.id, self.site.id,
|
self.run.run_id, self.account.id, self.site.id,
|
||||||
@@ -981,6 +1106,40 @@ class AutomationService:
|
|||||||
total_images = len(image_list)
|
total_images = len(image_list)
|
||||||
|
|
||||||
for idx, image in enumerate(image_list, 1):
|
for idx, image in enumerate(image_list, 1):
|
||||||
|
# Check if automation should stop (paused or cancelled)
|
||||||
|
should_stop, reason = self._check_should_stop()
|
||||||
|
if should_stop:
|
||||||
|
self.logger.log_stage_progress(
|
||||||
|
self.run.run_id, self.account.id, self.site.id,
|
||||||
|
stage_number, f"Stage {reason} - saving progress ({images_processed} images processed)"
|
||||||
|
)
|
||||||
|
# Save current progress
|
||||||
|
images_generated = Images.objects.filter(
|
||||||
|
site=self.site,
|
||||||
|
status='completed',
|
||||||
|
created_at__gte=self.run.started_at
|
||||||
|
).count()
|
||||||
|
content_moved = Content.objects.filter(
|
||||||
|
site=self.site,
|
||||||
|
status='review',
|
||||||
|
updated_at__gte=self.run.started_at
|
||||||
|
).count()
|
||||||
|
credits_used = self._get_credits_used() - credits_before
|
||||||
|
time_elapsed = self._format_time_elapsed(start_time)
|
||||||
|
from django.utils import timezone
|
||||||
|
self.run.stage_6_result = {
|
||||||
|
'images_processed': images_processed,
|
||||||
|
'images_generated': images_generated,
|
||||||
|
'content_moved_to_review': content_moved,
|
||||||
|
'credits_used': credits_used,
|
||||||
|
'time_elapsed': time_elapsed,
|
||||||
|
'partial': True,
|
||||||
|
'stopped_reason': reason
|
||||||
|
}
|
||||||
|
self.run.total_credits_used += credits_used
|
||||||
|
self.run.save()
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content_title = image.content.title if image.content else 'Unknown'
|
content_title = image.content.title if image.content else 'Unknown'
|
||||||
self.logger.log_stage_progress(
|
self.logger.log_stage_progress(
|
||||||
|
|||||||
@@ -152,12 +152,6 @@ def resume_automation_task(self, run_id: str):
|
|||||||
# Alias for continue_automation_task (same as resume)
|
# Alias for continue_automation_task (same as resume)
|
||||||
continue_automation_task = resume_automation_task
|
continue_automation_task = resume_automation_task
|
||||||
|
|
||||||
# Release lock
|
|
||||||
from django.core.cache import cache
|
|
||||||
cache.delete(f'automation_lock_{run.site.id}')
|
|
||||||
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def _calculate_next_run(config: AutomationConfig, now: datetime) -> datetime:
|
def _calculate_next_run(config: AutomationConfig, now: datetime) -> datetime:
|
||||||
"""Calculate next run time based on frequency"""
|
"""Calculate next run time based on frequency"""
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ class AutomationViewSet(viewsets.ViewSet):
|
|||||||
|
|
||||||
lines = int(request.query_params.get('lines', 100))
|
lines = int(request.query_params.get('lines', 100))
|
||||||
log_text = service.logger.get_activity_log(
|
log_text = service.logger.get_activity_log(
|
||||||
run_id, run.account.id, run.site.id, lines
|
run.account.id, run.site.id, run_id, lines
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
|
|||||||
54
backend/igny8_core/business/billing/views.py
Normal file
54
backend/igny8_core/business/billing/views.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Billing API Views
|
||||||
|
Stub endpoints for billing pages
|
||||||
|
"""
|
||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
|
|
||||||
|
class BillingViewSet(viewsets.ViewSet):
|
||||||
|
"""Billing endpoints"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='account_balance')
|
||||||
|
def account_balance(self, request):
|
||||||
|
"""Get user's credit balance"""
|
||||||
|
return Response({
|
||||||
|
'credits': 0,
|
||||||
|
'subscription_plan': 'Free',
|
||||||
|
'monthly_credits_included': 0,
|
||||||
|
'bonus_credits': 0
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def transactions(self, request):
|
||||||
|
"""List credit transactions"""
|
||||||
|
return Response({
|
||||||
|
'results': [],
|
||||||
|
'count': 0
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def usage(self, request):
|
||||||
|
"""List credit usage"""
|
||||||
|
return Response({
|
||||||
|
'results': [],
|
||||||
|
'count': 0
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class AdminBillingViewSet(viewsets.ViewSet):
|
||||||
|
"""Admin billing endpoints"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def stats(self, request):
|
||||||
|
"""System billing stats"""
|
||||||
|
return Response({
|
||||||
|
'total_users': 0,
|
||||||
|
'active_users': 0,
|
||||||
|
'total_credits_issued': 0,
|
||||||
|
'total_credits_used': 0
|
||||||
|
})
|
||||||
14
backend/igny8_core/modules/billing/admin_urls.py
Normal file
14
backend/igny8_core/modules/billing/admin_urls.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import AdminBillingViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('billing/stats/', AdminBillingViewSet.as_view({'get': 'stats'}), name='admin-billing-stats'),
|
||||||
|
path('users/', AdminBillingViewSet.as_view({'get': 'list_users'}), name='admin-users-list'),
|
||||||
|
path('users/<int:user_id>/adjust-credits/', AdminBillingViewSet.as_view({'post': 'adjust_credits'}), name='admin-adjust-credits'),
|
||||||
|
path('credit-costs/', AdminBillingViewSet.as_view({'get': 'list_credit_costs', 'post': 'update_credit_costs'}), name='admin-credit-costs'),
|
||||||
|
]
|
||||||
|
|
||||||
|
urlpatterns += router.urls
|
||||||
@@ -3,7 +3,13 @@ URL patterns for billing module
|
|||||||
"""
|
"""
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from .views import CreditBalanceViewSet, CreditUsageViewSet, CreditTransactionViewSet
|
from .views import (
|
||||||
|
CreditBalanceViewSet,
|
||||||
|
CreditUsageViewSet,
|
||||||
|
CreditTransactionViewSet,
|
||||||
|
BillingOverviewViewSet,
|
||||||
|
AdminBillingViewSet
|
||||||
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'credits/balance', CreditBalanceViewSet, basename='credit-balance')
|
router.register(r'credits/balance', CreditBalanceViewSet, basename='credit-balance')
|
||||||
@@ -12,5 +18,13 @@ router.register(r'credits/transactions', CreditTransactionViewSet, basename='cre
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
|
# User-facing billing overview
|
||||||
|
path('account_balance/', BillingOverviewViewSet.as_view({'get': 'account_balance'}), name='account-balance'),
|
||||||
|
path('transactions/', CreditTransactionViewSet.as_view({'get': 'list'}), name='transactions'),
|
||||||
|
path('usage/', CreditUsageViewSet.as_view({'get': 'list'}), name='usage'),
|
||||||
|
# Admin billing endpoints
|
||||||
|
path('admin/billing/stats/', AdminBillingViewSet.as_view({'get': 'stats'}), name='admin-billing-stats'),
|
||||||
|
path('admin/users/', AdminBillingViewSet.as_view({'get': 'list_users'}), name='admin-users-list'),
|
||||||
|
path('admin/credit-costs/', AdminBillingViewSet.as_view({'get': 'credit_costs'}), name='admin-credit-costs'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -404,3 +404,170 @@ class CreditTransactionViewSet(AccountModelViewSet):
|
|||||||
|
|
||||||
return queryset.order_by('-created_at')
|
return queryset.order_by('-created_at')
|
||||||
|
|
||||||
|
|
||||||
|
class BillingOverviewViewSet(viewsets.ViewSet):
|
||||||
|
"""User-facing billing overview API"""
|
||||||
|
permission_classes = [IsAuthenticatedAndActive]
|
||||||
|
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||||
|
|
||||||
|
def account_balance(self, request):
|
||||||
|
"""Get account balance with subscription info"""
|
||||||
|
account = getattr(request, 'account', None) or request.user.account
|
||||||
|
|
||||||
|
# Get subscription plan
|
||||||
|
subscription_plan = 'Free'
|
||||||
|
monthly_credits_included = 0
|
||||||
|
if account.plan:
|
||||||
|
subscription_plan = account.plan.name
|
||||||
|
monthly_credits_included = account.plan.get_effective_credits_per_month()
|
||||||
|
|
||||||
|
# Calculate bonus credits (credits beyond monthly allowance)
|
||||||
|
bonus_credits = max(0, account.credits - monthly_credits_included)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'credits': account.credits or 0,
|
||||||
|
'subscription_plan': subscription_plan,
|
||||||
|
'monthly_credits_included': monthly_credits_included,
|
||||||
|
'bonus_credits': bonus_credits,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminBillingViewSet(viewsets.ViewSet):
|
||||||
|
"""Admin-only billing management API"""
|
||||||
|
permission_classes = [IsAuthenticatedAndActive, permissions.IsAdminUser]
|
||||||
|
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||||
|
|
||||||
|
def stats(self, request):
|
||||||
|
"""Get system-wide billing statistics"""
|
||||||
|
from igny8_core.auth.models import Account
|
||||||
|
|
||||||
|
total_users = Account.objects.filter(status='active').count()
|
||||||
|
active_users = Account.objects.filter(status='active').exclude(users__last_login__isnull=True).count()
|
||||||
|
|
||||||
|
total_credits_issued = Account.objects.aggregate(
|
||||||
|
total=Sum('credits')
|
||||||
|
)['total'] or 0
|
||||||
|
|
||||||
|
total_credits_used = CreditUsageLog.objects.aggregate(
|
||||||
|
total=Sum('credits_used')
|
||||||
|
)['total'] or 0
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'total_users': total_users,
|
||||||
|
'active_users': active_users,
|
||||||
|
'total_credits_issued': total_credits_issued,
|
||||||
|
'total_credits_used': total_credits_used,
|
||||||
|
})
|
||||||
|
|
||||||
|
def list_users(self, request):
|
||||||
|
"""List all users with credit information"""
|
||||||
|
from igny8_core.auth.models import Account
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
# Get search query from request
|
||||||
|
search = request.query_params.get('search', '')
|
||||||
|
|
||||||
|
queryset = Account.objects.filter(status='active').prefetch_related('users')
|
||||||
|
|
||||||
|
# Apply search filter
|
||||||
|
if search:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(user__username__icontains=search) |
|
||||||
|
Q(user__email__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
accounts = queryset[:100]
|
||||||
|
|
||||||
|
data = []
|
||||||
|
for acc in accounts:
|
||||||
|
user = acc.users.first() if acc.users.exists() else None
|
||||||
|
data.append({
|
||||||
|
'id': acc.id,
|
||||||
|
'username': user.username if user else 'N/A',
|
||||||
|
'email': user.email if user else 'N/A',
|
||||||
|
'credits': acc.credits or 0,
|
||||||
|
'subscription_plan': acc.plan.name if acc.plan else 'Free',
|
||||||
|
'is_active': acc.status == 'active',
|
||||||
|
'date_joined': acc.created_at
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({'results': data})
|
||||||
|
|
||||||
|
def adjust_credits(self, request, user_id):
|
||||||
|
"""Adjust credits for a specific user"""
|
||||||
|
from igny8_core.auth.models import Account
|
||||||
|
|
||||||
|
try:
|
||||||
|
account = Account.objects.get(id=user_id)
|
||||||
|
except Account.DoesNotExist:
|
||||||
|
return Response({'error': 'User not found'}, status=404)
|
||||||
|
|
||||||
|
amount = request.data.get('amount', 0)
|
||||||
|
reason = request.data.get('reason', 'Admin adjustment')
|
||||||
|
|
||||||
|
try:
|
||||||
|
amount = int(amount)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return Response({'error': 'Invalid amount'}, status=400)
|
||||||
|
|
||||||
|
# Adjust credits
|
||||||
|
old_balance = account.credits
|
||||||
|
account.credits = (account.credits or 0) + amount
|
||||||
|
account.save()
|
||||||
|
|
||||||
|
# Log the adjustment
|
||||||
|
CreditUsageLog.objects.create(
|
||||||
|
account=account,
|
||||||
|
operation_type='admin_adjustment',
|
||||||
|
credits_used=-amount, # Negative for additions
|
||||||
|
credits_balance_after=account.credits,
|
||||||
|
details={'reason': reason, 'old_balance': old_balance, 'adjusted_by': request.user.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'new_balance': account.credits,
|
||||||
|
'old_balance': old_balance,
|
||||||
|
'adjustment': amount
|
||||||
|
})
|
||||||
|
|
||||||
|
def list_credit_costs(self, request):
|
||||||
|
"""List credit cost configurations"""
|
||||||
|
from igny8_core.business.billing.models import CreditCostConfig
|
||||||
|
|
||||||
|
configs = CreditCostConfig.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
data = [{
|
||||||
|
'id': c.id,
|
||||||
|
'operation_type': c.operation_type,
|
||||||
|
'display_name': c.display_name,
|
||||||
|
'credits_cost': c.credits_cost,
|
||||||
|
'unit': c.unit,
|
||||||
|
'is_active': c.is_active,
|
||||||
|
'created_at': c.created_at
|
||||||
|
} for c in configs]
|
||||||
|
|
||||||
|
return Response({'results': data})
|
||||||
|
|
||||||
|
def update_credit_costs(self, request):
|
||||||
|
"""Update credit cost configurations"""
|
||||||
|
from igny8_core.business.billing.models import CreditCostConfig
|
||||||
|
|
||||||
|
updates = request.data.get('updates', [])
|
||||||
|
|
||||||
|
for update in updates:
|
||||||
|
config_id = update.get('id')
|
||||||
|
new_cost = update.get('cost')
|
||||||
|
|
||||||
|
if config_id and new_cost is not None:
|
||||||
|
try:
|
||||||
|
config = CreditCostConfig.objects.get(id=config_id)
|
||||||
|
config.cost = new_cost
|
||||||
|
config.save()
|
||||||
|
except CreditCostConfig.DoesNotExist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return Response({'success': True})
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ urlpatterns = [
|
|||||||
path('api/v1/writer/', include('igny8_core.modules.writer.urls')),
|
path('api/v1/writer/', include('igny8_core.modules.writer.urls')),
|
||||||
path('api/v1/system/', include('igny8_core.modules.system.urls')),
|
path('api/v1/system/', include('igny8_core.modules.system.urls')),
|
||||||
path('api/v1/billing/', include('igny8_core.modules.billing.urls')), # Billing endpoints
|
path('api/v1/billing/', include('igny8_core.modules.billing.urls')), # Billing endpoints
|
||||||
|
path('api/v1/admin/', include('igny8_core.modules.billing.admin_urls')), # Admin billing
|
||||||
path('api/v1/automation/', include('igny8_core.business.automation.urls')), # Automation endpoints
|
path('api/v1/automation/', include('igny8_core.business.automation.urls')), # Automation endpoints
|
||||||
path('api/v1/linker/', include('igny8_core.modules.linker.urls')), # Linker endpoints
|
path('api/v1/linker/', include('igny8_core.modules.linker.urls')), # Linker endpoints
|
||||||
path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints
|
path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { AutomationConfig } from '../../services/automationService';
|
import { AutomationConfig } from '../../services/automationService';
|
||||||
|
import { Modal } from '../ui/modal';
|
||||||
|
import Button from '../ui/button/Button';
|
||||||
|
|
||||||
interface ConfigModalProps {
|
interface ConfigModalProps {
|
||||||
config: AutomationConfig;
|
config: AutomationConfig;
|
||||||
@@ -28,14 +30,24 @@ const ConfigModal: React.FC<ConfigModalProps> = ({ config, onSave, onCancel }) =
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSave(formData);
|
// Ensure delays are included in the save
|
||||||
|
const dataToSave = {
|
||||||
|
...formData,
|
||||||
|
within_stage_delay: formData.within_stage_delay || 3,
|
||||||
|
between_stage_delay: formData.between_stage_delay || 5,
|
||||||
|
};
|
||||||
|
console.log('Saving config with delays:', dataToSave);
|
||||||
|
onSave(dataToSave);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<Modal
|
||||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-screen overflow-y-auto">
|
isOpen={true}
|
||||||
<h2 className="text-2xl font-bold mb-4">Automation Configuration</h2>
|
onClose={onCancel}
|
||||||
|
className="max-w-4xl p-8"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-bold mb-6">Automation Configuration</h2>
|
||||||
|
<div className="max-h-[70vh] overflow-y-auto pr-2">{/* Added pr-2 for scrollbar padding */}
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{/* Enable/Disable */}
|
{/* Enable/Disable */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -270,23 +282,23 @@ const ConfigModal: React.FC<ConfigModalProps> = ({ config, onSave, onCancel }) =
|
|||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div className="flex justify-end gap-2 mt-6">
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
variant="primary"
|
||||||
>
|
>
|
||||||
Save Configuration
|
Save Configuration
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -92,3 +92,5 @@ export const Modal: React.FC<ModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ const AutomationPage: React.FC = () => {
|
|||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (currentRun && (currentRun.status === 'running' || currentRun.status === 'paused')) {
|
if (currentRun && (currentRun.status === 'running' || currentRun.status === 'paused')) {
|
||||||
|
// When automation is running, refresh both run and metrics
|
||||||
loadCurrentRun();
|
loadCurrentRun();
|
||||||
|
loadPipelineOverview();
|
||||||
|
loadMetrics(); // Add metrics refresh during run
|
||||||
} else {
|
} else {
|
||||||
loadPipelineOverview();
|
loadPipelineOverview();
|
||||||
}
|
}
|
||||||
@@ -172,6 +175,66 @@ const AutomationPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadMetrics = async () => {
|
||||||
|
if (!activeSite) return;
|
||||||
|
try {
|
||||||
|
const siteId = activeSite.id;
|
||||||
|
const [
|
||||||
|
keywordsTotalRes,
|
||||||
|
keywordsNewRes,
|
||||||
|
keywordsMappedRes,
|
||||||
|
clustersTotalRes,
|
||||||
|
clustersNewRes,
|
||||||
|
clustersMappedRes,
|
||||||
|
ideasTotalRes,
|
||||||
|
ideasNewRes,
|
||||||
|
ideasQueuedRes,
|
||||||
|
ideasCompletedRes,
|
||||||
|
tasksTotalRes,
|
||||||
|
contentTotalRes,
|
||||||
|
contentDraftRes,
|
||||||
|
contentReviewRes,
|
||||||
|
contentPublishedRes,
|
||||||
|
imagesTotalRes,
|
||||||
|
imagesPendingRes,
|
||||||
|
] = await Promise.all([
|
||||||
|
fetchKeywords({ page_size: 1, site_id: siteId }),
|
||||||
|
fetchKeywords({ page_size: 1, site_id: siteId, status: 'new' }),
|
||||||
|
fetchKeywords({ page_size: 1, site_id: siteId, status: 'mapped' }),
|
||||||
|
fetchClusters({ page_size: 1, site_id: siteId }),
|
||||||
|
fetchClusters({ page_size: 1, site_id: siteId, status: 'new' }),
|
||||||
|
fetchClusters({ page_size: 1, site_id: siteId, status: 'mapped' }),
|
||||||
|
fetchContentIdeas({ page_size: 1, site_id: siteId }),
|
||||||
|
fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'new' }),
|
||||||
|
fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'queued' }),
|
||||||
|
fetchContentIdeas({ page_size: 1, site_id: siteId, status: 'completed' }),
|
||||||
|
fetchTasks({ page_size: 1, site_id: siteId }),
|
||||||
|
fetchContent({ page_size: 1, site_id: siteId }),
|
||||||
|
fetchContent({ page_size: 1, site_id: siteId, status: 'draft' }),
|
||||||
|
fetchContent({ page_size: 1, site_id: siteId, status: 'review' }),
|
||||||
|
fetchContent({ page_size: 1, site_id: siteId, status: 'published' }),
|
||||||
|
fetchContentImages({ page_size: 1, site_id: siteId }),
|
||||||
|
fetchContentImages({ page_size: 1, site_id: siteId, status: 'pending' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setMetrics({
|
||||||
|
keywords: { total: keywordsTotalRes.count || 0, new: keywordsNewRes.count || 0, mapped: keywordsMappedRes.count || 0 },
|
||||||
|
clusters: { total: clustersTotalRes.count || 0, new: clustersNewRes.count || 0, mapped: clustersMappedRes.count || 0 },
|
||||||
|
ideas: { total: ideasTotalRes.count || 0, new: ideasNewRes.count || 0, queued: ideasQueuedRes.count || 0, completed: ideasCompletedRes.count || 0 },
|
||||||
|
tasks: { total: tasksTotalRes.count || 0 },
|
||||||
|
content: {
|
||||||
|
total: contentTotalRes.count || 0,
|
||||||
|
draft: contentDraftRes.count || 0,
|
||||||
|
review: contentReviewRes.count || 0,
|
||||||
|
published: contentPublishedRes.count || 0,
|
||||||
|
},
|
||||||
|
images: { total: imagesTotalRes.count || 0, pending: imagesPendingRes.count || 0 },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to fetch metrics', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRunNow = async () => {
|
const handleRunNow = async () => {
|
||||||
if (!activeSite) return;
|
if (!activeSite) return;
|
||||||
if (estimate && !estimate.sufficient) {
|
if (estimate && !estimate.sufficient) {
|
||||||
@@ -887,103 +950,9 @@ const AutomationPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Summary Card */}
|
|
||||||
{currentRun && (
|
|
||||||
<div className="relative rounded-xl border-2 border-slate-300 dark:border-gray-700 p-5 bg-gradient-to-br from-slate-100 to-slate-200 dark:from-gray-800/50 dark:to-gray-700/50">
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="text-sm font-bold text-gray-900 dark:text-white mb-1">Current Status</div>
|
|
||||||
<div className="text-xs font-medium text-gray-600 dark:text-gray-400">Run Summary</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 text-xs">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">Run ID:</span>
|
|
||||||
<span className="font-mono text-xs text-slate-900 dark:text-white">{currentRun.run_id.split('_').pop()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">Started:</span>
|
|
||||||
<span className="font-semibold text-slate-900 dark:text-white">
|
|
||||||
{new Date(currentRun.started_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">Current Stage:</span>
|
|
||||||
<span className="font-bold text-blue-600 dark:text-blue-400">{currentRun.current_stage}/7</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">Credits Used:</span>
|
|
||||||
<span className="font-bold text-brand-600 dark:text-brand-400">{currentRun.total_credits_used}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">Completion:</span>
|
|
||||||
<span className="font-bold text-slate-900 dark:text-white">{Math.round((currentRun.current_stage / 7) * 100)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-slate-300 dark:border-gray-600">
|
|
||||||
<div className={`
|
|
||||||
size-12 mx-auto rounded-full flex items-center justify-center
|
|
||||||
${currentRun.status === 'running'
|
|
||||||
? 'bg-gradient-to-br from-blue-500 to-blue-600 animate-pulse'
|
|
||||||
: currentRun.status === 'paused'
|
|
||||||
? 'bg-gradient-to-br from-amber-500 to-amber-600'
|
|
||||||
: 'bg-gradient-to-br from-success-500 to-success-600'
|
|
||||||
}
|
|
||||||
`}>
|
|
||||||
{currentRun.status === 'running' && <div className="size-3 bg-white rounded-full"></div>}
|
|
||||||
{currentRun.status === 'paused' && <ClockIcon className="size-6 text-white" />}
|
|
||||||
{currentRun.status === 'completed' && <CheckCircleIcon className="size-6 text-white" />}
|
|
||||||
</div>
|
|
||||||
<div className="text-center mt-2 text-xs font-semibold text-gray-700 dark:text-gray-300 capitalize">
|
|
||||||
{currentRun.status}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ComponentCard>
|
</ComponentCard>
|
||||||
|
|
||||||
{/* Current Run Details */}
|
|
||||||
{currentRun && (
|
|
||||||
<ComponentCard title={`Current Run: ${currentRun.run_id}`} desc="Live automation progress and detailed results">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<EnhancedMetricCard
|
|
||||||
title="Status"
|
|
||||||
value={currentRun.status}
|
|
||||||
icon={
|
|
||||||
currentRun.status === 'running' ? <BoltIcon className="size-6" /> :
|
|
||||||
currentRun.status === 'paused' ? <ClockIcon className="size-6" /> :
|
|
||||||
currentRun.status === 'completed' ? <CheckCircleIcon className="size-6" /> :
|
|
||||||
<FileTextIcon className="size-6" />
|
|
||||||
}
|
|
||||||
accentColor={
|
|
||||||
currentRun.status === 'running' ? 'blue' :
|
|
||||||
currentRun.status === 'paused' ? 'orange' :
|
|
||||||
currentRun.status === 'completed' ? 'success' : 'red'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<EnhancedMetricCard
|
|
||||||
title="Started"
|
|
||||||
value={new Date(currentRun.started_at).toLocaleTimeString()}
|
|
||||||
icon={<ClockIcon className="size-6" />}
|
|
||||||
accentColor="blue"
|
|
||||||
/>
|
|
||||||
<EnhancedMetricCard
|
|
||||||
title="Credits Used"
|
|
||||||
value={currentRun.total_credits_used}
|
|
||||||
icon={<BoltIcon className="size-6" />}
|
|
||||||
accentColor="blue"
|
|
||||||
/>
|
|
||||||
<EnhancedMetricCard
|
|
||||||
title="Trigger"
|
|
||||||
value={currentRun.trigger_type}
|
|
||||||
icon={<PaperPlaneIcon className="size-6" />}
|
|
||||||
accentColor="green"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ComponentCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activity Log */}
|
{/* Activity Log */}
|
||||||
{currentRun && <ActivityLog runId={currentRun.run_id} />}
|
{currentRun && <ActivityLog runId={currentRun.run_id} />}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import Badge from '../../components/ui/badge/Badge';
|
|||||||
import {
|
import {
|
||||||
BoltIcon,
|
BoltIcon,
|
||||||
DollarLineIcon,
|
DollarLineIcon,
|
||||||
ClockIcon,
|
TimeIcon,
|
||||||
CheckCircleIcon
|
CheckCircleIcon
|
||||||
} from '../../icons';
|
} from '../../icons';
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ const CreditsAndBilling: React.FC = () => {
|
|||||||
<EnhancedMetricCard
|
<EnhancedMetricCard
|
||||||
title="Total This Month"
|
title="Total This Month"
|
||||||
value={usageLogs.reduce((sum, log) => sum + log.credits_used, 0)}
|
value={usageLogs.reduce((sum, log) => sum + log.credits_used, 0)}
|
||||||
icon={ClockIcon}
|
icon={TimeIcon}
|
||||||
color="purple"
|
color="purple"
|
||||||
iconColor="text-purple-500"
|
iconColor="text-purple-500"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user