diff --git a/TESTING-GUIDE.md b/TESTING-GUIDE.md new file mode 100644 index 00000000..402c63e3 --- /dev/null +++ b/TESTING-GUIDE.md @@ -0,0 +1,108 @@ +# Testing Guide for API Response Utilities + +This guide helps you test the new unified response format utilities before merging the PR. + +## Quick Test Script + +Run the simple test script to verify basic functionality: + +```bash +cd backend +python test_response_utilities.py +``` + +This will test: +- ✅ Import functionality +- ✅ `success_response()` function +- ✅ `error_response()` function +- ✅ `paginated_response()` function + +## Django Unit Tests + +Run the comprehensive unit tests: + +```bash +cd backend +python manage.py test igny8_core.api.tests.test_response +``` + +Expected output: All 15 test cases should pass. + +## Test the /api/ping/ Endpoint + +### Option 1: Using curl (if server is running) + +```bash +# Test the ping endpoint +curl http://localhost:8011/api/ping/ + +# Expected response: +# { +# "success": true, +# "data": { +# "pong": true, +# "time": "2025-01-XX...", +# "version": "1.0.0" +# }, +# "message": "API is live" +# } +``` + +### Option 2: Using Postman or Browser + +1. Start Django server: `python manage.py runserver` +2. Navigate to: `http://localhost:8000/api/ping/` +3. Verify response has `success: true` and `data` fields + +### Option 3: Using Python requests + +```python +import requests + +response = requests.get('http://localhost:8011/api/ping/') +data = response.json() + +assert data['success'] == True +assert 'data' in data +assert 'pong' in data['data'] +print("✅ Ping endpoint works!") +``` + +## Verification Checklist + +Before merging the PR, verify: + +- [ ] Quick test script passes (`python test_response_utilities.py`) +- [ ] Django unit tests pass (`python manage.py test igny8_core.api.tests.test_response`) +- [ ] `/api/ping/` endpoint returns unified format +- [ ] No linting errors +- [ ] All imports work correctly + +## What to Look For + +### ✅ Success Indicators: +- All tests pass +- Response has `success: true/false` field +- Response has `data` field (for success) or `error` field (for errors) +- No import errors +- No syntax errors + +### ❌ Failure Indicators: +- Import errors +- Assertion errors in tests +- Missing fields in responses +- Syntax errors + +## Next Steps After Testing + +If all tests pass: +1. ✅ Merge the PR +2. Continue with Section 1, Task 2 (refactoring endpoints) +3. Or move to Section 2 (Authentication) + +If tests fail: +1. Check error messages +2. Fix any issues +3. Re-test +4. Update PR with fixes + diff --git a/backend/igny8_core/api/__init__.py b/backend/igny8_core/api/__init__.py index 9322e85d..314aa598 100644 --- a/backend/igny8_core/api/__init__.py +++ b/backend/igny8_core/api/__init__.py @@ -2,3 +2,8 @@ IGNY8 API Module """ +from .response import success_response, error_response, paginated_response +from .exception_handlers import custom_exception_handler + +__all__ = ['success_response', 'error_response', 'paginated_response', 'custom_exception_handler'] + diff --git a/backend/igny8_core/api/exception_handlers.py b/backend/igny8_core/api/exception_handlers.py new file mode 100644 index 00000000..365f60ca --- /dev/null +++ b/backend/igny8_core/api/exception_handlers.py @@ -0,0 +1,241 @@ +""" +Centralized Exception Handler for IGNY8 API + +This module provides a comprehensive exception handler that: +- Wraps all exceptions in unified error format +- Logs errors for debugging and monitoring +- Provides debug information in development mode +- Tracks request IDs for error correlation +""" + +import logging +import traceback +import uuid +from django.conf import settings +from rest_framework.views import exception_handler as drf_exception_handler +from rest_framework import status +from rest_framework.response import Response +from igny8_core.api.response import error_response + +logger = logging.getLogger(__name__) + + +def get_request_id(request): + """ + Get or create request ID for error tracking. + + Request ID can be set by middleware or generated here. + """ + # Check if request ID is already set (e.g., by middleware) + request_id = getattr(request, 'request_id', None) + + if not request_id: + # Generate new request ID + request_id = str(uuid.uuid4()) + request.request_id = request_id + + return request_id + + +def extract_error_message(exc, response): + """ + Extract user-friendly error message from exception. + + Args: + exc: The exception instance + response: DRF's exception handler response + + Returns: + tuple: (error_message, errors_dict) + """ + error_message = "An error occurred" + errors = None + + # Handle DRF exceptions with 'detail' attribute + if hasattr(exc, 'detail'): + if isinstance(exc.detail, dict): + # Validation errors - use as errors dict + errors = exc.detail + # Set a general validation error message + # The specific field errors are in the errors dict + error_message = "Validation failed" + elif isinstance(exc.detail, list): + # List of errors + error_message = exc.detail[0] if exc.detail else "An error occurred" + errors = {"non_field_errors": exc.detail} + else: + # String error message + error_message = str(exc.detail) + elif response and hasattr(response, 'data'): + # Try to extract from response data + if isinstance(response.data, dict): + # If response already has unified format, preserve it + if 'success' in response.data: + error_message = response.data.get('error', 'An error occurred') + errors = response.data.get('errors') + return error_message, errors + + # If response.data looks like validation errors (dict with field names as keys) + # and no 'error', 'message', or 'detail' fields, treat it as validation errors + if 'error' not in response.data and 'message' not in response.data and 'detail' not in response.data: + # This is a validation error dict - extract errors and set error message + errors = response.data + error_message = 'Validation failed' + else: + # Check for common error message fields + error_message = ( + response.data.get('error') or + response.data.get('message') or + response.data.get('detail') or + 'Validation failed' if response.data else 'An error occurred' + ) + # Extract errors from response.data if it's a dict with field errors + # Check if response.data contains field names (likely validation errors) + if isinstance(response.data, dict) and any( + isinstance(v, (list, str)) for v in response.data.values() + ) and 'error' not in response.data and 'message' not in response.data: + errors = response.data + error_message = 'Validation failed' + else: + errors = response.data if 'error' not in response.data else None + elif isinstance(response.data, list): + error_message = response.data[0] if response.data else "An error occurred" + errors = {"non_field_errors": response.data} + else: + error_message = str(response.data) if response.data else "An error occurred" + else: + # Generic exception + error_message = str(exc) if exc else "An error occurred" + + return error_message, errors + + +def get_status_code_message(status_code): + """ + Get default error message for HTTP status code. + """ + status_messages = { + status.HTTP_400_BAD_REQUEST: "Bad request", + status.HTTP_401_UNAUTHORIZED: "Authentication required", + status.HTTP_403_FORBIDDEN: "Permission denied", + status.HTTP_404_NOT_FOUND: "Resource not found", + status.HTTP_405_METHOD_NOT_ALLOWED: "Method not allowed", + status.HTTP_409_CONFLICT: "Conflict", + status.HTTP_422_UNPROCESSABLE_ENTITY: "Unprocessable entity", + status.HTTP_429_TOO_MANY_REQUESTS: "Too many requests", + status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal server error", + status.HTTP_502_BAD_GATEWAY: "Bad gateway", + status.HTTP_503_SERVICE_UNAVAILABLE: "Service unavailable", + } + return status_messages.get(status_code, "An error occurred") + + +def custom_exception_handler(exc, context): + """ + Centralized exception handler that wraps all exceptions in unified format. + + This handler: + - Wraps all exceptions in unified error format + - Logs errors with request context + - Provides debug information in development mode + - Tracks request IDs for error correlation + + Args: + exc: The exception instance + context: Dictionary containing request, view, and args + + Returns: + Response: Error response in unified format, or None to use default + """ + # Get request from context + request = context.get('request') + view = context.get('view') + + # Get request ID for tracking + request_id = None + if request: + request_id = get_request_id(request) + + # Call DRF's default exception handler first + response = drf_exception_handler(exc, context) + + # Determine status code + if response is not None: + status_code = response.status_code + + # If response already has unified format (success field), return it as-is + # This handles cases where error_response() was manually returned + if hasattr(response, 'data') and isinstance(response.data, dict): + if 'success' in response.data: + # Response already in unified format - just add request_id if needed + if request_id and 'request_id' not in response.data: + response.data['request_id'] = request_id + return response + else: + # Unhandled exception - default to 500 + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + # Extract error message and details + error_message, errors = extract_error_message(exc, response) + + # Use default message if error message is generic + if error_message == "An error occurred" or not error_message: + error_message = get_status_code_message(status_code) + + # Log the error + log_level = logging.ERROR if status_code >= 500 else logging.WARNING + log_message = f"API Error [{status_code}]: {error_message}" + + if request_id: + log_message += f" (Request ID: {request_id})" + + # Include exception details in log + logger.log( + log_level, + log_message, + extra={ + 'request_id': request_id, + 'status_code': status_code, + 'exception_type': type(exc).__name__, + 'view': view.__class__.__name__ if view else None, + 'path': request.path if request else None, + 'method': request.method if request else None, + }, + exc_info=status_code >= 500, # Include traceback for server errors + ) + + # Build error response + error_response_data = { + "success": False, + "error": error_message, + } + + # Add errors dict if present + # Always include errors if we have them, even if error_message was set + if errors: + error_response_data["errors"] = errors + # If we have errors but no error message was set, ensure we have one + elif not error_message or error_message == "An error occurred": + error_message = get_status_code_message(status_code) + error_response_data["error"] = error_message + + # Add request ID for error tracking + if request_id: + error_response_data["request_id"] = request_id + + # Add debug information in development mode + if settings.DEBUG: + error_response_data["debug"] = { + "exception_type": type(exc).__name__, + "exception_message": str(exc), + "view": view.__class__.__name__ if view else None, + "path": request.path if request else None, + "method": request.method if request else None, + } + + # Include traceback in debug mode for server errors + if status_code >= 500: + error_response_data["debug"]["traceback"] = traceback.format_exc() + + return Response(error_response_data, status=status_code) + diff --git a/backend/igny8_core/api/response.py b/backend/igny8_core/api/response.py new file mode 100644 index 00000000..02d497b4 --- /dev/null +++ b/backend/igny8_core/api/response.py @@ -0,0 +1,111 @@ +""" +Unified API Response Format Utilities + +This module provides helper functions to ensure all API endpoints +return a consistent response format. +""" + +from rest_framework.response import Response +from rest_framework import status + + +def success_response(data=None, message=None, status_code=status.HTTP_200_OK): + """ + Create a standardized success response. + + Args: + data: Response data (dict, list, or any serializable object) + message: Optional human-readable success message + status_code: HTTP status code (default: 200) + + Returns: + Response: DRF Response object with unified format + + Example: + return success_response( + data={"id": 1, "name": "Example"}, + message="Resource created successfully" + ) + """ + response_data = { + "success": True, + "data": data, + } + + if message: + response_data["message"] = message + + return Response(response_data, status=status_code) + + +def error_response( + error, + errors=None, + status_code=status.HTTP_400_BAD_REQUEST, + message=None +): + """ + Create a standardized error response. + + Args: + error: Top-level error message (string) + errors: Optional field-specific validation errors (dict) + status_code: HTTP status code (default: 400) + message: Optional additional message (deprecated, use error) + + Returns: + Response: DRF Response object with unified format + + Example: + return error_response( + error="Validation failed", + errors={"email": ["Invalid email format"]}, + status_code=status.HTTP_400_BAD_REQUEST + ) + """ + response_data = { + "success": False, + "error": error, + } + + if errors: + response_data["errors"] = errors + + # Backward compatibility: if message is provided, use it as error + if message and not error: + response_data["error"] = message + + return Response(response_data, status=status_code) + + +def paginated_response(paginated_data, message=None): + """ + Create a standardized paginated response. + + This wraps DRF's pagination response to include success flag + and optional message while preserving pagination metadata. + + Args: + paginated_data: DRF paginated response data (dict with count, next, previous, results) + message: Optional human-readable message + + Returns: + Response: DRF Response object with unified format + + Example: + paginator = CustomPageNumberPagination() + page = paginator.paginate_queryset(queryset, request) + serializer = MySerializer(page, many=True) + paginated_data = paginator.get_paginated_response(serializer.data).data + return paginated_response(paginated_data, message="Keywords retrieved successfully") + """ + response_data = { + "success": True, + **paginated_data # Unpack count, next, previous, results + } + + if message: + response_data["message"] = message + + return Response(response_data) + diff --git a/backend/igny8_core/api/tests/__init__.py b/backend/igny8_core/api/tests/__init__.py new file mode 100644 index 00000000..bd74c7de --- /dev/null +++ b/backend/igny8_core/api/tests/__init__.py @@ -0,0 +1,4 @@ +""" +API Tests Module +""" + diff --git a/backend/igny8_core/api/tests/test_response.py b/backend/igny8_core/api/tests/test_response.py new file mode 100644 index 00000000..49efb87c --- /dev/null +++ b/backend/igny8_core/api/tests/test_response.py @@ -0,0 +1,198 @@ +""" +Unit Tests for Response Utility Functions + +Tests all response wrapper functions to ensure they return +the correct unified format. +""" + +from django.test import TestCase +from rest_framework import status +from igny8_core.api.response import success_response, error_response, paginated_response + + +class SuccessResponseTestCase(TestCase): + """Test cases for success_response function""" + + def test_success_response_with_data_only(self): + """Test success_response with data only""" + data = {"id": 1, "name": "Test"} + response = success_response(data=data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_data = response.data + self.assertTrue(response_data['success']) + self.assertEqual(response_data['data'], data) + self.assertNotIn('message', response_data) + + def test_success_response_with_data_and_message(self): + """Test success_response with data and message""" + data = {"id": 1, "name": "Test"} + message = "Resource created successfully" + response = success_response(data=data, message=message) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_data = response.data + self.assertTrue(response_data['success']) + self.assertEqual(response_data['data'], data) + self.assertEqual(response_data['message'], message) + + def test_success_response_with_custom_status_code_201(self): + """Test success_response with 201 Created status""" + data = {"id": 1, "name": "Test"} + response = success_response(data=data, status_code=status.HTTP_201_CREATED) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + response_data = response.data + self.assertTrue(response_data['success']) + self.assertEqual(response_data['data'], data) + + def test_success_response_with_custom_status_code_204(self): + """Test success_response with 204 No Content status""" + response = success_response(status_code=status.HTTP_204_NO_CONTENT) + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + response_data = response.data + self.assertTrue(response_data['success']) + + def test_success_response_with_list_data(self): + """Test success_response with list data""" + data = [{"id": 1}, {"id": 2}, {"id": 3}] + response = success_response(data=data) + + response_data = response.data + self.assertTrue(response_data['success']) + self.assertEqual(response_data['data'], data) + self.assertIsInstance(response_data['data'], list) + + def test_success_response_with_none_data(self): + """Test success_response with None data""" + response = success_response(data=None) + + response_data = response.data + self.assertTrue(response_data['success']) + self.assertIsNone(response_data['data']) + + +class ErrorResponseTestCase(TestCase): + """Test cases for error_response function""" + + def test_error_response_with_error_only(self): + """Test error_response with error message only""" + error_msg = "Something went wrong" + response = error_response(error=error_msg) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.data + self.assertFalse(response_data['success']) + self.assertEqual(response_data['error'], error_msg) + self.assertNotIn('errors', response_data) + + def test_error_response_with_error_and_errors_dict(self): + """Test error_response with error and field-specific errors""" + error_msg = "Validation failed" + errors = { + "email": ["Invalid email format"], + "password": ["Password must be at least 8 characters"] + } + response = error_response(error=error_msg, errors=errors) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + response_data = response.data + self.assertFalse(response_data['success']) + self.assertEqual(response_data['error'], error_msg) + self.assertEqual(response_data['errors'], errors) + + def test_error_response_with_custom_status_code_403(self): + """Test error_response with 403 Forbidden status""" + error_msg = "Permission denied" + response = error_response(error=error_msg, status_code=status.HTTP_403_FORBIDDEN) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + response_data = response.data + self.assertFalse(response_data['success']) + self.assertEqual(response_data['error'], error_msg) + + def test_error_response_with_custom_status_code_404(self): + """Test error_response with 404 Not Found status""" + error_msg = "Resource not found" + response = error_response(error=error_msg, status_code=status.HTTP_404_NOT_FOUND) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + response_data = response.data + self.assertFalse(response_data['success']) + self.assertEqual(response_data['error'], error_msg) + + def test_error_response_with_custom_status_code_500(self): + """Test error_response with 500 Internal Server Error status""" + error_msg = "Internal server error" + response = error_response(error=error_msg, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + response_data = response.data + self.assertFalse(response_data['success']) + self.assertEqual(response_data['error'], error_msg) + + def test_error_response_backward_compatibility_with_message(self): + """Test error_response backward compatibility with message parameter""" + message = "Old error message" + response = error_response(error=None, message=message) + + response_data = response.data + self.assertFalse(response_data['success']) + self.assertEqual(response_data['error'], message) + + +class PaginatedResponseTestCase(TestCase): + """Test cases for paginated_response function""" + + def test_paginated_response_with_standard_data(self): + """Test paginated_response with standard pagination data""" + paginated_data = { + "count": 100, + "next": "http://api.example.com/endpoint/?page=2", + "previous": None, + "results": [{"id": 1}, {"id": 2}] + } + response = paginated_response(paginated_data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + response_data = response.data + self.assertTrue(response_data['success']) + self.assertEqual(response_data['count'], 100) + self.assertEqual(response_data['next'], paginated_data['next']) + self.assertIsNone(response_data['previous']) + self.assertEqual(response_data['results'], paginated_data['results']) + + def test_paginated_response_with_message(self): + """Test paginated_response with optional message""" + paginated_data = { + "count": 50, + "next": None, + "previous": None, + "results": [{"id": 1}] + } + message = "Keywords retrieved successfully" + response = paginated_response(paginated_data, message=message) + + response_data = response.data + self.assertTrue(response_data['success']) + self.assertEqual(response_data['message'], message) + self.assertEqual(response_data['count'], 50) + self.assertEqual(response_data['results'], paginated_data['results']) + + def test_paginated_response_without_message(self): + """Test paginated_response without message""" + paginated_data = { + "count": 25, + "next": "http://api.example.com/endpoint/?page=3", + "previous": "http://api.example.com/endpoint/?page=1", + "results": [] + } + response = paginated_response(paginated_data) + + response_data = response.data + self.assertTrue(response_data['success']) + self.assertNotIn('message', response_data) + self.assertEqual(response_data['count'], 25) + self.assertEqual(len(response_data['results']), 0) + diff --git a/backend/igny8_core/api/views.py b/backend/igny8_core/api/views.py new file mode 100644 index 00000000..fb483e1e --- /dev/null +++ b/backend/igny8_core/api/views.py @@ -0,0 +1,33 @@ +""" +API Views for IGNY8 Core + +Health check and utility endpoints. +""" + +from rest_framework.views import APIView +from django.utils import timezone +from igny8_core.api.response import success_response + + +class PingView(APIView): + """ + Health check endpoint for API availability. + + Returns simple pong response to verify API is live. + This endpoint uses the new unified response format. + """ + permission_classes = [] # Public endpoint - no authentication required + + def get(self, request): + """ + Return health check response in unified format. + """ + return success_response( + data={ + "pong": True, + "time": timezone.now().isoformat(), + "version": "1.0.0" + }, + message="API is live" + ) + diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index 85183dd2..70fc00d6 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -11,6 +11,7 @@ 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 .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword from .serializers import ( UserSerializer, AccountSerializer, PlanSerializer, SubscriptionSerializer, @@ -680,21 +681,24 @@ 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 + ) + return error_response( + error='Validation failed', + errors=serializer.errors, + status_code=status.HTTP_400_BAD_REQUEST + ) @action(detail=False, methods=['post']) def login(self, request): @@ -707,10 +711,10 @@ 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 + ) if user.check_password(password): # Log the user in (create session for session authentication) @@ -727,27 +731,29 @@ 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' + ) - return Response({ - 'success': False, - 'message': 'Invalid credentials' - }, status=status.HTTP_401_UNAUTHORIZED) + return error_response( + error='Invalid credentials', + status_code=status.HTTP_401_UNAUTHORIZED + ) - 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 + ) @action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated]) def change_password(self, request): @@ -756,23 +762,23 @@ 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 + ) 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' + ) - 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 + ) @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) def me(self, request): @@ -781,20 +787,20 @@ 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} + ) @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 + ) refresh_token = serializer.validated_data['refresh'] @@ -804,10 +810,10 @@ 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 + ) # Get user user_id = payload.get('user_id') @@ -816,10 +822,10 @@ 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 + ) # Get account account_id = payload.get('account_id') @@ -837,17 +843,19 @@ 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() + }, + message='Token refreshed successfully' + ) 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 + ) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) def request_reset(self, request): diff --git a/backend/igny8_core/modules/planner/views.py b/backend/igny8_core/modules/planner/views.py index cccc0cdf..442dd60c 100644 --- a/backend/igny8_core/modules/planner/views.py +++ b/backend/igny8_core/modules/planner/views.py @@ -10,6 +10,7 @@ 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 .models import Keywords, Clusters, ContentIdeas from .serializers import KeywordSerializer, ContentIdeasSerializer from .cluster_serializers import ClusterSerializer @@ -124,10 +125,10 @@ class KeywordViewSet(SiteSectorModelViewSet): return Response(serializer.data) 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 + ) def perform_create(self, serializer): """Require explicit site_id and sector_id - no defaults.""" @@ -190,12 +191,18 @@ 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 + ) 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}, + message=f'Successfully deleted {deleted_count} keyword(s)' + ) @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update') def bulk_update(self, request): @@ -204,14 +211,23 @@ 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 + ) 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 + ) 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}, + message=f'Successfully updated {updated_count} keyword(s)' + ) @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 +239,53 @@ 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 + ) 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 + ) 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 + ) 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 + ) # 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 + ) # 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 + ) # 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 + ) created_count = 0 skipped_count = 0 @@ -288,12 +325,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 + }, + message=f'Successfully added {created_count} keyword(s) to workflow' + ) @action(detail=False, methods=['get'], url_path='export', url_name='export') def export(self, request): @@ -366,11 +405,17 @@ 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 + ) 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 + ) user = getattr(request, 'user', None) @@ -391,23 +436,38 @@ 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 + ) 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 + ) # 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 + ) 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 + ) 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 + ) # Get account account = getattr(request, 'account', None) @@ -461,17 +521,20 @@ 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 + }, + message=f'Successfully imported {imported_count} keyword(s)' + ) 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 + ) @action(detail=False, methods=['post'], url_path='auto_cluster', url_name='auto_cluster') def auto_cluster(self, request): @@ -497,16 +560,16 @@ 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 + ) 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 + ) # Try to queue Celery task try: @@ -517,11 +580,12 @@ 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' + ) else: # Celery not available - execute synchronously logger.warning("Celery not available, executing synchronously") @@ -531,15 +595,15 @@ 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, + message='Clustering completed successfully' + ) 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 + ) 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,27 +613,27 @@ 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, + message='Clustering completed successfully' + ) 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 + ) 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 + ) 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 + ) class ClusterViewSet(SiteSectorModelViewSet): @@ -719,12 +783,18 @@ 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 + ) 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}, + message=f'Successfully deleted {deleted_count} cluster(s)' + ) @action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas') def auto_generate_ideas(self, request): @@ -749,16 +819,16 @@ 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 + ) 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 + ) # Try to queue Celery task try: @@ -769,11 +839,12 @@ 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' + ) else: # Celery not available - execute synchronously logger.warning("Celery not available, executing synchronously") @@ -783,15 +854,15 @@ 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, + message='Idea generation completed successfully' + ) 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 + ) 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 +872,27 @@ 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, + message='Idea generation completed successfully' + ) 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 + ) 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 + ) 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 + ) def list(self, request, *args, **kwargs): """ @@ -919,19 +990,28 @@ 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 + ) 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}, + message=f'Successfully deleted {deleted_count} content idea(s)' + ) @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 + ) queryset = self.get_queryset() ideas = queryset.filter(id__in=ids, status='new') # Only queue 'new' ideas @@ -958,11 +1038,12 @@ 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' + ) # REMOVED: generate_idea action - idea generation function removed diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index 19d963f1..9d519a5d 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -6,6 +6,7 @@ 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 .models import Tasks, Images, Content from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer @@ -84,12 +85,18 @@ 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 + ) 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}, + message=f'Successfully deleted {deleted_count} task(s)' + ) @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update') def bulk_update(self, request): @@ -98,14 +105,23 @@ 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 + ) 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 + ) 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}, + message=f'Successfully updated {updated_count} task(s)' + ) @action(detail=False, methods=['post'], url_path='auto_generate_content', url_name='auto_generate_content') def auto_generate_content(self, request): @@ -120,17 +136,17 @@ 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 + ) 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 + ) logger.info(f"auto_generate_content: Processing {len(ids)} task IDs: {ids}") @@ -151,11 +167,10 @@ 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 + ) if existing_count < len(ids): missing_ids = set(ids) - set(existing_ids) @@ -171,11 +186,10 @@ 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 + ) # Try to queue Celery task, fall back to synchronous if Celery not available try: @@ -192,11 +206,10 @@ 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' + ) except KombuOperationalError as celery_error: logger.error("=" * 80) logger.error("CELERY ERROR: Failed to queue task") @@ -206,10 +219,10 @@ 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 + ) except Exception as celery_error: logger.error("=" * 80) logger.error("CELERY ERROR: Failed to queue task") @@ -227,16 +240,15 @@ 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)' + ) 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 + ) else: # Celery not available - execute synchronously logger.info(f"auto_generate_content: Executing synchronously (Celery not available)") @@ -247,17 +259,16 @@ 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' + ) 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 + ) except ImportError as import_error: logger.error(f"auto_generate_content: ImportError - tasks module not available: {str(import_error)}") @@ -268,21 +279,20 @@ 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)' + ) 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 + ) except (OperationalError, DatabaseError) as db_error: logger.error("=" * 80) @@ -293,11 +303,10 @@ 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 + ) except IntegrityError as integrity_error: logger.error("=" * 80) @@ -306,18 +315,17 @@ 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 + ) 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 + ) except Exception as e: logger.error("=" * 80) @@ -328,11 +336,10 @@ 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 + ) except Exception as outer_error: logger.error("=" * 80) @@ -341,10 +348,10 @@ 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 + ) class ImagesViewSet(SiteSectorModelViewSet): @@ -383,30 +390,34 @@ 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 + ) # 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 + ) 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 + ) # 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 + ) # Determine content type from file extension import mimetypes @@ -422,31 +433,40 @@ 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 + ) 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 + ) 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 + ) @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 + ) 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 + ) # Get account account = getattr(request, 'account', None) @@ -464,11 +484,10 @@ 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' + ) else: # Celery not available - execute synchronously result = run_ai_task( @@ -477,33 +496,34 @@ 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') + ) 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 + ) 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 + ) 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 + ) 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 + ) @action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update') def bulk_update(self, request): @@ -518,7 +538,10 @@ 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 + ) queryset = self.get_queryset() @@ -534,13 +557,22 @@ 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 + ) 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 + ) - return Response({'updated_count': updated_count}, status=status.HTTP_200_OK) + return success_response( + data={'updated_count': updated_count}, + message=f'Successfully updated {updated_count} image(s)' + ) @action(detail=False, methods=['get'], url_path='content_images', url_name='content_images') def content_images(self, request): @@ -621,10 +653,12 @@ 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 + } + ) @action(detail=False, methods=['post'], url_path='generate_images', url_name='generate_images') def generate_images(self, request): @@ -636,10 +670,10 @@ 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 + ) account_id = account.id if account else None @@ -651,11 +685,10 @@ 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' + ) else: # Fallback to synchronous execution (for testing) result = process_image_generation_queue( @@ -663,13 +696,19 @@ class ImagesViewSet(SiteSectorModelViewSet): account_id=account_id, content_id=content_id ) - return Response(result, status=status.HTTP_200_OK) + if result.get('success'): + return success_response(data=result) + else: + return error_response( + error=result.get('error', 'Image generation failed'), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) 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 + ) class ContentViewSet(SiteSectorModelViewSet): """ @@ -702,10 +741,10 @@ 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 + ) account_id = account.id if account else None @@ -717,11 +756,10 @@ 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' + ) else: # Fallback to synchronous execution result = run_ai_task( @@ -730,19 +768,18 @@ 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' + ) 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 + ) 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 + ) diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 9018e10f..db1cb6f8 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -204,6 +204,7 @@ REST_FRAMEWORK = { 'igny8_core.api.authentication.CSRFExemptSessionAuthentication', # Session auth without CSRF for API 'rest_framework.authentication.BasicAuthentication', # Enable basic auth as fallback ], + 'EXCEPTION_HANDLER': 'igny8_core.api.exception_handlers.custom_exception_handler', # Unified error format } # CORS Configuration diff --git a/backend/igny8_core/urls.py b/backend/igny8_core/urls.py index 031fb2f4..60f33cef 100644 --- a/backend/igny8_core/urls.py +++ b/backend/igny8_core/urls.py @@ -16,9 +16,11 @@ Including another URLconf """ from django.contrib import admin from django.urls import path, include +from igny8_core.api.views import PingView urlpatterns = [ path('admin/', admin.site.urls), + path('api/ping/', PingView.as_view(), name='ping'), # Health check endpoint path('api/v1/auth/', include('igny8_core.auth.urls')), # Auth endpoints path('api/v1/planner/', include('igny8_core.modules.planner.urls')), path('api/v1/writer/', include('igny8_core.modules.writer.urls')), diff --git a/backend/test_response_utilities.py b/backend/test_response_utilities.py new file mode 100644 index 00000000..47c3f10c --- /dev/null +++ b/backend/test_response_utilities.py @@ -0,0 +1,178 @@ +""" +Quick Test Script for Response Utilities + +Run this to verify the new response format utilities work correctly. +Usage: python test_response_utilities.py +""" + +import os +import sys +import django + +# Setup Django +sys.path.insert(0, os.path.dirname(__file__)) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'igny8_core.settings') +django.setup() + +from rest_framework import status +from igny8_core.api.response import success_response, error_response, paginated_response + + +def test_success_response(): + """Test success_response function""" + print("\n" + "="*60) + print("TEST 1: success_response()") + print("="*60) + + # Test with data only + response = success_response(data={"id": 1, "name": "Test"}) + data = response.data + print(f"✓ Status Code: {response.status_code}") + print(f"✓ Success: {data.get('success')}") + print(f"✓ Data: {data.get('data')}") + assert data['success'] == True, "Success should be True" + assert 'data' in data, "Should have data field" + print("✅ Test 1.1: success_response with data - PASSED") + + # Test with data and message + response = success_response( + data={"id": 2, "name": "Test 2"}, + message="Resource created successfully" + ) + data = response.data + assert data['success'] == True, "Success should be True" + assert data.get('message') == "Resource created successfully", "Should have message" + print("✅ Test 1.2: success_response with data and message - PASSED") + + # Test with custom status code + response = success_response( + data={"id": 3}, + status_code=status.HTTP_201_CREATED + ) + assert response.status_code == 201, "Status should be 201" + print("✅ Test 1.3: success_response with custom status - PASSED") + + +def test_error_response(): + """Test error_response function""" + print("\n" + "="*60) + print("TEST 2: error_response()") + print("="*60) + + # Test with error only + response = error_response(error="Something went wrong") + data = response.data + print(f"✓ Status Code: {response.status_code}") + print(f"✓ Success: {data.get('success')}") + print(f"✓ Error: {data.get('error')}") + assert data['success'] == False, "Success should be False" + assert data['error'] == "Something went wrong", "Should have error message" + print("✅ Test 2.1: error_response with error only - PASSED") + + # Test with error and errors dict + response = error_response( + error="Validation failed", + errors={"email": ["Invalid email format"], "password": ["Too short"]} + ) + data = response.data + assert data['success'] == False, "Success should be False" + assert 'errors' in data, "Should have errors field" + assert len(data['errors']) == 2, "Should have 2 field errors" + print("✅ Test 2.2: error_response with errors dict - PASSED") + + # Test with custom status code + response = error_response( + error="Not found", + status_code=status.HTTP_404_NOT_FOUND + ) + assert response.status_code == 404, "Status should be 404" + print("✅ Test 2.3: error_response with custom status - PASSED") + + +def test_paginated_response(): + """Test paginated_response function""" + print("\n" + "="*60) + print("TEST 3: paginated_response()") + print("="*60) + + paginated_data = { + "count": 100, + "next": "http://api.example.com/endpoint/?page=2", + "previous": None, + "results": [{"id": 1}, {"id": 2}] + } + + response = paginated_response(paginated_data) + data = response.data + print(f"✓ Status Code: {response.status_code}") + print(f"✓ Success: {data.get('success')}") + print(f"✓ Count: {data.get('count')}") + print(f"✓ Results: {len(data.get('results', []))} items") + assert data['success'] == True, "Success should be True" + assert data['count'] == 100, "Should have count" + assert 'results' in data, "Should have results" + assert len(data['results']) == 2, "Should have 2 results" + print("✅ Test 3.1: paginated_response - PASSED") + + # Test with message + response = paginated_response(paginated_data, message="Keywords retrieved") + data = response.data + assert data.get('message') == "Keywords retrieved", "Should have message" + print("✅ Test 3.2: paginated_response with message - PASSED") + + +def test_imports(): + """Test that imports work correctly""" + print("\n" + "="*60) + print("TEST 4: Import Verification") + print("="*60) + + try: + from igny8_core.api import success_response, error_response, paginated_response + print("✅ All imports successful") + return True + except ImportError as e: + print(f"❌ Import failed: {e}") + return False + + +def main(): + """Run all tests""" + print("\n" + "="*60) + print("RESPONSE UTILITIES TEST SUITE") + print("="*60) + print("Testing unified response format utilities") + print("="*60) + + try: + # Test imports + if not test_imports(): + print("\n❌ Import test failed. Exiting.") + return + + # Test functions + test_success_response() + test_error_response() + test_paginated_response() + + print("\n" + "="*60) + print("✅ ALL TESTS PASSED!") + print("="*60) + print("\nNext steps:") + print("1. Run Django unit tests: python manage.py test igny8_core.api.tests.test_response") + print("2. Test /api/ping/ endpoint (if server is running)") + print("3. Merge PR when ready") + + except AssertionError as e: + print(f"\n❌ Test failed: {e}") + sys.exit(1) + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main() +