diff --git a/backend/igny8_core/modules/billing/constants.py b/backend/igny8_core/modules/billing/constants.py index 13f60131..428cddbe 100644 --- a/backend/igny8_core/modules/billing/constants.py +++ b/backend/igny8_core/modules/billing/constants.py @@ -1,22 +1,25 @@ """ -Credit Cost Constants +Credit Cost Constants - Phase 0: Credit-Only System +All features are unlimited. Only credits restrict usage. """ 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 - }, + # Existing operations + '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 + + # Legacy operation names (for backward compatibility) + 'ideas': 15, # Alias for idea_generation + 'content': 1, # Alias for content_generation (per 100 words) + 'images': 5, # Alias for image_generation + 'reparse': 2, # Alias for image_prompt_extraction + + # NEW: Phase 2+ operations + '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) } diff --git a/backend/igny8_core/modules/billing/services.py b/backend/igny8_core/modules/billing/services.py index 79a5651b..90719557 100644 --- a/backend/igny8_core/modules/billing/services.py +++ b/backend/igny8_core/modules/billing/services.py @@ -13,17 +13,49 @@ class CreditService: """Service for managing credits""" @staticmethod - def check_credits(account, required_credits): + def get_credit_cost(operation_type, amount=None): + """ + Get credit cost for operation. + + Args: + operation_type: Type of operation (from CREDIT_COSTS) + amount: Optional amount (word count, etc.) for variable costs + + Returns: + int: Number of credits required + """ + base_cost = CREDIT_COSTS.get(operation_type, 0) + + # Variable costs based on amount + 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))) + + return base_cost + + @staticmethod + def check_credits(account, required_credits=None, operation_type=None, amount=None): """ Check if account has enough credits. Args: account: Account instance - required_credits: Number of credits required + required_credits: Number of credits required (legacy parameter) + operation_type: Type of operation (new parameter) + amount: Optional amount for variable costs (new parameter) Raises: InsufficientCreditsError: If account doesn't have enough credits """ + # Support both old and new API + if operation_type: + required_credits = CreditService.get_credit_cost(operation_type, amount) + elif required_credits is None: + raise ValueError("Either required_credits or operation_type must be provided") + if account.credits < required_credits: raise InsufficientCreditsError( f"Insufficient credits. Required: {required_credits}, Available: {account.credits}" @@ -121,6 +153,9 @@ class CreditService: """ Calculate credits needed for an operation. + DEPRECATED: Use get_credit_cost() instead. + Kept for backward compatibility. + Args: operation_type: Type of operation **kwargs: Operation-specific parameters @@ -131,31 +166,31 @@ class CreditService: Raises: CreditCalculationError: If calculation fails """ - if operation_type not in CREDIT_COSTS: - raise CreditCalculationError(f"Unknown operation type: {operation_type}") + # Map old operation types to new ones + operation_mapping = { + 'ideas': 'idea_generation', + 'content': 'content_generation', + 'images': 'image_generation', + 'reparse': 'image_prompt_extraction', + } - cost_config = CREDIT_COSTS[operation_type] + mapped_type = operation_mapping.get(operation_type, operation_type) - if operation_type == 'clustering': - # 1 credit per 30 keywords + # Handle variable costs + if mapped_type == 'content_generation': + word_count = kwargs.get('word_count') or kwargs.get('content_count', 1000) * 100 + return CreditService.get_credit_cost(mapped_type, word_count) + elif mapped_type == 'clustering': 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 + # Clustering is fixed cost per request + return CreditService.get_credit_cost(mapped_type) + elif mapped_type == 'idea_generation': idea_count = kwargs.get('idea_count', 1) - return cost_config['base'] * idea_count - elif operation_type == 'content': - # 3 credits per content piece - content_count = kwargs.get('content_count', 1) - return cost_config['base'] * content_count - elif operation_type == 'images': - # 1 credit per image + # Fixed cost per request + return CreditService.get_credit_cost(mapped_type) + elif mapped_type == 'image_generation': 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'] + return CreditService.get_credit_cost(mapped_type) * image_count - return cost_config['base'] + return CreditService.get_credit_cost(mapped_type) diff --git a/backend/igny8_core/modules/system/models.py b/backend/igny8_core/modules/system/models.py index 219412bc..0ba175ed 100644 --- a/backend/igny8_core/modules/system/models.py +++ b/backend/igny8_core/modules/system/models.py @@ -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, AccountModuleSettings, AISettings ) diff --git a/backend/igny8_core/modules/system/settings_models.py b/backend/igny8_core/modules/system/settings_models.py index 075678e7..93c87f8c 100644 --- a/backend/igny8_core/modules/system/settings_models.py +++ b/backend/igny8_core/modules/system/settings_models.py @@ -92,6 +92,61 @@ class ModuleSettings(BaseSettings): return f"ModuleSetting: {self.module_name} - {self.key}" +class AccountModuleSettings(AccountBaseModel): + """ + Account-level module enable/disable settings. + Phase 0: Credit System - Module Settings + """ + # Module enable/disable flags + 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") + + updated_at = models.DateTimeField(auto_now=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'igny8_account_module_settings' + verbose_name = 'Account Module Settings' + verbose_name_plural = 'Account Module Settings' + # One settings record per account + constraints = [ + models.UniqueConstraint(fields=['account'], name='unique_account_module_settings') + ] + indexes = [ + models.Index(fields=['account']), + ] + + def __str__(self): + account = getattr(self, 'account', None) + return f"ModuleSettings: {account.name if account else 'No Account'}" + + @classmethod + def get_or_create_for_account(cls, account): + """Get or create module 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""" + module_map = { + '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 module_map.get(module_name, True) # Default to enabled if module not found + + # AISettings extends IntegrationSettings (which already exists) # We'll create it as a separate model that can reference IntegrationSettings class AISettings(AccountBaseModel): diff --git a/backend/igny8_core/modules/system/settings_serializers.py b/backend/igny8_core/modules/system/settings_serializers.py index 7bc10e9a..e2c7d484 100644 --- a/backend/igny8_core/modules/system/settings_serializers.py +++ b/backend/igny8_core/modules/system/settings_serializers.py @@ -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, AccountModuleSettings, AISettings from .validators import validate_settings_schema @@ -58,6 +58,18 @@ class ModuleSettingsSerializer(serializers.ModelSerializer): return value +class AccountModuleSettingsSerializer(serializers.ModelSerializer): + """Serializer for Account Module Settings (Phase 0)""" + class Meta: + model = AccountModuleSettings + 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 diff --git a/backend/igny8_core/modules/system/settings_views.py b/backend/igny8_core/modules/system/settings_views.py index 18da126f..a43edcca 100644 --- a/backend/igny8_core/modules/system/settings_views.py +++ b/backend/igny8_core/modules/system/settings_views.py @@ -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, AccountModuleSettings, AISettings from .settings_serializers import ( SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer, - ModuleSettingsSerializer, AISettingsSerializer + ModuleSettingsSerializer, AccountModuleSettingsSerializer, AISettingsSerializer ) @@ -276,6 +276,75 @@ 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 AccountModuleSettingsViewSet(AccountModelViewSet): + """ + ViewSet for managing account module enable/disable settings. + Phase 0: Credit System - Module Settings + One settings record per account (get_or_create pattern) + """ + queryset = AccountModuleSettings.objects.all() + serializer_class = AccountModuleSettingsSerializer + permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] + authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] + pagination_class = CustomPageNumberPagination + throttle_scope = 'system' + throttle_classes = [DebugScopedRateThrottle] + + def get_queryset(self): + """Get module settings for current account""" + queryset = super().get_queryset() + return queryset.filter(account=self.request.account) + + def list(self, request, *args, **kwargs): + """Get or create module settings for account""" + account = request.account + settings = AccountModuleSettings.get_or_create_for_account(account) + serializer = self.get_serializer(settings) + return success_response(data=serializer.data, request=request) + + def retrieve(self, request, pk=None): + """Get module settings for account""" + account = request.account + try: + settings = AccountModuleSettings.objects.get(account=account, pk=pk) + except AccountModuleSettings.DoesNotExist: + # Create if doesn't exist + settings = AccountModuleSettings.get_or_create_for_account(account) + serializer = self.get_serializer(settings) + return success_response(data=serializer.data, request=request) + + def perform_create(self, serializer): + """Set account automatically""" + account = getattr(self.request, 'account', None) + if not account: + user = getattr(self.request, 'user', None) + if user: + account = getattr(user, 'account', None) + + if not account: + from rest_framework.exceptions import ValidationError + raise ValidationError("Account is required") + + serializer.save(account=account) + + @action(detail=False, methods=['get'], url_path='check/(?P[^/.]+)', url_name='check_module') + def check_module(self, request, module_name=None): + """Check if a specific module is enabled""" + account = request.account + settings = AccountModuleSettings.get_or_create_for_account(account) + is_enabled = settings.is_module_enabled(module_name) + return success_response( + data={'module_name': module_name, 'enabled': is_enabled}, + request=request + ) + + @extend_schema_view( list=extend_schema(tags=['System']), create=extend_schema(tags=['System']), diff --git a/backend/igny8_core/modules/system/urls.py b/backend/igny8_core/modules/system/urls.py index 5e19b49c..3928e133 100644 --- a/backend/igny8_core/modules/system/urls.py +++ b/backend/igny8_core/modules/system/urls.py @@ -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, AccountModuleSettingsViewSet, AISettingsViewSet ) router = DefaultRouter() router.register(r'prompts', AIPromptViewSet, basename='prompts') @@ -17,6 +17,7 @@ router.register(r'settings/system', SystemSettingsViewSet, basename='system-sett router.register(r'settings/account', AccountSettingsViewSet, basename='account-settings') router.register(r'settings/user', UserSettingsViewSet, basename='user-settings') router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-settings') +router.register(r'settings/account-modules', AccountModuleSettingsViewSet, basename='account-module-settings') router.register(r'settings/ai', AISettingsViewSet, basename='ai-settings') # Custom URL patterns for integration settings - matching reference plugin structure