diff --git a/backend/igny8_core/api/base.py b/backend/igny8_core/api/base.py index 5adef3cf..a9e99781 100644 --- a/backend/igny8_core/api/base.py +++ b/backend/igny8_core/api/base.py @@ -194,6 +194,26 @@ class AccountModelViewSet(viewsets.ModelViewSet): status_code=status.HTTP_404_NOT_FOUND, request=request ) + + def list(self, request, *args, **kwargs): + """ + Override list to return unified format + """ + queryset = self.filter_queryset(self.get_queryset()) + + # Check if pagination is enabled + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + # Use paginator's get_paginated_response which already returns unified format + return self.get_paginated_response(serializer.data) + + # No pagination - return all results in unified format + serializer = self.get_serializer(queryset, many=True) + return success_response( + data=serializer.data, + request=request + ) class SiteSectorModelViewSet(AccountModelViewSet): diff --git a/backend/igny8_core/modules/planner/views.py b/backend/igny8_core/modules/planner/views.py index a19e8b1b..e4a3ec57 100644 --- a/backend/igny8_core/modules/planner/views.py +++ b/backend/igny8_core/modules/planner/views.py @@ -12,6 +12,7 @@ from igny8_core.api.base import SiteSectorModelViewSet from igny8_core.api.pagination import CustomPageNumberPagination from igny8_core.api.response import success_response, error_response from igny8_core.api.throttles import DebugScopedRateThrottle +from igny8_core.api.permissions import IsAuthenticatedAndActive, IsViewerOrAbove, IsEditorOrAbove from .models import Keywords, Clusters, ContentIdeas from .serializers import KeywordSerializer, ContentIdeasSerializer from .cluster_serializers import ClusterSerializer @@ -25,7 +26,7 @@ class KeywordViewSet(SiteSectorModelViewSet): """ queryset = Keywords.objects.all() serializer_class = KeywordSerializer - permission_classes = [] # Allow any for now + permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] pagination_class = CustomPageNumberPagination # Explicitly use custom pagination throttle_scope = 'planner' throttle_classes = [DebugScopedRateThrottle] @@ -668,6 +669,7 @@ class ClusterViewSet(SiteSectorModelViewSet): """ queryset = Clusters.objects.all() serializer_class = ClusterSerializer + permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] pagination_class = CustomPageNumberPagination # Explicitly use custom pagination throttle_scope = 'planner' throttle_classes = [DebugScopedRateThrottle] @@ -957,6 +959,7 @@ class ContentIdeasViewSet(SiteSectorModelViewSet): """ queryset = ContentIdeas.objects.all() serializer_class = ContentIdeasSerializer + permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] pagination_class = CustomPageNumberPagination throttle_scope = 'planner' throttle_classes = [DebugScopedRateThrottle] # Explicitly use custom pagination diff --git a/backend/igny8_core/modules/system/integration_views.py b/backend/igny8_core/modules/system/integration_views.py index b5a76fae..a92a9dc8 100644 --- a/backend/igny8_core/modules/system/integration_views.py +++ b/backend/igny8_core/modules/system/integration_views.py @@ -10,6 +10,7 @@ from django.db import transaction from igny8_core.api.base import AccountModelViewSet from igny8_core.api.response import success_response, error_response from igny8_core.api.throttles import DebugScopedRateThrottle +from igny8_core.api.permissions import IsAuthenticatedAndActive, IsAdminOrOwner from django.conf import settings logger = logging.getLogger(__name__) @@ -21,7 +22,7 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): Following reference plugin pattern: WordPress uses update_option() for igny8_api_settings We store in IntegrationSettings model with account isolation """ - permission_classes = [] # Allow any for now + permission_classes = [IsAuthenticatedAndActive, IsAdminOrOwner] throttle_scope = 'system_admin' throttle_classes = [DebugScopedRateThrottle] diff --git a/backend/igny8_core/modules/system/views.py b/backend/igny8_core/modules/system/views.py index 86a34913..6124e5c3 100644 --- a/backend/igny8_core/modules/system/views.py +++ b/backend/igny8_core/modules/system/views.py @@ -14,7 +14,7 @@ from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from igny8_core.api.base import AccountModelViewSet from igny8_core.api.response import success_response, error_response -from igny8_core.api.permissions import IsEditorOrAbove +from igny8_core.api.permissions import IsEditorOrAbove, IsAuthenticatedAndActive, IsViewerOrAbove from igny8_core.api.throttles import DebugScopedRateThrottle from igny8_core.api.pagination import CustomPageNumberPagination from .models import AIPrompt, AuthorProfile, Strategy @@ -199,6 +199,7 @@ class AuthorProfileViewSet(AccountModelViewSet): """ queryset = AuthorProfile.objects.all() serializer_class = AuthorProfileSerializer + permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] throttle_scope = 'system' throttle_classes = [DebugScopedRateThrottle] @@ -216,6 +217,7 @@ class StrategyViewSet(AccountModelViewSet): """ queryset = Strategy.objects.all() serializer_class = StrategySerializer + permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] throttle_scope = 'system' throttle_classes = [DebugScopedRateThrottle] diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index 10d0de71..a36f47b5 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -8,6 +8,7 @@ from igny8_core.api.base import SiteSectorModelViewSet from igny8_core.api.pagination import CustomPageNumberPagination from igny8_core.api.response import success_response, error_response from igny8_core.api.throttles import DebugScopedRateThrottle +from igny8_core.api.permissions import IsAuthenticatedAndActive, IsViewerOrAbove, IsEditorOrAbove from .models import Tasks, Images, Content from .serializers import TasksSerializer, ImagesSerializer, ContentSerializer @@ -19,6 +20,7 @@ class TasksViewSet(SiteSectorModelViewSet): """ queryset = Tasks.objects.select_related('content_record') serializer_class = TasksSerializer + permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] pagination_class = CustomPageNumberPagination # Explicitly use custom pagination throttle_scope = 'writer' throttle_classes = [DebugScopedRateThrottle] @@ -379,6 +381,7 @@ class ImagesViewSet(SiteSectorModelViewSet): """ queryset = Images.objects.all() serializer_class = ImagesSerializer + permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] pagination_class = CustomPageNumberPagination throttle_scope = 'writer' throttle_classes = [DebugScopedRateThrottle] @@ -777,6 +780,7 @@ class ContentViewSet(SiteSectorModelViewSet): """ queryset = Content.objects.all() serializer_class = ContentSerializer + permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove] pagination_class = CustomPageNumberPagination throttle_scope = 'writer' throttle_classes = [DebugScopedRateThrottle] diff --git a/frontend/src/hooks/usePersistentToggle.ts b/frontend/src/hooks/usePersistentToggle.ts index 3ef281bf..7807fc3c 100644 --- a/frontend/src/hooks/usePersistentToggle.ts +++ b/frontend/src/hooks/usePersistentToggle.ts @@ -134,12 +134,13 @@ export function usePersistentToggle( try { const endpoint = getEndpoint.replace('{id}', resourceId); + // fetchAPI extracts data from unified format {success: true, data: {...}} + // So result IS the data object, not wrapped const result = await fetchAPI(endpoint); - if (result.success && result.data) { - const apiData = result.data; - setData(apiData); - const newEnabled = extractEnabled(apiData); + if (result && typeof result === 'object') { + setData(result); + const newEnabled = extractEnabled(result); setEnabled(newEnabled); } else { // No data yet - use initial state diff --git a/frontend/src/pages/Settings/Integration.tsx b/frontend/src/pages/Settings/Integration.tsx index 3a151cef..908c0167 100644 --- a/frontend/src/pages/Settings/Integration.tsx +++ b/frontend/src/pages/Settings/Integration.tsx @@ -333,6 +333,9 @@ export default function Integration() { } try { + // fetchAPI extracts data from unified format {success: true, data: {...}} + // But test endpoint may return {success: true, ...} directly (not wrapped) + // So data could be either the extracted data object or the full response const data = await fetchAPI(`/v1/system/settings/integrations/${selectedIntegration}/test/`, { method: 'POST', body: JSON.stringify({ @@ -341,24 +344,51 @@ export default function Integration() { }), }); - if (data.success) { - toast.success(data.message || 'API connection test successful!'); - if (data.response) { - toast.info(`Response: ${data.response}`); - } - if (data.tokens_used) { - toast.info(`Tokens used: ${data.tokens_used}`); - } - - // Update validation status to success - if (selectedIntegration) { - setValidationStatuses(prev => ({ - ...prev, - [selectedIntegration]: 'success', - })); + // Handle both unified format (extracted) and direct format + // If data has success field, it's the direct response (not extracted) + // If data doesn't have success but has other fields, it's extracted data (successful) + if (data && typeof data === 'object') { + if (data.success === true || data.success === false) { + // Direct response format (not extracted by fetchAPI) + if (data.success) { + toast.success(data.message || 'API connection test successful!'); + if (data.response) { + toast.info(`Response: ${data.response}`); + } + if (data.tokens_used) { + toast.info(`Tokens used: ${data.tokens_used}`); + } + + // Update validation status to success + if (selectedIntegration) { + setValidationStatuses(prev => ({ + ...prev, + [selectedIntegration]: 'success', + })); + } + } else { + throw new Error(data.error || data.message || 'Connection test failed'); + } + } else { + // Extracted data format (successful response) + toast.success('API connection test successful!'); + if (data.response) { + toast.info(`Response: ${data.response}`); + } + if (data.tokens_used) { + toast.info(`Tokens used: ${data.tokens_used}`); + } + + // Update validation status to success + if (selectedIntegration) { + setValidationStatuses(prev => ({ + ...prev, + [selectedIntegration]: 'success', + })); + } } } else { - throw new Error(data.error || 'Connection test failed'); + throw new Error('Invalid response format'); } } catch (error: any) { console.error('Error testing connection:', error); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 31ef6711..277b863e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -640,34 +640,24 @@ export async function autoClusterKeywords(keywordIds: number[], sectorId?: numbe const requestBody = { ids: keywordIds, sector_id: sectorId }; try { - // fetchAPI will automatically extract data from unified format - // For action endpoints, response is {success: true, data: {...}} - // fetchAPI extracts and returns the data field, so response should already be the data object + // fetchAPI extracts data from unified format {success: true, data: {...}} + // So response is already the data object: {task_id: "...", ...} const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); - // After fetchAPI processing, response should be the data object (not wrapped in success/data) - // But check if it's still wrapped (shouldn't happen, but for safety) + // Wrap extracted data with success: true for frontend compatibility if (response && typeof response === 'object') { - if ('success' in response && response.success === false) { - // Error response - return as-is - return response as any; - } - // If response has data field, extract it - if ('data' in response && response.data) { - return { success: true, ...response.data } as any; - } - // Response is already the data object (after fetchAPI extraction) - // Ensure it has success: true - if (!('success' in response)) { - return { success: true, ...response } as any; - } + return { success: true, ...response } as any; } - return response as any; + return { success: true, ...response } as any; } catch (error: any) { + // Error responses are thrown by fetchAPI, but wrap them for consistency + if (error.response && typeof error.response === 'object') { + return { success: false, error: error.message, ...error.response } as any; + } throw error; } } @@ -677,13 +667,24 @@ export async function autoGenerateIdeas(clusterIds: number[]): Promise<{ success const requestBody = { ids: clusterIds }; try { + // fetchAPI extracts data from unified format {success: true, data: {...}} + // So response is already the data object: {task_id: "...", ...} const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); - return response; + // Wrap extracted data with success: true for frontend compatibility + if (response && typeof response === 'object') { + return { success: true, ...response } as any; + } + + return { success: true, ...response } as any; } catch (error: any) { + // Error responses are thrown by fetchAPI, but wrap them for consistency + if (error.response && typeof error.response === 'object') { + return { success: false, error: error.message, ...error.response } as any; + } throw error; } } @@ -693,13 +694,24 @@ export async function generateSingleIdea(ideaId: string | number, clusterId: num const requestBody = { cluster_id: clusterId }; try { + // fetchAPI extracts data from unified format {success: true, data: {...}} + // So response is already the data object: {task_id: "...", ...} const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); - return response; + // Wrap extracted data with success: true for frontend compatibility + if (response && typeof response === 'object') { + return { success: true, ...response } as any; + } + + return { success: true, ...response } as any; } catch (error: any) { + // Error responses are thrown by fetchAPI, but wrap them for consistency + if (error.response && typeof error.response === 'object') { + return { success: false, error: error.message, ...error.response } as any; + } throw error; } } @@ -1000,13 +1012,24 @@ export async function autoGenerateContent(ids: number[]): Promise<{ success: boo const requestBody = { ids }; try { + // fetchAPI extracts data from unified format {success: true, data: {...}} + // So response is already the data object: {task_id: "...", ...} const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); - return response; + // Wrap extracted data with success: true for frontend compatibility + if (response && typeof response === 'object') { + return { success: true, ...response } as any; + } + + return { success: true, ...response } as any; } catch (error: any) { + // Error responses are thrown by fetchAPI, but wrap them for consistency + if (error.response && typeof error.response === 'object') { + return { success: false, error: error.message, ...error.response } as any; + } throw error; } } @@ -1016,13 +1039,24 @@ export async function autoGenerateImages(taskIds: number[]): Promise<{ success: const requestBody = { task_ids: taskIds }; try { + // fetchAPI extracts data from unified format {success: true, data: {...}} + // So response is already the data object: {task_id: "...", ...} const response = await fetchAPI(endpoint, { method: 'POST', body: JSON.stringify(requestBody), }); - return response; + // Wrap extracted data with success: true for frontend compatibility + if (response && typeof response === 'object') { + return { success: true, ...response } as any; + } + + return { success: true, ...response } as any; } catch (error: any) { + // Error responses are thrown by fetchAPI, but wrap them for consistency + if (error.response && typeof error.response === 'object') { + return { success: false, error: error.message, ...error.response } as any; + } throw error; } }