uchill/backend/apps/users/dashboard_views.py

1256 lines
55 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 для личных кабинетов и дашбордов.
"""
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.utils import timezone
from django.core.cache import cache
from datetime import timedelta
import calendar
import logging
from collections import defaultdict
from .utils import format_datetime_for_user
from .models import User, Client, Parent
from apps.schedule.models import Lesson
from apps.homework.models import Homework, HomeworkSubmission
from apps.materials.models import Material
from apps.notifications.models import Notification
logger = logging.getLogger(__name__)
class MentorDashboardViewSet(viewsets.ViewSet):
"""
ViewSet для дашборда ментора.
dashboard: Основная статистика
clients: Список клиентов
upcoming_lessons: Предстоящие занятия
statistics: Детальная статистика
recent_activity: Недавняя активность
"""
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
def dashboard(self, request):
"""
Основной дашборд ментора.
GET /api/users/mentor/dashboard/
"""
user = request.user
if user.role != 'mentor':
return Response(
{'error': 'Только для менторов'},
status=status.HTTP_403_FORBIDDEN
)
# Кеширование: кеш на 30 секунд для каждого пользователя (для актуальности уведомлений)
cache_key = f'mentor_dashboard_{user.id}'
cached_data = cache.get(cache_key)
if cached_data is not None:
return Response(cached_data)
# Временные рамки
now = timezone.now()
week_ago = now - timedelta(days=7)
# Текущий календарный месяц (с 1 по последний день месяца)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# Конец текущего месяца (последний день месяца, 23:59:59.999999)
last_day = calendar.monthrange(now.year, now.month)[1]
month_end = now.replace(day=last_day, hour=23, minute=59, second=59, microsecond=999999)
# Клиенты - один запрос
clients = Client.objects.filter(mentors=user).select_related('user').prefetch_related('mentors')
total_clients = clients.count()
# Занятия - оптимизация: используем aggregate для всех подсчетов
from django.db.models import Count, Sum, Q
lessons = Lesson.objects.filter(mentor=user.id).select_related(
'mentor', 'client', 'client__user', 'subject', 'mentor_subject'
)
# Один запрос для всех подсчетов занятий
lessons_stats = lessons.aggregate(
total=Count('id'),
this_week=Count('id', filter=Q(start_time__gte=week_ago)),
this_month=Count('id', filter=Q(start_time__gte=month_start) & Q(start_time__lte=month_end)),
completed=Count('id', filter=Q(status='completed'))
)
total_lessons = lessons_stats['total']
lessons_this_week = lessons_stats['this_week']
lessons_this_month = lessons_stats['this_month']
completed_lessons = lessons_stats['completed']
# Ближайшие занятия
upcoming_lessons = lessons.filter(
start_time__gte=now,
status__in=['scheduled', 'in_progress']
).select_related('client', 'client__user', 'subject', 'mentor_subject').order_by('start_time')[:5]
# Домашние задания - один запрос
homeworks = Homework.objects.filter(mentor=user.id).select_related('mentor', 'lesson')
total_homeworks = homeworks.count()
# Отправки ДЗ - один запрос
pending_submissions = HomeworkSubmission.objects.filter(
homework__mentor=user,
status='pending'
).select_related('homework', 'student').count()
# Последние сданные ДЗ (4 шт)
recent_submissions = HomeworkSubmission.objects.filter(
homework__mentor=user,
status__in=['submitted', 'graded', 'returned']
).select_related('homework', 'homework__lesson', 'homework__lesson__subject', 'homework__lesson__mentor_subject', 'student').order_by('-submitted_at')[:4]
# Материалы - один запрос
materials = Material.objects.filter(owner=user, is_deleted=False).select_related('owner')
total_materials = materials.count()
# Уведомления - один запрос (только in_app уведомления)
from apps.notifications.models import Notification
unread_notifications = Notification.objects.filter(
recipient=user,
is_read=False,
channel='in_app' # Только in_app уведомления
).select_related('recipient').count()
# Доходы - оптимизация: один запрос для обоих подсчетов
revenue_stats = lessons.filter(
status='completed'
).exclude(price__isnull=True).exclude(price=0).aggregate(
total=Sum('price'),
this_month=Sum('price', filter=Q(start_time__gte=month_start))
)
total_revenue = revenue_stats['total'] or 0
revenue_this_month = revenue_stats['this_month'] or 0
response_data = {
'summary': {
'total_clients': total_clients,
'total_lessons': total_lessons,
'lessons_this_week': lessons_this_week,
'lessons_this_month': lessons_this_month,
'completed_lessons': completed_lessons,
'total_homeworks': total_homeworks,
'pending_submissions': pending_submissions,
'total_materials': total_materials,
'unread_notifications': unread_notifications,
'total_revenue': float(total_revenue),
'revenue_this_month': float(revenue_this_month)
},
'upcoming_lessons': [
{
'id': str(lesson.id),
'title': lesson.title,
'subject': (
lesson.subject.name if lesson.subject
else lesson.mentor_subject.name if lesson.mentor_subject
else lesson.subject_name if lesson.subject_name
else ''
),
'client': {
'id': str(lesson.client.id),
'name': lesson.client.user.get_full_name() if lesson.client.user else 'Студент',
'avatar': request.build_absolute_uri(lesson.client.user.avatar.url) if lesson.client.user and lesson.client.user.avatar else None,
'first_name': lesson.client.user.first_name if lesson.client.user else '',
'last_name': lesson.client.user.last_name if lesson.client.user else ''
},
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
}
for lesson in upcoming_lessons
],
'recent_submissions': [
{
'id': str(submission.id),
'homework': {
'id': str(submission.homework.id),
'title': submission.homework.title,
},
'subject': (
submission.homework.lesson.subject.name if submission.homework.lesson and submission.homework.lesson.subject
else submission.homework.lesson.mentor_subject.name if submission.homework.lesson and submission.homework.lesson.mentor_subject
else submission.homework.lesson.subject_name if submission.homework.lesson and submission.homework.lesson.subject_name
else ''
),
'student': {
'id': str(submission.student.id),
'name': submission.student.get_full_name() if submission.student else 'Студент',
'avatar': request.build_absolute_uri(submission.student.avatar.url) if submission.student and submission.student.avatar else None,
'first_name': submission.student.first_name if submission.student else '',
'last_name': submission.student.last_name if submission.student else ''
},
'status': submission.status,
'score': submission.score,
'max_score': submission.homework.max_score,
'submitted_at': format_datetime_for_user(submission.submitted_at, request.user.timezone) if submission.submitted_at else None,
}
for submission in recent_submissions
]
}
# Сохраняем в кеш на 30 секунд для актуальности уведомлений
cache.set(cache_key, response_data, 30)
return Response(response_data)
@action(detail=False, methods=['get'])
def clients(self, request):
"""
Список клиентов ментора.
GET /api/users/mentor/clients/
"""
user = request.user
if user.role != 'mentor':
return Response(
{'error': 'Только для менторов'},
status=status.HTTP_403_FORBIDDEN
)
clients = Client.objects.filter(mentors=user).select_related('user').prefetch_related('mentors')
# Оптимизация: получаем все данные одним batch-запросом
client_ids = [client.id for client in clients]
client_user_ids = [client.user.id for client in clients]
# Оптимизация: один запрос для всех статистик занятий всех клиентов
from django.db.models import Count, Avg, Q
now = timezone.now()
# Исправление: используем client__user_id для фильтрации по пользователям клиентов
lessons_stats = Lesson.objects.filter(
client__user_id__in=client_user_ids,
mentor=user
).values('client__user_id').annotate(
total=Count('id'),
completed=Count('id', filter=Q(status='completed')),
upcoming=Count('id', filter=Q(start_time__gte=now, status='confirmed'))
)
lessons_by_client = {item['client__user_id']: item for item in lessons_stats}
# Оптимизация: один запрос для всех статистик ДЗ всех клиентов
submissions_stats = HomeworkSubmission.objects.filter(
homework__mentor=user,
student_id__in=client_user_ids
).values('student_id').annotate(
total=Count('id'),
completed=Count('id', filter=Q(status='graded', passed=True)),
avg_score=Avg('score', filter=Q(status='graded'))
)
submissions_by_client = {item['student_id']: item for item in submissions_stats}
# Оптимизация: один запрос для последних занятий всех клиентов
from django.db.models import Max
last_lessons = Lesson.objects.filter(
client__user_id__in=client_user_ids,
mentor=user
).values('client__user_id').annotate(
last_lesson_time=Max('start_time')
)
last_lessons_by_client = {item['client__user_id']: item['last_lesson_time'] for item in last_lessons}
clients_data = []
for client in clients:
# Получаем статистику из предзагруженных данных
lesson_stats = lessons_by_client.get(client.user.id, {'total': 0, 'completed': 0, 'upcoming': 0})
submission_stats = submissions_by_client.get(client.user.id, {'total': 0, 'completed': 0, 'avg_score': 0})
total_lessons = lesson_stats['total']
completed_lessons = lesson_stats['completed']
upcoming_lessons = lesson_stats['upcoming']
total_homeworks = submission_stats['total']
completed_homeworks = submission_stats['completed']
average_score = submission_stats['avg_score'] or 0
clients_data.append({
'id': client.id,
'user': {
'id': client.user.id,
'email': client.user.email,
'first_name': client.user.first_name,
'last_name': client.user.last_name,
'phone': client.user.phone,
'avatar': client.user.avatar.url if client.user.avatar else None
},
'statistics': {
'total_lessons': total_lessons,
'completed_lessons': completed_lessons,
'upcoming_lessons': upcoming_lessons,
'total_homeworks': total_homeworks,
'completed_homeworks': completed_homeworks,
'average_score': round(average_score, 2)
},
'last_lesson': last_lessons_by_client.get(client.user.id)
})
return Response(clients_data)
@action(detail=False, methods=['get'])
def statistics(self, request):
"""
Детальная статистика ментора.
GET /api/users/mentor/statistics/
"""
user = request.user
if user.role != 'mentor':
return Response(
{'error': 'Только для менторов'},
status=status.HTTP_403_FORBIDDEN
)
# Период статистики
period = request.query_params.get('period', '30') # дни
days = int(period)
start_date = timezone.now() - timedelta(days=days)
# Оптимизация: используем aggregate для всех подсчетов одним запросом
lessons = Lesson.objects.filter(
mentor=user,
start_time__gte=start_date
)
# Оптимизация: получаем все статистики одним запросом
lessons_stats = lessons.aggregate(
total=models.Count('id')
)
# Занятия по дням
lessons_by_day = lessons.extra(
select={'day': 'DATE(start_time)'}
).values('day').annotate(
count=models.Count('id')
).order_by('day')
# Занятия по статусам
lessons_by_status = lessons.values('status').annotate(
count=models.Count('id')
)
# Оптимизация: один запрос для всех статистик ДЗ
from django.db.models import Q
homeworks = Homework.objects.filter(
mentor=user,
created_at__gte=start_date
)
submissions = HomeworkSubmission.objects.filter(
homework__mentor=user,
submitted_at__gte=start_date
)
# Оптимизация: один запрос для всех статистик submissions
submissions_stats = submissions.aggregate(
total=models.Count('id'),
avg_score=models.Avg('score', filter=Q(status='graded'))
)
# Оценки
grades_distribution = submissions.filter(
status='graded'
).values('score').annotate(
count=models.Count('id')
).order_by('score')
# Оптимизация: один запрос для всех статистик материалов
materials = Material.objects.filter(
owner=user,
is_deleted=False,
created_at__gte=start_date
)
materials_stats = materials.aggregate(
total=models.Count('id'),
total_views=models.Sum('views_count')
)
materials_by_type = materials.values('material_type').annotate(
count=models.Count('id')
)
return Response({
'period_days': days,
'lessons': {
'total': lessons_stats['total'],
'by_day': list(lessons_by_day),
'by_status': list(lessons_by_status)
},
'homeworks': {
'created': homeworks.count(),
'submissions': submissions_stats['total'],
'average_score': submissions_stats['avg_score'] or 0,
'grades_distribution': list(grades_distribution)
},
'materials': {
'total': materials_stats['total'],
'by_type': list(materials_by_type),
'total_views': materials_stats['total_views'] or 0
}
})
@action(detail=False, methods=['get'])
def income(self, request):
"""
Статистика доходов ментора.
GET /api/users/mentor/income/?period=day|week|month|range&start_date=2024-01-01&end_date=2024-01-31
"""
from django.db.models import Sum, Count
from datetime import datetime, date
user = request.user
if user.role != 'mentor':
return Response(
{'error': 'Только для менторов'},
status=status.HTTP_403_FORBIDDEN
)
# Получаем параметры
period = request.query_params.get('period', 'week') # day, week, month, range
start_date_str = request.query_params.get('start_date')
end_date_str = request.query_params.get('end_date')
# Используем локальное время сервера
now = timezone.now()
# Определяем диапазон дат для предустановленных периодов
if period == 'day':
# Текущий календарный день (с 00:00 до 23:59:59)
start_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = start_date + timedelta(days=1) - timedelta(microseconds=1)
elif period == 'week':
# Текущая календарная неделя (с понедельника по воскресенье)
# weekday(): 0 = понедельник, 6 = воскресенье
monday = now - timedelta(days=now.weekday())
start_date = monday.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = start_date + timedelta(days=7) - timedelta(microseconds=1)
elif period == 'month':
# Текущий календарный месяц: с 1-го числа до последнего дня месяца
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# Первый день следующего месяца
if start_date.month == 12:
next_month = start_date.replace(year=start_date.year + 1, month=1, day=1)
else:
next_month = start_date.replace(month=start_date.month + 1, day=1)
end_date = next_month - timedelta(microseconds=1)
elif period == 'range' and start_date_str and end_date_str:
try:
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)
except ValueError:
return Response(
{'error': 'Неверный формат даты. Используйте YYYY-MM-DD'},
status=status.HTTP_400_BAD_REQUEST
)
else:
# По умолчанию - последние 90 дней
start_date = now - timedelta(days=90)
end_date = now
# Получаем завершенные занятия с ценой
# Сначала получаем все завершенные занятия для отладки
all_completed = Lesson.objects.filter(
mentor=user,
status='completed'
)
# Проверяем занятия без цены
without_price = all_completed.filter(price__isnull=True) | all_completed.filter(price=0)
# Логируем для отладки
import logging
logger = logging.getLogger(__name__)
logger.info(f'[Income] All completed lessons: {all_completed.count()}')
logger.info(f'[Income] Lessons without price: {without_price.count()}')
logger.info(f'[Income] Date range: {start_date} to {end_date}')
# Проверяем занятия с ценой и без цены
with_price = all_completed.exclude(price__isnull=True).exclude(price=0)
logger.info(f'[Income] Lessons with price: {with_price.count()}')
# Фильтруем по датам и цене для всех периодов
lessons = with_price.filter(
start_time__gte=start_date,
start_time__lte=end_date
)
logger.info(f'[Income] Using {period} period - filtered lessons: {lessons.count()}')
logger.info(f'[Income] Filtered lessons: {lessons.count()}')
if lessons.exists():
sample_lessons = list(lessons[:5])
logger.info(f'[Income] Sample lessons: {[{"id": l.id, "price": float(l.price) if l.price else 0, "start_time": str(l.start_time)} for l in sample_lessons]}')
# Оптимизация: получаем все статистики одним запросом
lessons_stats = lessons.aggregate(
total_income=Sum('price'),
total_lessons=Count('id')
)
total_income = lessons_stats['total_income'] or 0
total_lessons = lessons_stats['total_lessons']
average_lesson_price = total_income / total_lessons if total_lessons > 0 else 0
logger.info(f'[Income] Total income: {total_income}, Total lessons: {total_lessons}, Avg price: {average_lesson_price}')
# Группировка по дням
if period == 'day':
# По часам для дня
# Оптимизация: используем один запрос с ExtractHour вместо 24 запросов
from django.db.models.functions import ExtractHour
by_hour_data = lessons.annotate(
hour=ExtractHour('start_time')
).values('hour').annotate(
total=Sum('price'),
count=Count('id')
).order_by('hour')
# Создаем словарь для быстрого доступа
by_hour_dict = {item['hour']: item for item in by_hour_data}
# Заполняем все 24 часа (если для какого-то часа нет данных, значения = 0)
chart_data = []
for hour in range(24):
hour_start = start_date.replace(hour=hour, minute=0, second=0, microsecond=0)
hour_stats = by_hour_dict.get(hour, {'total': 0, 'count': 0})
chart_data.append({
'date': hour_start.strftime('%H:00'),
'income': float(hour_stats['total'] or 0),
'lessons': hour_stats['count']
})
else:
# По дням для недели/месяца/диапазона
chart_data = []
current_date = start_date.date()
end_date_only = end_date.date()
while current_date <= end_date_only:
day_start = timezone.make_aware(datetime.combine(current_date, datetime.min.time()))
day_end = day_start + timedelta(days=1)
day_lessons = lessons.filter(
start_time__gte=day_start,
start_time__lt=day_end
)
# Оптимизация: один запрос для статистики дня
day_stats = day_lessons.aggregate(
total=Sum('price'),
count=Count('id')
)
chart_data.append({
'date': current_date.strftime('%d.%m'),
'income': float(day_stats['total'] or 0),
'lessons': day_stats['count']
})
current_date += timedelta(days=1)
# Статистика по занятиям (топ занятий по доходам)
lessons_stats = lessons.values(
'title',
'subject',
'client__user__first_name',
'client__user__last_name',
'group__id',
'group__name',
).annotate(
total_income=Sum('price'),
lessons_count=Count('id'),
).order_by('-total_income')[:10]
return Response({
'period': period,
'start_date': format_datetime_for_user(start_date, request.user.timezone) if start_date else None,
'end_date': format_datetime_for_user(end_date, request.user.timezone) if end_date else None,
'summary': {
'total_income': float(total_income),
'total_lessons': total_lessons,
'average_lesson_price': float(average_lesson_price)
},
'chart_data': chart_data,
'top_lessons': [
{
'lesson_title': item['title'] or 'Без названия',
'subject': item['subject'] or '',
'is_group': bool(item['group__id']),
'target_name': (
item['group__name']
if item['group__name']
else f"{item['client__user__first_name']} {item['client__user__last_name']}".strip() or 'Ученик'
),
'lessons_count': item['lessons_count'],
'total_income': float(item['total_income']),
}
for item in lessons_stats
],
})
class ClientDashboardViewSet(viewsets.ViewSet):
"""
ViewSet для дашборда клиента.
dashboard: Основная статистика
my_lessons: Мои занятия
my_homeworks: Мои домашние задания
progress: Прогресс обучения
"""
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
def dashboard(self, request):
"""
Основной дашборд клиента.
GET /api/users/client/dashboard/
"""
user = request.user
# Кеширование: кеш на 2 минуты для каждого пользователя
cache_key = f'client_dashboard_{user.id}'
cached_data = cache.get(cache_key)
if cached_data is not None:
return Response(cached_data)
# Временные рамки
now = timezone.now()
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
# Занятия - оптимизация: используем select_related и aggregate
from django.db.models import Count, Avg, Q
lessons = Lesson.objects.filter(client__user=user).select_related(
'mentor', 'client', 'client__user', 'subject'
)
# Один запрос для всех подсчетов занятий
lessons_stats = lessons.aggregate(
total=Count('id'),
completed=Count('id', filter=Q(status='completed')),
this_week=Count('id', filter=Q(start_time__gte=week_ago))
)
total_lessons = lessons_stats['total']
completed_lessons = lessons_stats['completed']
lessons_this_week = lessons_stats['this_week']
# Ближайшие занятия с оптимизацией
upcoming_lessons = lessons.filter(
start_time__gte=now,
status__in=['scheduled', 'in_progress']
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
# Домашние задания - оптимизация
my_homeworks = Homework.objects.filter(assigned_to=user).select_related('mentor', 'lesson')
total_homeworks = my_homeworks.count()
my_submissions = HomeworkSubmission.objects.filter(student=user).select_related('homework', 'student')
# Один запрос для подсчетов отправок ДЗ
submissions_stats = my_submissions.aggregate(
completed=Count('id', filter=Q(passed=True)),
avg_score=Avg('score', filter=Q(status='graded'))
)
completed_homeworks = submissions_stats['completed']
average_score = submissions_stats['avg_score'] or 0
# Пending homeworks - оптимизация через exists
pending_homeworks = my_homeworks.filter(
status='published'
).exclude(
id__in=my_submissions.values('homework_id')
).count()
# Материалы (исправлено: lesson__client__user)
shared_materials = Material.objects.filter(
models.Q(shared_with=user) |
models.Q(access_type='public') |
models.Q(lesson__client__user=user)
).filter(is_deleted=False).distinct().count()
# Уведомления
unread_notifications = Notification.objects.filter(
recipient=user,
is_read=False
).count()
response_data = {
'summary': {
'total_lessons': total_lessons,
'completed_lessons': completed_lessons,
'lessons_this_week': lessons_this_week,
'total_homeworks': total_homeworks,
'completed_homeworks': completed_homeworks,
'pending_homeworks': pending_homeworks,
'average_score': round(average_score, 2),
'shared_materials': shared_materials,
'unread_notifications': unread_notifications
},
'upcoming_lessons': [
{
'id': lesson.id,
'title': lesson.title,
'mentor': {
'id': lesson.mentor.id,
'name': lesson.mentor.get_full_name()
},
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
}
for lesson in upcoming_lessons
]
}
# Сохраняем в кеш на 2 минуты (120 секунд)
# Кеш на 30 секунд для актуальности уведомлений
cache.set(cache_key, response_data, 30)
return Response(response_data)
@action(detail=False, methods=['get'])
def progress(self, request):
"""
Прогресс обучения клиента.
GET /api/users/client/progress/
"""
user = request.user
# Период
period = request.query_params.get('period', '30')
days = int(period)
start_date = timezone.now() - timedelta(days=days)
# Занятия (исправлено: client__user)
lessons = Lesson.objects.filter(
client__user=user,
start_time__gte=start_date
).select_related('client', 'client__user', 'mentor', 'subject')
# Оптимизация: один запрос для статистики занятий
from django.db.models import Count, Q
lessons_stats = lessons.aggregate(
total=Count('id'),
completed=Count('id', filter=Q(status='completed'))
)
total_lessons = lessons_stats['total']
completed_lessons = lessons_stats['completed']
completion_rate = (completed_lessons / total_lessons * 100) if total_lessons > 0 else 0
# Домашние задания
submissions = HomeworkSubmission.objects.filter(
student=user,
submitted_at__gte=start_date
).select_related('homework', 'homework__lesson')
# Оптимизация: один запрос для статистики submissions
submissions_stats = submissions.filter(status='graded').aggregate(
total=models.Count('id'),
passed=models.Count('id', filter=models.Q(passed=True))
)
total_submissions = submissions_stats['total']
passed_submissions = submissions_stats['passed']
pass_rate = (passed_submissions / total_submissions * 100) if total_submissions > 0 else 0
# Прогресс по времени
progress_by_week = lessons.extra(
select={'week': 'EXTRACT(WEEK FROM start_time)'}
).values('week').annotate(
lessons_count=models.Count('id'),
completed_count=models.Count('id', filter=models.Q(status='completed'))
).order_by('week')
# Оценки по времени
scores_by_week = submissions.filter(
status='graded'
).extra(
select={'week': 'EXTRACT(WEEK FROM submitted_at)'}
).values('week').annotate(
average_score=models.Avg('score'),
count=models.Count('id')
).order_by('week')
# Получаем все занятия клиента (не только за период)
all_lessons = Lesson.objects.filter(client__user=user).select_related('client', 'client__user', 'mentor', 'subject')
# Оптимизация: один запрос для всех статистик
all_lessons_stats = all_lessons.aggregate(
total=models.Count('id'),
cancelled=models.Count('id', filter=models.Q(status='cancelled')),
avg_mentor_grade=models.Avg('mentor_grade', filter=models.Q(status='completed', mentor_grade__isnull=False)),
avg_school_grade=models.Avg('school_grade', filter=models.Q(status='completed', school_grade__isnull=False))
)
cancelled_lessons = all_lessons_stats['cancelled']
attendance_rate = ((all_lessons_stats['total'] - cancelled_lessons) / all_lessons_stats['total'] * 100) if all_lessons_stats['total'] > 0 else 0
average_mentor_grade = all_lessons_stats['avg_mentor_grade'] or 0
average_school_grade = all_lessons_stats['avg_school_grade'] or 0
# Домашние задания
all_homeworks = Homework.objects.filter(
lesson__client__user=user
).count()
completed_homeworks = HomeworkSubmission.objects.filter(
student=user,
status='graded'
).count()
homework_completion_rate = (completed_homeworks / all_homeworks * 100) if all_homeworks > 0 else 0
# Оценки по предметам
grades_by_subject = all_lessons.filter(
status='completed',
mentor_grade__isnull=False
).values('subject__name').annotate(
average_grade=models.Avg('mentor_grade'),
lessons_count=models.Count('id')
).order_by('-average_grade')[:5]
# Последние оценки
recent_grades = all_lessons.filter(
status='completed'
).exclude(
mentor_grade__isnull=True,
school_grade__isnull=True
).order_by('-start_time')[:5]
return Response({
'total_lessons': all_lessons.count(),
'completed_lessons': all_lessons.filter(status='completed').count(),
'cancelled_lessons': cancelled_lessons,
'attendance_rate': round(attendance_rate, 2),
'average_mentor_grade': round(average_mentor_grade, 2),
'average_school_grade': round(average_school_grade, 2),
'total_homework': all_homeworks,
'completed_homework': completed_homeworks,
'homework_completion_rate': round(homework_completion_rate, 2),
'grades_by_subject': [
{
'subject': item['subject__name'] or 'Без предмета',
'average_grade': round(item['average_grade'], 1),
'lessons_count': item['lessons_count']
}
for item in grades_by_subject
],
'recent_grades': [
{
'lesson_title': lesson.title,
'date': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
'mentor_grade': lesson.mentor_grade or 0,
'school_grade': lesson.school_grade or 0
}
for lesson in recent_grades
]
})
class ParentDashboardViewSet(viewsets.ViewSet):
"""
ViewSet для дашборда родителя.
dashboard: Основная статистика по детям
children: Список детей
child_report: Отчет по ребенку
"""
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
def dashboard(self, request):
"""
Основной дашборд родителя.
GET /api/users/parent/dashboard/
"""
user = request.user
# Кеширование: кеш на 2 минуты для каждого пользователя
# ВАЖНО: Очищаем кеш при изменении структуры данных (добавлении mentors)
cache_key = f'parent_dashboard_v2_{user.id}' # Версия 2 с mentors
cached_data = cache.get(cache_key)
if cached_data is not None:
return Response(cached_data)
try:
parent = Parent.objects.get(user=user)
except Parent.DoesNotExist:
return Response(
{'error': 'Профиль родителя не найден'},
status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
logger.error(f'Ошибка получения родителя для пользователя {user.id}: {e}', exc_info=True)
return Response(
{'error': 'Ошибка получения данных родителя'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# Оптимизация: используем prefetch_related для избежания N+1 запросов
from django.db.models import Count, Avg, Q, Prefetch
# Получаем всех детей с предзагрузкой менторов одним запросом
children = list(parent.children.select_related('user').prefetch_related(
Prefetch('mentors', queryset=User.objects.only(
'id', 'email', 'first_name', 'last_name', 'avatar'
))
).all())
# Получаем всех детей одним запросом
children_users = [child.user for child in children]
children_user_ids = [user.id for user in children_users]
children_client_ids = [c.id for c in children]
# Получаем всех менторов всех детей одним запросом через промежуточную таблицу ManyToMany
mentors_by_child = defaultdict(list)
if children_client_ids:
# Получаем все связи менторов с детьми одним запросом
client_mentor_relations = list(Client.mentors.through.objects.filter(
client_id__in=children_client_ids
).values_list('client_id', 'user_id'))
# Группируем менторов по детям (используем defaultdict для эффективности)
# Это единственный необходимый цикл для группировки
for client_id, mentor_id in client_mentor_relations:
mentors_by_child[client_id].append(mentor_id)
# Получаем всех уникальных менторов одним запросом (set comprehension)
all_mentor_ids = {mentor_id for mentor_ids in mentors_by_child.values() for mentor_id in mentor_ids}
# Загружаем всех менторов одним запросом (dict comprehension)
mentors_dict = {
mentor.id: mentor for mentor in User.objects.filter(id__in=all_mentor_ids).only(
'id', 'email', 'first_name', 'last_name', 'avatar'
)
}
else:
mentors_dict = {}
# Оптимизация: один запрос для всех занятий всех детей
now = timezone.now()
all_upcoming_lessons = Lesson.objects.filter(
client__user_id__in=children_user_ids,
start_time__gte=now,
status__in=['scheduled', 'in_progress']
).select_related('client', 'client__user').values('client__user_id').annotate(
count=Count('id')
)
upcoming_lessons_by_child = {item['client__user_id']: item['count'] for item in all_upcoming_lessons}
# Оптимизация: один запрос для всех отправок ДЗ всех детей
all_submissions = HomeworkSubmission.objects.filter(
student_id__in=children_user_ids
).select_related('homework', 'student').values('student_id').annotate(
avg_score=Avg('score', filter=Q(status='graded'))
)
avg_scores_by_child = {item['student_id']: (item['avg_score'] or 0) for item in all_submissions}
# Оптимизация: один запрос для всех домашних заданий (assigned_to - ManyToManyField)
all_homeworks = list(Homework.objects.filter(
assigned_to__in=children_user_ids,
status='published'
).prefetch_related('assigned_to').distinct())
# Получаем отправленные ДЗ
submitted_homework_ids = set(
HomeworkSubmission.objects.filter(
student_id__in=children_user_ids
).values_list('homework_id', flat=True)
)
# Подсчитываем pending для каждого ребенка
pending_by_child = {user_id: 0 for user_id in children_user_ids}
for homework in all_homeworks:
# Оптимизация: используем предзагруженные данные через prefetch_related
# Преобразуем в список, чтобы гарантировать использование предзагруженных данных
assigned_user_ids = {user.id for user in list(homework.assigned_to.all())}
# Пересечение с детьми
children_assigned = assigned_user_ids & set(children_user_ids)
# Если ДЗ не отправлено, добавляем к счетчику для каждого назначенного ребенка
if homework.id not in submitted_homework_ids:
for user_id in children_assigned:
pending_by_child[user_id] = pending_by_child.get(user_id, 0) + 1
# Создаем данные детей с менторами (list comprehension с вложенным list comprehension)
children_data = [
{
'child': {
'id': child.user.id,
'name': child.user.get_full_name(),
'email': child.user.email,
'avatar': child.user.avatar.url if child.user.avatar else None,
'avatar_url': (
request.build_absolute_uri(child.user.avatar.url)
if child.user.avatar else None
),
},
'mentors': [
{
'id': mentor.id,
'email': mentor.email,
'first_name': mentor.first_name,
'last_name': mentor.last_name,
'avatar_url': (
request.build_absolute_uri(mentor.avatar.url)
if mentor.avatar else None
),
}
for mentor_id in mentors_by_child.get(child.id, [])
if (mentor := mentors_dict.get(mentor_id))
],
'summary': {
'upcoming_lessons': upcoming_lessons_by_child.get(child.user.id, 0),
'pending_homeworks': pending_by_child.get(child.user.id, 0),
'average_score': round(avg_scores_by_child.get(child.user.id, 0), 2)
}
}
for child in children
]
response_data = {
'children_count': len(children_data),
'children': children_data
}
# Сохраняем в кеш на 2 минуты (120 секунд)
# Кеш на 30 секунд для актуальности уведомлений
try:
cache.set(cache_key, response_data, 30)
except Exception as e:
logger.warning(f'Не удалось сохранить в кеш: {e}')
return Response(response_data)
@action(detail=True, methods=['get'])
def child_report(self, request, pk=None):
"""
Детальный отчет по ребенку.
GET /api/users/parent/{child_id}/child_report/
"""
user = request.user
try:
parent = Parent.objects.get(user=user)
except Parent.DoesNotExist:
return Response(
{'error': 'Профиль родителя не найден'},
status=status.HTTP_404_NOT_FOUND
)
# Проверяем что это ребенок родителя
try:
child = parent.children.get(user_id=pk)
except Client.DoesNotExist:
return Response(
{'error': 'Ребенок не найден'},
status=status.HTTP_404_NOT_FOUND
)
# Период
period = request.query_params.get('period', '30')
days = int(period)
start_date = timezone.now() - timedelta(days=days)
# Получаем User объект ребенка
child_user = child.user
# Занятия
lessons = Lesson.objects.filter(
client=child,
start_time__gte=start_date
)
# Домашние задания
submissions = HomeworkSubmission.objects.filter(
student=child_user,
submitted_at__gte=start_date
)
# Материалы
materials = Material.objects.filter(
models.Q(shared_with=child_user) |
models.Q(lesson__client=child)
).filter(
is_deleted=False,
created_at__gte=start_date
).distinct()
return Response({
'child': {
'id': child.id,
'name': child_user.get_full_name(),
'email': child_user.email
},
'period_days': days,
'lessons': {
'total': lessons.count(),
'completed': lessons.filter(status='completed').count(),
'upcoming': lessons.filter(
start_time__gte=timezone.now(),
status='confirmed'
).count()
},
'homeworks': {
'total_submissions': submissions.count(),
'graded': submissions.filter(status='graded').count(),
'passed': submissions.filter(passed=True).count(),
'average_score': submissions.filter(
status='graded'
).aggregate(avg=models.Avg('score'))['avg'] or 0
},
'materials': {
'total': materials.count(),
'by_type': list(materials.values('material_type').annotate(
count=models.Count('id')
))
}
})
@action(detail=True, methods=['get'], url_path='child_dashboard')
def child_dashboard(self, request, pk=None):
"""
Дашборд выбранного ребенка для родителя.
Возвращает данные в формате, аналогичном /client/dashboard/
GET /api/users/parent/{child_id}/child_dashboard/
"""
user = request.user
try:
parent = Parent.objects.get(user=user)
except Parent.DoesNotExist:
return Response(
{'error': 'Профиль родителя не найден'},
status=status.HTTP_404_NOT_FOUND
)
# Проверяем что это ребенок родителя
try:
child = parent.children.get(user_id=pk)
except Client.DoesNotExist:
return Response(
{'error': 'Ребенок не найден'},
status=status.HTTP_404_NOT_FOUND
)
# Получаем User объект ребенка
child_user = child.user
# Кеширование: кеш на 2 минуты для каждого ребенка
cache_key = f'parent_child_dashboard_{user.id}_{child_user.id}'
cached_data = cache.get(cache_key)
if cached_data is not None:
return Response(cached_data)
# Временные рамки
now = timezone.now()
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)
# Занятия - используем ту же логику, что и для клиента
from django.db.models import Count, Avg, Q
lessons = Lesson.objects.filter(client__user=child_user).select_related(
'mentor', 'client', 'client__user', 'subject'
)
# Один запрос для всех подсчетов занятий
lessons_stats = lessons.aggregate(
total=Count('id'),
completed=Count('id', filter=Q(status='completed')),
this_week=Count('id', filter=Q(start_time__gte=week_ago))
)
total_lessons = lessons_stats['total']
completed_lessons = lessons_stats['completed']
lessons_this_week = lessons_stats['this_week']
# Ближайшие занятия
upcoming_lessons = lessons.filter(
start_time__gte=now,
status__in=['scheduled', 'in_progress']
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
# Домашние задания
my_homeworks = Homework.objects.filter(assigned_to=child_user).select_related('mentor', 'lesson')
total_homeworks = my_homeworks.count()
my_submissions = HomeworkSubmission.objects.filter(student=child_user).select_related('homework', 'student')
# Один запрос для подсчетов отправок ДЗ
submissions_stats = my_submissions.aggregate(
completed=Count('id', filter=Q(passed=True)),
avg_score=Avg('score', filter=Q(status='graded'))
)
completed_homeworks = submissions_stats['completed']
average_score = submissions_stats['avg_score'] or 0
# Pending homeworks
pending_homeworks = my_homeworks.filter(
status='published'
).exclude(
id__in=my_submissions.values('homework_id')
).count()
# Материалы
shared_materials = Material.objects.filter(
models.Q(shared_with=child_user) |
models.Q(access_type='public') |
models.Q(lesson__client__user=child_user)
).filter(is_deleted=False).distinct().count()
# Уведомления (для ребенка)
unread_notifications = Notification.objects.filter(
recipient=child_user,
is_read=False
).count()
response_data = {
'summary': {
'total_lessons': total_lessons,
'completed_lessons': completed_lessons,
'lessons_this_week': lessons_this_week,
'total_homeworks': total_homeworks,
'completed_homeworks': completed_homeworks,
'pending_homeworks': pending_homeworks,
'average_score': round(average_score, 2),
'shared_materials': shared_materials,
'unread_notifications': unread_notifications
},
'upcoming_lessons': [
{
'id': lesson.id,
'title': lesson.title,
'mentor': {
'id': lesson.mentor.id,
'name': lesson.mentor.get_full_name()
},
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
}
for lesson in upcoming_lessons
]
}
# Сохраняем в кеш на 2 минуты (120 секунд)
cache.set(cache_key, response_data, 30)
return Response(response_data)