From a722f6caa3ec0b61892f809934057cef7dc360e6 Mon Sep 17 00:00:00 2001 From: Desktop Date: Fri, 14 Nov 2025 20:05:22 +0500 Subject: [PATCH 01/11] feat(api): add unified response format utilities (Section 1, Step 1.1-1.3) - Create response.py with success_response(), error_response(), paginated_response() - Add unit tests for all response utility functions - Create /api/ping/ health check endpoint using new format - Update __init__.py to export response functions - All changes are non-breaking - existing code unaffected This implements Section 1 Task 1 from API-IMPLEMENTATION-PLAN-SECTION1.md Ready for testing before applying to existing endpoints. Ref: unified-api/API-IMPLEMENTATION-PLAN-SECTION1.md --- backend/igny8_core/api/__init__.py | 4 + backend/igny8_core/api/response.py | 111 ++++++++++ backend/igny8_core/api/tests/__init__.py | 4 + backend/igny8_core/api/tests/test_response.py | 198 ++++++++++++++++++ backend/igny8_core/api/views.py | 33 +++ backend/igny8_core/urls.py | 2 + 6 files changed, 352 insertions(+) create mode 100644 backend/igny8_core/api/response.py create mode 100644 backend/igny8_core/api/tests/__init__.py create mode 100644 backend/igny8_core/api/tests/test_response.py create mode 100644 backend/igny8_core/api/views.py diff --git a/backend/igny8_core/api/__init__.py b/backend/igny8_core/api/__init__.py index 9322e85d..2cbd0659 100644 --- a/backend/igny8_core/api/__init__.py +++ b/backend/igny8_core/api/__init__.py @@ -2,3 +2,7 @@ IGNY8 API Module """ +from .response import success_response, error_response, paginated_response + +__all__ = ['success_response', 'error_response', 'paginated_response'] + 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/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')), -- 2.49.1 From b6cd54479180c4a3673dfce92ef5378ac796823b Mon Sep 17 00:00:00 2001 From: Desktop Date: Fri, 14 Nov 2025 20:17:14 +0500 Subject: [PATCH 02/11] test --- TESTING-GUIDE.md | 108 +++++++++++++++++ backend/test_response_utilities.py | 178 +++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 TESTING-GUIDE.md create mode 100644 backend/test_response_utilities.py 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/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() + -- 2.49.1 From 66b1868672928059d1e254e16c5ddc082e25883d Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Fri, 14 Nov 2025 15:50:26 +0000 Subject: [PATCH 03/11] feat(api): implement centralized exception handling for unified error responses - Add custom_exception_handler to handle exceptions in a unified format - Log errors with request context and provide debug information in development mode - Update settings.py to use the new exception handler - Export custom_exception_handler in __init__.py for accessibility This enhances error management across the API, improving debugging and user experience. --- backend/igny8_core/api/__init__.py | 3 +- backend/igny8_core/api/exception_handlers.py | 213 +++++++++++++++++++ backend/igny8_core/settings.py | 1 + 3 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 backend/igny8_core/api/exception_handlers.py diff --git a/backend/igny8_core/api/__init__.py b/backend/igny8_core/api/__init__.py index 2cbd0659..314aa598 100644 --- a/backend/igny8_core/api/__init__.py +++ b/backend/igny8_core/api/__init__.py @@ -3,6 +3,7 @@ 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'] +__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..9181e451 --- /dev/null +++ b/backend/igny8_core/api/exception_handlers.py @@ -0,0 +1,213 @@ +""" +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 + # Extract first error message as top-level error + if errors: + first_key = list(errors.keys())[0] + first_error = errors[first_key] + if isinstance(first_error, list) and first_error: + error_message = f"{first_key}: {first_error[0]}" + else: + error_message = f"{first_key}: {first_error}" + else: + 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): + # Check for common error message fields + error_message = ( + response.data.get('error') or + response.data.get('message') or + response.data.get('detail') or + str(response.data) + ) + 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 + 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 + if errors: + error_response_data["errors"] = errors + + # 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/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 -- 2.49.1 From d14d6093e042b497da44e2dadeddeca1937573ba Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Fri, 14 Nov 2025 16:15:18 +0000 Subject: [PATCH 04/11] section 2 --- backend/igny8_core/api/exception_handlers.py | 64 +++++--- backend/igny8_core/auth/views.py | 158 ++++++++++--------- 2 files changed, 129 insertions(+), 93 deletions(-) diff --git a/backend/igny8_core/api/exception_handlers.py b/backend/igny8_core/api/exception_handlers.py index 9181e451..365f60ca 100644 --- a/backend/igny8_core/api/exception_handlers.py +++ b/backend/igny8_core/api/exception_handlers.py @@ -56,16 +56,9 @@ def extract_error_message(exc, response): if isinstance(exc.detail, dict): # Validation errors - use as errors dict errors = exc.detail - # Extract first error message as top-level error - if errors: - first_key = list(errors.keys())[0] - first_error = errors[first_key] - if isinstance(first_error, list) and first_error: - error_message = f"{first_key}: {first_error[0]}" - else: - error_message = f"{first_key}: {first_error}" - else: - error_message = "Validation failed" + # 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" @@ -76,14 +69,35 @@ def extract_error_message(exc, response): elif response and hasattr(response, 'data'): # Try to extract from response data if isinstance(response.data, dict): - # Check for common error message fields - error_message = ( - response.data.get('error') or - response.data.get('message') or - response.data.get('detail') or - str(response.data) - ) - errors = response.data if 'error' not in response.data else None + # 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} @@ -148,6 +162,15 @@ def custom_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 @@ -188,8 +211,13 @@ def custom_exception_handler(exc, context): } # 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: 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): -- 2.49.1 From 5cc4d073734773117544fcc8893a64961e0f399b Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Fri, 14 Nov 2025 17:27:58 +0000 Subject: [PATCH 05/11] section2-3 --- backend/igny8_core/modules/planner/views.py | 327 ++++++++++------- backend/igny8_core/modules/writer/views.py | 383 +++++++++++--------- 2 files changed, 414 insertions(+), 296 deletions(-) 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 + ) -- 2.49.1 From ecda7e0ef59fbdb471ccd13171fa63738a12cf81 Mon Sep 17 00:00:00 2001 From: Desktop Date: Fri, 14 Nov 2025 23:01:30 +0500 Subject: [PATCH 06/11] Update Status.tsx --- frontend/src/pages/Settings/Status.tsx | 233 ++++++++++++++++++++++++- 1 file changed, 231 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Settings/Status.tsx b/frontend/src/pages/Settings/Status.tsx index db5e511a..ad658edb 100644 --- a/frontend/src/pages/Settings/Status.tsx +++ b/frontend/src/pages/Settings/Status.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import PageMeta from "../../components/common/PageMeta"; import ComponentCard from "../../components/common/ComponentCard"; -import { fetchAPI } from "../../services/api"; +import { fetchAPI, API_BASE_URL } from "../../services/api"; interface SystemStatus { timestamp: string; @@ -313,6 +313,9 @@ export default function Status() { + {/* API Monitoring Status Card */} + + {/* Last Updated */}
Last updated: {new Date(status.timestamp).toLocaleString()} @@ -321,3 +324,229 @@ export default function Status() { ); } + +// API Monitoring Component +interface APIEndpointStatus { + name: string; + endpoint: string; + status: 'healthy' | 'warning' | 'critical' | 'checking'; + responseTime: number | null; + statusCode: number | null; + lastChecked: Date | null; + error: string | null; +} + +function APIMonitoringCard() { + const initialEndpoints: APIEndpointStatus[] = [ + { name: 'Health Check', endpoint: '/api/ping/', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, + { name: 'System Status', endpoint: '/v1/system/status/', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, + { name: 'Auth Endpoint', endpoint: '/v1/auth/me/', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, + { name: 'Planner API', endpoint: '/v1/planner/keywords/?page=1&page_size=1', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, + { name: 'Writer API', endpoint: '/v1/writer/tasks/?page=1&page_size=1', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, + ]; + + const [endpoints, setEndpoints] = useState(initialEndpoints); + const endpointsRef = useRef(initialEndpoints); + + // Keep ref in sync with state + useEffect(() => { + endpointsRef.current = endpoints; + }, [endpoints]); + + const checkEndpoint = async (endpoint: APIEndpointStatus) => { + const startTime = performance.now(); + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + const response = await fetch(`${API_BASE_URL}${endpoint.endpoint}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + signal: controller.signal, + }); + + clearTimeout(timeoutId); + const endTime = performance.now(); + const responseTime = Math.round(endTime - startTime); + + // Determine status based on response time and status code + let status: 'healthy' | 'warning' | 'critical' = 'healthy'; + if (response.status >= 500) { + status = 'critical'; + } else if (response.status >= 400 || responseTime > 2000) { + status = 'warning'; + } else if (responseTime > 1000) { + status = 'warning'; + } + + return { + ...endpoint, + status, + responseTime, + statusCode: response.status, + lastChecked: new Date(), + error: null, + }; + } catch (err: any) { + const endTime = performance.now(); + const responseTime = Math.round(endTime - startTime); + + return { + ...endpoint, + status: 'critical' as const, + responseTime, + statusCode: null, + lastChecked: new Date(), + error: err.name === 'AbortError' ? 'Timeout' : err.message || 'Network Error', + }; + } + }; + + useEffect(() => { + const checkAllEndpoints = async () => { + // Check all endpoints in parallel using ref to get latest state + const results = await Promise.all( + endpointsRef.current.map(endpoint => checkEndpoint(endpoint)) + ); + setEndpoints(results); + }; + + // Initial check + checkAllEndpoints(); + + // Check every 5 seconds for real-time monitoring + const interval = setInterval(checkAllEndpoints, 5000); + + return () => clearInterval(interval); + }, []); + + const getStatusIcon = (status: string) => { + switch (status) { + case 'healthy': + return ( +
+
+ Online +
+ ); + case 'warning': + return ( +
+
+ Slow +
+ ); + case 'critical': + return ( +
+
+ Down +
+ ); + default: + return ( +
+
+ Checking... +
+ ); + } + }; + + const getResponseTimeColor = (responseTime: number | null) => { + if (responseTime === null) return 'text-gray-500 dark:text-gray-400'; + if (responseTime < 500) return 'text-green-600 dark:text-green-400'; + if (responseTime < 1000) return 'text-yellow-600 dark:text-yellow-400'; + if (responseTime < 2000) return 'text-orange-600 dark:text-orange-400'; + return 'text-red-600 dark:text-red-400'; + }; + + const overallStatus = endpoints.every(e => e.status === 'healthy') + ? 'healthy' + : endpoints.some(e => e.status === 'critical') + ? 'critical' + : 'warning'; + + return ( + +
+ {/* Overall Status */} +
+
+

Overall API Status

+

+ {endpoints.filter(e => e.status === 'healthy').length} of {endpoints.length} endpoints healthy +

+
+
+ {overallStatus === 'healthy' ? '✓ All Systems Operational' : + overallStatus === 'warning' ? '⚠ Some Issues Detected' : + '✗ Critical Issues'} +
+
+ + {/* Endpoints List */} +
+ {endpoints.map((endpoint, index) => ( +
+
+
+ + {endpoint.name} + + {getStatusIcon(endpoint.status)} +
+
+ {endpoint.endpoint} + {endpoint.responseTime !== null && ( + + {endpoint.responseTime}ms + + )} + {endpoint.statusCode && ( + = 200 && endpoint.statusCode < 300 + ? 'text-green-600 dark:text-green-400' + : endpoint.statusCode >= 400 && endpoint.statusCode < 500 + ? 'text-yellow-600 dark:text-yellow-400' + : 'text-red-600 dark:text-red-400' + }`}> + {endpoint.statusCode} + + )} +
+ {endpoint.error && ( +
+ {endpoint.error} +
+ )} + {endpoint.lastChecked && ( +
+ Last checked: {endpoint.lastChecked.toLocaleTimeString()} +
+ )} +
+
+ ))} +
+ + {/* Refresh Indicator */} +
+ + + Auto-refreshing every 5 seconds + +
+
+
+ ); +} -- 2.49.1 From af73a786eda711bfd305463875f02caad81fe515 Mon Sep 17 00:00:00 2001 From: Desktop Date: Fri, 14 Nov 2025 23:09:42 +0500 Subject: [PATCH 07/11] Update Status.tsx --- frontend/src/pages/Settings/Status.tsx | 43 +++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/Settings/Status.tsx b/frontend/src/pages/Settings/Status.tsx index ad658edb..6806782b 100644 --- a/frontend/src/pages/Settings/Status.tsx +++ b/frontend/src/pages/Settings/Status.tsx @@ -275,7 +275,7 @@ export default function Status() { {/* Module Statistics */} - +
{/* Planner Module */}
@@ -359,11 +359,28 @@ function APIMonitoringCard() { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + // Get auth token if available (for endpoints that require auth) + const headers: HeadersInit = { + 'Content-Type': 'application/json', + }; + + // Try to get JWT token from localStorage for authenticated endpoints + try { + const authStorage = localStorage.getItem('auth-storage'); + if (authStorage) { + const parsed = JSON.parse(authStorage); + const token = parsed?.state?.token; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } + } catch (e) { + // Ignore errors getting token + } + const response = await fetch(`${API_BASE_URL}${endpoint.endpoint}`, { method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, + headers, credentials: 'include', signal: controller.signal, }); @@ -374,13 +391,29 @@ function APIMonitoringCard() { // Determine status based on response time and status code let status: 'healthy' | 'warning' | 'critical' = 'healthy'; + + // 401/403 for auth endpoints is normal (endpoint works, just needs auth) + const isAuthEndpoint = endpoint.endpoint.includes('/auth/') || + endpoint.endpoint.includes('/me/'); + if (response.status >= 500) { + // Server errors are critical status = 'critical'; - } else if (response.status >= 400 || responseTime > 2000) { + } else if (response.status === 401 || response.status === 403) { + // Auth errors are healthy for auth endpoints (endpoint is working) + // For other endpoints, it's a warning (endpoint works but access denied) + status = isAuthEndpoint ? 'healthy' : 'warning'; + } else if (response.status >= 400) { + // Other 4xx errors are warnings (client errors, but endpoint responds) status = 'warning'; + } else if (responseTime > 2000) { + // Very slow responses are critical + status = 'critical'; } else if (responseTime > 1000) { + // Slow responses are warnings status = 'warning'; } + // Otherwise healthy (200-299, fast response) return { ...endpoint, -- 2.49.1 From 4acac1276491c051774dc9bbc77fe9c613c086d7 Mon Sep 17 00:00:00 2001 From: Desktop Date: Fri, 14 Nov 2025 23:12:31 +0500 Subject: [PATCH 08/11] Revert "Update Status.tsx" This reverts commit af73a786eda711bfd305463875f02caad81fe515. --- frontend/src/pages/Settings/Status.tsx | 43 +++----------------------- 1 file changed, 5 insertions(+), 38 deletions(-) diff --git a/frontend/src/pages/Settings/Status.tsx b/frontend/src/pages/Settings/Status.tsx index 6806782b..ad658edb 100644 --- a/frontend/src/pages/Settings/Status.tsx +++ b/frontend/src/pages/Settings/Status.tsx @@ -275,7 +275,7 @@ export default function Status() { {/* Module Statistics */} - +
{/* Planner Module */}
@@ -359,28 +359,11 @@ function APIMonitoringCard() { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout - // Get auth token if available (for endpoints that require auth) - const headers: HeadersInit = { - 'Content-Type': 'application/json', - }; - - // Try to get JWT token from localStorage for authenticated endpoints - try { - const authStorage = localStorage.getItem('auth-storage'); - if (authStorage) { - const parsed = JSON.parse(authStorage); - const token = parsed?.state?.token; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - } - } catch (e) { - // Ignore errors getting token - } - const response = await fetch(`${API_BASE_URL}${endpoint.endpoint}`, { method: 'GET', - headers, + headers: { + 'Content-Type': 'application/json', + }, credentials: 'include', signal: controller.signal, }); @@ -391,29 +374,13 @@ function APIMonitoringCard() { // Determine status based on response time and status code let status: 'healthy' | 'warning' | 'critical' = 'healthy'; - - // 401/403 for auth endpoints is normal (endpoint works, just needs auth) - const isAuthEndpoint = endpoint.endpoint.includes('/auth/') || - endpoint.endpoint.includes('/me/'); - if (response.status >= 500) { - // Server errors are critical status = 'critical'; - } else if (response.status === 401 || response.status === 403) { - // Auth errors are healthy for auth endpoints (endpoint is working) - // For other endpoints, it's a warning (endpoint works but access denied) - status = isAuthEndpoint ? 'healthy' : 'warning'; - } else if (response.status >= 400) { - // Other 4xx errors are warnings (client errors, but endpoint responds) + } else if (response.status >= 400 || responseTime > 2000) { status = 'warning'; - } else if (responseTime > 2000) { - // Very slow responses are critical - status = 'critical'; } else if (responseTime > 1000) { - // Slow responses are warnings status = 'warning'; } - // Otherwise healthy (200-299, fast response) return { ...endpoint, -- 2.49.1 From 9e007023d02c0428fe759dc414fa2d67386f9e30 Mon Sep 17 00:00:00 2001 From: Desktop Date: Fri, 14 Nov 2025 23:25:25 +0500 Subject: [PATCH 09/11] Update Status.tsx --- frontend/src/pages/Settings/Status.tsx | 272 ++++--------------------- 1 file changed, 42 insertions(+), 230 deletions(-) diff --git a/frontend/src/pages/Settings/Status.tsx b/frontend/src/pages/Settings/Status.tsx index ad658edb..145b1a09 100644 --- a/frontend/src/pages/Settings/Status.tsx +++ b/frontend/src/pages/Settings/Status.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import PageMeta from "../../components/common/PageMeta"; import ComponentCard from "../../components/common/ComponentCard"; -import { fetchAPI, API_BASE_URL } from "../../services/api"; +import { fetchAPI } from "../../services/api"; interface SystemStatus { timestamp: string; @@ -313,8 +313,46 @@ export default function Status() {
- {/* API Monitoring Status Card */} - + {/* API Status Card */} + +
+
+
+
+ API Server + + {status.database?.connected ? 'Operational' : 'Offline'} + +
+
+ Base URL: {typeof window !== 'undefined' ? window.location.origin.replace('app.', 'api.') : 'api.igny8.com'} +
+
+ +
+
+ Response Format + + Unified + +
+
+ All endpoints use standardized response format +
+
+
+ +
+
+
• Health Check: /api/ping/
+
• System Status: /v1/system/status/
+
• Authentication: /v1/auth/
+
• Planner API: /v1/planner/
+
• Writer API: /v1/writer/
+
+
+
+
{/* Last Updated */}
@@ -324,229 +362,3 @@ export default function Status() { ); } - -// API Monitoring Component -interface APIEndpointStatus { - name: string; - endpoint: string; - status: 'healthy' | 'warning' | 'critical' | 'checking'; - responseTime: number | null; - statusCode: number | null; - lastChecked: Date | null; - error: string | null; -} - -function APIMonitoringCard() { - const initialEndpoints: APIEndpointStatus[] = [ - { name: 'Health Check', endpoint: '/api/ping/', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, - { name: 'System Status', endpoint: '/v1/system/status/', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, - { name: 'Auth Endpoint', endpoint: '/v1/auth/me/', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, - { name: 'Planner API', endpoint: '/v1/planner/keywords/?page=1&page_size=1', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, - { name: 'Writer API', endpoint: '/v1/writer/tasks/?page=1&page_size=1', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, - ]; - - const [endpoints, setEndpoints] = useState(initialEndpoints); - const endpointsRef = useRef(initialEndpoints); - - // Keep ref in sync with state - useEffect(() => { - endpointsRef.current = endpoints; - }, [endpoints]); - - const checkEndpoint = async (endpoint: APIEndpointStatus) => { - const startTime = performance.now(); - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout - - const response = await fetch(`${API_BASE_URL}${endpoint.endpoint}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - signal: controller.signal, - }); - - clearTimeout(timeoutId); - const endTime = performance.now(); - const responseTime = Math.round(endTime - startTime); - - // Determine status based on response time and status code - let status: 'healthy' | 'warning' | 'critical' = 'healthy'; - if (response.status >= 500) { - status = 'critical'; - } else if (response.status >= 400 || responseTime > 2000) { - status = 'warning'; - } else if (responseTime > 1000) { - status = 'warning'; - } - - return { - ...endpoint, - status, - responseTime, - statusCode: response.status, - lastChecked: new Date(), - error: null, - }; - } catch (err: any) { - const endTime = performance.now(); - const responseTime = Math.round(endTime - startTime); - - return { - ...endpoint, - status: 'critical' as const, - responseTime, - statusCode: null, - lastChecked: new Date(), - error: err.name === 'AbortError' ? 'Timeout' : err.message || 'Network Error', - }; - } - }; - - useEffect(() => { - const checkAllEndpoints = async () => { - // Check all endpoints in parallel using ref to get latest state - const results = await Promise.all( - endpointsRef.current.map(endpoint => checkEndpoint(endpoint)) - ); - setEndpoints(results); - }; - - // Initial check - checkAllEndpoints(); - - // Check every 5 seconds for real-time monitoring - const interval = setInterval(checkAllEndpoints, 5000); - - return () => clearInterval(interval); - }, []); - - const getStatusIcon = (status: string) => { - switch (status) { - case 'healthy': - return ( -
-
- Online -
- ); - case 'warning': - return ( -
-
- Slow -
- ); - case 'critical': - return ( -
-
- Down -
- ); - default: - return ( -
-
- Checking... -
- ); - } - }; - - const getResponseTimeColor = (responseTime: number | null) => { - if (responseTime === null) return 'text-gray-500 dark:text-gray-400'; - if (responseTime < 500) return 'text-green-600 dark:text-green-400'; - if (responseTime < 1000) return 'text-yellow-600 dark:text-yellow-400'; - if (responseTime < 2000) return 'text-orange-600 dark:text-orange-400'; - return 'text-red-600 dark:text-red-400'; - }; - - const overallStatus = endpoints.every(e => e.status === 'healthy') - ? 'healthy' - : endpoints.some(e => e.status === 'critical') - ? 'critical' - : 'warning'; - - return ( - -
- {/* Overall Status */} -
-
-

Overall API Status

-

- {endpoints.filter(e => e.status === 'healthy').length} of {endpoints.length} endpoints healthy -

-
-
- {overallStatus === 'healthy' ? '✓ All Systems Operational' : - overallStatus === 'warning' ? '⚠ Some Issues Detected' : - '✗ Critical Issues'} -
-
- - {/* Endpoints List */} -
- {endpoints.map((endpoint, index) => ( -
-
-
- - {endpoint.name} - - {getStatusIcon(endpoint.status)} -
-
- {endpoint.endpoint} - {endpoint.responseTime !== null && ( - - {endpoint.responseTime}ms - - )} - {endpoint.statusCode && ( - = 200 && endpoint.statusCode < 300 - ? 'text-green-600 dark:text-green-400' - : endpoint.statusCode >= 400 && endpoint.statusCode < 500 - ? 'text-yellow-600 dark:text-yellow-400' - : 'text-red-600 dark:text-red-400' - }`}> - {endpoint.statusCode} - - )} -
- {endpoint.error && ( -
- {endpoint.error} -
- )} - {endpoint.lastChecked && ( -
- Last checked: {endpoint.lastChecked.toLocaleTimeString()} -
- )} -
-
- ))} -
- - {/* Refresh Indicator */} -
- - - Auto-refreshing every 5 seconds - -
-
-
- ); -} -- 2.49.1 From a533d05e51c6d10e28343524ba8336bbd6b00093 Mon Sep 17 00:00:00 2001 From: Desktop Date: Fri, 14 Nov 2025 23:25:57 +0500 Subject: [PATCH 10/11] Revert "Update Status.tsx" This reverts commit 9e007023d02c0428fe759dc414fa2d67386f9e30. --- frontend/src/pages/Settings/Status.tsx | 272 +++++++++++++++++++++---- 1 file changed, 230 insertions(+), 42 deletions(-) diff --git a/frontend/src/pages/Settings/Status.tsx b/frontend/src/pages/Settings/Status.tsx index 145b1a09..ad658edb 100644 --- a/frontend/src/pages/Settings/Status.tsx +++ b/frontend/src/pages/Settings/Status.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import PageMeta from "../../components/common/PageMeta"; import ComponentCard from "../../components/common/ComponentCard"; -import { fetchAPI } from "../../services/api"; +import { fetchAPI, API_BASE_URL } from "../../services/api"; interface SystemStatus { timestamp: string; @@ -313,46 +313,8 @@ export default function Status() {
- {/* API Status Card */} - -
-
-
-
- API Server - - {status.database?.connected ? 'Operational' : 'Offline'} - -
-
- Base URL: {typeof window !== 'undefined' ? window.location.origin.replace('app.', 'api.') : 'api.igny8.com'} -
-
- -
-
- Response Format - - Unified - -
-
- All endpoints use standardized response format -
-
-
- -
-
-
• Health Check: /api/ping/
-
• System Status: /v1/system/status/
-
• Authentication: /v1/auth/
-
• Planner API: /v1/planner/
-
• Writer API: /v1/writer/
-
-
-
-
+ {/* API Monitoring Status Card */} + {/* Last Updated */}
@@ -362,3 +324,229 @@ export default function Status() { ); } + +// API Monitoring Component +interface APIEndpointStatus { + name: string; + endpoint: string; + status: 'healthy' | 'warning' | 'critical' | 'checking'; + responseTime: number | null; + statusCode: number | null; + lastChecked: Date | null; + error: string | null; +} + +function APIMonitoringCard() { + const initialEndpoints: APIEndpointStatus[] = [ + { name: 'Health Check', endpoint: '/api/ping/', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, + { name: 'System Status', endpoint: '/v1/system/status/', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, + { name: 'Auth Endpoint', endpoint: '/v1/auth/me/', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, + { name: 'Planner API', endpoint: '/v1/planner/keywords/?page=1&page_size=1', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, + { name: 'Writer API', endpoint: '/v1/writer/tasks/?page=1&page_size=1', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, + ]; + + const [endpoints, setEndpoints] = useState(initialEndpoints); + const endpointsRef = useRef(initialEndpoints); + + // Keep ref in sync with state + useEffect(() => { + endpointsRef.current = endpoints; + }, [endpoints]); + + const checkEndpoint = async (endpoint: APIEndpointStatus) => { + const startTime = performance.now(); + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout + + const response = await fetch(`${API_BASE_URL}${endpoint.endpoint}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + signal: controller.signal, + }); + + clearTimeout(timeoutId); + const endTime = performance.now(); + const responseTime = Math.round(endTime - startTime); + + // Determine status based on response time and status code + let status: 'healthy' | 'warning' | 'critical' = 'healthy'; + if (response.status >= 500) { + status = 'critical'; + } else if (response.status >= 400 || responseTime > 2000) { + status = 'warning'; + } else if (responseTime > 1000) { + status = 'warning'; + } + + return { + ...endpoint, + status, + responseTime, + statusCode: response.status, + lastChecked: new Date(), + error: null, + }; + } catch (err: any) { + const endTime = performance.now(); + const responseTime = Math.round(endTime - startTime); + + return { + ...endpoint, + status: 'critical' as const, + responseTime, + statusCode: null, + lastChecked: new Date(), + error: err.name === 'AbortError' ? 'Timeout' : err.message || 'Network Error', + }; + } + }; + + useEffect(() => { + const checkAllEndpoints = async () => { + // Check all endpoints in parallel using ref to get latest state + const results = await Promise.all( + endpointsRef.current.map(endpoint => checkEndpoint(endpoint)) + ); + setEndpoints(results); + }; + + // Initial check + checkAllEndpoints(); + + // Check every 5 seconds for real-time monitoring + const interval = setInterval(checkAllEndpoints, 5000); + + return () => clearInterval(interval); + }, []); + + const getStatusIcon = (status: string) => { + switch (status) { + case 'healthy': + return ( +
+
+ Online +
+ ); + case 'warning': + return ( +
+
+ Slow +
+ ); + case 'critical': + return ( +
+
+ Down +
+ ); + default: + return ( +
+
+ Checking... +
+ ); + } + }; + + const getResponseTimeColor = (responseTime: number | null) => { + if (responseTime === null) return 'text-gray-500 dark:text-gray-400'; + if (responseTime < 500) return 'text-green-600 dark:text-green-400'; + if (responseTime < 1000) return 'text-yellow-600 dark:text-yellow-400'; + if (responseTime < 2000) return 'text-orange-600 dark:text-orange-400'; + return 'text-red-600 dark:text-red-400'; + }; + + const overallStatus = endpoints.every(e => e.status === 'healthy') + ? 'healthy' + : endpoints.some(e => e.status === 'critical') + ? 'critical' + : 'warning'; + + return ( + +
+ {/* Overall Status */} +
+
+

Overall API Status

+

+ {endpoints.filter(e => e.status === 'healthy').length} of {endpoints.length} endpoints healthy +

+
+
+ {overallStatus === 'healthy' ? '✓ All Systems Operational' : + overallStatus === 'warning' ? '⚠ Some Issues Detected' : + '✗ Critical Issues'} +
+
+ + {/* Endpoints List */} +
+ {endpoints.map((endpoint, index) => ( +
+
+
+ + {endpoint.name} + + {getStatusIcon(endpoint.status)} +
+
+ {endpoint.endpoint} + {endpoint.responseTime !== null && ( + + {endpoint.responseTime}ms + + )} + {endpoint.statusCode && ( + = 200 && endpoint.statusCode < 300 + ? 'text-green-600 dark:text-green-400' + : endpoint.statusCode >= 400 && endpoint.statusCode < 500 + ? 'text-yellow-600 dark:text-yellow-400' + : 'text-red-600 dark:text-red-400' + }`}> + {endpoint.statusCode} + + )} +
+ {endpoint.error && ( +
+ {endpoint.error} +
+ )} + {endpoint.lastChecked && ( +
+ Last checked: {endpoint.lastChecked.toLocaleTimeString()} +
+ )} +
+
+ ))} +
+ + {/* Refresh Indicator */} +
+ + + Auto-refreshing every 5 seconds + +
+
+
+ ); +} -- 2.49.1 From ed255b37300125d23541e00c3ee0d33d4bcdd2a4 Mon Sep 17 00:00:00 2001 From: Desktop Date: Fri, 14 Nov 2025 23:26:03 +0500 Subject: [PATCH 11/11] Revert "Update Status.tsx" This reverts commit ecda7e0ef59fbdb471ccd13171fa63738a12cf81. --- frontend/src/pages/Settings/Status.tsx | 233 +------------------------ 1 file changed, 2 insertions(+), 231 deletions(-) diff --git a/frontend/src/pages/Settings/Status.tsx b/frontend/src/pages/Settings/Status.tsx index ad658edb..db5e511a 100644 --- a/frontend/src/pages/Settings/Status.tsx +++ b/frontend/src/pages/Settings/Status.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import PageMeta from "../../components/common/PageMeta"; import ComponentCard from "../../components/common/ComponentCard"; -import { fetchAPI, API_BASE_URL } from "../../services/api"; +import { fetchAPI } from "../../services/api"; interface SystemStatus { timestamp: string; @@ -313,9 +313,6 @@ export default function Status() {
- {/* API Monitoring Status Card */} - - {/* Last Updated */}
Last updated: {new Date(status.timestamp).toLocaleString()} @@ -324,229 +321,3 @@ export default function Status() { ); } - -// API Monitoring Component -interface APIEndpointStatus { - name: string; - endpoint: string; - status: 'healthy' | 'warning' | 'critical' | 'checking'; - responseTime: number | null; - statusCode: number | null; - lastChecked: Date | null; - error: string | null; -} - -function APIMonitoringCard() { - const initialEndpoints: APIEndpointStatus[] = [ - { name: 'Health Check', endpoint: '/api/ping/', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, - { name: 'System Status', endpoint: '/v1/system/status/', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, - { name: 'Auth Endpoint', endpoint: '/v1/auth/me/', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, - { name: 'Planner API', endpoint: '/v1/planner/keywords/?page=1&page_size=1', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, - { name: 'Writer API', endpoint: '/v1/writer/tasks/?page=1&page_size=1', status: 'checking', responseTime: null, statusCode: null, lastChecked: null, error: null }, - ]; - - const [endpoints, setEndpoints] = useState(initialEndpoints); - const endpointsRef = useRef(initialEndpoints); - - // Keep ref in sync with state - useEffect(() => { - endpointsRef.current = endpoints; - }, [endpoints]); - - const checkEndpoint = async (endpoint: APIEndpointStatus) => { - const startTime = performance.now(); - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout - - const response = await fetch(`${API_BASE_URL}${endpoint.endpoint}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - signal: controller.signal, - }); - - clearTimeout(timeoutId); - const endTime = performance.now(); - const responseTime = Math.round(endTime - startTime); - - // Determine status based on response time and status code - let status: 'healthy' | 'warning' | 'critical' = 'healthy'; - if (response.status >= 500) { - status = 'critical'; - } else if (response.status >= 400 || responseTime > 2000) { - status = 'warning'; - } else if (responseTime > 1000) { - status = 'warning'; - } - - return { - ...endpoint, - status, - responseTime, - statusCode: response.status, - lastChecked: new Date(), - error: null, - }; - } catch (err: any) { - const endTime = performance.now(); - const responseTime = Math.round(endTime - startTime); - - return { - ...endpoint, - status: 'critical' as const, - responseTime, - statusCode: null, - lastChecked: new Date(), - error: err.name === 'AbortError' ? 'Timeout' : err.message || 'Network Error', - }; - } - }; - - useEffect(() => { - const checkAllEndpoints = async () => { - // Check all endpoints in parallel using ref to get latest state - const results = await Promise.all( - endpointsRef.current.map(endpoint => checkEndpoint(endpoint)) - ); - setEndpoints(results); - }; - - // Initial check - checkAllEndpoints(); - - // Check every 5 seconds for real-time monitoring - const interval = setInterval(checkAllEndpoints, 5000); - - return () => clearInterval(interval); - }, []); - - const getStatusIcon = (status: string) => { - switch (status) { - case 'healthy': - return ( -
-
- Online -
- ); - case 'warning': - return ( -
-
- Slow -
- ); - case 'critical': - return ( -
-
- Down -
- ); - default: - return ( -
-
- Checking... -
- ); - } - }; - - const getResponseTimeColor = (responseTime: number | null) => { - if (responseTime === null) return 'text-gray-500 dark:text-gray-400'; - if (responseTime < 500) return 'text-green-600 dark:text-green-400'; - if (responseTime < 1000) return 'text-yellow-600 dark:text-yellow-400'; - if (responseTime < 2000) return 'text-orange-600 dark:text-orange-400'; - return 'text-red-600 dark:text-red-400'; - }; - - const overallStatus = endpoints.every(e => e.status === 'healthy') - ? 'healthy' - : endpoints.some(e => e.status === 'critical') - ? 'critical' - : 'warning'; - - return ( - -
- {/* Overall Status */} -
-
-

Overall API Status

-

- {endpoints.filter(e => e.status === 'healthy').length} of {endpoints.length} endpoints healthy -

-
-
- {overallStatus === 'healthy' ? '✓ All Systems Operational' : - overallStatus === 'warning' ? '⚠ Some Issues Detected' : - '✗ Critical Issues'} -
-
- - {/* Endpoints List */} -
- {endpoints.map((endpoint, index) => ( -
-
-
- - {endpoint.name} - - {getStatusIcon(endpoint.status)} -
-
- {endpoint.endpoint} - {endpoint.responseTime !== null && ( - - {endpoint.responseTime}ms - - )} - {endpoint.statusCode && ( - = 200 && endpoint.statusCode < 300 - ? 'text-green-600 dark:text-green-400' - : endpoint.statusCode >= 400 && endpoint.statusCode < 500 - ? 'text-yellow-600 dark:text-yellow-400' - : 'text-red-600 dark:text-red-400' - }`}> - {endpoint.statusCode} - - )} -
- {endpoint.error && ( -
- {endpoint.error} -
- )} - {endpoint.lastChecked && ( -
- Last checked: {endpoint.lastChecked.toLocaleTimeString()} -
- )} -
-
- ))} -
- - {/* Refresh Indicator */} -
- - - Auto-refreshing every 5 seconds - -
-
-
- ); -} -- 2.49.1