""" Сервисы для аналитики и отчетов. """ from django.db.models import Sum, Avg, Count, Q, F, Min, Max from django.utils import timezone from datetime import timedelta, datetime from decimal import Decimal import logging logger = logging.getLogger(__name__) class AnalyticsService: """Сервис для аналитики ментора.""" @staticmethod def get_detailed_lesson_stats(mentor, start_date, end_date): """ Детальная статистика по занятиям. Returns: dict: Детальная статистика """ from apps.schedule.models import Lesson lessons = Lesson.objects.filter( mentor=mentor, start_time__gte=start_date, start_time__lte=end_date ).select_related('client__user', 'subject') # Общая статистика total_lessons = lessons.count() completed_lessons = lessons.filter(status='completed') cancelled_lessons = lessons.filter(status='cancelled') # Время занятий total_duration_minutes = completed_lessons.aggregate( total=Sum('duration') )['total'] or 0 avg_duration = completed_lessons.aggregate( avg=Avg('duration') )['avg'] or 0 # Доходы total_revenue = completed_lessons.filter( price__isnull=False ).aggregate( total=Sum('price') )['total'] or Decimal('0') avg_price = completed_lessons.filter( price__isnull=False ).aggregate( avg=Avg('price') )['avg'] or Decimal('0') # Оценки graded_lessons = completed_lessons.filter(mentor_grade__isnull=False) avg_grade = graded_lessons.aggregate(avg=Avg('mentor_grade'))['avg'] or 0 max_grade = graded_lessons.aggregate(max=Max('mentor_grade'))['max'] or 0 min_grade = graded_lessons.aggregate(min=Min('mentor_grade'))['min'] or 0 # По часам дня # Оптимизация: используем один запрос с Extract вместо 24 запросов count() from django.db.models.functions import ExtractHour by_hour_data = completed_lessons.annotate( hour=ExtractHour('start_time') ).values('hour').annotate(count=Count('id')).order_by('hour') # Создаем словарь для быстрого доступа by_hour_dict = {item['hour']: item['count'] for item in by_hour_data} # Заполняем все 24 часа (если для какого-то часа нет данных, count = 0) by_hour = [ { 'hour': hour, 'count': by_hour_dict.get(hour, 0), } for hour in range(24) ] # По времени суток # Оптимизация: используем один запрос с фильтрацией вместо 4 запросов count() time_of_day_stats = completed_lessons.aggregate( morning=Count('id', filter=Q(start_time__hour__gte=6, start_time__hour__lt=12)), afternoon=Count('id', filter=Q(start_time__hour__gte=12, start_time__hour__lt=18)), evening=Count('id', filter=Q(start_time__hour__gte=18, start_time__hour__lt=24)), night=Count('id', filter=Q(start_time__hour__gte=0, start_time__hour__lt=6)) ) morning = time_of_day_stats['morning'] or 0 afternoon = time_of_day_stats['afternoon'] or 0 evening = time_of_day_stats['evening'] or 0 night = time_of_day_stats['night'] or 0 # Топ клиентов по количеству занятий top_clients = lessons.values( 'client__user__first_name', 'client__user__last_name', 'client__user__email' ).annotate( lessons_count=Count('id'), completed_count=Count('id', filter=Q(status='completed')), total_revenue=Sum('price', filter=Q(status='completed', price__isnull=False)), avg_grade=Avg('mentor_grade', filter=Q(status='completed', mentor_grade__isnull=False)) ).order_by('-lessons_count')[:10] return { 'summary': { 'total_lessons': total_lessons, 'completed': completed_lessons.count(), 'cancelled': cancelled_lessons.count(), 'completion_rate': round((completed_lessons.count() / total_lessons * 100) if total_lessons > 0 else 0, 1), }, 'time': { 'total_duration_minutes': total_duration_minutes, 'total_duration_hours': round(total_duration_minutes / 60, 1), 'average_duration_minutes': round(avg_duration, 1), 'by_hour': by_hour, 'by_time_of_day': { 'morning': morning, 'afternoon': afternoon, 'evening': evening, 'night': night, }, }, 'revenue': { 'total': float(total_revenue), 'average_per_lesson': float(avg_price), 'potential_revenue': float( cancelled_lessons.filter(price__isnull=False).aggregate( total=Sum('price') )['total'] or Decimal('0') ), }, 'grades': { 'average': round(avg_grade, 1), 'max': max_grade, 'min': min_grade, 'graded_count': graded_lessons.count(), }, 'top_clients': [ { 'name': f"{item['client__user__first_name']} {item['client__user__last_name']}".strip() or item['client__user__email'], 'email': item['client__user__email'], 'lessons_count': item['lessons_count'], 'completed_count': item['completed_count'], 'total_revenue': float(item['total_revenue'] or 0), 'average_grade': round(item['avg_grade'] or 0, 1), } for item in top_clients ], } @staticmethod def get_time_series_data(mentor, start_date, end_date, group_by='day'): """ Временные ряды для графиков. Args: group_by: 'day', 'week', 'month' """ from apps.schedule.models import Lesson lessons = Lesson.objects.filter( mentor=mentor, start_time__gte=start_date, start_time__lte=end_date, status='completed' ) if group_by == 'day': # Группируем по дням data = lessons.extra( select={'date': "DATE(start_time AT TIME ZONE 'UTC')"} ).values('date').annotate( lessons_count=Count('id'), revenue=Sum('price', filter=Q(price__isnull=False)), avg_duration=Avg('duration'), avg_grade=Avg('mentor_grade', filter=Q(mentor_grade__isnull=False)) ).order_by('date') return [ { 'date': item['date'].strftime('%Y-%m-%d'), 'lessons_count': item['lessons_count'], 'revenue': float(item['revenue'] or 0), 'avg_duration': round(item['avg_duration'] or 0, 1), 'avg_grade': round(item['avg_grade'] or 0, 1), } for item in data ] elif group_by == 'week': # Группируем по неделям data = lessons.extra( select={'week': "DATE_TRUNC('week', start_time AT TIME ZONE 'UTC')"} ).values('week').annotate( lessons_count=Count('id'), revenue=Sum('price', filter=Q(price__isnull=False)), avg_duration=Avg('duration'), avg_grade=Avg('mentor_grade', filter=Q(mentor_grade__isnull=False)) ).order_by('week') return [ { 'week': item['week'].strftime('%Y-W%W'), 'week_start': item['week'].strftime('%Y-%m-%d'), 'lessons_count': item['lessons_count'], 'revenue': float(item['revenue'] or 0), 'avg_duration': round(item['avg_duration'] or 0, 1), 'avg_grade': round(item['avg_grade'] or 0, 1), } for item in data ] elif group_by == 'month': # Группируем по месяцам data = lessons.extra( select={'month': "DATE_TRUNC('month', start_time AT TIME ZONE 'UTC')"} ).values('month').annotate( lessons_count=Count('id'), revenue=Sum('price', filter=Q(price__isnull=False)), avg_duration=Avg('duration'), avg_grade=Avg('mentor_grade', filter=Q(mentor_grade__isnull=False)) ).order_by('month') return [ { 'month': item['month'].strftime('%Y-%m'), 'month_name': item['month'].strftime('%B %Y'), 'lessons_count': item['lessons_count'], 'revenue': float(item['revenue'] or 0), 'avg_duration': round(item['avg_duration'] or 0, 1), 'avg_grade': round(item['avg_grade'] or 0, 1), } for item in data ] return [] @staticmethod def get_comparison_data(mentor, current_start, current_end, previous_start, previous_end): """ Сравнение текущего периода с предыдущим. """ from apps.schedule.models import Lesson # Текущий период current_lessons = Lesson.objects.filter( mentor=mentor, start_time__gte=current_start, start_time__lte=current_end, status='completed' ) current_stats = { 'lessons_count': current_lessons.count(), 'revenue': float( current_lessons.filter(price__isnull=False).aggregate( total=Sum('price') )['total'] or Decimal('0') ), 'avg_duration': current_lessons.aggregate(avg=Avg('duration'))['avg'] or 0, 'avg_grade': current_lessons.filter(mentor_grade__isnull=False).aggregate( avg=Avg('mentor_grade') )['avg'] or 0, } # Предыдущий период previous_lessons = Lesson.objects.filter( mentor=mentor, start_time__gte=previous_start, start_time__lte=previous_end, status='completed' ) previous_stats = { 'lessons_count': previous_lessons.count(), 'revenue': float( previous_lessons.filter(price__isnull=False).aggregate( total=Sum('price') )['total'] or Decimal('0') ), 'avg_duration': previous_lessons.aggregate(avg=Avg('duration'))['avg'] or 0, 'avg_grade': previous_lessons.filter(mentor_grade__isnull=False).aggregate( avg=Avg('mentor_grade') )['avg'] or 0, } # Вычисляем изменения def calculate_change(current, previous): if previous == 0: return 100.0 if current > 0 else 0.0 return round(((current - previous) / previous) * 100, 1) return { 'current': current_stats, 'previous': previous_stats, 'changes': { 'lessons_count': calculate_change( current_stats['lessons_count'], previous_stats['lessons_count'] ), 'revenue': calculate_change( current_stats['revenue'], previous_stats['revenue'] ), 'avg_duration': calculate_change( current_stats['avg_duration'], previous_stats['avg_duration'] ), 'avg_grade': calculate_change( current_stats['avg_grade'], previous_stats['avg_grade'] ), }, } @staticmethod def get_overview_data(mentor, start_date, end_date): """ Получить данные для overview отчета. Args: mentor: Пользователь-ментор start_date: Начало периода end_date: Конец периода Returns: dict: Данные overview """ from apps.schedule.models import Lesson from apps.users.models import Client from apps.homework.models import Homework, HomeworkSubmission # Занятия lessons = Lesson.objects.filter( mentor=mentor, start_time__gte=start_date, start_time__lte=end_date ) total_lessons = lessons.count() completed_lessons = lessons.filter(status='completed').count() cancelled_lessons = lessons.filter(status='cancelled').count() # Доходы total_revenue = lessons.filter( status='completed', price__isnull=False ).aggregate(total=Sum('price'))['total'] or Decimal('0') # Ученики active_students = Client.objects.filter( mentors=mentor ).count() # Домашние задания homeworks = Homework.objects.filter( mentor=mentor, created_at__gte=start_date, created_at__lte=end_date ) total_homeworks = homeworks.count() pending_submissions = HomeworkSubmission.objects.filter( homework__mentor=mentor, status='pending' ).count() # Средний балл average_grade = lessons.filter( status='completed', mentor_grade__isnull=False ).aggregate(avg=Avg('mentor_grade'))['avg'] or 0 return { 'period': { 'start': start_date.isoformat(), 'end': end_date.isoformat(), }, 'lessons': { 'total': total_lessons, 'completed': completed_lessons, 'cancelled': cancelled_lessons, }, 'revenue': { 'total': float(total_revenue), 'average_per_lesson': float(total_revenue / completed_lessons) if completed_lessons > 0 else 0, }, 'students': { 'active': active_students, }, 'homeworks': { 'total': total_homeworks, 'pending': pending_submissions, }, 'grades': { 'average': round(average_grade, 1), }, } @staticmethod def get_revenue_data(mentor, start_date, end_date): """ Получить данные для revenue отчета. Args: mentor: Пользователь-ментор start_date: Начало периода end_date: Конец периода Returns: dict: Данные revenue """ from apps.schedule.models import Lesson # Доходы по дням lessons = Lesson.objects.filter( mentor=mentor, start_time__gte=start_date, start_time__lte=end_date, status='completed', price__isnull=False ) # Группируем по дням revenue_by_day = lessons.extra( select={'day': "DATE(start_time AT TIME ZONE 'UTC')"} ).values('day').annotate( revenue=Sum('price'), lessons_count=Count('id') ).order_by('day') # Доходы по предметам revenue_by_subject = lessons.values('subject__name').annotate( revenue=Sum('price', filter=Q(price__isnull=False)), lessons_count=Count('id') ).order_by('-revenue')[:5] total_revenue = lessons.aggregate(total=Sum('price'))['total'] or Decimal('0') total_lessons = lessons.count() return { 'total_revenue': float(total_revenue), 'total_lessons': total_lessons, 'average_per_lesson': float(total_revenue / total_lessons) if total_lessons > 0 else 0, 'by_day': [ { 'date': item['day'].strftime('%Y-%m-%d'), 'revenue': float(item['revenue']), 'lessons_count': item['lessons_count'], } for item in revenue_by_day ], 'by_subject': [ { 'subject': item['subject__name'] or 'Без предмета', 'revenue': float(item['revenue'] or 0), 'lessons_count': item['lessons_count'], } for item in revenue_by_subject ], }