""" 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 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 """ 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 = CustomPageNumberPagination 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 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, 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 ) # ============================================================================ # 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 email (async via Celery if available, otherwise sync) try: from igny8_core.modules.system.tasks import send_password_reset_email send_password_reset_email.delay(user.id, token) except: # Fallback to sync email sending from django.core.mail import send_mail from django.conf import settings reset_url = f"{request.scheme}://{request.get_host()}/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 })