seed keywords
This commit is contained in:
@@ -838,15 +838,134 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
"""Filter by industry and sector if provided."""
|
"""Filter by industry and sector if provided."""
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
industry_id = self.request.query_params.get('industry_id')
|
industry_id = self.request.query_params.get('industry_id')
|
||||||
|
industry_name = self.request.query_params.get('industry_name')
|
||||||
sector_id = self.request.query_params.get('sector_id')
|
sector_id = self.request.query_params.get('sector_id')
|
||||||
|
sector_name = self.request.query_params.get('sector_name')
|
||||||
|
|
||||||
if industry_id:
|
if industry_id:
|
||||||
queryset = queryset.filter(industry_id=industry_id)
|
queryset = queryset.filter(industry_id=industry_id)
|
||||||
|
if industry_name:
|
||||||
|
queryset = queryset.filter(industry__name__icontains=industry_name)
|
||||||
if sector_id:
|
if sector_id:
|
||||||
queryset = queryset.filter(sector_id=sector_id)
|
queryset = queryset.filter(sector_id=sector_id)
|
||||||
|
if sector_name:
|
||||||
|
queryset = queryset.filter(sector__name__icontains=sector_name)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'], url_path='import_seed_keywords', url_name='import_seed_keywords')
|
||||||
|
def import_seed_keywords(self, request):
|
||||||
|
"""
|
||||||
|
Import seed keywords from CSV (Admin/Superuser only).
|
||||||
|
Expected columns: keyword, industry_name, sector_name, volume, difficulty, intent
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
# Check admin/superuser permission
|
||||||
|
if not (request.user.is_staff or request.user.is_superuser):
|
||||||
|
return error_response(
|
||||||
|
error='Admin or superuser access required',
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'file' not in request.FILES:
|
||||||
|
return error_response(
|
||||||
|
error='No file provided',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
file = request.FILES['file']
|
||||||
|
if not file.name.endswith('.csv'):
|
||||||
|
return error_response(
|
||||||
|
error='File must be a CSV',
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse CSV
|
||||||
|
decoded_file = file.read().decode('utf-8')
|
||||||
|
csv_reader = csv.DictReader(decoded_file.splitlines())
|
||||||
|
|
||||||
|
imported_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 (header is row 1)
|
||||||
|
try:
|
||||||
|
keyword_text = row.get('keyword', '').strip()
|
||||||
|
industry_name = row.get('industry_name', '').strip()
|
||||||
|
sector_name = row.get('sector_name', '').strip()
|
||||||
|
|
||||||
|
if not all([keyword_text, industry_name, sector_name]):
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get or create industry
|
||||||
|
industry = Industry.objects.filter(name=industry_name).first()
|
||||||
|
if not industry:
|
||||||
|
errors.append(f"Row {row_num}: Industry '{industry_name}' not found")
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get or create industry sector
|
||||||
|
sector = IndustrySector.objects.filter(
|
||||||
|
industry=industry,
|
||||||
|
name=sector_name
|
||||||
|
).first()
|
||||||
|
if not sector:
|
||||||
|
errors.append(f"Row {row_num}: Sector '{sector_name}' not found for industry '{industry_name}'")
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if keyword already exists
|
||||||
|
existing = SeedKeyword.objects.filter(
|
||||||
|
keyword=keyword_text,
|
||||||
|
industry=industry,
|
||||||
|
sector=sector
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create seed keyword
|
||||||
|
SeedKeyword.objects.create(
|
||||||
|
keyword=keyword_text,
|
||||||
|
industry=industry,
|
||||||
|
sector=sector,
|
||||||
|
volume=int(row.get('volume', 0) or 0),
|
||||||
|
difficulty=int(row.get('difficulty', 0) or 0),
|
||||||
|
intent=row.get('intent', 'informational') or 'informational',
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
imported_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Row {row_num}: {str(e)}")
|
||||||
|
skipped_count += 1
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data={
|
||||||
|
'imported': imported_count,
|
||||||
|
'skipped': skipped_count,
|
||||||
|
'errors': errors[:10] if errors else [] # Limit errors to first 10
|
||||||
|
},
|
||||||
|
message=f'Import completed: {imported_count} keywords imported, {skipped_count} skipped',
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return error_response(
|
||||||
|
error=f'Failed to import keywords: {str(e)}',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# AUTHENTICATION ENDPOINTS (Register, Login, Change Password, Me)
|
# AUTHENTICATION ENDPOINTS (Register, Login, Change Password, Me)
|
||||||
|
|||||||
@@ -356,11 +356,13 @@ export default function App() {
|
|||||||
} />
|
} />
|
||||||
|
|
||||||
{/* Setup Pages */}
|
{/* Setup Pages */}
|
||||||
<Route path="/setup/industries-sectors-keywords" element={
|
<Route path="/setup/add-keywords" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<IndustriesSectorsKeywords />
|
<IndustriesSectorsKeywords />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
|
{/* Legacy redirect */}
|
||||||
|
<Route path="/setup/industries-sectors-keywords" element={<Navigate to="/setup/add-keywords" replace />} />
|
||||||
|
|
||||||
{/* Automation Module - Redirect dashboard to rules */}
|
{/* Automation Module - Redirect dashboard to rules */}
|
||||||
<Route path="/automation" element={<Navigate to="/automation/rules" replace />} />
|
<Route path="/automation" element={<Navigate to="/automation/rules" replace />} />
|
||||||
|
|||||||
@@ -159,6 +159,11 @@ export function useImportExport(
|
|||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||||
Upload a CSV file (max {maxFileSize / 1024 / 1024}MB)
|
Upload a CSV file (max {maxFileSize / 1024 / 1024}MB)
|
||||||
</p>
|
</p>
|
||||||
|
{filename === 'keywords' && (
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-300 mt-2 p-2 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
|
||||||
|
<strong>Expected columns:</strong> keyword, volume, difficulty, intent, status
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-4 pt-4">
|
<div className="flex justify-end gap-4 pt-4">
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ const AppSidebar: React.FC = () => {
|
|||||||
const setupItems: NavItem[] = [
|
const setupItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
icon: <DocsIcon />,
|
icon: <DocsIcon />,
|
||||||
name: "Industry, Sectors & Keywords",
|
name: "Add Keywords",
|
||||||
path: "/setup/industries-sectors-keywords", // Merged page
|
path: "/setup/add-keywords",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <GridIcon />,
|
icon: <GridIcon />,
|
||||||
|
|||||||
@@ -260,11 +260,32 @@ export default function MasterStatus() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [fetchSystemMetrics, fetchApiHealth, checkWorkflowHealth, checkIntegrationHealth]);
|
}, [fetchSystemMetrics, fetchApiHealth, checkWorkflowHealth, checkIntegrationHealth]);
|
||||||
|
|
||||||
// Initial load and auto-refresh
|
// Initial load and auto-refresh (pause when page not visible)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let interval: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
// Page not visible - clear interval
|
||||||
|
if (interval) clearInterval(interval);
|
||||||
|
} else {
|
||||||
|
// Page visible - refresh and restart interval
|
||||||
|
refreshAll();
|
||||||
|
interval = setInterval(refreshAll, 30000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial setup
|
||||||
refreshAll();
|
refreshAll();
|
||||||
const interval = setInterval(refreshAll, 30000); // 30 seconds
|
interval = setInterval(refreshAll, 30000);
|
||||||
return () => clearInterval(interval);
|
|
||||||
|
// Listen for visibility changes
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
}, [refreshAll]);
|
}, [refreshAll]);
|
||||||
|
|
||||||
// Status badge component
|
// Status badge component
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -133,6 +133,15 @@ interface TablePageTemplateProps {
|
|||||||
onRowAction?: (actionKey: string, row: any) => Promise<void>;
|
onRowAction?: (actionKey: string, row: any) => Promise<void>;
|
||||||
getItemDisplayName?: (row: any) => string; // Function to get display name from row (e.g., row.keyword or row.name)
|
getItemDisplayName?: (row: any) => string; // Function to get display name from row (e.g., row.keyword or row.name)
|
||||||
className?: string;
|
className?: string;
|
||||||
|
// Custom actions to display in action buttons area (near column selector)
|
||||||
|
customActions?: ReactNode;
|
||||||
|
// Custom bulk actions configuration (overrides table-actions.config.ts)
|
||||||
|
bulkActions?: Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
variant?: 'primary' | 'success' | 'danger';
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TablePageTemplate({
|
export default function TablePageTemplate({
|
||||||
@@ -167,6 +176,8 @@ export default function TablePageTemplate({
|
|||||||
onExport,
|
onExport,
|
||||||
getItemDisplayName = (row: any) => row.name || row.keyword || row.title || String(row.id),
|
getItemDisplayName = (row: any) => row.name || row.keyword || row.title || String(row.id),
|
||||||
className = '',
|
className = '',
|
||||||
|
customActions,
|
||||||
|
bulkActions: customBulkActions,
|
||||||
}: TablePageTemplateProps) {
|
}: TablePageTemplateProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
|
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
|
||||||
@@ -181,7 +192,8 @@ export default function TablePageTemplate({
|
|||||||
|
|
||||||
// Get actions from config (edit/delete always included)
|
// Get actions from config (edit/delete always included)
|
||||||
const rowActions = tableActionsConfig?.rowActions || [];
|
const rowActions = tableActionsConfig?.rowActions || [];
|
||||||
const bulkActions = tableActionsConfig?.bulkActions || [];
|
// Use custom bulk actions if provided, otherwise use config
|
||||||
|
const bulkActions = customBulkActions || tableActionsConfig?.bulkActions || [];
|
||||||
|
|
||||||
// Selection and expanded rows state
|
// Selection and expanded rows state
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>(selection?.selectedIds || []);
|
const [selectedIds, setSelectedIds] = useState<string[]>(selection?.selectedIds || []);
|
||||||
@@ -737,6 +749,9 @@ export default function TablePageTemplate({
|
|||||||
|
|
||||||
{/* Action Buttons - Right aligned */}
|
{/* Action Buttons - Right aligned */}
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
|
{/* Custom Actions */}
|
||||||
|
{customActions}
|
||||||
|
|
||||||
{/* Column Selector */}
|
{/* Column Selector */}
|
||||||
<ColumnSelector
|
<ColumnSelector
|
||||||
columns={columns.map(col => ({
|
columns={columns.map(col => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user