feat(api): add unified response format utilities (Section 1, Step 1.1-1.3) #1
108
TESTING-GUIDE.md
Normal file
108
TESTING-GUIDE.md
Normal file
@@ -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
|
||||||
|
|
||||||
@@ -2,3 +2,8 @@
|
|||||||
IGNY8 API Module
|
IGNY8 API Module
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .response import success_response, error_response, paginated_response
|
||||||
|
from .exception_handlers import custom_exception_handler
|
||||||
|
|
||||||
|
__all__ = ['success_response', 'error_response', 'paginated_response', 'custom_exception_handler']
|
||||||
|
|
||||||
|
|||||||
241
backend/igny8_core/api/exception_handlers.py
Normal file
241
backend/igny8_core/api/exception_handlers.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""
|
||||||
|
Centralized Exception Handler for IGNY8 API
|
||||||
|
|
||||||
|
This module provides a comprehensive exception handler that:
|
||||||
|
- Wraps all exceptions in unified error format
|
||||||
|
- Logs errors for debugging and monitoring
|
||||||
|
- Provides debug information in development mode
|
||||||
|
- Tracks request IDs for error correlation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from rest_framework.views import exception_handler as drf_exception_handler
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from igny8_core.api.response import error_response
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_id(request):
|
||||||
|
"""
|
||||||
|
Get or create request ID for error tracking.
|
||||||
|
|
||||||
|
Request ID can be set by middleware or generated here.
|
||||||
|
"""
|
||||||
|
# Check if request ID is already set (e.g., by middleware)
|
||||||
|
request_id = getattr(request, 'request_id', None)
|
||||||
|
|
||||||
|
if not request_id:
|
||||||
|
# Generate new request ID
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
request.request_id = request_id
|
||||||
|
|
||||||
|
return request_id
|
||||||
|
|
||||||
|
|
||||||
|
def extract_error_message(exc, response):
|
||||||
|
"""
|
||||||
|
Extract user-friendly error message from exception.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
exc: The exception instance
|
||||||
|
response: DRF's exception handler response
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (error_message, errors_dict)
|
||||||
|
"""
|
||||||
|
error_message = "An error occurred"
|
||||||
|
errors = None
|
||||||
|
|
||||||
|
# Handle DRF exceptions with 'detail' attribute
|
||||||
|
if hasattr(exc, 'detail'):
|
||||||
|
if isinstance(exc.detail, dict):
|
||||||
|
# Validation errors - use as errors dict
|
||||||
|
errors = exc.detail
|
||||||
|
# Set a general validation error message
|
||||||
|
# The specific field errors are in the errors dict
|
||||||
|
error_message = "Validation failed"
|
||||||
|
elif isinstance(exc.detail, list):
|
||||||
|
# List of errors
|
||||||
|
error_message = exc.detail[0] if exc.detail else "An error occurred"
|
||||||
|
errors = {"non_field_errors": exc.detail}
|
||||||
|
else:
|
||||||
|
# String error message
|
||||||
|
error_message = str(exc.detail)
|
||||||
|
elif response and hasattr(response, 'data'):
|
||||||
|
# Try to extract from response data
|
||||||
|
if isinstance(response.data, dict):
|
||||||
|
# If response already has unified format, preserve it
|
||||||
|
if 'success' in response.data:
|
||||||
|
error_message = response.data.get('error', 'An error occurred')
|
||||||
|
errors = response.data.get('errors')
|
||||||
|
return error_message, errors
|
||||||
|
|
||||||
|
# If response.data looks like validation errors (dict with field names as keys)
|
||||||
|
# and no 'error', 'message', or 'detail' fields, treat it as validation errors
|
||||||
|
if 'error' not in response.data and 'message' not in response.data and 'detail' not in response.data:
|
||||||
|
# This is a validation error dict - extract errors and set error message
|
||||||
|
errors = response.data
|
||||||
|
error_message = 'Validation failed'
|
||||||
|
else:
|
||||||
|
# Check for common error message fields
|
||||||
|
error_message = (
|
||||||
|
response.data.get('error') or
|
||||||
|
response.data.get('message') or
|
||||||
|
response.data.get('detail') or
|
||||||
|
'Validation failed' if response.data else 'An error occurred'
|
||||||
|
)
|
||||||
|
# Extract errors from response.data if it's a dict with field errors
|
||||||
|
# Check if response.data contains field names (likely validation errors)
|
||||||
|
if isinstance(response.data, dict) and any(
|
||||||
|
isinstance(v, (list, str)) for v in response.data.values()
|
||||||
|
) and 'error' not in response.data and 'message' not in response.data:
|
||||||
|
errors = response.data
|
||||||
|
error_message = 'Validation failed'
|
||||||
|
else:
|
||||||
|
errors = response.data if 'error' not in response.data else None
|
||||||
|
elif isinstance(response.data, list):
|
||||||
|
error_message = response.data[0] if response.data else "An error occurred"
|
||||||
|
errors = {"non_field_errors": response.data}
|
||||||
|
else:
|
||||||
|
error_message = str(response.data) if response.data else "An error occurred"
|
||||||
|
else:
|
||||||
|
# Generic exception
|
||||||
|
error_message = str(exc) if exc else "An error occurred"
|
||||||
|
|
||||||
|
return error_message, errors
|
||||||
|
|
||||||
|
|
||||||
|
def get_status_code_message(status_code):
|
||||||
|
"""
|
||||||
|
Get default error message for HTTP status code.
|
||||||
|
"""
|
||||||
|
status_messages = {
|
||||||
|
status.HTTP_400_BAD_REQUEST: "Bad request",
|
||||||
|
status.HTTP_401_UNAUTHORIZED: "Authentication required",
|
||||||
|
status.HTTP_403_FORBIDDEN: "Permission denied",
|
||||||
|
status.HTTP_404_NOT_FOUND: "Resource not found",
|
||||||
|
status.HTTP_405_METHOD_NOT_ALLOWED: "Method not allowed",
|
||||||
|
status.HTTP_409_CONFLICT: "Conflict",
|
||||||
|
status.HTTP_422_UNPROCESSABLE_ENTITY: "Unprocessable entity",
|
||||||
|
status.HTTP_429_TOO_MANY_REQUESTS: "Too many requests",
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal server error",
|
||||||
|
status.HTTP_502_BAD_GATEWAY: "Bad gateway",
|
||||||
|
status.HTTP_503_SERVICE_UNAVAILABLE: "Service unavailable",
|
||||||
|
}
|
||||||
|
return status_messages.get(status_code, "An error occurred")
|
||||||
|
|
||||||
|
|
||||||
|
def custom_exception_handler(exc, context):
|
||||||
|
"""
|
||||||
|
Centralized exception handler that wraps all exceptions in unified format.
|
||||||
|
|
||||||
|
This handler:
|
||||||
|
- Wraps all exceptions in unified error format
|
||||||
|
- Logs errors with request context
|
||||||
|
- Provides debug information in development mode
|
||||||
|
- Tracks request IDs for error correlation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
exc: The exception instance
|
||||||
|
context: Dictionary containing request, view, and args
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: Error response in unified format, or None to use default
|
||||||
|
"""
|
||||||
|
# Get request from context
|
||||||
|
request = context.get('request')
|
||||||
|
view = context.get('view')
|
||||||
|
|
||||||
|
# Get request ID for tracking
|
||||||
|
request_id = None
|
||||||
|
if request:
|
||||||
|
request_id = get_request_id(request)
|
||||||
|
|
||||||
|
# Call DRF's default exception handler first
|
||||||
|
response = drf_exception_handler(exc, context)
|
||||||
|
|
||||||
|
# Determine status code
|
||||||
|
if response is not None:
|
||||||
|
status_code = response.status_code
|
||||||
|
|
||||||
|
# If response already has unified format (success field), return it as-is
|
||||||
|
# This handles cases where error_response() was manually returned
|
||||||
|
if hasattr(response, 'data') and isinstance(response.data, dict):
|
||||||
|
if 'success' in response.data:
|
||||||
|
# Response already in unified format - just add request_id if needed
|
||||||
|
if request_id and 'request_id' not in response.data:
|
||||||
|
response.data['request_id'] = request_id
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
# Unhandled exception - default to 500
|
||||||
|
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
|
# Extract error message and details
|
||||||
|
error_message, errors = extract_error_message(exc, response)
|
||||||
|
|
||||||
|
# Use default message if error message is generic
|
||||||
|
if error_message == "An error occurred" or not error_message:
|
||||||
|
error_message = get_status_code_message(status_code)
|
||||||
|
|
||||||
|
# Log the error
|
||||||
|
log_level = logging.ERROR if status_code >= 500 else logging.WARNING
|
||||||
|
log_message = f"API Error [{status_code}]: {error_message}"
|
||||||
|
|
||||||
|
if request_id:
|
||||||
|
log_message += f" (Request ID: {request_id})"
|
||||||
|
|
||||||
|
# Include exception details in log
|
||||||
|
logger.log(
|
||||||
|
log_level,
|
||||||
|
log_message,
|
||||||
|
extra={
|
||||||
|
'request_id': request_id,
|
||||||
|
'status_code': status_code,
|
||||||
|
'exception_type': type(exc).__name__,
|
||||||
|
'view': view.__class__.__name__ if view else None,
|
||||||
|
'path': request.path if request else None,
|
||||||
|
'method': request.method if request else None,
|
||||||
|
},
|
||||||
|
exc_info=status_code >= 500, # Include traceback for server errors
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build error response
|
||||||
|
error_response_data = {
|
||||||
|
"success": False,
|
||||||
|
"error": error_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add errors dict if present
|
||||||
|
# Always include errors if we have them, even if error_message was set
|
||||||
|
if errors:
|
||||||
|
error_response_data["errors"] = errors
|
||||||
|
# If we have errors but no error message was set, ensure we have one
|
||||||
|
elif not error_message or error_message == "An error occurred":
|
||||||
|
error_message = get_status_code_message(status_code)
|
||||||
|
error_response_data["error"] = error_message
|
||||||
|
|
||||||
|
# Add request ID for error tracking
|
||||||
|
if request_id:
|
||||||
|
error_response_data["request_id"] = request_id
|
||||||
|
|
||||||
|
# Add debug information in development mode
|
||||||
|
if settings.DEBUG:
|
||||||
|
error_response_data["debug"] = {
|
||||||
|
"exception_type": type(exc).__name__,
|
||||||
|
"exception_message": str(exc),
|
||||||
|
"view": view.__class__.__name__ if view else None,
|
||||||
|
"path": request.path if request else None,
|
||||||
|
"method": request.method if request else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Include traceback in debug mode for server errors
|
||||||
|
if status_code >= 500:
|
||||||
|
error_response_data["debug"]["traceback"] = traceback.format_exc()
|
||||||
|
|
||||||
|
return Response(error_response_data, status=status_code)
|
||||||
|
|
||||||
111
backend/igny8_core/api/response.py
Normal file
111
backend/igny8_core/api/response.py
Normal file
@@ -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)
|
||||||
|
|
||||||
4
backend/igny8_core/api/tests/__init__.py
Normal file
4
backend/igny8_core/api/tests/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
API Tests Module
|
||||||
|
"""
|
||||||
|
|
||||||
198
backend/igny8_core/api/tests/test_response.py
Normal file
198
backend/igny8_core/api/tests/test_response.py
Normal file
@@ -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)
|
||||||
|
|
||||||
33
backend/igny8_core/api/views.py
Normal file
33
backend/igny8_core/api/views.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ from django.db import transaction
|
|||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from igny8_core.api.base import AccountModelViewSet
|
from igny8_core.api.base import AccountModelViewSet
|
||||||
from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication
|
from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication
|
||||||
|
from igny8_core.api.response import success_response, error_response
|
||||||
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword
|
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
UserSerializer, AccountSerializer, PlanSerializer, SubscriptionSerializer,
|
UserSerializer, AccountSerializer, PlanSerializer, SubscriptionSerializer,
|
||||||
@@ -680,21 +681,24 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
refresh_expires_at = get_token_expiry('refresh')
|
refresh_expires_at = get_token_expiry('refresh')
|
||||||
|
|
||||||
user_serializer = UserSerializer(user)
|
user_serializer = UserSerializer(user)
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={
|
||||||
'message': 'Registration successful',
|
'user': user_serializer.data,
|
||||||
'user': user_serializer.data,
|
'tokens': {
|
||||||
'tokens': {
|
'access': access_token,
|
||||||
'access': access_token,
|
'refresh': refresh_token,
|
||||||
'refresh': refresh_token,
|
'access_expires_at': access_expires_at.isoformat(),
|
||||||
'access_expires_at': access_expires_at.isoformat(),
|
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
}
|
||||||
}
|
},
|
||||||
}, status=status.HTTP_201_CREATED)
|
message='Registration successful',
|
||||||
return Response({
|
status_code=status.HTTP_201_CREATED
|
||||||
'success': False,
|
)
|
||||||
'errors': serializer.errors
|
return error_response(
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
error='Validation failed',
|
||||||
|
errors=serializer.errors,
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'])
|
@action(detail=False, methods=['post'])
|
||||||
def login(self, request):
|
def login(self, request):
|
||||||
@@ -707,10 +711,10 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
try:
|
try:
|
||||||
user = User.objects.select_related('account', 'account__plan').get(email=email)
|
user = User.objects.select_related('account', 'account__plan').get(email=email)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error='Invalid credentials',
|
||||||
'message': 'Invalid credentials'
|
status_code=status.HTTP_401_UNAUTHORIZED
|
||||||
}, status=status.HTTP_401_UNAUTHORIZED)
|
)
|
||||||
|
|
||||||
if user.check_password(password):
|
if user.check_password(password):
|
||||||
# Log the user in (create session for session authentication)
|
# Log the user in (create session for session authentication)
|
||||||
@@ -727,27 +731,29 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
refresh_expires_at = get_token_expiry('refresh')
|
refresh_expires_at = get_token_expiry('refresh')
|
||||||
|
|
||||||
user_serializer = UserSerializer(user)
|
user_serializer = UserSerializer(user)
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={
|
||||||
'message': 'Login successful',
|
'user': user_serializer.data,
|
||||||
'user': user_serializer.data,
|
'tokens': {
|
||||||
'tokens': {
|
'access': access_token,
|
||||||
'access': access_token,
|
'refresh': refresh_token,
|
||||||
'refresh': refresh_token,
|
'access_expires_at': access_expires_at.isoformat(),
|
||||||
'access_expires_at': access_expires_at.isoformat(),
|
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
}
|
||||||
}
|
},
|
||||||
})
|
message='Login successful'
|
||||||
|
)
|
||||||
|
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error='Invalid credentials',
|
||||||
'message': 'Invalid credentials'
|
status_code=status.HTTP_401_UNAUTHORIZED
|
||||||
}, status=status.HTTP_401_UNAUTHORIZED)
|
)
|
||||||
|
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error='Validation failed',
|
||||||
'errors': serializer.errors
|
errors=serializer.errors,
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||||
def change_password(self, request):
|
def change_password(self, request):
|
||||||
@@ -756,23 +762,23 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.check_password(serializer.validated_data['old_password']):
|
if not user.check_password(serializer.validated_data['old_password']):
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error='Current password is incorrect',
|
||||||
'message': 'Current password is incorrect'
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
)
|
||||||
|
|
||||||
user.set_password(serializer.validated_data['new_password'])
|
user.set_password(serializer.validated_data['new_password'])
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
message='Password changed successfully'
|
||||||
'message': 'Password changed successfully'
|
)
|
||||||
})
|
|
||||||
|
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error='Validation failed',
|
||||||
'errors': serializer.errors
|
errors=serializer.errors,
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
||||||
def me(self, request):
|
def me(self, request):
|
||||||
@@ -781,20 +787,20 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
# This ensures account/plan changes are reflected immediately
|
# This ensures account/plan changes are reflected immediately
|
||||||
user = User.objects.select_related('account', 'account__plan').get(id=request.user.id)
|
user = User.objects.select_related('account', 'account__plan').get(id=request.user.id)
|
||||||
serializer = UserSerializer(user)
|
serializer = UserSerializer(user)
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={'user': serializer.data}
|
||||||
'user': serializer.data
|
)
|
||||||
})
|
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||||
def refresh(self, request):
|
def refresh(self, request):
|
||||||
"""Refresh access token using refresh token."""
|
"""Refresh access token using refresh token."""
|
||||||
serializer = RefreshTokenSerializer(data=request.data)
|
serializer = RefreshTokenSerializer(data=request.data)
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error='Validation failed',
|
||||||
'errors': serializer.errors
|
errors=serializer.errors,
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
refresh_token = serializer.validated_data['refresh']
|
refresh_token = serializer.validated_data['refresh']
|
||||||
|
|
||||||
@@ -804,10 +810,10 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
|
|
||||||
# Verify it's a refresh token
|
# Verify it's a refresh token
|
||||||
if payload.get('type') != 'refresh':
|
if payload.get('type') != 'refresh':
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error='Invalid token type',
|
||||||
'message': 'Invalid token type'
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
)
|
||||||
|
|
||||||
# Get user
|
# Get user
|
||||||
user_id = payload.get('user_id')
|
user_id = payload.get('user_id')
|
||||||
@@ -816,10 +822,10 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
try:
|
try:
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error='User not found',
|
||||||
'message': 'User not found'
|
status_code=status.HTTP_404_NOT_FOUND
|
||||||
}, status=status.HTTP_404_NOT_FOUND)
|
)
|
||||||
|
|
||||||
# Get account
|
# Get account
|
||||||
account_id = payload.get('account_id')
|
account_id = payload.get('account_id')
|
||||||
@@ -837,17 +843,19 @@ class AuthViewSet(viewsets.GenericViewSet):
|
|||||||
access_token = generate_access_token(user, account)
|
access_token = generate_access_token(user, account)
|
||||||
access_expires_at = get_token_expiry('access')
|
access_expires_at = get_token_expiry('access')
|
||||||
|
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={
|
||||||
'access': access_token,
|
'access': access_token,
|
||||||
'access_expires_at': access_expires_at.isoformat()
|
'access_expires_at': access_expires_at.isoformat()
|
||||||
})
|
},
|
||||||
|
message='Token refreshed successfully'
|
||||||
|
)
|
||||||
|
|
||||||
except jwt.InvalidTokenError as e:
|
except jwt.InvalidTokenError as e:
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error='Invalid or expired refresh token',
|
||||||
'message': 'Invalid or expired refresh token'
|
status_code=status.HTTP_401_UNAUTHORIZED
|
||||||
}, status=status.HTTP_401_UNAUTHORIZED)
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||||
def request_reset(self, request):
|
def request_reset(self, request):
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
from igny8_core.api.base import SiteSectorModelViewSet
|
from igny8_core.api.base import SiteSectorModelViewSet
|
||||||
from igny8_core.api.pagination import CustomPageNumberPagination
|
from igny8_core.api.pagination import CustomPageNumberPagination
|
||||||
|
from igny8_core.api.response import success_response, error_response
|
||||||
from .models import Keywords, Clusters, ContentIdeas
|
from .models import Keywords, Clusters, ContentIdeas
|
||||||
from .serializers import KeywordSerializer, ContentIdeasSerializer
|
from .serializers import KeywordSerializer, ContentIdeasSerializer
|
||||||
from .cluster_serializers import ClusterSerializer
|
from .cluster_serializers import ClusterSerializer
|
||||||
@@ -124,10 +125,10 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in KeywordViewSet.list(): {type(e).__name__}: {str(e)}", exc_info=True)
|
logger.error(f"Error in KeywordViewSet.list(): {type(e).__name__}: {str(e)}", exc_info=True)
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'Error loading keywords: {str(e)}',
|
error=f'Error loading keywords: {str(e)}',
|
||||||
'type': type(e).__name__
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Require explicit site_id and sector_id - no defaults."""
|
"""Require explicit site_id and sector_id - no defaults."""
|
||||||
@@ -190,12 +191,18 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
"""Bulk delete keywords"""
|
"""Bulk delete keywords"""
|
||||||
ids = request.data.get('ids', [])
|
ids = request.data.get('ids', [])
|
||||||
if not ids:
|
if not ids:
|
||||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No IDs provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
deleted_count, _ = queryset.filter(id__in=ids).delete()
|
deleted_count, _ = queryset.filter(id__in=ids).delete()
|
||||||
|
|
||||||
return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK)
|
return success_response(
|
||||||
|
data={'deleted_count': deleted_count},
|
||||||
|
message=f'Successfully deleted {deleted_count} keyword(s)'
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
|
@action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
|
||||||
def bulk_update(self, request):
|
def bulk_update(self, request):
|
||||||
@@ -204,14 +211,23 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
status_value = request.data.get('status')
|
status_value = request.data.get('status')
|
||||||
|
|
||||||
if not ids:
|
if not ids:
|
||||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No IDs provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
if not status_value:
|
if not status_value:
|
||||||
return Response({'error': 'No status provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No status provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
updated_count = queryset.filter(id__in=ids).update(status=status_value)
|
updated_count = queryset.filter(id__in=ids).update(status=status_value)
|
||||||
|
|
||||||
return Response({'updated_count': updated_count}, status=status.HTTP_200_OK)
|
return success_response(
|
||||||
|
data={'updated_count': updated_count},
|
||||||
|
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')
|
@action(detail=False, methods=['post'], url_path='bulk_add_from_seed', url_name='bulk_add_from_seed')
|
||||||
def bulk_add_from_seed(self, request):
|
def bulk_add_from_seed(self, request):
|
||||||
@@ -223,32 +239,53 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
sector_id = request.data.get('sector_id')
|
sector_id = request.data.get('sector_id')
|
||||||
|
|
||||||
if not seed_keyword_ids:
|
if not seed_keyword_ids:
|
||||||
return Response({'error': 'No seed keyword IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No seed keyword IDs provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
if not site_id:
|
if not site_id:
|
||||||
return Response({'error': 'site_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='site_id is required',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
if not sector_id:
|
if not sector_id:
|
||||||
return Response({'error': 'sector_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='sector_id is required',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
site = Site.objects.get(id=site_id)
|
site = Site.objects.get(id=site_id)
|
||||||
sector = Sector.objects.get(id=sector_id)
|
sector = Sector.objects.get(id=sector_id)
|
||||||
except (Site.DoesNotExist, Sector.DoesNotExist) as e:
|
except (Site.DoesNotExist, Sector.DoesNotExist) as e:
|
||||||
return Response({'error': f'Invalid site or sector: {str(e)}'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error=f'Invalid site or sector: {str(e)}',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
# Validate sector belongs to site
|
# Validate sector belongs to site
|
||||||
if sector.site != site:
|
if sector.site != site:
|
||||||
return Response({'error': 'Sector does not belong to the specified site'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='Sector does not belong to the specified site',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
# Get account from site
|
# Get account from site
|
||||||
account = site.account
|
account = site.account
|
||||||
if not account:
|
if not account:
|
||||||
return Response({'error': 'Site has no account assigned'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='Site has no account assigned',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
# Get SeedKeywords
|
# Get SeedKeywords
|
||||||
seed_keywords = SeedKeyword.objects.filter(id__in=seed_keyword_ids, is_active=True)
|
seed_keywords = SeedKeyword.objects.filter(id__in=seed_keyword_ids, is_active=True)
|
||||||
|
|
||||||
if not seed_keywords.exists():
|
if not seed_keywords.exists():
|
||||||
return Response({'error': 'No valid seed keywords found'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No valid seed keywords found',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
created_count = 0
|
created_count = 0
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
@@ -288,12 +325,14 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
errors.append(f"Error adding '{seed_keyword.keyword}': {str(e)}")
|
errors.append(f"Error adding '{seed_keyword.keyword}': {str(e)}")
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
|
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={
|
||||||
'created': created_count,
|
'created': created_count,
|
||||||
'skipped': skipped_count,
|
'skipped': skipped_count,
|
||||||
'errors': errors[:10] if errors else [] # Limit errors to first 10
|
'errors': errors[:10] if errors else [] # Limit errors to first 10
|
||||||
}, status=status.HTTP_200_OK)
|
},
|
||||||
|
message=f'Successfully added {created_count} keyword(s) to workflow'
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], url_path='export', url_name='export')
|
@action(detail=False, methods=['get'], url_path='export', url_name='export')
|
||||||
def export(self, request):
|
def export(self, request):
|
||||||
@@ -366,11 +405,17 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
Automatically links keywords to current active site/sector.
|
Automatically links keywords to current active site/sector.
|
||||||
"""
|
"""
|
||||||
if 'file' not in request.FILES:
|
if 'file' not in request.FILES:
|
||||||
return Response({'error': 'No file provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No file provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
file = request.FILES['file']
|
file = request.FILES['file']
|
||||||
if not file.name.endswith('.csv'):
|
if not file.name.endswith('.csv'):
|
||||||
return Response({'error': 'File must be a CSV'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='File must be a CSV',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
user = getattr(request, 'user', None)
|
user = getattr(request, 'user', None)
|
||||||
|
|
||||||
@@ -391,23 +436,38 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
# Site ID is REQUIRED
|
# Site ID is REQUIRED
|
||||||
if not site_id:
|
if not site_id:
|
||||||
return Response({'error': 'site_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='site_id is required',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
site = Site.objects.get(id=site_id)
|
site = Site.objects.get(id=site_id)
|
||||||
except Site.DoesNotExist:
|
except Site.DoesNotExist:
|
||||||
return Response({'error': f'Site with id {site_id} does not exist'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error=f'Site with id {site_id} does not exist',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
# Sector ID is REQUIRED
|
# Sector ID is REQUIRED
|
||||||
if not sector_id:
|
if not sector_id:
|
||||||
return Response({'error': 'sector_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='sector_id is required',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sector = Sector.objects.get(id=sector_id)
|
sector = Sector.objects.get(id=sector_id)
|
||||||
if sector.site_id != site_id:
|
if sector.site_id != site_id:
|
||||||
return Response({'error': 'Sector does not belong to the selected site'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='Sector does not belong to the selected site',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
except Sector.DoesNotExist:
|
except Sector.DoesNotExist:
|
||||||
return Response({'error': f'Sector with id {sector_id} does not exist'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error=f'Sector with id {sector_id} does not exist',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
# Get account
|
# Get account
|
||||||
account = getattr(request, 'account', None)
|
account = getattr(request, 'account', None)
|
||||||
@@ -461,17 +521,20 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
errors.append(f"Row {row_num}: {str(e)}")
|
errors.append(f"Row {row_num}: {str(e)}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={
|
||||||
'imported': imported_count,
|
'imported': imported_count,
|
||||||
'skipped': skipped_count,
|
'skipped': skipped_count,
|
||||||
'errors': errors[:10] if errors else [] # Limit errors to first 10
|
'errors': errors[:10] if errors else [] # Limit errors to first 10
|
||||||
}, status=status.HTTP_200_OK)
|
},
|
||||||
|
message=f'Successfully imported {imported_count} keyword(s)'
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'Failed to parse CSV: {str(e)}'
|
error=f'Failed to parse CSV: {str(e)}',
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='auto_cluster', url_name='auto_cluster')
|
@action(detail=False, methods=['post'], url_path='auto_cluster', url_name='auto_cluster')
|
||||||
def auto_cluster(self, request):
|
def auto_cluster(self, request):
|
||||||
@@ -497,16 +560,16 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
# Validate basic input
|
# Validate basic input
|
||||||
if not payload['ids']:
|
if not payload['ids']:
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error='No IDs provided',
|
||||||
'error': 'No IDs provided'
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
)
|
||||||
|
|
||||||
if len(payload['ids']) > 20:
|
if len(payload['ids']) > 20:
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error='Maximum 20 keywords allowed for clustering',
|
||||||
'error': 'Maximum 20 keywords allowed for clustering'
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
)
|
||||||
|
|
||||||
# Try to queue Celery task
|
# Try to queue Celery task
|
||||||
try:
|
try:
|
||||||
@@ -517,11 +580,12 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
account_id=account_id
|
account_id=account_id
|
||||||
)
|
)
|
||||||
logger.info(f"Task queued: {task.id}")
|
logger.info(f"Task queued: {task.id}")
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={
|
||||||
'task_id': str(task.id),
|
'task_id': str(task.id)
|
||||||
'message': 'Clustering started'
|
},
|
||||||
}, status=status.HTTP_200_OK)
|
message='Clustering started'
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Celery not available - execute synchronously
|
# Celery not available - execute synchronously
|
||||||
logger.warning("Celery not available, executing synchronously")
|
logger.warning("Celery not available, executing synchronously")
|
||||||
@@ -531,15 +595,15 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
account_id=account_id
|
account_id=account_id
|
||||||
)
|
)
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data=result,
|
||||||
**result
|
message='Clustering completed successfully'
|
||||||
}, status=status.HTTP_200_OK)
|
)
|
||||||
else:
|
else:
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error=result.get('error', 'Clustering failed'),
|
||||||
'error': result.get('error', 'Clustering failed')
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
except (KombuOperationalError, ConnectionError) as e:
|
except (KombuOperationalError, ConnectionError) as e:
|
||||||
# Broker connection failed - fall back to synchronous execution
|
# Broker connection failed - fall back to synchronous execution
|
||||||
logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}")
|
logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}")
|
||||||
@@ -549,27 +613,27 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
|||||||
account_id=account_id
|
account_id=account_id
|
||||||
)
|
)
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data=result,
|
||||||
**result
|
message='Clustering completed successfully'
|
||||||
}, status=status.HTTP_200_OK)
|
)
|
||||||
else:
|
else:
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error=result.get('error', 'Clustering failed'),
|
||||||
'error': result.get('error', 'Clustering failed')
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in auto_cluster: {str(e)}", exc_info=True)
|
logger.error(f"Error in auto_cluster: {str(e)}", exc_info=True)
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error=str(e),
|
||||||
'error': str(e)
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error in auto_cluster: {str(e)}", exc_info=True)
|
logger.error(f"Unexpected error in auto_cluster: {str(e)}", exc_info=True)
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error=f'Unexpected error: {str(e)}',
|
||||||
'error': f'Unexpected error: {str(e)}'
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ClusterViewSet(SiteSectorModelViewSet):
|
class ClusterViewSet(SiteSectorModelViewSet):
|
||||||
@@ -719,12 +783,18 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
|||||||
"""Bulk delete clusters"""
|
"""Bulk delete clusters"""
|
||||||
ids = request.data.get('ids', [])
|
ids = request.data.get('ids', [])
|
||||||
if not ids:
|
if not ids:
|
||||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No IDs provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
deleted_count, _ = queryset.filter(id__in=ids).delete()
|
deleted_count, _ = queryset.filter(id__in=ids).delete()
|
||||||
|
|
||||||
return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK)
|
return success_response(
|
||||||
|
data={'deleted_count': deleted_count},
|
||||||
|
message=f'Successfully deleted {deleted_count} cluster(s)'
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas')
|
@action(detail=False, methods=['post'], url_path='auto_generate_ideas', url_name='auto_generate_ideas')
|
||||||
def auto_generate_ideas(self, request):
|
def auto_generate_ideas(self, request):
|
||||||
@@ -749,16 +819,16 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
# Validate basic input
|
# Validate basic input
|
||||||
if not payload['ids']:
|
if not payload['ids']:
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error='No IDs provided',
|
||||||
'error': 'No IDs provided'
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
)
|
||||||
|
|
||||||
if len(payload['ids']) > 10:
|
if len(payload['ids']) > 10:
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error='Maximum 10 clusters allowed for idea generation',
|
||||||
'error': 'Maximum 10 clusters allowed for idea generation'
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
)
|
||||||
|
|
||||||
# Try to queue Celery task
|
# Try to queue Celery task
|
||||||
try:
|
try:
|
||||||
@@ -769,11 +839,12 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
|||||||
account_id=account_id
|
account_id=account_id
|
||||||
)
|
)
|
||||||
logger.info(f"Task queued: {task.id}")
|
logger.info(f"Task queued: {task.id}")
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={
|
||||||
'task_id': str(task.id),
|
'task_id': str(task.id)
|
||||||
'message': 'Idea generation started'
|
},
|
||||||
}, status=status.HTTP_200_OK)
|
message='Idea generation started'
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Celery not available - execute synchronously
|
# Celery not available - execute synchronously
|
||||||
logger.warning("Celery not available, executing synchronously")
|
logger.warning("Celery not available, executing synchronously")
|
||||||
@@ -783,15 +854,15 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
|||||||
account_id=account_id
|
account_id=account_id
|
||||||
)
|
)
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data=result,
|
||||||
**result
|
message='Idea generation completed successfully'
|
||||||
}, status=status.HTTP_200_OK)
|
)
|
||||||
else:
|
else:
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error=result.get('error', 'Idea generation failed'),
|
||||||
'error': result.get('error', 'Idea generation failed')
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
except (KombuOperationalError, ConnectionError) as e:
|
except (KombuOperationalError, ConnectionError) as e:
|
||||||
# Broker connection failed - fall back to synchronous execution
|
# Broker connection failed - fall back to synchronous execution
|
||||||
logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}")
|
logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}")
|
||||||
@@ -801,27 +872,27 @@ class ClusterViewSet(SiteSectorModelViewSet):
|
|||||||
account_id=account_id
|
account_id=account_id
|
||||||
)
|
)
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data=result,
|
||||||
**result
|
message='Idea generation completed successfully'
|
||||||
}, status=status.HTTP_200_OK)
|
)
|
||||||
else:
|
else:
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error=result.get('error', 'Idea generation failed'),
|
||||||
'error': result.get('error', 'Idea generation failed')
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in auto_generate_ideas: {str(e)}", exc_info=True)
|
logger.error(f"Error in auto_generate_ideas: {str(e)}", exc_info=True)
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error=str(e),
|
||||||
'error': str(e)
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error in auto_generate_ideas: {str(e)}", exc_info=True)
|
logger.error(f"Unexpected error in auto_generate_ideas: {str(e)}", exc_info=True)
|
||||||
return Response({
|
return error_response(
|
||||||
'success': False,
|
error=f'Unexpected error: {str(e)}',
|
||||||
'error': f'Unexpected error: {str(e)}'
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
@@ -919,19 +990,28 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
|
|||||||
"""Bulk delete content ideas"""
|
"""Bulk delete content ideas"""
|
||||||
ids = request.data.get('ids', [])
|
ids = request.data.get('ids', [])
|
||||||
if not ids:
|
if not ids:
|
||||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No IDs provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
deleted_count, _ = queryset.filter(id__in=ids).delete()
|
deleted_count, _ = queryset.filter(id__in=ids).delete()
|
||||||
|
|
||||||
return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK)
|
return success_response(
|
||||||
|
data={'deleted_count': deleted_count},
|
||||||
|
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')
|
@action(detail=False, methods=['post'], url_path='bulk_queue_to_writer', url_name='bulk_queue_to_writer')
|
||||||
def bulk_queue_to_writer(self, request):
|
def bulk_queue_to_writer(self, request):
|
||||||
"""Queue ideas to writer by creating Tasks"""
|
"""Queue ideas to writer by creating Tasks"""
|
||||||
ids = request.data.get('ids', [])
|
ids = request.data.get('ids', [])
|
||||||
if not ids:
|
if not ids:
|
||||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No IDs provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
ideas = queryset.filter(id__in=ids, status='new') # Only queue 'new' ideas
|
ideas = queryset.filter(id__in=ids, status='new') # Only queue 'new' ideas
|
||||||
@@ -958,11 +1038,12 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
|
|||||||
idea.status = 'scheduled'
|
idea.status = 'scheduled'
|
||||||
idea.save()
|
idea.save()
|
||||||
|
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={
|
||||||
'created_count': len(created_tasks),
|
'created_count': len(created_tasks),
|
||||||
'task_ids': created_tasks,
|
'task_ids': created_tasks
|
||||||
'message': f'Successfully queued {len(created_tasks)} ideas to writer'
|
},
|
||||||
}, status=status.HTTP_200_OK)
|
message=f'Successfully queued {len(created_tasks)} ideas to writer'
|
||||||
|
)
|
||||||
|
|
||||||
# REMOVED: generate_idea action - idea generation function removed
|
# REMOVED: generate_idea action - idea generation function removed
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.db import transaction, models
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from igny8_core.api.base import SiteSectorModelViewSet
|
from igny8_core.api.base import SiteSectorModelViewSet
|
||||||
from igny8_core.api.pagination import CustomPageNumberPagination
|
from igny8_core.api.pagination import CustomPageNumberPagination
|
||||||
|
from igny8_core.api.response import success_response, error_response
|
||||||
from .models import Tasks, Images, Content
|
from .models import Tasks, Images, Content
|
||||||
from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer
|
from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer
|
||||||
|
|
||||||
@@ -84,12 +85,18 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
"""Bulk delete tasks"""
|
"""Bulk delete tasks"""
|
||||||
ids = request.data.get('ids', [])
|
ids = request.data.get('ids', [])
|
||||||
if not ids:
|
if not ids:
|
||||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No IDs provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
deleted_count, _ = queryset.filter(id__in=ids).delete()
|
deleted_count, _ = queryset.filter(id__in=ids).delete()
|
||||||
|
|
||||||
return Response({'deleted_count': deleted_count}, status=status.HTTP_200_OK)
|
return success_response(
|
||||||
|
data={'deleted_count': deleted_count},
|
||||||
|
message=f'Successfully deleted {deleted_count} task(s)'
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
|
@action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
|
||||||
def bulk_update(self, request):
|
def bulk_update(self, request):
|
||||||
@@ -98,14 +105,23 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
status_value = request.data.get('status')
|
status_value = request.data.get('status')
|
||||||
|
|
||||||
if not ids:
|
if not ids:
|
||||||
return Response({'error': 'No IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No IDs provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
if not status_value:
|
if not status_value:
|
||||||
return Response({'error': 'No status provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No status provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
updated_count = queryset.filter(id__in=ids).update(status=status_value)
|
updated_count = queryset.filter(id__in=ids).update(status=status_value)
|
||||||
|
|
||||||
return Response({'updated_count': updated_count}, status=status.HTTP_200_OK)
|
return success_response(
|
||||||
|
data={'updated_count': updated_count},
|
||||||
|
message=f'Successfully updated {updated_count} task(s)'
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='auto_generate_content', url_name='auto_generate_content')
|
@action(detail=False, methods=['post'], url_path='auto_generate_content', url_name='auto_generate_content')
|
||||||
def auto_generate_content(self, request):
|
def auto_generate_content(self, request):
|
||||||
@@ -120,17 +136,17 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
ids = request.data.get('ids', [])
|
ids = request.data.get('ids', [])
|
||||||
if not ids:
|
if not ids:
|
||||||
logger.warning("auto_generate_content: No IDs provided")
|
logger.warning("auto_generate_content: No IDs provided")
|
||||||
return Response({
|
return error_response(
|
||||||
'error': 'No IDs provided',
|
error='No IDs provided',
|
||||||
'type': 'ValidationError'
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
)
|
||||||
|
|
||||||
if len(ids) > 10:
|
if len(ids) > 10:
|
||||||
logger.warning(f"auto_generate_content: Too many IDs provided: {len(ids)}")
|
logger.warning(f"auto_generate_content: Too many IDs provided: {len(ids)}")
|
||||||
return Response({
|
return error_response(
|
||||||
'error': 'Maximum 10 tasks allowed for content generation',
|
error='Maximum 10 tasks allowed for content generation',
|
||||||
'type': 'ValidationError'
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
)
|
||||||
|
|
||||||
logger.info(f"auto_generate_content: Processing {len(ids)} task IDs: {ids}")
|
logger.info(f"auto_generate_content: Processing {len(ids)} task IDs: {ids}")
|
||||||
|
|
||||||
@@ -151,11 +167,10 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
|
|
||||||
if existing_count == 0:
|
if existing_count == 0:
|
||||||
logger.error(f"auto_generate_content: No tasks found for IDs: {ids}")
|
logger.error(f"auto_generate_content: No tasks found for IDs: {ids}")
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'No tasks found for the provided IDs: {ids}',
|
error=f'No tasks found for the provided IDs: {ids}',
|
||||||
'type': 'NotFound',
|
status_code=status.HTTP_404_NOT_FOUND
|
||||||
'requested_ids': ids
|
)
|
||||||
}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
if existing_count < len(ids):
|
if existing_count < len(ids):
|
||||||
missing_ids = set(ids) - set(existing_ids)
|
missing_ids = set(ids) - set(existing_ids)
|
||||||
@@ -171,11 +186,10 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
logger.error(f" - Account ID: {account_id}")
|
logger.error(f" - Account ID: {account_id}")
|
||||||
logger.error("=" * 80, exc_info=True)
|
logger.error("=" * 80, exc_info=True)
|
||||||
|
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'Database error while querying tasks: {str(db_error)}',
|
error=f'Database error while querying tasks: {str(db_error)}',
|
||||||
'type': 'OperationalError',
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
'details': 'Failed to retrieve tasks from database. Please check database connection and try again.'
|
)
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
# Try to queue Celery task, fall back to synchronous if Celery not available
|
# Try to queue Celery task, fall back to synchronous if Celery not available
|
||||||
try:
|
try:
|
||||||
@@ -192,11 +206,10 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
account_id=account_id
|
account_id=account_id
|
||||||
)
|
)
|
||||||
logger.info(f"auto_generate_content: Celery task queued successfully: {task.id}")
|
logger.info(f"auto_generate_content: Celery task queued successfully: {task.id}")
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={'task_id': str(task.id)},
|
||||||
'task_id': str(task.id),
|
message='Content generation started'
|
||||||
'message': 'Content generation started'
|
)
|
||||||
}, status=status.HTTP_200_OK)
|
|
||||||
except KombuOperationalError as celery_error:
|
except KombuOperationalError as celery_error:
|
||||||
logger.error("=" * 80)
|
logger.error("=" * 80)
|
||||||
logger.error("CELERY ERROR: Failed to queue task")
|
logger.error("CELERY ERROR: Failed to queue task")
|
||||||
@@ -206,10 +219,10 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
logger.error(f" - Account ID: {account_id}")
|
logger.error(f" - Account ID: {account_id}")
|
||||||
logger.error("=" * 80, exc_info=True)
|
logger.error("=" * 80, exc_info=True)
|
||||||
|
|
||||||
return Response({
|
return error_response(
|
||||||
'error': 'Task queue unavailable. Please try again.',
|
error='Task queue unavailable. Please try again.',
|
||||||
'type': 'QueueError'
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
)
|
||||||
except Exception as celery_error:
|
except Exception as celery_error:
|
||||||
logger.error("=" * 80)
|
logger.error("=" * 80)
|
||||||
logger.error("CELERY ERROR: Failed to queue task")
|
logger.error("CELERY ERROR: Failed to queue task")
|
||||||
@@ -227,16 +240,15 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
account_id=account_id
|
account_id=account_id
|
||||||
)
|
)
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={'tasks_updated': result.get('count', 0)},
|
||||||
'tasks_updated': result.get('count', 0),
|
message='Content generated successfully (synchronous)'
|
||||||
'message': 'Content generated successfully (synchronous)'
|
)
|
||||||
}, status=status.HTTP_200_OK)
|
|
||||||
else:
|
else:
|
||||||
return Response({
|
return error_response(
|
||||||
'error': result.get('error', 'Content generation failed'),
|
error=result.get('error', 'Content generation failed'),
|
||||||
'type': 'TaskExecutionError'
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
else:
|
else:
|
||||||
# Celery not available - execute synchronously
|
# Celery not available - execute synchronously
|
||||||
logger.info(f"auto_generate_content: Executing synchronously (Celery not available)")
|
logger.info(f"auto_generate_content: Executing synchronously (Celery not available)")
|
||||||
@@ -247,17 +259,16 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
)
|
)
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
logger.info(f"auto_generate_content: Synchronous execution successful: {result.get('count', 0)} tasks updated")
|
logger.info(f"auto_generate_content: Synchronous execution successful: {result.get('count', 0)} tasks updated")
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={'tasks_updated': result.get('count', 0)},
|
||||||
'tasks_updated': result.get('count', 0),
|
message='Content generated successfully'
|
||||||
'message': 'Content generated successfully'
|
)
|
||||||
}, status=status.HTTP_200_OK)
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"auto_generate_content: Synchronous execution failed: {result.get('error', 'Unknown error')}")
|
logger.error(f"auto_generate_content: Synchronous execution failed: {result.get('error', 'Unknown error')}")
|
||||||
return Response({
|
return error_response(
|
||||||
'error': result.get('error', 'Content generation failed'),
|
error=result.get('error', 'Content generation failed'),
|
||||||
'type': 'TaskExecutionError'
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
|
|
||||||
except ImportError as import_error:
|
except ImportError as import_error:
|
||||||
logger.error(f"auto_generate_content: ImportError - tasks module not available: {str(import_error)}")
|
logger.error(f"auto_generate_content: ImportError - tasks module not available: {str(import_error)}")
|
||||||
@@ -268,21 +279,20 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
updated_count = tasks.update(status='completed', content='[AI content generation not available]')
|
updated_count = tasks.update(status='completed', content='[AI content generation not available]')
|
||||||
|
|
||||||
logger.info(f"auto_generate_content: Updated {updated_count} tasks (AI generation not available)")
|
logger.info(f"auto_generate_content: Updated {updated_count} tasks (AI generation not available)")
|
||||||
return Response({
|
return success_response(
|
||||||
'updated_count': updated_count,
|
data={'updated_count': updated_count},
|
||||||
'message': 'Tasks updated (AI generation not available)'
|
message='Tasks updated (AI generation not available)'
|
||||||
}, status=status.HTTP_200_OK)
|
)
|
||||||
except (OperationalError, DatabaseError) as db_error:
|
except (OperationalError, DatabaseError) as db_error:
|
||||||
logger.error("=" * 80)
|
logger.error("=" * 80)
|
||||||
logger.error("DATABASE ERROR: Failed to update tasks")
|
logger.error("DATABASE ERROR: Failed to update tasks")
|
||||||
logger.error(f" - Error type: {type(db_error).__name__}")
|
logger.error(f" - Error type: {type(db_error).__name__}")
|
||||||
logger.error(f" - Error message: {str(db_error)}")
|
logger.error(f" - Error message: {str(db_error)}")
|
||||||
logger.error("=" * 80, exc_info=True)
|
logger.error("=" * 80, exc_info=True)
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'Database error while updating tasks: {str(db_error)}',
|
error=f'Database error while updating tasks: {str(db_error)}',
|
||||||
'type': 'OperationalError',
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
'details': 'Failed to update tasks in database. Please check database connection.'
|
)
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
except (OperationalError, DatabaseError) as db_error:
|
except (OperationalError, DatabaseError) as db_error:
|
||||||
logger.error("=" * 80)
|
logger.error("=" * 80)
|
||||||
@@ -293,11 +303,10 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
logger.error(f" - Account ID: {account_id}")
|
logger.error(f" - Account ID: {account_id}")
|
||||||
logger.error("=" * 80, exc_info=True)
|
logger.error("=" * 80, exc_info=True)
|
||||||
|
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'Database error during content generation: {str(db_error)}',
|
error=f'Database error during content generation: {str(db_error)}',
|
||||||
'type': 'OperationalError',
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
'details': 'A database operation failed. This may be due to connection issues, constraint violations, or data integrity problems. Check the logs for more details.'
|
)
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
except IntegrityError as integrity_error:
|
except IntegrityError as integrity_error:
|
||||||
logger.error("=" * 80)
|
logger.error("=" * 80)
|
||||||
@@ -306,18 +315,17 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
logger.error(f" - Task IDs: {ids}")
|
logger.error(f" - Task IDs: {ids}")
|
||||||
logger.error("=" * 80, exc_info=True)
|
logger.error("=" * 80, exc_info=True)
|
||||||
|
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'Data integrity error: {str(integrity_error)}',
|
error=f'Data integrity error: {str(integrity_error)}',
|
||||||
'type': 'IntegrityError',
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
'details': 'The operation violated database constraints. This may indicate missing required relationships or invalid data.'
|
)
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
except ValidationError as validation_error:
|
except ValidationError as validation_error:
|
||||||
logger.error(f"auto_generate_content: ValidationError: {str(validation_error)}")
|
logger.error(f"auto_generate_content: ValidationError: {str(validation_error)}")
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'Validation error: {str(validation_error)}',
|
error=f'Validation error: {str(validation_error)}',
|
||||||
'type': 'ValidationError'
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("=" * 80)
|
logger.error("=" * 80)
|
||||||
@@ -328,11 +336,10 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
logger.error(f" - Account ID: {account_id}")
|
logger.error(f" - Account ID: {account_id}")
|
||||||
logger.error("=" * 80, exc_info=True)
|
logger.error("=" * 80, exc_info=True)
|
||||||
|
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'Unexpected error: {str(e)}',
|
error=f'Unexpected error: {str(e)}',
|
||||||
'type': type(e).__name__,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
'details': 'An unexpected error occurred. Please check the logs for more details.'
|
)
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
except Exception as outer_error:
|
except Exception as outer_error:
|
||||||
logger.error("=" * 80)
|
logger.error("=" * 80)
|
||||||
@@ -341,10 +348,10 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
logger.error(f" - Error message: {str(outer_error)}")
|
logger.error(f" - Error message: {str(outer_error)}")
|
||||||
logger.error("=" * 80, exc_info=True)
|
logger.error("=" * 80, exc_info=True)
|
||||||
|
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'Critical error: {str(outer_error)}',
|
error=f'Critical error: {str(outer_error)}',
|
||||||
'type': type(outer_error).__name__
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ImagesViewSet(SiteSectorModelViewSet):
|
class ImagesViewSet(SiteSectorModelViewSet):
|
||||||
@@ -383,30 +390,34 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
|||||||
try:
|
try:
|
||||||
image = Images.objects.get(pk=pk)
|
image = Images.objects.get(pk=pk)
|
||||||
except Images.DoesNotExist:
|
except Images.DoesNotExist:
|
||||||
return Response({
|
return error_response(
|
||||||
'error': 'Image not found'
|
error='Image not found',
|
||||||
}, status=status.HTTP_404_NOT_FOUND)
|
status_code=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
# Check if image has a local path
|
# Check if image has a local path
|
||||||
if not image.image_path:
|
if not image.image_path:
|
||||||
return Response({
|
return error_response(
|
||||||
'error': 'No local file path available for this image'
|
error='No local file path available for this image',
|
||||||
}, status=status.HTTP_404_NOT_FOUND)
|
status_code=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
file_path = image.image_path
|
file_path = image.image_path
|
||||||
|
|
||||||
# Verify file exists at the saved path
|
# Verify file exists at the saved path
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
logger.error(f"[serve_image_file] Image {pk} - File not found at saved path: {file_path}")
|
logger.error(f"[serve_image_file] Image {pk} - File not found at saved path: {file_path}")
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'Image file not found at: {file_path}'
|
error=f'Image file not found at: {file_path}',
|
||||||
}, status=status.HTTP_404_NOT_FOUND)
|
status_code=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
# Check if file is readable
|
# Check if file is readable
|
||||||
if not os.access(file_path, os.R_OK):
|
if not os.access(file_path, os.R_OK):
|
||||||
return Response({
|
return error_response(
|
||||||
'error': 'Image file is not readable'
|
error='Image file is not readable',
|
||||||
}, status=status.HTTP_403_FORBIDDEN)
|
status_code=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
# Determine content type from file extension
|
# Determine content type from file extension
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -422,31 +433,40 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
|||||||
filename=os.path.basename(file_path)
|
filename=os.path.basename(file_path)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'Failed to serve file: {str(e)}'
|
error=f'Failed to serve file: {str(e)}',
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
except Images.DoesNotExist:
|
except Images.DoesNotExist:
|
||||||
return Response({
|
return error_response(
|
||||||
'error': 'Image not found'
|
error='Image not found',
|
||||||
}, status=status.HTTP_404_NOT_FOUND)
|
status_code=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.error(f"Error serving image file: {str(e)}", exc_info=True)
|
logger.error(f"Error serving image file: {str(e)}", exc_info=True)
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'Failed to serve image: {str(e)}'
|
error=f'Failed to serve image: {str(e)}',
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='auto_generate', url_name='auto_generate_images')
|
@action(detail=False, methods=['post'], url_path='auto_generate', url_name='auto_generate_images')
|
||||||
def auto_generate_images(self, request):
|
def auto_generate_images(self, request):
|
||||||
"""Auto-generate images for tasks using AI"""
|
"""Auto-generate images for tasks using AI"""
|
||||||
task_ids = request.data.get('task_ids', [])
|
task_ids = request.data.get('task_ids', [])
|
||||||
if not task_ids:
|
if not task_ids:
|
||||||
return Response({'error': 'No task IDs provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No task IDs provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
if len(task_ids) > 10:
|
if len(task_ids) > 10:
|
||||||
return Response({'error': 'Maximum 10 tasks allowed for image generation'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='Maximum 10 tasks allowed for image generation',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
# Get account
|
# Get account
|
||||||
account = getattr(request, 'account', None)
|
account = getattr(request, 'account', None)
|
||||||
@@ -464,11 +484,10 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
|||||||
payload={'ids': task_ids},
|
payload={'ids': task_ids},
|
||||||
account_id=account_id
|
account_id=account_id
|
||||||
)
|
)
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={'task_id': str(task.id)},
|
||||||
'task_id': str(task.id),
|
message='Image generation started'
|
||||||
'message': 'Image generation started'
|
)
|
||||||
}, status=status.HTTP_200_OK)
|
|
||||||
else:
|
else:
|
||||||
# Celery not available - execute synchronously
|
# Celery not available - execute synchronously
|
||||||
result = run_ai_task(
|
result = run_ai_task(
|
||||||
@@ -477,33 +496,34 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
|||||||
account_id=account_id
|
account_id=account_id
|
||||||
)
|
)
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={'images_created': result.get('count', 0)},
|
||||||
'images_created': result.get('count', 0),
|
message=result.get('message', 'Image generation completed')
|
||||||
'message': result.get('message', 'Image generation completed')
|
)
|
||||||
}, status=status.HTTP_200_OK)
|
|
||||||
else:
|
else:
|
||||||
return Response({
|
return error_response(
|
||||||
'error': result.get('error', 'Image generation failed')
|
error=result.get('error', 'Image generation failed'),
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
except KombuOperationalError as e:
|
except KombuOperationalError as e:
|
||||||
return Response({
|
return error_response(
|
||||||
'error': 'Task queue unavailable. Please try again.',
|
error='Task queue unavailable. Please try again.',
|
||||||
'type': 'QueueError'
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Tasks module not available
|
# Tasks module not available
|
||||||
return Response({
|
return error_response(
|
||||||
'error': 'Image generation task not available'
|
error='Image generation task not available',
|
||||||
}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.error(f"Error queuing image generation task: {str(e)}", exc_info=True)
|
logger.error(f"Error queuing image generation task: {str(e)}", exc_info=True)
|
||||||
return Response({
|
return error_response(
|
||||||
'error': f'Failed to start image generation: {str(e)}',
|
error=f'Failed to start image generation: {str(e)}',
|
||||||
'type': 'TaskError'
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
|
@action(detail=False, methods=['post'], url_path='bulk_update', url_name='bulk_update')
|
||||||
def bulk_update(self, request):
|
def bulk_update(self, request):
|
||||||
@@ -518,7 +538,10 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
|||||||
status_value = request.data.get('status')
|
status_value = request.data.get('status')
|
||||||
|
|
||||||
if not status_value:
|
if not status_value:
|
||||||
return Response({'error': 'No status provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='No status provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
|
|
||||||
@@ -534,13 +557,22 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
|||||||
Q(content=content) | Q(task=content.task)
|
Q(content=content) | Q(task=content.task)
|
||||||
).update(status=status_value)
|
).update(status=status_value)
|
||||||
except Content.DoesNotExist:
|
except Content.DoesNotExist:
|
||||||
return Response({'error': 'Content not found'}, status=status.HTTP_404_NOT_FOUND)
|
return error_response(
|
||||||
|
error='Content not found',
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
elif image_ids:
|
elif image_ids:
|
||||||
updated_count = queryset.filter(id__in=image_ids).update(status=status_value)
|
updated_count = queryset.filter(id__in=image_ids).update(status=status_value)
|
||||||
else:
|
else:
|
||||||
return Response({'error': 'Either content_id or ids must be provided'}, status=status.HTTP_400_BAD_REQUEST)
|
return error_response(
|
||||||
|
error='Either content_id or ids must be provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
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')
|
@action(detail=False, methods=['get'], url_path='content_images', url_name='content_images')
|
||||||
def content_images(self, request):
|
def content_images(self, request):
|
||||||
@@ -621,10 +653,12 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
|||||||
# Sort by content title
|
# Sort by content title
|
||||||
grouped_data.sort(key=lambda x: x['content_title'])
|
grouped_data.sort(key=lambda x: x['content_title'])
|
||||||
|
|
||||||
return Response({
|
return success_response(
|
||||||
'count': len(grouped_data),
|
data={
|
||||||
'results': grouped_data
|
'count': len(grouped_data),
|
||||||
}, status=status.HTTP_200_OK)
|
'results': grouped_data
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='generate_images', url_name='generate_images')
|
@action(detail=False, methods=['post'], url_path='generate_images', url_name='generate_images')
|
||||||
def generate_images(self, request):
|
def generate_images(self, request):
|
||||||
@@ -636,10 +670,10 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
|||||||
content_id = request.data.get('content_id')
|
content_id = request.data.get('content_id')
|
||||||
|
|
||||||
if not image_ids:
|
if not image_ids:
|
||||||
return Response({
|
return error_response(
|
||||||
'error': 'No image IDs provided',
|
error='No image IDs provided',
|
||||||
'type': 'ValidationError'
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
)
|
||||||
|
|
||||||
account_id = account.id if account else None
|
account_id = account.id if account else None
|
||||||
|
|
||||||
@@ -651,11 +685,10 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
|||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
content_id=content_id
|
content_id=content_id
|
||||||
)
|
)
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={'task_id': str(task.id)},
|
||||||
'task_id': str(task.id),
|
message='Image generation started'
|
||||||
'message': 'Image generation started'
|
)
|
||||||
}, status=status.HTTP_200_OK)
|
|
||||||
else:
|
else:
|
||||||
# Fallback to synchronous execution (for testing)
|
# Fallback to synchronous execution (for testing)
|
||||||
result = process_image_generation_queue(
|
result = process_image_generation_queue(
|
||||||
@@ -663,13 +696,19 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
|||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
content_id=content_id
|
content_id=content_id
|
||||||
)
|
)
|
||||||
return Response(result, status=status.HTTP_200_OK)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"[generate_images] Error: {str(e)}", exc_info=True)
|
logger.error(f"[generate_images] Error: {str(e)}", exc_info=True)
|
||||||
return Response({
|
return error_response(
|
||||||
'error': str(e),
|
error=str(e),
|
||||||
'type': 'ExecutionError'
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
|
|
||||||
class ContentViewSet(SiteSectorModelViewSet):
|
class ContentViewSet(SiteSectorModelViewSet):
|
||||||
"""
|
"""
|
||||||
@@ -702,10 +741,10 @@ class ContentViewSet(SiteSectorModelViewSet):
|
|||||||
ids = request.data.get('ids', [])
|
ids = request.data.get('ids', [])
|
||||||
|
|
||||||
if not ids:
|
if not ids:
|
||||||
return Response({
|
return error_response(
|
||||||
'error': 'No IDs provided',
|
error='No IDs provided',
|
||||||
'type': 'ValidationError'
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
)
|
||||||
|
|
||||||
account_id = account.id if account else None
|
account_id = account.id if account else None
|
||||||
|
|
||||||
@@ -717,11 +756,10 @@ class ContentViewSet(SiteSectorModelViewSet):
|
|||||||
payload={'ids': ids},
|
payload={'ids': ids},
|
||||||
account_id=account_id
|
account_id=account_id
|
||||||
)
|
)
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={'task_id': str(task.id)},
|
||||||
'task_id': str(task.id),
|
message='Image prompt generation started'
|
||||||
'message': 'Image prompt generation started'
|
)
|
||||||
}, status=status.HTTP_200_OK)
|
|
||||||
else:
|
else:
|
||||||
# Fallback to synchronous execution
|
# Fallback to synchronous execution
|
||||||
result = run_ai_task(
|
result = run_ai_task(
|
||||||
@@ -730,19 +768,18 @@ class ContentViewSet(SiteSectorModelViewSet):
|
|||||||
account_id=account_id
|
account_id=account_id
|
||||||
)
|
)
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
return Response({
|
return success_response(
|
||||||
'success': True,
|
data={'prompts_created': result.get('count', 0)},
|
||||||
'prompts_created': result.get('count', 0),
|
message='Image prompts generated successfully'
|
||||||
'message': 'Image prompts generated successfully'
|
)
|
||||||
}, status=status.HTTP_200_OK)
|
|
||||||
else:
|
else:
|
||||||
return Response({
|
return error_response(
|
||||||
'error': result.get('error', 'Image prompt generation failed'),
|
error=result.get('error', 'Image prompt generation failed'),
|
||||||
'type': 'TaskExecutionError'
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response({
|
return error_response(
|
||||||
'error': str(e),
|
error=str(e),
|
||||||
'type': 'ExecutionError'
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ REST_FRAMEWORK = {
|
|||||||
'igny8_core.api.authentication.CSRFExemptSessionAuthentication', # Session auth without CSRF for API
|
'igny8_core.api.authentication.CSRFExemptSessionAuthentication', # Session auth without CSRF for API
|
||||||
'rest_framework.authentication.BasicAuthentication', # Enable basic auth as fallback
|
'rest_framework.authentication.BasicAuthentication', # Enable basic auth as fallback
|
||||||
],
|
],
|
||||||
|
'EXCEPTION_HANDLER': 'igny8_core.api.exception_handlers.custom_exception_handler', # Unified error format
|
||||||
}
|
}
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ Including another URLconf
|
|||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
|
from igny8_core.api.views import PingView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
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/auth/', include('igny8_core.auth.urls')), # Auth endpoints
|
||||||
path('api/v1/planner/', include('igny8_core.modules.planner.urls')),
|
path('api/v1/planner/', include('igny8_core.modules.planner.urls')),
|
||||||
path('api/v1/writer/', include('igny8_core.modules.writer.urls')),
|
path('api/v1/writer/', include('igny8_core.modules.writer.urls')),
|
||||||
|
|||||||
178
backend/test_response_utilities.py
Normal file
178
backend/test_response_utilities.py
Normal file
@@ -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()
|
||||||
|
|
||||||
Reference in New Issue
Block a user