Merge branch 'feature/phase-0-credit-system'
This commit is contained in:
Binary file not shown.
@@ -192,6 +192,31 @@ class AIEngine:
|
||||
self.step_tracker.add_request_step("PREP", "success", prep_message)
|
||||
self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta())
|
||||
|
||||
# Phase 2.5: CREDIT CHECK - Check credits before AI call (25%)
|
||||
if self.account:
|
||||
try:
|
||||
from igny8_core.modules.billing.services import CreditService
|
||||
from igny8_core.modules.billing.exceptions import InsufficientCreditsError
|
||||
|
||||
# Map function name to operation type
|
||||
operation_type = self._get_operation_type(function_name)
|
||||
|
||||
# Calculate estimated cost
|
||||
estimated_amount = self._get_estimated_amount(function_name, data, payload)
|
||||
|
||||
# Check credits BEFORE AI call
|
||||
CreditService.check_credits(self.account, operation_type, estimated_amount)
|
||||
|
||||
logger.info(f"[AIEngine] Credit check passed: {operation_type}, estimated amount: {estimated_amount}")
|
||||
except InsufficientCreditsError as e:
|
||||
error_msg = str(e)
|
||||
error_type = 'InsufficientCreditsError'
|
||||
logger.error(f"[AIEngine] {error_msg}")
|
||||
return self._handle_error(error_msg, fn, error_type=error_type)
|
||||
except Exception as e:
|
||||
logger.warning(f"[AIEngine] Failed to check credits: {e}", exc_info=True)
|
||||
# Don't fail the operation if credit check fails (for backward compatibility)
|
||||
|
||||
# Phase 3: AI_CALL - Provider API Call (25-70%)
|
||||
# Validate account exists before proceeding
|
||||
if not self.account:
|
||||
@@ -325,37 +350,45 @@ class AIEngine:
|
||||
# Store save_msg for use in DONE phase
|
||||
final_save_msg = save_msg
|
||||
|
||||
# Track credit usage after successful save
|
||||
# Phase 5.5: DEDUCT CREDITS - Deduct credits after successful save
|
||||
if self.account and raw_response:
|
||||
try:
|
||||
from igny8_core.modules.billing.services import CreditService
|
||||
from igny8_core.modules.billing.models import CreditUsageLog
|
||||
from igny8_core.modules.billing.exceptions import InsufficientCreditsError
|
||||
|
||||
# Calculate credits used (based on tokens or fixed cost)
|
||||
credits_used = self._calculate_credits_for_clustering(
|
||||
keyword_count=len(data.get('keywords', [])) if isinstance(data, dict) else len(data) if isinstance(data, list) else 1,
|
||||
tokens=raw_response.get('total_tokens', 0),
|
||||
cost=raw_response.get('cost', 0)
|
||||
)
|
||||
# Map function name to operation type
|
||||
operation_type = self._get_operation_type(function_name)
|
||||
|
||||
# Log credit usage (don't deduct from account.credits, just log)
|
||||
CreditUsageLog.objects.create(
|
||||
# Calculate actual amount based on results
|
||||
actual_amount = self._get_actual_amount(function_name, save_result, parsed, data)
|
||||
|
||||
# Deduct credits using the new convenience method
|
||||
CreditService.deduct_credits_for_operation(
|
||||
account=self.account,
|
||||
operation_type='clustering',
|
||||
credits_used=credits_used,
|
||||
operation_type=operation_type,
|
||||
amount=actual_amount,
|
||||
cost_usd=raw_response.get('cost'),
|
||||
model_used=raw_response.get('model', ''),
|
||||
tokens_input=raw_response.get('tokens_input', 0),
|
||||
tokens_output=raw_response.get('tokens_output', 0),
|
||||
related_object_type='cluster',
|
||||
related_object_type=self._get_related_object_type(function_name),
|
||||
related_object_id=save_result.get('id') or save_result.get('cluster_id') or save_result.get('task_id'),
|
||||
metadata={
|
||||
'function_name': function_name,
|
||||
'clusters_created': clusters_created,
|
||||
'keywords_updated': keywords_updated,
|
||||
'function_name': function_name
|
||||
'count': count,
|
||||
**save_result
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"[AIEngine] Credits deducted: {operation_type}, amount: {actual_amount}")
|
||||
except InsufficientCreditsError as e:
|
||||
# This shouldn't happen since we checked before, but log it
|
||||
logger.error(f"[AIEngine] Insufficient credits during deduction: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to log credit usage: {e}", exc_info=True)
|
||||
logger.warning(f"[AIEngine] Failed to deduct credits: {e}", exc_info=True)
|
||||
# Don't fail the operation if credit deduction fails (for backward compatibility)
|
||||
|
||||
# Phase 6: DONE - Finalization (98-100%)
|
||||
success_msg = f"Task completed: {final_save_msg}" if 'final_save_msg' in locals() else "Task completed successfully"
|
||||
@@ -453,18 +486,74 @@ class AIEngine:
|
||||
# Don't fail the task if logging fails
|
||||
logger.warning(f"Failed to log to database: {e}")
|
||||
|
||||
def _calculate_credits_for_clustering(self, keyword_count, tokens, cost):
|
||||
"""Calculate credits used for clustering operation"""
|
||||
# Use plan's cost per request if available, otherwise calculate from tokens
|
||||
if self.account and hasattr(self.account, 'plan') and self.account.plan:
|
||||
plan = self.account.plan
|
||||
# Check if plan has ai_cost_per_request config
|
||||
if hasattr(plan, 'ai_cost_per_request') and plan.ai_cost_per_request:
|
||||
cluster_cost = plan.ai_cost_per_request.get('cluster', 0)
|
||||
if cluster_cost:
|
||||
return int(cluster_cost)
|
||||
|
||||
# Fallback: 1 credit per 30 keywords (minimum 1)
|
||||
credits = max(1, int(keyword_count / 30))
|
||||
return credits
|
||||
def _get_operation_type(self, function_name):
|
||||
"""Map function name to operation type for credit system"""
|
||||
mapping = {
|
||||
'auto_cluster': 'clustering',
|
||||
'generate_ideas': 'idea_generation',
|
||||
'generate_content': 'content_generation',
|
||||
'generate_image_prompts': 'image_prompt_extraction',
|
||||
'generate_images': 'image_generation',
|
||||
}
|
||||
return mapping.get(function_name, function_name)
|
||||
|
||||
def _get_estimated_amount(self, function_name, data, payload):
|
||||
"""Get estimated amount for credit calculation (before operation)"""
|
||||
if function_name == 'generate_content':
|
||||
# Estimate word count from task or default
|
||||
if isinstance(data, dict):
|
||||
return data.get('estimated_word_count', 1000)
|
||||
return 1000 # Default estimate
|
||||
elif function_name == 'generate_images':
|
||||
# Count images to generate
|
||||
if isinstance(payload, dict):
|
||||
image_ids = payload.get('image_ids', [])
|
||||
return len(image_ids) if image_ids else 1
|
||||
return 1
|
||||
elif function_name == 'generate_ideas':
|
||||
# Count clusters
|
||||
if isinstance(data, dict) and 'cluster_data' in data:
|
||||
return len(data['cluster_data'])
|
||||
return 1
|
||||
# For fixed cost operations (clustering, image_prompt_extraction), return None
|
||||
return None
|
||||
|
||||
def _get_actual_amount(self, function_name, save_result, parsed, data):
|
||||
"""Get actual amount for credit calculation (after operation)"""
|
||||
if function_name == 'generate_content':
|
||||
# Get actual word count from saved content
|
||||
if isinstance(save_result, dict):
|
||||
word_count = save_result.get('word_count')
|
||||
if word_count:
|
||||
return word_count
|
||||
# Fallback: estimate from parsed content
|
||||
if isinstance(parsed, dict) and 'content' in parsed:
|
||||
content = parsed['content']
|
||||
return len(content.split()) if isinstance(content, str) else 1000
|
||||
return 1000
|
||||
elif function_name == 'generate_images':
|
||||
# Count successfully generated images
|
||||
count = save_result.get('count', 0)
|
||||
if count > 0:
|
||||
return count
|
||||
return 1
|
||||
elif function_name == 'generate_ideas':
|
||||
# Count ideas generated
|
||||
count = save_result.get('count', 0)
|
||||
if count > 0:
|
||||
return count
|
||||
return 1
|
||||
# For fixed cost operations, return None
|
||||
return None
|
||||
|
||||
def _get_related_object_type(self, function_name):
|
||||
"""Get related object type for credit logging"""
|
||||
mapping = {
|
||||
'auto_cluster': 'cluster',
|
||||
'generate_ideas': 'content_idea',
|
||||
'generate_content': 'content',
|
||||
'generate_image_prompts': 'image',
|
||||
'generate_images': 'image',
|
||||
}
|
||||
return mapping.get(function_name, 'unknown')
|
||||
|
||||
|
||||
@@ -19,21 +19,9 @@ class PlanAdmin(admin.ModelAdmin):
|
||||
('Plan Info', {
|
||||
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active')
|
||||
}),
|
||||
('User / Site Limits', {
|
||||
('Account Management Limits', {
|
||||
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles')
|
||||
}),
|
||||
('Planner Limits', {
|
||||
'fields': ('max_keywords', 'max_clusters', 'daily_cluster_limit', 'daily_keyword_import_limit', 'monthly_cluster_ai_credits')
|
||||
}),
|
||||
('Writer Limits', {
|
||||
'fields': ('daily_content_tasks', 'daily_ai_requests', 'monthly_word_count_limit', 'monthly_content_ai_credits')
|
||||
}),
|
||||
('Image Limits', {
|
||||
'fields': ('monthly_image_count', 'monthly_image_ai_credits', 'max_images_per_task', 'image_model_choices')
|
||||
}),
|
||||
('AI Controls', {
|
||||
'fields': ('daily_ai_request_limit', 'monthly_ai_credit_limit')
|
||||
}),
|
||||
('Billing & Credits', {
|
||||
'fields': ('included_credits', 'extra_credit_price', 'allow_credit_topup', 'auto_credit_topup_threshold', 'auto_credit_topup_amount', 'credits_per_month')
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Generated manually for Phase 0: Remove plan operation limit fields (credit-only system)
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0013_remove_ai_cost_per_request'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Remove Planner Limits
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='max_keywords',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='max_clusters',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='max_content_ideas',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='daily_cluster_limit',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='daily_keyword_import_limit',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='monthly_cluster_ai_credits',
|
||||
),
|
||||
# Remove Writer Limits
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='daily_content_tasks',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='daily_ai_requests',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='monthly_word_count_limit',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='monthly_content_ai_credits',
|
||||
),
|
||||
# Remove Image Generation Limits
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='monthly_image_count',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='daily_image_generation_limit',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='monthly_image_ai_credits',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='max_images_per_task',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='image_model_choices',
|
||||
),
|
||||
# Remove AI Request Controls
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='daily_ai_request_limit',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='plan',
|
||||
name='monthly_ai_credit_limit',
|
||||
),
|
||||
]
|
||||
|
||||
@@ -93,8 +93,8 @@ class Account(models.Model):
|
||||
|
||||
class Plan(models.Model):
|
||||
"""
|
||||
Subscription plan model with comprehensive limits and features.
|
||||
Plans define limits for users, sites, content generation, AI usage, and billing.
|
||||
Subscription plan model - Phase 0: Credit-only system.
|
||||
Plans define credits, billing, and account management limits only.
|
||||
"""
|
||||
BILLING_CYCLE_CHOICES = [
|
||||
('monthly', 'Monthly'),
|
||||
@@ -110,7 +110,7 @@ class Plan(models.Model):
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# User / Site / Scope Limits
|
||||
# Account Management Limits (kept - not operation limits)
|
||||
max_users = models.IntegerField(default=1, validators=[MinValueValidator(1)], help_text="Total users allowed per account")
|
||||
max_sites = models.IntegerField(
|
||||
default=1,
|
||||
@@ -120,32 +120,7 @@ class Plan(models.Model):
|
||||
max_industries = models.IntegerField(default=None, null=True, blank=True, validators=[MinValueValidator(1)], help_text="Optional limit for industries/sectors")
|
||||
max_author_profiles = models.IntegerField(default=5, validators=[MinValueValidator(0)], help_text="Limit for saved writing styles")
|
||||
|
||||
# Planner Limits
|
||||
max_keywords = models.IntegerField(default=1000, validators=[MinValueValidator(0)], help_text="Total keywords allowed (global limit)")
|
||||
max_clusters = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Total clusters allowed (global)")
|
||||
max_content_ideas = models.IntegerField(default=300, validators=[MinValueValidator(0)], help_text="Total content ideas allowed (global limit)")
|
||||
daily_cluster_limit = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max clusters that can be created per day")
|
||||
daily_keyword_import_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="SeedKeywords import limit per day")
|
||||
monthly_cluster_ai_credits = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="AI credits allocated for clustering")
|
||||
|
||||
# Writer Limits
|
||||
daily_content_tasks = models.IntegerField(default=10, validators=[MinValueValidator(0)], help_text="Max number of content tasks (blogs) per day")
|
||||
daily_ai_requests = models.IntegerField(default=50, validators=[MinValueValidator(0)], help_text="Total AI executions (content + idea + image) allowed per day")
|
||||
monthly_word_count_limit = models.IntegerField(default=50000, validators=[MinValueValidator(0)], help_text="Monthly word limit (for generated content)")
|
||||
monthly_content_ai_credits = models.IntegerField(default=200, validators=[MinValueValidator(0)], help_text="AI credit pool for content generation")
|
||||
|
||||
# Image Generation Limits
|
||||
monthly_image_count = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Max images per month")
|
||||
daily_image_generation_limit = models.IntegerField(default=25, validators=[MinValueValidator(0)], help_text="Max images that can be generated per day")
|
||||
monthly_image_ai_credits = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="AI credit pool for image generation")
|
||||
max_images_per_task = models.IntegerField(default=4, validators=[MinValueValidator(1)], help_text="Max images per content task")
|
||||
image_model_choices = models.JSONField(default=list, blank=True, help_text="Allowed image models (e.g., ['dalle3', 'hidream'])")
|
||||
|
||||
# AI Request Controls
|
||||
daily_ai_request_limit = models.IntegerField(default=100, validators=[MinValueValidator(0)], help_text="Global daily AI request cap")
|
||||
monthly_ai_credit_limit = models.IntegerField(default=500, validators=[MinValueValidator(0)], help_text="Unified credit ceiling per month (all AI functions)")
|
||||
|
||||
# Billing & Add-ons
|
||||
# Billing & Credits (Phase 0: Credit-only system)
|
||||
included_credits = models.IntegerField(default=0, validators=[MinValueValidator(0)], help_text="Monthly credits included")
|
||||
extra_credit_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.01, help_text="Price per additional credit")
|
||||
allow_credit_topup = models.BooleanField(default=True, help_text="Can user purchase more credits?")
|
||||
|
||||
@@ -11,10 +11,10 @@ class PlanSerializer(serializers.ModelSerializer):
|
||||
model = Plan
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'price', 'billing_cycle', 'features', 'is_active',
|
||||
'max_users', 'max_sites', 'max_keywords', 'max_clusters', 'max_content_ideas',
|
||||
'monthly_word_count_limit', 'monthly_ai_credit_limit', 'monthly_image_count',
|
||||
'daily_content_tasks', 'daily_ai_request_limit', 'daily_image_generation_limit',
|
||||
'included_credits', 'image_model_choices', 'credits_per_month'
|
||||
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
|
||||
'included_credits', 'extra_credit_price', 'allow_credit_topup',
|
||||
'auto_credit_topup_threshold', 'auto_credit_topup_amount',
|
||||
'stripe_product_id', 'stripe_price_id', 'credits_per_month'
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ from .views import (
|
||||
SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet,
|
||||
IndustryViewSet, SeedKeywordViewSet
|
||||
)
|
||||
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer
|
||||
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer, RefreshTokenSerializer
|
||||
from .models import User
|
||||
from .utils import generate_access_token, get_token_expiry, decode_token
|
||||
import jwt
|
||||
|
||||
router = DefaultRouter()
|
||||
# Main structure: Groups, Users, Accounts, Subscriptions, Site User Access
|
||||
@@ -78,7 +80,7 @@ class LoginView(APIView):
|
||||
password = serializer.validated_data['password']
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
user = User.objects.select_related('account', 'account__plan').get(email=email)
|
||||
except User.DoesNotExist:
|
||||
return error_response(
|
||||
error='Invalid credentials',
|
||||
@@ -107,9 +109,17 @@ class LoginView(APIView):
|
||||
user_data = user_serializer.data
|
||||
except Exception as e:
|
||||
# Fallback if serializer fails (e.g., missing account_id column)
|
||||
# Log the error for debugging but don't fail the login
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"UserSerializer failed for user {user.id}: {e}", exc_info=True)
|
||||
|
||||
# Ensure username is properly set (use email prefix if username is empty/default)
|
||||
username = user.username if user.username and user.username != 'user' else user.email.split('@')[0]
|
||||
|
||||
user_data = {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'username': username,
|
||||
'email': user.email,
|
||||
'role': user.role,
|
||||
'account': None,
|
||||
@@ -119,12 +129,10 @@ class LoginView(APIView):
|
||||
return success_response(
|
||||
data={
|
||||
'user': user_data,
|
||||
'tokens': {
|
||||
'access': access_token,
|
||||
'refresh': refresh_token,
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
}
|
||||
'access': access_token,
|
||||
'refresh': refresh_token,
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
},
|
||||
message='Login successful',
|
||||
request=request
|
||||
@@ -180,6 +188,84 @@ class ChangePasswordView(APIView):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=['Authentication'],
|
||||
summary='Refresh Token',
|
||||
description='Refresh access token using refresh token'
|
||||
)
|
||||
class RefreshTokenView(APIView):
|
||||
"""Refresh access token endpoint."""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
serializer = RefreshTokenSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
refresh_token = serializer.validated_data['refresh']
|
||||
|
||||
try:
|
||||
# Decode and validate refresh token
|
||||
payload = decode_token(refresh_token)
|
||||
|
||||
# Verify it's a refresh token
|
||||
if payload.get('type') != 'refresh':
|
||||
return error_response(
|
||||
error='Invalid token type',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get user
|
||||
user_id = payload.get('user_id')
|
||||
account_id = payload.get('account_id')
|
||||
|
||||
try:
|
||||
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return error_response(
|
||||
error='User not found',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get account
|
||||
account = None
|
||||
if account_id:
|
||||
try:
|
||||
from .models import Account
|
||||
account = Account.objects.get(id=account_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not account:
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
# Generate new access token
|
||||
access_token = generate_access_token(user, account)
|
||||
access_expires_at = get_token_expiry('access')
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'access': access_token,
|
||||
'access_expires_at': access_expires_at.isoformat()
|
||||
},
|
||||
request=request
|
||||
)
|
||||
|
||||
except jwt.InvalidTokenError:
|
||||
return error_response(
|
||||
error='Invalid or expired refresh token',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(exclude=True) # Exclude from public API documentation - internal authenticated endpoint
|
||||
class MeView(APIView):
|
||||
"""Get current user information."""
|
||||
@@ -201,6 +287,7 @@ urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
|
||||
path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'),
|
||||
path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'),
|
||||
path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
|
||||
path('me/', MeView.as_view(), name='auth-me'),
|
||||
]
|
||||
|
||||
@@ -933,12 +933,10 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
return success_response(
|
||||
data={
|
||||
'user': user_serializer.data,
|
||||
'tokens': {
|
||||
'access': access_token,
|
||||
'refresh': refresh_token,
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
}
|
||||
'access': access_token,
|
||||
'refresh': refresh_token,
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
},
|
||||
message='Login successful',
|
||||
request=request
|
||||
|
||||
@@ -3,6 +3,7 @@ Celery configuration for IGNY8
|
||||
"""
|
||||
import os
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings')
|
||||
@@ -18,6 +19,13 @@ app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
# Load task modules from all registered Django apps.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
# Celery Beat schedule for periodic tasks
|
||||
app.conf.beat_schedule = {
|
||||
'replenish-monthly-credits': {
|
||||
'task': 'igny8_core.modules.billing.tasks.replenish_monthly_credits',
|
||||
'schedule': crontab(hour=0, minute=0, day_of_month=1), # First day of month at midnight
|
||||
},
|
||||
}
|
||||
|
||||
@app.task(bind=True, ignore_result=True)
|
||||
def debug_task(self):
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
"""
|
||||
Credit Cost Constants
|
||||
Phase 0: Credit-only system costs per operation
|
||||
"""
|
||||
CREDIT_COSTS = {
|
||||
'clustering': {
|
||||
'base': 1, # 1 credit per 30 keywords
|
||||
'per_keyword': 1 / 30,
|
||||
},
|
||||
'ideas': {
|
||||
'base': 1, # 1 credit per idea
|
||||
},
|
||||
'content': {
|
||||
'base': 3, # 3 credits per full blog post
|
||||
},
|
||||
'images': {
|
||||
'base': 1, # 1 credit per image
|
||||
},
|
||||
'reparse': {
|
||||
'base': 1, # 1 credit per reparse
|
||||
},
|
||||
'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
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,65 @@ class CreditService:
|
||||
"""Service for managing credits"""
|
||||
|
||||
@staticmethod
|
||||
def check_credits(account, required_credits):
|
||||
def get_credit_cost(operation_type, amount=None):
|
||||
"""
|
||||
Check if account has enough credits.
|
||||
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
|
||||
@@ -51,8 +107,8 @@ class CreditService:
|
||||
Returns:
|
||||
int: New credit balance
|
||||
"""
|
||||
# Check sufficient credits
|
||||
CreditService.check_credits(account, amount)
|
||||
# Check sufficient credits (legacy: amount is already calculated)
|
||||
CreditService.check_credits_legacy(account, amount)
|
||||
|
||||
# Deduct from account.credits
|
||||
account.credits -= amount
|
||||
@@ -84,6 +140,61 @@ class CreditService:
|
||||
|
||||
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):
|
||||
@@ -120,6 +231,7 @@ class CreditService:
|
||||
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
|
||||
@@ -131,31 +243,22 @@ class CreditService:
|
||||
Raises:
|
||||
CreditCalculationError: If calculation fails
|
||||
"""
|
||||
if operation_type not in CREDIT_COSTS:
|
||||
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
|
||||
|
||||
cost_config = CREDIT_COSTS[operation_type]
|
||||
|
||||
if operation_type == 'clustering':
|
||||
# 1 credit per 30 keywords
|
||||
keyword_count = kwargs.get('keyword_count', 0)
|
||||
credits = max(1, int(keyword_count * cost_config['per_keyword']))
|
||||
return credits
|
||||
elif operation_type == 'ideas':
|
||||
# 1 credit per idea
|
||||
idea_count = kwargs.get('idea_count', 1)
|
||||
return cost_config['base'] * idea_count
|
||||
# Map legacy operation types
|
||||
if operation_type == 'ideas':
|
||||
operation_type = 'idea_generation'
|
||||
elif operation_type == 'content':
|
||||
# 3 credits per content piece
|
||||
content_count = kwargs.get('content_count', 1)
|
||||
return cost_config['base'] * content_count
|
||||
operation_type = 'content_generation'
|
||||
elif operation_type == 'images':
|
||||
# 1 credit per image
|
||||
image_count = kwargs.get('image_count', 1)
|
||||
return cost_config['base'] * image_count
|
||||
elif operation_type == 'reparse':
|
||||
# 1 credit per reparse
|
||||
return cost_config['base']
|
||||
operation_type = 'image_generation'
|
||||
|
||||
return cost_config['base']
|
||||
# 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)
|
||||
|
||||
|
||||
99
backend/igny8_core/modules/billing/tasks.py
Normal file
99
backend/igny8_core/modules/billing/tasks.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Celery tasks for billing operations
|
||||
"""
|
||||
import logging
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from igny8_core.auth.models import Account
|
||||
from .services import CreditService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name='igny8_core.modules.billing.tasks.replenish_monthly_credits')
|
||||
def replenish_monthly_credits():
|
||||
"""
|
||||
Replenish monthly credits for all active accounts.
|
||||
Runs on the first day of each month at midnight.
|
||||
|
||||
For each active account with a plan:
|
||||
- Adds plan.included_credits to account.credits
|
||||
- Creates a CreditTransaction record
|
||||
- Logs the replenishment
|
||||
"""
|
||||
logger.info("=" * 80)
|
||||
logger.info("MONTHLY CREDIT REPLENISHMENT TASK STARTED")
|
||||
logger.info(f"Timestamp: {timezone.now()}")
|
||||
logger.info("=" * 80)
|
||||
|
||||
# Get all active accounts with plans
|
||||
accounts = Account.objects.filter(
|
||||
status='active',
|
||||
plan__isnull=False
|
||||
).select_related('plan')
|
||||
|
||||
total_accounts = accounts.count()
|
||||
logger.info(f"Found {total_accounts} active accounts with plans")
|
||||
|
||||
replenished = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
for account in accounts:
|
||||
try:
|
||||
plan = account.plan
|
||||
|
||||
# Get monthly credits from plan
|
||||
monthly_credits = plan.included_credits or plan.credits_per_month or 0
|
||||
|
||||
if monthly_credits <= 0:
|
||||
logger.info(f"Account {account.id} ({account.name}): Plan has no included credits, skipping")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Add credits using CreditService
|
||||
with transaction.atomic():
|
||||
new_balance = CreditService.add_credits(
|
||||
account=account,
|
||||
amount=monthly_credits,
|
||||
transaction_type='subscription',
|
||||
description=f"Monthly credit replenishment - {plan.name} plan",
|
||||
metadata={
|
||||
'plan_id': plan.id,
|
||||
'plan_name': plan.name,
|
||||
'monthly_credits': monthly_credits,
|
||||
'replenishment_date': timezone.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Account {account.id} ({account.name}): "
|
||||
f"Added {monthly_credits} credits (balance: {new_balance})"
|
||||
)
|
||||
replenished += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Account {account.id} ({account.name}): "
|
||||
f"Failed to replenish credits: {str(e)}",
|
||||
exc_info=True
|
||||
)
|
||||
errors += 1
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("MONTHLY CREDIT REPLENISHMENT TASK COMPLETED")
|
||||
logger.info(f"Total accounts: {total_accounts}")
|
||||
logger.info(f"Replenished: {replenished}")
|
||||
logger.info(f"Skipped: {skipped}")
|
||||
logger.info(f"Errors: {errors}")
|
||||
logger.info("=" * 80)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'total_accounts': total_accounts,
|
||||
'replenished': replenished,
|
||||
'skipped': skipped,
|
||||
'errors': errors
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ class CreditBalanceViewSet(viewsets.ViewSet):
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get plan credits per month
|
||||
plan_credits_per_month = account.plan.credits_per_month if account.plan else 0
|
||||
# Get plan credits per month (use get_effective_credits_per_month for Phase 0 compatibility)
|
||||
plan_credits_per_month = account.plan.get_effective_credits_per_month() if account.plan else 0
|
||||
|
||||
# Calculate credits used this month
|
||||
now = timezone.now()
|
||||
@@ -207,7 +207,10 @@ class CreditUsageViewSet(AccountModelViewSet):
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='limits', url_name='limits')
|
||||
def limits(self, request):
|
||||
"""Get plan limits and current usage statistics"""
|
||||
"""
|
||||
Get account limits and credit usage statistics (Phase 0: Credit-only system).
|
||||
Returns account management limits and credit usage only.
|
||||
"""
|
||||
# Try multiple ways to get account
|
||||
account = getattr(request, 'account', None)
|
||||
|
||||
@@ -225,13 +228,7 @@ class CreditUsageViewSet(AccountModelViewSet):
|
||||
except (AttributeError, UserModel.DoesNotExist, Exception) as e:
|
||||
account = None
|
||||
|
||||
# Debug logging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f'Limits endpoint - User: {getattr(request, "user", None)}, Account: {account}, Account has plan: {account.plan if account else False}')
|
||||
|
||||
if not account:
|
||||
logger.warning(f'No account found in limits endpoint')
|
||||
# Return empty limits instead of error - frontend will show "no data" message
|
||||
return success_response(data={'limits': []}, request=request)
|
||||
|
||||
@@ -241,115 +238,16 @@ class CreditUsageViewSet(AccountModelViewSet):
|
||||
return success_response(data={'limits': []}, request=request)
|
||||
|
||||
# Import models
|
||||
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
|
||||
from igny8_core.modules.writer.models import Tasks, Images
|
||||
from igny8_core.auth.models import User, Site
|
||||
|
||||
# Get current month boundaries
|
||||
now = timezone.now()
|
||||
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Calculate usage statistics
|
||||
limits_data = []
|
||||
|
||||
# Planner Limits
|
||||
keywords_count = Keywords.objects.filter(account=account).count()
|
||||
clusters_count = Clusters.objects.filter(account=account).count()
|
||||
content_ideas_count = ContentIdeas.objects.filter(account=account).count()
|
||||
clusters_today = Clusters.objects.filter(account=account, created_at__gte=start_of_day).count()
|
||||
|
||||
limits_data.extend([
|
||||
{
|
||||
'title': 'Keywords',
|
||||
'limit': plan.max_keywords or 0,
|
||||
'used': keywords_count,
|
||||
'available': max(0, (plan.max_keywords or 0) - keywords_count),
|
||||
'unit': 'keywords',
|
||||
'category': 'planner',
|
||||
'percentage': (keywords_count / (plan.max_keywords or 1)) * 100 if plan.max_keywords else 0
|
||||
},
|
||||
{
|
||||
'title': 'Clusters',
|
||||
'limit': plan.max_clusters or 0,
|
||||
'used': clusters_count,
|
||||
'available': max(0, (plan.max_clusters or 0) - clusters_count),
|
||||
'unit': 'clusters',
|
||||
'category': 'planner',
|
||||
'percentage': (clusters_count / (plan.max_clusters or 1)) * 100 if plan.max_clusters else 0
|
||||
},
|
||||
{
|
||||
'title': 'Content Ideas',
|
||||
'limit': plan.max_content_ideas or 0,
|
||||
'used': content_ideas_count,
|
||||
'available': max(0, (plan.max_content_ideas or 0) - content_ideas_count),
|
||||
'unit': 'ideas',
|
||||
'category': 'planner',
|
||||
'percentage': (content_ideas_count / (plan.max_content_ideas or 1)) * 100 if plan.max_content_ideas else 0
|
||||
},
|
||||
{
|
||||
'title': 'Daily Cluster Limit',
|
||||
'limit': plan.daily_cluster_limit or 0,
|
||||
'used': clusters_today,
|
||||
'available': max(0, (plan.daily_cluster_limit or 0) - clusters_today),
|
||||
'unit': 'per day',
|
||||
'category': 'planner',
|
||||
'percentage': (clusters_today / (plan.daily_cluster_limit or 1)) * 100 if plan.daily_cluster_limit else 0
|
||||
},
|
||||
])
|
||||
|
||||
# Writer Limits
|
||||
tasks_today = Tasks.objects.filter(account=account, created_at__gte=start_of_day).count()
|
||||
tasks_month = Tasks.objects.filter(account=account, created_at__gte=start_of_month)
|
||||
word_count_month = tasks_month.aggregate(total=Sum('word_count'))['total'] or 0
|
||||
|
||||
limits_data.extend([
|
||||
{
|
||||
'title': 'Monthly Word Count',
|
||||
'limit': plan.monthly_word_count_limit or 0,
|
||||
'used': word_count_month,
|
||||
'available': max(0, (plan.monthly_word_count_limit or 0) - word_count_month),
|
||||
'unit': 'words',
|
||||
'category': 'writer',
|
||||
'percentage': (word_count_month / (plan.monthly_word_count_limit or 1)) * 100 if plan.monthly_word_count_limit else 0
|
||||
},
|
||||
{
|
||||
'title': 'Daily Content Tasks',
|
||||
'limit': plan.daily_content_tasks or 0,
|
||||
'used': tasks_today,
|
||||
'available': max(0, (plan.daily_content_tasks or 0) - tasks_today),
|
||||
'unit': 'per day',
|
||||
'category': 'writer',
|
||||
'percentage': (tasks_today / (plan.daily_content_tasks or 1)) * 100 if plan.daily_content_tasks else 0
|
||||
},
|
||||
])
|
||||
|
||||
# Image Limits
|
||||
images_month = Images.objects.filter(account=account, created_at__gte=start_of_month).count()
|
||||
images_today = Images.objects.filter(account=account, created_at__gte=start_of_day).count()
|
||||
|
||||
limits_data.extend([
|
||||
{
|
||||
'title': 'Monthly Images',
|
||||
'limit': plan.monthly_image_count or 0,
|
||||
'used': images_month,
|
||||
'available': max(0, (plan.monthly_image_count or 0) - images_month),
|
||||
'unit': 'images',
|
||||
'category': 'images',
|
||||
'percentage': (images_month / (plan.monthly_image_count or 1)) * 100 if plan.monthly_image_count else 0
|
||||
},
|
||||
{
|
||||
'title': 'Daily Image Generation',
|
||||
'limit': plan.daily_image_generation_limit or 0,
|
||||
'used': images_today,
|
||||
'available': max(0, (plan.daily_image_generation_limit or 0) - images_today),
|
||||
'unit': 'per day',
|
||||
'category': 'images',
|
||||
'percentage': (images_today / (plan.daily_image_generation_limit or 1)) * 100 if plan.daily_image_generation_limit else 0
|
||||
},
|
||||
])
|
||||
|
||||
# AI Credits
|
||||
# Credit Usage (Phase 0: Credit-only system)
|
||||
credits_used_month = CreditUsageLog.objects.filter(
|
||||
account=account,
|
||||
created_at__gte=start_of_month
|
||||
@@ -358,64 +256,89 @@ class CreditUsageViewSet(AccountModelViewSet):
|
||||
# Get credits by operation type
|
||||
cluster_credits = CreditUsageLog.objects.filter(
|
||||
account=account,
|
||||
operation_type='clustering',
|
||||
operation_type__in=['clustering'],
|
||||
created_at__gte=start_of_month
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
content_credits = CreditUsageLog.objects.filter(
|
||||
account=account,
|
||||
operation_type='content',
|
||||
operation_type__in=['content', 'content_generation'],
|
||||
created_at__gte=start_of_month
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
image_credits = CreditUsageLog.objects.filter(
|
||||
account=account,
|
||||
operation_type='image',
|
||||
operation_type__in=['images', 'image_generation', 'image_prompt_extraction'],
|
||||
created_at__gte=start_of_month
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
plan_credits = plan.monthly_ai_credit_limit or plan.credits_per_month or 0
|
||||
idea_credits = CreditUsageLog.objects.filter(
|
||||
account=account,
|
||||
operation_type__in=['ideas', 'idea_generation'],
|
||||
created_at__gte=start_of_month
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
# Use included_credits from plan (Phase 0: Credit-only)
|
||||
plan_credits = plan.included_credits or plan.credits_per_month or 0
|
||||
|
||||
limits_data.extend([
|
||||
{
|
||||
'title': 'Monthly AI Credits',
|
||||
'title': 'Monthly Credits',
|
||||
'limit': plan_credits,
|
||||
'used': credits_used_month,
|
||||
'available': max(0, plan_credits - credits_used_month),
|
||||
'unit': 'credits',
|
||||
'category': 'ai',
|
||||
'category': 'credits',
|
||||
'percentage': (credits_used_month / plan_credits * 100) if plan_credits else 0
|
||||
},
|
||||
{
|
||||
'title': 'Content AI Credits',
|
||||
'limit': plan.monthly_content_ai_credits or 0,
|
||||
'used': content_credits,
|
||||
'available': max(0, (plan.monthly_content_ai_credits or 0) - content_credits),
|
||||
'title': 'Current Balance',
|
||||
'limit': None, # No limit - shows current balance
|
||||
'used': None,
|
||||
'available': account.credits,
|
||||
'unit': 'credits',
|
||||
'category': 'ai',
|
||||
'percentage': (content_credits / (plan.monthly_content_ai_credits or 1)) * 100 if plan.monthly_content_ai_credits else 0
|
||||
'category': 'credits',
|
||||
'percentage': None
|
||||
},
|
||||
{
|
||||
'title': 'Image AI Credits',
|
||||
'limit': plan.monthly_image_ai_credits or 0,
|
||||
'used': image_credits,
|
||||
'available': max(0, (plan.monthly_image_ai_credits or 0) - image_credits),
|
||||
'unit': 'credits',
|
||||
'category': 'ai',
|
||||
'percentage': (image_credits / (plan.monthly_image_ai_credits or 1)) * 100 if plan.monthly_image_ai_credits else 0
|
||||
},
|
||||
{
|
||||
'title': 'Cluster AI Credits',
|
||||
'limit': plan.monthly_cluster_ai_credits or 0,
|
||||
'title': 'Clustering Credits',
|
||||
'limit': None,
|
||||
'used': cluster_credits,
|
||||
'available': max(0, (plan.monthly_cluster_ai_credits or 0) - cluster_credits),
|
||||
'available': None,
|
||||
'unit': 'credits',
|
||||
'category': 'ai',
|
||||
'percentage': (cluster_credits / (plan.monthly_cluster_ai_credits or 1)) * 100 if plan.monthly_cluster_ai_credits else 0
|
||||
'category': 'credits',
|
||||
'percentage': None
|
||||
},
|
||||
{
|
||||
'title': 'Content Generation Credits',
|
||||
'limit': None,
|
||||
'used': content_credits,
|
||||
'available': None,
|
||||
'unit': 'credits',
|
||||
'category': 'credits',
|
||||
'percentage': None
|
||||
},
|
||||
{
|
||||
'title': 'Image Generation Credits',
|
||||
'limit': None,
|
||||
'used': image_credits,
|
||||
'available': None,
|
||||
'unit': 'credits',
|
||||
'category': 'credits',
|
||||
'percentage': None
|
||||
},
|
||||
{
|
||||
'title': 'Idea Generation Credits',
|
||||
'limit': None,
|
||||
'used': idea_credits,
|
||||
'available': None,
|
||||
'unit': 'credits',
|
||||
'category': 'credits',
|
||||
'percentage': None
|
||||
},
|
||||
])
|
||||
|
||||
# General Limits
|
||||
# Account Management Limits (kept - not operation limits)
|
||||
users_count = User.objects.filter(account=account).count()
|
||||
sites_count = Site.objects.filter(account=account).count()
|
||||
|
||||
@@ -426,7 +349,7 @@ class CreditUsageViewSet(AccountModelViewSet):
|
||||
'used': users_count,
|
||||
'available': max(0, (plan.max_users or 0) - users_count),
|
||||
'unit': 'users',
|
||||
'category': 'general',
|
||||
'category': 'account',
|
||||
'percentage': (users_count / (plan.max_users or 1)) * 100 if plan.max_users else 0
|
||||
},
|
||||
{
|
||||
@@ -435,7 +358,7 @@ class CreditUsageViewSet(AccountModelViewSet):
|
||||
'used': sites_count,
|
||||
'available': max(0, (plan.max_sites or 0) - sites_count),
|
||||
'unit': 'sites',
|
||||
'category': 'general',
|
||||
'category': 'account',
|
||||
'percentage': (sites_count / (plan.max_sites or 1)) * 100 if plan.max_sites else 0
|
||||
},
|
||||
])
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated manually for Phase 0: Module Enable Settings
|
||||
# Using RunSQL to create table directly to avoid model resolution issues with new unified API model
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('system', '0006_alter_systemstatus_unique_together_and_more'),
|
||||
('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Create table using raw SQL to avoid model resolution issues
|
||||
# The model state is automatically discovered from models.py
|
||||
migrations.RunSQL(
|
||||
sql="""
|
||||
CREATE TABLE IF NOT EXISTS igny8_module_enable_settings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
planner_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
writer_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
thinker_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
automation_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
site_builder_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
linker_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
optimizer_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
publisher_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
tenant_id BIGINT NOT NULL REFERENCES igny8_tenants(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS igny8_module_enable_settings_tenant_id_idx ON igny8_module_enable_settings(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS igny8_module_enable_settings_account_created_idx ON igny8_module_enable_settings(tenant_id, created_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS unique_account_module_enable_settings ON igny8_module_enable_settings(tenant_id);
|
||||
""",
|
||||
reverse_sql="DROP TABLE IF EXISTS igny8_module_enable_settings CASCADE;",
|
||||
),
|
||||
]
|
||||
@@ -6,7 +6,7 @@ from igny8_core.auth.models import AccountBaseModel
|
||||
|
||||
# Import settings models
|
||||
from .settings_models import (
|
||||
SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||
SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Settings Models Admin
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from igny8_core.admin.base import AccountAdminMixin
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
|
||||
|
||||
|
||||
@admin.register(SystemSettings)
|
||||
|
||||
@@ -92,6 +92,46 @@ class ModuleSettings(BaseSettings):
|
||||
return f"ModuleSetting: {self.module_name} - {self.key}"
|
||||
|
||||
|
||||
class ModuleEnableSettings(AccountBaseModel):
|
||||
"""Module enable/disable settings per account"""
|
||||
planner_enabled = models.BooleanField(default=True, help_text="Enable Planner module")
|
||||
writer_enabled = models.BooleanField(default=True, help_text="Enable Writer module")
|
||||
thinker_enabled = models.BooleanField(default=True, help_text="Enable Thinker module")
|
||||
automation_enabled = models.BooleanField(default=True, help_text="Enable Automation module")
|
||||
site_builder_enabled = models.BooleanField(default=True, help_text="Enable Site Builder module")
|
||||
linker_enabled = models.BooleanField(default=True, help_text="Enable Linker module")
|
||||
optimizer_enabled = models.BooleanField(default=True, help_text="Enable Optimizer module")
|
||||
publisher_enabled = models.BooleanField(default=True, help_text="Enable Publisher module")
|
||||
|
||||
class Meta:
|
||||
db_table = 'igny8_module_enable_settings'
|
||||
unique_together = [['account']] # One record per account
|
||||
|
||||
def __str__(self):
|
||||
account = getattr(self, 'account', None)
|
||||
return f"ModuleEnableSettings: {account.name if account else 'No Account'}"
|
||||
|
||||
@classmethod
|
||||
def get_or_create_for_account(cls, account):
|
||||
"""Get or create module enable settings for an account"""
|
||||
settings, created = cls.objects.get_or_create(account=account)
|
||||
return settings
|
||||
|
||||
def is_module_enabled(self, module_name):
|
||||
"""Check if a module is enabled"""
|
||||
mapping = {
|
||||
'planner': self.planner_enabled,
|
||||
'writer': self.writer_enabled,
|
||||
'thinker': self.thinker_enabled,
|
||||
'automation': self.automation_enabled,
|
||||
'site_builder': self.site_builder_enabled,
|
||||
'linker': self.linker_enabled,
|
||||
'optimizer': self.optimizer_enabled,
|
||||
'publisher': self.publisher_enabled,
|
||||
}
|
||||
return mapping.get(module_name, True) # Default to enabled if unknown
|
||||
|
||||
|
||||
# AISettings extends IntegrationSettings (which already exists)
|
||||
# We'll create it as a separate model that can reference IntegrationSettings
|
||||
class AISettings(AccountBaseModel):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Serializers for Settings Models
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
|
||||
from .validators import validate_settings_schema
|
||||
|
||||
|
||||
@@ -58,6 +58,17 @@ class ModuleSettingsSerializer(serializers.ModelSerializer):
|
||||
return value
|
||||
|
||||
|
||||
class ModuleEnableSettingsSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ModuleEnableSettings
|
||||
fields = [
|
||||
'id', 'planner_enabled', 'writer_enabled', 'thinker_enabled',
|
||||
'automation_enabled', 'site_builder_enabled', 'linker_enabled',
|
||||
'optimizer_enabled', 'publisher_enabled', 'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at', 'account']
|
||||
|
||||
|
||||
class AISettingsSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AISettings
|
||||
|
||||
@@ -13,10 +13,10 @@ from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAu
|
||||
from igny8_core.api.pagination import CustomPageNumberPagination
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, ModuleEnableSettings, AISettings
|
||||
from .settings_serializers import (
|
||||
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
|
||||
ModuleSettingsSerializer, AISettingsSerializer
|
||||
ModuleSettingsSerializer, ModuleEnableSettingsSerializer, AISettingsSerializer
|
||||
)
|
||||
|
||||
|
||||
@@ -235,6 +235,15 @@ class ModuleSettingsViewSet(AccountModelViewSet):
|
||||
|
||||
def retrieve(self, request, pk=None):
|
||||
"""Get setting by key (pk can be key string)"""
|
||||
# Special case: if pk is "enable", this is likely a routing conflict
|
||||
# The correct endpoint is /settings/modules/enable/ which should go to ModuleEnableSettingsViewSet
|
||||
if pk == 'enable':
|
||||
return error_response(
|
||||
error='Use /api/v1/system/settings/modules/enable/ endpoint for module enable settings',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
try:
|
||||
# Try to get by ID first
|
||||
@@ -276,6 +285,171 @@ class ModuleSettingsViewSet(AccountModelViewSet):
|
||||
serializer.save(account=account)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['System']),
|
||||
retrieve=extend_schema(tags=['System']),
|
||||
update=extend_schema(tags=['System']),
|
||||
partial_update=extend_schema(tags=['System']),
|
||||
)
|
||||
class ModuleEnableSettingsViewSet(AccountModelViewSet):
|
||||
"""
|
||||
ViewSet for managing module enable/disable settings
|
||||
Unified API Standard v1.0 compliant
|
||||
One record per account
|
||||
Read access: All authenticated users
|
||||
Write access: Admins/Owners only
|
||||
"""
|
||||
queryset = ModuleEnableSettings.objects.all()
|
||||
serializer_class = ModuleEnableSettingsSerializer
|
||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||
throttle_scope = 'system'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def get_permissions(self):
|
||||
"""
|
||||
Allow read access to all authenticated users,
|
||||
but restrict write access to admins/owners
|
||||
"""
|
||||
if self.action in ['list', 'retrieve', 'get_current']:
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
else:
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner]
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get module enable settings for current account"""
|
||||
# Return queryset filtered by account - but list() will handle get_or_create
|
||||
queryset = super().get_queryset()
|
||||
# Filter by account if available
|
||||
account = getattr(self.request, 'account', None)
|
||||
if not account:
|
||||
user = getattr(self.request, 'user', None)
|
||||
if user:
|
||||
account = getattr(user, 'account', None)
|
||||
if account:
|
||||
queryset = queryset.filter(account=account)
|
||||
return queryset
|
||||
|
||||
@action(detail=False, methods=['get', 'put'], url_path='current', url_name='current')
|
||||
def get_current(self, request):
|
||||
"""Get or update current account's module enable settings"""
|
||||
if request.method == 'GET':
|
||||
return self.list(request)
|
||||
else:
|
||||
return self.update(request, pk=None)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Get or create module enable settings for current account"""
|
||||
try:
|
||||
account = getattr(request, 'account', None)
|
||||
if not account:
|
||||
user = getattr(request, 'user', None)
|
||||
if user and hasattr(user, 'account'):
|
||||
account = user.account
|
||||
|
||||
if not account:
|
||||
return error_response(
|
||||
error='Account not found',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Check if table exists (migration might not have been run)
|
||||
try:
|
||||
# Get or create settings for account (one per account)
|
||||
try:
|
||||
settings = ModuleEnableSettings.objects.get(account=account)
|
||||
except ModuleEnableSettings.DoesNotExist:
|
||||
# Create default settings for account
|
||||
settings = ModuleEnableSettings.objects.create(account=account)
|
||||
|
||||
serializer = self.get_serializer(settings)
|
||||
return success_response(data=serializer.data, request=request)
|
||||
except Exception as db_error:
|
||||
# Check if it's a "table does not exist" error
|
||||
error_str = str(db_error)
|
||||
if 'does not exist' in error_str.lower() or 'relation' in error_str.lower():
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"ModuleEnableSettings table does not exist. Migration 0007_add_module_enable_settings needs to be run: {error_str}")
|
||||
return error_response(
|
||||
error='Module enable settings table not found. Please run migration: python manage.py migrate igny8_core_modules_system 0007',
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
request=request
|
||||
)
|
||||
# Re-raise other database errors
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_trace = traceback.format_exc()
|
||||
return error_response(
|
||||
error=f'Failed to load module enable settings: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
def retrieve(self, request, pk=None, *args, **kwargs):
|
||||
"""Get module enable settings for current account"""
|
||||
try:
|
||||
account = getattr(request, 'account', None)
|
||||
if not account:
|
||||
user = getattr(request, 'user', None)
|
||||
if user:
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
if not account:
|
||||
return error_response(
|
||||
error='Account not found',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get or create settings for account
|
||||
settings, created = ModuleEnableSettings.objects.get_or_create(account=account)
|
||||
serializer = self.get_serializer(settings)
|
||||
return success_response(data=serializer.data, request=request)
|
||||
except Exception as e:
|
||||
return error_response(
|
||||
error=f'Failed to load module enable settings: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
def update(self, request, pk=None):
|
||||
"""Update module enable settings for current account"""
|
||||
account = getattr(request, 'account', None)
|
||||
if not account:
|
||||
user = getattr(request, 'user', None)
|
||||
if user:
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
if not account:
|
||||
return error_response(
|
||||
error='Account not found',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get or create settings for account
|
||||
settings = ModuleEnableSettings.get_or_create_for_account(account)
|
||||
serializer = self.get_serializer(settings, data=request.data, partial=True)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return success_response(data=serializer.data, request=request)
|
||||
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
def partial_update(self, request, pk=None):
|
||||
"""Partial update module enable settings"""
|
||||
return self.update(request, pk)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['System']),
|
||||
create=extend_schema(tags=['System']),
|
||||
|
||||
@@ -7,7 +7,7 @@ from .views import AIPromptViewSet, AuthorProfileViewSet, StrategyViewSet, syste
|
||||
from .integration_views import IntegrationSettingsViewSet
|
||||
from .settings_views import (
|
||||
SystemSettingsViewSet, AccountSettingsViewSet, UserSettingsViewSet,
|
||||
ModuleSettingsViewSet, AISettingsViewSet
|
||||
ModuleSettingsViewSet, ModuleEnableSettingsViewSet, AISettingsViewSet
|
||||
)
|
||||
router = DefaultRouter()
|
||||
router.register(r'prompts', AIPromptViewSet, basename='prompts')
|
||||
@@ -16,6 +16,7 @@ router.register(r'strategies', StrategyViewSet, basename='strategy')
|
||||
router.register(r'settings/system', SystemSettingsViewSet, basename='system-settings')
|
||||
router.register(r'settings/account', AccountSettingsViewSet, basename='account-settings')
|
||||
router.register(r'settings/user', UserSettingsViewSet, basename='user-settings')
|
||||
# Register ModuleSettingsViewSet first
|
||||
router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-settings')
|
||||
router.register(r'settings/ai', AISettingsViewSet, basename='ai-settings')
|
||||
|
||||
@@ -49,7 +50,20 @@ integration_image_gen_settings_viewset = IntegrationSettingsViewSet.as_view({
|
||||
'get': 'get_image_generation_settings',
|
||||
})
|
||||
|
||||
# Custom view for module enable settings to avoid URL routing conflict with ModuleSettingsViewSet
|
||||
# This must be defined as a custom path BEFORE router.urls to ensure it matches first
|
||||
# The update method handles pk=None correctly, so we can use as_view
|
||||
module_enable_viewset = ModuleEnableSettingsViewSet.as_view({
|
||||
'get': 'list',
|
||||
'put': 'update',
|
||||
'patch': 'partial_update',
|
||||
})
|
||||
|
||||
urlpatterns = [
|
||||
# Module enable settings endpoint - MUST come before router.urls to avoid conflict
|
||||
# When /settings/modules/enable/ is called, it would match ModuleSettingsViewSet with pk='enable'
|
||||
# So we define it as a custom path first
|
||||
path('settings/modules/enable/', module_enable_viewset, name='module-enable-settings'),
|
||||
path('', include(router.urls)),
|
||||
# Public health check endpoint (API Standard v1.0 requirement)
|
||||
path('ping/', ping, name='system-ping'),
|
||||
|
||||
Reference in New Issue
Block a user