seed keywords
This commit is contained in:
@@ -838,14 +838,133 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""Filter by industry and sector if provided."""
|
||||
queryset = super().get_queryset()
|
||||
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_name = self.request.query_params.get('sector_name')
|
||||
|
||||
if industry_id:
|
||||
queryset = queryset.filter(industry_id=industry_id)
|
||||
if industry_name:
|
||||
queryset = queryset.filter(industry__name__icontains=industry_name)
|
||||
if sector_id:
|
||||
queryset = queryset.filter(sector_id=sector_id)
|
||||
if sector_name:
|
||||
queryset = queryset.filter(sector__name__icontains=sector_name)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -356,11 +356,13 @@ export default function App() {
|
||||
} />
|
||||
|
||||
{/* Setup Pages */}
|
||||
<Route path="/setup/industries-sectors-keywords" element={
|
||||
<Route path="/setup/add-keywords" element={
|
||||
<Suspense fallback={null}>
|
||||
<IndustriesSectorsKeywords />
|
||||
</Suspense>
|
||||
} />
|
||||
{/* Legacy redirect */}
|
||||
<Route path="/setup/industries-sectors-keywords" element={<Navigate to="/setup/add-keywords" replace />} />
|
||||
|
||||
{/* Automation Module - Redirect dashboard to rules */}
|
||||
<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">
|
||||
Upload a CSV file (max {maxFileSize / 1024 / 1024}MB)
|
||||
</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 className="flex justify-end gap-4 pt-4">
|
||||
|
||||
@@ -85,8 +85,8 @@ const AppSidebar: React.FC = () => {
|
||||
const setupItems: NavItem[] = [
|
||||
{
|
||||
icon: <DocsIcon />,
|
||||
name: "Industry, Sectors & Keywords",
|
||||
path: "/setup/industries-sectors-keywords", // Merged page
|
||||
name: "Add Keywords",
|
||||
path: "/setup/add-keywords",
|
||||
},
|
||||
{
|
||||
icon: <GridIcon />,
|
||||
|
||||
@@ -260,11 +260,32 @@ export default function MasterStatus() {
|
||||
setLoading(false);
|
||||
}, [fetchSystemMetrics, fetchApiHealth, checkWorkflowHealth, checkIntegrationHealth]);
|
||||
|
||||
// Initial load and auto-refresh
|
||||
// Initial load and auto-refresh (pause when page not visible)
|
||||
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();
|
||||
const interval = setInterval(refreshAll, 30000); // 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
interval = setInterval(refreshAll, 30000);
|
||||
|
||||
// Listen for visibility changes
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [refreshAll]);
|
||||
|
||||
// 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>;
|
||||
getItemDisplayName?: (row: any) => string; // Function to get display name from row (e.g., row.keyword or row.name)
|
||||
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({
|
||||
@@ -167,6 +176,8 @@ export default function TablePageTemplate({
|
||||
onExport,
|
||||
getItemDisplayName = (row: any) => row.name || row.keyword || row.title || String(row.id),
|
||||
className = '',
|
||||
customActions,
|
||||
bulkActions: customBulkActions,
|
||||
}: TablePageTemplateProps) {
|
||||
const location = useLocation();
|
||||
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
|
||||
@@ -181,7 +192,8 @@ export default function TablePageTemplate({
|
||||
|
||||
// Get actions from config (edit/delete always included)
|
||||
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
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>(selection?.selectedIds || []);
|
||||
@@ -737,6 +749,9 @@ export default function TablePageTemplate({
|
||||
|
||||
{/* Action Buttons - Right aligned */}
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Custom Actions */}
|
||||
{customActions}
|
||||
|
||||
{/* Column Selector */}
|
||||
<ColumnSelector
|
||||
columns={columns.map(col => ({
|
||||
|
||||
Reference in New Issue
Block a user