Compare commits
9 Commits
8a9dd44c50
...
feature/ph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56c30e4904 | ||
|
|
51cd021f85 | ||
|
|
fc6dd5623a | ||
|
|
1531f41226 | ||
|
|
37a64fa1ef | ||
|
|
c4daeb1870 | ||
|
|
79aab68acd | ||
|
|
11a5a66c8b | ||
|
|
ab292de06c |
@@ -193,7 +193,6 @@ class AIEngine:
|
|||||||
self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta())
|
self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta())
|
||||||
|
|
||||||
# Phase 2.5: CREDIT CHECK - Check credits before AI call (25%)
|
# Phase 2.5: CREDIT CHECK - Check credits before AI call (25%)
|
||||||
# Bypass for system accounts and developers (handled in CreditService)
|
|
||||||
if self.account:
|
if self.account:
|
||||||
try:
|
try:
|
||||||
from igny8_core.modules.billing.services import CreditService
|
from igny8_core.modules.billing.services import CreditService
|
||||||
@@ -205,9 +204,8 @@ class AIEngine:
|
|||||||
# Calculate estimated cost
|
# Calculate estimated cost
|
||||||
estimated_amount = self._get_estimated_amount(function_name, data, payload)
|
estimated_amount = self._get_estimated_amount(function_name, data, payload)
|
||||||
|
|
||||||
# Check credits BEFORE AI call (CreditService handles developer/system account bypass)
|
# Check credits BEFORE AI call
|
||||||
# Note: user=None for Celery tasks, but CreditService checks account.is_system_account() and developer users
|
CreditService.check_credits(self.account, operation_type, estimated_amount)
|
||||||
CreditService.check_credits(self.account, operation_type, estimated_amount, user=None)
|
|
||||||
|
|
||||||
logger.info(f"[AIEngine] Credit check passed: {operation_type}, estimated amount: {estimated_amount}")
|
logger.info(f"[AIEngine] Credit check passed: {operation_type}, estimated amount: {estimated_amount}")
|
||||||
except InsufficientCreditsError as e:
|
except InsufficientCreditsError as e:
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ from .views import (
|
|||||||
SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet,
|
SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet,
|
||||||
IndustryViewSet, SeedKeywordViewSet
|
IndustryViewSet, SeedKeywordViewSet
|
||||||
)
|
)
|
||||||
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer
|
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer, RefreshTokenSerializer
|
||||||
from .models import User
|
from .models import User
|
||||||
|
from .utils import generate_access_token, get_token_expiry, decode_token
|
||||||
|
import jwt
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
# Main structure: Groups, Users, Accounts, Subscriptions, Site User Access
|
# Main structure: Groups, Users, Accounts, Subscriptions, Site User Access
|
||||||
@@ -78,7 +80,7 @@ class LoginView(APIView):
|
|||||||
password = serializer.validated_data['password']
|
password = serializer.validated_data['password']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(email=email)
|
user = User.objects.select_related('account', 'account__plan').get(email=email)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return error_response(
|
return error_response(
|
||||||
error='Invalid credentials',
|
error='Invalid credentials',
|
||||||
@@ -107,9 +109,17 @@ class LoginView(APIView):
|
|||||||
user_data = user_serializer.data
|
user_data = user_serializer.data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Fallback if serializer fails (e.g., missing account_id column)
|
# 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 = {
|
user_data = {
|
||||||
'id': user.id,
|
'id': user.id,
|
||||||
'username': user.username,
|
'username': username,
|
||||||
'email': user.email,
|
'email': user.email,
|
||||||
'role': user.role,
|
'role': user.role,
|
||||||
'account': None,
|
'account': None,
|
||||||
@@ -119,12 +129,10 @@ class LoginView(APIView):
|
|||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
'user': user_data,
|
'user': user_data,
|
||||||
'tokens': {
|
|
||||||
'access': access_token,
|
'access': access_token,
|
||||||
'refresh': refresh_token,
|
'refresh': refresh_token,
|
||||||
'access_expires_at': access_expires_at.isoformat(),
|
'access_expires_at': access_expires_at.isoformat(),
|
||||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||||
}
|
|
||||||
},
|
},
|
||||||
message='Login successful',
|
message='Login successful',
|
||||||
request=request
|
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
|
@extend_schema(exclude=True) # Exclude from public API documentation - internal authenticated endpoint
|
||||||
class MeView(APIView):
|
class MeView(APIView):
|
||||||
"""Get current user information."""
|
"""Get current user information."""
|
||||||
@@ -201,6 +287,7 @@ urlpatterns = [
|
|||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
|
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
|
||||||
path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'),
|
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('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
|
||||||
path('me/', MeView.as_view(), name='auth-me'),
|
path('me/', MeView.as_view(), name='auth-me'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -933,12 +933,10 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
return success_response(
|
return success_response(
|
||||||
data={
|
data={
|
||||||
'user': user_serializer.data,
|
'user': user_serializer.data,
|
||||||
'tokens': {
|
|
||||||
'access': access_token,
|
'access': access_token,
|
||||||
'refresh': refresh_token,
|
'refresh': refresh_token,
|
||||||
'access_expires_at': access_expires_at.isoformat(),
|
'access_expires_at': access_expires_at.isoformat(),
|
||||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||||
}
|
|
||||||
},
|
},
|
||||||
message='Login successful',
|
message='Login successful',
|
||||||
request=request
|
request=request
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class CreditService:
|
|||||||
return base_cost
|
return base_cost
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_credits(account, operation_type, amount=None, user=None):
|
def check_credits(account, operation_type, amount=None):
|
||||||
"""
|
"""
|
||||||
Check if account has sufficient credits for an operation.
|
Check if account has sufficient credits for an operation.
|
||||||
|
|
||||||
@@ -57,35 +57,10 @@ class CreditService:
|
|||||||
account: Account instance
|
account: Account instance
|
||||||
operation_type: Type of operation
|
operation_type: Type of operation
|
||||||
amount: Optional amount (word count, image count, etc.)
|
amount: Optional amount (word count, image count, etc.)
|
||||||
user: Optional user instance (for developer/admin bypass)
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
InsufficientCreditsError: If account doesn't have enough credits
|
InsufficientCreditsError: If account doesn't have enough credits
|
||||||
"""
|
"""
|
||||||
# Bypass credit check for:
|
|
||||||
# 1. System accounts (aws-admin, default-account, default)
|
|
||||||
# 2. Developer/admin users (if user provided)
|
|
||||||
if account and account.is_system_account():
|
|
||||||
return True
|
|
||||||
|
|
||||||
if user:
|
|
||||||
try:
|
|
||||||
if hasattr(user, 'is_admin_or_developer') and user.is_admin_or_developer():
|
|
||||||
return True
|
|
||||||
if hasattr(user, 'is_system_account_user') and user.is_system_account_user():
|
|
||||||
return True
|
|
||||||
except (AttributeError, Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check if account has any developer users (fallback for Celery tasks without user context)
|
|
||||||
if account:
|
|
||||||
try:
|
|
||||||
from igny8_core.auth.models import User
|
|
||||||
if User.objects.filter(account=account, role='developer').exists():
|
|
||||||
return True
|
|
||||||
except (AttributeError, Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
required = CreditService.get_credit_cost(operation_type, amount)
|
required = CreditService.get_credit_cost(operation_type, amount)
|
||||||
if account.credits < required:
|
if account.credits < required:
|
||||||
raise InsufficientCreditsError(
|
raise InsufficientCreditsError(
|
||||||
@@ -94,40 +69,17 @@ class CreditService:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_credits_legacy(account, required_credits, user=None):
|
def check_credits_legacy(account, required_credits):
|
||||||
"""
|
"""
|
||||||
Legacy method: Check if account has enough credits (for backward compatibility).
|
Legacy method: Check if account has enough credits (for backward compatibility).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
account: Account instance
|
account: Account instance
|
||||||
required_credits: Number of credits required
|
required_credits: Number of credits required
|
||||||
user: Optional user instance (for developer/admin bypass)
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
InsufficientCreditsError: If account doesn't have enough credits
|
InsufficientCreditsError: If account doesn't have enough credits
|
||||||
"""
|
"""
|
||||||
# Bypass credit check for system accounts and developers
|
|
||||||
if account and account.is_system_account():
|
|
||||||
return
|
|
||||||
|
|
||||||
if user:
|
|
||||||
try:
|
|
||||||
if hasattr(user, 'is_admin_or_developer') and user.is_admin_or_developer():
|
|
||||||
return
|
|
||||||
if hasattr(user, 'is_system_account_user') and user.is_system_account_user():
|
|
||||||
return
|
|
||||||
except (AttributeError, Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check if account has any developer users (fallback for Celery tasks)
|
|
||||||
if account:
|
|
||||||
try:
|
|
||||||
from igny8_core.auth.models import User
|
|
||||||
if User.objects.filter(account=account, role='developer').exists():
|
|
||||||
return
|
|
||||||
except (AttributeError, Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
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}"
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ class CreditBalanceViewSet(viewsets.ViewSet):
|
|||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get plan credits per month
|
# Get plan credits per month (use get_effective_credits_per_month for Phase 0 compatibility)
|
||||||
plan_credits_per_month = account.plan.credits_per_month if account.plan else 0
|
plan_credits_per_month = account.plan.get_effective_credits_per_month() if account.plan else 0
|
||||||
|
|
||||||
# Calculate credits used this month
|
# Calculate credits used this month
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
|||||||
@@ -1,37 +1,39 @@
|
|||||||
# Generated manually for Phase 0: Module Enable Settings
|
# 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, models
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('igny8_core_modules_system', '0006_alter_systemstatus_unique_together_and_more'),
|
('system', '0006_alter_systemstatus_unique_together_and_more'),
|
||||||
('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'),
|
('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
# Create table using raw SQL to avoid model resolution issues
|
||||||
name='ModuleEnableSettings',
|
# The model state is automatically discovered from models.py
|
||||||
fields=[
|
migrations.RunSQL(
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
sql="""
|
||||||
('planner_enabled', models.BooleanField(default=True, help_text='Enable Planner module')),
|
CREATE TABLE IF NOT EXISTS igny8_module_enable_settings (
|
||||||
('writer_enabled', models.BooleanField(default=True, help_text='Enable Writer module')),
|
id BIGSERIAL PRIMARY KEY,
|
||||||
('thinker_enabled', models.BooleanField(default=True, help_text='Enable Thinker module')),
|
planner_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
('automation_enabled', models.BooleanField(default=True, help_text='Enable Automation module')),
|
writer_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
('site_builder_enabled', models.BooleanField(default=True, help_text='Enable Site Builder module')),
|
thinker_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
('linker_enabled', models.BooleanField(default=True, help_text='Enable Linker module')),
|
automation_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
('optimizer_enabled', models.BooleanField(default=True, help_text='Enable Optimizer module')),
|
site_builder_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
('publisher_enabled', models.BooleanField(default=True, help_text='Enable Publisher module')),
|
linker_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
('account', models.ForeignKey(on_delete=models.CASCADE, to='igny8_core_auth.account', db_column='tenant_id')),
|
optimizer_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
],
|
publisher_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
options={
|
tenant_id BIGINT NOT NULL REFERENCES igny8_tenants(id) ON DELETE CASCADE,
|
||||||
'db_table': 'igny8_module_enable_settings',
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
},
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
),
|
);
|
||||||
migrations.AddConstraint(
|
CREATE INDEX IF NOT EXISTS igny8_module_enable_settings_tenant_id_idx ON igny8_module_enable_settings(tenant_id);
|
||||||
model_name='moduleenablesettings',
|
CREATE INDEX IF NOT EXISTS igny8_module_enable_settings_account_created_idx ON igny8_module_enable_settings(tenant_id, created_at);
|
||||||
constraint=models.UniqueConstraint(fields=('account',), name='unique_account_module_enable_settings'),
|
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;",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,15 @@ class ModuleSettingsViewSet(AccountModelViewSet):
|
|||||||
|
|
||||||
def retrieve(self, request, pk=None):
|
def retrieve(self, request, pk=None):
|
||||||
"""Get setting by key (pk can be key string)"""
|
"""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()
|
queryset = self.get_queryset()
|
||||||
try:
|
try:
|
||||||
# Try to get by ID first
|
# Try to get by ID first
|
||||||
@@ -301,7 +310,7 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet):
|
|||||||
Allow read access to all authenticated users,
|
Allow read access to all authenticated users,
|
||||||
but restrict write access to admins/owners
|
but restrict write access to admins/owners
|
||||||
"""
|
"""
|
||||||
if self.action in ['list', 'retrieve']:
|
if self.action in ['list', 'retrieve', 'get_current']:
|
||||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||||
else:
|
else:
|
||||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner]
|
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner]
|
||||||
@@ -309,17 +318,34 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Get module enable settings for current account"""
|
"""Get module enable settings for current account"""
|
||||||
# Don't filter here - list() and retrieve() handle get_or_create
|
# Return queryset filtered by account - but list() will handle get_or_create
|
||||||
# This prevents empty queryset from causing 404 errors
|
queryset = super().get_queryset()
|
||||||
return ModuleEnableSettings.objects.all()
|
# 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):
|
def list(self, request, *args, **kwargs):
|
||||||
"""Get or create module enable settings for current account"""
|
"""Get or create module enable settings for current account"""
|
||||||
|
try:
|
||||||
account = getattr(request, 'account', None)
|
account = getattr(request, 'account', None)
|
||||||
if not account:
|
if not account:
|
||||||
user = getattr(request, 'user', None)
|
user = getattr(request, 'user', None)
|
||||||
if user and hasattr(user, 'account'):
|
if user and hasattr(user, 'account'):
|
||||||
account = getattr(user, 'account', None)
|
account = user.account
|
||||||
|
|
||||||
if not account:
|
if not account:
|
||||||
return error_response(
|
return error_response(
|
||||||
@@ -328,10 +354,39 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet):
|
|||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if table exists (migration might not have been run)
|
||||||
|
try:
|
||||||
# Get or create settings for account (one per account)
|
# Get or create settings for account (one per account)
|
||||||
settings, created = ModuleEnableSettings.objects.get_or_create(account=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)
|
serializer = self.get_serializer(settings)
|
||||||
return success_response(data=serializer.data, request=request)
|
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):
|
def retrieve(self, request, pk=None, *args, **kwargs):
|
||||||
"""Get module enable settings for current account"""
|
"""Get module enable settings for current account"""
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ router.register(r'strategies', StrategyViewSet, basename='strategy')
|
|||||||
router.register(r'settings/system', SystemSettingsViewSet, basename='system-settings')
|
router.register(r'settings/system', SystemSettingsViewSet, basename='system-settings')
|
||||||
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')
|
||||||
|
# Register ModuleSettingsViewSet first
|
||||||
router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-settings')
|
router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-settings')
|
||||||
router.register(r'settings/modules/enable', ModuleEnableSettingsViewSet, basename='module-enable-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
|
||||||
@@ -50,7 +50,20 @@ integration_image_gen_settings_viewset = IntegrationSettingsViewSet.as_view({
|
|||||||
'get': 'get_image_generation_settings',
|
'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 = [
|
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)),
|
path('', include(router.urls)),
|
||||||
# Public health check endpoint (API Standard v1.0 requirement)
|
# Public health check endpoint (API Standard v1.0 requirement)
|
||||||
path('ping/', ping, name='system-ping'),
|
path('ping/', ping, name='system-ping'),
|
||||||
|
|||||||
@@ -411,9 +411,9 @@ frontend/
|
|||||||
<Route path="/reference/seed-keywords" element={<SeedKeywords />} />
|
<Route path="/reference/seed-keywords" element={<SeedKeywords />} />
|
||||||
<Route path="/reference/industries" element={<ReferenceIndustries />} />
|
<Route path="/reference/industries" element={<ReferenceIndustries />} />
|
||||||
|
|
||||||
{/* Automation & Schedules */}
|
{/* Automation */}
|
||||||
<Route path="/automation" element={<AutomationDashboard />} />
|
<Route path="/automation" element={<AutomationDashboard />} />
|
||||||
<Route path="/schedules" element={<Schedules />} />
|
{/* Note: Schedules functionality is integrated into Automation Dashboard */}
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<Route path="/settings" element={<GeneralSettings />} />
|
<Route path="/settings" element={<GeneralSettings />} />
|
||||||
|
|||||||
@@ -644,9 +644,12 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
"data": {
|
"data": {
|
||||||
"user": { ... },
|
"user": { ... },
|
||||||
"access": "eyJ0eXAiOiJKV1QiLCJhbGc...",
|
"access": "eyJ0eXAiOiJKV1QiLCJhbGc...",
|
||||||
"refresh": "eyJ0eXAiOiJKV1QiLCJhbGc..."
|
"refresh": "eyJ0eXAiOiJKV1QiLCJhbGc...",
|
||||||
|
"access_expires_at": "2025-01-XXT...",
|
||||||
|
"refresh_expires_at": "2025-01-XXT..."
|
||||||
},
|
},
|
||||||
"message": "Login successful"
|
"message": "Login successful",
|
||||||
|
"request_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -278,11 +278,10 @@ frontend/src/
|
|||||||
│ ├── Billing/ # Existing
|
│ ├── Billing/ # Existing
|
||||||
│ ├── Settings/ # Existing
|
│ ├── Settings/ # Existing
|
||||||
│ ├── Automation/ # EXISTING (placeholder) - IMPLEMENT
|
│ ├── Automation/ # EXISTING (placeholder) - IMPLEMENT
|
||||||
│ │ ├── Dashboard.tsx # Automation overview
|
│ │ ├── Dashboard.tsx # Automation overview (includes schedules functionality)
|
||||||
│ │ ├── Rules.tsx # Automation rules management
|
│ │ ├── Rules.tsx # Automation rules management
|
||||||
│ │ ├── Workflows.tsx # Workflow templates
|
│ │ ├── Workflows.tsx # Workflow templates
|
||||||
│ │ └── History.tsx # Automation execution history
|
│ │ └── History.tsx # Automation execution history
|
||||||
│ ├── Schedules.tsx # EXISTING (placeholder) - IMPLEMENT
|
|
||||||
│ ├── Linker/ # NEW
|
│ ├── Linker/ # NEW
|
||||||
│ │ ├── Dashboard.tsx
|
│ │ ├── Dashboard.tsx
|
||||||
│ │ ├── Candidates.tsx
|
│ │ ├── Candidates.tsx
|
||||||
@@ -653,7 +652,7 @@ docker-data/
|
|||||||
| **Implement Automation Service** | `domain/automation/services/` | TODO | HIGH |
|
| **Implement Automation Service** | `domain/automation/services/` | TODO | HIGH |
|
||||||
| **Implement Automation API** | `modules/automation/` | TODO | HIGH |
|
| **Implement Automation API** | `modules/automation/` | TODO | HIGH |
|
||||||
| **Implement Automation UI** | `frontend/src/pages/Automation/` | TODO | HIGH |
|
| **Implement Automation UI** | `frontend/src/pages/Automation/` | TODO | HIGH |
|
||||||
| **Implement Schedules UI** | `frontend/src/pages/Schedules.tsx` | TODO | HIGH |
|
| **Note**: Schedules functionality will be integrated into Automation UI, not as a separate page | - | - | - |
|
||||||
|
|
||||||
### 9.2 Phase 1: Site Builder
|
### 9.2 Phase 1: Site Builder
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ CREDIT_COSTS = {
|
|||||||
|------|-------|--------------|
|
|------|-------|--------------|
|
||||||
| **Automation Dashboard** | `frontend/src/pages/Automation/Dashboard.tsx` | EXISTING (placeholder) |
|
| **Automation Dashboard** | `frontend/src/pages/Automation/Dashboard.tsx` | EXISTING (placeholder) |
|
||||||
| **Rules Management** | `frontend/src/pages/Automation/Rules.tsx` | NEW |
|
| **Rules Management** | `frontend/src/pages/Automation/Rules.tsx` | NEW |
|
||||||
| **Schedules Page** | `frontend/src/pages/Schedules.tsx` | EXISTING (placeholder) |
|
| **Schedules (within Automation)** | Integrated into Automation Dashboard | Part of automation menu |
|
||||||
| **Automation API Client** | `frontend/src/services/automation.api.ts` | NEW |
|
| **Automation API Client** | `frontend/src/services/automation.api.ts` | NEW |
|
||||||
|
|
||||||
### 2.6 Testing
|
### 2.6 Testing
|
||||||
|
|||||||
@@ -462,13 +462,11 @@ urlpatterns = router.urls
|
|||||||
- Test rule
|
- Test rule
|
||||||
- Manual execution
|
- Manual execution
|
||||||
|
|
||||||
#### Schedules Page
|
#### Schedules (Part of Automation Menu)
|
||||||
|
|
||||||
| Task | File | Dependencies | Implementation |
|
**Note**: Schedules functionality will be integrated into the Automation menu group, not as a separate page.
|
||||||
|------|------|--------------|----------------|
|
|
||||||
| **Schedules Page** | `frontend/src/pages/Schedules.tsx` | EXISTING (placeholder) | View scheduled task history |
|
|
||||||
|
|
||||||
**Schedules Page Features**:
|
**Schedules Features** (within Automation Dashboard):
|
||||||
- List scheduled tasks
|
- List scheduled tasks
|
||||||
- Filter by status, rule, date
|
- Filter by status, rule, date
|
||||||
- View execution results
|
- View execution results
|
||||||
@@ -553,11 +551,11 @@ export const automationApi = {
|
|||||||
|
|
||||||
- [ ] Implement `frontend/src/pages/Automation/Dashboard.tsx`
|
- [ ] Implement `frontend/src/pages/Automation/Dashboard.tsx`
|
||||||
- [ ] Create `frontend/src/pages/Automation/Rules.tsx`
|
- [ ] Create `frontend/src/pages/Automation/Rules.tsx`
|
||||||
- [ ] Implement `frontend/src/pages/Schedules.tsx`
|
- [ ] Integrate schedules functionality into Automation Dashboard (not as separate page)
|
||||||
- [ ] Create `frontend/src/services/automation.api.ts`
|
- [ ] Create `frontend/src/services/automation.api.ts`
|
||||||
- [ ] Create rule creation wizard
|
- [ ] Create rule creation wizard
|
||||||
- [ ] Create rule editor
|
- [ ] Create rule editor
|
||||||
- [ ] Create schedule history table
|
- [ ] Create schedule history table (within Automation Dashboard)
|
||||||
|
|
||||||
### Testing Tasks
|
### Testing Tasks
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ const SeedKeywords = lazy(() => import("./pages/Reference/SeedKeywords"));
|
|||||||
const ReferenceIndustries = lazy(() => import("./pages/Reference/Industries"));
|
const ReferenceIndustries = lazy(() => import("./pages/Reference/Industries"));
|
||||||
|
|
||||||
// Other Pages - Lazy loaded
|
// Other Pages - Lazy loaded
|
||||||
const Schedules = lazy(() => import("./pages/Schedules"));
|
|
||||||
const AutomationDashboard = lazy(() => import("./pages/Automation/Dashboard"));
|
const AutomationDashboard = lazy(() => import("./pages/Automation/Dashboard"));
|
||||||
|
|
||||||
// Settings - Lazy loaded
|
// Settings - Lazy loaded
|
||||||
@@ -294,11 +293,6 @@ export default function App() {
|
|||||||
</ModuleGuard>
|
</ModuleGuard>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
<Route path="/schedules" element={
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<Schedules />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<Route path="/settings" element={
|
<Route path="/settings" element={
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { useAuthStore } from "../../store/authStore";
|
|||||||
* - /settings (including /settings/sites)
|
* - /settings (including /settings/sites)
|
||||||
* - /dashboard
|
* - /dashboard
|
||||||
* - /analytics
|
* - /analytics
|
||||||
* - /schedules
|
|
||||||
* - /thinker
|
* - /thinker
|
||||||
* - /signin, /signup
|
* - /signin, /signup
|
||||||
*/
|
*/
|
||||||
@@ -37,7 +36,6 @@ const SITE_SWITCHER_HIDDEN_PATHS = [
|
|||||||
'/settings',
|
'/settings',
|
||||||
'/dashboard',
|
'/dashboard',
|
||||||
'/analytics',
|
'/analytics',
|
||||||
'/schedules',
|
|
||||||
'/thinker',
|
'/thinker',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -51,11 +51,6 @@ export const routes: RouteConfig[] = [
|
|||||||
{ path: '/thinker/profile', label: 'Profile', breadcrumb: 'Profile' },
|
{ path: '/thinker/profile', label: 'Profile', breadcrumb: 'Profile' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/schedules',
|
|
||||||
label: 'Schedules',
|
|
||||||
icon: 'Schedules',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const getBreadcrumbs = (pathname: string): Array<{ label: string; path: string }> => {
|
export const getBreadcrumbs = (pathname: string): Array<{ label: string; path: string }> => {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
PlugInIcon,
|
PlugInIcon,
|
||||||
TaskIcon,
|
TaskIcon,
|
||||||
BoltIcon,
|
BoltIcon,
|
||||||
TimeIcon,
|
|
||||||
DocsIcon,
|
DocsIcon,
|
||||||
PageIcon,
|
PageIcon,
|
||||||
DollarLineIcon,
|
DollarLineIcon,
|
||||||
@@ -144,12 +143,6 @@ const AppSidebar: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
workflowItems.push({
|
|
||||||
icon: <TimeIcon />,
|
|
||||||
name: "Schedules",
|
|
||||||
path: "/schedules",
|
|
||||||
});
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: "OVERVIEW",
|
label: "OVERVIEW",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function Help() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "How do I set up automation?",
|
question: "How do I set up automation?",
|
||||||
answer: "Go to Dashboard > Automation Setup section. Enable automation for each step (Keywords, Ideas, Content, Images) and configure settings like how many keywords to process per cycle. Advanced settings are available in Schedules page."
|
answer: "Go to Dashboard > Automation Setup section. Enable automation for each step (Keywords, Ideas, Content, Images) and configure settings like how many keywords to process per cycle. Advanced scheduling settings are available in the Automation menu."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Can I edit AI-generated content?",
|
question: "Can I edit AI-generated content?",
|
||||||
@@ -539,7 +539,7 @@ export default function Help() {
|
|||||||
|
|
||||||
<div className="mt-6 p-4 bg-brand-50 dark:bg-brand-900/10 rounded-lg border border-brand-200 dark:border-brand-800">
|
<div className="mt-6 p-4 bg-brand-50 dark:bg-brand-900/10 rounded-lg border border-brand-200 dark:border-brand-800">
|
||||||
<p className="text-sm text-brand-800 dark:text-brand-300">
|
<p className="text-sm text-brand-800 dark:text-brand-300">
|
||||||
<strong>Note:</strong> Configure automation in Dashboard > Automation Setup. For advanced scheduling, go to Schedules page.
|
<strong>Note:</strong> Configure automation in Dashboard > Automation Setup. For advanced scheduling, go to the Automation menu.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -194,13 +194,14 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo
|
|||||||
|
|
||||||
if (refreshResponse.ok) {
|
if (refreshResponse.ok) {
|
||||||
const refreshData = await refreshResponse.json();
|
const refreshData = await refreshResponse.json();
|
||||||
if (refreshData.success && refreshData.access) {
|
const accessToken = refreshData.data?.access || refreshData.access;
|
||||||
|
if (refreshData.success && accessToken) {
|
||||||
// Update token in store
|
// Update token in store
|
||||||
try {
|
try {
|
||||||
const authStorage = localStorage.getItem('auth-storage');
|
const authStorage = localStorage.getItem('auth-storage');
|
||||||
if (authStorage) {
|
if (authStorage) {
|
||||||
const parsed = JSON.parse(authStorage);
|
const parsed = JSON.parse(authStorage);
|
||||||
parsed.state.token = refreshData.access;
|
parsed.state.token = accessToken;
|
||||||
localStorage.setItem('auth-storage', JSON.stringify(parsed));
|
localStorage.setItem('auth-storage', JSON.stringify(parsed));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -210,7 +211,7 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo
|
|||||||
// Retry original request with new token
|
// Retry original request with new token
|
||||||
const newHeaders = {
|
const newHeaders = {
|
||||||
...headers,
|
...headers,
|
||||||
'Authorization': `Bearer ${refreshData.access}`,
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryResponse = await fetch(`${API_BASE_URL}${endpoint}`, {
|
const retryResponse = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
|
|||||||
@@ -60,14 +60,17 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok || !data.success) {
|
if (!response.ok || !data.success) {
|
||||||
throw new Error(data.message || 'Login failed');
|
throw new Error(data.error || data.message || 'Login failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store user and JWT tokens
|
// Store user and JWT tokens (handle both old and new API formats)
|
||||||
|
const responseData = data.data || data;
|
||||||
|
// Support both formats: new (access/refresh at top level) and old (tokens.access/refresh)
|
||||||
|
const tokens = responseData.tokens || {};
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: responseData.user || data.user,
|
||||||
token: data.tokens?.access || null,
|
token: responseData.access || tokens.access || data.access || null,
|
||||||
refreshToken: data.tokens?.refresh || null,
|
refreshToken: responseData.refresh || tokens.refresh || data.refresh || null,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
loading: false
|
loading: false
|
||||||
});
|
});
|
||||||
@@ -119,8 +122,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
// Store user and JWT tokens
|
// Store user and JWT tokens
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
token: data.tokens?.access || null,
|
token: data.data?.access || data.access || null,
|
||||||
refreshToken: data.tokens?.refresh || null,
|
refreshToken: data.data?.refresh || data.refresh || null,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
loading: false
|
loading: false
|
||||||
});
|
});
|
||||||
@@ -168,8 +171,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
throw new Error(data.message || 'Token refresh failed');
|
throw new Error(data.message || 'Token refresh failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update access token
|
// Update access token (API returns access at top level of data)
|
||||||
set({ token: data.access });
|
set({ token: data.data?.access || data.access });
|
||||||
|
|
||||||
// Also refresh user data to get latest account/plan information
|
// Also refresh user data to get latest account/plan information
|
||||||
// This ensures account/plan changes are reflected immediately
|
// This ensures account/plan changes are reflected immediately
|
||||||
|
|||||||
Reference in New Issue
Block a user