""" Account Management API Views Handles account settings, team management, and usage analytics """ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from django.contrib.auth import get_user_model from django.db.models import Q, Count, Sum from django.utils import timezone from datetime import timedelta from decimal import Decimal import logging import secrets from drf_spectacular.utils import extend_schema, extend_schema_view from igny8_core.auth.models import Account, PasswordResetToken COUNTRY_TIMEZONE_MAP = { 'US': 'America/New_York', 'GB': 'Europe/London', 'CA': 'America/Toronto', 'AU': 'Australia/Sydney', 'IN': 'Asia/Kolkata', 'PK': 'Asia/Karachi', 'DE': 'Europe/Berlin', 'FR': 'Europe/Paris', 'ES': 'Europe/Madrid', 'IT': 'Europe/Rome', 'NL': 'Europe/Amsterdam', 'SE': 'Europe/Stockholm', 'NO': 'Europe/Oslo', 'DK': 'Europe/Copenhagen', 'FI': 'Europe/Helsinki', 'BE': 'Europe/Brussels', 'AT': 'Europe/Vienna', 'CH': 'Europe/Zurich', 'IE': 'Europe/Dublin', 'NZ': 'Pacific/Auckland', 'SG': 'Asia/Singapore', 'AE': 'Asia/Dubai', 'SA': 'Asia/Riyadh', 'ZA': 'Africa/Johannesburg', 'BR': 'America/Sao_Paulo', 'MX': 'America/Mexico_City', 'AR': 'America/Argentina/Buenos_Aires', 'CL': 'America/Santiago', 'CO': 'America/Bogota', 'JP': 'Asia/Tokyo', 'KR': 'Asia/Seoul', 'CN': 'Asia/Shanghai', 'TH': 'Asia/Bangkok', 'MY': 'Asia/Kuala_Lumpur', 'ID': 'Asia/Jakarta', 'PH': 'Asia/Manila', 'VN': 'Asia/Ho_Chi_Minh', 'BD': 'Asia/Dhaka', 'LK': 'Asia/Colombo', 'EG': 'Africa/Cairo', 'NG': 'Africa/Lagos', 'KE': 'Africa/Nairobi', 'GH': 'Africa/Accra', } def _timezone_for_country(country_code: str | None) -> str: if not country_code: return 'UTC' return COUNTRY_TIMEZONE_MAP.get(country_code.upper(), 'UTC') from igny8_core.business.billing.models import CreditTransaction User = get_user_model() logger = logging.getLogger(__name__) @extend_schema_view( retrieve=extend_schema(tags=['Account']), partial_update=extend_schema(tags=['Account']), ) class AccountSettingsViewSet(viewsets.ViewSet): """Account settings management""" permission_classes = [IsAuthenticated] def retrieve(self, request): """Get account settings""" account = request.user.account return Response({ 'id': account.id, 'name': account.name, 'slug': account.slug, 'billing_address_line1': account.billing_address_line1 or '', 'billing_address_line2': account.billing_address_line2 or '', 'billing_city': account.billing_city or '', 'billing_state': account.billing_state or '', 'billing_postal_code': account.billing_postal_code or '', 'billing_country': account.billing_country or '', 'tax_id': account.tax_id or '', 'billing_email': account.billing_email or '', 'account_timezone': account.account_timezone or 'UTC', 'timezone_mode': account.timezone_mode or 'country', 'timezone_offset': account.timezone_offset or '', 'credits': account.credits, 'created_at': account.created_at.isoformat(), 'updated_at': account.updated_at.isoformat(), }) def partial_update(self, request): """Update account settings""" account = request.user.account # Update allowed fields allowed_fields = [ 'name', 'billing_address_line1', 'billing_address_line2', 'billing_city', 'billing_state', 'billing_postal_code', 'billing_country', 'tax_id', 'billing_email', 'account_timezone', 'timezone_mode', 'timezone_offset' ] for field in allowed_fields: if field in request.data: setattr(account, field, request.data[field]) # Derive timezone from country unless manual mode is selected if getattr(account, 'timezone_mode', 'country') != 'manual': country_code = account.billing_country account.account_timezone = _timezone_for_country(country_code) account.timezone_offset = '' account.save() return Response({ 'message': 'Account settings updated successfully', 'account': { 'id': account.id, 'name': account.name, 'slug': account.slug, 'billing_address_line1': account.billing_address_line1, 'billing_address_line2': account.billing_address_line2, 'billing_city': account.billing_city, 'billing_state': account.billing_state, 'billing_postal_code': account.billing_postal_code, 'billing_country': account.billing_country, 'tax_id': account.tax_id, 'billing_email': account.billing_email, 'account_timezone': account.account_timezone, 'timezone_mode': account.timezone_mode, 'timezone_offset': account.timezone_offset, } }) @extend_schema_view( list=extend_schema(tags=['Account']), create=extend_schema(tags=['Account']), destroy=extend_schema(tags=['Account']), ) class TeamManagementViewSet(viewsets.ViewSet): """Team members management""" permission_classes = [IsAuthenticated] def list(self, request): """List team members""" account = request.user.account users = User.objects.filter(account=account) return Response({ 'results': [ { 'id': user.id, 'email': user.email, 'first_name': user.first_name, 'last_name': user.last_name, 'role': user.role, 'is_active': user.is_active, 'is_staff': user.is_staff, 'date_joined': user.date_joined.isoformat(), 'last_login': user.last_login.isoformat() if user.last_login else None, } for user in users ], 'count': users.count() }) def create(self, request): """Invite new team member with role and optional site access.""" account = request.user.account email = request.data.get('email') role = request.data.get('role', 'viewer') site_ids = request.data.get('site_ids', []) # For viewer role: which sites to grant access if not email: return Response( {'error': 'Email is required'}, status=status.HTTP_400_BAD_REQUEST ) # Validate role - only admin and viewer allowed for invites if role not in ['admin', 'viewer']: return Response( {'error': 'Role must be either "admin" or "viewer"'}, status=status.HTTP_400_BAD_REQUEST ) # Check if user already exists if User.objects.filter(email=email).exists(): return Response( {'error': 'User with this email already exists'}, status=status.HTTP_400_BAD_REQUEST ) # Check hard limit for users BEFORE creating from igny8_core.business.billing.services.limit_service import LimitService, HardLimitExceededError try: LimitService.check_hard_limit(account, 'users', additional_count=1) except HardLimitExceededError as e: return Response( {'error': str(e)}, status=status.HTTP_400_BAD_REQUEST ) # Generate username from email if not provided base_username = email.split('@')[0] username = base_username counter = 1 while User.objects.filter(username=username).exists(): username = f"{base_username}{counter}" counter += 1 # Create user with assigned role user = User.objects.create_user( username=username, email=email, first_name=request.data.get('first_name', ''), last_name=request.data.get('last_name', ''), account=account, role=role, ) # Grant site access based on role from igny8_core.auth.models import SiteUserAccess, Site if role == 'viewer' and site_ids: # Viewer: grant access only to specified sites sites = Site.objects.filter(id__in=site_ids, account=account) for site in sites: SiteUserAccess.objects.get_or_create( user=user, site=site, defaults={'granted_by': request.user} ) elif role == 'admin': # Admin: automatically grant access to ALL current sites # (admin also sees all sites via get_accessible_sites, but # creating records ensures consistency) sites = Site.objects.filter(account=account) for site in sites: SiteUserAccess.objects.get_or_create( user=user, site=site, defaults={'granted_by': request.user} ) # Create password reset token for invite token = secrets.token_urlsafe(32) expires_at = timezone.now() + timedelta(hours=24) PasswordResetToken.objects.create( user=user, token=token, expires_at=expires_at ) try: from igny8_core.business.billing.services.email_service import send_team_invite_email send_team_invite_email(user, request.user, account, token) except Exception as e: logger.error(f"Failed to send team invite email: {e}") return Response({ 'message': 'Team member invited successfully', 'user': { 'id': user.id, 'email': user.email, 'first_name': user.first_name, 'last_name': user.last_name, 'role': user.role, } }, status=status.HTTP_201_CREATED) def destroy(self, request, pk=None): """Remove team member""" account = request.user.account try: user = User.objects.get(id=pk, account=account) # Prevent removing yourself if user.id == request.user.id: return Response( {'error': 'Cannot remove yourself'}, status=status.HTTP_400_BAD_REQUEST ) user.is_active = False user.save() return Response({ 'message': 'Team member removed successfully' }) except User.DoesNotExist: return Response( {'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND ) @extend_schema_view( overview=extend_schema(tags=['Account']), ) class UsageAnalyticsViewSet(viewsets.ViewSet): """Usage analytics and statistics""" permission_classes = [IsAuthenticated] @action(detail=False, methods=['get']) def overview(self, request): """Get usage analytics overview""" account = request.user.account # Get date range (default: last 30 days) days = int(request.query_params.get('days', 30)) start_date = timezone.now() - timedelta(days=days) # Get transactions in period transactions = CreditTransaction.objects.filter( account=account, created_at__gte=start_date ) # Calculate totals by type usage_by_type = transactions.filter( amount__lt=0 ).values('transaction_type').annotate( total=Sum('amount'), count=Count('id') ) purchases_by_type = transactions.filter( amount__gt=0 ).values('transaction_type').annotate( total=Sum('amount'), count=Count('id') ) # Daily usage daily_usage = [] for i in range(days): date = start_date + timedelta(days=i) day_txns = transactions.filter( created_at__date=date.date() ) usage = day_txns.filter(amount__lt=0).aggregate(Sum('amount'))['amount__sum'] or 0 purchases = day_txns.filter(amount__gt=0).aggregate(Sum('amount'))['amount__sum'] or 0 daily_usage.append({ 'date': date.date().isoformat(), 'usage': abs(usage), 'purchases': purchases, 'net': purchases + usage }) return Response({ 'period_days': days, 'start_date': start_date.isoformat(), 'end_date': timezone.now().isoformat(), 'current_balance': account.credits, 'usage_by_type': list(usage_by_type), 'purchases_by_type': list(purchases_by_type), 'daily_usage': daily_usage, 'total_usage': abs(transactions.filter(amount__lt=0).aggregate(Sum('amount'))['amount__sum'] or 0), 'total_purchases': transactions.filter(amount__gt=0).aggregate(Sum('amount'))['amount__sum'] or 0, }) @extend_schema_view( stats=extend_schema(tags=['Account']), ) class DashboardStatsViewSet(viewsets.ViewSet): """Dashboard statistics - real data for home page widgets""" permission_classes = [IsAuthenticated] @action(detail=False, methods=['get']) def stats(self, request): """ Get dashboard statistics for the home page. Query params: - site_id: Filter by site (optional, defaults to all sites) - days: Number of days for AI operations (default: 7) Returns: - ai_operations: Real credit usage by operation type - recent_activity: Recent notifications - content_velocity: Content created this week/month - images_count: Actual total images count - published_count: Actual published content count """ account = request.user.account site_id = request.query_params.get('site_id') days = int(request.query_params.get('days', 7)) # Import models here to avoid circular imports from igny8_core.modules.writer.models import Images, Content from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas from igny8_core.business.notifications.models import Notification from igny8_core.business.billing.models import CreditUsageLog from igny8_core.auth.models import Site # Build base filter for site site_filter = {} if site_id: try: site_filter['site_id'] = int(site_id) except (ValueError, TypeError): pass # ========== AI OPERATIONS (from CreditUsageLog) ========== start_date = timezone.now() - timedelta(days=days) usage_query = CreditUsageLog.objects.filter( account=account, created_at__gte=start_date ) # Filter by site if provided if site_id: usage_query = usage_query.filter(site_id=site_id) # Get operations grouped by type operations_data = usage_query.values('operation_type').annotate( count=Count('id'), credits=Sum('credits_used') ).order_by('-credits') # Calculate totals total_ops = usage_query.count() total_credits = usage_query.aggregate(total=Sum('credits_used'))['total'] or 0 # Format operations for frontend operations = [] for op in operations_data: op_type = op['operation_type'] or 'other' operations.append({ 'type': op_type, 'count': op['count'] or 0, 'credits': op['credits'] or 0, }) ai_operations = { 'period': f'{days}d', 'operations': operations, 'totals': { 'count': total_ops, 'credits': total_credits, 'successRate': 98.5, # TODO: calculate from actual success/failure 'avgCreditsPerOp': round(total_credits / total_ops, 1) if total_ops > 0 else 0, } } # ========== RECENT ACTIVITY (from Notifications) ========== recent_notifications = Notification.objects.filter( account=account ).order_by('-created_at')[:10] recent_activity = [] for notif in recent_notifications: # Map notification type to activity type activity_type_map = { 'ai_clustering_complete': 'clustering', 'ai_ideas_complete': 'ideas', 'ai_content_complete': 'content', 'ai_images_complete': 'images', 'ai_prompts_complete': 'images', 'content_published': 'published', 'wp_sync_success': 'published', } activity_type = activity_type_map.get(notif.notification_type, 'system') # Map notification type to href href_map = { 'clustering': '/planner/clusters', 'ideas': '/planner/ideas', 'content': '/writer/content', 'images': '/writer/images', 'published': '/writer/published', } recent_activity.append({ 'id': str(notif.id), 'type': activity_type, 'title': notif.title, 'description': notif.message[:100] if notif.message else '', 'timestamp': notif.created_at.isoformat(), 'href': href_map.get(activity_type, '/dashboard'), }) # ========== CONTENT COUNTS ========== content_base = Content.objects.filter(account=account) if site_filter: content_base = content_base.filter(**site_filter) total_content = content_base.count() draft_content = content_base.filter(status='draft').count() review_content = content_base.filter(status='review').count() published_content = content_base.filter(status='published').count() # ========== IMAGES COUNT (actual images, not content with images) ========== images_base = Images.objects.filter(account=account) if site_filter: images_base = images_base.filter(**site_filter) total_images = images_base.count() generated_images = images_base.filter(status='generated').count() pending_images = images_base.filter(status='pending').count() # ========== CONTENT VELOCITY ========== now = timezone.now() week_ago = now - timedelta(days=7) month_ago = now - timedelta(days=30) # This week's content week_content = content_base.filter(created_at__gte=week_ago).count() week_images = images_base.filter(created_at__gte=week_ago).count() # This month's content month_content = content_base.filter(created_at__gte=month_ago).count() month_images = images_base.filter(created_at__gte=month_ago).count() # Estimate words (avg 1500 per article) content_velocity = { 'thisWeek': { 'articles': week_content, 'words': week_content * 1500, 'images': week_images, }, 'thisMonth': { 'articles': month_content, 'words': month_content * 1500, 'images': month_images, }, 'total': { 'articles': total_content, 'words': total_content * 1500, 'images': total_images, }, 'trend': 0, # TODO: calculate actual trend } # ========== PIPELINE COUNTS ========== keywords_base = Keywords.objects.filter(account=account) clusters_base = Clusters.objects.filter(account=account) ideas_base = ContentIdeas.objects.filter(account=account) if site_filter: keywords_base = keywords_base.filter(**site_filter) clusters_base = clusters_base.filter(**site_filter) ideas_base = ideas_base.filter(**site_filter) # Get site count sites_count = Site.objects.filter(account=account, is_active=True).count() pipeline = { 'sites': sites_count, 'keywords': keywords_base.count(), 'clusters': clusters_base.count(), 'ideas': ideas_base.count(), 'tasks': ideas_base.filter(status='queued').count() + ideas_base.filter(status='completed').count(), 'drafts': draft_content + review_content, 'published': published_content, } return Response({ 'ai_operations': ai_operations, 'recent_activity': recent_activity, 'content_velocity': content_velocity, 'pipeline': pipeline, 'counts': { 'content': { 'total': total_content, 'draft': draft_content, 'review': review_content, 'published': published_content, }, 'images': { 'total': total_images, 'generated': generated_images, 'pending': pending_images, }, } })