""" 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 throttle_scope = 'auth' 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. Unified API Standard v1.0 compliant """ queryset = Plan.objects.filter(is_active=True) serializer_class = PlanSerializer permission_classes = [permissions.AllowAny] pagination_class = CustomPageNumberPagination throttle_scope = 'auth' throttle_classes = [DebugScopedRateThrottle] 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, CSRFExemptSessionAuthentication] def get_permissions(self): """Allow normal users (viewer) to create sites, but require editor+ for other operations.""" if self.action == 'create': return [permissions.IsAuthenticated()] return [IsEditorOrAbove()] def get_queryset(self): """Return sites accessible to the current user.""" user = self.request.user if not user or not user.is_authenticated: return Site.objects.none() # ADMIN/DEV OVERRIDE: Both admins and developers can see all sites if user.is_admin_or_developer(): return Site.objects.all().distinct() # Get account from user account = getattr(user, 'account', None) if not account: return Site.objects.none() if user.role in ['owner', 'admin']: return Site.objects.filter(account=account) return Site.objects.filter( account=account, user_access__user=user ).distinct() def perform_create(self, serializer): """Create site with account.""" account = getattr(self.request, 'account', None) if not account: user = self.request.user if user and user.is_authenticated: account = getattr(user, 'account', None) # Multiple sites can be active simultaneously - no constraint serializer.save(account=account) 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, CSRFExemptSessionAuthentication] 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() # ADMIN/DEV OVERRIDE: Both admins and developers can see all sectors across all sites if user.is_admin_or_developer(): return Sector.objects.all().distinct() 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', 'intent', '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') sector_id = self.request.query_params.get('sector_id') if industry_id: queryset = queryset.filter(industry_id=industry_id) if sector_id: queryset = queryset.filter(sector_id=sector_id) return queryset # ============================================================================ # 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): # 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, '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 )