uchill/backend/apps/analytics/services.py

467 lines
18 KiB
Python
Raw Permalink 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.

"""
Сервисы для аналитики и отчетов.
"""
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
],
}