asdasd
This commit is contained in:
581
tenant-temp/backend/igny8_core/modules/billing/views.py
Normal file
581
tenant-temp/backend/igny8_core/modules/billing/views.py
Normal file
@@ -0,0 +1,581 @@
|
||||
"""
|
||||
ViewSets for Billing API
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
from rest_framework import viewsets, status, permissions
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Sum, Count, Q
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from igny8_core.api.base import AccountModelViewSet
|
||||
from igny8_core.api.pagination import CustomPageNumberPagination
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner
|
||||
from .models import CreditTransaction, CreditUsageLog
|
||||
from .serializers import (
|
||||
CreditTransactionSerializer, CreditUsageLogSerializer,
|
||||
CreditBalanceSerializer, UsageSummarySerializer, UsageLimitsSerializer
|
||||
)
|
||||
from .services import CreditService
|
||||
from .exceptions import InsufficientCreditsError
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['Billing'], summary='Get credit balance'),
|
||||
)
|
||||
class CreditBalanceViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
ViewSet for credit balance operations
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||
throttle_scope = 'billing'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def list(self, request):
|
||||
"""Get current credit balance and usage"""
|
||||
account = getattr(request, 'account', None)
|
||||
if not account:
|
||||
user = getattr(request, 'user', None)
|
||||
if user and user.is_authenticated:
|
||||
from igny8_core.auth.models import User as UserModel
|
||||
user = UserModel.objects.select_related('account', 'account__plan').get(id=user.id)
|
||||
account = user.account
|
||||
request.account = account
|
||||
|
||||
if not account:
|
||||
return success_response(data={
|
||||
'credits': 0,
|
||||
'plan_credits_per_month': 0,
|
||||
'credits_used_this_month': 0,
|
||||
'credits_remaining': 0,
|
||||
}, request=request)
|
||||
|
||||
# Get plan credits - plan is already associated
|
||||
plan_credits_per_month = 0
|
||||
if account.plan:
|
||||
plan_credits_per_month = account.plan.get_effective_credits_per_month()
|
||||
|
||||
# Calculate credits used this month
|
||||
now = timezone.now()
|
||||
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
credits_used_this_month = CreditUsageLog.objects.filter(
|
||||
account=account,
|
||||
created_at__gte=start_of_month
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
credits = account.credits or 0
|
||||
credits_remaining = credits
|
||||
|
||||
data = {
|
||||
'credits': credits,
|
||||
'plan_credits_per_month': plan_credits_per_month,
|
||||
'credits_used_this_month': credits_used_this_month,
|
||||
'credits_remaining': credits_remaining,
|
||||
}
|
||||
|
||||
# Validate and serialize data
|
||||
serializer = CreditBalanceSerializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return success_response(data=serializer.validated_data, request=request)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['Billing']),
|
||||
retrieve=extend_schema(tags=['Billing']),
|
||||
)
|
||||
class CreditUsageViewSet(AccountModelViewSet):
|
||||
"""
|
||||
ViewSet for credit usage logs
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = CreditUsageLog.objects.all()
|
||||
serializer_class = CreditUsageLogSerializer
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||
pagination_class = CustomPageNumberPagination
|
||||
throttle_scope = 'billing'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
filter_backends = []
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get usage logs for current account - base class handles account filtering"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Filter by operation type
|
||||
operation_type = self.request.query_params.get('operation_type')
|
||||
if operation_type:
|
||||
queryset = queryset.filter(operation_type=operation_type)
|
||||
|
||||
# Filter by date range
|
||||
start_date = self.request.query_params.get('start_date')
|
||||
end_date = self.request.query_params.get('end_date')
|
||||
if start_date:
|
||||
queryset = queryset.filter(created_at__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(created_at__lte=end_date)
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
|
||||
@extend_schema(tags=['Billing'], summary='Get usage summary')
|
||||
@action(detail=False, methods=['get'])
|
||||
def summary(self, request):
|
||||
"""Get usage summary for date range"""
|
||||
account = getattr(request, 'account', None)
|
||||
if not account:
|
||||
user = getattr(request, 'user', None)
|
||||
if user:
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
if not account:
|
||||
return error_response(
|
||||
error='Account not found',
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get date range from query params
|
||||
start_date = request.query_params.get('start_date')
|
||||
end_date = request.query_params.get('end_date')
|
||||
|
||||
# Default to current month if not provided
|
||||
now = timezone.now()
|
||||
if not start_date:
|
||||
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
else:
|
||||
from django.utils.dateparse import parse_datetime
|
||||
start_date = parse_datetime(start_date) or start_date
|
||||
|
||||
if not end_date:
|
||||
end_date = now
|
||||
else:
|
||||
from django.utils.dateparse import parse_datetime
|
||||
end_date = parse_datetime(end_date) or end_date
|
||||
|
||||
# Get usage logs in date range
|
||||
usage_logs = CreditUsageLog.objects.filter(
|
||||
account=account,
|
||||
created_at__gte=start_date,
|
||||
created_at__lte=end_date
|
||||
)
|
||||
|
||||
# Calculate totals
|
||||
total_credits_used = usage_logs.aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
total_cost_usd = usage_logs.aggregate(total=Sum('cost_usd'))['total'] or Decimal('0.00')
|
||||
|
||||
# Group by operation type
|
||||
by_operation = {}
|
||||
for operation_type, _ in CreditUsageLog.OPERATION_TYPE_CHOICES:
|
||||
operation_logs = usage_logs.filter(operation_type=operation_type)
|
||||
credits = operation_logs.aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
cost = operation_logs.aggregate(total=Sum('cost_usd'))['total'] or Decimal('0.00')
|
||||
count = operation_logs.count()
|
||||
|
||||
if credits > 0 or count > 0:
|
||||
by_operation[operation_type] = {
|
||||
'credits': credits,
|
||||
'cost': float(cost),
|
||||
'count': count
|
||||
}
|
||||
|
||||
# Group by model
|
||||
by_model = {}
|
||||
model_stats = usage_logs.values('model_used').annotate(
|
||||
credits=Sum('credits_used'),
|
||||
cost=Sum('cost_usd'),
|
||||
count=Count('id')
|
||||
).filter(model_used__isnull=False).exclude(model_used='')
|
||||
|
||||
for stat in model_stats:
|
||||
model = stat['model_used']
|
||||
by_model[model] = {
|
||||
'credits': stat['credits'] or 0,
|
||||
'cost': float(stat['cost'] or Decimal('0.00'))
|
||||
}
|
||||
|
||||
data = {
|
||||
'period': {
|
||||
'start': start_date.isoformat() if hasattr(start_date, 'isoformat') else str(start_date),
|
||||
'end': end_date.isoformat() if hasattr(end_date, 'isoformat') else str(end_date),
|
||||
},
|
||||
'total_credits_used': total_credits_used,
|
||||
'total_cost_usd': float(total_cost_usd),
|
||||
'by_operation': by_operation,
|
||||
'by_model': by_model,
|
||||
}
|
||||
|
||||
serializer = UsageSummarySerializer(data)
|
||||
return success_response(data=serializer.data, request=request)
|
||||
|
||||
@extend_schema(tags=['Billing'], summary='Get usage limits')
|
||||
@action(detail=False, methods=['get'], url_path='limits', url_name='limits')
|
||||
def limits(self, request):
|
||||
"""
|
||||
Get account limits and credit usage statistics (Phase 0: Credit-only system).
|
||||
Returns account management limits and credit usage only.
|
||||
"""
|
||||
# Try multiple ways to get account
|
||||
account = getattr(request, 'account', None)
|
||||
|
||||
if not account:
|
||||
user = getattr(request, 'user', None)
|
||||
if user and user.is_authenticated:
|
||||
# Try to get account from user - refresh from DB to ensure we have latest
|
||||
try:
|
||||
from igny8_core.auth.models import User as UserModel
|
||||
# Refresh user from DB to get account relationship
|
||||
user = UserModel.objects.select_related('account', 'account__plan').get(id=user.id)
|
||||
account = user.account
|
||||
# Also set it on request for future use
|
||||
request.account = account
|
||||
except (AttributeError, UserModel.DoesNotExist, Exception) as e:
|
||||
account = None
|
||||
|
||||
if not account:
|
||||
# Return empty limits instead of error - frontend will show "no data" message
|
||||
return success_response(data={'limits': []}, request=request)
|
||||
|
||||
plan = account.plan
|
||||
if not plan:
|
||||
# Return empty limits instead of error - allows frontend to show "no plan" message
|
||||
return success_response(data={'limits': []}, request=request)
|
||||
|
||||
# Import models
|
||||
from igny8_core.auth.models import User, Site
|
||||
|
||||
# Get current month boundaries
|
||||
now = timezone.now()
|
||||
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Calculate usage statistics
|
||||
limits_data = []
|
||||
|
||||
# Credit Usage (Phase 0: Credit-only system)
|
||||
credits_used_month = CreditUsageLog.objects.filter(
|
||||
account=account,
|
||||
created_at__gte=start_of_month
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
# Get credits by operation type
|
||||
cluster_credits = CreditUsageLog.objects.filter(
|
||||
account=account,
|
||||
operation_type__in=['clustering'],
|
||||
created_at__gte=start_of_month
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
content_credits = CreditUsageLog.objects.filter(
|
||||
account=account,
|
||||
operation_type__in=['content', 'content_generation'],
|
||||
created_at__gte=start_of_month
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
image_credits = CreditUsageLog.objects.filter(
|
||||
account=account,
|
||||
operation_type__in=['images', 'image_generation', 'image_prompt_extraction'],
|
||||
created_at__gte=start_of_month
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
idea_credits = CreditUsageLog.objects.filter(
|
||||
account=account,
|
||||
operation_type__in=['ideas', 'idea_generation'],
|
||||
created_at__gte=start_of_month
|
||||
).aggregate(total=Sum('credits_used'))['total'] or 0
|
||||
|
||||
# Use included_credits from plan (Phase 0: Credit-only)
|
||||
plan_credits = plan.included_credits or plan.credits_per_month or 0
|
||||
|
||||
limits_data.extend([
|
||||
{
|
||||
'title': 'Monthly Credits',
|
||||
'limit': plan_credits,
|
||||
'used': credits_used_month,
|
||||
'available': max(0, plan_credits - credits_used_month),
|
||||
'unit': 'credits',
|
||||
'category': 'credits',
|
||||
'percentage': (credits_used_month / plan_credits * 100) if plan_credits else 0
|
||||
},
|
||||
{
|
||||
'title': 'Current Balance',
|
||||
'limit': None, # No limit - shows current balance
|
||||
'used': None,
|
||||
'available': account.credits,
|
||||
'unit': 'credits',
|
||||
'category': 'credits',
|
||||
'percentage': None
|
||||
},
|
||||
{
|
||||
'title': 'Clustering Credits',
|
||||
'limit': None,
|
||||
'used': cluster_credits,
|
||||
'available': None,
|
||||
'unit': 'credits',
|
||||
'category': 'credits',
|
||||
'percentage': None
|
||||
},
|
||||
{
|
||||
'title': 'Content Generation Credits',
|
||||
'limit': None,
|
||||
'used': content_credits,
|
||||
'available': None,
|
||||
'unit': 'credits',
|
||||
'category': 'credits',
|
||||
'percentage': None
|
||||
},
|
||||
{
|
||||
'title': 'Image Generation Credits',
|
||||
'limit': None,
|
||||
'used': image_credits,
|
||||
'available': None,
|
||||
'unit': 'credits',
|
||||
'category': 'credits',
|
||||
'percentage': None
|
||||
},
|
||||
{
|
||||
'title': 'Idea Generation Credits',
|
||||
'limit': None,
|
||||
'used': idea_credits,
|
||||
'available': None,
|
||||
'unit': 'credits',
|
||||
'category': 'credits',
|
||||
'percentage': None
|
||||
},
|
||||
])
|
||||
|
||||
# Account Management Limits (kept - not operation limits)
|
||||
users_count = User.objects.filter(account=account).count()
|
||||
sites_count = Site.objects.filter(account=account).count()
|
||||
|
||||
limits_data.extend([
|
||||
{
|
||||
'title': 'Users',
|
||||
'limit': plan.max_users or 0,
|
||||
'used': users_count,
|
||||
'available': max(0, (plan.max_users or 0) - users_count),
|
||||
'unit': 'users',
|
||||
'category': 'account',
|
||||
'percentage': (users_count / (plan.max_users or 1)) * 100 if plan.max_users else 0
|
||||
},
|
||||
{
|
||||
'title': 'Sites',
|
||||
'limit': plan.max_sites or 0,
|
||||
'used': sites_count,
|
||||
'available': max(0, (plan.max_sites or 0) - sites_count),
|
||||
'unit': 'sites',
|
||||
'category': 'account',
|
||||
'percentage': (sites_count / (plan.max_sites or 1)) * 100 if plan.max_sites else 0
|
||||
},
|
||||
])
|
||||
|
||||
# Return data directly - serializer validation not needed for read-only endpoint
|
||||
return success_response(data={'limits': limits_data}, request=request)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['Billing']),
|
||||
retrieve=extend_schema(tags=['Billing']),
|
||||
)
|
||||
class CreditTransactionViewSet(AccountModelViewSet):
|
||||
"""
|
||||
ViewSet for credit transaction history
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = CreditTransaction.objects.all()
|
||||
serializer_class = CreditTransactionSerializer
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner]
|
||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||
pagination_class = CustomPageNumberPagination
|
||||
throttle_scope = 'billing'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get transactions for current account - base class handles account filtering"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Filter by transaction type
|
||||
transaction_type = self.request.query_params.get('transaction_type')
|
||||
if transaction_type:
|
||||
queryset = queryset.filter(transaction_type=transaction_type)
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
|
||||
|
||||
class BillingOverviewViewSet(viewsets.ViewSet):
|
||||
"""User-facing billing overview API"""
|
||||
permission_classes = [IsAuthenticatedAndActive]
|
||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||
|
||||
def account_balance(self, request):
|
||||
"""Get account balance with subscription info"""
|
||||
account = getattr(request, 'account', None) or request.user.account
|
||||
|
||||
# Get subscription plan
|
||||
subscription_plan = 'Free'
|
||||
monthly_credits_included = 0
|
||||
if account.plan:
|
||||
subscription_plan = account.plan.name
|
||||
monthly_credits_included = account.plan.get_effective_credits_per_month()
|
||||
|
||||
# Calculate bonus credits (credits beyond monthly allowance)
|
||||
bonus_credits = max(0, account.credits - monthly_credits_included)
|
||||
|
||||
data = {
|
||||
'credits': account.credits or 0,
|
||||
'subscription_plan': subscription_plan,
|
||||
'monthly_credits_included': monthly_credits_included,
|
||||
'bonus_credits': bonus_credits,
|
||||
}
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
stats=extend_schema(tags=['Admin Billing'], summary='Admin billing stats'),
|
||||
list_users=extend_schema(tags=['Admin Billing'], summary='List users with credit info'),
|
||||
adjust_credits=extend_schema(tags=['Admin Billing'], summary='Adjust user credits'),
|
||||
list_credit_costs=extend_schema(tags=['Admin Billing'], summary='List credit cost configurations'),
|
||||
update_credit_costs=extend_schema(tags=['Admin Billing'], summary='Update credit cost configurations'),
|
||||
)
|
||||
class AdminBillingViewSet(viewsets.ViewSet):
|
||||
"""Admin-only billing management API"""
|
||||
permission_classes = [IsAuthenticatedAndActive, permissions.IsAdminUser]
|
||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||
|
||||
def stats(self, request):
|
||||
"""Get system-wide billing statistics"""
|
||||
from igny8_core.auth.models import Account
|
||||
|
||||
total_users = Account.objects.filter(status='active').count()
|
||||
active_users = Account.objects.filter(status='active').exclude(users__last_login__isnull=True).count()
|
||||
|
||||
total_credits_issued = Account.objects.aggregate(
|
||||
total=Sum('credits')
|
||||
)['total'] or 0
|
||||
|
||||
total_credits_used = CreditUsageLog.objects.aggregate(
|
||||
total=Sum('credits_used')
|
||||
)['total'] or 0
|
||||
|
||||
return Response({
|
||||
'total_users': total_users,
|
||||
'active_users': active_users,
|
||||
'total_credits_issued': total_credits_issued,
|
||||
'total_credits_used': total_credits_used,
|
||||
})
|
||||
|
||||
def list_users(self, request):
|
||||
"""List all users with credit information"""
|
||||
from igny8_core.auth.models import Account
|
||||
from django.db.models import Q
|
||||
|
||||
# Get search query from request
|
||||
search = request.query_params.get('search', '')
|
||||
|
||||
queryset = Account.objects.filter(status='active').prefetch_related('users')
|
||||
|
||||
# Apply search filter
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(user__username__icontains=search) |
|
||||
Q(user__email__icontains=search)
|
||||
)
|
||||
|
||||
accounts = queryset[:100]
|
||||
|
||||
data = []
|
||||
for acc in accounts:
|
||||
user = acc.users.first() if acc.users.exists() else None
|
||||
data.append({
|
||||
'id': acc.id,
|
||||
'username': user.username if user else 'N/A',
|
||||
'email': user.email if user else 'N/A',
|
||||
'credits': acc.credits or 0,
|
||||
'subscription_plan': acc.plan.name if acc.plan else 'Free',
|
||||
'is_active': acc.status == 'active',
|
||||
'date_joined': acc.created_at
|
||||
})
|
||||
|
||||
return Response({'results': data})
|
||||
|
||||
def adjust_credits(self, request, user_id):
|
||||
"""Adjust credits for a specific user"""
|
||||
from igny8_core.auth.models import Account
|
||||
|
||||
try:
|
||||
account = Account.objects.get(id=user_id)
|
||||
except Account.DoesNotExist:
|
||||
return Response({'error': 'User not found'}, status=404)
|
||||
|
||||
amount = request.data.get('amount', 0)
|
||||
reason = request.data.get('reason', 'Admin adjustment')
|
||||
|
||||
try:
|
||||
amount = int(amount)
|
||||
except (ValueError, TypeError):
|
||||
return Response({'error': 'Invalid amount'}, status=400)
|
||||
|
||||
# Adjust credits
|
||||
old_balance = account.credits
|
||||
account.credits = (account.credits or 0) + amount
|
||||
account.save()
|
||||
|
||||
# Log the adjustment
|
||||
CreditUsageLog.objects.create(
|
||||
account=account,
|
||||
operation_type='admin_adjustment',
|
||||
credits_used=-amount, # Negative for additions
|
||||
credits_balance_after=account.credits,
|
||||
details={'reason': reason, 'old_balance': old_balance, 'adjusted_by': request.user.id}
|
||||
)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'new_balance': account.credits,
|
||||
'old_balance': old_balance,
|
||||
'adjustment': amount
|
||||
})
|
||||
|
||||
def list_credit_costs(self, request):
|
||||
"""List credit cost configurations"""
|
||||
from igny8_core.business.billing.models import CreditCostConfig
|
||||
|
||||
configs = CreditCostConfig.objects.filter(is_active=True)
|
||||
|
||||
data = [{
|
||||
'id': c.id,
|
||||
'operation_type': c.operation_type,
|
||||
'display_name': c.display_name,
|
||||
'credits_cost': c.credits_cost,
|
||||
'unit': c.unit,
|
||||
'is_active': c.is_active,
|
||||
'created_at': c.created_at
|
||||
} for c in configs]
|
||||
|
||||
return Response({'results': data})
|
||||
|
||||
def update_credit_costs(self, request):
|
||||
"""Update credit cost configurations"""
|
||||
from igny8_core.business.billing.models import CreditCostConfig
|
||||
|
||||
updates = request.data.get('updates', [])
|
||||
|
||||
for update in updates:
|
||||
config_id = update.get('id')
|
||||
new_cost = update.get('cost')
|
||||
|
||||
if config_id and new_cost is not None:
|
||||
try:
|
||||
config = CreditCostConfig.objects.get(id=config_id)
|
||||
config.cost = new_cost
|
||||
config.save()
|
||||
except CreditCostConfig.DoesNotExist:
|
||||
continue
|
||||
|
||||
return Response({'success': True})
|
||||
|
||||
1182
tenant-temp/backend/igny8_core/modules/planner/views.py
Normal file
1182
tenant-temp/backend/igny8_core/modules/planner/views.py
Normal file
File diff suppressed because it is too large
Load Diff
1392
tenant-temp/backend/igny8_core/modules/system/integration_views.py
Normal file
1392
tenant-temp/backend/igny8_core/modules/system/integration_views.py
Normal file
File diff suppressed because it is too large
Load Diff
782
tenant-temp/backend/igny8_core/modules/system/views.py
Normal file
782
tenant-temp/backend/igny8_core/modules/system/views.py
Normal file
@@ -0,0 +1,782 @@
|
||||
"""
|
||||
System module views - for global settings and prompts
|
||||
"""
|
||||
import psutil
|
||||
import os
|
||||
import logging
|
||||
from rest_framework import viewsets, status as http_status, filters
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.db import transaction, connection
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
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.response import success_response, error_response
|
||||
from igny8_core.api.permissions import IsEditorOrAbove, IsAuthenticatedAndActive, IsViewerOrAbove, HasTenantAccess
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.api.pagination import CustomPageNumberPagination
|
||||
from .models import AIPrompt, AuthorProfile, Strategy
|
||||
from .serializers import AIPromptSerializer, AuthorProfileSerializer, StrategySerializer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['System']),
|
||||
create=extend_schema(tags=['System']),
|
||||
retrieve=extend_schema(tags=['System']),
|
||||
update=extend_schema(tags=['System']),
|
||||
partial_update=extend_schema(tags=['System']),
|
||||
destroy=extend_schema(tags=['System']),
|
||||
)
|
||||
class AIPromptViewSet(AccountModelViewSet):
|
||||
"""
|
||||
ViewSet for managing AI prompts
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = AIPrompt.objects.all()
|
||||
serializer_class = AIPromptSerializer
|
||||
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
|
||||
throttle_scope = 'system'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
pagination_class = CustomPageNumberPagination # Explicitly use custom pagination
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get prompts for the current account"""
|
||||
return super().get_queryset().order_by('prompt_type')
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='by_type/(?P<prompt_type>[^/.]+)', url_name='by_type')
|
||||
def get_by_type(self, request, prompt_type=None):
|
||||
"""Get prompt by type"""
|
||||
try:
|
||||
prompt = self.get_queryset().get(prompt_type=prompt_type)
|
||||
serializer = self.get_serializer(prompt)
|
||||
return success_response(data=serializer.data, request=request)
|
||||
except AIPrompt.DoesNotExist:
|
||||
# Return default if not found
|
||||
from .utils import get_default_prompt
|
||||
default_value = get_default_prompt(prompt_type)
|
||||
return success_response(
|
||||
data={
|
||||
'prompt_type': prompt_type,
|
||||
'prompt_value': default_value,
|
||||
'default_prompt': default_value,
|
||||
'is_active': True,
|
||||
},
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='save', url_name='save')
|
||||
def save_prompt(self, request):
|
||||
"""Save or update a prompt - requires editor or above"""
|
||||
# Check if user has editor or above permissions
|
||||
if not IsEditorOrAbove().has_permission(request, self):
|
||||
return error_response(
|
||||
error='Permission denied. Editor or above role required.',
|
||||
status_code=http_status.HTTP_403_FORBIDDEN,
|
||||
request=request
|
||||
)
|
||||
|
||||
prompt_type = request.data.get('prompt_type')
|
||||
prompt_value = request.data.get('prompt_value')
|
||||
|
||||
if not prompt_type:
|
||||
return error_response(
|
||||
error='prompt_type is required',
|
||||
status_code=http_status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
if prompt_value is None:
|
||||
return error_response(
|
||||
error='prompt_value is required',
|
||||
status_code=http_status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get account - try multiple methods
|
||||
account = getattr(request, 'account', None)
|
||||
|
||||
# Fallback 1: Get from authenticated user's account
|
||||
if not account:
|
||||
user = getattr(request, 'user', None)
|
||||
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
# Fallback 2: If still no account, get default account (for development)
|
||||
if not account:
|
||||
from igny8_core.auth.models import Account
|
||||
try:
|
||||
account = Account.objects.first()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not account:
|
||||
return error_response(
|
||||
error='Account not found. Please ensure you are logged in.',
|
||||
status_code=http_status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get default prompt value if creating new
|
||||
from .utils import get_default_prompt
|
||||
default_value = get_default_prompt(prompt_type)
|
||||
|
||||
# Get or create prompt
|
||||
prompt, created = AIPrompt.objects.get_or_create(
|
||||
prompt_type=prompt_type,
|
||||
account=account,
|
||||
defaults={
|
||||
'prompt_value': prompt_value,
|
||||
'default_prompt': default_value,
|
||||
'is_active': True,
|
||||
}
|
||||
)
|
||||
|
||||
if not created:
|
||||
prompt.prompt_value = prompt_value
|
||||
prompt.save()
|
||||
|
||||
serializer = self.get_serializer(prompt)
|
||||
return success_response(
|
||||
data=serializer.data,
|
||||
message=f'{prompt.get_prompt_type_display()} saved successfully',
|
||||
request=request
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='reset', url_name='reset')
|
||||
def reset_prompt(self, request):
|
||||
"""Reset prompt to default - requires editor or above"""
|
||||
# Check if user has editor or above permissions
|
||||
if not IsEditorOrAbove().has_permission(request, self):
|
||||
return error_response(
|
||||
error='Permission denied. Editor or above role required.',
|
||||
status_code=http_status.HTTP_403_FORBIDDEN,
|
||||
request=request
|
||||
)
|
||||
|
||||
prompt_type = request.data.get('prompt_type')
|
||||
|
||||
if not prompt_type:
|
||||
return error_response(
|
||||
error='prompt_type is required',
|
||||
status_code=http_status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get account - try multiple methods (same as integration_views)
|
||||
account = getattr(request, 'account', None)
|
||||
|
||||
# Fallback 1: Get from authenticated user's account
|
||||
if not account:
|
||||
user = getattr(request, 'user', None)
|
||||
if user and hasattr(user, 'is_authenticated') and user.is_authenticated:
|
||||
account = getattr(user, 'account', None)
|
||||
|
||||
# Fallback 2: If still no account, get default account (for development)
|
||||
if not account:
|
||||
from igny8_core.auth.models import Account
|
||||
try:
|
||||
account = Account.objects.first()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not account:
|
||||
return error_response(
|
||||
error='Account not found. Please ensure you are logged in.',
|
||||
status_code=http_status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get default prompt
|
||||
from .utils import get_default_prompt
|
||||
default_value = get_default_prompt(prompt_type)
|
||||
|
||||
# Update or create prompt
|
||||
prompt, created = AIPrompt.objects.get_or_create(
|
||||
prompt_type=prompt_type,
|
||||
account=account,
|
||||
defaults={
|
||||
'prompt_value': default_value,
|
||||
'default_prompt': default_value,
|
||||
'is_active': True,
|
||||
}
|
||||
)
|
||||
|
||||
if not created:
|
||||
prompt.prompt_value = default_value
|
||||
prompt.save()
|
||||
|
||||
serializer = self.get_serializer(prompt)
|
||||
return success_response(
|
||||
data=serializer.data,
|
||||
message=f'{prompt.get_prompt_type_display()} reset to default',
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['System']),
|
||||
create=extend_schema(tags=['System']),
|
||||
retrieve=extend_schema(tags=['System']),
|
||||
update=extend_schema(tags=['System']),
|
||||
partial_update=extend_schema(tags=['System']),
|
||||
destroy=extend_schema(tags=['System']),
|
||||
)
|
||||
class AuthorProfileViewSet(AccountModelViewSet):
|
||||
"""
|
||||
ViewSet for managing Author Profiles
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = AuthorProfile.objects.all()
|
||||
serializer_class = AuthorProfileSerializer
|
||||
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
|
||||
throttle_scope = 'system'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
search_fields = ['name', 'description', 'tone']
|
||||
ordering_fields = ['name', 'created_at', 'updated_at']
|
||||
ordering = ['name']
|
||||
filterset_fields = ['is_active', 'language']
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['System']),
|
||||
create=extend_schema(tags=['System']),
|
||||
retrieve=extend_schema(tags=['System']),
|
||||
update=extend_schema(tags=['System']),
|
||||
partial_update=extend_schema(tags=['System']),
|
||||
destroy=extend_schema(tags=['System']),
|
||||
)
|
||||
class StrategyViewSet(AccountModelViewSet):
|
||||
"""
|
||||
ViewSet for managing Strategies
|
||||
Unified API Standard v1.0 compliant
|
||||
"""
|
||||
queryset = Strategy.objects.all()
|
||||
serializer_class = StrategySerializer
|
||||
permission_classes = [IsAuthenticatedAndActive, IsViewerOrAbove]
|
||||
throttle_scope = 'system'
|
||||
throttle_classes = [DebugScopedRateThrottle]
|
||||
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['name', 'created_at', 'updated_at']
|
||||
ordering = ['name']
|
||||
filterset_fields = ['is_active', 'sector']
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny]) # Public endpoint
|
||||
@extend_schema(
|
||||
tags=['System'],
|
||||
summary='Health Check',
|
||||
description='Simple health check endpoint to verify API is responding'
|
||||
)
|
||||
def ping(request):
|
||||
"""
|
||||
Simple health check endpoint
|
||||
Returns unified format: {success: true, data: {status: 'ok'}}
|
||||
"""
|
||||
return success_response(
|
||||
data={'status': 'ok'},
|
||||
request=request
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny]) # Public endpoint for monitoring
|
||||
def system_status(request):
|
||||
"""
|
||||
Comprehensive system status endpoint for monitoring
|
||||
Returns CPU, memory, disk, database, Redis, Celery, and process information
|
||||
"""
|
||||
status_data = {
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'system': {},
|
||||
'database': {},
|
||||
'redis': {},
|
||||
'celery': {},
|
||||
'processes': {},
|
||||
'modules': {},
|
||||
}
|
||||
|
||||
try:
|
||||
# System Resources
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
cpu_count = psutil.cpu_count()
|
||||
memory = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage('/')
|
||||
|
||||
status_data['system'] = {
|
||||
'cpu': {
|
||||
'usage_percent': cpu_percent,
|
||||
'cores': cpu_count,
|
||||
'status': 'healthy' if cpu_percent < 80 else 'warning' if cpu_percent < 95 else 'critical'
|
||||
},
|
||||
'memory': {
|
||||
'total_gb': round(memory.total / (1024**3), 2),
|
||||
'used_gb': round(memory.used / (1024**3), 2),
|
||||
'available_gb': round(memory.available / (1024**3), 2),
|
||||
'usage_percent': memory.percent,
|
||||
'status': 'healthy' if memory.percent < 80 else 'warning' if memory.percent < 95 else 'critical'
|
||||
},
|
||||
'disk': {
|
||||
'total_gb': round(disk.total / (1024**3), 2),
|
||||
'used_gb': round(disk.used / (1024**3), 2),
|
||||
'free_gb': round(disk.free / (1024**3), 2),
|
||||
'usage_percent': disk.percent,
|
||||
'status': 'healthy' if disk.percent < 80 else 'warning' if disk.percent < 95 else 'critical'
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting system resources: {str(e)}")
|
||||
status_data['system'] = {'error': str(e)}
|
||||
|
||||
try:
|
||||
# Database Status
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
db_conn = True
|
||||
cursor.execute("SELECT version()")
|
||||
db_version = cursor.fetchone()[0] if cursor.rowcount > 0 else 'Unknown'
|
||||
|
||||
# Get database size (PostgreSQL)
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT pg_size_pretty(pg_database_size(current_database()))
|
||||
""")
|
||||
db_size = cursor.fetchone()[0] if cursor.rowcount > 0 else 'Unknown'
|
||||
except:
|
||||
db_size = 'Unknown'
|
||||
|
||||
# Count active connections
|
||||
try:
|
||||
cursor.execute("SELECT count(*) FROM pg_stat_activity WHERE state = 'active'")
|
||||
active_connections = cursor.fetchone()[0] if cursor.rowcount > 0 else 0
|
||||
except:
|
||||
active_connections = 0
|
||||
|
||||
status_data['database'] = {
|
||||
'connected': db_conn,
|
||||
'version': db_version,
|
||||
'size': db_size,
|
||||
'active_connections': active_connections,
|
||||
'status': 'healthy' if db_conn else 'critical'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting database status: {str(e)}")
|
||||
status_data['database'] = {'connected': False, 'error': str(e), 'status': 'critical'}
|
||||
|
||||
try:
|
||||
# Redis Status
|
||||
redis_conn = False
|
||||
redis_info = {}
|
||||
try:
|
||||
cache.set('status_check', 'ok', 10)
|
||||
test_value = cache.get('status_check')
|
||||
redis_conn = test_value == 'ok'
|
||||
|
||||
# Try to get Redis info if available
|
||||
if hasattr(cache, 'client'):
|
||||
try:
|
||||
redis_client = cache.client.get_client()
|
||||
redis_info = redis_client.info()
|
||||
except:
|
||||
pass
|
||||
except Exception as e:
|
||||
redis_conn = False
|
||||
redis_info = {'error': str(e)}
|
||||
|
||||
status_data['redis'] = {
|
||||
'connected': redis_conn,
|
||||
'status': 'healthy' if redis_conn else 'critical',
|
||||
'info': redis_info if redis_info else {}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Redis status: {str(e)}")
|
||||
status_data['redis'] = {'connected': False, 'error': str(e), 'status': 'critical'}
|
||||
|
||||
try:
|
||||
# Celery Status
|
||||
celery_workers = []
|
||||
celery_tasks = {
|
||||
'active': 0,
|
||||
'scheduled': 0,
|
||||
'reserved': 0,
|
||||
}
|
||||
|
||||
try:
|
||||
from celery import current_app
|
||||
inspect = current_app.control.inspect()
|
||||
|
||||
# Get active workers
|
||||
active_workers = inspect.active() or {}
|
||||
scheduled = inspect.scheduled() or {}
|
||||
reserved = inspect.reserved() or {}
|
||||
|
||||
celery_workers = list(active_workers.keys())
|
||||
celery_tasks['active'] = sum(len(tasks) for tasks in active_workers.values())
|
||||
celery_tasks['scheduled'] = sum(len(tasks) for tasks in scheduled.values())
|
||||
celery_tasks['reserved'] = sum(len(tasks) for tasks in reserved.values())
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting Celery status: {str(e)}")
|
||||
celery_workers = []
|
||||
celery_tasks = {'error': str(e)}
|
||||
|
||||
status_data['celery'] = {
|
||||
'workers': celery_workers,
|
||||
'worker_count': len(celery_workers),
|
||||
'tasks': celery_tasks,
|
||||
'status': 'healthy' if len(celery_workers) > 0 else 'warning'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Celery status: {str(e)}")
|
||||
status_data['celery'] = {'error': str(e), 'status': 'warning'}
|
||||
|
||||
try:
|
||||
# Process Monitoring by Stack/Component
|
||||
processes = {
|
||||
'gunicorn': [],
|
||||
'celery': [],
|
||||
'postgres': [],
|
||||
'redis': [],
|
||||
'nginx': [],
|
||||
'other': []
|
||||
}
|
||||
|
||||
process_stats = {
|
||||
'gunicorn': {'count': 0, 'cpu': 0, 'memory_mb': 0},
|
||||
'celery': {'count': 0, 'cpu': 0, 'memory_mb': 0},
|
||||
'postgres': {'count': 0, 'cpu': 0, 'memory_mb': 0},
|
||||
'redis': {'count': 0, 'cpu': 0, 'memory_mb': 0},
|
||||
'nginx': {'count': 0, 'cpu': 0, 'memory_mb': 0},
|
||||
}
|
||||
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'cpu_percent', 'memory_info']):
|
||||
try:
|
||||
proc_info = proc.info
|
||||
name = proc_info['name'].lower()
|
||||
cmdline = ' '.join(proc_info['cmdline']) if proc_info['cmdline'] else ''
|
||||
cmdline_lower = cmdline.lower()
|
||||
|
||||
cpu = proc_info.get('cpu_percent', 0) or 0
|
||||
memory = proc_info.get('memory_info', None)
|
||||
memory_mb = (memory.rss / (1024**2)) if memory else 0
|
||||
|
||||
# Categorize processes
|
||||
if 'gunicorn' in cmdline_lower or 'gunicorn' in name:
|
||||
processes['gunicorn'].append({
|
||||
'pid': proc_info['pid'],
|
||||
'name': name,
|
||||
'cpu_percent': round(cpu, 2),
|
||||
'memory_mb': round(memory_mb, 2)
|
||||
})
|
||||
process_stats['gunicorn']['count'] += 1
|
||||
process_stats['gunicorn']['cpu'] += cpu
|
||||
process_stats['gunicorn']['memory_mb'] += memory_mb
|
||||
elif 'celery' in cmdline_lower or 'celery' in name:
|
||||
processes['celery'].append({
|
||||
'pid': proc_info['pid'],
|
||||
'name': name,
|
||||
'cpu_percent': round(cpu, 2),
|
||||
'memory_mb': round(memory_mb, 2)
|
||||
})
|
||||
process_stats['celery']['count'] += 1
|
||||
process_stats['celery']['cpu'] += cpu
|
||||
process_stats['celery']['memory_mb'] += memory_mb
|
||||
elif 'postgres' in name or 'postgresql' in name:
|
||||
processes['postgres'].append({
|
||||
'pid': proc_info['pid'],
|
||||
'name': name,
|
||||
'cpu_percent': round(cpu, 2),
|
||||
'memory_mb': round(memory_mb, 2)
|
||||
})
|
||||
process_stats['postgres']['count'] += 1
|
||||
process_stats['postgres']['cpu'] += cpu
|
||||
process_stats['postgres']['memory_mb'] += memory_mb
|
||||
elif 'redis' in name or 'redis-server' in name:
|
||||
processes['redis'].append({
|
||||
'pid': proc_info['pid'],
|
||||
'name': name,
|
||||
'cpu_percent': round(cpu, 2),
|
||||
'memory_mb': round(memory_mb, 2)
|
||||
})
|
||||
process_stats['redis']['count'] += 1
|
||||
process_stats['redis']['cpu'] += cpu
|
||||
process_stats['redis']['memory_mb'] += memory_mb
|
||||
elif 'nginx' in name or 'caddy' in name:
|
||||
processes['nginx'].append({
|
||||
'pid': proc_info['pid'],
|
||||
'name': name,
|
||||
'cpu_percent': round(cpu, 2),
|
||||
'memory_mb': round(memory_mb, 2)
|
||||
})
|
||||
process_stats['nginx']['count'] += 1
|
||||
process_stats['nginx']['cpu'] += cpu
|
||||
process_stats['nginx']['memory_mb'] += memory_mb
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
# Round stats
|
||||
for key in process_stats:
|
||||
process_stats[key]['cpu'] = round(process_stats[key]['cpu'], 2)
|
||||
process_stats[key]['memory_mb'] = round(process_stats[key]['memory_mb'], 2)
|
||||
|
||||
status_data['processes'] = {
|
||||
'by_stack': process_stats,
|
||||
'details': {k: v[:10] for k, v in processes.items()} # Limit details to 10 per type
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting process information: {str(e)}")
|
||||
status_data['processes'] = {'error': str(e)}
|
||||
|
||||
try:
|
||||
# Module-specific task counts
|
||||
from igny8_core.modules.planner.models import Keywords, Clusters, ContentIdeas
|
||||
from igny8_core.modules.writer.models import Tasks, Images
|
||||
|
||||
status_data['modules'] = {
|
||||
'planner': {
|
||||
'keywords': Keywords.objects.count(),
|
||||
'clusters': Clusters.objects.count(),
|
||||
'content_ideas': ContentIdeas.objects.count(),
|
||||
},
|
||||
'writer': {
|
||||
'tasks': Tasks.objects.count(),
|
||||
'images': Images.objects.count(),
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting module statistics: {str(e)}")
|
||||
status_data['modules'] = {'error': str(e)}
|
||||
|
||||
return success_response(data=status_data, request=request)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny]) # Will check admin in view
|
||||
def get_request_metrics(request, request_id):
|
||||
"""
|
||||
Get resource metrics for a specific request.
|
||||
Only accessible to admins/developers.
|
||||
"""
|
||||
# Check if user is admin/developer
|
||||
if not request.user.is_authenticated:
|
||||
return error_response(
|
||||
error='Authentication required',
|
||||
status_code=http_status.HTTP_401_UNAUTHORIZED,
|
||||
request=request
|
||||
)
|
||||
|
||||
if not (hasattr(request.user, 'is_admin_or_developer') and request.user.is_admin_or_developer()):
|
||||
return error_response(
|
||||
error='Admin access required',
|
||||
status_code=http_status.HTTP_403_FORBIDDEN,
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get metrics from cache
|
||||
from django.core.cache import cache
|
||||
metrics = cache.get(f"resource_tracking_{request_id}")
|
||||
|
||||
if not metrics:
|
||||
return error_response(
|
||||
error='Metrics not found or expired',
|
||||
status_code=http_status.HTTP_404_NOT_FOUND,
|
||||
request=request
|
||||
)
|
||||
|
||||
return success_response(data=metrics, request=request)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
def gitea_webhook(request):
|
||||
"""
|
||||
Webhook endpoint to receive push events from Gitea.
|
||||
Handles automatic deployment when code is pushed to the repository.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
try:
|
||||
# Parse webhook payload
|
||||
payload = json.loads(request.body)
|
||||
event_type = request.headers.get('X-Gitea-Event', 'push')
|
||||
|
||||
logger.info(f"[Webhook] Received {event_type} event from Gitea")
|
||||
|
||||
# Only process push events
|
||||
if event_type != 'push':
|
||||
return success_response(
|
||||
data={'status': 'ignored'},
|
||||
message=f'Event type {event_type} is not processed',
|
||||
request=request
|
||||
)
|
||||
|
||||
# Extract repository information
|
||||
repository = payload.get('repository', {})
|
||||
repo_name = repository.get('name', '')
|
||||
repo_full_name = repository.get('full_name', '')
|
||||
ref = payload.get('ref', '')
|
||||
|
||||
# Only process pushes to main branch
|
||||
if ref != 'refs/heads/main':
|
||||
logger.info(f"[Webhook] Ignoring push to {ref}, only processing main branch")
|
||||
return success_response(
|
||||
data={'status': 'ignored'},
|
||||
message=f'Push to {ref} ignored, only main branch is processed',
|
||||
request=request
|
||||
)
|
||||
|
||||
# Get commit information
|
||||
commits = payload.get('commits', [])
|
||||
commit_count = len(commits)
|
||||
pusher = payload.get('pusher', {}).get('username', 'unknown')
|
||||
|
||||
logger.info(f"[Webhook] Processing push: {commit_count} commit(s) by {pusher} to {repo_full_name}")
|
||||
|
||||
# Pull latest code - run git pull directly
|
||||
try:
|
||||
import subprocess
|
||||
logger.info(f"[Webhook] Pulling latest code...")
|
||||
# Set safe directory first
|
||||
subprocess.run(
|
||||
['git', 'config', '--global', '--add', 'safe.directory', '/data/app/igny8'],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
# Pull latest code
|
||||
result = subprocess.run(
|
||||
['git', '-C', '/data/app/igny8', 'pull', 'origin', 'main'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info(f"[Webhook] Git pull successful")
|
||||
else:
|
||||
logger.error(f"[Webhook] Git pull failed: {result.stderr}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Webhook] Git pull error: {e}")
|
||||
|
||||
# Trigger deployment - restart containers
|
||||
deployment_success = False
|
||||
deployment_error = None
|
||||
|
||||
try:
|
||||
# Try to use docker Python library first, fallback to subprocess
|
||||
try:
|
||||
import docker as docker_lib
|
||||
client = docker_lib.DockerClient(base_url='unix://var/run/docker.sock')
|
||||
|
||||
# Restart frontend container (don't restart backend from within itself)
|
||||
logger.info(f"[Webhook] Restarting frontend container...")
|
||||
frontend_container = client.containers.get("igny8_frontend")
|
||||
frontend_container.restart(timeout=30)
|
||||
logger.info(f"[Webhook] Frontend container restarted successfully")
|
||||
|
||||
# Schedule backend restart via subprocess in background (non-blocking)
|
||||
# This avoids deadlock from restarting the container we're running in
|
||||
logger.info(f"[Webhook] Scheduling backend container restart...")
|
||||
import threading
|
||||
def restart_backend():
|
||||
import time
|
||||
time.sleep(2) # Give webhook time to respond
|
||||
try:
|
||||
backend_container = client.containers.get("igny8_backend")
|
||||
backend_container.restart(timeout=30)
|
||||
logger.info(f"[Webhook] Backend container restarted successfully (delayed)")
|
||||
except Exception as e:
|
||||
logger.error(f"[Webhook] Delayed backend restart failed: {e}")
|
||||
|
||||
restart_thread = threading.Thread(target=restart_backend, daemon=True)
|
||||
restart_thread.start()
|
||||
|
||||
deployment_success = True
|
||||
|
||||
except ImportError:
|
||||
# Fallback to subprocess with docker command
|
||||
logger.info(f"[Webhook] Docker library not available, using subprocess...")
|
||||
|
||||
# Try /usr/bin/docker or docker in PATH
|
||||
docker_cmd = "/usr/bin/docker"
|
||||
import shutil
|
||||
if not os.path.exists(docker_cmd):
|
||||
docker_cmd = shutil.which("docker") or "docker"
|
||||
|
||||
# Restart backend container
|
||||
logger.info(f"[Webhook] Restarting backend container...")
|
||||
backend_result = subprocess.run(
|
||||
[docker_cmd, "restart", "igny8_backend"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if backend_result.returncode != 0:
|
||||
raise Exception(f"Backend restart failed: {backend_result.stderr}")
|
||||
logger.info(f"[Webhook] Backend container restarted successfully")
|
||||
|
||||
# Restart frontend container
|
||||
logger.info(f"[Webhook] Restarting frontend container...")
|
||||
frontend_result = subprocess.run(
|
||||
[docker_cmd, "restart", "igny8_frontend"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if frontend_result.returncode != 0:
|
||||
raise Exception(f"Frontend restart failed: {frontend_result.stderr}")
|
||||
logger.info(f"[Webhook] Frontend container restarted successfully")
|
||||
|
||||
deployment_success = True
|
||||
|
||||
logger.info(f"[Webhook] Deployment completed: containers restarted")
|
||||
|
||||
except subprocess.TimeoutExpired as e:
|
||||
deployment_error = f"Deployment timeout: {str(e)}"
|
||||
logger.error(f"[Webhook] {deployment_error}")
|
||||
except Exception as deploy_error:
|
||||
deployment_error = str(deploy_error)
|
||||
logger.error(f"[Webhook] Deployment error: {deploy_error}", exc_info=True)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'status': 'success' if deployment_success else 'partial',
|
||||
'repository': repo_full_name,
|
||||
'branch': ref,
|
||||
'commits': commit_count,
|
||||
'pusher': pusher,
|
||||
'event': event_type,
|
||||
'deployment': {
|
||||
'success': deployment_success,
|
||||
'error': deployment_error
|
||||
}
|
||||
},
|
||||
message='Webhook received and processed',
|
||||
request=request
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"[Webhook] Invalid JSON payload: {e}")
|
||||
return error_response(
|
||||
error='Invalid JSON payload',
|
||||
status_code=http_status.HTTP_400_BAD_REQUEST,
|
||||
request=request
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Webhook] Error processing webhook: {e}", exc_info=True)
|
||||
return error_response(
|
||||
error=str(e),
|
||||
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request=request
|
||||
)
|
||||
1710
tenant-temp/backend/igny8_core/modules/writer/views.py
Normal file
1710
tenant-temp/backend/igny8_core/modules/writer/views.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user