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:
IGNY8 VPS (Salman)
2025-11-15 20:18:42 +00:00
parent 94f243f4a2
commit a75ebf2584
18 changed files with 1974 additions and 642 deletions

View 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
)

View File

@@ -1,7 +1,9 @@
""" """
Custom pagination class for DRF to support dynamic page_size query parameter Custom pagination class for DRF to support dynamic page_size query parameter
and unified response format
""" """
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
from .response import get_request_id
class CustomPageNumberPagination(PageNumberPagination): class CustomPageNumberPagination(PageNumberPagination):
@@ -11,8 +13,37 @@ class CustomPageNumberPagination(PageNumberPagination):
Default page size: 10 Default page size: 10
Max page size: 100 Max page size: 100
Returns unified format with success field
""" """
page_size = 10 page_size = 10
page_size_query_param = 'page_size' page_size_query_param = 'page_size'
max_page_size = 100 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)

View 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

View 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)

View 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

View File

@@ -1,5 +1,6 @@
""" """
Authentication Views - Structured as: Groups, Users, Accounts, Subscriptions, Site User Access 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 import viewsets, status, permissions, filters
from rest_framework.decorators import action from rest_framework.decorators import action
@@ -11,6 +12,9 @@ from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from igny8_core.api.base import AccountModelViewSet from igny8_core.api.base import AccountModelViewSet
from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication 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 .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword
from .serializers import ( from .serializers import (
UserSerializer, AccountSerializer, PlanSerializer, SubscriptionSerializer, UserSerializer, AccountSerializer, PlanSerializer, SubscriptionSerializer,
@@ -33,8 +37,11 @@ class GroupsViewSet(viewsets.ViewSet):
""" """
ViewSet for managing user roles and permissions (Groups). ViewSet for managing user roles and permissions (Groups).
Groups are defined by the User.ROLE_CHOICES. Groups are defined by the User.ROLE_CHOICES.
Unified API Standard v1.0 compliant
""" """
permission_classes = [IsOwnerOrAdmin] permission_classes = [IsOwnerOrAdmin]
throttle_scope = 'auth'
throttle_classes = [DebugScopedRateThrottle]
def list(self, request): def list(self, request):
"""List all available roles/groups.""" """List all available roles/groups."""
@@ -76,17 +83,18 @@ class GroupsViewSet(viewsets.ViewSet):
'permissions': ['automation_only'] 'permissions': ['automation_only']
} }
] ]
return Response({ return success_response(data={'groups': roles}, request=request)
'success': True,
'groups': roles
})
@action(detail=False, methods=['get'], url_path='permissions') @action(detail=False, methods=['get'], url_path='permissions')
def permissions(self, request): def permissions(self, request):
"""Get permissions for a specific role.""" """Get permissions for a specific role."""
role = request.query_params.get('role') role = request.query_params.get('role')
if not 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 = { role_permissions = {
'developer': ['full_access', 'bypass_filters', 'all_modules', 'all_accounts'], 'developer': ['full_access', 'bypass_filters', 'all_modules', 'all_accounts'],
@@ -98,11 +106,13 @@ class GroupsViewSet(viewsets.ViewSet):
} }
permissions_list = role_permissions.get(role, []) permissions_list = role_permissions.get(role, [])
return Response({ return success_response(
'success': True, data={
'role': role, 'role': role,
'permissions': permissions_list 'permissions': permissions_list
}) },
request=request
)
# ============================================================================ # ============================================================================
@@ -113,10 +123,14 @@ class UsersViewSet(viewsets.ModelViewSet):
""" """
ViewSet for managing global user records and credentials. ViewSet for managing global user records and credentials.
Users are global, but belong to accounts. Users are global, but belong to accounts.
Unified API Standard v1.0 compliant
""" """
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserSerializer serializer_class = UserSerializer
permission_classes = [IsOwnerOrAdmin] permission_classes = [IsOwnerOrAdmin]
pagination_class = CustomPageNumberPagination
throttle_scope = 'auth'
throttle_classes = [DebugScopedRateThrottle]
def get_queryset(self): def get_queryset(self):
"""Return users based on access level.""" """Return users based on access level."""
@@ -147,17 +161,21 @@ class UsersViewSet(viewsets.ModelViewSet):
account_id = request.data.get('account_id') account_id = request.data.get('account_id')
if not email or not username or not password: if not email or not username or not password:
return Response({ return error_response(
'error': 'email, username, and password are required' error='email, username, and password are required',
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Validate password # Validate password
try: try:
validate_password(password) validate_password(password)
except Exception as e: except Exception as e:
return Response({ return error_response(
'error': str(e) error=str(e),
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Get account # Get account
account = None account = None
@@ -165,9 +183,11 @@ class UsersViewSet(viewsets.ModelViewSet):
try: try:
account = Account.objects.get(id=account_id) account = Account.objects.get(id=account_id)
except Account.DoesNotExist: except Account.DoesNotExist:
return Response({ return error_response(
'error': f'Account with id {account_id} does not exist' error=f'Account with id {account_id} does not exist',
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
else: else:
# Use current user's account # Use current user's account
if request.user.account: if request.user.account:
@@ -183,14 +203,17 @@ class UsersViewSet(viewsets.ModelViewSet):
account=account account=account
) )
serializer = UserSerializer(user) serializer = UserSerializer(user)
return Response({ return success_response(
'success': True, data={'user': serializer.data},
'user': serializer.data status_code=status.HTTP_201_CREATED,
}, status=status.HTTP_201_CREATED) request=request
)
except Exception as e: except Exception as e:
return Response({ return error_response(
'error': str(e) error=str(e),
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def update_role(self, request, pk=None): def update_role(self, request, pk=None):
@@ -199,23 +222,24 @@ class UsersViewSet(viewsets.ModelViewSet):
new_role = request.data.get('role') new_role = request.data.get('role')
if not new_role: if not new_role:
return Response({ return error_response(
'error': 'role is required' error='role is required',
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
if new_role not in [choice[0] for choice in User.ROLE_CHOICES]: if new_role not in [choice[0] for choice in User.ROLE_CHOICES]:
return Response({ return error_response(
'error': f'Invalid role. Must be one of: {[c[0] for c in User.ROLE_CHOICES]}' error=f'Invalid role. Must be one of: {[c[0] for c in User.ROLE_CHOICES]}',
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
user.role = new_role user.role = new_role
user.save() user.save()
serializer = UserSerializer(user) serializer = UserSerializer(user)
return Response({ return success_response(data={'user': serializer.data}, request=request)
'success': True,
'user': serializer.data
})
# ============================================================================ # ============================================================================
@@ -308,14 +332,16 @@ class SubscriptionsViewSet(viewsets.ModelViewSet):
try: try:
subscription = Subscription.objects.get(account_id=account_id) subscription = Subscription.objects.get(account_id=account_id)
serializer = self.get_serializer(subscription) serializer = self.get_serializer(subscription)
return Response({ return success_response(
'success': True, data={'subscription': serializer.data},
'subscription': serializer.data request=request
}) )
except Subscription.DoesNotExist: except Subscription.DoesNotExist:
return Response({ return error_response(
'error': 'Subscription not found for this account' error='Subscription not found for this account',
}, status=status.HTTP_404_NOT_FOUND) status_code=status.HTTP_404_NOT_FOUND,
request=request
)
# ============================================================================ # ============================================================================
@@ -424,7 +450,10 @@ class SiteViewSet(AccountModelViewSet):
site = self.get_object() site = self.get_object()
sectors = site.sectors.filter(is_active=True) sectors = site.sectors.filter(is_active=True)
serializer = SectorSerializer(sectors, many=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') @action(detail=True, methods=['post'], url_path='set_active')
def set_active(self, request, pk=None): def set_active(self, request, pk=None):
@@ -437,11 +466,11 @@ class SiteViewSet(AccountModelViewSet):
site.save() site.save()
serializer = self.get_serializer(site) serializer = self.get_serializer(site)
return Response({ return success_response(
'success': True, data={'site': serializer.data},
'message': f'Site "{site.name}" is now active', message=f'Site "{site.name}" is now active',
'site': serializer.data request=request
}) )
@action(detail=True, methods=['post'], url_path='select_sectors') @action(detail=True, methods=['post'], url_path='select_sectors')
def select_sectors(self, request, pk=None): def select_sectors(self, request, pk=None):
@@ -453,43 +482,53 @@ class SiteViewSet(AccountModelViewSet):
site = self.get_object() site = self.get_object()
except Exception as e: except Exception as e:
logger.error(f"Error getting site object: {str(e)}", exc_info=True) logger.error(f"Error getting site object: {str(e)}", exc_info=True)
return Response({ return error_response(
'error': f'Site not found: {str(e)}' error=f'Site not found: {str(e)}',
}, status=status.HTTP_404_NOT_FOUND) status_code=status.HTTP_404_NOT_FOUND,
request=request
)
sector_slugs = request.data.get('sector_slugs', []) sector_slugs = request.data.get('sector_slugs', [])
industry_slug = request.data.get('industry_slug') industry_slug = request.data.get('industry_slug')
if not industry_slug: if not industry_slug:
return Response({ return error_response(
'error': 'Industry slug is required' error='Industry slug is required',
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
try: try:
industry = Industry.objects.get(slug=industry_slug, is_active=True) industry = Industry.objects.get(slug=industry_slug, is_active=True)
except Industry.DoesNotExist: except Industry.DoesNotExist:
return Response({ return error_response(
'error': f'Industry with slug "{industry_slug}" not found' error=f'Industry with slug "{industry_slug}" not found',
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
site.industry = industry site.industry = industry
site.save() site.save()
if not sector_slugs: if not sector_slugs:
return Response({ return success_response(
'success': True, data={
'message': f'Industry "{industry.name}" set for site. No sectors selected.', 'site': SiteSerializer(site).data,
'site': SiteSerializer(site).data, 'sectors': []
'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 # Get plan's max_industries limit (if set), otherwise default to 5
max_sectors = site.get_max_sectors_limit() max_sectors = site.get_max_sectors_limit()
if len(sector_slugs) > max_sectors: if len(sector_slugs) > max_sectors:
return Response({ return error_response(
'error': f'Maximum {max_sectors} sectors allowed per site for this plan' error=f'Maximum {max_sectors} sectors allowed per site for this plan',
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
created_sectors = [] created_sectors = []
updated_sectors = [] updated_sectors = []
@@ -506,9 +545,11 @@ class SiteViewSet(AccountModelViewSet):
).first() ).first()
if not industry_sector: if not industry_sector:
return Response({ return error_response(
'error': f'Sector "{sector_slug}" not found in industry "{industry.name}"' error=f'Sector "{sector_slug}" not found in industry "{industry.name}"',
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
industry_sectors_map[sector_slug] = industry_sector industry_sectors_map[sector_slug] = industry_sector
@@ -517,9 +558,11 @@ class SiteViewSet(AccountModelViewSet):
# Check if site has account before proceeding # Check if site has account before proceeding
if not site.account: if not site.account:
logger.error(f"Site {site.id} has no account assigned") logger.error(f"Site {site.id} has no account assigned")
return Response({ return error_response(
'error': f'Site "{site.name}" has no account assigned. Please contact support.' error=f'Site "{site.name}" has no account assigned. Please contact support.',
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
# Create or get sector - account will be set automatically in save() method # 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 # 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) created_sectors.append(sector)
except Exception as e: except Exception as e:
logger.error(f"Error creating/updating sector {sector_slug}: {str(e)}", exc_info=True) logger.error(f"Error creating/updating sector {sector_slug}: {str(e)}", exc_info=True)
return Response({ return error_response(
'error': f'Failed to create/update sector "{sector_slug}": {str(e)}' error=f'Failed to create/update sector "{sector_slug}": {str(e)}',
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
# Get plan's max_industries limit (if set), otherwise default to 5 # Get plan's max_industries limit (if set), otherwise default to 5
max_sectors = site.get_max_sectors_limit() max_sectors = site.get_max_sectors_limit()
if site.get_active_sectors_count() > max_sectors: if site.get_active_sectors_count() > max_sectors:
return Response({ return error_response(
'error': f'Maximum {max_sectors} sectors allowed per site for this plan' error=f'Maximum {max_sectors} sectors allowed per site for this plan',
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
serializer = SectorSerializer(site.sectors.filter(is_active=True), many=True) serializer = SectorSerializer(site.sectors.filter(is_active=True), many=True)
return Response({ return success_response(
'success': True, data={
'message': f'Selected {len(sector_slugs)} sectors from industry "{industry.name}".', 'created_count': len(created_sectors),
'created_count': len(created_sectors), 'updated_count': len(updated_sectors),
'updated_count': len(updated_sectors), 'sectors': serializer.data,
'sectors': serializer.data, 'site': SiteSerializer(site).data
'site': SiteSerializer(site).data },
}) message=f'Selected {len(sector_slugs)} sectors from industry "{industry.name}".',
request=request
)
class SectorViewSet(AccountModelViewSet): class SectorViewSet(AccountModelViewSet):
@@ -606,7 +655,10 @@ class SectorViewSet(AccountModelViewSet):
"""Override list to apply site filter.""" """Override list to apply site filter."""
queryset = self.get_queryset_with_site_filter() queryset = self.get_queryset_with_site_filter()
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data) return success_response(
data=serializer.data,
request=request
)
class IndustryViewSet(viewsets.ReadOnlyModelViewSet): class IndustryViewSet(viewsets.ReadOnlyModelViewSet):
@@ -619,10 +671,10 @@ class IndustryViewSet(viewsets.ReadOnlyModelViewSet):
"""Get all industries with their sectors.""" """Get all industries with their sectors."""
industries = self.get_queryset() industries = self.get_queryset()
serializer = self.get_serializer(industries, many=True) serializer = self.get_serializer(industries, many=True)
return Response({ return success_response(
'success': True, data={'industries': serializer.data},
'industries': serializer.data request=request
}) )
class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
@@ -656,8 +708,12 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
# ============================================================================ # ============================================================================
class AuthViewSet(viewsets.GenericViewSet): class AuthViewSet(viewsets.GenericViewSet):
"""Authentication endpoints.""" """Authentication endpoints.
Unified API Standard v1.0 compliant
"""
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
throttle_scope = 'auth_strict'
throttle_classes = [DebugScopedRateThrottle]
@action(detail=False, methods=['post']) @action(detail=False, methods=['post'])
def register(self, request): def register(self, request):
@@ -680,21 +736,26 @@ class AuthViewSet(viewsets.GenericViewSet):
refresh_expires_at = get_token_expiry('refresh') refresh_expires_at = get_token_expiry('refresh')
user_serializer = UserSerializer(user) user_serializer = UserSerializer(user)
return Response({ return success_response(
'success': True, data={
'message': 'Registration successful', 'user': user_serializer.data,
'user': user_serializer.data, 'tokens': {
'tokens': { 'access': access_token,
'access': access_token, 'refresh': refresh_token,
'refresh': refresh_token, 'access_expires_at': access_expires_at.isoformat(),
'access_expires_at': access_expires_at.isoformat(), 'refresh_expires_at': refresh_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(), }
} },
}, status=status.HTTP_201_CREATED) message='Registration successful',
return Response({ status_code=status.HTTP_201_CREATED,
'success': False, request=request
'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']) @action(detail=False, methods=['post'])
def login(self, request): def login(self, request):
@@ -707,10 +768,11 @@ class AuthViewSet(viewsets.GenericViewSet):
try: try:
user = User.objects.select_related('account', 'account__plan').get(email=email) user = User.objects.select_related('account', 'account__plan').get(email=email)
except User.DoesNotExist: except User.DoesNotExist:
return Response({ return error_response(
'success': False, error='Invalid credentials',
'message': 'Invalid credentials' status_code=status.HTTP_401_UNAUTHORIZED,
}, status=status.HTTP_401_UNAUTHORIZED) request=request
)
if user.check_password(password): if user.check_password(password):
# Log the user in (create session for session authentication) # Log the user in (create session for session authentication)
@@ -727,27 +789,32 @@ class AuthViewSet(viewsets.GenericViewSet):
refresh_expires_at = get_token_expiry('refresh') refresh_expires_at = get_token_expiry('refresh')
user_serializer = UserSerializer(user) user_serializer = UserSerializer(user)
return Response({ return success_response(
'success': True, data={
'message': 'Login successful', 'user': user_serializer.data,
'user': user_serializer.data, 'tokens': {
'tokens': { 'access': access_token,
'access': access_token, 'refresh': refresh_token,
'refresh': refresh_token, 'access_expires_at': access_expires_at.isoformat(),
'access_expires_at': access_expires_at.isoformat(), 'refresh_expires_at': refresh_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(), }
} },
}) message='Login successful',
request=request
)
return Response({ return error_response(
'success': False, error='Invalid credentials',
'message': 'Invalid credentials' status_code=status.HTTP_401_UNAUTHORIZED,
}, status=status.HTTP_401_UNAUTHORIZED) request=request
)
return Response({ return error_response(
'success': False, error='Validation failed',
'errors': serializer.errors errors=serializer.errors,
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated]) @action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def change_password(self, request): def change_password(self, request):
@@ -756,23 +823,26 @@ class AuthViewSet(viewsets.GenericViewSet):
if serializer.is_valid(): if serializer.is_valid():
user = request.user user = request.user
if not user.check_password(serializer.validated_data['old_password']): if not user.check_password(serializer.validated_data['old_password']):
return Response({ return error_response(
'success': False, error='Current password is incorrect',
'message': 'Current password is incorrect' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
user.set_password(serializer.validated_data['new_password']) user.set_password(serializer.validated_data['new_password'])
user.save() user.save()
return Response({ return success_response(
'success': True, message='Password changed successfully',
'message': 'Password changed successfully' request=request
}) )
return Response({ return error_response(
'success': False, error='Validation failed',
'errors': serializer.errors errors=serializer.errors,
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
def me(self, request): def me(self, request):
@@ -781,20 +851,22 @@ class AuthViewSet(viewsets.GenericViewSet):
# This ensures account/plan changes are reflected immediately # This ensures account/plan changes are reflected immediately
user = User.objects.select_related('account', 'account__plan').get(id=request.user.id) user = User.objects.select_related('account', 'account__plan').get(id=request.user.id)
serializer = UserSerializer(user) serializer = UserSerializer(user)
return Response({ return success_response(
'success': True, data={'user': serializer.data},
'user': serializer.data request=request
}) )
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def refresh(self, request): def refresh(self, request):
"""Refresh access token using refresh token.""" """Refresh access token using refresh token."""
serializer = RefreshTokenSerializer(data=request.data) serializer = RefreshTokenSerializer(data=request.data)
if not serializer.is_valid(): if not serializer.is_valid():
return Response({ return error_response(
'success': False, error='Validation failed',
'errors': serializer.errors errors=serializer.errors,
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
refresh_token = serializer.validated_data['refresh'] refresh_token = serializer.validated_data['refresh']
@@ -804,10 +876,11 @@ class AuthViewSet(viewsets.GenericViewSet):
# Verify it's a refresh token # Verify it's a refresh token
if payload.get('type') != 'refresh': if payload.get('type') != 'refresh':
return Response({ return error_response(
'success': False, error='Invalid token type',
'message': 'Invalid token type' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
# Get user # Get user
user_id = payload.get('user_id') user_id = payload.get('user_id')
@@ -816,10 +889,11 @@ class AuthViewSet(viewsets.GenericViewSet):
try: try:
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
except User.DoesNotExist: except User.DoesNotExist:
return Response({ return error_response(
'success': False, error='User not found',
'message': 'User not found' status_code=status.HTTP_404_NOT_FOUND,
}, status=status.HTTP_404_NOT_FOUND) request=request
)
# Get account # Get account
account_id = payload.get('account_id') account_id = payload.get('account_id')
@@ -837,27 +911,32 @@ class AuthViewSet(viewsets.GenericViewSet):
access_token = generate_access_token(user, account) access_token = generate_access_token(user, account)
access_expires_at = get_token_expiry('access') access_expires_at = get_token_expiry('access')
return Response({ return success_response(
'success': True, data={
'access': access_token, 'access': access_token,
'access_expires_at': access_expires_at.isoformat() 'access_expires_at': access_expires_at.isoformat()
}) },
request=request
)
except jwt.InvalidTokenError as e: except jwt.InvalidTokenError as e:
return Response({ return error_response(
'success': False, error='Invalid or expired refresh token',
'message': 'Invalid or expired refresh token' status_code=status.HTTP_401_UNAUTHORIZED,
}, status=status.HTTP_401_UNAUTHORIZED) request=request
)
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def request_reset(self, request): def request_reset(self, request):
"""Request password reset - sends email with reset token.""" """Request password reset - sends email with reset token."""
serializer = RequestPasswordResetSerializer(data=request.data) serializer = RequestPasswordResetSerializer(data=request.data)
if not serializer.is_valid(): if not serializer.is_valid():
return Response({ return error_response(
'success': False, error='Validation failed',
'errors': serializer.errors errors=serializer.errors,
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
email = serializer.validated_data['email'] email = serializer.validated_data['email']
@@ -865,10 +944,10 @@ class AuthViewSet(viewsets.GenericViewSet):
user = User.objects.get(email=email) user = User.objects.get(email=email)
except User.DoesNotExist: except User.DoesNotExist:
# Don't reveal if email exists - return success anyway # Don't reveal if email exists - return success anyway
return Response({ return success_response(
'success': True, message='If an account with that email exists, a password reset link has been sent.',
'message': 'If an account with that email exists, a password reset link has been sent.' request=request
}) )
# Generate secure token # Generate secure token
import secrets import secrets
@@ -904,20 +983,22 @@ class AuthViewSet(viewsets.GenericViewSet):
fail_silently=False, fail_silently=False,
) )
return Response({ return success_response(
'success': True, message='If an account with that email exists, a password reset link has been sent.',
'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]) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def reset_password(self, request): def reset_password(self, request):
"""Reset password using reset token.""" """Reset password using reset token."""
serializer = ResetPasswordSerializer(data=request.data) serializer = ResetPasswordSerializer(data=request.data)
if not serializer.is_valid(): if not serializer.is_valid():
return Response({ return error_response(
'success': False, error='Validation failed',
'errors': serializer.errors errors=serializer.errors,
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
token = serializer.validated_data['token'] token = serializer.validated_data['token']
new_password = serializer.validated_data['new_password'] new_password = serializer.validated_data['new_password']
@@ -925,17 +1006,19 @@ class AuthViewSet(viewsets.GenericViewSet):
try: try:
reset_token = PasswordResetToken.objects.get(token=token) reset_token = PasswordResetToken.objects.get(token=token)
except PasswordResetToken.DoesNotExist: except PasswordResetToken.DoesNotExist:
return Response({ return error_response(
'success': False, error='Invalid reset token',
'message': 'Invalid reset token' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
# Check if token is valid # Check if token is valid
if not reset_token.is_valid(): if not reset_token.is_valid():
return Response({ return error_response(
'success': False, error='Reset token has expired or has already been used',
'message': 'Reset token has expired or has already been used' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
# Update password # Update password
user = reset_token.user user = reset_token.user
@@ -946,7 +1029,7 @@ class AuthViewSet(viewsets.GenericViewSet):
reset_token.used = True reset_token.used = True
reset_token.save() reset_token.save()
return Response({ return success_response(
'success': True, message='Password has been reset successfully',
'message': 'Password has been reset successfully' request=request
}) )

View 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

View File

@@ -1,5 +1,6 @@
""" """
ViewSets for Billing API ViewSets for Billing API
Unified API Standard v1.0 compliant
""" """
from rest_framework import viewsets, status, permissions from rest_framework import viewsets, status, permissions
from rest_framework.decorators import action from rest_framework.decorators import action
@@ -10,6 +11,8 @@ from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from igny8_core.api.base import AccountModelViewSet from igny8_core.api.base import AccountModelViewSet
from igny8_core.api.pagination import CustomPageNumberPagination 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 igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication
from .models import CreditTransaction, CreditUsageLog from .models import CreditTransaction, CreditUsageLog
from .serializers import ( from .serializers import (
@@ -23,9 +26,12 @@ from .exceptions import InsufficientCreditsError
class CreditBalanceViewSet(viewsets.ViewSet): class CreditBalanceViewSet(viewsets.ViewSet):
""" """
ViewSet for credit balance operations ViewSet for credit balance operations
Unified API Standard v1.0 compliant
""" """
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
throttle_scope = 'billing'
throttle_classes = [DebugScopedRateThrottle]
@action(detail=False, methods=['get']) @action(detail=False, methods=['get'])
def balance(self, request): def balance(self, request):
@@ -37,9 +43,10 @@ class CreditBalanceViewSet(viewsets.ViewSet):
account = getattr(user, 'account', None) account = getattr(user, 'account', None)
if not account: if not account:
return Response( return error_response(
{'error': 'Account not found'}, error='Account not found',
status=status.HTTP_400_BAD_REQUEST status_code=status.HTTP_400_BAD_REQUEST,
request=request
) )
# Get plan credits per month # Get plan credits per month
@@ -63,18 +70,21 @@ class CreditBalanceViewSet(viewsets.ViewSet):
} }
serializer = CreditBalanceSerializer(data) serializer = CreditBalanceSerializer(data)
return Response(serializer.data) return success_response(data=serializer.data, request=request)
class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet): class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet):
""" """
ViewSet for credit usage logs ViewSet for credit usage logs
Unified API Standard v1.0 compliant
""" """
queryset = CreditUsageLog.objects.all() queryset = CreditUsageLog.objects.all()
serializer_class = CreditUsageLogSerializer serializer_class = CreditUsageLogSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
pagination_class = CustomPageNumberPagination pagination_class = CustomPageNumberPagination
throttle_scope = 'billing'
throttle_classes = [DebugScopedRateThrottle]
filter_backends = [] filter_backends = []
@@ -116,9 +126,10 @@ class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet):
account = getattr(user, 'account', None) account = getattr(user, 'account', None)
if not account: if not account:
return Response( return error_response(
{'error': 'Account not found'}, error='Account not found',
status=status.HTTP_400_BAD_REQUEST status_code=status.HTTP_400_BAD_REQUEST,
request=request
) )
# Get date range from query params # Get date range from query params
@@ -192,7 +203,7 @@ class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet):
} }
serializer = UsageSummarySerializer(data) 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') @action(detail=False, methods=['get'], url_path='limits', url_name='limits')
def limits(self, request): def limits(self, request):
@@ -222,12 +233,12 @@ class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet):
if not account: if not account:
logger.warning(f'No account found in limits endpoint') logger.warning(f'No account found in limits endpoint')
# Return empty limits instead of error - frontend will show "no data" message # 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 plan = account.plan
if not plan: if not plan:
# Return empty limits instead of error - allows frontend to show "no plan" message # 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 # Import models
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas 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 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): class CreditTransactionViewSet(viewsets.ReadOnlyModelViewSet):
""" """
ViewSet for credit transaction history ViewSet for credit transaction history
Unified API Standard v1.0 compliant
""" """
queryset = CreditTransaction.objects.all() queryset = CreditTransaction.objects.all()
serializer_class = CreditTransactionSerializer serializer_class = CreditTransactionSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
pagination_class = CustomPageNumberPagination pagination_class = CustomPageNumberPagination
throttle_scope = 'billing'
throttle_classes = [DebugScopedRateThrottle]
def get_queryset(self): def get_queryset(self):
"""Get transactions for current account""" """Get transactions for current account"""

View File

@@ -10,6 +10,8 @@ import json
import time import time
from igny8_core.api.base import SiteSectorModelViewSet from igny8_core.api.base import SiteSectorModelViewSet
from igny8_core.api.pagination import CustomPageNumberPagination 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 .models import Keywords, Clusters, ContentIdeas
from .serializers import KeywordSerializer, ContentIdeasSerializer from .serializers import KeywordSerializer, ContentIdeasSerializer
from .cluster_serializers import ClusterSerializer from .cluster_serializers import ClusterSerializer
@@ -19,11 +21,14 @@ class KeywordViewSet(SiteSectorModelViewSet):
""" """
ViewSet for managing keywords with CRUD operations ViewSet for managing keywords with CRUD operations
Provides list, create, retrieve, update, and destroy actions Provides list, create, retrieve, update, and destroy actions
Unified API Standard v1.0 compliant
""" """
queryset = Keywords.objects.all() queryset = Keywords.objects.all()
serializer_class = KeywordSerializer serializer_class = KeywordSerializer
permission_classes = [] # Allow any for now permission_classes = [] # Allow any for now
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
throttle_scope = 'planner'
throttle_classes = [DebugScopedRateThrottle]
# DRF filtering configuration # DRF filtering configuration
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
@@ -121,13 +126,17 @@ class KeywordViewSet(SiteSectorModelViewSet):
return self.get_paginated_response(serializer.data) return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data) return success_response(
data=serializer.data,
request=request
)
except Exception as e: except Exception as e:
logger.error(f"Error in KeywordViewSet.list(): {type(e).__name__}: {str(e)}", exc_info=True) logger.error(f"Error in KeywordViewSet.list(): {type(e).__name__}: {str(e)}", exc_info=True)
return Response({ return error_response(
'error': f'Error loading keywords: {str(e)}', error=f'Error loading keywords: {str(e)}',
'type': type(e).__name__ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
def perform_create(self, serializer): def perform_create(self, serializer):
"""Require explicit site_id and sector_id - no defaults.""" """Require explicit site_id and sector_id - no defaults."""
@@ -190,12 +199,16 @@ class KeywordViewSet(SiteSectorModelViewSet):
"""Bulk delete keywords""" """Bulk delete keywords"""
ids = request.data.get('ids', []) ids = request.data.get('ids', [])
if not 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() queryset = self.get_queryset()
deleted_count, _ = queryset.filter(id__in=ids).delete() 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') @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
def bulk_update(self, request): def bulk_update(self, request):
@@ -204,14 +217,22 @@ class KeywordViewSet(SiteSectorModelViewSet):
status_value = request.data.get('status') status_value = request.data.get('status')
if not 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
)
if not status_value: 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() queryset = self.get_queryset()
updated_count = queryset.filter(id__in=ids).update(status=status_value) 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') @action(detail=False, methods=['post'], url_path='bulk_add_from_seed', url_name='bulk_add_from_seed')
def bulk_add_from_seed(self, request): def bulk_add_from_seed(self, request):
@@ -223,32 +244,60 @@ class KeywordViewSet(SiteSectorModelViewSet):
sector_id = request.data.get('sector_id') sector_id = request.data.get('sector_id')
if not seed_keyword_ids: 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: 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: 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: try:
site = Site.objects.get(id=site_id) site = Site.objects.get(id=site_id)
sector = Sector.objects.get(id=sector_id) sector = Sector.objects.get(id=sector_id)
except (Site.DoesNotExist, Sector.DoesNotExist) as e: 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 # Validate sector belongs to site
if sector.site != 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 # Get account from site
account = site.account account = site.account
if not 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 # Get SeedKeywords
seed_keywords = SeedKeyword.objects.filter(id__in=seed_keyword_ids, is_active=True) seed_keywords = SeedKeyword.objects.filter(id__in=seed_keyword_ids, is_active=True)
if not seed_keywords.exists(): 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 created_count = 0
skipped_count = 0 skipped_count = 0
@@ -288,12 +337,14 @@ class KeywordViewSet(SiteSectorModelViewSet):
errors.append(f"Error adding '{seed_keyword.keyword}': {str(e)}") errors.append(f"Error adding '{seed_keyword.keyword}': {str(e)}")
skipped_count += 1 skipped_count += 1
return Response({ return success_response(
'success': True, data={
'created': created_count, 'created': created_count,
'skipped': skipped_count, 'skipped': skipped_count,
'errors': errors[:10] if errors else [] # Limit errors to first 10 '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') @action(detail=False, methods=['get'], url_path='export', url_name='export')
def export(self, request): def export(self, request):
@@ -366,11 +417,19 @@ class KeywordViewSet(SiteSectorModelViewSet):
Automatically links keywords to current active site/sector. Automatically links keywords to current active site/sector.
""" """
if 'file' not in request.FILES: 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'] file = request.FILES['file']
if not file.name.endswith('.csv'): 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) user = getattr(request, 'user', None)
@@ -391,23 +450,43 @@ class KeywordViewSet(SiteSectorModelViewSet):
# Site ID is REQUIRED # Site ID is REQUIRED
if not site_id: 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: try:
site = Site.objects.get(id=site_id) site = Site.objects.get(id=site_id)
except Site.DoesNotExist: 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 # Sector ID is REQUIRED
if not sector_id: 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: try:
sector = Sector.objects.get(id=sector_id) sector = Sector.objects.get(id=sector_id)
if sector.site_id != site_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: 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 # Get account
account = getattr(request, 'account', None) account = getattr(request, 'account', None)
@@ -461,17 +540,21 @@ class KeywordViewSet(SiteSectorModelViewSet):
errors.append(f"Row {row_num}: {str(e)}") errors.append(f"Row {row_num}: {str(e)}")
continue continue
return Response({ return success_response(
'success': True, data={
'imported': imported_count, 'imported': imported_count,
'skipped': skipped_count, 'skipped': skipped_count,
'errors': errors[:10] if errors else [] # Limit errors to first 10 'errors': errors[:10] if errors else [] # Limit errors to first 10
}, status=status.HTTP_200_OK) },
request=request
)
except Exception as e: except Exception as e:
return Response({ return error_response(
'error': f'Failed to parse CSV: {str(e)}' error=f'Failed to parse CSV: {str(e)}',
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
@action(detail=False, methods=['post'], url_path='auto_cluster', url_name='auto_cluster') @action(detail=False, methods=['post'], url_path='auto_cluster', url_name='auto_cluster')
def auto_cluster(self, request): def auto_cluster(self, request):
@@ -497,16 +580,18 @@ class KeywordViewSet(SiteSectorModelViewSet):
# Validate basic input # Validate basic input
if not payload['ids']: if not payload['ids']:
return Response({ return error_response(
'success': False, error='No IDs provided',
'error': 'No IDs provided' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
if len(payload['ids']) > 20: if len(payload['ids']) > 20:
return Response({ return error_response(
'success': False, error='Maximum 20 keywords allowed for clustering',
'error': 'Maximum 20 keywords allowed for clustering' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
# Try to queue Celery task # Try to queue Celery task
try: try:
@@ -517,11 +602,11 @@ class KeywordViewSet(SiteSectorModelViewSet):
account_id=account_id account_id=account_id
) )
logger.info(f"Task queued: {task.id}") logger.info(f"Task queued: {task.id}")
return Response({ return success_response(
'success': True, data={'task_id': str(task.id)},
'task_id': str(task.id), message='Clustering started',
'message': 'Clustering started' request=request
}, status=status.HTTP_200_OK) )
else: else:
# Celery not available - execute synchronously # Celery not available - execute synchronously
logger.warning("Celery not available, executing synchronously") logger.warning("Celery not available, executing synchronously")
@@ -531,15 +616,16 @@ class KeywordViewSet(SiteSectorModelViewSet):
account_id=account_id account_id=account_id
) )
if result.get('success'): if result.get('success'):
return Response({ return success_response(
'success': True, data=result,
**result request=request
}, status=status.HTTP_200_OK) )
else: else:
return Response({ return error_response(
'success': False, error=result.get('error', 'Clustering failed'),
'error': result.get('error', 'Clustering failed') status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
except (KombuOperationalError, ConnectionError) as e: except (KombuOperationalError, ConnectionError) as e:
# Broker connection failed - fall back to synchronous execution # Broker connection failed - fall back to synchronous execution
logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}") logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}")
@@ -549,36 +635,42 @@ class KeywordViewSet(SiteSectorModelViewSet):
account_id=account_id account_id=account_id
) )
if result.get('success'): if result.get('success'):
return Response({ return success_response(
'success': True, data=result,
**result request=request
}, status=status.HTTP_200_OK) )
else: else:
return Response({ return error_response(
'success': False, error=result.get('error', 'Clustering failed'),
'error': result.get('error', 'Clustering failed') status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
except Exception as e: except Exception as e:
logger.error(f"Error in auto_cluster: {str(e)}", exc_info=True) logger.error(f"Error in auto_cluster: {str(e)}", exc_info=True)
return Response({ return error_response(
'success': False, error=str(e),
'error': str(e) status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in auto_cluster: {str(e)}", exc_info=True) logger.error(f"Unexpected error in auto_cluster: {str(e)}", exc_info=True)
return Response({ return error_response(
'success': False, error=f'Unexpected error: {str(e)}',
'error': f'Unexpected error: {str(e)}' status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
class ClusterViewSet(SiteSectorModelViewSet): class ClusterViewSet(SiteSectorModelViewSet):
""" """
ViewSet for managing clusters with CRUD operations ViewSet for managing clusters with CRUD operations
Unified API Standard v1.0 compliant
""" """
queryset = Clusters.objects.all() queryset = Clusters.objects.all()
serializer_class = ClusterSerializer serializer_class = ClusterSerializer
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
throttle_scope = 'planner'
throttle_classes = [DebugScopedRateThrottle]
# DRF filtering configuration # DRF filtering configuration
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
@@ -719,12 +811,16 @@ class ClusterViewSet(SiteSectorModelViewSet):
"""Bulk delete clusters""" """Bulk delete clusters"""
ids = request.data.get('ids', []) ids = request.data.get('ids', [])
if not 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() queryset = self.get_queryset()
deleted_count, _ = queryset.filter(id__in=ids).delete() 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') @action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas')
def auto_generate_ideas(self, request): def auto_generate_ideas(self, request):
@@ -749,16 +845,18 @@ class ClusterViewSet(SiteSectorModelViewSet):
# Validate basic input # Validate basic input
if not payload['ids']: if not payload['ids']:
return Response({ return error_response(
'success': False, error='No IDs provided',
'error': 'No IDs provided' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
if len(payload['ids']) > 10: if len(payload['ids']) > 10:
return Response({ return error_response(
'success': False, error='Maximum 10 clusters allowed for idea generation',
'error': 'Maximum 10 clusters allowed for idea generation' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
# Try to queue Celery task # Try to queue Celery task
try: try:
@@ -769,11 +867,11 @@ class ClusterViewSet(SiteSectorModelViewSet):
account_id=account_id account_id=account_id
) )
logger.info(f"Task queued: {task.id}") logger.info(f"Task queued: {task.id}")
return Response({ return success_response(
'success': True, data={'task_id': str(task.id)},
'task_id': str(task.id), message='Idea generation started',
'message': 'Idea generation started' request=request
}, status=status.HTTP_200_OK) )
else: else:
# Celery not available - execute synchronously # Celery not available - execute synchronously
logger.warning("Celery not available, executing synchronously") logger.warning("Celery not available, executing synchronously")
@@ -783,15 +881,16 @@ class ClusterViewSet(SiteSectorModelViewSet):
account_id=account_id account_id=account_id
) )
if result.get('success'): if result.get('success'):
return Response({ return success_response(
'success': True, data=result,
**result request=request
}, status=status.HTTP_200_OK) )
else: else:
return Response({ return error_response(
'success': False, error=result.get('error', 'Idea generation failed'),
'error': result.get('error', 'Idea generation failed') status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
except (KombuOperationalError, ConnectionError) as e: except (KombuOperationalError, ConnectionError) as e:
# Broker connection failed - fall back to synchronous execution # Broker connection failed - fall back to synchronous execution
logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}") logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}")
@@ -801,27 +900,30 @@ class ClusterViewSet(SiteSectorModelViewSet):
account_id=account_id account_id=account_id
) )
if result.get('success'): if result.get('success'):
return Response({ return success_response(
'success': True, data=result,
**result request=request
}, status=status.HTTP_200_OK) )
else: else:
return Response({ return error_response(
'success': False, error=result.get('error', 'Idea generation failed'),
'error': result.get('error', 'Idea generation failed') status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
except Exception as e: except Exception as e:
logger.error(f"Error in auto_generate_ideas: {str(e)}", exc_info=True) logger.error(f"Error in auto_generate_ideas: {str(e)}", exc_info=True)
return Response({ return error_response(
'success': False, error=str(e),
'error': str(e) status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in auto_generate_ideas: {str(e)}", exc_info=True) logger.error(f"Unexpected error in auto_generate_ideas: {str(e)}", exc_info=True)
return Response({ return error_response(
'success': False, error=f'Unexpected error: {str(e)}',
'error': f'Unexpected error: {str(e)}' status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
""" """
@@ -842,16 +944,22 @@ class ClusterViewSet(SiteSectorModelViewSet):
cluster_list = list(queryset) cluster_list = list(queryset)
ClusterSerializer.prefetch_keyword_stats(cluster_list) ClusterSerializer.prefetch_keyword_stats(cluster_list)
serializer = self.get_serializer(cluster_list, many=True) serializer = self.get_serializer(cluster_list, many=True)
return Response(serializer.data) return success_response(
data=serializer.data,
request=request
)
class ContentIdeasViewSet(SiteSectorModelViewSet): class ContentIdeasViewSet(SiteSectorModelViewSet):
""" """
ViewSet for managing content ideas with CRUD operations ViewSet for managing content ideas with CRUD operations
Unified API Standard v1.0 compliant
""" """
queryset = ContentIdeas.objects.all() queryset = ContentIdeas.objects.all()
serializer_class = ContentIdeasSerializer 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 # DRF filtering configuration
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
@@ -919,19 +1027,27 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
"""Bulk delete content ideas""" """Bulk delete content ideas"""
ids = request.data.get('ids', []) ids = request.data.get('ids', [])
if not 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() queryset = self.get_queryset()
deleted_count, _ = queryset.filter(id__in=ids).delete() 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') @action(detail=False, methods=['post'], url_path='bulk_queue_to_writer', url_name='bulk_queue_to_writer')
def bulk_queue_to_writer(self, request): def bulk_queue_to_writer(self, request):
"""Queue ideas to writer by creating Tasks""" """Queue ideas to writer by creating Tasks"""
ids = request.data.get('ids', []) ids = request.data.get('ids', [])
if not 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() queryset = self.get_queryset()
ideas = queryset.filter(id__in=ids, status='new') # Only queue 'new' ideas ideas = queryset.filter(id__in=ids, status='new') # Only queue 'new' ideas
@@ -958,11 +1074,13 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
idea.status = 'scheduled' idea.status = 'scheduled'
idea.save() idea.save()
return Response({ return success_response(
'success': True, data={
'created_count': len(created_tasks), 'created_count': len(created_tasks),
'task_ids': 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 # REMOVED: generate_idea action - idea generation function removed

View File

@@ -1,5 +1,6 @@
""" """
Integration settings views - for OpenAI, Runware, GSC integrations Integration settings views - for OpenAI, Runware, GSC integrations
Unified API Standard v1.0 compliant
""" """
import logging import logging
from rest_framework import viewsets, status from rest_framework import viewsets, status
@@ -7,6 +8,8 @@ from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from django.db import transaction from django.db import transaction
from igny8_core.api.base import AccountModelViewSet 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 from django.conf import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,18 +23,24 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
""" """
permission_classes = [] # Allow any for now permission_classes = [] # Allow any for now
throttle_scope = 'system_admin'
throttle_classes = [DebugScopedRateThrottle]
def list(self, request): def list(self, request):
"""List all integrations - for debugging URL patterns""" """List all integrations - for debugging URL patterns"""
logger.info("[IntegrationSettingsViewSet] list() called") logger.info("[IntegrationSettingsViewSet] list() called")
return Response({ return success_response(
'message': 'IntegrationSettingsViewSet is working', data={
'available_endpoints': [ 'message': 'IntegrationSettingsViewSet is working',
'GET /api/v1/system/settings/integrations/<pk>/', 'available_endpoints': [
'POST /api/v1/system/settings/integrations/<pk>/save/', 'GET /api/v1/system/settings/integrations/<pk>/',
'POST /api/v1/system/settings/integrations/<pk>/test/', 'POST /api/v1/system/settings/integrations/<pk>/save/',
'POST /api/v1/system/settings/integrations/<pk>/generate/', 'POST /api/v1/system/settings/integrations/<pk>/test/',
] 'POST /api/v1/system/settings/integrations/<pk>/generate/',
}) ]
},
request=request
)
def retrieve(self, request, pk=None): def retrieve(self, request, pk=None):
"""Get integration settings - GET /api/v1/system/settings/integrations/{pk}/""" """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)}") 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: 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 # Get API key and config from request or saved settings
config = request.data.get('config', {}) if isinstance(request.data.get('config'), dict) else {} 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: if not api_key:
logger.error(f"[test_connection] No API key found in request or saved settings") logger.error(f"[test_connection] No API key found in request or saved settings")
return Response({ return error_response(
'success': False, error='API key is required',
'error': 'API key is required' status_code=status.HTTP_400_BAD_REQUEST,
}, status=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})") logger.info(f"[test_connection] Testing {integration_type} connection with API key (length={len(api_key) if api_key else 0})")
try: try:
@@ -120,19 +134,21 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
elif integration_type == 'runware': elif integration_type == 'runware':
return self._test_runware(api_key, request) return self._test_runware(api_key, request)
else: else:
return Response({ return error_response(
'success': False, error=f'Validation not supported for {integration_type}',
'error': f'Validation not supported for {integration_type}' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
except Exception as e: except Exception as e:
logger.error(f"Error testing {integration_type} connection: {str(e)}", exc_info=True) logger.error(f"Error testing {integration_type} connection: {str(e)}", exc_info=True)
import traceback import traceback
error_trace = traceback.format_exc() error_trace = traceback.format_exc()
logger.error(f"Full traceback: {error_trace}") logger.error(f"Full traceback: {error_trace}")
return Response({ return error_response(
'success': False, error=str(e),
'error': str(e) status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
def _test_openai(self, api_key: str, config: dict = None): 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)}") 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: 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 # Ensure config is a dict
config = dict(request.data) if hasattr(request.data, 'dict') else (request.data if isinstance(request.data, dict) else {}) 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: if not account:
logger.error(f"[save_settings] No account found after all fallbacks") 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})") 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] Settings updated successfully")
logger.info(f"[save_settings] Successfully saved settings for {integration_type}") logger.info(f"[save_settings] Successfully saved settings for {integration_type}")
return Response({ return success_response(
'success': True, data={'config': config},
'message': f'{integration_type.upper()} settings saved successfully' message=f'{integration_type.upper()} settings saved successfully',
}) request=request
)
except Exception as e: except Exception as e:
logger.error(f"Error saving integration settings for {integration_type}: {str(e)}", exc_info=True) 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 integration_type = pk
if not integration_type: if not integration_type:
return Response({ return error_response(
'success': False, error='Integration type is required',
'error': 'Integration type is required' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
try: try:
# Get account - try multiple methods (same as save_settings) # Get account - try multiple methods (same as save_settings)
@@ -695,26 +721,27 @@ class IntegrationSettingsViewSet(viewsets.ViewSet):
integration_type=integration_type, integration_type=integration_type,
account=account account=account
) )
return Response({ return success_response(
'success': True, data=integration_settings.config,
'data': integration_settings.config request=request
}) )
except IntegrationSettings.DoesNotExist: except IntegrationSettings.DoesNotExist:
pass pass
except Exception as e: except Exception as e:
logger.error(f"Error getting account-specific settings: {e}", exc_info=True) logger.error(f"Error getting account-specific settings: {e}", exc_info=True)
# Return empty config if no settings found # Return empty config if no settings found
return Response({ return success_response(
'success': True, data={},
'data': {} request=request
}) )
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in get_settings for {integration_type}: {e}", exc_info=True) logger.error(f"Unexpected error in get_settings for {integration_type}: {e}", exc_info=True)
return Response({ return error_response(
'success': False, error=f'Failed to get settings: {str(e)}',
'error': f'Failed to get settings: {str(e)}' status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
@action(detail=False, methods=['get'], url_path='image_generation', url_name='image_generation_settings') @action(detail=False, methods=['get'], url_path='image_generation', url_name='image_generation_settings')
def get_image_generation_settings(self, request): def get_image_generation_settings(self, request):

View File

@@ -13,6 +13,10 @@ from django.core.cache import cache
from django.utils import timezone from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from igny8_core.api.base import AccountModelViewSet 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 .models import AIPrompt, AuthorProfile, Strategy
from .serializers import AIPromptSerializer, AuthorProfileSerializer, StrategySerializer from .serializers import AIPromptSerializer, AuthorProfileSerializer, StrategySerializer
@@ -22,10 +26,14 @@ logger = logging.getLogger(__name__)
class AIPromptViewSet(AccountModelViewSet): class AIPromptViewSet(AccountModelViewSet):
""" """
ViewSet for managing AI prompts ViewSet for managing AI prompts
Unified API Standard v1.0 compliant
""" """
queryset = AIPrompt.objects.all() queryset = AIPrompt.objects.all()
serializer_class = AIPromptSerializer 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): def get_queryset(self):
"""Get prompts for the current account""" """Get prompts for the current account"""
@@ -37,28 +45,39 @@ class AIPromptViewSet(AccountModelViewSet):
try: try:
prompt = self.get_queryset().get(prompt_type=prompt_type) prompt = self.get_queryset().get(prompt_type=prompt_type)
serializer = self.get_serializer(prompt) serializer = self.get_serializer(prompt)
return Response(serializer.data) return success_response(data=serializer.data, request=request)
except AIPrompt.DoesNotExist: except AIPrompt.DoesNotExist:
# Return default if not found # Return default if not found
from .utils import get_default_prompt from .utils import get_default_prompt
default_value = get_default_prompt(prompt_type) default_value = get_default_prompt(prompt_type)
return Response({ return success_response(
'prompt_type': prompt_type, data={
'prompt_value': default_value, 'prompt_type': prompt_type,
'default_prompt': default_value, 'prompt_value': default_value,
'is_active': True, 'default_prompt': default_value,
}) 'is_active': True,
},
request=request
)
@action(detail=False, methods=['post'], url_path='save', url_name='save') @action(detail=False, methods=['post'], url_path='save', url_name='save')
def save_prompt(self, request): 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_type = request.data.get('prompt_type')
prompt_value = request.data.get('prompt_value') prompt_value = request.data.get('prompt_value')
if not 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
)
if prompt_value is None: 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 # Get account - try multiple methods
account = getattr(request, 'account', None) account = getattr(request, 'account', None)
@@ -78,7 +97,11 @@ class AIPromptViewSet(AccountModelViewSet):
pass pass
if not account: 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 # Get default prompt value if creating new
from .utils import get_default_prompt from .utils import get_default_prompt
@@ -100,11 +123,11 @@ class AIPromptViewSet(AccountModelViewSet):
prompt.save() prompt.save()
serializer = self.get_serializer(prompt) serializer = self.get_serializer(prompt)
return Response({ return success_response(
'success': True, data=serializer.data,
'data': serializer.data, message=f'{prompt.get_prompt_type_display()} saved successfully',
'message': f'{prompt.get_prompt_type_display()} saved successfully' request=request
}) )
@action(detail=False, methods=['post'], url_path='reset', url_name='reset') @action(detail=False, methods=['post'], url_path='reset', url_name='reset')
def reset_prompt(self, request): def reset_prompt(self, request):
@@ -112,7 +135,11 @@ class AIPromptViewSet(AccountModelViewSet):
prompt_type = request.data.get('prompt_type') prompt_type = request.data.get('prompt_type')
if not 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) # Get account - try multiple methods (same as integration_views)
account = getattr(request, 'account', None) account = getattr(request, 'account', None)
@@ -132,7 +159,11 @@ class AIPromptViewSet(AccountModelViewSet):
pass pass
if not account: 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 # Get default prompt
from .utils import get_default_prompt from .utils import get_default_prompt
@@ -154,19 +185,22 @@ class AIPromptViewSet(AccountModelViewSet):
prompt.save() prompt.save()
serializer = self.get_serializer(prompt) serializer = self.get_serializer(prompt)
return Response({ return success_response(
'success': True, data=serializer.data,
'data': serializer.data, message=f'{prompt.get_prompt_type_display()} reset to default',
'message': f'{prompt.get_prompt_type_display()} reset to default' request=request
}) )
class AuthorProfileViewSet(AccountModelViewSet): class AuthorProfileViewSet(AccountModelViewSet):
""" """
ViewSet for managing Author Profiles ViewSet for managing Author Profiles
Unified API Standard v1.0 compliant
""" """
queryset = AuthorProfile.objects.all() queryset = AuthorProfile.objects.all()
serializer_class = AuthorProfileSerializer serializer_class = AuthorProfileSerializer
throttle_scope = 'system'
throttle_classes = [DebugScopedRateThrottle]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
search_fields = ['name', 'description', 'tone'] search_fields = ['name', 'description', 'tone']
@@ -178,9 +212,12 @@ class AuthorProfileViewSet(AccountModelViewSet):
class StrategyViewSet(AccountModelViewSet): class StrategyViewSet(AccountModelViewSet):
""" """
ViewSet for managing Strategies ViewSet for managing Strategies
Unified API Standard v1.0 compliant
""" """
queryset = Strategy.objects.all() queryset = Strategy.objects.all()
serializer_class = StrategySerializer serializer_class = StrategySerializer
throttle_scope = 'system'
throttle_classes = [DebugScopedRateThrottle]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
search_fields = ['name', 'description'] search_fields = ['name', 'description']
@@ -190,7 +227,7 @@ class StrategyViewSet(AccountModelViewSet):
@api_view(['GET']) @api_view(['GET'])
@permission_classes([AllowAny]) # Adjust permissions as needed @permission_classes([AllowAny]) # Public endpoint for monitoring
def system_status(request): def system_status(request):
""" """
Comprehensive system status endpoint for monitoring Comprehensive system status endpoint for monitoring
@@ -457,7 +494,7 @@ def system_status(request):
logger.error(f"Error getting module statistics: {str(e)}") logger.error(f"Error getting module statistics: {str(e)}")
status_data['modules'] = {'error': str(e)} status_data['modules'] = {'error': str(e)}
return Response(status_data) return success_response(data=status_data, request=request)
@api_view(['GET']) @api_view(['GET'])
@@ -469,19 +506,31 @@ def get_request_metrics(request, request_id):
""" """
# Check if user is admin/developer # Check if user is admin/developer
if not request.user.is_authenticated: 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()): 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 # Get metrics from cache
from django.core.cache import cache from django.core.cache import cache
metrics = cache.get(f"resource_tracking_{request_id}") metrics = cache.get(f"resource_tracking_{request_id}")
if not metrics: 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']) @api_view(['POST'])
@@ -504,10 +553,11 @@ def gitea_webhook(request):
# Only process push events # Only process push events
if event_type != 'push': if event_type != 'push':
return Response({ return success_response(
'status': 'ignored', data={'status': 'ignored'},
'message': f'Event type {event_type} is not processed' message=f'Event type {event_type} is not processed',
}, status=http_status.HTTP_200_OK) request=request
)
# Extract repository information # Extract repository information
repository = payload.get('repository', {}) repository = payload.get('repository', {})
@@ -518,10 +568,11 @@ def gitea_webhook(request):
# Only process pushes to main branch # Only process pushes to main branch
if ref != 'refs/heads/main': if ref != 'refs/heads/main':
logger.info(f"[Webhook] Ignoring push to {ref}, only processing main branch") logger.info(f"[Webhook] Ignoring push to {ref}, only processing main branch")
return Response({ return success_response(
'status': 'ignored', data={'status': 'ignored'},
'message': f'Push to {ref} ignored, only main branch is processed' message=f'Push to {ref} ignored, only main branch is processed',
}, status=http_status.HTTP_200_OK) request=request
)
# Get commit information # Get commit information
commits = payload.get('commits', []) commits = payload.get('commits', [])
@@ -636,30 +687,35 @@ def gitea_webhook(request):
deployment_error = str(deploy_error) deployment_error = str(deploy_error)
logger.error(f"[Webhook] Deployment error: {deploy_error}", exc_info=True) logger.error(f"[Webhook] Deployment error: {deploy_error}", exc_info=True)
return Response({ return success_response(
'status': 'success' if deployment_success else 'partial', data={
'message': 'Webhook received and processed', 'status': 'success' if deployment_success else 'partial',
'repository': repo_full_name, 'repository': repo_full_name,
'branch': ref, 'branch': ref,
'commits': commit_count, 'commits': commit_count,
'pusher': pusher, 'pusher': pusher,
'event': event_type, 'event': event_type,
'deployment': { 'deployment': {
'success': deployment_success, 'success': deployment_success,
'error': deployment_error 'error': deployment_error
} }
}, status=http_status.HTTP_200_OK) },
message='Webhook received and processed',
request=request
)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"[Webhook] Invalid JSON payload: {e}") logger.error(f"[Webhook] Invalid JSON payload: {e}")
return Response({ return error_response(
'status': 'error', error='Invalid JSON payload',
'message': 'Invalid JSON payload' status_code=http_status.HTTP_400_BAD_REQUEST,
}, status=http_status.HTTP_400_BAD_REQUEST) request=request
)
except Exception as e: except Exception as e:
logger.error(f"[Webhook] Error processing webhook: {e}", exc_info=True) logger.error(f"[Webhook] Error processing webhook: {e}", exc_info=True)
return Response({ return error_response(
'status': 'error', error=str(e),
'message': str(e) status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=http_status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)

View File

@@ -6,6 +6,8 @@ from django.db import transaction, models
from django.db.models import Q from django.db.models import Q
from igny8_core.api.base import SiteSectorModelViewSet from igny8_core.api.base import SiteSectorModelViewSet
from igny8_core.api.pagination import CustomPageNumberPagination 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 .models import Tasks, Images, Content
from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer
@@ -13,10 +15,13 @@ from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer
class TasksViewSet(SiteSectorModelViewSet): class TasksViewSet(SiteSectorModelViewSet):
""" """
ViewSet for managing tasks with CRUD operations ViewSet for managing tasks with CRUD operations
Unified API Standard v1.0 compliant
""" """
queryset = Tasks.objects.select_related('content_record') queryset = Tasks.objects.select_related('content_record')
serializer_class = TasksSerializer serializer_class = TasksSerializer
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
throttle_scope = 'writer'
throttle_classes = [DebugScopedRateThrottle]
# DRF filtering configuration # DRF filtering configuration
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
@@ -84,12 +89,16 @@ class TasksViewSet(SiteSectorModelViewSet):
"""Bulk delete tasks""" """Bulk delete tasks"""
ids = request.data.get('ids', []) ids = request.data.get('ids', [])
if not 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() queryset = self.get_queryset()
deleted_count, _ = queryset.filter(id__in=ids).delete() 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') @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
def bulk_update(self, request): def bulk_update(self, request):
@@ -98,14 +107,22 @@ class TasksViewSet(SiteSectorModelViewSet):
status_value = request.data.get('status') status_value = request.data.get('status')
if not 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
)
if not status_value: 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() queryset = self.get_queryset()
updated_count = queryset.filter(id__in=ids).update(status=status_value) 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') @action(detail=False, methods=['post'], url_path='auto_generate_content', url_name='auto_generate_content')
def auto_generate_content(self, request): def auto_generate_content(self, request):
@@ -120,17 +137,19 @@ class TasksViewSet(SiteSectorModelViewSet):
ids = request.data.get('ids', []) ids = request.data.get('ids', [])
if not ids: if not ids:
logger.warning("auto_generate_content: No IDs provided") logger.warning("auto_generate_content: No IDs provided")
return Response({ return error_response(
'error': 'No IDs provided', error='No IDs provided',
'type': 'ValidationError' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
if len(ids) > 10: if len(ids) > 10:
logger.warning(f"auto_generate_content: Too many IDs provided: {len(ids)}") logger.warning(f"auto_generate_content: Too many IDs provided: {len(ids)}")
return Response({ return error_response(
'error': 'Maximum 10 tasks allowed for content generation', error='Maximum 10 tasks allowed for content generation',
'type': 'ValidationError' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
logger.info(f"auto_generate_content: Processing {len(ids)} task IDs: {ids}") logger.info(f"auto_generate_content: Processing {len(ids)} task IDs: {ids}")
@@ -151,11 +170,11 @@ class TasksViewSet(SiteSectorModelViewSet):
if existing_count == 0: if existing_count == 0:
logger.error(f"auto_generate_content: No tasks found for IDs: {ids}") logger.error(f"auto_generate_content: No tasks found for IDs: {ids}")
return Response({ return error_response(
'error': f'No tasks found for the provided IDs: {ids}', error=f'No tasks found for the provided IDs: {ids}',
'type': 'NotFound', status_code=status.HTTP_404_NOT_FOUND,
'requested_ids': ids request=request
}, status=status.HTTP_404_NOT_FOUND) )
if existing_count < len(ids): if existing_count < len(ids):
missing_ids = set(ids) - set(existing_ids) missing_ids = set(ids) - set(existing_ids)
@@ -171,11 +190,11 @@ class TasksViewSet(SiteSectorModelViewSet):
logger.error(f" - Account ID: {account_id}") logger.error(f" - Account ID: {account_id}")
logger.error("=" * 80, exc_info=True) logger.error("=" * 80, exc_info=True)
return Response({ return error_response(
'error': f'Database error while querying tasks: {str(db_error)}', error=f'Database error while querying tasks: {str(db_error)}',
'type': 'OperationalError', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
'details': 'Failed to retrieve tasks from database. Please check database connection and try again.' request=request
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) )
# Try to queue Celery task, fall back to synchronous if Celery not available # Try to queue Celery task, fall back to synchronous if Celery not available
try: try:
@@ -192,11 +211,11 @@ class TasksViewSet(SiteSectorModelViewSet):
account_id=account_id account_id=account_id
) )
logger.info(f"auto_generate_content: Celery task queued successfully: {task.id}") logger.info(f"auto_generate_content: Celery task queued successfully: {task.id}")
return Response({ return success_response(
'success': True, data={'task_id': str(task.id)},
'task_id': str(task.id), message='Content generation started',
'message': 'Content generation started' request=request
}, status=status.HTTP_200_OK) )
except KombuOperationalError as celery_error: except KombuOperationalError as celery_error:
logger.error("=" * 80) logger.error("=" * 80)
logger.error("CELERY ERROR: Failed to queue task") logger.error("CELERY ERROR: Failed to queue task")
@@ -206,10 +225,11 @@ class TasksViewSet(SiteSectorModelViewSet):
logger.error(f" - Account ID: {account_id}") logger.error(f" - Account ID: {account_id}")
logger.error("=" * 80, exc_info=True) logger.error("=" * 80, exc_info=True)
return Response({ return error_response(
'error': 'Task queue unavailable. Please try again.', error='Task queue unavailable. Please try again.',
'type': 'QueueError' status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
}, status=status.HTTP_503_SERVICE_UNAVAILABLE) request=request
)
except Exception as celery_error: except Exception as celery_error:
logger.error("=" * 80) logger.error("=" * 80)
logger.error("CELERY ERROR: Failed to queue task") logger.error("CELERY ERROR: Failed to queue task")
@@ -227,16 +247,17 @@ class TasksViewSet(SiteSectorModelViewSet):
account_id=account_id account_id=account_id
) )
if result.get('success'): if result.get('success'):
return Response({ return success_response(
'success': True, data={'tasks_updated': result.get('count', 0)},
'tasks_updated': result.get('count', 0), message='Content generated successfully (synchronous)',
'message': 'Content generated successfully (synchronous)' request=request
}, status=status.HTTP_200_OK) )
else: else:
return Response({ return error_response(
'error': result.get('error', 'Content generation failed'), error=result.get('error', 'Content generation failed'),
'type': 'TaskExecutionError' status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
else: else:
# Celery not available - execute synchronously # Celery not available - execute synchronously
logger.info(f"auto_generate_content: Executing synchronously (Celery not available)") logger.info(f"auto_generate_content: Executing synchronously (Celery not available)")
@@ -247,17 +268,18 @@ class TasksViewSet(SiteSectorModelViewSet):
) )
if result.get('success'): if result.get('success'):
logger.info(f"auto_generate_content: Synchronous execution successful: {result.get('count', 0)} tasks updated") logger.info(f"auto_generate_content: Synchronous execution successful: {result.get('count', 0)} tasks updated")
return Response({ return success_response(
'success': True, data={'tasks_updated': result.get('count', 0)},
'tasks_updated': result.get('count', 0), message='Content generated successfully',
'message': 'Content generated successfully' request=request
}, status=status.HTTP_200_OK) )
else: else:
logger.error(f"auto_generate_content: Synchronous execution failed: {result.get('error', 'Unknown error')}") logger.error(f"auto_generate_content: Synchronous execution failed: {result.get('error', 'Unknown error')}")
return Response({ return error_response(
'error': result.get('error', 'Content generation failed'), error=result.get('error', 'Content generation failed'),
'type': 'TaskExecutionError' status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
except ImportError as import_error: except ImportError as import_error:
logger.error(f"auto_generate_content: ImportError - tasks module not available: {str(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]') 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)") logger.info(f"auto_generate_content: Updated {updated_count} tasks (AI generation not available)")
return Response({ return success_response(
'updated_count': updated_count, data={'updated_count': updated_count},
'message': 'Tasks updated (AI generation not available)' message='Tasks updated (AI generation not available)',
}, status=status.HTTP_200_OK) request=request
)
except (OperationalError, DatabaseError) as db_error: except (OperationalError, DatabaseError) as db_error:
logger.error("=" * 80) logger.error("=" * 80)
logger.error("DATABASE ERROR: Failed to update tasks") logger.error("DATABASE ERROR: Failed to update tasks")
logger.error(f" - Error type: {type(db_error).__name__}") logger.error(f" - Error type: {type(db_error).__name__}")
logger.error(f" - Error message: {str(db_error)}") logger.error(f" - Error message: {str(db_error)}")
logger.error("=" * 80, exc_info=True) logger.error("=" * 80, exc_info=True)
return Response({ return error_response(
'error': f'Database error while updating tasks: {str(db_error)}', error=f'Database error while updating tasks: {str(db_error)}',
'type': 'OperationalError', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
'details': 'Failed to update tasks in database. Please check database connection.' request=request
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) )
except (OperationalError, DatabaseError) as db_error: except (OperationalError, DatabaseError) as db_error:
logger.error("=" * 80) logger.error("=" * 80)
@@ -293,11 +316,11 @@ class TasksViewSet(SiteSectorModelViewSet):
logger.error(f" - Account ID: {account_id}") logger.error(f" - Account ID: {account_id}")
logger.error("=" * 80, exc_info=True) logger.error("=" * 80, exc_info=True)
return Response({ return error_response(
'error': f'Database error during content generation: {str(db_error)}', error=f'Database error during content generation: {str(db_error)}',
'type': 'OperationalError', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
'details': 'A database operation failed. This may be due to connection issues, constraint violations, or data integrity problems. Check the logs for more details.' request=request
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) )
except IntegrityError as integrity_error: except IntegrityError as integrity_error:
logger.error("=" * 80) logger.error("=" * 80)
@@ -306,18 +329,19 @@ class TasksViewSet(SiteSectorModelViewSet):
logger.error(f" - Task IDs: {ids}") logger.error(f" - Task IDs: {ids}")
logger.error("=" * 80, exc_info=True) logger.error("=" * 80, exc_info=True)
return Response({ return error_response(
'error': f'Data integrity error: {str(integrity_error)}', error=f'Data integrity error: {str(integrity_error)}',
'type': 'IntegrityError', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
'details': 'The operation violated database constraints. This may indicate missing required relationships or invalid data.' request=request
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) )
except ValidationError as validation_error: except ValidationError as validation_error:
logger.error(f"auto_generate_content: ValidationError: {str(validation_error)}") logger.error(f"auto_generate_content: ValidationError: {str(validation_error)}")
return Response({ return error_response(
'error': f'Validation error: {str(validation_error)}', error=f'Validation error: {str(validation_error)}',
'type': 'ValidationError' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
except Exception as e: except Exception as e:
logger.error("=" * 80) logger.error("=" * 80)
@@ -328,11 +352,11 @@ class TasksViewSet(SiteSectorModelViewSet):
logger.error(f" - Account ID: {account_id}") logger.error(f" - Account ID: {account_id}")
logger.error("=" * 80, exc_info=True) logger.error("=" * 80, exc_info=True)
return Response({ return error_response(
'error': f'Unexpected error: {str(e)}', error=f'Unexpected error: {str(e)}',
'type': type(e).__name__, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
'details': 'An unexpected error occurred. Please check the logs for more details.' request=request
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) )
except Exception as outer_error: except Exception as outer_error:
logger.error("=" * 80) logger.error("=" * 80)
@@ -341,10 +365,11 @@ class TasksViewSet(SiteSectorModelViewSet):
logger.error(f" - Error message: {str(outer_error)}") logger.error(f" - Error message: {str(outer_error)}")
logger.error("=" * 80, exc_info=True) logger.error("=" * 80, exc_info=True)
return Response({ return error_response(
'error': f'Critical error: {str(outer_error)}', error=f'Critical error: {str(outer_error)}',
'type': type(outer_error).__name__ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
class ImagesViewSet(SiteSectorModelViewSet): class ImagesViewSet(SiteSectorModelViewSet):
@@ -383,30 +408,38 @@ class ImagesViewSet(SiteSectorModelViewSet):
try: try:
image = Images.objects.get(pk=pk) image = Images.objects.get(pk=pk)
except Images.DoesNotExist: except Images.DoesNotExist:
return Response({ return error_response(
'error': 'Image not found' error='Image not found',
}, status=status.HTTP_404_NOT_FOUND) status_code=status.HTTP_404_NOT_FOUND,
request=request
)
# Check if image has a local path # Check if image has a local path
if not image.image_path: if not image.image_path:
return Response({ return error_response(
'error': 'No local file path available for this image' error='No local file path available for this image',
}, status=status.HTTP_404_NOT_FOUND) status_code=status.HTTP_404_NOT_FOUND,
request=request
)
file_path = image.image_path file_path = image.image_path
# Verify file exists at the saved path # Verify file exists at the saved path
if not os.path.exists(file_path): if not os.path.exists(file_path):
logger.error(f"[serve_image_file] Image {pk} - File not found at saved path: {file_path}") logger.error(f"[serve_image_file] Image {pk} - File not found at saved path: {file_path}")
return Response({ return error_response(
'error': f'Image file not found at: {file_path}' error=f'Image file not found at: {file_path}',
}, status=status.HTTP_404_NOT_FOUND) status_code=status.HTTP_404_NOT_FOUND,
request=request
)
# Check if file is readable # Check if file is readable
if not os.access(file_path, os.R_OK): if not os.access(file_path, os.R_OK):
return Response({ return error_response(
'error': 'Image file is not readable' error='Image file is not readable',
}, status=status.HTTP_403_FORBIDDEN) status_code=status.HTTP_403_FORBIDDEN,
request=request
)
# Determine content type from file extension # Determine content type from file extension
import mimetypes import mimetypes
@@ -422,31 +455,45 @@ class ImagesViewSet(SiteSectorModelViewSet):
filename=os.path.basename(file_path) filename=os.path.basename(file_path)
) )
except Exception as e: except Exception as e:
return Response({ return error_response(
'error': f'Failed to serve file: {str(e)}' error=f'Failed to serve file: {str(e)}',
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
except Images.DoesNotExist: except Images.DoesNotExist:
return Response({ return error_response(
'error': 'Image not found' error='Image not found',
}, status=status.HTTP_404_NOT_FOUND) status_code=status.HTTP_404_NOT_FOUND,
request=request
)
except Exception as e: except Exception as e:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.error(f"Error serving image file: {str(e)}", exc_info=True) logger.error(f"Error serving image file: {str(e)}", exc_info=True)
return Response({ return error_response(
'error': f'Failed to serve image: {str(e)}' error=f'Failed to serve image: {str(e)}',
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
@action(detail=False, methods=['post'], url_path='auto_generate', url_name='auto_generate_images') @action(detail=False, methods=['post'], url_path='auto_generate', url_name='auto_generate_images')
def auto_generate_images(self, request): def auto_generate_images(self, request):
"""Auto-generate images for tasks using AI""" """Auto-generate images for tasks using AI"""
task_ids = request.data.get('task_ids', []) task_ids = request.data.get('task_ids', [])
if not 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: 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 # Get account
account = getattr(request, 'account', None) account = getattr(request, 'account', None)
@@ -464,11 +511,11 @@ class ImagesViewSet(SiteSectorModelViewSet):
payload={'ids': task_ids}, payload={'ids': task_ids},
account_id=account_id account_id=account_id
) )
return Response({ return success_response(
'success': True, data={'task_id': str(task.id)},
'task_id': str(task.id), message='Image generation started',
'message': 'Image generation started' request=request
}, status=status.HTTP_200_OK) )
else: else:
# Celery not available - execute synchronously # Celery not available - execute synchronously
result = run_ai_task( result = run_ai_task(
@@ -477,33 +524,39 @@ class ImagesViewSet(SiteSectorModelViewSet):
account_id=account_id account_id=account_id
) )
if result.get('success'): if result.get('success'):
return Response({ return success_response(
'success': True, data={'images_created': result.get('count', 0)},
'images_created': result.get('count', 0), message=result.get('message', 'Image generation completed'),
'message': result.get('message', 'Image generation completed') request=request
}, status=status.HTTP_200_OK) )
else: else:
return Response({ return error_response(
'error': result.get('error', 'Image generation failed') error=result.get('error', 'Image generation failed'),
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
except KombuOperationalError as e: except KombuOperationalError as e:
return Response({ return error_response(
'error': 'Task queue unavailable. Please try again.', error='Task queue unavailable. Please try again.',
'type': 'QueueError' status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
}, status=status.HTTP_503_SERVICE_UNAVAILABLE) request=request
)
except ImportError: except ImportError:
# Tasks module not available # Tasks module not available
return Response({ return error_response(
'error': 'Image generation task not available' error='Image generation task not available',
}, status=status.HTTP_503_SERVICE_UNAVAILABLE) status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
request=request
)
except Exception as e: except Exception as e:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.error(f"Error queuing image generation task: {str(e)}", exc_info=True) logger.error(f"Error queuing image generation task: {str(e)}", exc_info=True)
return Response({ return error_response(
'error': f'Failed to start image generation: {str(e)}', error=f'Failed to start image generation: {str(e)}',
'type': 'TaskError' status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
@action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update') @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
def bulk_update(self, request): def bulk_update(self, request):
@@ -518,7 +571,11 @@ class ImagesViewSet(SiteSectorModelViewSet):
status_value = request.data.get('status') status_value = request.data.get('status')
if not status_value: 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() queryset = self.get_queryset()
@@ -534,13 +591,21 @@ class ImagesViewSet(SiteSectorModelViewSet):
Q(content=content) | Q(task=content.task) Q(content=content) | Q(task=content.task)
).update(status=status_value) ).update(status=status_value)
except Content.DoesNotExist: 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: elif image_ids:
updated_count = queryset.filter(id__in=image_ids).update(status=status_value) updated_count = queryset.filter(id__in=image_ids).update(status=status_value)
else: 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') @action(detail=False, methods=['get'], url_path='content_images', url_name='content_images')
def content_images(self, request): def content_images(self, request):
@@ -621,10 +686,13 @@ class ImagesViewSet(SiteSectorModelViewSet):
# Sort by content title # Sort by content title
grouped_data.sort(key=lambda x: x['content_title']) grouped_data.sort(key=lambda x: x['content_title'])
return Response({ return success_response(
'count': len(grouped_data), data={
'results': grouped_data 'count': len(grouped_data),
}, status=status.HTTP_200_OK) 'results': grouped_data
},
request=request
)
@action(detail=False, methods=['post'], url_path='generate_images', url_name='generate_images') @action(detail=False, methods=['post'], url_path='generate_images', url_name='generate_images')
def generate_images(self, request): def generate_images(self, request):
@@ -636,10 +704,11 @@ class ImagesViewSet(SiteSectorModelViewSet):
content_id = request.data.get('content_id') content_id = request.data.get('content_id')
if not image_ids: if not image_ids:
return Response({ return error_response(
'error': 'No image IDs provided', error='No image IDs provided',
'type': 'ValidationError' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
account_id = account.id if account else None account_id = account.id if account else None
@@ -651,11 +720,11 @@ class ImagesViewSet(SiteSectorModelViewSet):
account_id=account_id, account_id=account_id,
content_id=content_id content_id=content_id
) )
return Response({ return success_response(
'success': True, data={'task_id': str(task.id)},
'task_id': str(task.id), message='Image generation started',
'message': 'Image generation started' request=request
}, status=status.HTTP_200_OK) )
else: else:
# Fallback to synchronous execution (for testing) # Fallback to synchronous execution (for testing)
result = process_image_generation_queue( result = process_image_generation_queue(
@@ -663,21 +732,25 @@ class ImagesViewSet(SiteSectorModelViewSet):
account_id=account_id, account_id=account_id,
content_id=content_id content_id=content_id
) )
return Response(result, status=status.HTTP_200_OK) return success_response(data=result, request=request)
except Exception as e: except Exception as e:
logger.error(f"[generate_images] Error: {str(e)}", exc_info=True) logger.error(f"[generate_images] Error: {str(e)}", exc_info=True)
return Response({ return error_response(
'error': str(e), error=str(e),
'type': 'ExecutionError' status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
class ContentViewSet(SiteSectorModelViewSet): class ContentViewSet(SiteSectorModelViewSet):
""" """
ViewSet for managing task content ViewSet for managing task content
Unified API Standard v1.0 compliant
""" """
queryset = Content.objects.all() queryset = Content.objects.all()
serializer_class = ContentSerializer serializer_class = ContentSerializer
pagination_class = CustomPageNumberPagination pagination_class = CustomPageNumberPagination
throttle_scope = 'writer'
throttle_classes = [DebugScopedRateThrottle]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
search_fields = ['title', 'meta_title', 'primary_keyword'] search_fields = ['title', 'meta_title', 'primary_keyword']
@@ -702,10 +775,11 @@ class ContentViewSet(SiteSectorModelViewSet):
ids = request.data.get('ids', []) ids = request.data.get('ids', [])
if not ids: if not ids:
return Response({ return error_response(
'error': 'No IDs provided', error='No IDs provided',
'type': 'ValidationError' status_code=status.HTTP_400_BAD_REQUEST,
}, status=status.HTTP_400_BAD_REQUEST) request=request
)
account_id = account.id if account else None account_id = account.id if account else None
@@ -717,11 +791,11 @@ class ContentViewSet(SiteSectorModelViewSet):
payload={'ids': ids}, payload={'ids': ids},
account_id=account_id account_id=account_id
) )
return Response({ return success_response(
'success': True, data={'task_id': str(task.id)},
'task_id': str(task.id), message='Image prompt generation started',
'message': 'Image prompt generation started' request=request
}, status=status.HTTP_200_OK) )
else: else:
# Fallback to synchronous execution # Fallback to synchronous execution
result = run_ai_task( result = run_ai_task(
@@ -730,19 +804,21 @@ class ContentViewSet(SiteSectorModelViewSet):
account_id=account_id account_id=account_id
) )
if result.get('success'): if result.get('success'):
return Response({ return success_response(
'success': True, data={'prompts_created': result.get('count', 0)},
'prompts_created': result.get('count', 0), message='Image prompts generated successfully',
'message': 'Image prompts generated successfully' request=request
}, status=status.HTTP_200_OK) )
else: else:
return Response({ return error_response(
'error': result.get('error', 'Image prompt generation failed'), error=result.get('error', 'Image prompt generation failed'),
'type': 'TaskExecutionError' status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)
except Exception as e: except Exception as e:
return Response({ return error_response(
'error': str(e), error=str(e),
'type': 'ExecutionError' status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) request=request
)

View File

@@ -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 # Set DEBUG=False via environment variable for production deployments
DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' 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 = [ ALLOWED_HOSTS = [
'*', # Allow all hosts for flexibility '*', # Allow all hosts for flexibility
'api.igny8.com', 'api.igny8.com',
@@ -73,6 +78,7 @@ MIDDLEWARE = [
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', '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 'igny8_core.auth.middleware.AccountContextMiddleware', # Multi-account support
# AccountContextMiddleware sets request.account from JWT # AccountContextMiddleware sets request.account from JWT
'igny8_core.middleware.resource_tracker.ResourceTrackingMiddleware', # Resource tracking for admin debug '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 'igny8_core.api.authentication.CSRFExemptSessionAuthentication', # Session auth without CSRF for API
'rest_framework.authentication.BasicAuthentication', # Enable basic auth as fallback '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 # CORS Configuration

View File

@@ -65,13 +65,13 @@ export default function UsageChartWidget() {
<div> <div>
<h4 className="text-sm font-semibold mb-2">By Operation</h4> <h4 className="text-sm font-semibold mb-2">By Operation</h4>
<div className="space-y-2"> <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"> <div key={op} className="flex justify-between items-center text-sm">
<span className="capitalize">{op.replace('_', ' ')}</span> <span className="capitalize">{op.replace('_', ' ')}</span>
<span className="font-medium">{stats.credits} credits (${(Number(stats.cost) || 0).toFixed(2)})</span> <span className="font-medium">{stats.credits} credits (${(Number(stats.cost) || 0).toFixed(2)})</span>
</div> </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 className="text-sm text-gray-500 dark:text-gray-400">No usage data available</div>
)} )}
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import PageMeta from "../../components/common/PageMeta"; import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard"; 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 { interface EndpointStatus {
endpoint: string; endpoint: string;
@@ -10,6 +10,8 @@ interface EndpointStatus {
responseTime?: number; responseTime?: number;
lastChecked?: string; lastChecked?: string;
error?: string; error?: string;
apiStatus?: 'healthy' | 'warning' | 'error'; // API endpoint status
dataStatus?: 'healthy' | 'warning' | 'error'; // Page data population status
} }
interface EndpointGroup { interface EndpointGroup {
@@ -18,6 +20,8 @@ interface EndpointGroup {
path: string; path: string;
method: string; method: string;
description: 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/", method: "GET", description: "List content" },
{ path: "/v1/writer/content/generate_image_prompts/", method: "POST", description: "Image prompts" }, { 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/", 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" }, { path: "/v1/writer/images/generate_images/", method: "POST", description: "AI images" },
], ],
}, },
@@ -71,6 +88,21 @@ const endpointGroups: EndpointGroup[] = [
name: "System & Billing", name: "System & Billing",
endpoints: [ endpoints: [
{ path: "/v1/system/prompts/", method: "GET", description: "List prompts" }, { 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/author-profiles/", method: "GET", description: "List author profiles" },
{ path: "/v1/system/strategies/", method: "GET", description: "List strategies" }, { path: "/v1/system/strategies/", method: "GET", description: "List strategies" },
{ path: "/v1/system/settings/integrations/1/test/", method: "POST", description: "Test integration" }, { 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/balance/balance/", method: "GET", description: "Credit balance" },
{ path: "/v1/billing/credits/usage/", method: "GET", description: "Usage logs" }, { 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/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" }, { path: "/v1/billing/credits/transactions/", method: "GET", description: "Transactions" },
], ],
}, },
@@ -127,7 +172,7 @@ export default function ApiMonitor() {
return saved ? parseInt(saved, 10) : 30; 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}`; const key = `${method}:${path}`;
// Set checking status // Set checking status
@@ -141,6 +186,8 @@ export default function ApiMonitor() {
})); }));
const startTime = Date.now(); const startTime = Date.now();
let apiStatus: 'healthy' | 'warning' | 'error' = 'healthy';
let dataStatus: 'healthy' | 'warning' | 'error' = 'healthy';
try { try {
// Get token from auth store or localStorage // Get token from auth store or localStorage
@@ -209,10 +256,19 @@ export default function ApiMonitor() {
// Determine status based on response // Determine status based on response
let status: 'healthy' | 'warning' | 'error' = 'healthy'; let status: 'healthy' | 'warning' | 'error' = 'healthy';
let responseText = ''; 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 { try {
responseText = await response.text(); 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) { } catch (e) {
// Ignore body read errors // Ignore body read errors
} }
@@ -236,9 +292,56 @@ export default function ApiMonitor() {
status = 'warning'; status = 'warning';
} }
} else if (method === 'GET') { } 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) { 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) { } else if (response.status === 401 || response.status === 403) {
status = 'warning'; // Endpoint exists, needs authentication status = 'warning'; // Endpoint exists, needs authentication
} else if (response.status === 404) { } else if (response.status === 404) {
@@ -249,12 +352,25 @@ export default function ApiMonitor() {
status = 'warning'; status = 'warning';
} }
} else if (method === 'POST') { } 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) { if (response.status === 400) {
// 400 means endpoint exists and validation works - this is healthy for monitoring // 400 means endpoint exists and validation works - this is healthy for monitoring
status = 'healthy'; // 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) { } else if (response.status >= 200 && response.status < 300) {
status = 'healthy'; // 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) { } else if (response.status === 401 || response.status === 403) {
status = 'warning'; // Endpoint exists, needs authentication status = 'warning'; // Endpoint exists, needs authentication
} else if (response.status === 404) { } 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 // Suppress console errors for expected monitoring responses
// Only log real errors (5xx, network errors, or unexpected 4xx for GET endpoints) // 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) // Don't log expected 400s for POST endpoints (they indicate validation is working)
const isExpectedResponse = const isExpectedResponse =
(method === 'POST' && response.status === 400) || // Expected validation error (method === 'POST' && response.status === 400) || // Expected validation error
(actualMethod === 'OPTIONS' && response.status === 200) || // Expected OPTIONS success (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 || if (!isExpectedResponse && (response.status >= 500 ||
(method === 'GET' && response.status === 404) || (method === 'GET' && response.status === 404) ||
@@ -289,6 +467,8 @@ export default function ApiMonitor() {
status, status,
responseTime, responseTime,
lastChecked: new Date().toISOString(), lastChecked: new Date().toISOString(),
apiStatus,
dataStatus,
}, },
})); }));
} catch (err: any) { } catch (err: any) {
@@ -314,7 +494,10 @@ export default function ApiMonitor() {
// Check all endpoints in parallel (but limit concurrency) // Check all endpoints in parallel (but limit concurrency)
const allChecks = endpointGroups.flatMap(group => 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 // Check in batches of 5 to avoid overwhelming the server
@@ -459,13 +642,25 @@ export default function ApiMonitor() {
</div> </div>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span <div className="flex flex-col gap-1">
className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded ${getStatusBadge(status.status)}`} <span
title={status.error || status.status} className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded ${getStatusBadge(status.status)}`}
> title={status.error || status.status}
<span>{getStatusIcon(status.status)}</span> >
<span className="capitalize">{status.status}</span> <span>{getStatusIcon(status.status)}</span>
</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>
<td className="px-3 py-2"> <td className="px-3 py-2">
{status.responseTime ? ( {status.responseTime ? (

View File

@@ -63,8 +63,10 @@ export default function Status() {
const fetchStatus = async () => { const fetchStatus = async () => {
try { try {
const data = await fetchAPI('/v1/system/status/'); const response = await fetchAPI('/v1/system/status/');
setStatus(data); // Handle unified API response format: {success: true, data: {...}}
const statusData = response?.data || response;
setStatus(statusData);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : 'Unknown error');

View File

@@ -75,7 +75,10 @@ export default function Prompts() {
try { try {
const promises = PROMPT_TYPES.map(async (type) => { const promises = PROMPT_TYPES.map(async (type) => {
try { 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 }; return { key: type.key, data };
} catch (error) { } catch (error) {
console.error(`Error loading prompt ${type.key}:`, error); console.error(`Error loading prompt ${type.key}:`, error);
@@ -116,7 +119,7 @@ export default function Prompts() {
setSaving({ ...saving, [promptType]: true }); setSaving({ ...saving, [promptType]: true });
try { try {
const data = await fetchAPI('/v1/system/prompts/save/', { const response = await fetchAPI('/v1/system/prompts/save/', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
prompt_type: promptType, prompt_type: promptType,
@@ -124,11 +127,15 @@ export default function Prompts() {
}), }),
}); });
if (data.success) { // Extract data field from unified API response format
toast.success(data.message || 'Prompt saved successfully'); // 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 await loadPrompts(); // Reload to get updated data
} else { } else {
throw new Error(data.error || 'Failed to save prompt'); throw new Error(response.error || 'Failed to save prompt');
} }
} catch (error: any) { } catch (error: any) {
console.error('Error saving prompt:', error); console.error('Error saving prompt:', error);
@@ -145,18 +152,22 @@ export default function Prompts() {
setSaving({ ...saving, [promptType]: true }); setSaving({ ...saving, [promptType]: true });
try { try {
const data = await fetchAPI('/v1/system/prompts/reset/', { const response = await fetchAPI('/v1/system/prompts/reset/', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
prompt_type: promptType, prompt_type: promptType,
}), }),
}); });
if (data.success) { // Extract data field from unified API response format
toast.success(data.message || 'Prompt reset to default'); // 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 await loadPrompts(); // Reload to get default value
} else { } else {
throw new Error(data.error || 'Failed to reset prompt'); throw new Error(response.error || 'Failed to reset prompt');
} }
} catch (error: any) { } catch (error: any) {
console.error('Error resetting prompt:', error); console.error('Error resetting prompt:', error);

View File

@@ -1114,7 +1114,10 @@ export async function fetchContentImages(filters: ContentImagesFilters = {}): Pr
if (filters.sector_id) params.append('sector_id', filters.sector_id.toString()); if (filters.sector_id) params.append('sector_id', filters.sector_id.toString());
const queryString = params.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 }> { 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); if (endDate) params.append('end_date', endDate);
const queryString = params.toString(); 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 { export interface LimitCard {
@@ -1464,7 +1471,10 @@ export async function fetchUsageLimits(): Promise<UsageLimitsResponse> {
try { try {
const response = await fetchAPI('/v1/billing/credits/usage/limits/'); const response = await fetchAPI('/v1/billing/credits/usage/limits/');
console.log('Usage limits API response:', response); 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) { } catch (error) {
console.error('Error fetching usage limits:', error); console.error('Error fetching usage limits:', error);
throw error; throw error;