uchill/backend/apps/analytics/views.py

738 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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
)