""" Authentication Views - Structured as: Groups, Users, Accounts, Subscriptions, Site User Access Unified API Standard v1.0 compliant """ from rest_framework import viewsets, status, permissions, filters from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.views import APIView from django.contrib.auth import authenticate from django.utils import timezone from django.db import transaction from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema, extend_schema_view from igny8_core.api.base import AccountModelViewSet from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication from igny8_core.api.response import success_response, error_response from igny8_core.api.throttles import DebugScopedRateThrottle from igny8_core.api.pagination import CustomPageNumberPagination, LargeTablePagination from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword from .serializers import ( UserSerializer, AccountSerializer, PlanSerializer, SubscriptionSerializer, RegisterSerializer, LoginSerializer, ChangePasswordSerializer, SiteSerializer, SectorSerializer, SiteUserAccessSerializer, IndustrySerializer, IndustrySectorSerializer, SeedKeywordSerializer, RefreshTokenSerializer, RequestPasswordResetSerializer, ResetPasswordSerializer ) from .permissions import IsOwnerOrAdmin, IsEditorOrAbove from .utils import generate_access_token, generate_refresh_token, get_token_expiry, decode_token from .models import PasswordResetToken import jwt # ============================================================================ # 1. GROUPS - Define user roles and permissions across the system # ============================================================================ @extend_schema_view( list=extend_schema(tags=['Authentication']), retrieve=extend_schema(tags=['Authentication']), ) class GroupsViewSet(viewsets.ViewSet): """ ViewSet for managing user roles and permissions (Groups). Groups are defined by the User.ROLE_CHOICES. Unified API Standard v1.0 compliant """ permission_classes = [IsOwnerOrAdmin] throttle_scope = 'auth' throttle_classes = [DebugScopedRateThrottle] def list(self, request): """List all available roles/groups.""" roles = [ { 'id': 'developer', 'name': 'Developer / Super Admin', 'description': 'Full access across all accounts (bypasses all filters)', 'permissions': ['full_access', 'bypass_filters', 'all_modules'] }, { 'id': 'owner', 'name': 'Owner', 'description': 'Full account access, billing, automation', 'permissions': ['account_management', 'billing', 'automation', 'all_sites'] }, { 'id': 'admin', 'name': 'Admin', 'description': 'Manage content modules, view billing (no edit)', 'permissions': ['content_management', 'view_billing', 'all_sites'] }, { 'id': 'editor', 'name': 'Editor', 'description': 'Generate AI content, manage clusters/tasks', 'permissions': ['ai_content', 'manage_clusters', 'manage_tasks', 'assigned_sites'] }, { 'id': 'viewer', 'name': 'Viewer', 'description': 'Read-only dashboards', 'permissions': ['read_only', 'assigned_sites'] }, { 'id': 'system_bot', 'name': 'System Bot', 'description': 'System automation user', 'permissions': ['automation_only'] } ] return success_response(data={'groups': roles}, request=request) @action(detail=False, methods=['get'], url_path='permissions') def permissions(self, request): """Get permissions for a specific role.""" role = request.query_params.get('role') if not role: return error_response( error='role parameter is required', status_code=status.HTTP_400_BAD_REQUEST, request=request ) role_permissions = { 'developer': ['full_access', 'bypass_filters', 'all_modules', 'all_accounts'], 'owner': ['account_management', 'billing', 'automation', 'all_sites', 'user_management'], 'admin': ['content_management', 'view_billing', 'all_sites', 'user_management'], 'editor': ['ai_content', 'manage_clusters', 'manage_tasks', 'assigned_sites'], 'viewer': ['read_only', 'assigned_sites'], 'system_bot': ['automation_only'] } permissions_list = role_permissions.get(role, []) return success_response( data={ 'role': role, 'permissions': permissions_list }, request=request ) # ============================================================================ # 2. USERS - Manage global user records and credentials # ============================================================================ @extend_schema_view( list=extend_schema(tags=['Authentication']), create=extend_schema(tags=['Authentication']), retrieve=extend_schema(tags=['Authentication']), update=extend_schema(tags=['Authentication']), partial_update=extend_schema(tags=['Authentication']), destroy=extend_schema(tags=['Authentication']), ) class UsersViewSet(AccountModelViewSet): """ ViewSet for managing global user records and credentials. Users are global, but belong to accounts. Unified API Standard v1.0 compliant """ queryset = User.objects.all() serializer_class = UserSerializer permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsOwnerOrAdmin] pagination_class = CustomPageNumberPagination throttle_scope = 'auth' throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """Return users based on access level.""" user = self.request.user if not user or not user.is_authenticated: return User.objects.none() # Developers can see all users if user.is_developer(): return User.objects.all() # Owners/Admins can see users in their account if user.role in ['owner', 'admin'] and user.account: return User.objects.filter(account=user.account) # Others can only see themselves return User.objects.filter(id=user.id) @action(detail=False, methods=['post']) def create_user(self, request): """Create a new user (separate from registration).""" from django.contrib.auth.password_validation import validate_password email = request.data.get('email') username = request.data.get('username') password = request.data.get('password') role = request.data.get('role', 'viewer') account_id = request.data.get('account_id') if not email or not username or not password: return error_response( error='email, username, and password are required', status_code=status.HTTP_400_BAD_REQUEST, request=request ) # Validate password try: validate_password(password) except Exception as e: return error_response( error=str(e), status_code=status.HTTP_400_BAD_REQUEST, request=request ) # Get account account = None if account_id: try: account = Account.objects.get(id=account_id) except Account.DoesNotExist: return error_response( error=f'Account with id {account_id} does not exist', status_code=status.HTTP_400_BAD_REQUEST, request=request ) else: # Use current user's account if request.user.account: account = request.user.account # Create user try: user = User.objects.create_user( username=username, email=email, password=password, role=role, account=account ) serializer = UserSerializer(user) return success_response( data={'user': serializer.data}, status_code=status.HTTP_201_CREATED, request=request ) except Exception as e: return error_response( error=str(e), status_code=status.HTTP_400_BAD_REQUEST, request=request ) @action(detail=True, methods=['post']) def update_role(self, request, pk=None): """Update user role.""" user = self.get_object() new_role = request.data.get('role') if not new_role: return error_response( error='role is required', status_code=status.HTTP_400_BAD_REQUEST, request=request ) if new_role not in [choice[0] for choice in User.ROLE_CHOICES]: return error_response( error=f'Invalid role. Must be one of: {[c[0] for c in User.ROLE_CHOICES]}', status_code=status.HTTP_400_BAD_REQUEST, request=request ) user.role = new_role user.save() serializer = UserSerializer(user) return success_response(data={'user': serializer.data}, request=request) # ============================================================================ # 3. ACCOUNTS - Register each unique organization/user space # ============================================================================ @extend_schema_view( list=extend_schema(tags=['Authentication']), create=extend_schema(tags=['Authentication']), retrieve=extend_schema(tags=['Authentication']), update=extend_schema(tags=['Authentication']), partial_update=extend_schema(tags=['Authentication']), destroy=extend_schema(tags=['Authentication']), ) class AccountsViewSet(AccountModelViewSet): """ ViewSet for managing accounts (unique organization/user spaces). Unified API Standard v1.0 compliant """ queryset = Account.objects.all() serializer_class = AccountSerializer permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsOwnerOrAdmin] pagination_class = CustomPageNumberPagination throttle_scope = 'auth' throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """Return accounts based on access level.""" user = self.request.user if not user or not user.is_authenticated: return Account.objects.none() # Developers can see all accounts if user.is_developer(): return Account.objects.all() # Owners can see their own accounts if user.role == 'owner': return Account.objects.filter(owner=user) # Admins can see their account if user.role == 'admin' and user.account: return Account.objects.filter(id=user.account.id) return Account.objects.none() def perform_create(self, serializer): """Create account with owner.""" user = self.request.user # plan_id is mapped to plan in serializer (source='plan') plan = serializer.validated_data.get('plan') if not plan: from rest_framework.exceptions import ValidationError raise ValidationError("plan_id is required") # Set owner to current user if not provided owner = serializer.validated_data.get('owner') if not owner: owner = user account = serializer.save(plan=plan, owner=owner) return account # ============================================================================ # 4. SUBSCRIPTIONS - Control plan level, limits, and billing per account # ============================================================================ @extend_schema_view( list=extend_schema(tags=['Authentication']), create=extend_schema(tags=['Authentication']), retrieve=extend_schema(tags=['Authentication']), update=extend_schema(tags=['Authentication']), partial_update=extend_schema(tags=['Authentication']), destroy=extend_schema(tags=['Authentication']), ) class SubscriptionsViewSet(AccountModelViewSet): """ ViewSet for managing subscriptions (plan level, limits, billing per account). Unified API Standard v1.0 compliant """ queryset = Subscription.objects.all() permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsOwnerOrAdmin] pagination_class = CustomPageNumberPagination # Use relaxed auth throttle to avoid 429s during onboarding plan fetches throttle_scope = 'auth_read' throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """Return subscriptions based on access level.""" user = self.request.user if not user or not user.is_authenticated: return Subscription.objects.none() # Developers can see all subscriptions if user.is_developer(): return Subscription.objects.all() # Owners/Admins can see subscriptions for their account if user.role in ['owner', 'admin'] and user.account: return Subscription.objects.filter(account=user.account) return Subscription.objects.none() def get_serializer_class(self): """Return appropriate serializer.""" return SubscriptionSerializer @action(detail=False, methods=['get'], url_path='by-account/(?P[^/.]+)') def by_account(self, request, account_id=None): """Get subscription for a specific account.""" try: subscription = Subscription.objects.get(account_id=account_id) serializer = self.get_serializer(subscription) return success_response( data={'subscription': serializer.data}, request=request ) except Subscription.DoesNotExist: return error_response( error='Subscription not found for this account', status_code=status.HTTP_404_NOT_FOUND, request=request ) # ============================================================================ # 5. SITE USER ACCESS - Assign users access to specific sites within account # ============================================================================ @extend_schema_view( list=extend_schema(tags=['Authentication']), create=extend_schema(tags=['Authentication']), retrieve=extend_schema(tags=['Authentication']), update=extend_schema(tags=['Authentication']), partial_update=extend_schema(tags=['Authentication']), destroy=extend_schema(tags=['Authentication']), ) class SiteUserAccessViewSet(AccountModelViewSet): """ ViewSet for managing Site-User access permissions. Assign users access to specific sites within their account. Unified API Standard v1.0 compliant """ serializer_class = SiteUserAccessSerializer permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsOwnerOrAdmin] pagination_class = CustomPageNumberPagination throttle_scope = 'auth' throttle_classes = [DebugScopedRateThrottle] def get_queryset(self): """Return access records for sites in user's account.""" user = self.request.user if not user or not user.is_authenticated: return SiteUserAccess.objects.none() # Developers can see all access records if user.is_developer(): return SiteUserAccess.objects.all() if not user.account: return SiteUserAccess.objects.none() # Return access records for sites in user's account return SiteUserAccess.objects.filter(site__account=user.account) def perform_create(self, serializer): """Create site user access with granted_by.""" user = self.request.user serializer.save(granted_by=user) # ============================================================================ # SUPPORTING VIEWSETS (Sites, Sectors, Industries, Plans, Auth) # ============================================================================ @extend_schema_view( list=extend_schema(tags=['Authentication']), retrieve=extend_schema(tags=['Authentication']), ) class PlanViewSet(viewsets.ReadOnlyModelViewSet): """ ViewSet for listing active subscription plans. Excludes internal-only plans (Free/Internal) from public listings. Unified API Standard v1.0 compliant """ queryset = Plan.objects.filter(is_active=True, is_internal=False) serializer_class = PlanSerializer permission_classes = [permissions.AllowAny] pagination_class = CustomPageNumberPagination # Plans are public and should not throttle aggressively to avoid blocking signup/onboarding throttle_scope = None throttle_classes: list = [] def list(self, request, *args, **kwargs): """Override list to return paginated response with unified format""" queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True) return success_response(data={'results': serializer.data}, request=request) def retrieve(self, request, *args, **kwargs): """Override retrieve to return unified format""" try: instance = self.get_object() serializer = self.get_serializer(instance) return success_response(data=serializer.data, request=request) except Exception as e: return error_response( error=str(e), status_code=status.HTTP_404_NOT_FOUND, request=request ) @extend_schema_view( list=extend_schema(tags=['Authentication']), create=extend_schema(tags=['Authentication']), retrieve=extend_schema(tags=['Authentication']), update=extend_schema(tags=['Authentication']), partial_update=extend_schema(tags=['Authentication']), destroy=extend_schema(tags=['Authentication']), ) class SiteViewSet(AccountModelViewSet): """ViewSet for managing Sites.""" serializer_class = SiteSerializer permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsEditorOrAbove] authentication_classes = [JWTAuthentication] def get_permissions(self): """Allow normal users (viewer) to create sites, but require editor+ for other operations.""" # Allow public read access for list requests with slug filter (used by Sites Renderer) if self.action == 'list' and self.request.query_params.get('slug'): from rest_framework.permissions import AllowAny return [AllowAny()] if self.action == 'create': # For create, only require authentication - not active account status return [permissions.IsAuthenticated()] return [IsAuthenticatedAndActive(), HasTenantAccess(), IsEditorOrAbove()] def get_queryset(self): """Return sites accessible to the current user.""" # If this is a public request (no auth) with slug filter, return site by slug if not self.request.user or not self.request.user.is_authenticated: slug = self.request.query_params.get('slug') if slug: # Return queryset directly from model (bypassing base class account filtering) return Site.objects.filter(slug=slug, is_active=True) return Site.objects.none() user = self.request.user account = getattr(user, 'account', None) if not account: return Site.objects.none() if hasattr(user, 'get_accessible_sites'): return user.get_accessible_sites() return Site.objects.filter(account=account) def perform_create(self, serializer): """Create site with account and auto-grant access to creator.""" account = getattr(self.request, 'account', None) if not account: user = self.request.user if user and user.is_authenticated: account = getattr(user, 'account', None) # Check hard limit for sites from igny8_core.business.billing.services.limit_service import LimitService, HardLimitExceededError try: LimitService.check_hard_limit(account, 'sites', additional_count=1) except HardLimitExceededError as e: from rest_framework.exceptions import PermissionDenied raise PermissionDenied(str(e)) # Multiple sites can be active simultaneously - no constraint site = serializer.save(account=account) # Auto-create SiteUserAccess for owner/admin who creates the site user = self.request.user if user and user.is_authenticated and hasattr(user, 'role'): if user.role in ['owner', 'admin']: from igny8_core.auth.models import SiteUserAccess SiteUserAccess.objects.get_or_create( user=user, site=site, defaults={'granted_by': user} ) def perform_update(self, serializer): """Update site.""" account = getattr(self.request, 'account', None) if not account: account = getattr(serializer.instance, 'account', None) # Multiple sites can be active simultaneously - no constraint serializer.save() @action(detail=True, methods=['get']) def sectors(self, request, pk=None): """Get all sectors for this site.""" site = self.get_object() sectors = site.sectors.filter(is_active=True) serializer = SectorSerializer(sectors, many=True) return success_response( data=serializer.data, request=request ) @action(detail=True, methods=['post'], url_path='set_active') def set_active(self, request, pk=None): """Set this site as active (multiple sites can be active simultaneously).""" site = self.get_object() # Simply activate this site - no need to deactivate others site.is_active = True site.status = 'active' site.save() serializer = self.get_serializer(site) return success_response( data={'site': serializer.data}, message=f'Site "{site.name}" is now active', request=request ) @action(detail=True, methods=['post'], url_path='select_sectors') def select_sectors(self, request, pk=None): """Select industry and sectors for this site.""" import logging logger = logging.getLogger(__name__) try: site = self.get_object() except Exception as e: logger.error(f"Error getting site object: {str(e)}", exc_info=True) return error_response( error=f'Site not found: {str(e)}', status_code=status.HTTP_404_NOT_FOUND, request=request ) sector_slugs = request.data.get('sector_slugs', []) industry_slug = request.data.get('industry_slug') if not industry_slug: return error_response( error='Industry slug is required', status_code=status.HTTP_400_BAD_REQUEST, request=request ) try: industry = Industry.objects.get(slug=industry_slug, is_active=True) except Industry.DoesNotExist: return error_response( error=f'Industry with slug "{industry_slug}" not found', status_code=status.HTTP_400_BAD_REQUEST, request=request ) site.industry = industry site.save() if not sector_slugs: return success_response( data={ 'site': SiteSerializer(site).data, 'sectors': [] }, message=f'Industry "{industry.name}" set for site. No sectors selected.', request=request ) # Get plan's max_industries limit (if set), otherwise default to 5 max_sectors = site.get_max_sectors_limit() if len(sector_slugs) > max_sectors: return error_response( error=f'Maximum {max_sectors} sectors allowed per site for this plan', status_code=status.HTTP_400_BAD_REQUEST, request=request ) created_sectors = [] updated_sectors = [] existing_sector_slugs = set(sector_slugs) site.sectors.exclude(slug__in=existing_sector_slugs).update(is_active=False) industry_sectors_map = {} for sector_slug in sector_slugs: industry_sector = IndustrySector.objects.filter( industry=industry, slug=sector_slug, is_active=True ).first() if not industry_sector: return error_response( error=f'Sector "{sector_slug}" not found in industry "{industry.name}"', status_code=status.HTTP_400_BAD_REQUEST, request=request ) industry_sectors_map[sector_slug] = industry_sector for sector_slug, industry_sector in industry_sectors_map.items(): try: # Check if site has account before proceeding if not site.account: logger.error(f"Site {site.id} has no account assigned") return error_response( error=f'Site "{site.name}" has no account assigned. Please contact support.', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) # Create or get sector - account will be set automatically in save() method # But we need to pass it in defaults for get_or_create to work sector, created = Sector.objects.get_or_create( site=site, slug=sector_slug, defaults={ 'industry_sector': industry_sector, 'name': industry_sector.name, 'description': industry_sector.description or '', 'is_active': True, 'status': 'active', 'account': site.account # Pass the account object, not the ID } ) if not created: # Update existing sector sector.industry_sector = industry_sector sector.name = industry_sector.name sector.description = industry_sector.description or '' sector.is_active = True sector.status = 'active' # Ensure account is set (save() will also set it, but be explicit) if not sector.account: sector.account = site.account sector.save() updated_sectors.append(sector) else: created_sectors.append(sector) except Exception as e: logger.error(f"Error creating/updating sector {sector_slug}: {str(e)}", exc_info=True) return error_response( error=f'Failed to create/update sector "{sector_slug}": {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) # Get plan's max_industries limit (if set), otherwise default to 5 max_sectors = site.get_max_sectors_limit() if site.get_active_sectors_count() > max_sectors: return error_response( error=f'Maximum {max_sectors} sectors allowed per site for this plan', status_code=status.HTTP_400_BAD_REQUEST, request=request ) serializer = SectorSerializer(site.sectors.filter(is_active=True), many=True) return success_response( data={ 'created_count': len(created_sectors), 'updated_count': len(updated_sectors), 'sectors': serializer.data, 'site': SiteSerializer(site).data }, message=f'Selected {len(sector_slugs)} sectors from industry "{industry.name}".', request=request ) @extend_schema_view( list=extend_schema(tags=['Authentication']), create=extend_schema(tags=['Authentication']), retrieve=extend_schema(tags=['Authentication']), update=extend_schema(tags=['Authentication']), partial_update=extend_schema(tags=['Authentication']), destroy=extend_schema(tags=['Authentication']), ) class SectorViewSet(AccountModelViewSet): """ViewSet for managing Sectors.""" serializer_class = SectorSerializer permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsEditorOrAbove] authentication_classes = [JWTAuthentication] def get_queryset(self): """Return sectors from sites accessible to the current user.""" user = self.request.user if not user or not user.is_authenticated: return Sector.objects.none() accessible_sites = user.get_accessible_sites() return Sector.objects.filter(site__in=accessible_sites) def get_queryset_with_site_filter(self): """Get queryset, optionally filtered by site_id.""" queryset = self.get_queryset() site_id = self.request.query_params.get('site_id') if site_id: queryset = queryset.filter(site_id=site_id) return queryset def list(self, request, *args, **kwargs): """Override list to apply site filter.""" queryset = self.get_queryset_with_site_filter() serializer = self.get_serializer(queryset, many=True) return success_response( data=serializer.data, request=request ) @extend_schema_view( list=extend_schema(tags=['Authentication']), retrieve=extend_schema(tags=['Authentication']), ) class IndustryViewSet(viewsets.ReadOnlyModelViewSet): """ ViewSet for industry templates. Unified API Standard v1.0 compliant """ queryset = Industry.objects.filter(is_active=True).prefetch_related('sectors') serializer_class = IndustrySerializer permission_classes = [permissions.AllowAny] pagination_class = CustomPageNumberPagination throttle_scope = 'auth' throttle_classes = [DebugScopedRateThrottle] def list(self, request): """Get all industries with their sectors.""" industries = self.get_queryset() serializer = self.get_serializer(industries, many=True) return success_response( data={'industries': serializer.data}, request=request ) def retrieve(self, request, *args, **kwargs): """Override retrieve to return unified format""" try: instance = self.get_object() serializer = self.get_serializer(instance) return success_response(data=serializer.data, request=request) except Exception as e: return error_response( error=str(e), status_code=status.HTTP_404_NOT_FOUND, request=request ) @extend_schema_view( list=extend_schema(tags=['Authentication']), retrieve=extend_schema(tags=['Authentication']), ) class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): """ ViewSet for SeedKeyword - Global reference data (read-only for non-admins). Unified API Standard v1.0 compliant Sorting and filtering is applied server-side to ALL records, then paginated. This ensures operations like "sort by volume DESC" return the globally highest volume keywords, not just the highest within the current page. """ queryset = SeedKeyword.objects.filter(is_active=True).select_related('industry', 'sector') serializer_class = SeedKeywordSerializer permission_classes = [permissions.AllowAny] # Read-only, allow any authenticated user pagination_class = LargeTablePagination # Supports up to 500 records per page throttle_scope = 'auth' throttle_classes = [DebugScopedRateThrottle] filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend] search_fields = ['keyword'] ordering_fields = ['keyword', 'volume', 'difficulty', 'created_at'] ordering = ['keyword'] filterset_fields = ['industry', 'sector', 'country', 'is_active'] def retrieve(self, request, *args, **kwargs): """Override retrieve to return unified format""" try: instance = self.get_object() serializer = self.get_serializer(instance) return success_response(data=serializer.data, request=request) except Exception as e: return error_response( error=str(e), status_code=status.HTTP_404_NOT_FOUND, request=request ) def get_queryset(self): """Filter by industry, sector, and difficulty range 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') difficulty_min = self.request.query_params.get('difficulty_min') difficulty_max = self.request.query_params.get('difficulty_max') volume_min = self.request.query_params.get('volume_min') volume_max = self.request.query_params.get('volume_max') 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) # Difficulty range filtering if difficulty_min is not None: try: queryset = queryset.filter(difficulty__gte=int(difficulty_min)) except (ValueError, TypeError): pass if difficulty_max is not None: try: queryset = queryset.filter(difficulty__lte=int(difficulty_max)) except (ValueError, TypeError): pass # Volume range filtering if volume_min is not None: try: queryset = queryset.filter(volume__gte=int(volume_min)) except (ValueError, TypeError): pass if volume_max is not None: try: queryset = queryset.filter(volume__lte=int(volume_max)) except (ValueError, TypeError): pass return queryset @action(detail=False, methods=['get'], url_path='stats', url_name='stats') def stats(self, request): """ Get aggregated keyword statistics by industry and country. Returns top industries and countries with keyword counts and total volume. """ from django.db.models import Count, Sum, Q try: # Top industries by keyword count industries = Industry.objects.annotate( keyword_count=Count('seed_keywords', filter=Q(seed_keywords__is_active=True)), total_volume=Sum('seed_keywords__volume', filter=Q(seed_keywords__is_active=True)) ).filter( keyword_count__gt=0 ).order_by('-keyword_count')[:10] industries_data = [{ 'name': ind.name, 'slug': ind.slug, 'keyword_count': ind.keyword_count or 0, 'total_volume': ind.total_volume or 0, } for ind in industries] # Keywords by country countries = SeedKeyword.objects.filter( is_active=True ).values('country').annotate( keyword_count=Count('id'), total_volume=Sum('volume') ).order_by('-keyword_count') countries_data = [{ 'country': c['country'], 'country_display': dict(SeedKeyword.COUNTRY_CHOICES).get(c['country'], c['country']), 'keyword_count': c['keyword_count'], 'total_volume': c['total_volume'] or 0, } for c in countries] # Total stats total_stats = SeedKeyword.objects.filter(is_active=True).aggregate( total_keywords=Count('id'), total_volume=Sum('volume') ) data = { 'industries': industries_data, 'countries': countries_data, 'total_keywords': total_stats['total_keywords'] or 0, 'total_volume': total_stats['total_volume'] or 0, } return success_response(data=data, request=request) except Exception as e: return error_response( error=f'Failed to fetch keyword stats: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) @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, country """ 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), country=row.get('country', 'US') or 'US', 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 ) @action(detail=False, methods=['get'], url_path='sector_stats', url_name='sector_stats') def sector_stats(self, request): """ Get sector-level statistics for the Keywords Library dashboard. Returns 6 stat types with dynamic fallback thresholds. Stats: - total: Total keywords in sector - available: Keywords not yet added by user's site - high_volume: Volume >= 10K (Premium Traffic) - premium_traffic: Volume >= 50K with fallbacks (50K -> 25K -> 10K) - long_tail: 4+ words with Volume > threshold (1K -> 500 -> 200) - quick_wins: Difficulty <= 20, Volume > threshold, AND available """ from django.db.models import Count, Sum, Q, F from django.db.models.functions import Length try: # Get filters industry_id = request.query_params.get('industry_id') sector_id = request.query_params.get('sector_id') site_id = request.query_params.get('site_id') if not industry_id: return error_response( error='industry_id is required', status_code=status.HTTP_400_BAD_REQUEST, request=request ) # Base queryset for the industry base_qs = SeedKeyword.objects.filter( is_active=True, industry_id=industry_id ) if sector_id: base_qs = base_qs.filter(sector_id=sector_id) # Get already-added keyword IDs if site_id provided already_added_ids = set() if site_id: from igny8_core.business.planning.models import Keywords already_added_ids = set( Keywords.objects.filter( site_id=site_id, seed_keyword__isnull=False ).values_list('seed_keyword_id', flat=True) ) # Helper to count with availability filter def count_available(qs): if not site_id: return qs.count() return qs.exclude(id__in=already_added_ids).count() # Helper for dynamic threshold fallback def get_count_with_fallback(qs, thresholds, volume_field='volume'): """Try thresholds in order, return first with results.""" for threshold in thresholds: filtered = qs.filter(**{f'{volume_field}__gte': threshold}) count = filtered.count() if count > 0: return {'count': count, 'threshold': threshold} return {'count': 0, 'threshold': thresholds[-1]} # 1. Total keywords total_count = base_qs.count() # 2. Available keywords (not yet added) available_count = count_available(base_qs) # 3. High Volume (>= 10K) - simple threshold high_volume_count = base_qs.filter(volume__gte=10000).count() # 4. Premium Traffic with dynamic fallback (50K -> 25K -> 10K) premium_thresholds = [50000, 25000, 10000] premium_result = get_count_with_fallback(base_qs, premium_thresholds) # 5. Long Tail: 4+ words AND volume > threshold (1K -> 500 -> 200) # Count words by counting spaces + 1 long_tail_base = base_qs.annotate( word_count=Length('keyword') - Length('keyword', output_field=None) + 1 ) # Simpler: filter keywords with 3+ spaces (4+ words) long_tail_base = base_qs.filter(keyword__regex=r'^(\S+\s+){3,}\S+$') long_tail_thresholds = [1000, 500, 200] long_tail_result = get_count_with_fallback(long_tail_base, long_tail_thresholds) # 6. Quick Wins: Difficulty <= 20 AND volume > threshold AND available quick_wins_base = base_qs.filter(difficulty__lte=20) if site_id: quick_wins_base = quick_wins_base.exclude(id__in=already_added_ids) quick_wins_thresholds = [1000, 500, 200] quick_wins_result = get_count_with_fallback(quick_wins_base, quick_wins_thresholds) # Build response per sector if no sector_id, or single stats if sector_id provided if sector_id: data = { 'sector_id': int(sector_id), 'stats': { 'total': {'count': total_count}, 'available': {'count': available_count}, 'high_volume': {'count': high_volume_count, 'threshold': 10000}, 'premium_traffic': premium_result, 'long_tail': long_tail_result, 'quick_wins': quick_wins_result, } } else: # Get stats per sector in the industry sectors = IndustrySector.objects.filter(industry_id=industry_id) sectors_data = [] for sector in sectors: sector_qs = base_qs.filter(sector=sector) sector_total = sector_qs.count() if sector_total == 0: continue sector_available = count_available(sector_qs) sector_high_volume = sector_qs.filter(volume__gte=10000).count() sector_premium = get_count_with_fallback(sector_qs, premium_thresholds) sector_long_tail_base = sector_qs.filter(keyword__regex=r'^(\S+\s+){3,}\S+$') sector_long_tail = get_count_with_fallback(sector_long_tail_base, long_tail_thresholds) sector_quick_wins_base = sector_qs.filter(difficulty__lte=20) if site_id: sector_quick_wins_base = sector_quick_wins_base.exclude(id__in=already_added_ids) sector_quick_wins = get_count_with_fallback(sector_quick_wins_base, quick_wins_thresholds) sectors_data.append({ 'sector_id': sector.id, 'sector_name': sector.name, 'stats': { 'total': {'count': sector_total}, 'available': {'count': sector_available}, 'high_volume': {'count': sector_high_volume, 'threshold': 10000}, 'premium_traffic': sector_premium, 'long_tail': sector_long_tail, 'quick_wins': sector_quick_wins, } }) data = { 'industry_id': int(industry_id), 'sectors': sectors_data, } return success_response(data=data, request=request) except Exception as e: return error_response( error=f'Failed to fetch sector stats: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) @action(detail=False, methods=['get'], url_path='filter_options', url_name='filter_options') def filter_options(self, request): """ Get cascading filter options for Keywords Library. Returns industries, sectors (filtered by industry), and available filter values. """ from django.db.models import Count, Min, Max, Q try: industry_id = request.query_params.get('industry_id') # Get industries with keyword counts industries = Industry.objects.annotate( keyword_count=Count('seed_keywords', filter=Q(seed_keywords__is_active=True)) ).filter(keyword_count__gt=0).order_by('name') industries_data = [{ 'id': ind.id, 'name': ind.name, 'slug': ind.slug, 'keyword_count': ind.keyword_count, } for ind in industries] # Get sectors filtered by industry if provided sectors_data = [] if industry_id: sectors = IndustrySector.objects.filter( industry_id=industry_id ).annotate( keyword_count=Count('seed_keywords', filter=Q(seed_keywords__is_active=True)) ).filter(keyword_count__gt=0).order_by('name') sectors_data = [{ 'id': sec.id, 'name': sec.name, 'slug': sec.slug, 'keyword_count': sec.keyword_count, } for sec in sectors] # Get difficulty range difficulty_range = SeedKeyword.objects.filter(is_active=True).aggregate( min_difficulty=Min('difficulty'), max_difficulty=Max('difficulty') ) # Get volume range volume_range = SeedKeyword.objects.filter(is_active=True).aggregate( min_volume=Min('volume'), max_volume=Max('volume') ) # Difficulty levels for frontend (maps to backend values) difficulty_levels = [ {'level': 1, 'label': 'Very Easy', 'backend_range': [0, 20]}, {'level': 2, 'label': 'Easy', 'backend_range': [21, 40]}, {'level': 3, 'label': 'Medium', 'backend_range': [41, 60]}, {'level': 4, 'label': 'Hard', 'backend_range': [61, 80]}, {'level': 5, 'label': 'Very Hard', 'backend_range': [81, 100]}, ] data = { 'industries': industries_data, 'sectors': sectors_data, 'difficulty': { 'range': difficulty_range, 'levels': difficulty_levels, }, 'volume': volume_range, } return success_response(data=data, request=request) except Exception as e: return error_response( error=f'Failed to fetch filter options: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) @action(detail=False, methods=['post'], url_path='bulk_add', url_name='bulk_add') def bulk_add(self, request): """ Bulk add keywords to a site from the Keywords Library. Accepts a list of seed_keyword IDs and adds them to the specified site. """ from django.db import transaction from igny8_core.business.planning.models import Keywords try: site_id = request.data.get('site_id') keyword_ids = request.data.get('keyword_ids', []) if not site_id: return error_response( error='site_id is required', status_code=status.HTTP_400_BAD_REQUEST, request=request ) if not keyword_ids or not isinstance(keyword_ids, list): return error_response( error='keyword_ids must be a non-empty list', status_code=status.HTTP_400_BAD_REQUEST, request=request ) # Verify site access from igny8_core.auth.models import Site site = Site.objects.filter(id=site_id).first() if not site: return error_response( error='Site not found', status_code=status.HTTP_404_NOT_FOUND, request=request ) # Check user has access to this site user = request.user if not user.is_authenticated: return error_response( error='Authentication required', status_code=status.HTTP_401_UNAUTHORIZED, request=request ) # Allow if user owns the site or is staff if not (user.is_staff or site.account_id == getattr(user, 'account_id', None)): return error_response( error='Access denied to this site', status_code=status.HTTP_403_FORBIDDEN, request=request ) # Get seed keywords seed_keywords = SeedKeyword.objects.filter( id__in=keyword_ids, is_active=True ) # Get already existing existing_seed_ids = set( Keywords.objects.filter( site_id=site_id, seed_keyword_id__in=keyword_ids ).values_list('seed_keyword_id', flat=True) ) # Get site sectors mapped by industry_sector_id for fast lookup from igny8_core.auth.models import Sector site_sectors = { s.industry_sector_id: s for s in Sector.objects.filter(site=site, is_deleted=False, is_active=True) } added_count = 0 skipped_count = 0 with transaction.atomic(): for seed_kw in seed_keywords: if seed_kw.id in existing_seed_ids: skipped_count += 1 continue # Find the site's sector that matches this keyword's industry_sector site_sector = site_sectors.get(seed_kw.sector_id) if not site_sector: # Skip if site doesn't have this sector skipped_count += 1 continue Keywords.objects.create( site=site, sector=site_sector, seed_keyword=seed_kw, ) added_count += 1 return success_response( data={ 'added': added_count, 'skipped': skipped_count, 'total_requested': len(keyword_ids), }, message=f'Successfully added {added_count} keywords to your site', request=request ) except Exception as e: return error_response( error=f'Failed to bulk add keywords: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, request=request ) # ============================================================================ # AUTHENTICATION ENDPOINTS (Register, Login, Change Password, Me) # ============================================================================ @extend_schema_view( register=extend_schema(tags=['Authentication']), login=extend_schema(tags=['Authentication']), change_password=extend_schema(tags=['Authentication']), refresh_token=extend_schema(tags=['Authentication']), ) class AuthViewSet(viewsets.GenericViewSet): """Authentication endpoints. Unified API Standard v1.0 compliant """ permission_classes = [permissions.AllowAny] throttle_scope = 'auth_strict' throttle_classes = [DebugScopedRateThrottle] @action(detail=False, methods=['post']) def register(self, request): """User registration endpoint.""" serializer = RegisterSerializer(data=request.data) if serializer.is_valid(): user = serializer.save() # Log the user in (create session for session authentication) from django.contrib.auth import login login(request, user) # Get account from user account = getattr(user, 'account', None) # Generate JWT tokens access_token = generate_access_token(user, account) refresh_token = generate_refresh_token(user, account) access_expires_at = get_token_expiry('access') refresh_expires_at = get_token_expiry('refresh') user_serializer = UserSerializer(user) return success_response( data={ 'user': user_serializer.data, 'tokens': { 'access': access_token, 'refresh': refresh_token, 'access_expires_at': access_expires_at.isoformat(), 'refresh_expires_at': refresh_expires_at.isoformat(), } }, message='Registration successful', status_code=status.HTTP_201_CREATED, request=request ) return error_response( error='Validation failed', errors=serializer.errors, status_code=status.HTTP_400_BAD_REQUEST, request=request ) @action(detail=False, methods=['post']) def login(self, request): """User login endpoint.""" serializer = LoginSerializer(data=request.data) if serializer.is_valid(): email = serializer.validated_data['email'] password = serializer.validated_data['password'] try: user = User.objects.select_related('account', 'account__plan').get(email=email) except User.DoesNotExist: return error_response( error='Invalid credentials', status_code=status.HTTP_401_UNAUTHORIZED, request=request ) if user.check_password(password): # Ensure user has an account account = getattr(user, 'account', None) if account is None: return error_response( error='Account not configured for this user. Please contact support.', status_code=status.HTTP_403_FORBIDDEN, request=request, ) # Ensure account has an active plan plan = getattr(account, 'plan', None) if plan is None or getattr(plan, 'is_active', False) is False: return error_response( error='Active subscription required. Visit igny8.com/pricing to subscribe.', status_code=status.HTTP_402_PAYMENT_REQUIRED, request=request, ) # Log the user in (create session for session authentication) from django.contrib.auth import login login(request, user) # Generate JWT tokens access_token = generate_access_token(user, account) refresh_token = generate_refresh_token(user, account) access_expires_at = get_token_expiry('access') refresh_expires_at = get_token_expiry('refresh') user_serializer = UserSerializer(user) return success_response( data={ 'user': user_serializer.data, 'access': access_token, 'refresh': refresh_token, 'access_expires_at': access_expires_at.isoformat(), 'refresh_expires_at': refresh_expires_at.isoformat(), }, message='Login successful', request=request ) return error_response( error='Invalid credentials', status_code=status.HTTP_401_UNAUTHORIZED, request=request ) return error_response( error='Validation failed', errors=serializer.errors, status_code=status.HTTP_400_BAD_REQUEST, request=request ) @action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated]) def change_password(self, request): """Change password endpoint.""" serializer = ChangePasswordSerializer(data=request.data, context={'request': request}) if serializer.is_valid(): user = request.user if not user.check_password(serializer.validated_data['old_password']): return error_response( error='Current password is incorrect', status_code=status.HTTP_400_BAD_REQUEST, request=request ) user.set_password(serializer.validated_data['new_password']) user.save() return success_response( message='Password changed successfully', request=request ) return error_response( error='Validation failed', errors=serializer.errors, status_code=status.HTTP_400_BAD_REQUEST, request=request ) @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) def me(self, request): """Get current user information.""" # Refresh user from DB to get latest account/plan data # This ensures account/plan changes are reflected immediately user = User.objects.select_related('account', 'account__plan').get(id=request.user.id) serializer = UserSerializer(user) return success_response( data={'user': serializer.data}, request=request ) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) def refresh(self, request): """Refresh access token using refresh token.""" serializer = RefreshTokenSerializer(data=request.data) if not serializer.is_valid(): return error_response( error='Validation failed', errors=serializer.errors, status_code=status.HTTP_400_BAD_REQUEST, request=request ) refresh_token = serializer.validated_data['refresh'] try: # Decode and validate refresh token payload = decode_token(refresh_token) # Verify it's a refresh token if payload.get('type') != 'refresh': return error_response( error='Invalid token type', status_code=status.HTTP_400_BAD_REQUEST, request=request ) # Get user user_id = payload.get('user_id') account_id = payload.get('account_id') try: user = User.objects.get(id=user_id) except User.DoesNotExist: return error_response( error='User not found', status_code=status.HTTP_404_NOT_FOUND, request=request ) # Get account account_id = payload.get('account_id') account = None if account_id: try: account = Account.objects.get(id=account_id) except Account.DoesNotExist: pass if not account: account = getattr(user, 'account', None) # Generate new access token access_token = generate_access_token(user, account) access_expires_at = get_token_expiry('access') return success_response( data={ 'access': access_token, 'access_expires_at': access_expires_at.isoformat() }, request=request ) except jwt.InvalidTokenError as e: return error_response( error='Invalid or expired refresh token', status_code=status.HTTP_401_UNAUTHORIZED, request=request ) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) def request_reset(self, request): """Request password reset - sends email with reset token.""" serializer = RequestPasswordResetSerializer(data=request.data) if not serializer.is_valid(): return error_response( error='Validation failed', errors=serializer.errors, status_code=status.HTTP_400_BAD_REQUEST, request=request ) email = serializer.validated_data['email'] try: user = User.objects.get(email=email) except User.DoesNotExist: # Don't reveal if email exists - return success anyway return success_response( message='If an account with that email exists, a password reset link has been sent.', request=request ) # Generate secure token import secrets token = secrets.token_urlsafe(32) # Create reset token (expires in 1 hour) from django.utils import timezone from datetime import timedelta expires_at = timezone.now() + timedelta(hours=1) PasswordResetToken.objects.create( user=user, token=token, expires_at=expires_at ) # Send password reset email using the email service try: from igny8_core.business.billing.services.email_service import send_password_reset_email send_password_reset_email(user, token) except Exception as e: # Fallback to Django's send_mail if email service fails import logging logger = logging.getLogger(__name__) logger.error(f"Failed to send password reset email via email service: {e}") from django.core.mail import send_mail from django.conf import settings frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com') reset_url = f"{frontend_url}/reset-password?token={token}" send_mail( subject='Reset Your IGNY8 Password', message=f'Click the following link to reset your password: {reset_url}\n\nThis link expires in 1 hour.', from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@igny8.com'), recipient_list=[user.email], fail_silently=False, ) return success_response( message='If an account with that email exists, a password reset link has been sent.', request=request ) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) def reset_password(self, request): """Reset password using reset token.""" serializer = ResetPasswordSerializer(data=request.data) if not serializer.is_valid(): return error_response( error='Validation failed', errors=serializer.errors, status_code=status.HTTP_400_BAD_REQUEST, request=request ) token = serializer.validated_data['token'] new_password = serializer.validated_data['new_password'] try: reset_token = PasswordResetToken.objects.get(token=token) except PasswordResetToken.DoesNotExist: return error_response( error='Invalid reset token', status_code=status.HTTP_400_BAD_REQUEST, request=request ) # Check if token is valid if not reset_token.is_valid(): return error_response( error='Reset token has expired or has already been used', status_code=status.HTTP_400_BAD_REQUEST, request=request ) # Update password user = reset_token.user user.set_password(new_password) user.save() # Mark token as used reset_token.used = True reset_token.save() return success_response( message='Password has been reset successfully', request=request ) # ============================================================================ # CSV Import/Export Views for Admin # ============================================================================ from django.http import HttpResponse, JsonResponse from django.contrib.admin.views.decorators import staff_member_required from django.views.decorators.http import require_http_methods import csv import io @staff_member_required @require_http_methods(["GET"]) def industry_csv_template(request): """Download CSV template for Industry import""" response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="industry_template.csv"' writer = csv.writer(response) writer.writerow(['name', 'description', 'is_active']) writer.writerow(['Technology', 'Technology industry', 'true']) writer.writerow(['Healthcare', 'Healthcare and medical services', 'true']) return response @staff_member_required @require_http_methods(["POST"]) def industry_csv_import(request): """Import industries from CSV""" if not request.FILES.get('csv_file'): return JsonResponse({'success': False, 'error': 'No CSV file provided'}, status=400) csv_file = request.FILES['csv_file'] decoded_file = csv_file.read().decode('utf-8') io_string = io.StringIO(decoded_file) reader = csv.DictReader(io_string) created = 0 updated = 0 errors = [] from django.utils.text import slugify for row_num, row in enumerate(reader, start=2): try: is_active = row.get('is_active', 'true').lower() in ['true', '1', 'yes'] slug = slugify(row['name']) industry, created_flag = Industry.objects.update_or_create( name=row['name'], defaults={ 'slug': slug, 'description': row.get('description', ''), 'is_active': is_active } ) if created_flag: created += 1 else: updated += 1 except Exception as e: errors.append(f"Row {row_num}: {str(e)}") return JsonResponse({ 'success': True, 'created': created, 'updated': updated, 'errors': errors }) @staff_member_required @require_http_methods(["GET"]) def industrysector_csv_template(request): """Download CSV template for IndustrySector import""" response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="industrysector_template.csv"' writer = csv.writer(response) writer.writerow(['name', 'industry', 'description', 'is_active']) writer.writerow(['Software Development', 'Technology', 'Software and app development', 'true']) writer.writerow(['Healthcare IT', 'Healthcare', 'Healthcare information technology', 'true']) return response @staff_member_required @require_http_methods(["POST"]) def industrysector_csv_import(request): """Import industry sectors from CSV""" if not request.FILES.get('csv_file'): return JsonResponse({'success': False, 'error': 'No CSV file provided'}, status=400) csv_file = request.FILES['csv_file'] decoded_file = csv_file.read().decode('utf-8') io_string = io.StringIO(decoded_file) reader = csv.DictReader(io_string) created = 0 updated = 0 errors = [] from django.utils.text import slugify for row_num, row in enumerate(reader, start=2): try: is_active = row.get('is_active', 'true').lower() in ['true', '1', 'yes'] slug = slugify(row['name']) # Find industry by name try: industry = Industry.objects.get(name=row['industry']) except Industry.DoesNotExist: errors.append(f"Row {row_num}: Industry '{row['industry']}' not found") continue sector, created_flag = IndustrySector.objects.update_or_create( name=row['name'], industry=industry, defaults={ 'slug': slug, 'description': row.get('description', ''), 'is_active': is_active } ) if created_flag: created += 1 else: updated += 1 except Exception as e: errors.append(f"Row {row_num}: {str(e)}") return JsonResponse({ 'success': True, 'created': created, 'updated': updated, 'errors': errors }) @staff_member_required @require_http_methods(["GET"]) def seedkeyword_csv_template(request): """Download CSV template for SeedKeyword import""" response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="seedkeyword_template.csv"' writer = csv.writer(response) writer.writerow(['keyword', 'industry', 'sector', 'volume', 'difficulty', 'country', 'is_active']) writer.writerow(['python programming', 'Technology', 'Software Development', '10000', '45', 'US', 'true']) writer.writerow(['medical software', 'Healthcare', 'Healthcare IT', '5000', '60', 'CA', 'true']) return response @staff_member_required @require_http_methods(["POST"]) def seedkeyword_csv_import(request): """Import seed keywords from CSV""" if not request.FILES.get('csv_file'): return JsonResponse({'success': False, 'error': 'No CSV file provided'}, status=400) csv_file = request.FILES['csv_file'] decoded_file = csv_file.read().decode('utf-8') io_string = io.StringIO(decoded_file) reader = csv.DictReader(io_string) created = 0 updated = 0 errors = [] for row_num, row in enumerate(reader, start=2): try: is_active = row.get('is_active', 'true').lower() in ['true', '1', 'yes'] # Find industry and sector by name try: industry = Industry.objects.get(name=row['industry']) except Industry.DoesNotExist: errors.append(f"Row {row_num}: Industry '{row['industry']}' not found") continue try: sector = IndustrySector.objects.get(name=row['sector'], industry=industry) except IndustrySector.DoesNotExist: errors.append(f"Row {row_num}: Sector '{row['sector']}' not found in industry '{row['industry']}'") continue keyword, created_flag = SeedKeyword.objects.update_or_create( keyword=row['keyword'], industry=industry, sector=sector, defaults={ 'volume': int(row.get('volume', 0)), 'difficulty': int(row.get('difficulty', 0)), 'country': row.get('country', 'US'), 'is_active': is_active } ) if created_flag: created += 1 else: updated += 1 except Exception as e: errors.append(f"Row {row_num}: {str(e)}") return JsonResponse({ 'success': True, 'created': created, 'updated': updated, 'errors': errors })