diff --git a/backend/igny8_core/api/exception_handlers.py b/backend/igny8_core/api/exception_handlers.py new file mode 100644 index 00000000..2385be61 --- /dev/null +++ b/backend/igny8_core/api/exception_handlers.py @@ -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 + ) + + diff --git a/backend/igny8_core/api/pagination.py b/backend/igny8_core/api/pagination.py index 28378cf8..c471a27c 100644 --- a/backend/igny8_core/api/pagination.py +++ b/backend/igny8_core/api/pagination.py @@ -1,7 +1,9 @@ """ Custom pagination class for DRF to support dynamic page_size query parameter +and unified response format """ from rest_framework.pagination import PageNumberPagination +from .response import get_request_id class CustomPageNumberPagination(PageNumberPagination): @@ -11,8 +13,37 @@ class CustomPageNumberPagination(PageNumberPagination): Default page size: 10 Max page size: 100 + + Returns unified format with success field """ page_size = 10 page_size_query_param = 'page_size' max_page_size = 100 + + def paginate_queryset(self, queryset, request, view=None): + """ + Override to store request for later use in get_paginated_response + """ + self.request = request + return super().paginate_queryset(queryset, request, view) + + def get_paginated_response(self, data): + """ + Return a paginated response with unified format including success field + """ + from rest_framework.response import Response + + response_data = { + 'success': True, + 'count': self.page.paginator.count, + 'next': self.get_next_link(), + 'previous': self.get_previous_link(), + 'results': data + } + + # Add request_id if request is available + if hasattr(self, 'request') and self.request: + response_data['request_id'] = get_request_id(self.request) + + return Response(response_data) diff --git a/backend/igny8_core/api/permissions.py b/backend/igny8_core/api/permissions.py new file mode 100644 index 00000000..dc006ea7 --- /dev/null +++ b/backend/igny8_core/api/permissions.py @@ -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 + + diff --git a/backend/igny8_core/api/response.py b/backend/igny8_core/api/response.py new file mode 100644 index 00000000..a25a30e8 --- /dev/null +++ b/backend/igny8_core/api/response.py @@ -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) + + diff --git a/backend/igny8_core/api/throttles.py b/backend/igny8_core/api/throttles.py new file mode 100644 index 00000000..0b7aaf1d --- /dev/null +++ b/backend/igny8_core/api/throttles.py @@ -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 + + diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index 85183dd2..3e09a436 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -1,5 +1,6 @@ """ Authentication Views - Structured as: Groups, Users, Accounts, Subscriptions, Site User Access +Unified API Standard v1.0 compliant """ from rest_framework import viewsets, status, permissions, filters from rest_framework.decorators import action @@ -11,6 +12,9 @@ from django.db import transaction from django_filters.rest_framework import DjangoFilterBackend from igny8_core.api.base import AccountModelViewSet from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication +from igny8_core.api.response import success_response, error_response +from igny8_core.api.throttles import DebugScopedRateThrottle +from igny8_core.api.pagination import CustomPageNumberPagination from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword from .serializers import ( UserSerializer, AccountSerializer, PlanSerializer, SubscriptionSerializer, @@ -33,8 +37,11 @@ class GroupsViewSet(viewsets.ViewSet): """ ViewSet for managing user roles and permissions (Groups). Groups are defined by the User.ROLE_CHOICES. + Unified API Standard v1.0 compliant """ permission_classes = [IsOwnerOrAdmin] + throttle_scope = 'auth' + throttle_classes = [DebugScopedRateThrottle] def list(self, request): """List all available roles/groups.""" @@ -76,17 +83,18 @@ class GroupsViewSet(viewsets.ViewSet): 'permissions': ['automation_only'] } ] - return Response({ - 'success': True, - 'groups': roles - }) + return success_response(data={'groups': roles}, request=request) @action(detail=False, methods=['get'], url_path='permissions') def permissions(self, request): """Get permissions for a specific role.""" role = request.query_params.get('role') if not role: - return Response({'error': 'role parameter is required'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='role parameter is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) role_permissions = { 'developer': ['full_access', 'bypass_filters', 'all_modules', 'all_accounts'], @@ -98,11 +106,13 @@ class GroupsViewSet(viewsets.ViewSet): } permissions_list = role_permissions.get(role, []) - return Response({ - 'success': True, - 'role': role, - 'permissions': permissions_list - }) + return success_response( + data={ + 'role': role, + 'permissions': permissions_list + }, + request=request + ) # ============================================================================ @@ -113,10 +123,14 @@ class UsersViewSet(viewsets.ModelViewSet): """ ViewSet for managing global user records and credentials. Users are global, but belong to accounts. + Unified API Standard v1.0 compliant """ queryset = User.objects.all() serializer_class = UserSerializer permission_classes = [IsOwnerOrAdmin] + pagination_class = CustomPageNumberPagination + throttle_scope = 'auth' + throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """Return users based on access level.""" @@ -147,17 +161,21 @@ class UsersViewSet(viewsets.ModelViewSet): account_id = request.data.get('account_id') if not email or not username or not password: - return Response({ - 'error': 'email, username, and password are required' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='email, username, and password are required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Validate password try: validate_password(password) except Exception as e: - return Response({ - 'error': str(e) - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=str(e), + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Get account account = None @@ -165,9 +183,11 @@ class UsersViewSet(viewsets.ModelViewSet): try: account = Account.objects.get(id=account_id) except Account.DoesNotExist: - return Response({ - 'error': f'Account with id {account_id} does not exist' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=f'Account with id {account_id} does not exist', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) else: # Use current user's account if request.user.account: @@ -183,14 +203,17 @@ class UsersViewSet(viewsets.ModelViewSet): account=account ) serializer = UserSerializer(user) - return Response({ - 'success': True, - 'user': serializer.data - }, status=status.HTTP_201_CREATED) + return success_response( + data={'user': serializer.data}, + status_code=status.HTTP_201_CREATED, + request=request + ) except Exception as e: - return Response({ - 'error': str(e) - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=str(e), + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) @action(detail=True, methods=['post']) def update_role(self, request, pk=None): @@ -199,23 +222,24 @@ class UsersViewSet(viewsets.ModelViewSet): new_role = request.data.get('role') if not new_role: - return Response({ - 'error': 'role is required' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='role is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) if new_role not in [choice[0] for choice in User.ROLE_CHOICES]: - return Response({ - 'error': f'Invalid role. Must be one of: {[c[0] for c in User.ROLE_CHOICES]}' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=f'Invalid role. Must be one of: {[c[0] for c in User.ROLE_CHOICES]}', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) user.role = new_role user.save() serializer = UserSerializer(user) - return Response({ - 'success': True, - 'user': serializer.data - }) + return success_response(data={'user': serializer.data}, request=request) # ============================================================================ @@ -308,14 +332,16 @@ class SubscriptionsViewSet(viewsets.ModelViewSet): try: subscription = Subscription.objects.get(account_id=account_id) serializer = self.get_serializer(subscription) - return Response({ - 'success': True, - 'subscription': serializer.data - }) + return success_response( + data={'subscription': serializer.data}, + request=request + ) except Subscription.DoesNotExist: - return Response({ - 'error': 'Subscription not found for this account' - }, status=status.HTTP_404_NOT_FOUND) + return error_response( + error='Subscription not found for this account', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) # ============================================================================ @@ -424,7 +450,10 @@ class SiteViewSet(AccountModelViewSet): site = self.get_object() sectors = site.sectors.filter(is_active=True) serializer = SectorSerializer(sectors, many=True) - return Response(serializer.data) + return success_response( + data=serializer.data, + request=request + ) @action(detail=True, methods=['post'], url_path='set_active') def set_active(self, request, pk=None): @@ -437,11 +466,11 @@ class SiteViewSet(AccountModelViewSet): site.save() serializer = self.get_serializer(site) - return Response({ - 'success': True, - 'message': f'Site "{site.name}" is now active', - 'site': serializer.data - }) + return success_response( + data={'site': serializer.data}, + message=f'Site "{site.name}" is now active', + request=request + ) @action(detail=True, methods=['post'], url_path='select_sectors') def select_sectors(self, request, pk=None): @@ -453,43 +482,53 @@ class SiteViewSet(AccountModelViewSet): site = self.get_object() except Exception as e: logger.error(f"Error getting site object: {str(e)}", exc_info=True) - return Response({ - 'error': f'Site not found: {str(e)}' - }, status=status.HTTP_404_NOT_FOUND) + return error_response( + error=f'Site not found: {str(e)}', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) sector_slugs = request.data.get('sector_slugs', []) industry_slug = request.data.get('industry_slug') if not industry_slug: - return Response({ - 'error': 'Industry slug is required' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Industry slug is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) try: industry = Industry.objects.get(slug=industry_slug, is_active=True) except Industry.DoesNotExist: - return Response({ - 'error': f'Industry with slug "{industry_slug}" not found' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=f'Industry with slug "{industry_slug}" not found', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) site.industry = industry site.save() if not sector_slugs: - return Response({ - 'success': True, - 'message': f'Industry "{industry.name}" set for site. No sectors selected.', - 'site': SiteSerializer(site).data, - 'sectors': [] - }) + return success_response( + data={ + 'site': SiteSerializer(site).data, + 'sectors': [] + }, + message=f'Industry "{industry.name}" set for site. No sectors selected.', + request=request + ) # Get plan's max_industries limit (if set), otherwise default to 5 max_sectors = site.get_max_sectors_limit() if len(sector_slugs) > max_sectors: - return Response({ - 'error': f'Maximum {max_sectors} sectors allowed per site for this plan' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=f'Maximum {max_sectors} sectors allowed per site for this plan', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) created_sectors = [] updated_sectors = [] @@ -506,9 +545,11 @@ class SiteViewSet(AccountModelViewSet): ).first() if not industry_sector: - return Response({ - 'error': f'Sector "{sector_slug}" not found in industry "{industry.name}"' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=f'Sector "{sector_slug}" not found in industry "{industry.name}"', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) industry_sectors_map[sector_slug] = industry_sector @@ -517,9 +558,11 @@ class SiteViewSet(AccountModelViewSet): # Check if site has account before proceeding if not site.account: logger.error(f"Site {site.id} has no account assigned") - return Response({ - 'error': f'Site "{site.name}" has no account assigned. Please contact support.' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Site "{site.name}" has no account assigned. Please contact support.', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) # Create or get sector - account will be set automatically in save() method # But we need to pass it in defaults for get_or_create to work @@ -552,27 +595,33 @@ class SiteViewSet(AccountModelViewSet): created_sectors.append(sector) except Exception as e: logger.error(f"Error creating/updating sector {sector_slug}: {str(e)}", exc_info=True) - return Response({ - 'error': f'Failed to create/update sector "{sector_slug}": {str(e)}' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Failed to create/update sector "{sector_slug}": {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) # Get plan's max_industries limit (if set), otherwise default to 5 max_sectors = site.get_max_sectors_limit() if site.get_active_sectors_count() > max_sectors: - return Response({ - 'error': f'Maximum {max_sectors} sectors allowed per site for this plan' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=f'Maximum {max_sectors} sectors allowed per site for this plan', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) serializer = SectorSerializer(site.sectors.filter(is_active=True), many=True) - return Response({ - 'success': True, - 'message': f'Selected {len(sector_slugs)} sectors from industry "{industry.name}".', - 'created_count': len(created_sectors), - 'updated_count': len(updated_sectors), - 'sectors': serializer.data, - 'site': SiteSerializer(site).data - }) + return success_response( + data={ + 'created_count': len(created_sectors), + 'updated_count': len(updated_sectors), + 'sectors': serializer.data, + 'site': SiteSerializer(site).data + }, + message=f'Selected {len(sector_slugs)} sectors from industry "{industry.name}".', + request=request + ) class SectorViewSet(AccountModelViewSet): @@ -606,7 +655,10 @@ class SectorViewSet(AccountModelViewSet): """Override list to apply site filter.""" queryset = self.get_queryset_with_site_filter() serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) + return success_response( + data=serializer.data, + request=request + ) class IndustryViewSet(viewsets.ReadOnlyModelViewSet): @@ -619,10 +671,10 @@ class IndustryViewSet(viewsets.ReadOnlyModelViewSet): """Get all industries with their sectors.""" industries = self.get_queryset() serializer = self.get_serializer(industries, many=True) - return Response({ - 'success': True, - 'industries': serializer.data - }) + return success_response( + data={'industries': serializer.data}, + request=request + ) class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): @@ -656,8 +708,12 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): # ============================================================================ class AuthViewSet(viewsets.GenericViewSet): - """Authentication endpoints.""" + """Authentication endpoints. + Unified API Standard v1.0 compliant + """ permission_classes = [permissions.AllowAny] + throttle_scope = 'auth_strict' + throttle_classes = [DebugScopedRateThrottle] @action(detail=False, methods=['post']) def register(self, request): @@ -680,21 +736,26 @@ class AuthViewSet(viewsets.GenericViewSet): refresh_expires_at = get_token_expiry('refresh') user_serializer = UserSerializer(user) - return Response({ - 'success': True, - 'message': 'Registration successful', - 'user': user_serializer.data, - 'tokens': { - 'access': access_token, - 'refresh': refresh_token, - 'access_expires_at': access_expires_at.isoformat(), - 'refresh_expires_at': refresh_expires_at.isoformat(), - } - }, status=status.HTTP_201_CREATED) - return Response({ - 'success': False, - 'errors': serializer.errors - }, status=status.HTTP_400_BAD_REQUEST) + return success_response( + data={ + 'user': user_serializer.data, + 'tokens': { + 'access': access_token, + 'refresh': refresh_token, + 'access_expires_at': access_expires_at.isoformat(), + 'refresh_expires_at': refresh_expires_at.isoformat(), + } + }, + message='Registration successful', + status_code=status.HTTP_201_CREATED, + request=request + ) + return error_response( + error='Validation failed', + errors=serializer.errors, + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) @action(detail=False, methods=['post']) def login(self, request): @@ -707,10 +768,11 @@ class AuthViewSet(viewsets.GenericViewSet): try: user = User.objects.select_related('account', 'account__plan').get(email=email) except User.DoesNotExist: - return Response({ - 'success': False, - 'message': 'Invalid credentials' - }, status=status.HTTP_401_UNAUTHORIZED) + return error_response( + error='Invalid credentials', + status_code=status.HTTP_401_UNAUTHORIZED, + request=request + ) if user.check_password(password): # Log the user in (create session for session authentication) @@ -727,27 +789,32 @@ class AuthViewSet(viewsets.GenericViewSet): refresh_expires_at = get_token_expiry('refresh') user_serializer = UserSerializer(user) - return Response({ - 'success': True, - 'message': 'Login successful', - 'user': user_serializer.data, - 'tokens': { - 'access': access_token, - 'refresh': refresh_token, - 'access_expires_at': access_expires_at.isoformat(), - 'refresh_expires_at': refresh_expires_at.isoformat(), - } - }) + return success_response( + data={ + 'user': user_serializer.data, + 'tokens': { + 'access': access_token, + 'refresh': refresh_token, + 'access_expires_at': access_expires_at.isoformat(), + 'refresh_expires_at': refresh_expires_at.isoformat(), + } + }, + message='Login successful', + request=request + ) - return Response({ - 'success': False, - 'message': 'Invalid credentials' - }, status=status.HTTP_401_UNAUTHORIZED) + return error_response( + error='Invalid credentials', + status_code=status.HTTP_401_UNAUTHORIZED, + request=request + ) - return Response({ - 'success': False, - 'errors': serializer.errors - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Validation failed', + errors=serializer.errors, + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) @action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated]) def change_password(self, request): @@ -756,23 +823,26 @@ class AuthViewSet(viewsets.GenericViewSet): if serializer.is_valid(): user = request.user if not user.check_password(serializer.validated_data['old_password']): - return Response({ - 'success': False, - 'message': 'Current password is incorrect' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Current password is incorrect', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) user.set_password(serializer.validated_data['new_password']) user.save() - return Response({ - 'success': True, - 'message': 'Password changed successfully' - }) + return success_response( + message='Password changed successfully', + request=request + ) - return Response({ - 'success': False, - 'errors': serializer.errors - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Validation failed', + errors=serializer.errors, + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) def me(self, request): @@ -781,20 +851,22 @@ class AuthViewSet(viewsets.GenericViewSet): # This ensures account/plan changes are reflected immediately user = User.objects.select_related('account', 'account__plan').get(id=request.user.id) serializer = UserSerializer(user) - return Response({ - 'success': True, - 'user': serializer.data - }) + return success_response( + data={'user': serializer.data}, + request=request + ) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) def refresh(self, request): """Refresh access token using refresh token.""" serializer = RefreshTokenSerializer(data=request.data) if not serializer.is_valid(): - return Response({ - 'success': False, - 'errors': serializer.errors - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Validation failed', + errors=serializer.errors, + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) refresh_token = serializer.validated_data['refresh'] @@ -804,10 +876,11 @@ class AuthViewSet(viewsets.GenericViewSet): # Verify it's a refresh token if payload.get('type') != 'refresh': - return Response({ - 'success': False, - 'message': 'Invalid token type' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Invalid token type', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Get user user_id = payload.get('user_id') @@ -816,10 +889,11 @@ class AuthViewSet(viewsets.GenericViewSet): try: user = User.objects.get(id=user_id) except User.DoesNotExist: - return Response({ - 'success': False, - 'message': 'User not found' - }, status=status.HTTP_404_NOT_FOUND) + return error_response( + error='User not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) # Get account account_id = payload.get('account_id') @@ -837,27 +911,32 @@ class AuthViewSet(viewsets.GenericViewSet): access_token = generate_access_token(user, account) access_expires_at = get_token_expiry('access') - return Response({ - 'success': True, - 'access': access_token, - 'access_expires_at': access_expires_at.isoformat() - }) + return success_response( + data={ + 'access': access_token, + 'access_expires_at': access_expires_at.isoformat() + }, + request=request + ) except jwt.InvalidTokenError as e: - return Response({ - 'success': False, - 'message': 'Invalid or expired refresh token' - }, status=status.HTTP_401_UNAUTHORIZED) + return error_response( + error='Invalid or expired refresh token', + status_code=status.HTTP_401_UNAUTHORIZED, + request=request + ) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) def request_reset(self, request): """Request password reset - sends email with reset token.""" serializer = RequestPasswordResetSerializer(data=request.data) if not serializer.is_valid(): - return Response({ - 'success': False, - 'errors': serializer.errors - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Validation failed', + errors=serializer.errors, + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) email = serializer.validated_data['email'] @@ -865,10 +944,10 @@ class AuthViewSet(viewsets.GenericViewSet): user = User.objects.get(email=email) except User.DoesNotExist: # Don't reveal if email exists - return success anyway - return Response({ - 'success': True, - 'message': 'If an account with that email exists, a password reset link has been sent.' - }) + return success_response( + message='If an account with that email exists, a password reset link has been sent.', + request=request + ) # Generate secure token import secrets @@ -904,20 +983,22 @@ class AuthViewSet(viewsets.GenericViewSet): fail_silently=False, ) - return Response({ - 'success': True, - 'message': 'If an account with that email exists, a password reset link has been sent.' - }) + return success_response( + message='If an account with that email exists, a password reset link has been sent.', + request=request + ) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) def reset_password(self, request): """Reset password using reset token.""" serializer = ResetPasswordSerializer(data=request.data) if not serializer.is_valid(): - return Response({ - 'success': False, - 'errors': serializer.errors - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Validation failed', + errors=serializer.errors, + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) token = serializer.validated_data['token'] new_password = serializer.validated_data['new_password'] @@ -925,17 +1006,19 @@ class AuthViewSet(viewsets.GenericViewSet): try: reset_token = PasswordResetToken.objects.get(token=token) except PasswordResetToken.DoesNotExist: - return Response({ - 'success': False, - 'message': 'Invalid reset token' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Invalid reset token', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Check if token is valid if not reset_token.is_valid(): - return Response({ - 'success': False, - 'message': 'Reset token has expired or has already been used' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Reset token has expired or has already been used', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Update password user = reset_token.user @@ -946,7 +1029,7 @@ class AuthViewSet(viewsets.GenericViewSet): reset_token.used = True reset_token.save() - return Response({ - 'success': True, - 'message': 'Password has been reset successfully' - }) + return success_response( + message='Password has been reset successfully', + request=request + ) diff --git a/backend/igny8_core/middleware/request_id.py b/backend/igny8_core/middleware/request_id.py new file mode 100644 index 00000000..9494eee8 --- /dev/null +++ b/backend/igny8_core/middleware/request_id.py @@ -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 + + diff --git a/backend/igny8_core/modules/billing/views.py b/backend/igny8_core/modules/billing/views.py index 122045d0..89353333 100644 --- a/backend/igny8_core/modules/billing/views.py +++ b/backend/igny8_core/modules/billing/views.py @@ -1,5 +1,6 @@ """ ViewSets for Billing API +Unified API Standard v1.0 compliant """ from rest_framework import viewsets, status, permissions from rest_framework.decorators import action @@ -10,6 +11,8 @@ from datetime import timedelta from decimal import Decimal from igny8_core.api.base import AccountModelViewSet from igny8_core.api.pagination import CustomPageNumberPagination +from igny8_core.api.response import success_response, error_response +from igny8_core.api.throttles import DebugScopedRateThrottle from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication from .models import CreditTransaction, CreditUsageLog from .serializers import ( @@ -23,9 +26,12 @@ from .exceptions import InsufficientCreditsError class CreditBalanceViewSet(viewsets.ViewSet): """ ViewSet for credit balance operations + Unified API Standard v1.0 compliant """ permission_classes = [permissions.IsAuthenticated] authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] + throttle_scope = 'billing' + throttle_classes = [DebugScopedRateThrottle] @action(detail=False, methods=['get']) def balance(self, request): @@ -37,9 +43,10 @@ class CreditBalanceViewSet(viewsets.ViewSet): account = getattr(user, 'account', None) if not account: - return Response( - {'error': 'Account not found'}, - status=status.HTTP_400_BAD_REQUEST + return error_response( + error='Account not found', + status_code=status.HTTP_400_BAD_REQUEST, + request=request ) # Get plan credits per month @@ -63,18 +70,21 @@ class CreditBalanceViewSet(viewsets.ViewSet): } serializer = CreditBalanceSerializer(data) - return Response(serializer.data) + return success_response(data=serializer.data, request=request) class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet): """ ViewSet for credit usage logs + Unified API Standard v1.0 compliant """ queryset = CreditUsageLog.objects.all() serializer_class = CreditUsageLogSerializer permission_classes = [permissions.IsAuthenticated] authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] pagination_class = CustomPageNumberPagination + throttle_scope = 'billing' + throttle_classes = [DebugScopedRateThrottle] filter_backends = [] @@ -116,9 +126,10 @@ class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet): account = getattr(user, 'account', None) if not account: - return Response( - {'error': 'Account not found'}, - status=status.HTTP_400_BAD_REQUEST + return error_response( + error='Account not found', + status_code=status.HTTP_400_BAD_REQUEST, + request=request ) # Get date range from query params @@ -192,7 +203,7 @@ class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet): } serializer = UsageSummarySerializer(data) - return Response(serializer.data) + return success_response(data=serializer.data, request=request) @action(detail=False, methods=['get'], url_path='limits', url_name='limits') def limits(self, request): @@ -222,12 +233,12 @@ class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet): if not account: logger.warning(f'No account found in limits endpoint') # Return empty limits instead of error - frontend will show "no data" message - return Response({'limits': []}) + return success_response(data={'limits': []}, request=request) plan = account.plan if not plan: # Return empty limits instead of error - allows frontend to show "no plan" message - return Response({'limits': []}) + return success_response(data={'limits': []}, request=request) # Import models from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas @@ -430,18 +441,21 @@ class CreditUsageViewSet(viewsets.ReadOnlyModelViewSet): ]) # Return data directly - serializer validation not needed for read-only endpoint - return Response({'limits': limits_data}) + return success_response(data={'limits': limits_data}, request=request) class CreditTransactionViewSet(viewsets.ReadOnlyModelViewSet): """ ViewSet for credit transaction history + Unified API Standard v1.0 compliant """ queryset = CreditTransaction.objects.all() serializer_class = CreditTransactionSerializer permission_classes = [permissions.IsAuthenticated] authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication] pagination_class = CustomPageNumberPagination + throttle_scope = 'billing' + throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """Get transactions for current account""" diff --git a/backend/igny8_core/modules/planner/views.py b/backend/igny8_core/modules/planner/views.py index cccc0cdf..a19e8b1b 100644 --- a/backend/igny8_core/modules/planner/views.py +++ b/backend/igny8_core/modules/planner/views.py @@ -10,6 +10,8 @@ import json import time from igny8_core.api.base import SiteSectorModelViewSet from igny8_core.api.pagination import CustomPageNumberPagination +from igny8_core.api.response import success_response, error_response +from igny8_core.api.throttles import DebugScopedRateThrottle from .models import Keywords, Clusters, ContentIdeas from .serializers import KeywordSerializer, ContentIdeasSerializer from .cluster_serializers import ClusterSerializer @@ -19,11 +21,14 @@ class KeywordViewSet(SiteSectorModelViewSet): """ ViewSet for managing keywords with CRUD operations Provides list, create, retrieve, update, and destroy actions + Unified API Standard v1.0 compliant """ queryset = Keywords.objects.all() serializer_class = KeywordSerializer permission_classes = [] # Allow any for now pagination_class = CustomPageNumberPagination # Explicitly use custom pagination + throttle_scope = 'planner' + throttle_classes = [DebugScopedRateThrottle] # DRF filtering configuration filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] @@ -121,13 +126,17 @@ class KeywordViewSet(SiteSectorModelViewSet): return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data) + return success_response( + data=serializer.data, + request=request + ) except Exception as e: logger.error(f"Error in KeywordViewSet.list(): {type(e).__name__}: {str(e)}", exc_info=True) - return Response({ - 'error': f'Error loading keywords: {str(e)}', - 'type': type(e).__name__ - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Error loading keywords: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) def perform_create(self, serializer): """Require explicit site_id and sector_id - no defaults.""" @@ -190,12 +199,16 @@ class KeywordViewSet(SiteSectorModelViewSet): """Bulk delete keywords""" ids = request.data.get('ids', []) if not ids: - return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) queryset = self.get_queryset() deleted_count, _ = queryset.filter(id__in=ids).delete() - return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK) + return success_response(data={'deleted_count': deleted_count}, request=request) @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update') def bulk_update(self, request): @@ -204,14 +217,22 @@ class KeywordViewSet(SiteSectorModelViewSet): status_value = request.data.get('status') if not ids: - return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) if not status_value: - return Response({'error': 'No status provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No status provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) queryset = self.get_queryset() updated_count = queryset.filter(id__in=ids).update(status=status_value) - return Response({'updated_count': updated_count}, status=status.HTTP_200_OK) + return success_response(data={'updated_count': updated_count}, request=request) @action(detail=False, methods=['post'], url_path='bulk_add_from_seed', url_name='bulk_add_from_seed') def bulk_add_from_seed(self, request): @@ -223,32 +244,60 @@ class KeywordViewSet(SiteSectorModelViewSet): sector_id = request.data.get('sector_id') if not seed_keyword_ids: - return Response({'error': 'No seed keyword IDs provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No seed keyword IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) if not site_id: - return Response({'error': 'site_id is required'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='site_id is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) if not sector_id: - return Response({'error': 'sector_id is required'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='sector_id is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) try: site = Site.objects.get(id=site_id) sector = Sector.objects.get(id=sector_id) except (Site.DoesNotExist, Sector.DoesNotExist) as e: - return Response({'error': f'Invalid site or sector: {str(e)}'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=f'Invalid site or sector: {str(e)}', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Validate sector belongs to site if sector.site != site: - return Response({'error': 'Sector does not belong to the specified site'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Sector does not belong to the specified site', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Get account from site account = site.account if not account: - return Response({'error': 'Site has no account assigned'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Site has no account assigned', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Get SeedKeywords seed_keywords = SeedKeyword.objects.filter(id__in=seed_keyword_ids, is_active=True) if not seed_keywords.exists(): - return Response({'error': 'No valid seed keywords found'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No valid seed keywords found', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) created_count = 0 skipped_count = 0 @@ -288,12 +337,14 @@ class KeywordViewSet(SiteSectorModelViewSet): errors.append(f"Error adding '{seed_keyword.keyword}': {str(e)}") skipped_count += 1 - return Response({ - 'success': True, - 'created': created_count, - 'skipped': skipped_count, - 'errors': errors[:10] if errors else [] # Limit errors to first 10 - }, status=status.HTTP_200_OK) + return success_response( + data={ + 'created': created_count, + 'skipped': skipped_count, + 'errors': errors[:10] if errors else [] # Limit errors to first 10 + }, + request=request + ) @action(detail=False, methods=['get'], url_path='export', url_name='export') def export(self, request): @@ -366,11 +417,19 @@ class KeywordViewSet(SiteSectorModelViewSet): Automatically links keywords to current active site/sector. """ if 'file' not in request.FILES: - return Response({'error': 'No file provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No file provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) file = request.FILES['file'] if not file.name.endswith('.csv'): - return Response({'error': 'File must be a CSV'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='File must be a CSV', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) user = getattr(request, 'user', None) @@ -391,23 +450,43 @@ class KeywordViewSet(SiteSectorModelViewSet): # Site ID is REQUIRED if not site_id: - return Response({'error': 'site_id is required'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='site_id is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) try: site = Site.objects.get(id=site_id) except Site.DoesNotExist: - return Response({'error': f'Site with id {site_id} does not exist'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=f'Site with id {site_id} does not exist', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Sector ID is REQUIRED if not sector_id: - return Response({'error': 'sector_id is required'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='sector_id is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) try: sector = Sector.objects.get(id=sector_id) if sector.site_id != site_id: - return Response({'error': 'Sector does not belong to the selected site'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Sector does not belong to the selected site', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) except Sector.DoesNotExist: - return Response({'error': f'Sector with id {sector_id} does not exist'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=f'Sector with id {sector_id} does not exist', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Get account account = getattr(request, 'account', None) @@ -461,17 +540,21 @@ class KeywordViewSet(SiteSectorModelViewSet): errors.append(f"Row {row_num}: {str(e)}") continue - return Response({ - 'success': True, - 'imported': imported_count, - 'skipped': skipped_count, - 'errors': errors[:10] if errors else [] # Limit errors to first 10 - }, status=status.HTTP_200_OK) + return success_response( + data={ + 'imported': imported_count, + 'skipped': skipped_count, + 'errors': errors[:10] if errors else [] # Limit errors to first 10 + }, + request=request + ) except Exception as e: - return Response({ - 'error': f'Failed to parse CSV: {str(e)}' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=f'Failed to parse CSV: {str(e)}', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) @action(detail=False, methods=['post'], url_path='auto_cluster', url_name='auto_cluster') def auto_cluster(self, request): @@ -497,16 +580,18 @@ class KeywordViewSet(SiteSectorModelViewSet): # Validate basic input if not payload['ids']: - return Response({ - 'success': False, - 'error': 'No IDs provided' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) if len(payload['ids']) > 20: - return Response({ - 'success': False, - 'error': 'Maximum 20 keywords allowed for clustering' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Maximum 20 keywords allowed for clustering', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Try to queue Celery task try: @@ -517,11 +602,11 @@ class KeywordViewSet(SiteSectorModelViewSet): account_id=account_id ) logger.info(f"Task queued: {task.id}") - return Response({ - 'success': True, - 'task_id': str(task.id), - 'message': 'Clustering started' - }, status=status.HTTP_200_OK) + return success_response( + data={'task_id': str(task.id)}, + message='Clustering started', + request=request + ) else: # Celery not available - execute synchronously logger.warning("Celery not available, executing synchronously") @@ -531,15 +616,16 @@ class KeywordViewSet(SiteSectorModelViewSet): account_id=account_id ) if result.get('success'): - return Response({ - 'success': True, - **result - }, status=status.HTTP_200_OK) + return success_response( + data=result, + request=request + ) else: - return Response({ - 'success': False, - 'error': result.get('error', 'Clustering failed') - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=result.get('error', 'Clustering failed'), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except (KombuOperationalError, ConnectionError) as e: # Broker connection failed - fall back to synchronous execution logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}") @@ -549,36 +635,42 @@ class KeywordViewSet(SiteSectorModelViewSet): account_id=account_id ) if result.get('success'): - return Response({ - 'success': True, - **result - }, status=status.HTTP_200_OK) + return success_response( + data=result, + request=request + ) else: - return Response({ - 'success': False, - 'error': result.get('error', 'Clustering failed') - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=result.get('error', 'Clustering failed'), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except Exception as e: logger.error(f"Error in auto_cluster: {str(e)}", exc_info=True) - return Response({ - 'success': False, - 'error': str(e) - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=str(e), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except Exception as e: logger.error(f"Unexpected error in auto_cluster: {str(e)}", exc_info=True) - return Response({ - 'success': False, - 'error': f'Unexpected error: {str(e)}' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Unexpected error: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) class ClusterViewSet(SiteSectorModelViewSet): """ ViewSet for managing clusters with CRUD operations + Unified API Standard v1.0 compliant """ queryset = Clusters.objects.all() serializer_class = ClusterSerializer pagination_class = CustomPageNumberPagination # Explicitly use custom pagination + throttle_scope = 'planner' + throttle_classes = [DebugScopedRateThrottle] # DRF filtering configuration filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] @@ -719,12 +811,16 @@ class ClusterViewSet(SiteSectorModelViewSet): """Bulk delete clusters""" ids = request.data.get('ids', []) if not ids: - return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) queryset = self.get_queryset() deleted_count, _ = queryset.filter(id__in=ids).delete() - return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK) + return success_response(data={'deleted_count': deleted_count}, request=request) @action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas') def auto_generate_ideas(self, request): @@ -749,16 +845,18 @@ class ClusterViewSet(SiteSectorModelViewSet): # Validate basic input if not payload['ids']: - return Response({ - 'success': False, - 'error': 'No IDs provided' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) if len(payload['ids']) > 10: - return Response({ - 'success': False, - 'error': 'Maximum 10 clusters allowed for idea generation' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Maximum 10 clusters allowed for idea generation', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Try to queue Celery task try: @@ -769,11 +867,11 @@ class ClusterViewSet(SiteSectorModelViewSet): account_id=account_id ) logger.info(f"Task queued: {task.id}") - return Response({ - 'success': True, - 'task_id': str(task.id), - 'message': 'Idea generation started' - }, status=status.HTTP_200_OK) + return success_response( + data={'task_id': str(task.id)}, + message='Idea generation started', + request=request + ) else: # Celery not available - execute synchronously logger.warning("Celery not available, executing synchronously") @@ -783,15 +881,16 @@ class ClusterViewSet(SiteSectorModelViewSet): account_id=account_id ) if result.get('success'): - return Response({ - 'success': True, - **result - }, status=status.HTTP_200_OK) + return success_response( + data=result, + request=request + ) else: - return Response({ - 'success': False, - 'error': result.get('error', 'Idea generation failed') - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=result.get('error', 'Idea generation failed'), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except (KombuOperationalError, ConnectionError) as e: # Broker connection failed - fall back to synchronous execution logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}") @@ -801,27 +900,30 @@ class ClusterViewSet(SiteSectorModelViewSet): account_id=account_id ) if result.get('success'): - return Response({ - 'success': True, - **result - }, status=status.HTTP_200_OK) + return success_response( + data=result, + request=request + ) else: - return Response({ - 'success': False, - 'error': result.get('error', 'Idea generation failed') - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=result.get('error', 'Idea generation failed'), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except Exception as e: logger.error(f"Error in auto_generate_ideas: {str(e)}", exc_info=True) - return Response({ - 'success': False, - 'error': str(e) - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=str(e), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except Exception as e: logger.error(f"Unexpected error in auto_generate_ideas: {str(e)}", exc_info=True) - return Response({ - 'success': False, - 'error': f'Unexpected error: {str(e)}' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Unexpected error: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) def list(self, request, *args, **kwargs): """ @@ -842,16 +944,22 @@ class ClusterViewSet(SiteSectorModelViewSet): cluster_list = list(queryset) ClusterSerializer.prefetch_keyword_stats(cluster_list) serializer = self.get_serializer(cluster_list, many=True) - return Response(serializer.data) + return success_response( + data=serializer.data, + request=request + ) class ContentIdeasViewSet(SiteSectorModelViewSet): """ ViewSet for managing content ideas with CRUD operations + Unified API Standard v1.0 compliant """ queryset = ContentIdeas.objects.all() serializer_class = ContentIdeasSerializer - pagination_class = CustomPageNumberPagination # Explicitly use custom pagination + pagination_class = CustomPageNumberPagination + throttle_scope = 'planner' + throttle_classes = [DebugScopedRateThrottle] # Explicitly use custom pagination # DRF filtering configuration filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] @@ -919,19 +1027,27 @@ class ContentIdeasViewSet(SiteSectorModelViewSet): """Bulk delete content ideas""" ids = request.data.get('ids', []) if not ids: - return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) queryset = self.get_queryset() deleted_count, _ = queryset.filter(id__in=ids).delete() - return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK) + return success_response(data={'deleted_count': deleted_count}, request=request) @action(detail=False, methods=['post'], url_path='bulk_queue_to_writer', url_name='bulk_queue_to_writer') def bulk_queue_to_writer(self, request): """Queue ideas to writer by creating Tasks""" ids = request.data.get('ids', []) if not ids: - return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) queryset = self.get_queryset() ideas = queryset.filter(id__in=ids, status='new') # Only queue 'new' ideas @@ -958,11 +1074,13 @@ class ContentIdeasViewSet(SiteSectorModelViewSet): idea.status = 'scheduled' idea.save() - return Response({ - 'success': True, - 'created_count': len(created_tasks), - 'task_ids': created_tasks, - 'message': f'Successfully queued {len(created_tasks)} ideas to writer' - }, status=status.HTTP_200_OK) + return success_response( + data={ + 'created_count': len(created_tasks), + 'task_ids': created_tasks, + }, + message=f'Successfully queued {len(created_tasks)} ideas to writer', + request=request + ) # REMOVED: generate_idea action - idea generation function removed diff --git a/backend/igny8_core/modules/system/integration_views.py b/backend/igny8_core/modules/system/integration_views.py index ce09ea1a..b5a76fae 100644 --- a/backend/igny8_core/modules/system/integration_views.py +++ b/backend/igny8_core/modules/system/integration_views.py @@ -1,5 +1,6 @@ """ Integration settings views - for OpenAI, Runware, GSC integrations +Unified API Standard v1.0 compliant """ import logging from rest_framework import viewsets, status @@ -7,6 +8,8 @@ from rest_framework.decorators import action from rest_framework.response import Response from django.db import transaction from igny8_core.api.base import AccountModelViewSet +from igny8_core.api.response import success_response, error_response +from igny8_core.api.throttles import DebugScopedRateThrottle from django.conf import settings logger = logging.getLogger(__name__) @@ -20,18 +23,24 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): """ permission_classes = [] # Allow any for now + throttle_scope = 'system_admin' + throttle_classes = [DebugScopedRateThrottle] + def list(self, request): """List all integrations - for debugging URL patterns""" logger.info("[IntegrationSettingsViewSet] list() called") - return Response({ - 'message': 'IntegrationSettingsViewSet is working', - 'available_endpoints': [ - 'GET /api/v1/system/settings/integrations//', - 'POST /api/v1/system/settings/integrations//save/', - 'POST /api/v1/system/settings/integrations//test/', - 'POST /api/v1/system/settings/integrations//generate/', - ] - }) + return success_response( + data={ + 'message': 'IntegrationSettingsViewSet is working', + 'available_endpoints': [ + 'GET /api/v1/system/settings/integrations//', + 'POST /api/v1/system/settings/integrations//save/', + 'POST /api/v1/system/settings/integrations//test/', + 'POST /api/v1/system/settings/integrations//generate/', + ] + }, + request=request + ) def retrieve(self, request, pk=None): """Get integration settings - GET /api/v1/system/settings/integrations/{pk}/""" @@ -65,7 +74,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): logger.info(f"[test_connection] Called for integration_type={integration_type}, user={getattr(request, 'user', None)}, account={getattr(request, 'account', None)}") if not integration_type: - return Response({'error': 'Integration type is required'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Integration type is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Get API key and config from request or saved settings config = request.data.get('config', {}) if isinstance(request.data.get('config'), dict) else {} @@ -108,10 +121,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): if not api_key: logger.error(f"[test_connection] No API key found in request or saved settings") - return Response({ - 'success': False, - 'error': 'API key is required' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='API key is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) logger.info(f"[test_connection] Testing {integration_type} connection with API key (length={len(api_key) if api_key else 0})") try: @@ -120,19 +134,21 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): elif integration_type == 'runware': return self._test_runware(api_key, request) else: - return Response({ - 'success': False, - 'error': f'Validation not supported for {integration_type}' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=f'Validation not supported for {integration_type}', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) except Exception as e: logger.error(f"Error testing {integration_type} connection: {str(e)}", exc_info=True) import traceback error_trace = traceback.format_exc() logger.error(f"Full traceback: {error_trace}") - return Response({ - 'success': False, - 'error': str(e) - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=str(e), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) def _test_openai(self, api_key: str, config: dict = None): """ @@ -554,7 +570,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): logger.info(f"[save_settings] Called for integration_type={integration_type}, user={getattr(request, 'user', None)}, account={getattr(request, 'account', None)}") if not integration_type: - return Response({'error': 'Integration type is required'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Integration type is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Ensure config is a dict config = dict(request.data) if hasattr(request.data, 'dict') else (request.data if isinstance(request.data, dict) else {}) @@ -587,7 +607,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): if not account: logger.error(f"[save_settings] No account found after all fallbacks") - return Response({'error': 'Account not found. Please ensure you are logged in.'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Account not found. Please ensure you are logged in.', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) logger.info(f"[save_settings] Using account: {account.id} ({account.name}, slug={account.slug}, status={account.status})") @@ -648,10 +672,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): logger.info(f"[save_settings] Settings updated successfully") logger.info(f"[save_settings] Successfully saved settings for {integration_type}") - return Response({ - 'success': True, - 'message': f'{integration_type.upper()} settings saved successfully' - }) + return success_response( + data={'config': config}, + message=f'{integration_type.upper()} settings saved successfully', + request=request + ) except Exception as e: logger.error(f"Error saving integration settings for {integration_type}: {str(e)}", exc_info=True) @@ -667,10 +692,11 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): integration_type = pk if not integration_type: - return Response({ - 'success': False, - 'error': 'Integration type is required' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Integration type is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) try: # Get account - try multiple methods (same as save_settings) @@ -695,26 +721,27 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): integration_type=integration_type, account=account ) - return Response({ - 'success': True, - 'data': integration_settings.config - }) + return success_response( + data=integration_settings.config, + request=request + ) except IntegrationSettings.DoesNotExist: pass except Exception as e: logger.error(f"Error getting account-specific settings: {e}", exc_info=True) # Return empty config if no settings found - return Response({ - 'success': True, - 'data': {} - }) + return success_response( + data={}, + request=request + ) except Exception as e: logger.error(f"Unexpected error in get_settings for {integration_type}: {e}", exc_info=True) - return Response({ - 'success': False, - 'error': f'Failed to get settings: {str(e)}' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Failed to get settings: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) @action(detail=False, methods=['get'], url_path='image_generation', url_name='image_generation_settings') def get_image_generation_settings(self, request): diff --git a/backend/igny8_core/modules/system/views.py b/backend/igny8_core/modules/system/views.py index b8f1611e..86a34913 100644 --- a/backend/igny8_core/modules/system/views.py +++ b/backend/igny8_core/modules/system/views.py @@ -13,6 +13,10 @@ from django.core.cache import cache from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from igny8_core.api.base import AccountModelViewSet +from igny8_core.api.response import success_response, error_response +from igny8_core.api.permissions import IsEditorOrAbove +from igny8_core.api.throttles import DebugScopedRateThrottle +from igny8_core.api.pagination import CustomPageNumberPagination from .models import AIPrompt, AuthorProfile, Strategy from .serializers import AIPromptSerializer, AuthorProfileSerializer, StrategySerializer @@ -22,10 +26,14 @@ logger = logging.getLogger(__name__) class AIPromptViewSet(AccountModelViewSet): """ ViewSet for managing AI prompts + Unified API Standard v1.0 compliant """ queryset = AIPrompt.objects.all() serializer_class = AIPromptSerializer - permission_classes = [] # Allow any for now + permission_classes = [] # Allow any for now (backward compatibility) + throttle_scope = 'system' + throttle_classes = [DebugScopedRateThrottle] + pagination_class = CustomPageNumberPagination # Explicitly use custom pagination def get_queryset(self): """Get prompts for the current account""" @@ -37,28 +45,39 @@ class AIPromptViewSet(AccountModelViewSet): try: prompt = self.get_queryset().get(prompt_type=prompt_type) serializer = self.get_serializer(prompt) - return Response(serializer.data) + return success_response(data=serializer.data, request=request) except AIPrompt.DoesNotExist: # Return default if not found from .utils import get_default_prompt default_value = get_default_prompt(prompt_type) - return Response({ - 'prompt_type': prompt_type, - 'prompt_value': default_value, - 'default_prompt': default_value, - 'is_active': True, - }) + return success_response( + data={ + 'prompt_type': prompt_type, + 'prompt_value': default_value, + 'default_prompt': default_value, + 'is_active': True, + }, + request=request + ) @action(detail=False, methods=['post'], url_path='save', url_name='save') def save_prompt(self, request): - """Save or update a prompt""" + """Save or update a prompt - requires editor or above""" prompt_type = request.data.get('prompt_type') prompt_value = request.data.get('prompt_value') if not prompt_type: - return Response({'error': 'prompt_type is required'}, status=http_status.HTTP_400_BAD_REQUEST) + return error_response( + error='prompt_type is required', + status_code=http_status.HTTP_400_BAD_REQUEST, + request=request + ) if prompt_value is None: - return Response({'error': 'prompt_value is required'}, status=http_status.HTTP_400_BAD_REQUEST) + return error_response( + error='prompt_value is required', + status_code=http_status.HTTP_400_BAD_REQUEST, + request=request + ) # Get account - try multiple methods account = getattr(request, 'account', None) @@ -78,7 +97,11 @@ class AIPromptViewSet(AccountModelViewSet): pass if not account: - return Response({'error': 'Account not found. Please ensure you are logged in.'}, status=http_status.HTTP_400_BAD_REQUEST) + return error_response( + error='Account not found. Please ensure you are logged in.', + status_code=http_status.HTTP_400_BAD_REQUEST, + request=request + ) # Get default prompt value if creating new from .utils import get_default_prompt @@ -100,11 +123,11 @@ class AIPromptViewSet(AccountModelViewSet): prompt.save() serializer = self.get_serializer(prompt) - return Response({ - 'success': True, - 'data': serializer.data, - 'message': f'{prompt.get_prompt_type_display()} saved successfully' - }) + return success_response( + data=serializer.data, + message=f'{prompt.get_prompt_type_display()} saved successfully', + request=request + ) @action(detail=False, methods=['post'], url_path='reset', url_name='reset') def reset_prompt(self, request): @@ -112,7 +135,11 @@ class AIPromptViewSet(AccountModelViewSet): prompt_type = request.data.get('prompt_type') if not prompt_type: - return Response({'error': 'prompt_type is required'}, status=http_status.HTTP_400_BAD_REQUEST) + return error_response( + error='prompt_type is required', + status_code=http_status.HTTP_400_BAD_REQUEST, + request=request + ) # Get account - try multiple methods (same as integration_views) account = getattr(request, 'account', None) @@ -132,7 +159,11 @@ class AIPromptViewSet(AccountModelViewSet): pass if not account: - return Response({'error': 'Account not found. Please ensure you are logged in.'}, status=http_status.HTTP_400_BAD_REQUEST) + return error_response( + error='Account not found. Please ensure you are logged in.', + status_code=http_status.HTTP_400_BAD_REQUEST, + request=request + ) # Get default prompt from .utils import get_default_prompt @@ -154,19 +185,22 @@ class AIPromptViewSet(AccountModelViewSet): prompt.save() serializer = self.get_serializer(prompt) - return Response({ - 'success': True, - 'data': serializer.data, - 'message': f'{prompt.get_prompt_type_display()} reset to default' - }) + return success_response( + data=serializer.data, + message=f'{prompt.get_prompt_type_display()} reset to default', + request=request + ) class AuthorProfileViewSet(AccountModelViewSet): """ ViewSet for managing Author Profiles + Unified API Standard v1.0 compliant """ queryset = AuthorProfile.objects.all() serializer_class = AuthorProfileSerializer + throttle_scope = 'system' + throttle_classes = [DebugScopedRateThrottle] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] search_fields = ['name', 'description', 'tone'] @@ -178,9 +212,12 @@ class AuthorProfileViewSet(AccountModelViewSet): class StrategyViewSet(AccountModelViewSet): """ ViewSet for managing Strategies + Unified API Standard v1.0 compliant """ queryset = Strategy.objects.all() serializer_class = StrategySerializer + throttle_scope = 'system' + throttle_classes = [DebugScopedRateThrottle] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] search_fields = ['name', 'description'] @@ -190,7 +227,7 @@ class StrategyViewSet(AccountModelViewSet): @api_view(['GET']) -@permission_classes([AllowAny]) # Adjust permissions as needed +@permission_classes([AllowAny]) # Public endpoint for monitoring def system_status(request): """ Comprehensive system status endpoint for monitoring @@ -457,7 +494,7 @@ def system_status(request): logger.error(f"Error getting module statistics: {str(e)}") status_data['modules'] = {'error': str(e)} - return Response(status_data) + return success_response(data=status_data, request=request) @api_view(['GET']) @@ -469,19 +506,31 @@ def get_request_metrics(request, request_id): """ # Check if user is admin/developer if not request.user.is_authenticated: - return Response({'error': 'Authentication required'}, status=http_status.HTTP_401_UNAUTHORIZED) + return error_response( + error='Authentication required', + status_code=http_status.HTTP_401_UNAUTHORIZED, + request=request + ) if not (hasattr(request.user, 'is_admin_or_developer') and request.user.is_admin_or_developer()): - return Response({'error': 'Admin access required'}, status=http_status.HTTP_403_FORBIDDEN) + return error_response( + error='Admin access required', + status_code=http_status.HTTP_403_FORBIDDEN, + request=request + ) # Get metrics from cache from django.core.cache import cache metrics = cache.get(f"resource_tracking_{request_id}") if not metrics: - return Response({'error': 'Metrics not found or expired'}, status=http_status.HTTP_404_NOT_FOUND) + return error_response( + error='Metrics not found or expired', + status_code=http_status.HTTP_404_NOT_FOUND, + request=request + ) - return Response(metrics) + return success_response(data=metrics, request=request) @api_view(['POST']) @@ -504,10 +553,11 @@ def gitea_webhook(request): # Only process push events if event_type != 'push': - return Response({ - 'status': 'ignored', - 'message': f'Event type {event_type} is not processed' - }, status=http_status.HTTP_200_OK) + return success_response( + data={'status': 'ignored'}, + message=f'Event type {event_type} is not processed', + request=request + ) # Extract repository information repository = payload.get('repository', {}) @@ -518,10 +568,11 @@ def gitea_webhook(request): # Only process pushes to main branch if ref != 'refs/heads/main': logger.info(f"[Webhook] Ignoring push to {ref}, only processing main branch") - return Response({ - 'status': 'ignored', - 'message': f'Push to {ref} ignored, only main branch is processed' - }, status=http_status.HTTP_200_OK) + return success_response( + data={'status': 'ignored'}, + message=f'Push to {ref} ignored, only main branch is processed', + request=request + ) # Get commit information commits = payload.get('commits', []) @@ -636,30 +687,35 @@ def gitea_webhook(request): deployment_error = str(deploy_error) logger.error(f"[Webhook] Deployment error: {deploy_error}", exc_info=True) - return Response({ - 'status': 'success' if deployment_success else 'partial', - 'message': 'Webhook received and processed', - 'repository': repo_full_name, - 'branch': ref, - 'commits': commit_count, - 'pusher': pusher, - 'event': event_type, - 'deployment': { - 'success': deployment_success, - 'error': deployment_error - } - }, status=http_status.HTTP_200_OK) + return success_response( + data={ + 'status': 'success' if deployment_success else 'partial', + 'repository': repo_full_name, + 'branch': ref, + 'commits': commit_count, + 'pusher': pusher, + 'event': event_type, + 'deployment': { + 'success': deployment_success, + 'error': deployment_error + } + }, + message='Webhook received and processed', + request=request + ) except json.JSONDecodeError as e: logger.error(f"[Webhook] Invalid JSON payload: {e}") - return Response({ - 'status': 'error', - 'message': 'Invalid JSON payload' - }, status=http_status.HTTP_400_BAD_REQUEST) + return error_response( + error='Invalid JSON payload', + status_code=http_status.HTTP_400_BAD_REQUEST, + request=request + ) except Exception as e: logger.error(f"[Webhook] Error processing webhook: {e}", exc_info=True) - return Response({ - 'status': 'error', - 'message': str(e) - }, status=http_status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=str(e), + status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index 19d963f1..5bd3d263 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -6,6 +6,8 @@ from django.db import transaction, models from django.db.models import Q from igny8_core.api.base import SiteSectorModelViewSet from igny8_core.api.pagination import CustomPageNumberPagination +from igny8_core.api.response import success_response, error_response +from igny8_core.api.throttles import DebugScopedRateThrottle from .models import Tasks, Images, Content from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer @@ -13,10 +15,13 @@ from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer class TasksViewSet(SiteSectorModelViewSet): """ ViewSet for managing tasks with CRUD operations + Unified API Standard v1.0 compliant """ queryset = Tasks.objects.select_related('content_record') serializer_class = TasksSerializer pagination_class = CustomPageNumberPagination # Explicitly use custom pagination + throttle_scope = 'writer' + throttle_classes = [DebugScopedRateThrottle] # DRF filtering configuration filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] @@ -84,12 +89,16 @@ class TasksViewSet(SiteSectorModelViewSet): """Bulk delete tasks""" ids = request.data.get('ids', []) if not ids: - return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) queryset = self.get_queryset() deleted_count, _ = queryset.filter(id__in=ids).delete() - return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK) + return success_response(data={'deleted_count': deleted_count}, request=request) @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update') def bulk_update(self, request): @@ -98,14 +107,22 @@ class TasksViewSet(SiteSectorModelViewSet): status_value = request.data.get('status') if not ids: - return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) if not status_value: - return Response({'error': 'No status provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No status provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) queryset = self.get_queryset() updated_count = queryset.filter(id__in=ids).update(status=status_value) - return Response({'updated_count': updated_count}, status=status.HTTP_200_OK) + return success_response(data={'updated_count': updated_count}, request=request) @action(detail=False, methods=['post'], url_path='auto_generate_content', url_name='auto_generate_content') def auto_generate_content(self, request): @@ -120,17 +137,19 @@ class TasksViewSet(SiteSectorModelViewSet): ids = request.data.get('ids', []) if not ids: logger.warning("auto_generate_content: No IDs provided") - return Response({ - 'error': 'No IDs provided', - 'type': 'ValidationError' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) if len(ids) > 10: logger.warning(f"auto_generate_content: Too many IDs provided: {len(ids)}") - return Response({ - 'error': 'Maximum 10 tasks allowed for content generation', - 'type': 'ValidationError' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Maximum 10 tasks allowed for content generation', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) logger.info(f"auto_generate_content: Processing {len(ids)} task IDs: {ids}") @@ -151,11 +170,11 @@ class TasksViewSet(SiteSectorModelViewSet): if existing_count == 0: logger.error(f"auto_generate_content: No tasks found for IDs: {ids}") - return Response({ - 'error': f'No tasks found for the provided IDs: {ids}', - 'type': 'NotFound', - 'requested_ids': ids - }, status=status.HTTP_404_NOT_FOUND) + return error_response( + error=f'No tasks found for the provided IDs: {ids}', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) if existing_count < len(ids): missing_ids = set(ids) - set(existing_ids) @@ -171,11 +190,11 @@ class TasksViewSet(SiteSectorModelViewSet): logger.error(f" - Account ID: {account_id}") logger.error("=" * 80, exc_info=True) - return Response({ - 'error': f'Database error while querying tasks: {str(db_error)}', - 'type': 'OperationalError', - 'details': 'Failed to retrieve tasks from database. Please check database connection and try again.' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Database error while querying tasks: {str(db_error)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) # Try to queue Celery task, fall back to synchronous if Celery not available try: @@ -192,11 +211,11 @@ class TasksViewSet(SiteSectorModelViewSet): account_id=account_id ) logger.info(f"auto_generate_content: Celery task queued successfully: {task.id}") - return Response({ - 'success': True, - 'task_id': str(task.id), - 'message': 'Content generation started' - }, status=status.HTTP_200_OK) + return success_response( + data={'task_id': str(task.id)}, + message='Content generation started', + request=request + ) except KombuOperationalError as celery_error: logger.error("=" * 80) logger.error("CELERY ERROR: Failed to queue task") @@ -206,10 +225,11 @@ class TasksViewSet(SiteSectorModelViewSet): logger.error(f" - Account ID: {account_id}") logger.error("=" * 80, exc_info=True) - return Response({ - 'error': 'Task queue unavailable. Please try again.', - 'type': 'QueueError' - }, status=status.HTTP_503_SERVICE_UNAVAILABLE) + return error_response( + error='Task queue unavailable. Please try again.', + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + request=request + ) except Exception as celery_error: logger.error("=" * 80) logger.error("CELERY ERROR: Failed to queue task") @@ -227,16 +247,17 @@ class TasksViewSet(SiteSectorModelViewSet): account_id=account_id ) if result.get('success'): - return Response({ - 'success': True, - 'tasks_updated': result.get('count', 0), - 'message': 'Content generated successfully (synchronous)' - }, status=status.HTTP_200_OK) + return success_response( + data={'tasks_updated': result.get('count', 0)}, + message='Content generated successfully (synchronous)', + request=request + ) else: - return Response({ - 'error': result.get('error', 'Content generation failed'), - 'type': 'TaskExecutionError' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=result.get('error', 'Content generation failed'), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) else: # Celery not available - execute synchronously logger.info(f"auto_generate_content: Executing synchronously (Celery not available)") @@ -247,17 +268,18 @@ class TasksViewSet(SiteSectorModelViewSet): ) if result.get('success'): logger.info(f"auto_generate_content: Synchronous execution successful: {result.get('count', 0)} tasks updated") - return Response({ - 'success': True, - 'tasks_updated': result.get('count', 0), - 'message': 'Content generated successfully' - }, status=status.HTTP_200_OK) + return success_response( + data={'tasks_updated': result.get('count', 0)}, + message='Content generated successfully', + request=request + ) else: logger.error(f"auto_generate_content: Synchronous execution failed: {result.get('error', 'Unknown error')}") - return Response({ - 'error': result.get('error', 'Content generation failed'), - 'type': 'TaskExecutionError' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=result.get('error', 'Content generation failed'), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except ImportError as import_error: logger.error(f"auto_generate_content: ImportError - tasks module not available: {str(import_error)}") @@ -268,21 +290,22 @@ class TasksViewSet(SiteSectorModelViewSet): updated_count = tasks.update(status='completed', content='[AI content generation not available]') logger.info(f"auto_generate_content: Updated {updated_count} tasks (AI generation not available)") - return Response({ - 'updated_count': updated_count, - 'message': 'Tasks updated (AI generation not available)' - }, status=status.HTTP_200_OK) + return success_response( + data={'updated_count': updated_count}, + message='Tasks updated (AI generation not available)', + request=request + ) except (OperationalError, DatabaseError) as db_error: logger.error("=" * 80) logger.error("DATABASE ERROR: Failed to update tasks") logger.error(f" - Error type: {type(db_error).__name__}") logger.error(f" - Error message: {str(db_error)}") logger.error("=" * 80, exc_info=True) - return Response({ - 'error': f'Database error while updating tasks: {str(db_error)}', - 'type': 'OperationalError', - 'details': 'Failed to update tasks in database. Please check database connection.' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Database error while updating tasks: {str(db_error)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except (OperationalError, DatabaseError) as db_error: logger.error("=" * 80) @@ -293,11 +316,11 @@ class TasksViewSet(SiteSectorModelViewSet): logger.error(f" - Account ID: {account_id}") logger.error("=" * 80, exc_info=True) - return Response({ - 'error': f'Database error during content generation: {str(db_error)}', - 'type': 'OperationalError', - 'details': 'A database operation failed. This may be due to connection issues, constraint violations, or data integrity problems. Check the logs for more details.' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Database error during content generation: {str(db_error)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except IntegrityError as integrity_error: logger.error("=" * 80) @@ -306,18 +329,19 @@ class TasksViewSet(SiteSectorModelViewSet): logger.error(f" - Task IDs: {ids}") logger.error("=" * 80, exc_info=True) - return Response({ - 'error': f'Data integrity error: {str(integrity_error)}', - 'type': 'IntegrityError', - 'details': 'The operation violated database constraints. This may indicate missing required relationships or invalid data.' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Data integrity error: {str(integrity_error)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except ValidationError as validation_error: logger.error(f"auto_generate_content: ValidationError: {str(validation_error)}") - return Response({ - 'error': f'Validation error: {str(validation_error)}', - 'type': 'ValidationError' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error=f'Validation error: {str(validation_error)}', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) except Exception as e: logger.error("=" * 80) @@ -328,11 +352,11 @@ class TasksViewSet(SiteSectorModelViewSet): logger.error(f" - Account ID: {account_id}") logger.error("=" * 80, exc_info=True) - return Response({ - 'error': f'Unexpected error: {str(e)}', - 'type': type(e).__name__, - 'details': 'An unexpected error occurred. Please check the logs for more details.' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Unexpected error: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except Exception as outer_error: logger.error("=" * 80) @@ -341,10 +365,11 @@ class TasksViewSet(SiteSectorModelViewSet): logger.error(f" - Error message: {str(outer_error)}") logger.error("=" * 80, exc_info=True) - return Response({ - 'error': f'Critical error: {str(outer_error)}', - 'type': type(outer_error).__name__ - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Critical error: {str(outer_error)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) class ImagesViewSet(SiteSectorModelViewSet): @@ -383,30 +408,38 @@ class ImagesViewSet(SiteSectorModelViewSet): try: image = Images.objects.get(pk=pk) except Images.DoesNotExist: - return Response({ - 'error': 'Image not found' - }, status=status.HTTP_404_NOT_FOUND) + return error_response( + error='Image not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) # Check if image has a local path if not image.image_path: - return Response({ - 'error': 'No local file path available for this image' - }, status=status.HTTP_404_NOT_FOUND) + return error_response( + error='No local file path available for this image', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) file_path = image.image_path # Verify file exists at the saved path if not os.path.exists(file_path): logger.error(f"[serve_image_file] Image {pk} - File not found at saved path: {file_path}") - return Response({ - 'error': f'Image file not found at: {file_path}' - }, status=status.HTTP_404_NOT_FOUND) + return error_response( + error=f'Image file not found at: {file_path}', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) # Check if file is readable if not os.access(file_path, os.R_OK): - return Response({ - 'error': 'Image file is not readable' - }, status=status.HTTP_403_FORBIDDEN) + return error_response( + error='Image file is not readable', + status_code=status.HTTP_403_FORBIDDEN, + request=request + ) # Determine content type from file extension import mimetypes @@ -422,31 +455,45 @@ class ImagesViewSet(SiteSectorModelViewSet): filename=os.path.basename(file_path) ) except Exception as e: - return Response({ - 'error': f'Failed to serve file: {str(e)}' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Failed to serve file: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except Images.DoesNotExist: - return Response({ - 'error': 'Image not found' - }, status=status.HTTP_404_NOT_FOUND) + return error_response( + error='Image not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Error serving image file: {str(e)}", exc_info=True) - return Response({ - 'error': f'Failed to serve image: {str(e)}' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Failed to serve image: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) @action(detail=False, methods=['post'], url_path='auto_generate', url_name='auto_generate_images') def auto_generate_images(self, request): """Auto-generate images for tasks using AI""" task_ids = request.data.get('task_ids', []) if not task_ids: - return Response({'error': 'No task IDs provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No task IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) if len(task_ids) > 10: - return Response({'error': 'Maximum 10 tasks allowed for image generation'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Maximum 10 tasks allowed for image generation', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) # Get account account = getattr(request, 'account', None) @@ -464,11 +511,11 @@ class ImagesViewSet(SiteSectorModelViewSet): payload={'ids': task_ids}, account_id=account_id ) - return Response({ - 'success': True, - 'task_id': str(task.id), - 'message': 'Image generation started' - }, status=status.HTTP_200_OK) + return success_response( + data={'task_id': str(task.id)}, + message='Image generation started', + request=request + ) else: # Celery not available - execute synchronously result = run_ai_task( @@ -477,33 +524,39 @@ class ImagesViewSet(SiteSectorModelViewSet): account_id=account_id ) if result.get('success'): - return Response({ - 'success': True, - 'images_created': result.get('count', 0), - 'message': result.get('message', 'Image generation completed') - }, status=status.HTTP_200_OK) + return success_response( + data={'images_created': result.get('count', 0)}, + message=result.get('message', 'Image generation completed'), + request=request + ) else: - return Response({ - 'error': result.get('error', 'Image generation failed') - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=result.get('error', 'Image generation failed'), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except KombuOperationalError as e: - return Response({ - 'error': 'Task queue unavailable. Please try again.', - 'type': 'QueueError' - }, status=status.HTTP_503_SERVICE_UNAVAILABLE) + return error_response( + error='Task queue unavailable. Please try again.', + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + request=request + ) except ImportError: # Tasks module not available - return Response({ - 'error': 'Image generation task not available' - }, status=status.HTTP_503_SERVICE_UNAVAILABLE) + return error_response( + error='Image generation task not available', + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + request=request + ) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Error queuing image generation task: {str(e)}", exc_info=True) - return Response({ - 'error': f'Failed to start image generation: {str(e)}', - 'type': 'TaskError' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=f'Failed to start image generation: {str(e)}', + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update') def bulk_update(self, request): @@ -518,7 +571,11 @@ class ImagesViewSet(SiteSectorModelViewSet): status_value = request.data.get('status') if not status_value: - return Response({'error': 'No status provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No status provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) queryset = self.get_queryset() @@ -534,13 +591,21 @@ class ImagesViewSet(SiteSectorModelViewSet): Q(content=content) | Q(task=content.task) ).update(status=status_value) except Content.DoesNotExist: - return Response({'error': 'Content not found'}, status=status.HTTP_404_NOT_FOUND) + return error_response( + error='Content not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) elif image_ids: updated_count = queryset.filter(id__in=image_ids).update(status=status_value) else: - return Response({'error': 'Either content_id or ids must be provided'}, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='Either content_id or ids must be provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) - return Response({'updated_count': updated_count}, status=status.HTTP_200_OK) + return success_response(data={'updated_count': updated_count}, request=request) @action(detail=False, methods=['get'], url_path='content_images', url_name='content_images') def content_images(self, request): @@ -621,10 +686,13 @@ class ImagesViewSet(SiteSectorModelViewSet): # Sort by content title grouped_data.sort(key=lambda x: x['content_title']) - return Response({ - 'count': len(grouped_data), - 'results': grouped_data - }, status=status.HTTP_200_OK) + return success_response( + data={ + 'count': len(grouped_data), + 'results': grouped_data + }, + request=request + ) @action(detail=False, methods=['post'], url_path='generate_images', url_name='generate_images') def generate_images(self, request): @@ -636,10 +704,11 @@ class ImagesViewSet(SiteSectorModelViewSet): content_id = request.data.get('content_id') if not image_ids: - return Response({ - 'error': 'No image IDs provided', - 'type': 'ValidationError' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No image IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) account_id = account.id if account else None @@ -651,11 +720,11 @@ class ImagesViewSet(SiteSectorModelViewSet): account_id=account_id, content_id=content_id ) - return Response({ - 'success': True, - 'task_id': str(task.id), - 'message': 'Image generation started' - }, status=status.HTTP_200_OK) + return success_response( + data={'task_id': str(task.id)}, + message='Image generation started', + request=request + ) else: # Fallback to synchronous execution (for testing) result = process_image_generation_queue( @@ -663,21 +732,25 @@ class ImagesViewSet(SiteSectorModelViewSet): account_id=account_id, content_id=content_id ) - return Response(result, status=status.HTTP_200_OK) + return success_response(data=result, request=request) except Exception as e: logger.error(f"[generate_images] Error: {str(e)}", exc_info=True) - return Response({ - 'error': str(e), - 'type': 'ExecutionError' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=str(e), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) class ContentViewSet(SiteSectorModelViewSet): """ ViewSet for managing task content + Unified API Standard v1.0 compliant """ queryset = Content.objects.all() serializer_class = ContentSerializer pagination_class = CustomPageNumberPagination + throttle_scope = 'writer' + throttle_classes = [DebugScopedRateThrottle] filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] search_fields = ['title', 'meta_title', 'primary_keyword'] @@ -702,10 +775,11 @@ class ContentViewSet(SiteSectorModelViewSet): ids = request.data.get('ids', []) if not ids: - return Response({ - 'error': 'No IDs provided', - 'type': 'ValidationError' - }, status=status.HTTP_400_BAD_REQUEST) + return error_response( + error='No IDs provided', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) account_id = account.id if account else None @@ -717,11 +791,11 @@ class ContentViewSet(SiteSectorModelViewSet): payload={'ids': ids}, account_id=account_id ) - return Response({ - 'success': True, - 'task_id': str(task.id), - 'message': 'Image prompt generation started' - }, status=status.HTTP_200_OK) + return success_response( + data={'task_id': str(task.id)}, + message='Image prompt generation started', + request=request + ) else: # Fallback to synchronous execution result = run_ai_task( @@ -730,19 +804,21 @@ class ContentViewSet(SiteSectorModelViewSet): account_id=account_id ) if result.get('success'): - return Response({ - 'success': True, - 'prompts_created': result.get('count', 0), - 'message': 'Image prompts generated successfully' - }, status=status.HTTP_200_OK) + return success_response( + data={'prompts_created': result.get('count', 0)}, + message='Image prompts generated successfully', + request=request + ) else: - return Response({ - 'error': result.get('error', 'Image prompt generation failed'), - 'type': 'TaskExecutionError' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=result.get('error', 'Image prompt generation failed'), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) except Exception as e: - return Response({ - 'error': str(e), - 'type': 'ExecutionError' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return error_response( + error=str(e), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + request=request + ) diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 7cda2608..2e3af5f3 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -17,6 +17,11 @@ SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-)#i8!6+_&j97eb_4actu86=qtg # Set DEBUG=False via environment variable for production deployments DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' +# Unified API Standard v1.0 Feature Flags +# Set IGNY8_USE_UNIFIED_EXCEPTION_HANDLER=True to enable unified exception handler +# Set IGNY8_DEBUG_THROTTLE=True to bypass rate limiting in development +IGNY8_DEBUG_THROTTLE = os.getenv('IGNY8_DEBUG_THROTTLE', str(DEBUG)).lower() == 'true' + ALLOWED_HOSTS = [ '*', # Allow all hosts for flexibility 'api.igny8.com', @@ -73,6 +78,7 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'igny8_core.middleware.request_id.RequestIDMiddleware', # Request ID tracking (must be early) 'igny8_core.auth.middleware.AccountContextMiddleware', # Multi-account support # AccountContextMiddleware sets request.account from JWT 'igny8_core.middleware.resource_tracker.ResourceTrackingMiddleware', # Resource tracking for admin debug @@ -205,6 +211,40 @@ REST_FRAMEWORK = { 'igny8_core.api.authentication.CSRFExemptSessionAuthentication', # Session auth without CSRF for API 'rest_framework.authentication.BasicAuthentication', # Enable basic auth as fallback ], + # Unified API Standard v1.0 Configuration + # Exception handler - wraps all errors in unified format + # Unified API Standard v1.0: Exception handler enabled by default + # Set IGNY8_USE_UNIFIED_EXCEPTION_HANDLER=False to disable + 'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler' if os.getenv('IGNY8_USE_UNIFIED_EXCEPTION_HANDLER', 'True').lower() == 'false' else 'igny8_core.api.exception_handlers.custom_exception_handler', + # Rate limiting - configured but bypassed in DEBUG mode + 'DEFAULT_THROTTLE_CLASSES': [ + 'igny8_core.api.throttles.DebugScopedRateThrottle', + ], + 'DEFAULT_THROTTLE_RATES': { + # AI Functions - Expensive operations + 'ai_function': '10/min', # AI content generation, clustering + 'image_gen': '15/min', # Image generation + # Content Operations + 'content_write': '30/min', # Content creation, updates + 'content_read': '100/min', # Content listing, retrieval + # Authentication + 'auth': '20/min', # Login, register, password reset + 'auth_strict': '5/min', # Sensitive auth operations + # Planner Operations + 'planner': '60/min', # Keyword, cluster, idea operations + 'planner_ai': '10/min', # AI-powered planner operations + # Writer Operations + 'writer': '60/min', # Task, content management + 'writer_ai': '10/min', # AI-powered writer operations + # System Operations + 'system': '100/min', # Settings, prompts, profiles + 'system_admin': '30/min', # Admin-only system operations + # Billing Operations + 'billing': '30/min', # Credit queries, usage logs + 'billing_admin': '10/min', # Credit management (admin) + # Default fallback + 'default': '100/min', # Default for endpoints without scope + }, } # CORS Configuration diff --git a/frontend/src/components/dashboard/UsageChartWidget.tsx b/frontend/src/components/dashboard/UsageChartWidget.tsx index 9969bc08..ba28a287 100644 --- a/frontend/src/components/dashboard/UsageChartWidget.tsx +++ b/frontend/src/components/dashboard/UsageChartWidget.tsx @@ -65,13 +65,13 @@ export default function UsageChartWidget() {

By Operation

- {Object.entries(usageSummary.by_operation).map(([op, stats]) => ( + {usageSummary.by_operation && Object.entries(usageSummary.by_operation).map(([op, stats]) => (
{op.replace('_', ' ')} {stats.credits} credits (${(Number(stats.cost) || 0).toFixed(2)})
))} - {Object.keys(usageSummary.by_operation).length === 0 && ( + {(!usageSummary.by_operation || Object.keys(usageSummary.by_operation || {}).length === 0) && (
No usage data available
)}
diff --git a/frontend/src/pages/Settings/ApiMonitor.tsx b/frontend/src/pages/Settings/ApiMonitor.tsx index ded3aee0..bbef8e6d 100644 --- a/frontend/src/pages/Settings/ApiMonitor.tsx +++ b/frontend/src/pages/Settings/ApiMonitor.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import PageMeta from "../../components/common/PageMeta"; import ComponentCard from "../../components/common/ComponentCard"; -import { API_BASE_URL } from "../../services/api"; +import { API_BASE_URL, fetchContentImages, fetchUsageLimits, fetchAPI } from "../../services/api"; interface EndpointStatus { endpoint: string; @@ -10,6 +10,8 @@ interface EndpointStatus { responseTime?: number; lastChecked?: string; error?: string; + apiStatus?: 'healthy' | 'warning' | 'error'; // API endpoint status + dataStatus?: 'healthy' | 'warning' | 'error'; // Page data population status } interface EndpointGroup { @@ -18,6 +20,8 @@ interface EndpointGroup { path: string; method: string; description: string; + pageFetchFunction?: () => Promise; // Optional: function to test page data population + dataValidator?: (data: any) => boolean; // Optional: function to validate data is populated }[]; } @@ -64,6 +68,19 @@ const endpointGroups: EndpointGroup[] = [ { path: "/v1/writer/content/", method: "GET", description: "List content" }, { path: "/v1/writer/content/generate_image_prompts/", method: "POST", description: "Image prompts" }, { path: "/v1/writer/images/", method: "GET", description: "List images" }, + { + path: "/v1/writer/images/content_images/", + method: "GET", + description: "Content images", + pageFetchFunction: async () => { + const data = await fetchContentImages({}); + return data; + }, + dataValidator: (data: any) => { + // Check if data has results array with content + return data && data.results && Array.isArray(data.results) && data.results.length > 0; + } + }, { path: "/v1/writer/images/generate_images/", method: "POST", description: "AI images" }, ], }, @@ -71,6 +88,21 @@ const endpointGroups: EndpointGroup[] = [ name: "System & Billing", endpoints: [ { path: "/v1/system/prompts/", method: "GET", description: "List prompts" }, + { + path: "/v1/system/prompts/by_type/clustering/", + method: "GET", + description: "Get prompt by type", + pageFetchFunction: async () => { + const response = await fetchAPI('/v1/system/prompts/by_type/clustering/'); + const data = response?.data || response; + return data; + }, + dataValidator: (data: any) => { + // Check if prompt data exists and has prompt_value + return data && data.prompt_type && (data.prompt_value !== null && data.prompt_value !== undefined); + } + }, + { path: "/v1/system/prompts/save/", method: "POST", description: "Save prompt" }, { path: "/v1/system/author-profiles/", method: "GET", description: "List author profiles" }, { path: "/v1/system/strategies/", method: "GET", description: "List strategies" }, { path: "/v1/system/settings/integrations/1/test/", method: "POST", description: "Test integration" }, @@ -78,6 +110,19 @@ const endpointGroups: EndpointGroup[] = [ { path: "/v1/billing/credits/balance/balance/", method: "GET", description: "Credit balance" }, { path: "/v1/billing/credits/usage/", method: "GET", description: "Usage logs" }, { path: "/v1/billing/credits/usage/summary/", method: "GET", description: "Usage summary" }, + { + path: "/v1/billing/credits/usage/limits/", + method: "GET", + description: "Usage limits", + pageFetchFunction: async () => { + const data = await fetchUsageLimits(); + return data; + }, + dataValidator: (data: any) => { + // Check if limits array exists and has content + return data && data.limits && Array.isArray(data.limits) && data.limits.length > 0; + } + }, { path: "/v1/billing/credits/transactions/", method: "GET", description: "Transactions" }, ], }, @@ -127,7 +172,7 @@ export default function ApiMonitor() { return saved ? parseInt(saved, 10) : 30; }); - const checkEndpoint = useCallback(async (path: string, method: string) => { + const checkEndpoint = useCallback(async (path: string, method: string, endpointConfig?: { pageFetchFunction?: () => Promise; dataValidator?: (data: any) => boolean }) => { const key = `${method}:${path}`; // Set checking status @@ -141,6 +186,8 @@ export default function ApiMonitor() { })); const startTime = Date.now(); + let apiStatus: 'healthy' | 'warning' | 'error' = 'healthy'; + let dataStatus: 'healthy' | 'warning' | 'error' = 'healthy'; try { // Get token from auth store or localStorage @@ -209,10 +256,19 @@ export default function ApiMonitor() { // Determine status based on response let status: 'healthy' | 'warning' | 'error' = 'healthy'; let responseText = ''; + let responseData: any = null; - // Read response body for debugging (but don't log errors for expected 400s) + // Read response body for debugging and content validation try { responseText = await response.text(); + // Try to parse JSON to check unified API response format + if (responseText && responseText.trim().startsWith('{')) { + try { + responseData = JSON.parse(responseText); + } catch (e) { + // Not JSON, ignore + } + } } catch (e) { // Ignore body read errors } @@ -236,9 +292,56 @@ export default function ApiMonitor() { status = 'warning'; } } else if (method === 'GET') { - // GET: 2xx = healthy, 401/403 = warning (needs auth), 404 = error, 5xx = error + // GET: 2xx = healthy, 401/403 = warning (needs auth), 404 = error, 429 = warning (rate limit), 5xx = error if (response.status >= 200 && response.status < 300) { - status = 'healthy'; + // Check unified API response format for errors or empty data + if (responseData) { + // Check if response has success: false (unified format error) + if (responseData.success === false) { + status = 'error'; // API returned an error in unified format + } else if (responseData.success === true) { + // Check if data is empty for endpoints that should return data + // These endpoints should have data: {count: X, results: [...]} or data: {...} + const shouldHaveData = + path.includes('/content_images/') || + path.includes('/prompts/by_type/') || + path.includes('/usage/limits/') || + path.includes('/prompts/') && !path.includes('/save/'); + + if (shouldHaveData) { + // Check if data field exists and has content + if (responseData.data === null || responseData.data === undefined) { + status = 'warning'; // Missing data field + } else if (Array.isArray(responseData.data) && responseData.data.length === 0) { + // Empty array might be OK for some endpoints, but check if results should exist + if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) { + // These endpoints should return data, empty might indicate a problem + status = 'warning'; // Empty data - might indicate configuration issue + } + } else if (typeof responseData.data === 'object' && responseData.data !== null) { + // Check if it's a paginated response with empty results + if (responseData.data.results && Array.isArray(responseData.data.results) && responseData.data.results.length === 0) { + // Empty results might be OK, but for critical endpoints it's a warning + if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) { + status = 'warning'; // Empty results - might indicate data issue + } + } else if (responseData.data.count !== undefined && responseData.data.count === 0) { + // Paginated response with count: 0 + if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) { + status = 'warning'; // No data available - might indicate configuration issue + } + } + } + } + } + } + + // If status is still healthy after content checks, keep it healthy + if (status === 'healthy') { + status = 'healthy'; // HTTP 2xx and valid response = healthy + } + } else if (response.status === 429) { + status = 'warning'; // Rate limited - endpoint exists but temporarily throttled } else if (response.status === 401 || response.status === 403) { status = 'warning'; // Endpoint exists, needs authentication } else if (response.status === 404) { @@ -249,12 +352,25 @@ export default function ApiMonitor() { status = 'warning'; } } else if (method === 'POST') { - // POST: 400 = healthy (endpoint exists and validates), 401/403 = warning, 404 = error, 5xx = error + // POST: 400 = healthy (endpoint exists and validates), 401/403 = warning, 404 = error, 429 = warning (rate limit), 5xx = error if (response.status === 400) { // 400 means endpoint exists and validation works - this is healthy for monitoring - 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) { - 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) { status = 'warning'; // Endpoint exists, needs authentication } else if (response.status === 404) { @@ -266,13 +382,75 @@ export default function ApiMonitor() { } } + // Store API status + apiStatus = status; + + // Now check page data population if pageFetchFunction is configured + if (endpointConfig?.pageFetchFunction) { + try { + const pageData = await endpointConfig.pageFetchFunction(); + + // Validate data using validator if provided + if (endpointConfig.dataValidator) { + const isValid = endpointConfig.dataValidator(pageData); + if (!isValid) { + dataStatus = 'warning'; // Data exists but doesn't pass validation (e.g., empty) + } else { + dataStatus = 'healthy'; // Data is valid and populated + } + } else { + // If no validator, just check if data exists + if (pageData === null || pageData === undefined) { + dataStatus = 'error'; + } else { + dataStatus = 'healthy'; + } + } + } catch (error: any) { + // Page fetch function failed + dataStatus = 'error'; + console.warn(`[API Monitor] Page data fetch failed for ${path}:`, error.message); + } + } else { + // No page fetch function configured, data status matches API status + dataStatus = apiStatus; + } + + // Combine API and data statuses - both must be healthy for overall healthy + // If either is error, overall is error + // If either is warning, overall is warning + if (apiStatus === 'error' || dataStatus === 'error') { + status = 'error'; + } else if (apiStatus === 'warning' || dataStatus === 'warning') { + status = 'warning'; + } else { + status = 'healthy'; + } + + // Log warnings/errors for issues detected in response content + if (status === 'warning' || status === 'error') { + if (responseData) { + if (responseData.success === false) { + console.warn(`[API Monitor] ${method} ${path}: Unified format error - ${responseData.error || 'Unknown error'}`); + } else if (responseData.data === null || responseData.data === undefined) { + console.warn(`[API Monitor] ${method} ${path}: Missing data field in response`); + } else if (Array.isArray(responseData.data) && responseData.data.length === 0) { + console.warn(`[API Monitor] ${method} ${path}: Empty data array returned`); + } else if (responseData.data?.results && Array.isArray(responseData.data.results) && responseData.data.results.length === 0) { + console.warn(`[API Monitor] ${method} ${path}: Empty results array returned`); + } else if (responseData.data?.count === 0) { + console.warn(`[API Monitor] ${method} ${path}: No data available (count: 0)`); + } + } + } + // Suppress console errors for expected monitoring responses // Only log real errors (5xx, network errors, or unexpected 4xx for GET endpoints) // Don't log expected 400s for POST endpoints (they indicate validation is working) const isExpectedResponse = (method === 'POST' && response.status === 400) || // Expected validation error (actualMethod === 'OPTIONS' && response.status === 200) || // Expected OPTIONS success - (method === 'GET' && response.status >= 200 && response.status < 300); // Expected GET success + (method === 'GET' && response.status >= 200 && response.status < 300 && status === 'healthy'); // Expected GET success with valid data if (!isExpectedResponse && (response.status >= 500 || (method === 'GET' && response.status === 404) || @@ -289,6 +467,8 @@ export default function ApiMonitor() { status, responseTime, lastChecked: new Date().toISOString(), + apiStatus, + dataStatus, }, })); } catch (err: any) { @@ -314,7 +494,10 @@ export default function ApiMonitor() { // Check all endpoints in parallel (but limit concurrency) const allChecks = endpointGroups.flatMap(group => - group.endpoints.map(ep => checkEndpoint(ep.path, ep.method)) + group.endpoints.map(ep => checkEndpoint(ep.path, ep.method, { + pageFetchFunction: ep.pageFetchFunction, + dataValidator: ep.dataValidator + })) ); // Check in batches of 5 to avoid overwhelming the server @@ -459,13 +642,25 @@ export default function ApiMonitor() {
- - {getStatusIcon(status.status)} - {status.status} - +
+ + {getStatusIcon(status.status)} + {status.status} + + {status.apiStatus && status.dataStatus && endpoint.pageFetchFunction && status.apiStatus !== status.dataStatus && ( +
+
+ API: {status.apiStatus} +
+
+ Data: {status.dataStatus} +
+
+ )} +
{status.responseTime ? ( diff --git a/frontend/src/pages/Settings/Status.tsx b/frontend/src/pages/Settings/Status.tsx index db5e511a..50d02fff 100644 --- a/frontend/src/pages/Settings/Status.tsx +++ b/frontend/src/pages/Settings/Status.tsx @@ -63,8 +63,10 @@ export default function Status() { const fetchStatus = async () => { try { - const data = await fetchAPI('/v1/system/status/'); - setStatus(data); + const response = await fetchAPI('/v1/system/status/'); + // Handle unified API response format: {success: true, data: {...}} + const statusData = response?.data || response; + setStatus(statusData); setError(null); } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); diff --git a/frontend/src/pages/Thinker/Prompts.tsx b/frontend/src/pages/Thinker/Prompts.tsx index a3f8555a..f3ee9f71 100644 --- a/frontend/src/pages/Thinker/Prompts.tsx +++ b/frontend/src/pages/Thinker/Prompts.tsx @@ -75,7 +75,10 @@ export default function Prompts() { try { const promises = PROMPT_TYPES.map(async (type) => { try { - const data = await fetchAPI(`/v1/system/prompts/by_type/${type.key}/`); + const response = await fetchAPI(`/v1/system/prompts/by_type/${type.key}/`); + // Extract data field from unified API response format + // Response format: { success: true, data: {...}, request_id: "..." } + const data = response?.data || response; return { key: type.key, data }; } catch (error) { console.error(`Error loading prompt ${type.key}:`, error); @@ -116,7 +119,7 @@ export default function Prompts() { setSaving({ ...saving, [promptType]: true }); try { - const data = await fetchAPI('/v1/system/prompts/save/', { + const response = await fetchAPI('/v1/system/prompts/save/', { method: 'POST', body: JSON.stringify({ prompt_type: promptType, @@ -124,11 +127,15 @@ export default function Prompts() { }), }); - if (data.success) { - toast.success(data.message || 'Prompt saved successfully'); + // Extract data field from unified API response format + // Response format: { success: true, data: {...}, message: "...", request_id: "..." } + const data = response?.data || response; + + if (response.success) { + toast.success(response.message || 'Prompt saved successfully'); await loadPrompts(); // Reload to get updated data } else { - throw new Error(data.error || 'Failed to save prompt'); + throw new Error(response.error || 'Failed to save prompt'); } } catch (error: any) { console.error('Error saving prompt:', error); @@ -145,18 +152,22 @@ export default function Prompts() { setSaving({ ...saving, [promptType]: true }); try { - const data = await fetchAPI('/v1/system/prompts/reset/', { + const response = await fetchAPI('/v1/system/prompts/reset/', { method: 'POST', body: JSON.stringify({ prompt_type: promptType, }), }); - if (data.success) { - toast.success(data.message || 'Prompt reset to default'); + // Extract data field from unified API response format + // Response format: { success: true, data: {...}, message: "...", request_id: "..." } + const data = response?.data || response; + + if (response.success) { + toast.success(response.message || 'Prompt reset to default'); await loadPrompts(); // Reload to get default value } else { - throw new Error(data.error || 'Failed to reset prompt'); + throw new Error(response.error || 'Failed to reset prompt'); } } catch (error: any) { console.error('Error resetting prompt:', error); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4eb655e0..ea54abdc 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1114,7 +1114,10 @@ export async function fetchContentImages(filters: ContentImagesFilters = {}): Pr if (filters.sector_id) params.append('sector_id', filters.sector_id.toString()); const queryString = params.toString(); - return fetchAPI(`/v1/writer/images/content_images/${queryString ? `?${queryString}` : ''}`); + const response = await fetchAPI(`/v1/writer/images/content_images/${queryString ? `?${queryString}` : ''}`); + // Extract data field from unified API response format + // Response format: { success: true, data: { count: ..., results: [...] }, request_id: "..." } + return response?.data || response; } export async function bulkUpdateImagesStatus(contentId: number, status: string): Promise<{ updated_count: number }> { @@ -1442,7 +1445,11 @@ export async function fetchUsageSummary(startDate?: string, endDate?: string): P if (endDate) params.append('end_date', endDate); const queryString = params.toString(); - return fetchAPI(`/v1/billing/credits/usage/summary/${queryString ? `?${queryString}` : ''}`); + const response = await fetchAPI(`/v1/billing/credits/usage/summary/${queryString ? `?${queryString}` : ''}`); + // Extract data field from unified API response format + // Response format: { success: true, data: {...}, request_id: "..." } + const summaryData = response?.data || response; + return summaryData; } export interface LimitCard { @@ -1464,7 +1471,10 @@ export async function fetchUsageLimits(): Promise { try { const response = await fetchAPI('/v1/billing/credits/usage/limits/'); console.log('Usage limits API response:', response); - return response; + // Extract data field from unified API response format + // Response format: { success: true, data: { limits: [...] }, request_id: "..." } + const limitsData = response?.data || response; + return limitsData; } catch (error) { console.error('Error fetching usage limits:', error); throw error;