599 lines
22 KiB
Python
599 lines
22 KiB
Python
"""
|
|
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,
|
|
},
|
|
}
|
|
})
|