""" API views для аналитики и отчетов. """ import logging 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.db import models from django.db.models import Sum, Avg, Count, Q, F from django.db.models.functions import TruncDate from django.utils import timezone from django.core.cache import cache from datetime import timedelta, datetime from django.http import HttpResponse from apps.users.models import User, Client from apps.schedule.models import Lesson from apps.homework.models import Homework, HomeworkSubmission from apps.materials.models import Material from apps.users.utils import format_datetime_for_user from .services import AnalyticsService logger = logging.getLogger(__name__) class AnalyticsViewSet(viewsets.ViewSet): """ ViewSet для аналитики и отчетов ментора. """ permission_classes = [IsAuthenticated] def _check_mentor(self, request): """Проверка что пользователь - ментор""" if request.user.role != 'mentor': return False return True def _get_period_dates(self, request): """Получить даты начала и конца периода""" period = request.query_params.get('period', 'month') start_date_str = request.query_params.get('start_date') end_date_str = request.query_params.get('end_date') now = timezone.now() if period == 'day': start_date = now.replace(hour=0, minute=0, second=0, microsecond=0) end_date = now elif period == 'week': start_date = now - timedelta(days=now.weekday()) start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0) end_date = now elif period == 'month': start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) end_date = now elif period == 'year': start_date = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0) end_date = now elif period == 'custom' and start_date_str and end_date_str: start_date = timezone.make_aware(datetime.strptime(start_date_str, '%Y-%m-%d')) end_date = timezone.make_aware(datetime.strptime(end_date_str, '%Y-%m-%d')) end_date = end_date.replace(hour=23, minute=59, second=59) else: # По умолчанию - текущий месяц start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) end_date = now return start_date, end_date @action(detail=False, methods=['get']) def overview(self, request): """ Общая статистика ментора. GET /api/analytics/overview/?period=month&start_date=2024-01-01&end_date=2024-12-31 """ if not self._check_mentor(request): return Response( {'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN ) mentor = request.user start_date, end_date = self._get_period_dates(request) period = request.query_params.get('period', 'month') # Кеширование: кеш на 2 минуты для каждого пользователя и периода cache_key = f'analytics_overview_{mentor.id}_{period}' cached_data = cache.get(cache_key) if cached_data is not None: return Response(cached_data) # Занятия - оптимизация: используем only() для ограничения полей lessons = Lesson.objects.filter( mentor=mentor, start_time__gte=start_date, start_time__lte=end_date ).only('id', 'status', 'price', 'mentor_grade', 'start_time') # Оптимизация: один запрос для всех статистик занятий lessons_stats = lessons.aggregate( total=Count('id'), completed=Count('id', filter=Q(status='completed')), cancelled=Count('id', filter=Q(status='cancelled')), total_revenue=Sum('price', filter=Q(status='completed', price__isnull=False)), avg_grade=Avg('mentor_grade', filter=Q(status='completed', mentor_grade__isnull=False)) ) total_lessons = lessons_stats['total'] completed_lessons = lessons_stats['completed'] cancelled_lessons = lessons_stats['cancelled'] total_revenue = lessons_stats['total_revenue'] or 0 average_grade = lessons_stats['avg_grade'] or 0 # Ученики - оптимизация: используем exists() вместо count() если нужно только проверить наличие active_students = Client.objects.filter( mentors=mentor ).select_related('user').count() # Домашние задания - оптимизация: используем aggregate для подсчета homeworks_stats = Homework.objects.filter( mentor=mentor, created_at__gte=start_date, created_at__lte=end_date ).aggregate(total=Count('id')) total_homeworks = homeworks_stats['total'] or 0 # Пending submissions - оптимизация: используем aggregate pending_submissions = HomeworkSubmission.objects.filter( homework__mentor=mentor, status='pending' ).aggregate(count=Count('id'))['count'] or 0 response_data = { 'period': { 'start': format_datetime_for_user(start_date, request.user.timezone) if start_date else None, 'end': format_datetime_for_user(end_date, request.user.timezone) if end_date else None, }, '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), }, } # Сохраняем в кеш на 5 минут (300 секунд) - аналитика не требует мгновенного обновления cache.set(cache_key, response_data, 300) return Response(response_data) @action(detail=False, methods=['get']) def students(self, request): """ Статистика по ученикам. GET /api/analytics/students/ """ if not self._check_mentor(request): return Response( {'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN ) mentor = request.user start_date, end_date = self._get_period_dates(request) # Оптимизация: получаем только ID студентов одним запросом student_ids = list(Client.objects.filter(mentors=mentor).values_list('id', flat=True)) if not student_ids: return Response({ 'students': [], 'total_count': 0, }) # Оптимизация: batch-запрос для всех статистик студентов students_lessons_stats = Lesson.objects.filter( mentor=mentor, client_id__in=student_ids, start_time__gte=start_date, start_time__lte=end_date ).values('client_id').annotate( total=Count('id'), completed=Count('id', filter=Q(status='completed')), avg_grade=Avg('mentor_grade', filter=Q(status='completed', mentor_grade__isnull=False)), revenue=Sum('price', filter=Q(status='completed', price__isnull=False)) ) lessons_by_student = {item['client_id']: item for item in students_lessons_stats} # Получаем данные студентов только для тех, у кого есть статистика students = Client.objects.filter( id__in=student_ids ).select_related('user').only('id', 'user__first_name', 'user__last_name', 'user__email') students_data = [] for student in students: stats = lessons_by_student.get(student.id, { 'total': 0, 'completed': 0, 'avg_grade': 0, 'revenue': 0 }) total = stats['total'] completed = stats['completed'] avg_grade = stats['avg_grade'] or 0 revenue = stats['revenue'] or 0 students_data.append({ 'id': student.id, 'name': student.user.get_full_name(), 'email': student.user.email, 'lessons_total': total, 'lessons_completed': completed, 'average_grade': round(avg_grade, 1), 'revenue': float(revenue), }) # Сортируем по доходу students_data.sort(key=lambda x: x['revenue'], reverse=True) return Response({ 'students': students_data, 'total_count': len(students_data), }) @action(detail=False, methods=['get']) def revenue(self, request): """ Финансовая аналитика. GET /api/analytics/revenue/ """ if not self._check_mentor(request): return Response( {'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN ) mentor = request.user start_date, end_date = self._get_period_dates(request) period = request.query_params.get('period', 'month') # Кеширование: кеш на 2 минуты для каждого пользователя и периода cache_key = f'analytics_revenue_{mentor.id}_{period}' cached_data = cache.get(cache_key) if cached_data is not None: return Response(cached_data) # Доходы по дням lessons = Lesson.objects.filter( mentor=mentor, start_time__gte=start_date, start_time__lte=end_date, status='completed', price__isnull=False ).select_related('subject').only('start_time', 'price', 'subject__name') # Группируем по дням 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 0 response_data = { 'total_revenue': float(total_revenue), 'by_day': [ { 'date': item['day'], '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']), 'lessons_count': item['lessons_count'], } for item in revenue_by_subject ], } # Сохраняем в кеш на 5 минут (300 секунд) - аналитика не требует мгновенного обновления cache.set(cache_key, response_data, 300) return Response(response_data) @action(detail=False, methods=['get']) def grades_by_day(self, request): """ Средняя оценка по дням за период (успех учеников / продуктивность репетитора). GET /api/analytics/grades_by_day/?period=custom&start_date=2024-01-01&end_date=2024-01-31 Возвращает по каждому дню: дата, средняя оценка, количество занятий и оцененных занятий. Репетитор видит прогресс и общую продуктивность по оценкам за выбранные дни. """ if not self._check_mentor(request): return Response( {'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN ) mentor = request.user start_date, end_date = self._get_period_dates(request) period = request.query_params.get('period', 'month') cache_key = f'analytics_grades_by_day_{mentor.id}_{period}_{start_date.date()}_{end_date.date()}' cached_data = cache.get(cache_key) if cached_data is not None: return Response(cached_data) lessons = Lesson.objects.filter( mentor=mentor, start_time__gte=start_date, start_time__lte=end_date, status='completed', ).only('id', 'start_time', 'mentor_grade') by_day_qs = ( lessons.annotate(day=TruncDate('start_time')) .values('day') .annotate( average_grade=Avg('mentor_grade', filter=Q(mentor_grade__isnull=False)), lessons_count=Count('id'), graded_count=Count('id', filter=Q(mentor_grade__isnull=False)), ) .order_by('day') ) stats_by_date = {} for item in by_day_qs: day = item['day'] if day is not None: stats_by_date[day.strftime('%Y-%m-%d')] = { 'average_grade': item['average_grade'], 'lessons_count': item['lessons_count'], 'graded_count': item['graded_count'], } # Одна запись на каждый календарный день в диапазоне — без «склейки» и пропусков start_d = start_date.date() end_d = end_date.date() by_day = [] d = start_d while d <= end_d: date_str = d.strftime('%Y-%m-%d') st = stats_by_date.get(date_str) if st: avg = st['average_grade'] by_day.append({ 'date': date_str, 'average_grade': round(float(avg), 1) if avg is not None else None, 'lessons_count': st['lessons_count'], 'graded_count': st['graded_count'], }) else: by_day.append({ 'date': date_str, 'average_grade': None, 'lessons_count': 0, 'graded_count': 0, }) d += timedelta(days=1) summary_agg = lessons.aggregate( total_lessons=Count('id'), graded_lessons=Count('id', filter=Q(mentor_grade__isnull=False)), average_grade=Avg('mentor_grade', filter=Q(mentor_grade__isnull=False)), ) summary = { 'total_lessons': summary_agg['total_lessons'] or 0, 'graded_lessons': summary_agg['graded_lessons'] or 0, 'average_grade': round(float(summary_agg['average_grade'] or 0), 1), } response_data = { 'period': { 'start': format_datetime_for_user(start_date, request.user.timezone) if start_date else None, 'end': format_datetime_for_user(end_date, request.user.timezone) if end_date else None, }, 'by_day': by_day, 'summary': summary, } cache.set(cache_key, response_data, 300) return Response(response_data) @action(detail=False, methods=['get']) def lessons_stats(self, request): """ Статистика занятий. GET /api/analytics/lessons_stats/ """ if not self._check_mentor(request): return Response( {'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN ) mentor = request.user start_date, end_date = self._get_period_dates(request) lessons = Lesson.objects.filter( mentor=mentor, start_time__gte=start_date, start_time__lte=end_date ) # По статусам by_status = lessons.values('status').annotate( count=Count('id') ) # По предметам by_subject = lessons.values('subject__name').annotate( count=Count('id') ).order_by('-count')[:10] # По дням недели by_weekday = lessons.extra( select={'weekday': "EXTRACT(DOW FROM start_time)"} ).values('weekday').annotate( count=Count('id') ).order_by('weekday') weekday_names = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'] return Response({ 'by_status': list(by_status), 'by_subject': [ { 'subject': item['subject__name'] or 'Без предмета', 'count': item['count'], } for item in by_subject ], 'by_weekday': [ { 'day': weekday_names[int(item['weekday'])], 'count': item['count'], } for item in by_weekday ], }) @action(detail=False, methods=['get']) def homework_stats(self, request): """ Статистика по домашним заданиям. GET /api/analytics/homework_stats/ """ if not self._check_mentor(request): return Response( {'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN ) mentor = request.user start_date, end_date = self._get_period_dates(request) # Домашние задания homeworks = Homework.objects.filter( mentor=mentor, created_at__gte=start_date, created_at__lte=end_date ) # Сдачи submissions = HomeworkSubmission.objects.filter( homework__mentor=mentor, submitted_at__gte=start_date, submitted_at__lte=end_date ) # Статистика total_homeworks = homeworks.count() total_submissions = submissions.count() graded_submissions = submissions.filter(status='graded').count() average_score = submissions.filter( status='graded' ).aggregate(avg=Avg('score'))['avg'] or 0 # По статусам by_status = submissions.values('status').annotate( count=Count('id') ) return Response({ 'total_homeworks': total_homeworks, 'total_submissions': total_submissions, 'graded': graded_submissions, 'average_score': round(average_score, 1), 'by_status': list(by_status), }) @action(detail=False, methods=['get']) def detailed_lessons(self, request): """ Детальная статистика по занятиям. GET /api/analytics/detailed_lessons/?period=month """ if not self._check_mentor(request): return Response( {'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN ) mentor = request.user start_date, end_date = self._get_period_dates(request) period = request.query_params.get('period', 'month') # Кеширование: кеш на 2 минуты для каждого пользователя и периода cache_key = f'analytics_detailed_lessons_{mentor.id}_{period}' cached_data = cache.get(cache_key) if cached_data is not None: return Response(cached_data) stats = AnalyticsService.get_detailed_lesson_stats(mentor, start_date, end_date) # Сохраняем в кеш на 2 минуты (120 секунд) cache.set(cache_key, stats, 120) return Response(stats) @action(detail=False, methods=['get']) def time_series(self, request): """ Временные ряды для графиков. GET /api/analytics/time_series/?period=month&group_by=day """ if not self._check_mentor(request): return Response( {'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN ) mentor = request.user start_date, end_date = self._get_period_dates(request) period = request.query_params.get('period', 'month') group_by = request.query_params.get('group_by', 'day') if group_by not in ['day', 'week', 'month']: group_by = 'day' # Кеширование: кеш на 2 минуты для каждого пользователя, периода и группировки cache_key = f'analytics_time_series_{mentor.id}_{period}_{group_by}' cached_data = cache.get(cache_key) if cached_data is not None: return Response(cached_data) data = AnalyticsService.get_time_series_data(mentor, start_date, end_date, group_by) response_data = { 'group_by': group_by, 'data': data, } # Сохраняем в кеш на 5 минут (300 секунд) - аналитика не требует мгновенного обновления cache.set(cache_key, response_data, 300) return Response(response_data) @action(detail=False, methods=['get']) def comparison(self, request): """ Сравнение текущего периода с предыдущим. GET /api/analytics/comparison/?period=month """ if not self._check_mentor(request): return Response( {'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN ) mentor = request.user current_start, current_end = self._get_period_dates(request) period = request.query_params.get('period', 'month') # Кеширование: кеш на 2 минуты для каждого пользователя и периода cache_key = f'analytics_comparison_{mentor.id}_{period}' cached_data = cache.get(cache_key) if cached_data is not None: return Response(cached_data) # Вычисляем предыдущий период now = timezone.now() if period == 'day': previous_start = current_start - timedelta(days=1) previous_end = current_start elif period == 'week': previous_start = current_start - timedelta(weeks=1) previous_end = current_start elif period == 'month': # Предыдущий месяц if current_start.month == 1: previous_start = current_start.replace(year=current_start.year - 1, month=12, day=1) else: previous_start = current_start.replace(month=current_start.month - 1, day=1) previous_end = current_start elif period == 'year': previous_start = current_start.replace(year=current_start.year - 1, month=1, day=1) previous_end = current_start else: # По умолчанию - предыдущий месяц if current_start.month == 1: previous_start = current_start.replace(year=current_start.year - 1, month=12, day=1) else: previous_start = current_start.replace(month=current_start.month - 1, day=1) previous_end = current_start comparison = AnalyticsService.get_comparison_data( mentor, current_start, current_end, previous_start, previous_end ) # Сохраняем в кеш на 2 минуты (120 секунд) cache.set(cache_key, comparison, 120) return Response(comparison) @action(detail=False, methods=['get']) def export_pdf(self, request): """ Экспорт отчета в PDF. GET /api/analytics/export_pdf/?period=month&type=overview """ if not self._check_mentor(request): return Response( {'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN ) from .exporters import PDFExporter mentor = request.user start_date, end_date = self._get_period_dates(request) report_type = request.query_params.get('type', 'overview') try: pdf_buffer = PDFExporter.generate_report( mentor=mentor, start_date=start_date, end_date=end_date, report_type=report_type ) response = HttpResponse(pdf_buffer.getvalue(), content_type='application/pdf') filename = f"analytics_report_{start_date.strftime('%Y%m%d')}_{end_date.strftime('%Y%m%d')}.pdf" response['Content-Disposition'] = f'attachment; filename="{filename}"' return response except Exception as e: logger.error(f"Error generating PDF report: {e}", exc_info=True) return Response( {'error': f'Ошибка генерации PDF: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) @action(detail=False, methods=['get']) def export_excel(self, request): """ Экспорт отчета в Excel. GET /api/analytics/export_excel/?period=month&type=overview """ if not self._check_mentor(request): return Response( {'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN ) from .exporters import ExcelExporter mentor = request.user start_date, end_date = self._get_period_dates(request) report_type = request.query_params.get('type', 'overview') try: excel_buffer = ExcelExporter.generate_report( mentor=mentor, start_date=start_date, end_date=end_date, report_type=report_type ) response = HttpResponse( excel_buffer.getvalue(), content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) filename = f"analytics_report_{start_date.strftime('%Y%m%d')}_{end_date.strftime('%Y%m%d')}.xlsx" response['Content-Disposition'] = f'attachment; filename="{filename}"' return response except Exception as e: logger.error(f"Error generating Excel report: {e}", exc_info=True) return Response( {'error': f'Ошибка генерации Excel: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR )