Compare commits
2 Commits
phase-0-fo
...
b2e60b749a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2e60b749a | ||
|
|
9f3c4a6cdd |
21
CHANGELOG.md
21
CHANGELOG.md
@@ -27,27 +27,6 @@ Each entry follows this format:
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Phase 0: Foundation & Credit System - Initial Implementation**
|
|
||||||
- Updated `CREDIT_COSTS` constants to Phase 0 format with new operations
|
|
||||||
- Added new credit costs: `linking` (8 credits), `optimization` (1 credit per 200 words), `site_structure_generation` (50 credits), `site_page_generation` (20 credits)
|
|
||||||
- Maintained backward compatibility with legacy operation names (`ideas`, `content`, `images`, `reparse`)
|
|
||||||
- Enhanced `CreditService` with `get_credit_cost()` method for dynamic cost calculation
|
|
||||||
- Supports variable costs based on operation type and amount (word count, etc.)
|
|
||||||
- Updated `check_credits()` and `deduct_credits()` to support both legacy `required_credits` parameter and new `operation_type`/`amount` parameters
|
|
||||||
- Maintained full backward compatibility with existing code
|
|
||||||
- Created `AccountModuleSettings` model for module enable/disable functionality
|
|
||||||
- One settings record per account (get_or_create pattern)
|
|
||||||
- Enable/disable flags for all 8 modules: `planner_enabled`, `writer_enabled`, `thinker_enabled`, `automation_enabled`, `site_builder_enabled`, `linker_enabled`, `optimizer_enabled`, `publisher_enabled`
|
|
||||||
- Helper method `is_module_enabled(module_name)` for easy module checking
|
|
||||||
- Added `AccountModuleSettingsSerializer` and `AccountModuleSettingsViewSet`
|
|
||||||
- API endpoint: `/api/v1/system/settings/account-modules/`
|
|
||||||
- Custom action: `check/(?P<module_name>[^/.]+)` to check if a specific module is enabled
|
|
||||||
- Automatic account assignment on create
|
|
||||||
- Unified API Standard v1.0 compliant
|
|
||||||
- **Affected Areas**: Billing module (`constants.py`, `services.py`), System module (`settings_models.py`, `settings_serializers.py`, `settings_views.py`, `urls.py`)
|
|
||||||
- **Documentation**: See `docs/planning/phases/PHASE-0-FOUNDATION-CREDIT-SYSTEM.md` for complete details
|
|
||||||
- **Impact**: Foundation for credit-only system and module-based feature access control
|
|
||||||
|
|
||||||
- **Planning Documents Organization**: Organized architecture and implementation planning documents
|
- **Planning Documents Organization**: Organized architecture and implementation planning documents
|
||||||
- Created `docs/planning/` directory for all planning documents
|
- Created `docs/planning/` directory for all planning documents
|
||||||
- Moved `IGNY8-HOLISTIC-ARCHITECTURE-PLAN.md` to `docs/planning/`
|
- Moved `IGNY8-HOLISTIC-ARCHITECTURE-PLAN.md` to `docs/planning/`
|
||||||
|
|||||||
@@ -67,16 +67,10 @@ class JWTAuthentication(BaseAuthentication):
|
|||||||
try:
|
try:
|
||||||
account = Account.objects.get(id=account_id)
|
account = Account.objects.get(id=account_id)
|
||||||
except Account.DoesNotExist:
|
except Account.DoesNotExist:
|
||||||
pass
|
# Account from token doesn't exist - don't fallback, set to None
|
||||||
|
|
||||||
if not account:
|
|
||||||
try:
|
|
||||||
account = getattr(user, 'account', None)
|
|
||||||
except (AttributeError, Exception):
|
|
||||||
# If account access fails, set to None
|
|
||||||
account = None
|
account = None
|
||||||
|
|
||||||
# Set account on request
|
# Set account on request (only if account_id was in token and account exists)
|
||||||
request.account = account
|
request.account = account
|
||||||
|
|
||||||
return (user, token)
|
return (user, token)
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ class AccountContextMiddleware(MiddlewareMixin):
|
|||||||
if not JWT_AVAILABLE:
|
if not JWT_AVAILABLE:
|
||||||
# JWT library not installed yet - skip for now
|
# JWT library not installed yet - skip for now
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Decode JWT token with signature verification
|
# Decode JWT token with signature verification
|
||||||
@@ -94,42 +93,30 @@ class AccountContextMiddleware(MiddlewareMixin):
|
|||||||
if user_id:
|
if user_id:
|
||||||
from .models import User, Account
|
from .models import User, Account
|
||||||
try:
|
try:
|
||||||
# Refresh user from DB with account and plan relationships to get latest data
|
# Get user from DB (but don't set request.user - let DRF authentication handle that)
|
||||||
# This ensures changes to account/plan are reflected immediately without re-login
|
# Only set request.account for account context
|
||||||
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
|
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
|
||||||
request.user = user
|
|
||||||
if account_id:
|
if account_id:
|
||||||
# Verify account still exists and matches user
|
# Verify account still exists
|
||||||
account = Account.objects.get(id=account_id)
|
|
||||||
# If user's account changed, use the new one from user object
|
|
||||||
if user.account and user.account.id != account_id:
|
|
||||||
request.account = user.account
|
|
||||||
else:
|
|
||||||
request.account = account
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
user_account = getattr(user, 'account', None)
|
account = Account.objects.get(id=account_id)
|
||||||
if user_account:
|
request.account = account
|
||||||
request.account = user_account
|
except Account.DoesNotExist:
|
||||||
else:
|
# Account from token doesn't exist - don't fallback, set to None
|
||||||
request.account = None
|
|
||||||
except (AttributeError, Exception):
|
|
||||||
# If account access fails (e.g., column mismatch), set to None
|
|
||||||
request.account = None
|
request.account = None
|
||||||
|
else:
|
||||||
|
# No account_id in token - set to None (don't fallback to user.account)
|
||||||
|
request.account = None
|
||||||
except (User.DoesNotExist, Account.DoesNotExist):
|
except (User.DoesNotExist, Account.DoesNotExist):
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
else:
|
else:
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
|
|
||||||
except jwt.InvalidTokenError:
|
except jwt.InvalidTokenError:
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fail silently for now - allow unauthenticated access
|
# Fail silently for now - allow unauthenticated access
|
||||||
request.account = None
|
request.account = None
|
||||||
request.user = None
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
"""
|
"""
|
||||||
Credit Cost Constants - Phase 0: Credit-Only System
|
Credit Cost Constants
|
||||||
All features are unlimited. Only credits restrict usage.
|
|
||||||
"""
|
"""
|
||||||
CREDIT_COSTS = {
|
CREDIT_COSTS = {
|
||||||
# Existing operations
|
'clustering': {
|
||||||
'clustering': 10, # Per clustering request
|
'base': 1, # 1 credit per 30 keywords
|
||||||
'idea_generation': 15, # Per cluster → ideas request
|
'per_keyword': 1 / 30,
|
||||||
'content_generation': 1, # Per 100 words
|
},
|
||||||
'image_prompt_extraction': 2, # Per content piece
|
'ideas': {
|
||||||
'image_generation': 5, # Per image
|
'base': 1, # 1 credit per idea
|
||||||
|
},
|
||||||
# Legacy operation names (for backward compatibility)
|
'content': {
|
||||||
'ideas': 15, # Alias for idea_generation
|
'base': 3, # 3 credits per full blog post
|
||||||
'content': 1, # Alias for content_generation (per 100 words)
|
},
|
||||||
'images': 5, # Alias for image_generation
|
'images': {
|
||||||
'reparse': 2, # Alias for image_prompt_extraction
|
'base': 1, # 1 credit per image
|
||||||
|
},
|
||||||
# NEW: Phase 2+ operations
|
'reparse': {
|
||||||
'linking': 8, # Per content piece (NEW)
|
'base': 1, # 1 credit per reparse
|
||||||
'optimization': 1, # Per 200 words (NEW)
|
},
|
||||||
'site_structure_generation': 50, # Per site blueprint (NEW)
|
|
||||||
'site_page_generation': 20, # Per page (NEW)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,49 +13,17 @@ class CreditService:
|
|||||||
"""Service for managing credits"""
|
"""Service for managing credits"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_credit_cost(operation_type, amount=None):
|
def check_credits(account, required_credits):
|
||||||
"""
|
|
||||||
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.
|
Check if account has enough credits.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
account: Account instance
|
account: Account instance
|
||||||
required_credits: Number of credits required (legacy parameter)
|
required_credits: Number of credits required
|
||||||
operation_type: Type of operation (new parameter)
|
|
||||||
amount: Optional amount for variable costs (new parameter)
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
InsufficientCreditsError: If account doesn't have enough credits
|
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:
|
if account.credits < required_credits:
|
||||||
raise InsufficientCreditsError(
|
raise InsufficientCreditsError(
|
||||||
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
|
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"
|
||||||
@@ -153,9 +121,6 @@ class CreditService:
|
|||||||
"""
|
"""
|
||||||
Calculate credits needed for an operation.
|
Calculate credits needed for an operation.
|
||||||
|
|
||||||
DEPRECATED: Use get_credit_cost() instead.
|
|
||||||
Kept for backward compatibility.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
operation_type: Type of operation
|
operation_type: Type of operation
|
||||||
**kwargs: Operation-specific parameters
|
**kwargs: Operation-specific parameters
|
||||||
@@ -166,31 +131,31 @@ class CreditService:
|
|||||||
Raises:
|
Raises:
|
||||||
CreditCalculationError: If calculation fails
|
CreditCalculationError: If calculation fails
|
||||||
"""
|
"""
|
||||||
# Map old operation types to new ones
|
if operation_type not in CREDIT_COSTS:
|
||||||
operation_mapping = {
|
raise CreditCalculationError(f"Unknown operation type: {operation_type}")
|
||||||
'ideas': 'idea_generation',
|
|
||||||
'content': 'content_generation',
|
|
||||||
'images': 'image_generation',
|
|
||||||
'reparse': 'image_prompt_extraction',
|
|
||||||
}
|
|
||||||
|
|
||||||
mapped_type = operation_mapping.get(operation_type, operation_type)
|
cost_config = CREDIT_COSTS[operation_type]
|
||||||
|
|
||||||
# Handle variable costs
|
if operation_type == 'clustering':
|
||||||
if mapped_type == 'content_generation':
|
# 1 credit per 30 keywords
|
||||||
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)
|
keyword_count = kwargs.get('keyword_count', 0)
|
||||||
# Clustering is fixed cost per request
|
credits = max(1, int(keyword_count * cost_config['per_keyword']))
|
||||||
return CreditService.get_credit_cost(mapped_type)
|
return credits
|
||||||
elif mapped_type == 'idea_generation':
|
elif operation_type == 'ideas':
|
||||||
|
# 1 credit per idea
|
||||||
idea_count = kwargs.get('idea_count', 1)
|
idea_count = kwargs.get('idea_count', 1)
|
||||||
# Fixed cost per request
|
return cost_config['base'] * idea_count
|
||||||
return CreditService.get_credit_cost(mapped_type)
|
elif operation_type == 'content':
|
||||||
elif mapped_type == 'image_generation':
|
# 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
|
||||||
image_count = kwargs.get('image_count', 1)
|
image_count = kwargs.get('image_count', 1)
|
||||||
return CreditService.get_credit_cost(mapped_type) * image_count
|
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)
|
return cost_config['base']
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from igny8_core.auth.models import AccountBaseModel
|
|||||||
|
|
||||||
# Import settings models
|
# Import settings models
|
||||||
from .settings_models import (
|
from .settings_models import (
|
||||||
SystemSettings, AccountSettings, UserSettings, ModuleSettings, AccountModuleSettings, AISettings
|
SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -92,61 +92,6 @@ class ModuleSettings(BaseSettings):
|
|||||||
return f"ModuleSetting: {self.module_name} - {self.key}"
|
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)
|
# AISettings extends IntegrationSettings (which already exists)
|
||||||
# We'll create it as a separate model that can reference IntegrationSettings
|
# We'll create it as a separate model that can reference IntegrationSettings
|
||||||
class AISettings(AccountBaseModel):
|
class AISettings(AccountBaseModel):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Serializers for Settings Models
|
Serializers for Settings Models
|
||||||
"""
|
"""
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AccountModuleSettings, AISettings
|
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||||
from .validators import validate_settings_schema
|
from .validators import validate_settings_schema
|
||||||
|
|
||||||
|
|
||||||
@@ -58,18 +58,6 @@ class ModuleSettingsSerializer(serializers.ModelSerializer):
|
|||||||
return value
|
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 AISettingsSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = AISettings
|
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.pagination import CustomPageNumberPagination
|
||||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner
|
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner
|
||||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AccountModuleSettings, AISettings
|
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||||
from .settings_serializers import (
|
from .settings_serializers import (
|
||||||
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
|
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
|
||||||
ModuleSettingsSerializer, AccountModuleSettingsSerializer, AISettingsSerializer
|
ModuleSettingsSerializer, AISettingsSerializer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -276,75 +276,6 @@ class ModuleSettingsViewSet(AccountModelViewSet):
|
|||||||
serializer.save(account=account)
|
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<module_name>[^/.]+)', 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(
|
@extend_schema_view(
|
||||||
list=extend_schema(tags=['System']),
|
list=extend_schema(tags=['System']),
|
||||||
create=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 .integration_views import IntegrationSettingsViewSet
|
||||||
from .settings_views import (
|
from .settings_views import (
|
||||||
SystemSettingsViewSet, AccountSettingsViewSet, UserSettingsViewSet,
|
SystemSettingsViewSet, AccountSettingsViewSet, UserSettingsViewSet,
|
||||||
ModuleSettingsViewSet, AccountModuleSettingsViewSet, AISettingsViewSet
|
ModuleSettingsViewSet, AISettingsViewSet
|
||||||
)
|
)
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'prompts', AIPromptViewSet, basename='prompts')
|
router.register(r'prompts', AIPromptViewSet, basename='prompts')
|
||||||
@@ -17,7 +17,6 @@ router.register(r'settings/system', SystemSettingsViewSet, basename='system-sett
|
|||||||
router.register(r'settings/account', AccountSettingsViewSet, basename='account-settings')
|
router.register(r'settings/account', AccountSettingsViewSet, basename='account-settings')
|
||||||
router.register(r'settings/user', UserSettingsViewSet, basename='user-settings')
|
router.register(r'settings/user', UserSettingsViewSet, basename='user-settings')
|
||||||
router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-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')
|
router.register(r'settings/ai', AISettingsViewSet, basename='ai-settings')
|
||||||
|
|
||||||
# Custom URL patterns for integration settings - matching reference plugin structure
|
# Custom URL patterns for integration settings - matching reference plugin structure
|
||||||
|
|||||||
273
docs/DIAGNOSIS-AUTHENTICATION-ISSUE.md
Normal file
273
docs/DIAGNOSIS-AUTHENTICATION-ISSUE.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# Authentication & Account Context Diagnosis
|
||||||
|
|
||||||
|
## Issue Summary
|
||||||
|
**Problem**: Wrong user showing without proper rights - authentication/account context mismatch
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Request Flow
|
||||||
|
```
|
||||||
|
1. Request arrives
|
||||||
|
↓
|
||||||
|
2. Django Middleware Stack (settings.py:74-88)
|
||||||
|
- SecurityMiddleware
|
||||||
|
- WhiteNoiseMiddleware
|
||||||
|
- CorsMiddleware
|
||||||
|
- SessionMiddleware
|
||||||
|
- CommonMiddleware
|
||||||
|
- CsrfViewMiddleware
|
||||||
|
- AuthenticationMiddleware (sets request.user from session)
|
||||||
|
↓
|
||||||
|
3. AccountContextMiddleware (line 83)
|
||||||
|
- Extracts account from JWT token OR session
|
||||||
|
- Sets request.account
|
||||||
|
↓
|
||||||
|
4. DRF Authentication Classes (settings.py:210-214)
|
||||||
|
- JWTAuthentication (runs first)
|
||||||
|
- CSRFExemptSessionAuthentication
|
||||||
|
- BasicAuthentication
|
||||||
|
↓
|
||||||
|
5. View/ViewSet
|
||||||
|
- Uses request.user (from DRF auth)
|
||||||
|
- Uses request.account (from middleware OR JWTAuthentication)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical Issues Found
|
||||||
|
|
||||||
|
### Issue #1: Duplicate Account Setting Logic
|
||||||
|
**Location**: Two places set `request.account` with different logic
|
||||||
|
|
||||||
|
1. **AccountContextMiddleware** (`backend/igny8_core/auth/middleware.py:99-106`)
|
||||||
|
```python
|
||||||
|
if account_id:
|
||||||
|
account = Account.objects.get(id=account_id)
|
||||||
|
# If user's account changed, use the new one from user object
|
||||||
|
if user.account and user.account.id != account_id:
|
||||||
|
request.account = user.account # Prioritizes user's current account
|
||||||
|
else:
|
||||||
|
request.account = account # Uses token's account
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **JWTAuthentication** (`backend/igny8_core/api/authentication.py:64-80`)
|
||||||
|
```python
|
||||||
|
account_id = payload.get('account_id')
|
||||||
|
account = None
|
||||||
|
if account_id:
|
||||||
|
account = Account.objects.get(id=account_id) # Always uses token's account
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
account = getattr(user, 'account', None) # Fallback only if no account_id
|
||||||
|
|
||||||
|
request.account = account # OVERWRITES middleware's account
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Middleware validates if user's account changed and prioritizes `user.account`
|
||||||
|
- JWTAuthentication runs AFTER middleware and OVERWRITES `request.account` without validation
|
||||||
|
- This means middleware's validation is ignored
|
||||||
|
|
||||||
|
### Issue #2: User Object Loading Mismatch
|
||||||
|
**Location**: Different user loading strategies
|
||||||
|
|
||||||
|
1. **AccountContextMiddleware** (line 98)
|
||||||
|
```python
|
||||||
|
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
|
||||||
|
```
|
||||||
|
- Loads user WITH account relationship (efficient, has account data)
|
||||||
|
|
||||||
|
2. **JWTAuthentication** (line 58)
|
||||||
|
```python
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
```
|
||||||
|
- Does NOT load account relationship
|
||||||
|
- When checking `user.account`, it triggers a separate DB query
|
||||||
|
- If account relationship is stale or missing, this can fail
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- JWTAuthentication's user object doesn't have account relationship loaded
|
||||||
|
- When `/me` endpoint uses `request.user.id` and then serializes with `UserSerializer`, it tries to access `user.account`
|
||||||
|
- This might trigger lazy loading which could return wrong/stale data
|
||||||
|
|
||||||
|
### Issue #3: Middleware Updates request.user (Session Auth)
|
||||||
|
**Location**: `backend/igny8_core/auth/middleware.py:32-46`
|
||||||
|
|
||||||
|
```python
|
||||||
|
if hasattr(request, 'user') and request.user and request.user.is_authenticated:
|
||||||
|
user = UserModel.objects.select_related('account', 'account__plan').get(id=request.user.id)
|
||||||
|
request.user = user # OVERWRITES request.user
|
||||||
|
request.account = user_account
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Middleware is setting `request.user` for session authentication
|
||||||
|
- But then JWTAuthentication runs and might set a DIFFERENT user (from JWT token)
|
||||||
|
- This creates a conflict where middleware's user is overwritten
|
||||||
|
|
||||||
|
### Issue #4: Token Account vs User Account Mismatch
|
||||||
|
**Location**: Token generation vs user's current account
|
||||||
|
|
||||||
|
**Token Generation** (`backend/igny8_core/auth/utils.py:30-57`):
|
||||||
|
```python
|
||||||
|
def generate_access_token(user, account=None):
|
||||||
|
if account is None:
|
||||||
|
account = getattr(user, 'account', None)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'user_id': user.id,
|
||||||
|
'account_id': account.id if account else None, # Token stores account_id at login time
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Token is generated at login with `user.account` at that moment
|
||||||
|
- If user's account changes AFTER login (e.g., admin moves user to different account), token still has old `account_id`
|
||||||
|
- Middleware tries to handle this (line 103-104), but JWTAuthentication overwrites it
|
||||||
|
|
||||||
|
### Issue #5: /me Endpoint Uses request.user Without Account Relationship
|
||||||
|
**Location**: `backend/igny8_core/auth/urls.py:188-197`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get(self, request):
|
||||||
|
user = UserModel.objects.select_related('account', 'account__plan').get(id=request.user.id)
|
||||||
|
serializer = UserSerializer(user)
|
||||||
|
return success_response(data={'user': serializer.data}, request=request)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- `/me` endpoint correctly loads user with account relationship
|
||||||
|
- BUT `request.user` (from JWTAuthentication) doesn't have account relationship loaded
|
||||||
|
- If other code uses `request.user.account` directly, it might get wrong/stale data
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
### Primary Root Cause
|
||||||
|
**JWTAuthentication overwrites `request.account` set by middleware without validating if user's account changed**
|
||||||
|
|
||||||
|
### Secondary Issues
|
||||||
|
1. JWTAuthentication doesn't load user with account relationship (inefficient + potential stale data)
|
||||||
|
2. Middleware sets `request.user` for session auth, but JWTAuthentication might overwrite it
|
||||||
|
3. Token's `account_id` can become stale if user's account changes after login
|
||||||
|
|
||||||
|
## Data Flow Problem
|
||||||
|
|
||||||
|
### Current Flow (BROKEN)
|
||||||
|
```
|
||||||
|
1. Request with JWT token arrives
|
||||||
|
↓
|
||||||
|
2. AccountContextMiddleware runs:
|
||||||
|
- Decodes JWT token
|
||||||
|
- Gets user_id=5, account_id=10
|
||||||
|
- Loads User(id=5) with account relationship
|
||||||
|
- Checks: user.account.id = 12 (user moved to account 12)
|
||||||
|
- Sets: request.account = Account(id=12) ✅ CORRECT
|
||||||
|
↓
|
||||||
|
3. JWTAuthentication runs:
|
||||||
|
- Decodes JWT token (again)
|
||||||
|
- Gets user_id=5, account_id=10
|
||||||
|
- Loads User(id=5) WITHOUT account relationship
|
||||||
|
- Gets Account(id=10) from token
|
||||||
|
- Sets: request.account = Account(id=10) ❌ WRONG (overwrites middleware)
|
||||||
|
- Sets: request.user = User(id=5) (without account relationship)
|
||||||
|
↓
|
||||||
|
4. View uses request.account (WRONG - account 10 instead of 12)
|
||||||
|
5. View uses request.user.account (might trigger lazy load, could be stale)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected Flow (CORRECT)
|
||||||
|
```
|
||||||
|
1. Request with JWT token arrives
|
||||||
|
↓
|
||||||
|
2. AccountContextMiddleware runs:
|
||||||
|
- Sets request.account based on token with validation
|
||||||
|
↓
|
||||||
|
3. JWTAuthentication runs:
|
||||||
|
- Sets request.user with account relationship loaded
|
||||||
|
- Does NOT overwrite request.account (respects middleware)
|
||||||
|
↓
|
||||||
|
4. View uses request.account (CORRECT - from middleware)
|
||||||
|
5. View uses request.user.account (CORRECT - loaded with relationship)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema Check
|
||||||
|
|
||||||
|
### User Model
|
||||||
|
- `User.account` = ForeignKey to Account, `db_column='tenant_id'`, nullable
|
||||||
|
- Relationship: User → Account (many-to-one)
|
||||||
|
|
||||||
|
### Account Model
|
||||||
|
- `Account.owner` = ForeignKey to User
|
||||||
|
- Relationship: Account → User (many-to-one, owner)
|
||||||
|
|
||||||
|
### Potential Database Issues
|
||||||
|
- If `User.account_id` (tenant_id column) doesn't match token's `account_id`, there's a mismatch
|
||||||
|
- If user's account was changed in DB but token wasn't refreshed, token has stale account_id
|
||||||
|
|
||||||
|
## Permission System Check
|
||||||
|
|
||||||
|
### HasTenantAccess Permission
|
||||||
|
**Location**: `backend/igny8_core/api/permissions.py:25-67`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
account = getattr(request, 'account', None)
|
||||||
|
|
||||||
|
# If no account in request, try to get from user
|
||||||
|
if not account and hasattr(request.user, 'account'):
|
||||||
|
account = request.user.account
|
||||||
|
|
||||||
|
# Check if user belongs to this account
|
||||||
|
if account:
|
||||||
|
user_account = request.user.account
|
||||||
|
return user_account == account or user_account.id == account.id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Permission checks `request.account` vs `request.user.account`
|
||||||
|
- If `request.account` is wrong (from JWTAuthentication overwrite), permission check fails
|
||||||
|
- User gets 403 Forbidden even though they should have access
|
||||||
|
|
||||||
|
## Recommendations (Diagnosis Only - No Code Changes)
|
||||||
|
|
||||||
|
### Fix Priority
|
||||||
|
|
||||||
|
1. **CRITICAL**: Make JWTAuthentication respect middleware's `request.account` OR remove duplicate logic
|
||||||
|
- Option A: JWTAuthentication should check if `request.account` already exists and not overwrite it
|
||||||
|
- Option B: Remove account setting from JWTAuthentication, let middleware handle it
|
||||||
|
|
||||||
|
2. **HIGH**: Load user with account relationship in JWTAuthentication
|
||||||
|
- Change `User.objects.get(id=user_id)` to `User.objects.select_related('account', 'account__plan').get(id=user_id)`
|
||||||
|
|
||||||
|
3. **MEDIUM**: Don't set `request.user` in middleware for JWT auth
|
||||||
|
- Middleware should only set `request.user` for session auth
|
||||||
|
- For JWT auth, let JWTAuthentication handle `request.user`
|
||||||
|
|
||||||
|
4. **LOW**: Add validation in token generation to ensure account_id matches user.account
|
||||||
|
- Or add token refresh mechanism when user's account changes
|
||||||
|
|
||||||
|
### Architecture Decision Needed
|
||||||
|
|
||||||
|
**Question**: Should `request.account` be set by:
|
||||||
|
- A) Middleware only (current middleware logic with validation)
|
||||||
|
- B) JWTAuthentication only (simpler, but loses validation)
|
||||||
|
- C) Both, but JWTAuthentication checks if middleware already set it
|
||||||
|
|
||||||
|
**Recommendation**: Option C - Middleware sets it with validation, JWTAuthentication only sets if not already set
|
||||||
|
|
||||||
|
## Files Involved
|
||||||
|
|
||||||
|
1. `backend/igny8_core/auth/middleware.py` - AccountContextMiddleware
|
||||||
|
2. `backend/igny8_core/api/authentication.py` - JWTAuthentication
|
||||||
|
3. `backend/igny8_core/auth/urls.py` - MeView endpoint
|
||||||
|
4. `backend/igny8_core/auth/utils.py` - Token generation
|
||||||
|
5. `backend/igny8_core/api/permissions.py` - HasTenantAccess permission
|
||||||
|
6. `backend/igny8_core/settings.py` - Middleware and authentication class order
|
||||||
|
|
||||||
|
## Testing Scenarios to Verify
|
||||||
|
|
||||||
|
1. **User with account_id in token matches user.account** → Should work
|
||||||
|
2. **User's account changed after login (token has old account_id)** → Currently broken
|
||||||
|
3. **User with no account in token** → Should fallback to user.account
|
||||||
|
4. **Developer/admin user** → Should bypass account checks
|
||||||
|
5. **Session auth vs JWT auth** → Both should work consistently
|
||||||
|
|
||||||
Reference in New Issue
Block a user