Enhance API response handling and implement unified API standard across multiple modules. Added feature flags for unified exception handling and debug throttling in settings. Updated pagination and response formats in various viewsets to align with the new standard. Improved error handling and response validation in frontend components for better user feedback.
This commit is contained in:
176
backend/igny8_core/api/exception_handlers.py
Normal file
176
backend/igny8_core/api/exception_handlers.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Centralized Exception Handler
|
||||
Wraps all exceptions in unified format
|
||||
"""
|
||||
import logging
|
||||
from rest_framework.views import exception_handler
|
||||
from rest_framework import status
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
from .response import get_request_id, error_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def custom_exception_handler(exc, context):
|
||||
"""
|
||||
Custom exception handler that wraps all errors in unified format
|
||||
|
||||
Args:
|
||||
exc: The exception that was raised
|
||||
context: Dictionary containing request, view, args, kwargs
|
||||
|
||||
Returns:
|
||||
Response object with unified error format
|
||||
"""
|
||||
# Get request from context
|
||||
request = context.get('request')
|
||||
|
||||
# Get request ID
|
||||
request_id = get_request_id(request) if request else None
|
||||
|
||||
# Call DRF's default exception handler first
|
||||
response = exception_handler(exc, context)
|
||||
|
||||
# If DRF handled it, wrap it in unified format
|
||||
if response is not None:
|
||||
# Extract error details from DRF response
|
||||
error_message = None
|
||||
errors = None
|
||||
status_code = response.status_code
|
||||
|
||||
# Try to extract error message from response data
|
||||
if hasattr(response, 'data'):
|
||||
if isinstance(response.data, dict):
|
||||
# DRF validation errors
|
||||
if 'detail' in response.data:
|
||||
error_message = str(response.data['detail'])
|
||||
elif 'non_field_errors' in response.data:
|
||||
error_message = str(response.data['non_field_errors'][0]) if response.data['non_field_errors'] else None
|
||||
errors = response.data
|
||||
else:
|
||||
# Field-specific errors
|
||||
errors = response.data
|
||||
# Create top-level error message
|
||||
if errors:
|
||||
first_error = list(errors.values())[0] if errors else None
|
||||
if first_error and isinstance(first_error, list) and len(first_error) > 0:
|
||||
error_message = str(first_error[0])
|
||||
elif first_error:
|
||||
error_message = str(first_error)
|
||||
else:
|
||||
error_message = 'Validation failed'
|
||||
elif isinstance(response.data, list):
|
||||
# List of errors
|
||||
error_message = str(response.data[0]) if response.data else 'Validation failed'
|
||||
else:
|
||||
error_message = str(response.data)
|
||||
|
||||
# Map status codes to appropriate error messages
|
||||
if not error_message:
|
||||
if status_code == status.HTTP_400_BAD_REQUEST:
|
||||
error_message = 'Bad request'
|
||||
elif status_code == status.HTTP_401_UNAUTHORIZED:
|
||||
error_message = 'Authentication required'
|
||||
elif status_code == status.HTTP_403_FORBIDDEN:
|
||||
error_message = 'Permission denied'
|
||||
elif status_code == status.HTTP_404_NOT_FOUND:
|
||||
error_message = 'Resource not found'
|
||||
elif status_code == status.HTTP_409_CONFLICT:
|
||||
error_message = 'Conflict'
|
||||
elif status_code == status.HTTP_422_UNPROCESSABLE_ENTITY:
|
||||
error_message = 'Validation failed'
|
||||
elif status_code == status.HTTP_429_TOO_MANY_REQUESTS:
|
||||
error_message = 'Rate limit exceeded'
|
||||
elif status_code >= 500:
|
||||
error_message = 'Internal server error'
|
||||
else:
|
||||
error_message = 'An error occurred'
|
||||
|
||||
# Prepare debug info (only in DEBUG mode)
|
||||
debug_info = None
|
||||
if settings.DEBUG:
|
||||
debug_info = {
|
||||
'exception_type': type(exc).__name__,
|
||||
'exception_message': str(exc),
|
||||
'view': context.get('view').__class__.__name__ if context.get('view') else None,
|
||||
'path': request.path if request else None,
|
||||
'method': request.method if request else None,
|
||||
}
|
||||
# Include traceback in debug mode
|
||||
import traceback
|
||||
debug_info['traceback'] = traceback.format_exc()
|
||||
|
||||
# Log the error
|
||||
if status_code >= 500:
|
||||
logger.error(
|
||||
f"Server error: {error_message}",
|
||||
extra={
|
||||
'request_id': request_id,
|
||||
'endpoint': request.path if request else None,
|
||||
'method': request.method if request else None,
|
||||
'user_id': request.user.id if request and request.user and request.user.is_authenticated else None,
|
||||
'account_id': request.account.id if request and hasattr(request, 'account') and request.account else None,
|
||||
'status_code': status_code,
|
||||
'exception_type': type(exc).__name__,
|
||||
},
|
||||
exc_info=True
|
||||
)
|
||||
elif status_code >= 400:
|
||||
logger.warning(
|
||||
f"Client error: {error_message}",
|
||||
extra={
|
||||
'request_id': request_id,
|
||||
'endpoint': request.path if request else None,
|
||||
'method': request.method if request else None,
|
||||
'user_id': request.user.id if request and request.user and request.user.is_authenticated else None,
|
||||
'account_id': request.account.id if request and hasattr(request, 'account') and request.account else None,
|
||||
'status_code': status_code,
|
||||
}
|
||||
)
|
||||
|
||||
# Return unified error response
|
||||
return error_response(
|
||||
error=error_message,
|
||||
errors=errors,
|
||||
status_code=status_code,
|
||||
request=request,
|
||||
debug_info=debug_info
|
||||
)
|
||||
|
||||
# If DRF didn't handle it, it's an unhandled exception
|
||||
# Log it and return unified error response
|
||||
logger.error(
|
||||
f"Unhandled exception: {type(exc).__name__}: {str(exc)}",
|
||||
extra={
|
||||
'request_id': request_id,
|
||||
'endpoint': request.path if request else None,
|
||||
'method': request.method if request else None,
|
||||
'user_id': request.user.id if request and request.user and request.user.is_authenticated else None,
|
||||
'account_id': request.account.id if request and hasattr(request, 'account') and request.account else None,
|
||||
},
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Prepare debug info
|
||||
debug_info = None
|
||||
if settings.DEBUG:
|
||||
import traceback
|
||||
debug_info = {
|
||||
'exception_type': type(exc).__name__,
|
||||
'exception_message': str(exc),
|
||||
'view': context.get('view').__class__.__name__ if context.get('view') else None,
|
||||
'path': request.path if request else None,
|
||||
'method': request.method if request else None,
|
||||
'traceback': traceback.format_exc()
|
||||
}
|
||||
|
||||
return error_response(
|
||||
error='Internal server error',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request,
|
||||
debug_info=debug_info
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""
|
||||
Custom pagination class for DRF to support dynamic page_size query parameter
|
||||
and unified response format
|
||||
"""
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from .response import get_request_id
|
||||
|
||||
|
||||
class CustomPageNumberPagination(PageNumberPagination):
|
||||
@@ -11,8 +13,37 @@ class CustomPageNumberPagination(PageNumberPagination):
|
||||
|
||||
Default page size: 10
|
||||
Max page size: 100
|
||||
|
||||
Returns unified format with success field
|
||||
"""
|
||||
page_size = 10
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 100
|
||||
|
||||
def paginate_queryset(self, queryset, request, view=None):
|
||||
"""
|
||||
Override to store request for later use in get_paginated_response
|
||||
"""
|
||||
self.request = request
|
||||
return super().paginate_queryset(queryset, request, view)
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
"""
|
||||
Return a paginated response with unified format including success field
|
||||
"""
|
||||
from rest_framework.response import Response
|
||||
|
||||
response_data = {
|
||||
'success': True,
|
||||
'count': self.page.paginator.count,
|
||||
'next': self.get_next_link(),
|
||||
'previous': self.get_previous_link(),
|
||||
'results': data
|
||||
}
|
||||
|
||||
# Add request_id if request is available
|
||||
if hasattr(self, 'request') and self.request:
|
||||
response_data['request_id'] = get_request_id(self.request)
|
||||
|
||||
return Response(response_data)
|
||||
|
||||
|
||||
162
backend/igny8_core/api/permissions.py
Normal file
162
backend/igny8_core/api/permissions.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Standardized Permission Classes
|
||||
Provides consistent permission checking across all endpoints
|
||||
"""
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
|
||||
class IsAuthenticatedAndActive(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that requires user to be authenticated and active
|
||||
Base permission for most endpoints
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Check if user is active
|
||||
if hasattr(request.user, 'is_active'):
|
||||
return request.user.is_active
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class HasTenantAccess(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that requires user to belong to the tenant/account
|
||||
Ensures tenant isolation
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Get account from request (set by middleware)
|
||||
account = getattr(request, 'account', None)
|
||||
|
||||
# If no account in request, try to get from user
|
||||
if not account and hasattr(request.user, 'account'):
|
||||
try:
|
||||
account = request.user.account
|
||||
except (AttributeError, Exception):
|
||||
pass
|
||||
|
||||
# Admin/Developer/System account users bypass tenant check
|
||||
if request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated:
|
||||
try:
|
||||
is_admin_or_dev = (hasattr(request.user, 'is_admin_or_developer') and
|
||||
request.user.is_admin_or_developer()) if request.user else False
|
||||
is_system_user = (hasattr(request.user, 'is_system_account_user') and
|
||||
request.user.is_system_account_user()) if request.user else False
|
||||
|
||||
if is_admin_or_dev or is_system_user:
|
||||
return True
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
# Regular users must have account access
|
||||
if account:
|
||||
# Check if user belongs to this account
|
||||
if hasattr(request.user, 'account'):
|
||||
try:
|
||||
user_account = request.user.account
|
||||
return user_account == account or user_account.id == account.id
|
||||
except (AttributeError, Exception):
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class IsViewerOrAbove(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that requires viewer, editor, admin, or owner role
|
||||
For read-only operations
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Admin/Developer/System account users always have access
|
||||
try:
|
||||
is_admin_or_dev = (hasattr(request.user, 'is_admin_or_developer') and
|
||||
request.user.is_admin_or_developer()) if request.user else False
|
||||
is_system_user = (hasattr(request.user, 'is_system_account_user') and
|
||||
request.user.is_system_account_user()) if request.user else False
|
||||
|
||||
if is_admin_or_dev or is_system_user:
|
||||
return True
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
# Check user role
|
||||
if hasattr(request.user, 'role'):
|
||||
role = request.user.role
|
||||
# viewer, editor, admin, owner all have access
|
||||
return role in ['viewer', 'editor', 'admin', 'owner']
|
||||
|
||||
# If no role system, allow authenticated users
|
||||
return True
|
||||
|
||||
|
||||
class IsEditorOrAbove(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that requires editor, admin, or owner role
|
||||
For content operations
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Admin/Developer/System account users always have access
|
||||
try:
|
||||
is_admin_or_dev = (hasattr(request.user, 'is_admin_or_developer') and
|
||||
request.user.is_admin_or_developer()) if request.user else False
|
||||
is_system_user = (hasattr(request.user, 'is_system_account_user') and
|
||||
request.user.is_system_account_user()) if request.user else False
|
||||
|
||||
if is_admin_or_dev or is_system_user:
|
||||
return True
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
# Check user role
|
||||
if hasattr(request.user, 'role'):
|
||||
role = request.user.role
|
||||
# editor, admin, owner have access
|
||||
return role in ['editor', 'admin', 'owner']
|
||||
|
||||
# If no role system, allow authenticated users
|
||||
return True
|
||||
|
||||
|
||||
class IsAdminOrOwner(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that requires admin or owner role only
|
||||
For settings, keys, billing operations
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Admin/Developer/System account users always have access
|
||||
try:
|
||||
is_admin_or_dev = (hasattr(request.user, 'is_admin_or_developer') and
|
||||
request.user.is_admin_or_developer()) if request.user else False
|
||||
is_system_user = (hasattr(request.user, 'is_system_account_user') and
|
||||
request.user.is_system_account_user()) if request.user else False
|
||||
|
||||
if is_admin_or_dev or is_system_user:
|
||||
return True
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
# Check user role
|
||||
if hasattr(request.user, 'role'):
|
||||
role = request.user.role
|
||||
# admin, owner have access
|
||||
return role in ['admin', 'owner']
|
||||
|
||||
# If no role system, deny by default for security
|
||||
return False
|
||||
|
||||
|
||||
152
backend/igny8_core/api/response.py
Normal file
152
backend/igny8_core/api/response.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Unified API Response Helpers
|
||||
Provides consistent response format across all endpoints
|
||||
"""
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
import uuid
|
||||
|
||||
|
||||
def get_request_id(request):
|
||||
"""Get request ID from request object (set by middleware) or headers, or generate new one"""
|
||||
if not request:
|
||||
return None
|
||||
|
||||
# First check if middleware set request_id on request object
|
||||
if hasattr(request, 'request_id') and request.request_id:
|
||||
return request.request_id
|
||||
|
||||
# Fallback to headers
|
||||
if hasattr(request, 'META'):
|
||||
request_id = request.META.get('HTTP_X_REQUEST_ID') or request.META.get('X-Request-ID')
|
||||
if request_id:
|
||||
return request_id
|
||||
|
||||
# Generate new request ID if none found
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def success_response(data=None, message=None, status_code=status.HTTP_200_OK, request=None):
|
||||
"""
|
||||
Create a standardized success response
|
||||
|
||||
Args:
|
||||
data: Response data (dict, list, or None)
|
||||
message: Optional success message
|
||||
status_code: HTTP status code (default: 200)
|
||||
request: Request object (optional, for request_id)
|
||||
|
||||
Returns:
|
||||
Response object with unified format
|
||||
"""
|
||||
response_data = {
|
||||
'success': True,
|
||||
}
|
||||
|
||||
if data is not None:
|
||||
response_data['data'] = data
|
||||
|
||||
if message:
|
||||
response_data['message'] = message
|
||||
|
||||
# Add request_id if request is provided
|
||||
if request:
|
||||
response_data['request_id'] = get_request_id(request)
|
||||
|
||||
return Response(response_data, status=status_code)
|
||||
|
||||
|
||||
def error_response(error=None, errors=None, status_code=status.HTTP_400_BAD_REQUEST, request=None, debug_info=None):
|
||||
"""
|
||||
Create a standardized error response
|
||||
|
||||
Args:
|
||||
error: Top-level error message
|
||||
errors: Field-specific errors (dict of field -> list of errors)
|
||||
status_code: HTTP status code (default: 400)
|
||||
request: Request object (optional, for request_id)
|
||||
debug_info: Debug information (only in DEBUG mode)
|
||||
|
||||
Returns:
|
||||
Response object with unified error format
|
||||
"""
|
||||
response_data = {
|
||||
'success': False,
|
||||
}
|
||||
|
||||
if error:
|
||||
response_data['error'] = error
|
||||
elif status_code == status.HTTP_400_BAD_REQUEST:
|
||||
response_data['error'] = 'Bad request'
|
||||
elif status_code == status.HTTP_401_UNAUTHORIZED:
|
||||
response_data['error'] = 'Authentication required'
|
||||
elif status_code == status.HTTP_403_FORBIDDEN:
|
||||
response_data['error'] = 'Permission denied'
|
||||
elif status_code == status.HTTP_404_NOT_FOUND:
|
||||
response_data['error'] = 'Resource not found'
|
||||
elif status_code == status.HTTP_409_CONFLICT:
|
||||
response_data['error'] = 'Conflict'
|
||||
elif status_code == status.HTTP_422_UNPROCESSABLE_ENTITY:
|
||||
response_data['error'] = 'Validation failed'
|
||||
elif status_code == status.HTTP_429_TOO_MANY_REQUESTS:
|
||||
response_data['error'] = 'Rate limit exceeded'
|
||||
elif status_code >= 500:
|
||||
response_data['error'] = 'Internal server error'
|
||||
else:
|
||||
response_data['error'] = 'An error occurred'
|
||||
|
||||
if errors:
|
||||
response_data['errors'] = errors
|
||||
|
||||
# Add request_id if request is provided
|
||||
if request:
|
||||
response_data['request_id'] = get_request_id(request)
|
||||
|
||||
# Add debug info in DEBUG mode
|
||||
if debug_info:
|
||||
response_data['debug'] = debug_info
|
||||
|
||||
return Response(response_data, status=status_code)
|
||||
|
||||
|
||||
def paginated_response(paginated_data, message=None, request=None):
|
||||
"""
|
||||
Create a standardized paginated response
|
||||
|
||||
Args:
|
||||
paginated_data: Paginated data dict from DRF paginator (contains count, next, previous, results)
|
||||
message: Optional success message
|
||||
request: Request object (optional, for request_id)
|
||||
|
||||
Returns:
|
||||
Response object with unified paginated format
|
||||
"""
|
||||
response_data = {
|
||||
'success': True,
|
||||
}
|
||||
|
||||
# Copy pagination fields from DRF paginator
|
||||
if isinstance(paginated_data, dict):
|
||||
response_data.update({
|
||||
'count': paginated_data.get('count', 0),
|
||||
'next': paginated_data.get('next'),
|
||||
'previous': paginated_data.get('previous'),
|
||||
'results': paginated_data.get('results', [])
|
||||
})
|
||||
else:
|
||||
# Fallback if paginated_data is not a dict
|
||||
response_data['count'] = 0
|
||||
response_data['next'] = None
|
||||
response_data['previous'] = None
|
||||
response_data['results'] = []
|
||||
|
||||
if message:
|
||||
response_data['message'] = message
|
||||
|
||||
# Add request_id if request is provided
|
||||
if request:
|
||||
response_data['request_id'] = get_request_id(request)
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
136
backend/igny8_core/api/throttles.py
Normal file
136
backend/igny8_core/api/throttles.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Scoped Rate Throttling
|
||||
Provides rate limiting with different scopes for different operation types
|
||||
"""
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DebugScopedRateThrottle(ScopedRateThrottle):
|
||||
"""
|
||||
Scoped rate throttle that can be bypassed in debug mode
|
||||
|
||||
Usage:
|
||||
class MyViewSet(viewsets.ModelViewSet):
|
||||
throttle_scope = 'planner'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
"""
|
||||
|
||||
def allow_request(self, request, view):
|
||||
"""
|
||||
Check if request should be throttled
|
||||
|
||||
Bypasses throttling if:
|
||||
- DEBUG mode is True
|
||||
- IGNY8_DEBUG_THROTTLE environment variable is True
|
||||
- User belongs to aws-admin or other system accounts
|
||||
- User is admin/developer role
|
||||
"""
|
||||
# Check if throttling should be bypassed
|
||||
debug_bypass = getattr(settings, 'DEBUG', False)
|
||||
env_bypass = getattr(settings, 'IGNY8_DEBUG_THROTTLE', False)
|
||||
|
||||
# Bypass for system account users (aws-admin, default-account, etc.)
|
||||
system_account_bypass = False
|
||||
if hasattr(request, 'user') and request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated:
|
||||
try:
|
||||
# Check if user is in system account (aws-admin, default-account, default)
|
||||
if hasattr(request.user, 'is_system_account_user') and request.user.is_system_account_user():
|
||||
system_account_bypass = True
|
||||
# Also bypass for admin/developer roles
|
||||
elif hasattr(request.user, 'is_admin_or_developer') and request.user.is_admin_or_developer():
|
||||
system_account_bypass = True
|
||||
except (AttributeError, Exception):
|
||||
# If checking fails, continue with normal throttling
|
||||
pass
|
||||
|
||||
if debug_bypass or env_bypass or system_account_bypass:
|
||||
# In debug mode or for system accounts, still set throttle headers but don't actually throttle
|
||||
# This allows testing throttle headers without blocking requests
|
||||
if hasattr(self, 'get_rate'):
|
||||
# Set headers for debugging
|
||||
self.scope = getattr(view, 'throttle_scope', None)
|
||||
if self.scope:
|
||||
# Get rate for this scope
|
||||
rate = self.get_rate()
|
||||
if rate:
|
||||
# Parse rate (e.g., "10/min")
|
||||
num_requests, duration = self.parse_rate(rate)
|
||||
# Set headers
|
||||
request._throttle_debug_info = {
|
||||
'scope': self.scope,
|
||||
'rate': rate,
|
||||
'limit': num_requests,
|
||||
'duration': duration
|
||||
}
|
||||
return True
|
||||
|
||||
# Normal throttling behavior
|
||||
return super().allow_request(request, view)
|
||||
|
||||
def get_rate(self):
|
||||
"""
|
||||
Get rate for the current scope
|
||||
"""
|
||||
if not self.scope:
|
||||
return None
|
||||
|
||||
# Get throttle rates from settings
|
||||
throttle_rates = getattr(settings, 'REST_FRAMEWORK', {}).get('DEFAULT_THROTTLE_RATES', {})
|
||||
|
||||
# Get rate for this scope
|
||||
rate = throttle_rates.get(self.scope)
|
||||
|
||||
# Fallback to default if scope not found
|
||||
if not rate:
|
||||
rate = throttle_rates.get('default', '100/min')
|
||||
|
||||
return rate
|
||||
|
||||
def parse_rate(self, rate):
|
||||
"""
|
||||
Parse rate string (e.g., "10/min") into (num_requests, duration)
|
||||
|
||||
Returns:
|
||||
tuple: (num_requests, duration_in_seconds)
|
||||
"""
|
||||
if not rate:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
num, period = rate.split('/')
|
||||
num_requests = int(num)
|
||||
|
||||
# Parse duration
|
||||
period = period.strip().lower()
|
||||
if period == 'sec' or period == 's':
|
||||
duration = 1
|
||||
elif period == 'min' or period == 'm':
|
||||
duration = 60
|
||||
elif period == 'hour' or period == 'h':
|
||||
duration = 3600
|
||||
elif period == 'day' or period == 'd':
|
||||
duration = 86400
|
||||
else:
|
||||
# Default to seconds
|
||||
duration = 1
|
||||
|
||||
return num_requests, duration
|
||||
except (ValueError, AttributeError):
|
||||
# Invalid rate format, default to 100/min
|
||||
logger.warning(f"Invalid rate format: {rate}, defaulting to 100/min")
|
||||
return 100, 60
|
||||
|
||||
def throttle_success(self):
|
||||
"""
|
||||
Called when request is allowed
|
||||
Sets throttle headers on response
|
||||
"""
|
||||
# This is called by DRF after allow_request returns True
|
||||
# Headers are set automatically by ScopedRateThrottle
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""
|
||||
Authentication Views - Structured as: Groups, Users, Accounts, Subscriptions, Site User Access
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
from rest_framework import viewsets, status, permissions, filters
|
||||
from rest_framework.decorators import action
|
||||
@@ -11,6 +12,9 @@ from django.db import transaction
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from igny8_core.api.base import AccountModelViewSet
|
||||
from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.api.pagination import CustomPageNumberPagination
|
||||
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword
|
||||
from .serializers import (
|
||||
UserSerializer, AccountSerializer, PlanSerializer, SubscriptionSerializer,
|
||||
@@ -33,8 +37,11 @@ class GroupsViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
ViewSet for managing user roles and permissions (Groups).
|
||||
Groups are defined by the User.ROLE_CHOICES.
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
permission_classes = [IsOwnerOrAdmin]
|
||||
throttle_scope = 'auth'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def list(self, request):
|
||||
"""List all available roles/groups."""
|
||||
@@ -76,17 +83,18 @@ class GroupsViewSet(viewsets.ViewSet):
|
||||
'permissions': ['automation_only']
|
||||
}
|
||||
]
|
||||
return Response({
|
||||
'success': True,
|
||||
'groups': roles
|
||||
})
|
||||
return success_response(data={'groups': roles}, request=request)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='permissions')
|
||||
def permissions(self, request):
|
||||
"""Get permissions for a specific role."""
|
||||
role = request.query_params.get('role')
|
||||
if not role:
|
||||
return Response({'error': 'role parameter is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='role parameter is required',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
role_permissions = {
|
||||
'developer': ['full_access', 'bypass_filters', 'all_modules', 'all_accounts'],
|
||||
@@ -98,11 +106,13 @@ class GroupsViewSet(viewsets.ViewSet):
|
||||
}
|
||||
|
||||
permissions_list = role_permissions.get(role, [])
|
||||
return Response({
|
||||
'success': True,
|
||||
return success_response(
|
||||
data={
|
||||
'role': role,
|
||||
'permissions': permissions_list
|
||||
})
|
||||
},
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -113,10 +123,14 @@ class UsersViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing global user records and credentials.
|
||||
Users are global, but belong to accounts.
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [IsOwnerOrAdmin]
|
||||
pagination_class = CustomPageNumberPagination
|
||||
throttle_scope = 'auth'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return users based on access level."""
|
||||
@@ -147,17 +161,21 @@ class UsersViewSet(viewsets.ModelViewSet):
|
||||
account_id = request.data.get('account_id')
|
||||
|
||||
if not email or not username or not password:
|
||||
return Response({
|
||||
'error': 'email, username, and password are required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='email, username, and password are required',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Validate password
|
||||
try:
|
||||
validate_password(password)
|
||||
except Exception as e:
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get account
|
||||
account = None
|
||||
@@ -165,9 +183,11 @@ class UsersViewSet(viewsets.ModelViewSet):
|
||||
try:
|
||||
account = Account.objects.get(id=account_id)
|
||||
except Account.DoesNotExist:
|
||||
return Response({
|
||||
'error': f'Account with id {account_id} does not exist'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=f'Account with id {account_id} does not exist',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
# Use current user's account
|
||||
if request.user.account:
|
||||
@@ -183,14 +203,17 @@ class UsersViewSet(viewsets.ModelViewSet):
|
||||
account=account
|
||||
)
|
||||
serializer = UserSerializer(user)
|
||||
return Response({
|
||||
'success': True,
|
||||
'user': serializer.data
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
return success_response(
|
||||
data={'user': serializer.data},
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
return Response({
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def update_role(self, request, pk=None):
|
||||
@@ -199,23 +222,24 @@ class UsersViewSet(viewsets.ModelViewSet):
|
||||
new_role = request.data.get('role')
|
||||
|
||||
if not new_role:
|
||||
return Response({
|
||||
'error': 'role is required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='role is required',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
if new_role not in [choice[0] for choice in User.ROLE_CHOICES]:
|
||||
return Response({
|
||||
'error': f'Invalid role. Must be one of: {[c[0] for c in User.ROLE_CHOICES]}'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=f'Invalid role. Must be one of: {[c[0] for c in User.ROLE_CHOICES]}',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
user.role = new_role
|
||||
user.save()
|
||||
|
||||
serializer = UserSerializer(user)
|
||||
return Response({
|
||||
'success': True,
|
||||
'user': serializer.data
|
||||
})
|
||||
return success_response(data={'user': serializer.data}, request=request)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -308,14 +332,16 @@ class SubscriptionsViewSet(viewsets.ModelViewSet):
|
||||
try:
|
||||
subscription = Subscription.objects.get(account_id=account_id)
|
||||
serializer = self.get_serializer(subscription)
|
||||
return Response({
|
||||
'success': True,
|
||||
'subscription': serializer.data
|
||||
})
|
||||
return success_response(
|
||||
data={'subscription': serializer.data},
|
||||
request=request
|
||||
)
|
||||
except Subscription.DoesNotExist:
|
||||
return Response({
|
||||
'error': 'Subscription not found for this account'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
return error_response(
|
||||
error='Subscription not found for this account',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -424,7 +450,10 @@ class SiteViewSet(AccountModelViewSet):
|
||||
site = self.get_object()
|
||||
sectors = site.sectors.filter(is_active=True)
|
||||
serializer = SectorSerializer(sectors, many=True)
|
||||
return Response(serializer.data)
|
||||
return success_response(
|
||||
data=serializer.data,
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='set_active')
|
||||
def set_active(self, request, pk=None):
|
||||
@@ -437,11 +466,11 @@ class SiteViewSet(AccountModelViewSet):
|
||||
site.save()
|
||||
|
||||
serializer = self.get_serializer(site)
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': f'Site "{site.name}" is now active',
|
||||
'site': serializer.data
|
||||
})
|
||||
return success_response(
|
||||
data={'site': serializer.data},
|
||||
message=f'Site "{site.name}" is now active',
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='select_sectors')
|
||||
def select_sectors(self, request, pk=None):
|
||||
@@ -453,43 +482,53 @@ class SiteViewSet(AccountModelViewSet):
|
||||
site = self.get_object()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting site object: {str(e)}", exc_info=True)
|
||||
return Response({
|
||||
'error': f'Site not found: {str(e)}'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
return error_response(
|
||||
error=f'Site not found: {str(e)}',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
|
||||
sector_slugs = request.data.get('sector_slugs', [])
|
||||
industry_slug = request.data.get('industry_slug')
|
||||
|
||||
if not industry_slug:
|
||||
return Response({
|
||||
'error': 'Industry slug is required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Industry slug is required',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
try:
|
||||
industry = Industry.objects.get(slug=industry_slug, is_active=True)
|
||||
except Industry.DoesNotExist:
|
||||
return Response({
|
||||
'error': f'Industry with slug "{industry_slug}" not found'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=f'Industry with slug "{industry_slug}" not found',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
site.industry = industry
|
||||
site.save()
|
||||
|
||||
if not sector_slugs:
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': f'Industry "{industry.name}" set for site. No sectors selected.',
|
||||
return success_response(
|
||||
data={
|
||||
'site': SiteSerializer(site).data,
|
||||
'sectors': []
|
||||
})
|
||||
},
|
||||
message=f'Industry "{industry.name}" set for site. No sectors selected.',
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get plan's max_industries limit (if set), otherwise default to 5
|
||||
max_sectors = site.get_max_sectors_limit()
|
||||
|
||||
if len(sector_slugs) > max_sectors:
|
||||
return Response({
|
||||
'error': f'Maximum {max_sectors} sectors allowed per site for this plan'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=f'Maximum {max_sectors} sectors allowed per site for this plan',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
created_sectors = []
|
||||
updated_sectors = []
|
||||
@@ -506,9 +545,11 @@ class SiteViewSet(AccountModelViewSet):
|
||||
).first()
|
||||
|
||||
if not industry_sector:
|
||||
return Response({
|
||||
'error': f'Sector "{sector_slug}" not found in industry "{industry.name}"'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=f'Sector "{sector_slug}" not found in industry "{industry.name}"',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
industry_sectors_map[sector_slug] = industry_sector
|
||||
|
||||
@@ -517,9 +558,11 @@ class SiteViewSet(AccountModelViewSet):
|
||||
# Check if site has account before proceeding
|
||||
if not site.account:
|
||||
logger.error(f"Site {site.id} has no account assigned")
|
||||
return Response({
|
||||
'error': f'Site "{site.name}" has no account assigned. Please contact support.'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Site "{site.name}" has no account assigned. Please contact support.',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Create or get sector - account will be set automatically in save() method
|
||||
# But we need to pass it in defaults for get_or_create to work
|
||||
@@ -552,27 +595,33 @@ class SiteViewSet(AccountModelViewSet):
|
||||
created_sectors.append(sector)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating/updating sector {sector_slug}: {str(e)}", exc_info=True)
|
||||
return Response({
|
||||
'error': f'Failed to create/update sector "{sector_slug}": {str(e)}'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Failed to create/update sector "{sector_slug}": {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get plan's max_industries limit (if set), otherwise default to 5
|
||||
max_sectors = site.get_max_sectors_limit()
|
||||
|
||||
if site.get_active_sectors_count() > max_sectors:
|
||||
return Response({
|
||||
'error': f'Maximum {max_sectors} sectors allowed per site for this plan'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=f'Maximum {max_sectors} sectors allowed per site for this plan',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
serializer = SectorSerializer(site.sectors.filter(is_active=True), many=True)
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': f'Selected {len(sector_slugs)} sectors from industry "{industry.name}".',
|
||||
return success_response(
|
||||
data={
|
||||
'created_count': len(created_sectors),
|
||||
'updated_count': len(updated_sectors),
|
||||
'sectors': serializer.data,
|
||||
'site': SiteSerializer(site).data
|
||||
})
|
||||
},
|
||||
message=f'Selected {len(sector_slugs)} sectors from industry "{industry.name}".',
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
class SectorViewSet(AccountModelViewSet):
|
||||
@@ -606,7 +655,10 @@ class SectorViewSet(AccountModelViewSet):
|
||||
"""Override list to apply site filter."""
|
||||
queryset = self.get_queryset_with_site_filter()
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
return success_response(
|
||||
data=serializer.data,
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
class IndustryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@@ -619,10 +671,10 @@ class IndustryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""Get all industries with their sectors."""
|
||||
industries = self.get_queryset()
|
||||
serializer = self.get_serializer(industries, many=True)
|
||||
return Response({
|
||||
'success': True,
|
||||
'industries': serializer.data
|
||||
})
|
||||
return success_response(
|
||||
data={'industries': serializer.data},
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@@ -656,8 +708,12 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
# ============================================================================
|
||||
|
||||
class AuthViewSet(viewsets.GenericViewSet):
|
||||
"""Authentication endpoints."""
|
||||
"""Authentication endpoints.
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
permission_classes = [permissions.AllowAny]
|
||||
throttle_scope = 'auth_strict'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def register(self, request):
|
||||
@@ -680,9 +736,8 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
refresh_expires_at = get_token_expiry('refresh')
|
||||
|
||||
user_serializer = UserSerializer(user)
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Registration successful',
|
||||
return success_response(
|
||||
data={
|
||||
'user': user_serializer.data,
|
||||
'tokens': {
|
||||
'access': access_token,
|
||||
@@ -690,11 +745,17 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
}
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
return Response({
|
||||
'success': False,
|
||||
'errors': serializer.errors
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
},
|
||||
message='Registration successful',
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
request=request
|
||||
)
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def login(self, request):
|
||||
@@ -707,10 +768,11 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
try:
|
||||
user = User.objects.select_related('account', 'account__plan').get(email=email)
|
||||
except User.DoesNotExist:
|
||||
return Response({
|
||||
'success': False,
|
||||
'message': 'Invalid credentials'
|
||||
}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
return error_response(
|
||||
error='Invalid credentials',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
request=request
|
||||
)
|
||||
|
||||
if user.check_password(password):
|
||||
# Log the user in (create session for session authentication)
|
||||
@@ -727,9 +789,8 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
refresh_expires_at = get_token_expiry('refresh')
|
||||
|
||||
user_serializer = UserSerializer(user)
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Login successful',
|
||||
return success_response(
|
||||
data={
|
||||
'user': user_serializer.data,
|
||||
'tokens': {
|
||||
'access': access_token,
|
||||
@@ -737,17 +798,23 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
'access_expires_at': access_expires_at.isoformat(),
|
||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||
}
|
||||
})
|
||||
},
|
||||
message='Login successful',
|
||||
request=request
|
||||
)
|
||||
|
||||
return Response({
|
||||
'success': False,
|
||||
'message': 'Invalid credentials'
|
||||
}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
return error_response(
|
||||
error='Invalid credentials',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
request=request
|
||||
)
|
||||
|
||||
return Response({
|
||||
'success': False,
|
||||
'errors': serializer.errors
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def change_password(self, request):
|
||||
@@ -756,23 +823,26 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
if serializer.is_valid():
|
||||
user = request.user
|
||||
if not user.check_password(serializer.validated_data['old_password']):
|
||||
return Response({
|
||||
'success': False,
|
||||
'message': 'Current password is incorrect'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Current password is incorrect',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
user.set_password(serializer.validated_data['new_password'])
|
||||
user.save()
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Password changed successfully'
|
||||
})
|
||||
return success_response(
|
||||
message='Password changed successfully',
|
||||
request=request
|
||||
)
|
||||
|
||||
return Response({
|
||||
'success': False,
|
||||
'errors': serializer.errors
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
||||
def me(self, request):
|
||||
@@ -781,20 +851,22 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
# This ensures account/plan changes are reflected immediately
|
||||
user = User.objects.select_related('account', 'account__plan').get(id=request.user.id)
|
||||
serializer = UserSerializer(user)
|
||||
return Response({
|
||||
'success': True,
|
||||
'user': serializer.data
|
||||
})
|
||||
return success_response(
|
||||
data={'user': serializer.data},
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def refresh(self, request):
|
||||
"""Refresh access token using refresh token."""
|
||||
serializer = RefreshTokenSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response({
|
||||
'success': False,
|
||||
'errors': serializer.errors
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
refresh_token = serializer.validated_data['refresh']
|
||||
|
||||
@@ -804,10 +876,11 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
|
||||
# Verify it's a refresh token
|
||||
if payload.get('type') != 'refresh':
|
||||
return Response({
|
||||
'success': False,
|
||||
'message': 'Invalid token type'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Invalid token type',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get user
|
||||
user_id = payload.get('user_id')
|
||||
@@ -816,10 +889,11 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return Response({
|
||||
'success': False,
|
||||
'message': 'User not found'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
return error_response(
|
||||
error='User not found',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get account
|
||||
account_id = payload.get('account_id')
|
||||
@@ -837,27 +911,32 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
access_token = generate_access_token(user, account)
|
||||
access_expires_at = get_token_expiry('access')
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
return success_response(
|
||||
data={
|
||||
'access': access_token,
|
||||
'access_expires_at': access_expires_at.isoformat()
|
||||
})
|
||||
},
|
||||
request=request
|
||||
)
|
||||
|
||||
except jwt.InvalidTokenError as e:
|
||||
return Response({
|
||||
'success': False,
|
||||
'message': 'Invalid or expired refresh token'
|
||||
}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
return error_response(
|
||||
error='Invalid or expired refresh token',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def request_reset(self, request):
|
||||
"""Request password reset - sends email with reset token."""
|
||||
serializer = RequestPasswordResetSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response({
|
||||
'success': False,
|
||||
'errors': serializer.errors
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
email = serializer.validated_data['email']
|
||||
|
||||
@@ -865,10 +944,10 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
user = User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
# Don't reveal if email exists - return success anyway
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'If an account with that email exists, a password reset link has been sent.'
|
||||
})
|
||||
return success_response(
|
||||
message='If an account with that email exists, a password reset link has been sent.',
|
||||
request=request
|
||||
)
|
||||
|
||||
# Generate secure token
|
||||
import secrets
|
||||
@@ -904,20 +983,22 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'If an account with that email exists, a password reset link has been sent.'
|
||||
})
|
||||
return success_response(
|
||||
message='If an account with that email exists, a password reset link has been sent.',
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def reset_password(self, request):
|
||||
"""Reset password using reset token."""
|
||||
serializer = ResetPasswordSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response({
|
||||
'success': False,
|
||||
'errors': serializer.errors
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Validation failed',
|
||||
errors=serializer.errors,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
token = serializer.validated_data['token']
|
||||
new_password = serializer.validated_data['new_password']
|
||||
@@ -925,17 +1006,19 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
try:
|
||||
reset_token = PasswordResetToken.objects.get(token=token)
|
||||
except PasswordResetToken.DoesNotExist:
|
||||
return Response({
|
||||
'success': False,
|
||||
'message': 'Invalid reset token'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Invalid reset token',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Check if token is valid
|
||||
if not reset_token.is_valid():
|
||||
return Response({
|
||||
'success': False,
|
||||
'message': 'Reset token has expired or has already been used'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Reset token has expired or has already been used',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Update password
|
||||
user = reset_token.user
|
||||
@@ -946,7 +1029,7 @@ class AuthViewSet(viewsets.GenericViewSet):
|
||||
reset_token.used = True
|
||||
reset_token.save()
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Password has been reset successfully'
|
||||
})
|
||||
return success_response(
|
||||
message='Password has been reset successfully',
|
||||
request=request
|
||||
)
|
||||
|
||||
43
backend/igny8_core/middleware/request_id.py
Normal file
43
backend/igny8_core/middleware/request_id.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Request ID Middleware
|
||||
Generates unique request ID for every request and includes it in response headers
|
||||
"""
|
||||
import uuid
|
||||
import logging
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RequestIDMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Middleware that generates a unique request ID for every request
|
||||
and includes it in response headers as X-Request-ID
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
"""Generate or retrieve request ID"""
|
||||
# Check if request ID already exists in headers
|
||||
request_id = request.META.get('HTTP_X_REQUEST_ID') or request.META.get('X-Request-ID')
|
||||
|
||||
if not request_id:
|
||||
# Generate new request ID
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
# Store in request for use in views/exception handlers
|
||||
request.request_id = request_id
|
||||
|
||||
return None
|
||||
|
||||
def process_response(self, request, response):
|
||||
"""Add request ID to response headers"""
|
||||
# Get request ID from request
|
||||
request_id = getattr(request, 'request_id', None)
|
||||
|
||||
if request_id:
|
||||
# Add to response headers
|
||||
response['X-Request-ID'] = request_id
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""
|
||||
ViewSets for Billing API
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
from rest_framework import viewsets, status, permissions
|
||||
from rest_framework.decorators import action
|
||||
@@ -10,6 +11,8 @@ from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from igny8_core.api.base import AccountModelViewSet
|
||||
from igny8_core.api.pagination import CustomPageNumberPagination
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication
|
||||
from .models import CreditTransaction, CreditUsageLog
|
||||
from .serializers import (
|
||||
@@ -23,9 +26,12 @@ from .exceptions import InsufficientCreditsError
|
||||
class CreditBalanceViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
ViewSet for credit balance operations
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||
throttle_scope = 'billing'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def balance(self, request):
|
||||
@@ -37,9 +43,10 @@ class CreditBalanceViewSet(viewsets.ViewSet):
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
if not account:
|
||||
return Response(
|
||||
{'error': 'Account not found'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
return error_response(
|
||||
error='Account not found',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get plan credits per month
|
||||
@@ -63,18 +70,21 @@ class CreditBalanceViewSet(viewsets.ViewSet):
|
||||
}
|
||||
|
||||
serializer = CreditBalanceSerializer(data)
|
||||
return Response(serializer.data)
|
||||
return success_response(data=serializer.data, request=request)
|
||||
|
||||
|
||||
class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for credit usage logs
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = CreditUsageLog.objects.all()
|
||||
serializer_class = CreditUsageLogSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||
pagination_class = CustomPageNumberPagination
|
||||
throttle_scope = 'billing'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
filter_backends = []
|
||||
|
||||
@@ -116,9 +126,10 @@ class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
if not account:
|
||||
return Response(
|
||||
{'error': 'Account not found'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
return error_response(
|
||||
error='Account not found',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get date range from query params
|
||||
@@ -192,7 +203,7 @@ class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
}
|
||||
|
||||
serializer = UsageSummarySerializer(data)
|
||||
return Response(serializer.data)
|
||||
return success_response(data=serializer.data, request=request)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='limits', url_name='limits')
|
||||
def limits(self, request):
|
||||
@@ -222,12 +233,12 @@ class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
if not account:
|
||||
logger.warning(f'No account found in limits endpoint')
|
||||
# Return empty limits instead of error - frontend will show "no data" message
|
||||
return Response({'limits': []})
|
||||
return success_response(data={'limits': []}, request=request)
|
||||
|
||||
plan = account.plan
|
||||
if not plan:
|
||||
# Return empty limits instead of error - allows frontend to show "no plan" message
|
||||
return Response({'limits': []})
|
||||
return success_response(data={'limits': []}, request=request)
|
||||
|
||||
# Import models
|
||||
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
|
||||
@@ -430,18 +441,21 @@ class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
])
|
||||
|
||||
# Return data directly - serializer validation not needed for read-only endpoint
|
||||
return Response({'limits': limits_data})
|
||||
return success_response(data={'limits': limits_data}, request=request)
|
||||
|
||||
|
||||
class CreditTransactionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for credit transaction history
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = CreditTransaction.objects.all()
|
||||
serializer_class = CreditTransactionSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||
pagination_class = CustomPageNumberPagination
|
||||
throttle_scope = 'billing'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get transactions for current account"""
|
||||
|
||||
@@ -10,6 +10,8 @@ import json
|
||||
import time
|
||||
from igny8_core.api.base import SiteSectorModelViewSet
|
||||
from igny8_core.api.pagination import CustomPageNumberPagination
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from .models import Keywords, Clusters, ContentIdeas
|
||||
from .serializers import KeywordSerializer, ContentIdeasSerializer
|
||||
from .cluster_serializers import ClusterSerializer
|
||||
@@ -19,11 +21,14 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
"""
|
||||
ViewSet for managing keywords with CRUD operations
|
||||
Provides list, create, retrieve, update, and destroy actions
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = Keywords.objects.all()
|
||||
serializer_class = KeywordSerializer
|
||||
permission_classes = [] # Allow any for now
|
||||
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
|
||||
throttle_scope = 'planner'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
# DRF filtering configuration
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
@@ -121,13 +126,17 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
return success_response(
|
||||
data=serializer.data,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in KeywordViewSet.list(): {type(e).__name__}: {str(e)}", exc_info=True)
|
||||
return Response({
|
||||
'error': f'Error loading keywords: {str(e)}',
|
||||
'type': type(e).__name__
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Error loading keywords: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Require explicit site_id and sector_id - no defaults."""
|
||||
@@ -190,12 +199,16 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
"""Bulk delete keywords"""
|
||||
ids = request.data.get('ids', [])
|
||||
if not ids:
|
||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
deleted_count, _ = queryset.filter(id__in=ids).delete()
|
||||
|
||||
return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK)
|
||||
return success_response(data={'deleted_count': deleted_count}, request=request)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
|
||||
def bulk_update(self, request):
|
||||
@@ -204,14 +217,22 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
status_value = request.data.get('status')
|
||||
|
||||
if not ids:
|
||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
if not status_value:
|
||||
return Response({'error': 'No status provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No status provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
updated_count = queryset.filter(id__in=ids).update(status=status_value)
|
||||
|
||||
return Response({'updated_count': updated_count}, status=status.HTTP_200_OK)
|
||||
return success_response(data={'updated_count': updated_count}, request=request)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='bulk_add_from_seed', url_name='bulk_add_from_seed')
|
||||
def bulk_add_from_seed(self, request):
|
||||
@@ -223,32 +244,60 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
sector_id = request.data.get('sector_id')
|
||||
|
||||
if not seed_keyword_ids:
|
||||
return Response({'error': 'No seed keyword IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No seed keyword IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
if not site_id:
|
||||
return Response({'error': 'site_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='site_id is required',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
if not sector_id:
|
||||
return Response({'error': 'sector_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='sector_id is required',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
try:
|
||||
site = Site.objects.get(id=site_id)
|
||||
sector = Sector.objects.get(id=sector_id)
|
||||
except (Site.DoesNotExist, Sector.DoesNotExist) as e:
|
||||
return Response({'error': f'Invalid site or sector: {str(e)}'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=f'Invalid site or sector: {str(e)}',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Validate sector belongs to site
|
||||
if sector.site != site:
|
||||
return Response({'error': 'Sector does not belong to the specified site'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Sector does not belong to the specified site',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get account from site
|
||||
account = site.account
|
||||
if not account:
|
||||
return Response({'error': 'Site has no account assigned'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Site has no account assigned',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get SeedKeywords
|
||||
seed_keywords = SeedKeyword.objects.filter(id__in=seed_keyword_ids, is_active=True)
|
||||
|
||||
if not seed_keywords.exists():
|
||||
return Response({'error': 'No valid seed keywords found'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No valid seed keywords found',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
@@ -288,12 +337,14 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
errors.append(f"Error adding '{seed_keyword.keyword}': {str(e)}")
|
||||
skipped_count += 1
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
return success_response(
|
||||
data={
|
||||
'created': created_count,
|
||||
'skipped': skipped_count,
|
||||
'errors': errors[:10] if errors else [] # Limit errors to first 10
|
||||
}, status=status.HTTP_200_OK)
|
||||
},
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='export', url_name='export')
|
||||
def export(self, request):
|
||||
@@ -366,11 +417,19 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
Automatically links keywords to current active site/sector.
|
||||
"""
|
||||
if 'file' not in request.FILES:
|
||||
return Response({'error': 'No file provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No file provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
file = request.FILES['file']
|
||||
if not file.name.endswith('.csv'):
|
||||
return Response({'error': 'File must be a CSV'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='File must be a CSV',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
user = getattr(request, 'user', None)
|
||||
|
||||
@@ -391,23 +450,43 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
|
||||
# Site ID is REQUIRED
|
||||
if not site_id:
|
||||
return Response({'error': 'site_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='site_id is required',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
try:
|
||||
site = Site.objects.get(id=site_id)
|
||||
except Site.DoesNotExist:
|
||||
return Response({'error': f'Site with id {site_id} does not exist'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=f'Site with id {site_id} does not exist',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Sector ID is REQUIRED
|
||||
if not sector_id:
|
||||
return Response({'error': 'sector_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='sector_id is required',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
try:
|
||||
sector = Sector.objects.get(id=sector_id)
|
||||
if sector.site_id != site_id:
|
||||
return Response({'error': 'Sector does not belong to the selected site'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Sector does not belong to the selected site',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
except Sector.DoesNotExist:
|
||||
return Response({'error': f'Sector with id {sector_id} does not exist'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=f'Sector with id {sector_id} does not exist',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get account
|
||||
account = getattr(request, 'account', None)
|
||||
@@ -461,17 +540,21 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
errors.append(f"Row {row_num}: {str(e)}")
|
||||
continue
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
return success_response(
|
||||
data={
|
||||
'imported': imported_count,
|
||||
'skipped': skipped_count,
|
||||
'errors': errors[:10] if errors else [] # Limit errors to first 10
|
||||
}, status=status.HTTP_200_OK)
|
||||
},
|
||||
request=request
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return Response({
|
||||
'error': f'Failed to parse CSV: {str(e)}'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=f'Failed to parse CSV: {str(e)}',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='auto_cluster', url_name='auto_cluster')
|
||||
def auto_cluster(self, request):
|
||||
@@ -497,16 +580,18 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
|
||||
# Validate basic input
|
||||
if not payload['ids']:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'No IDs provided'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
if len(payload['ids']) > 20:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Maximum 20 keywords allowed for clustering'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Maximum 20 keywords allowed for clustering',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Try to queue Celery task
|
||||
try:
|
||||
@@ -517,11 +602,11 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
account_id=account_id
|
||||
)
|
||||
logger.info(f"Task queued: {task.id}")
|
||||
return Response({
|
||||
'success': True,
|
||||
'task_id': str(task.id),
|
||||
'message': 'Clustering started'
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data={'task_id': str(task.id)},
|
||||
message='Clustering started',
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
# Celery not available - execute synchronously
|
||||
logger.warning("Celery not available, executing synchronously")
|
||||
@@ -531,15 +616,16 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
account_id=account_id
|
||||
)
|
||||
if result.get('success'):
|
||||
return Response({
|
||||
'success': True,
|
||||
**result
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data=result,
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': result.get('error', 'Clustering failed')
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=result.get('error', 'Clustering failed'),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
except (KombuOperationalError, ConnectionError) as e:
|
||||
# Broker connection failed - fall back to synchronous execution
|
||||
logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}")
|
||||
@@ -549,36 +635,42 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
account_id=account_id
|
||||
)
|
||||
if result.get('success'):
|
||||
return Response({
|
||||
'success': True,
|
||||
**result
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data=result,
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': result.get('error', 'Clustering failed')
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=result.get('error', 'Clustering failed'),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in auto_cluster: {str(e)}", exc_info=True)
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in auto_cluster: {str(e)}", exc_info=True)
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': f'Unexpected error: {str(e)}'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Unexpected error: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
class ClusterViewSet(SiteSectorModelViewSet):
|
||||
"""
|
||||
ViewSet for managing clusters with CRUD operations
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = Clusters.objects.all()
|
||||
serializer_class = ClusterSerializer
|
||||
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
|
||||
throttle_scope = 'planner'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
# DRF filtering configuration
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
@@ -719,12 +811,16 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
||||
"""Bulk delete clusters"""
|
||||
ids = request.data.get('ids', [])
|
||||
if not ids:
|
||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
deleted_count, _ = queryset.filter(id__in=ids).delete()
|
||||
|
||||
return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK)
|
||||
return success_response(data={'deleted_count': deleted_count}, request=request)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas')
|
||||
def auto_generate_ideas(self, request):
|
||||
@@ -749,16 +845,18 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
||||
|
||||
# Validate basic input
|
||||
if not payload['ids']:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'No IDs provided'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
if len(payload['ids']) > 10:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Maximum 10 clusters allowed for idea generation'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Maximum 10 clusters allowed for idea generation',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Try to queue Celery task
|
||||
try:
|
||||
@@ -769,11 +867,11 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
||||
account_id=account_id
|
||||
)
|
||||
logger.info(f"Task queued: {task.id}")
|
||||
return Response({
|
||||
'success': True,
|
||||
'task_id': str(task.id),
|
||||
'message': 'Idea generation started'
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data={'task_id': str(task.id)},
|
||||
message='Idea generation started',
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
# Celery not available - execute synchronously
|
||||
logger.warning("Celery not available, executing synchronously")
|
||||
@@ -783,15 +881,16 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
||||
account_id=account_id
|
||||
)
|
||||
if result.get('success'):
|
||||
return Response({
|
||||
'success': True,
|
||||
**result
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data=result,
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': result.get('error', 'Idea generation failed')
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=result.get('error', 'Idea generation failed'),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
except (KombuOperationalError, ConnectionError) as e:
|
||||
# Broker connection failed - fall back to synchronous execution
|
||||
logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}")
|
||||
@@ -801,27 +900,30 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
||||
account_id=account_id
|
||||
)
|
||||
if result.get('success'):
|
||||
return Response({
|
||||
'success': True,
|
||||
**result
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data=result,
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': result.get('error', 'Idea generation failed')
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=result.get('error', 'Idea generation failed'),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in auto_generate_ideas: {str(e)}", exc_info=True)
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in auto_generate_ideas: {str(e)}", exc_info=True)
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': f'Unexpected error: {str(e)}'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Unexpected error: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -842,16 +944,22 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
||||
cluster_list = list(queryset)
|
||||
ClusterSerializer.prefetch_keyword_stats(cluster_list)
|
||||
serializer = self.get_serializer(cluster_list, many=True)
|
||||
return Response(serializer.data)
|
||||
return success_response(
|
||||
data=serializer.data,
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
class ContentIdeasViewSet(SiteSectorModelViewSet):
|
||||
"""
|
||||
ViewSet for managing content ideas with CRUD operations
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = ContentIdeas.objects.all()
|
||||
serializer_class = ContentIdeasSerializer
|
||||
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
|
||||
pagination_class = CustomPageNumberPagination
|
||||
throttle_scope = 'planner'
|
||||
throttle_classes = [DebugScopedRateThrottle] # Explicitly use custom pagination
|
||||
|
||||
# DRF filtering configuration
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
@@ -919,19 +1027,27 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
|
||||
"""Bulk delete content ideas"""
|
||||
ids = request.data.get('ids', [])
|
||||
if not ids:
|
||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
deleted_count, _ = queryset.filter(id__in=ids).delete()
|
||||
|
||||
return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK)
|
||||
return success_response(data={'deleted_count': deleted_count}, request=request)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='bulk_queue_to_writer', url_name='bulk_queue_to_writer')
|
||||
def bulk_queue_to_writer(self, request):
|
||||
"""Queue ideas to writer by creating Tasks"""
|
||||
ids = request.data.get('ids', [])
|
||||
if not ids:
|
||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
ideas = queryset.filter(id__in=ids, status='new') # Only queue 'new' ideas
|
||||
@@ -958,11 +1074,13 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
|
||||
idea.status = 'scheduled'
|
||||
idea.save()
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
return success_response(
|
||||
data={
|
||||
'created_count': len(created_tasks),
|
||||
'task_ids': created_tasks,
|
||||
'message': f'Successfully queued {len(created_tasks)} ideas to writer'
|
||||
}, status=status.HTTP_200_OK)
|
||||
},
|
||||
message=f'Successfully queued {len(created_tasks)} ideas to writer',
|
||||
request=request
|
||||
)
|
||||
|
||||
# REMOVED: generate_idea action - idea generation function removed
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""
|
||||
Integration settings views - for OpenAI, Runware, GSC integrations
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
import logging
|
||||
from rest_framework import viewsets, status
|
||||
@@ -7,6 +8,8 @@ from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.db import transaction
|
||||
from igny8_core.api.base import AccountModelViewSet
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,10 +23,14 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
permission_classes = [] # Allow any for now
|
||||
|
||||
throttle_scope = 'system_admin'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def list(self, request):
|
||||
"""List all integrations - for debugging URL patterns"""
|
||||
logger.info("[IntegrationSettingsViewSet] list() called")
|
||||
return Response({
|
||||
return success_response(
|
||||
data={
|
||||
'message': 'IntegrationSettingsViewSet is working',
|
||||
'available_endpoints': [
|
||||
'GET /api/v1/system/settings/integrations/<pk>/',
|
||||
@@ -31,7 +38,9 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
'POST /api/v1/system/settings/integrations/<pk>/test/',
|
||||
'POST /api/v1/system/settings/integrations/<pk>/generate/',
|
||||
]
|
||||
})
|
||||
},
|
||||
request=request
|
||||
)
|
||||
|
||||
def retrieve(self, request, pk=None):
|
||||
"""Get integration settings - GET /api/v1/system/settings/integrations/{pk}/"""
|
||||
@@ -65,7 +74,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
logger.info(f"[test_connection] Called for integration_type={integration_type}, user={getattr(request, 'user', None)}, account={getattr(request, 'account', None)}")
|
||||
|
||||
if not integration_type:
|
||||
return Response({'error': 'Integration type is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Integration type is required',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get API key and config from request or saved settings
|
||||
config = request.data.get('config', {}) if isinstance(request.data.get('config'), dict) else {}
|
||||
@@ -108,10 +121,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
|
||||
if not api_key:
|
||||
logger.error(f"[test_connection] No API key found in request or saved settings")
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'API key is required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='API key is required',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
logger.info(f"[test_connection] Testing {integration_type} connection with API key (length={len(api_key) if api_key else 0})")
|
||||
try:
|
||||
@@ -120,19 +134,21 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
elif integration_type == 'runware':
|
||||
return self._test_runware(api_key, request)
|
||||
else:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': f'Validation not supported for {integration_type}'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=f'Validation not supported for {integration_type}',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error testing {integration_type} connection: {str(e)}", exc_info=True)
|
||||
import traceback
|
||||
error_trace = traceback.format_exc()
|
||||
logger.error(f"Full traceback: {error_trace}")
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
def _test_openai(self, api_key: str, config: dict = None):
|
||||
"""
|
||||
@@ -554,7 +570,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
logger.info(f"[save_settings] Called for integration_type={integration_type}, user={getattr(request, 'user', None)}, account={getattr(request, 'account', None)}")
|
||||
|
||||
if not integration_type:
|
||||
return Response({'error': 'Integration type is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Integration type is required',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Ensure config is a dict
|
||||
config = dict(request.data) if hasattr(request.data, 'dict') else (request.data if isinstance(request.data, dict) else {})
|
||||
@@ -587,7 +607,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
|
||||
if not account:
|
||||
logger.error(f"[save_settings] No account found after all fallbacks")
|
||||
return Response({'error': 'Account not found. Please ensure you are logged in.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Account not found. Please ensure you are logged in.',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
logger.info(f"[save_settings] Using account: {account.id} ({account.name}, slug={account.slug}, status={account.status})")
|
||||
|
||||
@@ -648,10 +672,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
logger.info(f"[save_settings] Settings updated successfully")
|
||||
|
||||
logger.info(f"[save_settings] Successfully saved settings for {integration_type}")
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': f'{integration_type.upper()} settings saved successfully'
|
||||
})
|
||||
return success_response(
|
||||
data={'config': config},
|
||||
message=f'{integration_type.upper()} settings saved successfully',
|
||||
request=request
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving integration settings for {integration_type}: {str(e)}", exc_info=True)
|
||||
@@ -667,10 +692,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
integration_type = pk
|
||||
|
||||
if not integration_type:
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'Integration type is required'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Integration type is required',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
try:
|
||||
# Get account - try multiple methods (same as save_settings)
|
||||
@@ -695,26 +721,27 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
|
||||
integration_type=integration_type,
|
||||
account=account
|
||||
)
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': integration_settings.config
|
||||
})
|
||||
return success_response(
|
||||
data=integration_settings.config,
|
||||
request=request
|
||||
)
|
||||
except IntegrationSettings.DoesNotExist:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting account-specific settings: {e}", exc_info=True)
|
||||
|
||||
# Return empty config if no settings found
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': {}
|
||||
})
|
||||
return success_response(
|
||||
data={},
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in get_settings for {integration_type}: {e}", exc_info=True)
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': f'Failed to get settings: {str(e)}'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Failed to get settings: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='image_generation', url_name='image_generation_settings')
|
||||
def get_image_generation_settings(self, request):
|
||||
|
||||
@@ -13,6 +13,10 @@ from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from igny8_core.api.base import AccountModelViewSet
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.permissions import IsEditorOrAbove
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.api.pagination import CustomPageNumberPagination
|
||||
from .models import AIPrompt, AuthorProfile, Strategy
|
||||
from .serializers import AIPromptSerializer, AuthorProfileSerializer, StrategySerializer
|
||||
|
||||
@@ -22,10 +26,14 @@ logger = logging.getLogger(__name__)
|
||||
class AIPromptViewSet(AccountModelViewSet):
|
||||
"""
|
||||
ViewSet for managing AI prompts
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = AIPrompt.objects.all()
|
||||
serializer_class = AIPromptSerializer
|
||||
permission_classes = [] # Allow any for now
|
||||
permission_classes = [] # Allow any for now (backward compatibility)
|
||||
throttle_scope = 'system'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get prompts for the current account"""
|
||||
@@ -37,28 +45,39 @@ class AIPromptViewSet(AccountModelViewSet):
|
||||
try:
|
||||
prompt = self.get_queryset().get(prompt_type=prompt_type)
|
||||
serializer = self.get_serializer(prompt)
|
||||
return Response(serializer.data)
|
||||
return success_response(data=serializer.data, request=request)
|
||||
except AIPrompt.DoesNotExist:
|
||||
# Return default if not found
|
||||
from .utils import get_default_prompt
|
||||
default_value = get_default_prompt(prompt_type)
|
||||
return Response({
|
||||
return success_response(
|
||||
data={
|
||||
'prompt_type': prompt_type,
|
||||
'prompt_value': default_value,
|
||||
'default_prompt': default_value,
|
||||
'is_active': True,
|
||||
})
|
||||
},
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='save', url_name='save')
|
||||
def save_prompt(self, request):
|
||||
"""Save or update a prompt"""
|
||||
"""Save or update a prompt - requires editor or above"""
|
||||
prompt_type = request.data.get('prompt_type')
|
||||
prompt_value = request.data.get('prompt_value')
|
||||
|
||||
if not prompt_type:
|
||||
return Response({'error': 'prompt_type is required'}, status=http_status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='prompt_type is required',
|
||||
status_code=http_status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
if prompt_value is None:
|
||||
return Response({'error': 'prompt_value is required'}, status=http_status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='prompt_value is required',
|
||||
status_code=http_status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get account - try multiple methods
|
||||
account = getattr(request, 'account', None)
|
||||
@@ -78,7 +97,11 @@ class AIPromptViewSet(AccountModelViewSet):
|
||||
pass
|
||||
|
||||
if not account:
|
||||
return Response({'error': 'Account not found. Please ensure you are logged in.'}, status=http_status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Account not found. Please ensure you are logged in.',
|
||||
status_code=http_status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get default prompt value if creating new
|
||||
from .utils import get_default_prompt
|
||||
@@ -100,11 +123,11 @@ class AIPromptViewSet(AccountModelViewSet):
|
||||
prompt.save()
|
||||
|
||||
serializer = self.get_serializer(prompt)
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': serializer.data,
|
||||
'message': f'{prompt.get_prompt_type_display()} saved successfully'
|
||||
})
|
||||
return success_response(
|
||||
data=serializer.data,
|
||||
message=f'{prompt.get_prompt_type_display()} saved successfully',
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='reset', url_name='reset')
|
||||
def reset_prompt(self, request):
|
||||
@@ -112,7 +135,11 @@ class AIPromptViewSet(AccountModelViewSet):
|
||||
prompt_type = request.data.get('prompt_type')
|
||||
|
||||
if not prompt_type:
|
||||
return Response({'error': 'prompt_type is required'}, status=http_status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='prompt_type is required',
|
||||
status_code=http_status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get account - try multiple methods (same as integration_views)
|
||||
account = getattr(request, 'account', None)
|
||||
@@ -132,7 +159,11 @@ class AIPromptViewSet(AccountModelViewSet):
|
||||
pass
|
||||
|
||||
if not account:
|
||||
return Response({'error': 'Account not found. Please ensure you are logged in.'}, status=http_status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Account not found. Please ensure you are logged in.',
|
||||
status_code=http_status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get default prompt
|
||||
from .utils import get_default_prompt
|
||||
@@ -154,19 +185,22 @@ class AIPromptViewSet(AccountModelViewSet):
|
||||
prompt.save()
|
||||
|
||||
serializer = self.get_serializer(prompt)
|
||||
return Response({
|
||||
'success': True,
|
||||
'data': serializer.data,
|
||||
'message': f'{prompt.get_prompt_type_display()} reset to default'
|
||||
})
|
||||
return success_response(
|
||||
data=serializer.data,
|
||||
message=f'{prompt.get_prompt_type_display()} reset to default',
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
class AuthorProfileViewSet(AccountModelViewSet):
|
||||
"""
|
||||
ViewSet for managing Author Profiles
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = AuthorProfile.objects.all()
|
||||
serializer_class = AuthorProfileSerializer
|
||||
throttle_scope = 'system'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
search_fields = ['name', 'description', 'tone']
|
||||
@@ -178,9 +212,12 @@ class AuthorProfileViewSet(AccountModelViewSet):
|
||||
class StrategyViewSet(AccountModelViewSet):
|
||||
"""
|
||||
ViewSet for managing Strategies
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = Strategy.objects.all()
|
||||
serializer_class = StrategySerializer
|
||||
throttle_scope = 'system'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
search_fields = ['name', 'description']
|
||||
@@ -190,7 +227,7 @@ class StrategyViewSet(AccountModelViewSet):
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny]) # Adjust permissions as needed
|
||||
@permission_classes([AllowAny]) # Public endpoint for monitoring
|
||||
def system_status(request):
|
||||
"""
|
||||
Comprehensive system status endpoint for monitoring
|
||||
@@ -457,7 +494,7 @@ def system_status(request):
|
||||
logger.error(f"Error getting module statistics: {str(e)}")
|
||||
status_data['modules'] = {'error': str(e)}
|
||||
|
||||
return Response(status_data)
|
||||
return success_response(data=status_data, request=request)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@@ -469,19 +506,31 @@ def get_request_metrics(request, request_id):
|
||||
"""
|
||||
# Check if user is admin/developer
|
||||
if not request.user.is_authenticated:
|
||||
return Response({'error': 'Authentication required'}, status=http_status.HTTP_401_UNAUTHORIZED)
|
||||
return error_response(
|
||||
error='Authentication required',
|
||||
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
||||
request=request
|
||||
)
|
||||
|
||||
if not (hasattr(request.user, 'is_admin_or_developer') and request.user.is_admin_or_developer()):
|
||||
return Response({'error': 'Admin access required'}, status=http_status.HTTP_403_FORBIDDEN)
|
||||
return error_response(
|
||||
error='Admin access required',
|
||||
status_code=http_status.HTTP_403_FORBIDDEN,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get metrics from cache
|
||||
from django.core.cache import cache
|
||||
metrics = cache.get(f"resource_tracking_{request_id}")
|
||||
|
||||
if not metrics:
|
||||
return Response({'error': 'Metrics not found or expired'}, status=http_status.HTTP_404_NOT_FOUND)
|
||||
return error_response(
|
||||
error='Metrics not found or expired',
|
||||
status_code=http_status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
|
||||
return Response(metrics)
|
||||
return success_response(data=metrics, request=request)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@@ -504,10 +553,11 @@ def gitea_webhook(request):
|
||||
|
||||
# Only process push events
|
||||
if event_type != 'push':
|
||||
return Response({
|
||||
'status': 'ignored',
|
||||
'message': f'Event type {event_type} is not processed'
|
||||
}, status=http_status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data={'status': 'ignored'},
|
||||
message=f'Event type {event_type} is not processed',
|
||||
request=request
|
||||
)
|
||||
|
||||
# Extract repository information
|
||||
repository = payload.get('repository', {})
|
||||
@@ -518,10 +568,11 @@ def gitea_webhook(request):
|
||||
# Only process pushes to main branch
|
||||
if ref != 'refs/heads/main':
|
||||
logger.info(f"[Webhook] Ignoring push to {ref}, only processing main branch")
|
||||
return Response({
|
||||
'status': 'ignored',
|
||||
'message': f'Push to {ref} ignored, only main branch is processed'
|
||||
}, status=http_status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data={'status': 'ignored'},
|
||||
message=f'Push to {ref} ignored, only main branch is processed',
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get commit information
|
||||
commits = payload.get('commits', [])
|
||||
@@ -636,9 +687,9 @@ def gitea_webhook(request):
|
||||
deployment_error = str(deploy_error)
|
||||
logger.error(f"[Webhook] Deployment error: {deploy_error}", exc_info=True)
|
||||
|
||||
return Response({
|
||||
return success_response(
|
||||
data={
|
||||
'status': 'success' if deployment_success else 'partial',
|
||||
'message': 'Webhook received and processed',
|
||||
'repository': repo_full_name,
|
||||
'branch': ref,
|
||||
'commits': commit_count,
|
||||
@@ -648,18 +699,23 @@ def gitea_webhook(request):
|
||||
'success': deployment_success,
|
||||
'error': deployment_error
|
||||
}
|
||||
}, status=http_status.HTTP_200_OK)
|
||||
},
|
||||
message='Webhook received and processed',
|
||||
request=request
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"[Webhook] Invalid JSON payload: {e}")
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'message': 'Invalid JSON payload'
|
||||
}, status=http_status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Invalid JSON payload',
|
||||
status_code=http_status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Webhook] Error processing webhook: {e}", exc_info=True)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}, status=http_status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ from django.db import transaction, models
|
||||
from django.db.models import Q
|
||||
from igny8_core.api.base import SiteSectorModelViewSet
|
||||
from igny8_core.api.pagination import CustomPageNumberPagination
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from .models import Tasks, Images, Content
|
||||
from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer
|
||||
|
||||
@@ -13,10 +15,13 @@ from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer
|
||||
class TasksViewSet(SiteSectorModelViewSet):
|
||||
"""
|
||||
ViewSet for managing tasks with CRUD operations
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = Tasks.objects.select_related('content_record')
|
||||
serializer_class = TasksSerializer
|
||||
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
|
||||
throttle_scope = 'writer'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
# DRF filtering configuration
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
@@ -84,12 +89,16 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
"""Bulk delete tasks"""
|
||||
ids = request.data.get('ids', [])
|
||||
if not ids:
|
||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
deleted_count, _ = queryset.filter(id__in=ids).delete()
|
||||
|
||||
return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK)
|
||||
return success_response(data={'deleted_count': deleted_count}, request=request)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
|
||||
def bulk_update(self, request):
|
||||
@@ -98,14 +107,22 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
status_value = request.data.get('status')
|
||||
|
||||
if not ids:
|
||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
if not status_value:
|
||||
return Response({'error': 'No status provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No status provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
updated_count = queryset.filter(id__in=ids).update(status=status_value)
|
||||
|
||||
return Response({'updated_count': updated_count}, status=status.HTTP_200_OK)
|
||||
return success_response(data={'updated_count': updated_count}, request=request)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='auto_generate_content', url_name='auto_generate_content')
|
||||
def auto_generate_content(self, request):
|
||||
@@ -120,17 +137,19 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
ids = request.data.get('ids', [])
|
||||
if not ids:
|
||||
logger.warning("auto_generate_content: No IDs provided")
|
||||
return Response({
|
||||
'error': 'No IDs provided',
|
||||
'type': 'ValidationError'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
if len(ids) > 10:
|
||||
logger.warning(f"auto_generate_content: Too many IDs provided: {len(ids)}")
|
||||
return Response({
|
||||
'error': 'Maximum 10 tasks allowed for content generation',
|
||||
'type': 'ValidationError'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Maximum 10 tasks allowed for content generation',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
logger.info(f"auto_generate_content: Processing {len(ids)} task IDs: {ids}")
|
||||
|
||||
@@ -151,11 +170,11 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
|
||||
if existing_count == 0:
|
||||
logger.error(f"auto_generate_content: No tasks found for IDs: {ids}")
|
||||
return Response({
|
||||
'error': f'No tasks found for the provided IDs: {ids}',
|
||||
'type': 'NotFound',
|
||||
'requested_ids': ids
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
return error_response(
|
||||
error=f'No tasks found for the provided IDs: {ids}',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
|
||||
if existing_count < len(ids):
|
||||
missing_ids = set(ids) - set(existing_ids)
|
||||
@@ -171,11 +190,11 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
logger.error(f" - Account ID: {account_id}")
|
||||
logger.error("=" * 80, exc_info=True)
|
||||
|
||||
return Response({
|
||||
'error': f'Database error while querying tasks: {str(db_error)}',
|
||||
'type': 'OperationalError',
|
||||
'details': 'Failed to retrieve tasks from database. Please check database connection and try again.'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Database error while querying tasks: {str(db_error)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Try to queue Celery task, fall back to synchronous if Celery not available
|
||||
try:
|
||||
@@ -192,11 +211,11 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
account_id=account_id
|
||||
)
|
||||
logger.info(f"auto_generate_content: Celery task queued successfully: {task.id}")
|
||||
return Response({
|
||||
'success': True,
|
||||
'task_id': str(task.id),
|
||||
'message': 'Content generation started'
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data={'task_id': str(task.id)},
|
||||
message='Content generation started',
|
||||
request=request
|
||||
)
|
||||
except KombuOperationalError as celery_error:
|
||||
logger.error("=" * 80)
|
||||
logger.error("CELERY ERROR: Failed to queue task")
|
||||
@@ -206,10 +225,11 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
logger.error(f" - Account ID: {account_id}")
|
||||
logger.error("=" * 80, exc_info=True)
|
||||
|
||||
return Response({
|
||||
'error': 'Task queue unavailable. Please try again.',
|
||||
'type': 'QueueError'
|
||||
}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
return error_response(
|
||||
error='Task queue unavailable. Please try again.',
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
request=request
|
||||
)
|
||||
except Exception as celery_error:
|
||||
logger.error("=" * 80)
|
||||
logger.error("CELERY ERROR: Failed to queue task")
|
||||
@@ -227,16 +247,17 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
account_id=account_id
|
||||
)
|
||||
if result.get('success'):
|
||||
return Response({
|
||||
'success': True,
|
||||
'tasks_updated': result.get('count', 0),
|
||||
'message': 'Content generated successfully (synchronous)'
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data={'tasks_updated': result.get('count', 0)},
|
||||
message='Content generated successfully (synchronous)',
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
return Response({
|
||||
'error': result.get('error', 'Content generation failed'),
|
||||
'type': 'TaskExecutionError'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=result.get('error', 'Content generation failed'),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
# Celery not available - execute synchronously
|
||||
logger.info(f"auto_generate_content: Executing synchronously (Celery not available)")
|
||||
@@ -247,17 +268,18 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
)
|
||||
if result.get('success'):
|
||||
logger.info(f"auto_generate_content: Synchronous execution successful: {result.get('count', 0)} tasks updated")
|
||||
return Response({
|
||||
'success': True,
|
||||
'tasks_updated': result.get('count', 0),
|
||||
'message': 'Content generated successfully'
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data={'tasks_updated': result.get('count', 0)},
|
||||
message='Content generated successfully',
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
logger.error(f"auto_generate_content: Synchronous execution failed: {result.get('error', 'Unknown error')}")
|
||||
return Response({
|
||||
'error': result.get('error', 'Content generation failed'),
|
||||
'type': 'TaskExecutionError'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=result.get('error', 'Content generation failed'),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
except ImportError as import_error:
|
||||
logger.error(f"auto_generate_content: ImportError - tasks module not available: {str(import_error)}")
|
||||
@@ -268,21 +290,22 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
updated_count = tasks.update(status='completed', content='[AI content generation not available]')
|
||||
|
||||
logger.info(f"auto_generate_content: Updated {updated_count} tasks (AI generation not available)")
|
||||
return Response({
|
||||
'updated_count': updated_count,
|
||||
'message': 'Tasks updated (AI generation not available)'
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data={'updated_count': updated_count},
|
||||
message='Tasks updated (AI generation not available)',
|
||||
request=request
|
||||
)
|
||||
except (OperationalError, DatabaseError) as db_error:
|
||||
logger.error("=" * 80)
|
||||
logger.error("DATABASE ERROR: Failed to update tasks")
|
||||
logger.error(f" - Error type: {type(db_error).__name__}")
|
||||
logger.error(f" - Error message: {str(db_error)}")
|
||||
logger.error("=" * 80, exc_info=True)
|
||||
return Response({
|
||||
'error': f'Database error while updating tasks: {str(db_error)}',
|
||||
'type': 'OperationalError',
|
||||
'details': 'Failed to update tasks in database. Please check database connection.'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Database error while updating tasks: {str(db_error)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
except (OperationalError, DatabaseError) as db_error:
|
||||
logger.error("=" * 80)
|
||||
@@ -293,11 +316,11 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
logger.error(f" - Account ID: {account_id}")
|
||||
logger.error("=" * 80, exc_info=True)
|
||||
|
||||
return Response({
|
||||
'error': f'Database error during content generation: {str(db_error)}',
|
||||
'type': 'OperationalError',
|
||||
'details': 'A database operation failed. This may be due to connection issues, constraint violations, or data integrity problems. Check the logs for more details.'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Database error during content generation: {str(db_error)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
except IntegrityError as integrity_error:
|
||||
logger.error("=" * 80)
|
||||
@@ -306,18 +329,19 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
logger.error(f" - Task IDs: {ids}")
|
||||
logger.error("=" * 80, exc_info=True)
|
||||
|
||||
return Response({
|
||||
'error': f'Data integrity error: {str(integrity_error)}',
|
||||
'type': 'IntegrityError',
|
||||
'details': 'The operation violated database constraints. This may indicate missing required relationships or invalid data.'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Data integrity error: {str(integrity_error)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
except ValidationError as validation_error:
|
||||
logger.error(f"auto_generate_content: ValidationError: {str(validation_error)}")
|
||||
return Response({
|
||||
'error': f'Validation error: {str(validation_error)}',
|
||||
'type': 'ValidationError'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error=f'Validation error: {str(validation_error)}',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("=" * 80)
|
||||
@@ -328,11 +352,11 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
logger.error(f" - Account ID: {account_id}")
|
||||
logger.error("=" * 80, exc_info=True)
|
||||
|
||||
return Response({
|
||||
'error': f'Unexpected error: {str(e)}',
|
||||
'type': type(e).__name__,
|
||||
'details': 'An unexpected error occurred. Please check the logs for more details.'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Unexpected error: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
except Exception as outer_error:
|
||||
logger.error("=" * 80)
|
||||
@@ -341,10 +365,11 @@ class TasksViewSet(SiteSectorModelViewSet):
|
||||
logger.error(f" - Error message: {str(outer_error)}")
|
||||
logger.error("=" * 80, exc_info=True)
|
||||
|
||||
return Response({
|
||||
'error': f'Critical error: {str(outer_error)}',
|
||||
'type': type(outer_error).__name__
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Critical error: {str(outer_error)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
class ImagesViewSet(SiteSectorModelViewSet):
|
||||
@@ -383,30 +408,38 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
||||
try:
|
||||
image = Images.objects.get(pk=pk)
|
||||
except Images.DoesNotExist:
|
||||
return Response({
|
||||
'error': 'Image not found'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
return error_response(
|
||||
error='Image not found',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Check if image has a local path
|
||||
if not image.image_path:
|
||||
return Response({
|
||||
'error': 'No local file path available for this image'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
return error_response(
|
||||
error='No local file path available for this image',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
|
||||
file_path = image.image_path
|
||||
|
||||
# Verify file exists at the saved path
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"[serve_image_file] Image {pk} - File not found at saved path: {file_path}")
|
||||
return Response({
|
||||
'error': f'Image file not found at: {file_path}'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
return error_response(
|
||||
error=f'Image file not found at: {file_path}',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Check if file is readable
|
||||
if not os.access(file_path, os.R_OK):
|
||||
return Response({
|
||||
'error': 'Image file is not readable'
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
return error_response(
|
||||
error='Image file is not readable',
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Determine content type from file extension
|
||||
import mimetypes
|
||||
@@ -422,31 +455,45 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
||||
filename=os.path.basename(file_path)
|
||||
)
|
||||
except Exception as e:
|
||||
return Response({
|
||||
'error': f'Failed to serve file: {str(e)}'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Failed to serve file: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
except Images.DoesNotExist:
|
||||
return Response({
|
||||
'error': 'Image not found'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
return error_response(
|
||||
error='Image not found',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error serving image file: {str(e)}", exc_info=True)
|
||||
return Response({
|
||||
'error': f'Failed to serve image: {str(e)}'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Failed to serve image: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='auto_generate', url_name='auto_generate_images')
|
||||
def auto_generate_images(self, request):
|
||||
"""Auto-generate images for tasks using AI"""
|
||||
task_ids = request.data.get('task_ids', [])
|
||||
if not task_ids:
|
||||
return Response({'error': 'No task IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No task IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
if len(task_ids) > 10:
|
||||
return Response({'error': 'Maximum 10 tasks allowed for image generation'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Maximum 10 tasks allowed for image generation',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get account
|
||||
account = getattr(request, 'account', None)
|
||||
@@ -464,11 +511,11 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
||||
payload={'ids': task_ids},
|
||||
account_id=account_id
|
||||
)
|
||||
return Response({
|
||||
'success': True,
|
||||
'task_id': str(task.id),
|
||||
'message': 'Image generation started'
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data={'task_id': str(task.id)},
|
||||
message='Image generation started',
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
# Celery not available - execute synchronously
|
||||
result = run_ai_task(
|
||||
@@ -477,33 +524,39 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
||||
account_id=account_id
|
||||
)
|
||||
if result.get('success'):
|
||||
return Response({
|
||||
'success': True,
|
||||
'images_created': result.get('count', 0),
|
||||
'message': result.get('message', 'Image generation completed')
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data={'images_created': result.get('count', 0)},
|
||||
message=result.get('message', 'Image generation completed'),
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
return Response({
|
||||
'error': result.get('error', 'Image generation failed')
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=result.get('error', 'Image generation failed'),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
except KombuOperationalError as e:
|
||||
return Response({
|
||||
'error': 'Task queue unavailable. Please try again.',
|
||||
'type': 'QueueError'
|
||||
}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
return error_response(
|
||||
error='Task queue unavailable. Please try again.',
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
request=request
|
||||
)
|
||||
except ImportError:
|
||||
# Tasks module not available
|
||||
return Response({
|
||||
'error': 'Image generation task not available'
|
||||
}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
return error_response(
|
||||
error='Image generation task not available',
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error queuing image generation task: {str(e)}", exc_info=True)
|
||||
return Response({
|
||||
'error': f'Failed to start image generation: {str(e)}',
|
||||
'type': 'TaskError'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=f'Failed to start image generation: {str(e)}',
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
|
||||
def bulk_update(self, request):
|
||||
@@ -518,7 +571,11 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
||||
status_value = request.data.get('status')
|
||||
|
||||
if not status_value:
|
||||
return Response({'error': 'No status provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No status provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
queryset = self.get_queryset()
|
||||
|
||||
@@ -534,13 +591,21 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
||||
Q(content=content) | Q(task=content.task)
|
||||
).update(status=status_value)
|
||||
except Content.DoesNotExist:
|
||||
return Response({'error': 'Content not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
return error_response(
|
||||
error='Content not found',
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
elif image_ids:
|
||||
updated_count = queryset.filter(id__in=image_ids).update(status=status_value)
|
||||
else:
|
||||
return Response({'error': 'Either content_id or ids must be provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='Either content_id or ids must be provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
return Response({'updated_count': updated_count}, status=status.HTTP_200_OK)
|
||||
return success_response(data={'updated_count': updated_count}, request=request)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='content_images', url_name='content_images')
|
||||
def content_images(self, request):
|
||||
@@ -621,10 +686,13 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
||||
# Sort by content title
|
||||
grouped_data.sort(key=lambda x: x['content_title'])
|
||||
|
||||
return Response({
|
||||
return success_response(
|
||||
data={
|
||||
'count': len(grouped_data),
|
||||
'results': grouped_data
|
||||
}, status=status.HTTP_200_OK)
|
||||
},
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='generate_images', url_name='generate_images')
|
||||
def generate_images(self, request):
|
||||
@@ -636,10 +704,11 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
||||
content_id = request.data.get('content_id')
|
||||
|
||||
if not image_ids:
|
||||
return Response({
|
||||
'error': 'No image IDs provided',
|
||||
'type': 'ValidationError'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No image IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
account_id = account.id if account else None
|
||||
|
||||
@@ -651,11 +720,11 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
||||
account_id=account_id,
|
||||
content_id=content_id
|
||||
)
|
||||
return Response({
|
||||
'success': True,
|
||||
'task_id': str(task.id),
|
||||
'message': 'Image generation started'
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data={'task_id': str(task.id)},
|
||||
message='Image generation started',
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
# Fallback to synchronous execution (for testing)
|
||||
result = process_image_generation_queue(
|
||||
@@ -663,21 +732,25 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
||||
account_id=account_id,
|
||||
content_id=content_id
|
||||
)
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
return success_response(data=result, request=request)
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_images] Error: {str(e)}", exc_info=True)
|
||||
return Response({
|
||||
'error': str(e),
|
||||
'type': 'ExecutionError'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
class ContentViewSet(SiteSectorModelViewSet):
|
||||
"""
|
||||
ViewSet for managing task content
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = Content.objects.all()
|
||||
serializer_class = ContentSerializer
|
||||
pagination_class = CustomPageNumberPagination
|
||||
throttle_scope = 'writer'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
search_fields = ['title', 'meta_title', 'primary_keyword']
|
||||
@@ -702,10 +775,11 @@ class ContentViewSet(SiteSectorModelViewSet):
|
||||
ids = request.data.get('ids', [])
|
||||
|
||||
if not ids:
|
||||
return Response({
|
||||
'error': 'No IDs provided',
|
||||
'type': 'ValidationError'
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return error_response(
|
||||
error='No IDs provided',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
account_id = account.id if account else None
|
||||
|
||||
@@ -717,11 +791,11 @@ class ContentViewSet(SiteSectorModelViewSet):
|
||||
payload={'ids': ids},
|
||||
account_id=account_id
|
||||
)
|
||||
return Response({
|
||||
'success': True,
|
||||
'task_id': str(task.id),
|
||||
'message': 'Image prompt generation started'
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data={'task_id': str(task.id)},
|
||||
message='Image prompt generation started',
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
# Fallback to synchronous execution
|
||||
result = run_ai_task(
|
||||
@@ -730,19 +804,21 @@ class ContentViewSet(SiteSectorModelViewSet):
|
||||
account_id=account_id
|
||||
)
|
||||
if result.get('success'):
|
||||
return Response({
|
||||
'success': True,
|
||||
'prompts_created': result.get('count', 0),
|
||||
'message': 'Image prompts generated successfully'
|
||||
}, status=status.HTTP_200_OK)
|
||||
return success_response(
|
||||
data={'prompts_created': result.get('count', 0)},
|
||||
message='Image prompts generated successfully',
|
||||
request=request
|
||||
)
|
||||
else:
|
||||
return Response({
|
||||
'error': result.get('error', 'Image prompt generation failed'),
|
||||
'type': 'TaskExecutionError'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=result.get('error', 'Image prompt generation failed'),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
except Exception as e:
|
||||
return Response({
|
||||
'error': str(e),
|
||||
'type': 'ExecutionError'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-)#i8!6+_&j97eb_4actu86=qtg
|
||||
# Set DEBUG=False via environment variable for production deployments
|
||||
DEBUG = os.getenv('DEBUG', 'False').lower() == 'true'
|
||||
|
||||
# Unified API Standard v1.0 Feature Flags
|
||||
# Set IGNY8_USE_UNIFIED_EXCEPTION_HANDLER=True to enable unified exception handler
|
||||
# Set IGNY8_DEBUG_THROTTLE=True to bypass rate limiting in development
|
||||
IGNY8_DEBUG_THROTTLE = os.getenv('IGNY8_DEBUG_THROTTLE', str(DEBUG)).lower() == 'true'
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
'*', # Allow all hosts for flexibility
|
||||
'api.igny8.com',
|
||||
@@ -73,6 +78,7 @@ MIDDLEWARE = [
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'igny8_core.middleware.request_id.RequestIDMiddleware', # Request ID tracking (must be early)
|
||||
'igny8_core.auth.middleware.AccountContextMiddleware', # Multi-account support
|
||||
# AccountContextMiddleware sets request.account from JWT
|
||||
'igny8_core.middleware.resource_tracker.ResourceTrackingMiddleware', # Resource tracking for admin debug
|
||||
@@ -205,6 +211,40 @@ REST_FRAMEWORK = {
|
||||
'igny8_core.api.authentication.CSRFExemptSessionAuthentication', # Session auth without CSRF for API
|
||||
'rest_framework.authentication.BasicAuthentication', # Enable basic auth as fallback
|
||||
],
|
||||
# Unified API Standard v1.0 Configuration
|
||||
# Exception handler - wraps all errors in unified format
|
||||
# Unified API Standard v1.0: Exception handler enabled by default
|
||||
# Set IGNY8_USE_UNIFIED_EXCEPTION_HANDLER=False to disable
|
||||
'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler' if os.getenv('IGNY8_USE_UNIFIED_EXCEPTION_HANDLER', 'True').lower() == 'false' else 'igny8_core.api.exception_handlers.custom_exception_handler',
|
||||
# Rate limiting - configured but bypassed in DEBUG mode
|
||||
'DEFAULT_THROTTLE_CLASSES': [
|
||||
'igny8_core.api.throttles.DebugScopedRateThrottle',
|
||||
],
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
# AI Functions - Expensive operations
|
||||
'ai_function': '10/min', # AI content generation, clustering
|
||||
'image_gen': '15/min', # Image generation
|
||||
# Content Operations
|
||||
'content_write': '30/min', # Content creation, updates
|
||||
'content_read': '100/min', # Content listing, retrieval
|
||||
# Authentication
|
||||
'auth': '20/min', # Login, register, password reset
|
||||
'auth_strict': '5/min', # Sensitive auth operations
|
||||
# Planner Operations
|
||||
'planner': '60/min', # Keyword, cluster, idea operations
|
||||
'planner_ai': '10/min', # AI-powered planner operations
|
||||
# Writer Operations
|
||||
'writer': '60/min', # Task, content management
|
||||
'writer_ai': '10/min', # AI-powered writer operations
|
||||
# System Operations
|
||||
'system': '100/min', # Settings, prompts, profiles
|
||||
'system_admin': '30/min', # Admin-only system operations
|
||||
# Billing Operations
|
||||
'billing': '30/min', # Credit queries, usage logs
|
||||
'billing_admin': '10/min', # Credit management (admin)
|
||||
# Default fallback
|
||||
'default': '100/min', # Default for endpoints without scope
|
||||
},
|
||||
}
|
||||
|
||||
# CORS Configuration
|
||||
|
||||
@@ -65,13 +65,13 @@ export default function UsageChartWidget() {
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">By Operation</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(usageSummary.by_operation).map(([op, stats]) => (
|
||||
{usageSummary.by_operation && Object.entries(usageSummary.by_operation).map(([op, stats]) => (
|
||||
<div key={op} className="flex justify-between items-center text-sm">
|
||||
<span className="capitalize">{op.replace('_', ' ')}</span>
|
||||
<span className="font-medium">{stats.credits} credits (${(Number(stats.cost) || 0).toFixed(2)})</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(usageSummary.by_operation).length === 0 && (
|
||||
{(!usageSummary.by_operation || Object.keys(usageSummary.by_operation || {}).length === 0) && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">No usage data available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import PageMeta from "../../components/common/PageMeta";
|
||||
import ComponentCard from "../../components/common/ComponentCard";
|
||||
import { API_BASE_URL } from "../../services/api";
|
||||
import { API_BASE_URL, fetchContentImages, fetchUsageLimits, fetchAPI } from "../../services/api";
|
||||
|
||||
interface EndpointStatus {
|
||||
endpoint: string;
|
||||
@@ -10,6 +10,8 @@ interface EndpointStatus {
|
||||
responseTime?: number;
|
||||
lastChecked?: string;
|
||||
error?: string;
|
||||
apiStatus?: 'healthy' | 'warning' | 'error'; // API endpoint status
|
||||
dataStatus?: 'healthy' | 'warning' | 'error'; // Page data population status
|
||||
}
|
||||
|
||||
interface EndpointGroup {
|
||||
@@ -18,6 +20,8 @@ interface EndpointGroup {
|
||||
path: string;
|
||||
method: string;
|
||||
description: string;
|
||||
pageFetchFunction?: () => Promise<any>; // Optional: function to test page data population
|
||||
dataValidator?: (data: any) => boolean; // Optional: function to validate data is populated
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -64,6 +68,19 @@ const endpointGroups: EndpointGroup[] = [
|
||||
{ path: "/v1/writer/content/", method: "GET", description: "List content" },
|
||||
{ path: "/v1/writer/content/generate_image_prompts/", method: "POST", description: "Image prompts" },
|
||||
{ path: "/v1/writer/images/", method: "GET", description: "List images" },
|
||||
{
|
||||
path: "/v1/writer/images/content_images/",
|
||||
method: "GET",
|
||||
description: "Content images",
|
||||
pageFetchFunction: async () => {
|
||||
const data = await fetchContentImages({});
|
||||
return data;
|
||||
},
|
||||
dataValidator: (data: any) => {
|
||||
// Check if data has results array with content
|
||||
return data && data.results && Array.isArray(data.results) && data.results.length > 0;
|
||||
}
|
||||
},
|
||||
{ path: "/v1/writer/images/generate_images/", method: "POST", description: "AI images" },
|
||||
],
|
||||
},
|
||||
@@ -71,6 +88,21 @@ const endpointGroups: EndpointGroup[] = [
|
||||
name: "System & Billing",
|
||||
endpoints: [
|
||||
{ path: "/v1/system/prompts/", method: "GET", description: "List prompts" },
|
||||
{
|
||||
path: "/v1/system/prompts/by_type/clustering/",
|
||||
method: "GET",
|
||||
description: "Get prompt by type",
|
||||
pageFetchFunction: async () => {
|
||||
const response = await fetchAPI('/v1/system/prompts/by_type/clustering/');
|
||||
const data = response?.data || response;
|
||||
return data;
|
||||
},
|
||||
dataValidator: (data: any) => {
|
||||
// Check if prompt data exists and has prompt_value
|
||||
return data && data.prompt_type && (data.prompt_value !== null && data.prompt_value !== undefined);
|
||||
}
|
||||
},
|
||||
{ path: "/v1/system/prompts/save/", method: "POST", description: "Save prompt" },
|
||||
{ path: "/v1/system/author-profiles/", method: "GET", description: "List author profiles" },
|
||||
{ path: "/v1/system/strategies/", method: "GET", description: "List strategies" },
|
||||
{ path: "/v1/system/settings/integrations/1/test/", method: "POST", description: "Test integration" },
|
||||
@@ -78,6 +110,19 @@ const endpointGroups: EndpointGroup[] = [
|
||||
{ path: "/v1/billing/credits/balance/balance/", method: "GET", description: "Credit balance" },
|
||||
{ path: "/v1/billing/credits/usage/", method: "GET", description: "Usage logs" },
|
||||
{ path: "/v1/billing/credits/usage/summary/", method: "GET", description: "Usage summary" },
|
||||
{
|
||||
path: "/v1/billing/credits/usage/limits/",
|
||||
method: "GET",
|
||||
description: "Usage limits",
|
||||
pageFetchFunction: async () => {
|
||||
const data = await fetchUsageLimits();
|
||||
return data;
|
||||
},
|
||||
dataValidator: (data: any) => {
|
||||
// Check if limits array exists and has content
|
||||
return data && data.limits && Array.isArray(data.limits) && data.limits.length > 0;
|
||||
}
|
||||
},
|
||||
{ path: "/v1/billing/credits/transactions/", method: "GET", description: "Transactions" },
|
||||
],
|
||||
},
|
||||
@@ -127,7 +172,7 @@ export default function ApiMonitor() {
|
||||
return saved ? parseInt(saved, 10) : 30;
|
||||
});
|
||||
|
||||
const checkEndpoint = useCallback(async (path: string, method: string) => {
|
||||
const checkEndpoint = useCallback(async (path: string, method: string, endpointConfig?: { pageFetchFunction?: () => Promise<any>; dataValidator?: (data: any) => boolean }) => {
|
||||
const key = `${method}:${path}`;
|
||||
|
||||
// Set checking status
|
||||
@@ -141,6 +186,8 @@ export default function ApiMonitor() {
|
||||
}));
|
||||
|
||||
const startTime = Date.now();
|
||||
let apiStatus: 'healthy' | 'warning' | 'error' = 'healthy';
|
||||
let dataStatus: 'healthy' | 'warning' | 'error' = 'healthy';
|
||||
|
||||
try {
|
||||
// Get token from auth store or localStorage
|
||||
@@ -209,10 +256,19 @@ export default function ApiMonitor() {
|
||||
// Determine status based on response
|
||||
let status: 'healthy' | 'warning' | 'error' = 'healthy';
|
||||
let responseText = '';
|
||||
let responseData: any = null;
|
||||
|
||||
// Read response body for debugging (but don't log errors for expected 400s)
|
||||
// Read response body for debugging and content validation
|
||||
try {
|
||||
responseText = await response.text();
|
||||
// Try to parse JSON to check unified API response format
|
||||
if (responseText && responseText.trim().startsWith('{')) {
|
||||
try {
|
||||
responseData = JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore body read errors
|
||||
}
|
||||
@@ -236,9 +292,56 @@ export default function ApiMonitor() {
|
||||
status = 'warning';
|
||||
}
|
||||
} else if (method === 'GET') {
|
||||
// GET: 2xx = healthy, 401/403 = warning (needs auth), 404 = error, 5xx = error
|
||||
// GET: 2xx = healthy, 401/403 = warning (needs auth), 404 = error, 429 = warning (rate limit), 5xx = error
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
status = 'healthy';
|
||||
// Check unified API response format for errors or empty data
|
||||
if (responseData) {
|
||||
// Check if response has success: false (unified format error)
|
||||
if (responseData.success === false) {
|
||||
status = 'error'; // API returned an error in unified format
|
||||
} else if (responseData.success === true) {
|
||||
// Check if data is empty for endpoints that should return data
|
||||
// These endpoints should have data: {count: X, results: [...]} or data: {...}
|
||||
const shouldHaveData =
|
||||
path.includes('/content_images/') ||
|
||||
path.includes('/prompts/by_type/') ||
|
||||
path.includes('/usage/limits/') ||
|
||||
path.includes('/prompts/') && !path.includes('/save/');
|
||||
|
||||
if (shouldHaveData) {
|
||||
// Check if data field exists and has content
|
||||
if (responseData.data === null || responseData.data === undefined) {
|
||||
status = 'warning'; // Missing data field
|
||||
} else if (Array.isArray(responseData.data) && responseData.data.length === 0) {
|
||||
// Empty array might be OK for some endpoints, but check if results should exist
|
||||
if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) {
|
||||
// These endpoints should return data, empty might indicate a problem
|
||||
status = 'warning'; // Empty data - might indicate configuration issue
|
||||
}
|
||||
} else if (typeof responseData.data === 'object' && responseData.data !== null) {
|
||||
// Check if it's a paginated response with empty results
|
||||
if (responseData.data.results && Array.isArray(responseData.data.results) && responseData.data.results.length === 0) {
|
||||
// Empty results might be OK, but for critical endpoints it's a warning
|
||||
if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) {
|
||||
status = 'warning'; // Empty results - might indicate data issue
|
||||
}
|
||||
} else if (responseData.data.count !== undefined && responseData.data.count === 0) {
|
||||
// Paginated response with count: 0
|
||||
if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) {
|
||||
status = 'warning'; // No data available - might indicate configuration issue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If status is still healthy after content checks, keep it healthy
|
||||
if (status === 'healthy') {
|
||||
status = 'healthy'; // HTTP 2xx and valid response = healthy
|
||||
}
|
||||
} else if (response.status === 429) {
|
||||
status = 'warning'; // Rate limited - endpoint exists but temporarily throttled
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
status = 'warning'; // Endpoint exists, needs authentication
|
||||
} else if (response.status === 404) {
|
||||
@@ -249,12 +352,25 @@ export default function ApiMonitor() {
|
||||
status = 'warning';
|
||||
}
|
||||
} else if (method === 'POST') {
|
||||
// POST: 400 = healthy (endpoint exists and validates), 401/403 = warning, 404 = error, 5xx = error
|
||||
// POST: 400 = healthy (endpoint exists and validates), 401/403 = warning, 404 = error, 429 = warning (rate limit), 5xx = error
|
||||
if (response.status === 400) {
|
||||
// 400 means endpoint exists and validation works - this is healthy for monitoring
|
||||
// But check if it's a unified format error response
|
||||
if (responseData && responseData.success === false) {
|
||||
// This is expected for validation errors, so still healthy
|
||||
status = 'healthy';
|
||||
} else {
|
||||
status = 'healthy';
|
||||
}
|
||||
} else if (response.status >= 200 && response.status < 300) {
|
||||
// Check unified API response format for errors
|
||||
if (responseData && responseData.success === false) {
|
||||
status = 'error'; // API returned an error in unified format
|
||||
} else {
|
||||
status = 'healthy';
|
||||
}
|
||||
} else if (response.status === 429) {
|
||||
status = 'warning'; // Rate limited - endpoint exists but temporarily throttled
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
status = 'warning'; // Endpoint exists, needs authentication
|
||||
} else if (response.status === 404) {
|
||||
@@ -266,13 +382,75 @@ export default function ApiMonitor() {
|
||||
}
|
||||
}
|
||||
|
||||
// Store API status
|
||||
apiStatus = status;
|
||||
|
||||
// Now check page data population if pageFetchFunction is configured
|
||||
if (endpointConfig?.pageFetchFunction) {
|
||||
try {
|
||||
const pageData = await endpointConfig.pageFetchFunction();
|
||||
|
||||
// Validate data using validator if provided
|
||||
if (endpointConfig.dataValidator) {
|
||||
const isValid = endpointConfig.dataValidator(pageData);
|
||||
if (!isValid) {
|
||||
dataStatus = 'warning'; // Data exists but doesn't pass validation (e.g., empty)
|
||||
} else {
|
||||
dataStatus = 'healthy'; // Data is valid and populated
|
||||
}
|
||||
} else {
|
||||
// If no validator, just check if data exists
|
||||
if (pageData === null || pageData === undefined) {
|
||||
dataStatus = 'error';
|
||||
} else {
|
||||
dataStatus = 'healthy';
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Page fetch function failed
|
||||
dataStatus = 'error';
|
||||
console.warn(`[API Monitor] Page data fetch failed for ${path}:`, error.message);
|
||||
}
|
||||
} else {
|
||||
// No page fetch function configured, data status matches API status
|
||||
dataStatus = apiStatus;
|
||||
}
|
||||
|
||||
// Combine API and data statuses - both must be healthy for overall healthy
|
||||
// If either is error, overall is error
|
||||
// If either is warning, overall is warning
|
||||
if (apiStatus === 'error' || dataStatus === 'error') {
|
||||
status = 'error';
|
||||
} else if (apiStatus === 'warning' || dataStatus === 'warning') {
|
||||
status = 'warning';
|
||||
} else {
|
||||
status = 'healthy';
|
||||
}
|
||||
|
||||
// Log warnings/errors for issues detected in response content
|
||||
if (status === 'warning' || status === 'error') {
|
||||
if (responseData) {
|
||||
if (responseData.success === false) {
|
||||
console.warn(`[API Monitor] ${method} ${path}: Unified format error - ${responseData.error || 'Unknown error'}`);
|
||||
} else if (responseData.data === null || responseData.data === undefined) {
|
||||
console.warn(`[API Monitor] ${method} ${path}: Missing data field in response`);
|
||||
} else if (Array.isArray(responseData.data) && responseData.data.length === 0) {
|
||||
console.warn(`[API Monitor] ${method} ${path}: Empty data array returned`);
|
||||
} else if (responseData.data?.results && Array.isArray(responseData.data.results) && responseData.data.results.length === 0) {
|
||||
console.warn(`[API Monitor] ${method} ${path}: Empty results array returned`);
|
||||
} else if (responseData.data?.count === 0) {
|
||||
console.warn(`[API Monitor] ${method} ${path}: No data available (count: 0)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress console errors for expected monitoring responses
|
||||
// Only log real errors (5xx, network errors, or unexpected 4xx for GET endpoints)
|
||||
// Don't log expected 400s for POST endpoints (they indicate validation is working)
|
||||
const isExpectedResponse =
|
||||
(method === 'POST' && response.status === 400) || // Expected validation error
|
||||
(actualMethod === 'OPTIONS' && response.status === 200) || // Expected OPTIONS success
|
||||
(method === 'GET' && response.status >= 200 && response.status < 300); // Expected GET success
|
||||
(method === 'GET' && response.status >= 200 && response.status < 300 && status === 'healthy'); // Expected GET success with valid data
|
||||
|
||||
if (!isExpectedResponse && (response.status >= 500 ||
|
||||
(method === 'GET' && response.status === 404) ||
|
||||
@@ -289,6 +467,8 @@ export default function ApiMonitor() {
|
||||
status,
|
||||
responseTime,
|
||||
lastChecked: new Date().toISOString(),
|
||||
apiStatus,
|
||||
dataStatus,
|
||||
},
|
||||
}));
|
||||
} catch (err: any) {
|
||||
@@ -314,7 +494,10 @@ export default function ApiMonitor() {
|
||||
|
||||
// Check all endpoints in parallel (but limit concurrency)
|
||||
const allChecks = endpointGroups.flatMap(group =>
|
||||
group.endpoints.map(ep => checkEndpoint(ep.path, ep.method))
|
||||
group.endpoints.map(ep => checkEndpoint(ep.path, ep.method, {
|
||||
pageFetchFunction: ep.pageFetchFunction,
|
||||
dataValidator: ep.dataValidator
|
||||
}))
|
||||
);
|
||||
|
||||
// Check in batches of 5 to avoid overwhelming the server
|
||||
@@ -459,6 +642,7 @@ export default function ApiMonitor() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded ${getStatusBadge(status.status)}`}
|
||||
title={status.error || status.status}
|
||||
@@ -466,6 +650,17 @@ export default function ApiMonitor() {
|
||||
<span>{getStatusIcon(status.status)}</span>
|
||||
<span className="capitalize">{status.status}</span>
|
||||
</span>
|
||||
{status.apiStatus && status.dataStatus && endpoint.pageFetchFunction && status.apiStatus !== status.dataStatus && (
|
||||
<div className="text-xs space-y-0.5 mt-1">
|
||||
<div className={`${getStatusColor(status.apiStatus)}`}>
|
||||
API: {status.apiStatus}
|
||||
</div>
|
||||
<div className={`${getStatusColor(status.dataStatus)}`}>
|
||||
Data: {status.dataStatus}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{status.responseTime ? (
|
||||
|
||||
@@ -63,8 +63,10 @@ export default function Status() {
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const data = await fetchAPI('/v1/system/status/');
|
||||
setStatus(data);
|
||||
const response = await fetchAPI('/v1/system/status/');
|
||||
// Handle unified API response format: {success: true, data: {...}}
|
||||
const statusData = response?.data || response;
|
||||
setStatus(statusData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
|
||||
@@ -75,7 +75,10 @@ export default function Prompts() {
|
||||
try {
|
||||
const promises = PROMPT_TYPES.map(async (type) => {
|
||||
try {
|
||||
const data = await fetchAPI(`/v1/system/prompts/by_type/${type.key}/`);
|
||||
const response = await fetchAPI(`/v1/system/prompts/by_type/${type.key}/`);
|
||||
// Extract data field from unified API response format
|
||||
// Response format: { success: true, data: {...}, request_id: "..." }
|
||||
const data = response?.data || response;
|
||||
return { key: type.key, data };
|
||||
} catch (error) {
|
||||
console.error(`Error loading prompt ${type.key}:`, error);
|
||||
@@ -116,7 +119,7 @@ export default function Prompts() {
|
||||
|
||||
setSaving({ ...saving, [promptType]: true });
|
||||
try {
|
||||
const data = await fetchAPI('/v1/system/prompts/save/', {
|
||||
const response = await fetchAPI('/v1/system/prompts/save/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
prompt_type: promptType,
|
||||
@@ -124,11 +127,15 @@ export default function Prompts() {
|
||||
}),
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
toast.success(data.message || 'Prompt saved successfully');
|
||||
// Extract data field from unified API response format
|
||||
// Response format: { success: true, data: {...}, message: "...", request_id: "..." }
|
||||
const data = response?.data || response;
|
||||
|
||||
if (response.success) {
|
||||
toast.success(response.message || 'Prompt saved successfully');
|
||||
await loadPrompts(); // Reload to get updated data
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to save prompt');
|
||||
throw new Error(response.error || 'Failed to save prompt');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error saving prompt:', error);
|
||||
@@ -145,18 +152,22 @@ export default function Prompts() {
|
||||
|
||||
setSaving({ ...saving, [promptType]: true });
|
||||
try {
|
||||
const data = await fetchAPI('/v1/system/prompts/reset/', {
|
||||
const response = await fetchAPI('/v1/system/prompts/reset/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
prompt_type: promptType,
|
||||
}),
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
toast.success(data.message || 'Prompt reset to default');
|
||||
// Extract data field from unified API response format
|
||||
// Response format: { success: true, data: {...}, message: "...", request_id: "..." }
|
||||
const data = response?.data || response;
|
||||
|
||||
if (response.success) {
|
||||
toast.success(response.message || 'Prompt reset to default');
|
||||
await loadPrompts(); // Reload to get default value
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to reset prompt');
|
||||
throw new Error(response.error || 'Failed to reset prompt');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error resetting prompt:', error);
|
||||
|
||||
@@ -1114,7 +1114,10 @@ export async function fetchContentImages(filters: ContentImagesFilters = {}): Pr
|
||||
if (filters.sector_id) params.append('sector_id', filters.sector_id.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
return fetchAPI(`/v1/writer/images/content_images/${queryString ? `?${queryString}` : ''}`);
|
||||
const response = await fetchAPI(`/v1/writer/images/content_images/${queryString ? `?${queryString}` : ''}`);
|
||||
// Extract data field from unified API response format
|
||||
// Response format: { success: true, data: { count: ..., results: [...] }, request_id: "..." }
|
||||
return response?.data || response;
|
||||
}
|
||||
|
||||
export async function bulkUpdateImagesStatus(contentId: number, status: string): Promise<{ updated_count: number }> {
|
||||
@@ -1442,7 +1445,11 @@ export async function fetchUsageSummary(startDate?: string, endDate?: string): P
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
const queryString = params.toString();
|
||||
return fetchAPI(`/v1/billing/credits/usage/summary/${queryString ? `?${queryString}` : ''}`);
|
||||
const response = await fetchAPI(`/v1/billing/credits/usage/summary/${queryString ? `?${queryString}` : ''}`);
|
||||
// Extract data field from unified API response format
|
||||
// Response format: { success: true, data: {...}, request_id: "..." }
|
||||
const summaryData = response?.data || response;
|
||||
return summaryData;
|
||||
}
|
||||
|
||||
export interface LimitCard {
|
||||
@@ -1464,7 +1471,10 @@ export async function fetchUsageLimits(): Promise<UsageLimitsResponse> {
|
||||
try {
|
||||
const response = await fetchAPI('/v1/billing/credits/usage/limits/');
|
||||
console.log('Usage limits API response:', response);
|
||||
return response;
|
||||
// Extract data field from unified API response format
|
||||
// Response format: { success: true, data: { limits: [...] }, request_id: "..." }
|
||||
const limitsData = response?.data || response;
|
||||
return limitsData;
|
||||
} catch (error) {
|
||||
console.error('Error fetching usage limits:', error);
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user