Harden backend warnings and lint policy

This commit is contained in:
lizerk 2026-03-31 17:19:59 +03:00
parent 3d4416615e
commit 9edb44502a
6 changed files with 175 additions and 124 deletions

14
backend/.flake8 Normal file
View File

@ -0,0 +1,14 @@
[flake8]
max-line-length = 88
exclude =
.git,
.venv,
__pycache__,
node_modules,
staticfiles,
media,
migrations
extend-ignore =
W503
per-file-ignores =
communications/api/viewsets.py:E501

View File

@ -568,9 +568,9 @@ class ScheduleEventRelationViewSet(viewsets.ModelViewSet):
def get_queryset(self):
"""Фильтрация по событию."""
qs = super().get_queryset()
event_id = self.request.query_params.get('event')
if event_id:
qs = qs.filter(event_id=event_id)
return qs
return qs.order_by('id')

View File

@ -19,16 +19,9 @@ from datetime import timezone as dt_tz
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from ..models import (Chat, ChatMembership, ChatReadState,
ChatUserSettings, Message,
MessageAttachment, MessageReaction, Poll,
PollOption, PollVote)
from communications.serialization import serialize_message
from ..utils import _coerce_ts, user_can_access_chat
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Count, Exists, OuterRef, Prefetch, Q, Subquery
from django.db.models.functions import Coalesce
from django.db.models import Prefetch, Q
from django.utils import timezone
from rest_framework import status, viewsets
from rest_framework.decorators import action
@ -36,13 +29,32 @@ from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .serializers import (BulkDeleteSerializer, ChatDetailSerializer,
ChatListSerializer, ForwardMessageSerializer,
MessageAttachmentSerializer, MessageCreateSerializer,
MessageDetailSerializer, MessageEditSerializer,
MessageListSerializer, PollSerializer,
ReactionSerializer)
from .permissions import ChatPermission, MessagePermission
from .serializers import (
BulkDeleteSerializer,
ChatDetailSerializer,
ChatListSerializer,
ForwardMessageSerializer,
MessageCreateSerializer,
MessageDetailSerializer,
MessageEditSerializer,
MessageListSerializer,
PollSerializer,
ReactionSerializer,
)
from ..models import (
Chat,
ChatMembership,
ChatReadState,
ChatUserSettings,
Message,
MessageAttachment,
MessageReaction,
Poll,
PollVote,
)
from ..utils import _coerce_ts, user_can_access_chat
from communications.serialization import serialize_message
logger = logging.getLogger(__name__)
User = get_user_model()
@ -80,15 +92,14 @@ class ChatViewSet(viewsets.ModelViewSet):
def get_queryset(self):
"""
Чаты доступные пользователю с аннотацией непрочитанных.
ОПТИМИЗИРОВАНО: Используем денормализованное поле unread_count из ChatReadState
вместо подзапросов COUNT(*). Это убирает N+1 проблему и ускоряет в ~100x.
ФИЛЬТРАЦИЯ: Чаты типа 'comments' исключены из списка (list action),
но доступны по прямой ссылке (retrieve action).
"""
user = self.request.user
from django.contrib.contenttypes.models import ContentType
queryset = Chat.objects.filter(
Q(memberships__user=user, memberships__is_active=True)
@ -117,12 +128,12 @@ class ChatViewSet(viewsets.ModelViewSet):
to_attr='my_read_state'
)
).distinct()
# Исключаем чаты-комментарии из общего списка
# Они доступны только через прямой запрос (retrieve) или через контекст поста
if self.action == 'list':
queryset = queryset.exclude(type='comments')
return queryset.order_by('-created_at')
def create(self, request, *args, **kwargs):
@ -211,29 +222,29 @@ class ChatViewSet(viewsets.ModelViewSet):
def leave(self, request, pk=None):
"""Покинуть чат (выход пользователя из чата)"""
import rules
chat = self.get_object()
# Проверка прав через django-rules
if not rules.test_rule('communications.leave_chat', request.user, chat):
return Response(
{'error': 'Permission denied'},
status=status.HTTP_403_FORBIDDEN
)
# Нельзя покинуть чат, если ты его владелец
if chat.created_by == request.user:
return Response(
{'error': 'Chat owner cannot leave the chat'},
status=status.HTTP_400_BAD_REQUEST
)
# MIGRATION: Убрали participants.remove, только деактивируем membership
membership = ChatMembership.objects.filter(
chat=chat,
user=request.user
).first()
if membership:
membership.is_active = False
membership.left_at = timezone.now()
@ -244,7 +255,7 @@ class ChatViewSet(viewsets.ModelViewSet):
{'error': 'You are not a member of this chat'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({
'ok': True,
'message': 'Successfully left the chat'
@ -254,23 +265,23 @@ class ChatViewSet(viewsets.ModelViewSet):
def add_member(self, request, pk=None):
"""Добавить участника в чат"""
import rules
chat = self.get_object()
user_id = request.data.get('user_id')
if not user_id:
return Response(
{'error': 'user_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Проверка прав
if not rules.test_rule('communications.add_members', request.user, chat):
return Response(
{'error': 'Permission denied'},
status=status.HTTP_403_FORBIDDEN
)
try:
user_to_add = User.objects.get(pk=user_id)
except User.DoesNotExist:
@ -278,7 +289,7 @@ class ChatViewSet(viewsets.ModelViewSet):
{'error': 'User not found'},
status=status.HTTP_404_NOT_FOUND
)
# MIGRATION: Проверяем через memberships вместо participants
if ChatMembership.objects.filter(
chat=chat,
@ -289,7 +300,7 @@ class ChatViewSet(viewsets.ModelViewSet):
{'error': 'User is already a member'},
status=status.HTTP_400_BAD_REQUEST
)
# Создаем или восстанавливаем membership для чатов с управлением участниками
if chat.type in ['group', 'channel', 'announcement']:
membership, created = ChatMembership.objects.get_or_create(
@ -301,7 +312,7 @@ class ChatViewSet(viewsets.ModelViewSet):
'is_active': True
}
)
# Если membership уже существовал (например, пользователь раньше покинул чат),
# восстанавливаем его активность
if not created and not membership.is_active:
@ -310,7 +321,7 @@ class ChatViewSet(viewsets.ModelViewSet):
membership.invited_by = request.user # Обновляем кто пригласил повторно
membership.save()
chat.participants.add(user_to_add)
return Response({
'ok': True,
'message': 'User added successfully'
@ -320,23 +331,23 @@ class ChatViewSet(viewsets.ModelViewSet):
def remove_member(self, request, pk=None):
"""Удалить участника из чата"""
import rules
chat = self.get_object()
user_id = request.data.get('user_id')
if not user_id:
return Response(
{'error': 'user_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Проверка прав
if not rules.test_rule('communications.remove_members', request.user, chat):
return Response(
{'error': 'Permission denied'},
status=status.HTTP_403_FORBIDDEN
)
try:
user_to_remove = User.objects.get(pk=user_id)
except User.DoesNotExist:
@ -344,31 +355,31 @@ class ChatViewSet(viewsets.ModelViewSet):
{'error': 'User not found'},
status=status.HTTP_404_NOT_FOUND
)
# Нельзя удалить владельца
if chat.created_by == user_to_remove:
return Response(
{'error': 'Cannot remove chat owner'},
status=status.HTTP_400_BAD_REQUEST
)
# MIGRATION: Убрали participants.remove, только деактивируем membership
membership = ChatMembership.objects.filter(
chat=chat,
user=user_to_remove
).first()
if not membership:
return Response(
{'error': 'User is not a member'},
status=status.HTTP_400_BAD_REQUEST
)
membership.is_active = False
membership.left_at = timezone.now()
membership.save(update_fields=['is_active', 'left_at'])
chat.participants.remove(user_to_remove)
return Response({
'ok': True,
'message': 'User removed successfully'
@ -378,17 +389,17 @@ class ChatViewSet(viewsets.ModelViewSet):
def change_role(self, request, pk=None):
"""Изменить роль участника чата"""
import rules
chat = self.get_object()
user_id = request.data.get('user_id')
new_role = request.data.get('role')
if not user_id or not new_role:
return Response(
{'error': 'user_id and role are required'},
status=status.HTTP_400_BAD_REQUEST
)
# Проверка валидности роли
valid_roles = ['admin', 'moderator', 'member', 'guest']
if new_role not in valid_roles:
@ -396,10 +407,10 @@ class ChatViewSet(viewsets.ModelViewSet):
{'error': f'Invalid role. Must be one of: {", ".join(valid_roles)}'},
status=status.HTTP_400_BAD_REQUEST
)
# Проверка прав (только владелец может менять роли)
can_change = rules.test_rule('communications.change_member_role', request.user, chat)
# Добавляем логирование для отладки
logger.warning(
f"[change_role] User {request.user.id} trying to change role in chat {chat.id}. "
@ -407,13 +418,13 @@ class ChatViewSet(viewsets.ModelViewSet):
f"request.user.id={request.user.id}, "
f"can_change={can_change}"
)
if not can_change:
return Response(
{'error': 'Permission denied. Only chat owner can change roles.'},
status=status.HTTP_403_FORBIDDEN
)
try:
user_to_change = User.objects.get(pk=user_id)
except User.DoesNotExist:
@ -421,14 +432,14 @@ class ChatViewSet(viewsets.ModelViewSet):
{'error': 'User not found'},
status=status.HTTP_404_NOT_FOUND
)
# Нельзя изменить роль владельца
if chat.created_by == user_to_change:
return Response(
{'error': 'Cannot change role of chat owner'},
status=status.HTTP_400_BAD_REQUEST
)
# Получаем или создаем membership
membership, created = ChatMembership.objects.get_or_create(
chat=chat,
@ -439,13 +450,13 @@ class ChatViewSet(viewsets.ModelViewSet):
'is_active': True # Явно устанавливаем is_active при создании
}
)
logger.warning(
f"[change_role] membership found: created={created}, "
f"user_id={user_to_change.id}, old_role={membership.role}, new_role={new_role}, "
f"is_active={membership.is_active}"
)
if not created:
# Обновляем существующий membership
old_role = membership.role
@ -455,7 +466,7 @@ class ChatViewSet(viewsets.ModelViewSet):
membership.left_at = None # Сбрасываем left_at если был
membership.set_permissions_for_role()
membership.save()
# Перезагружаем из БД чтобы убедиться что сохранилось
membership.refresh_from_db()
logger.warning(
@ -464,7 +475,7 @@ class ChatViewSet(viewsets.ModelViewSet):
f"old_is_active={old_is_active}, new_is_active={membership.is_active}, "
f"saved_correctly={membership.role == new_role and membership.is_active}"
)
return Response({
'ok': True,
'message': f'User role changed to {new_role}',
@ -897,13 +908,13 @@ class MessageViewSet(viewsets.ModelViewSet):
def get_queryset(self):
"""
Сообщения доступные пользователю
Включает сообщения из чатов:
- где user в participants (прямое участие)
- где user в ChatMembership (роли)
- где include_all_users=True (открытые чаты)
- context-based чаты (type=comments с context_object)
Детальная проверка доступа выполняется через MessagePermission
"""
user = self.request.user
@ -915,13 +926,13 @@ class MessageViewSet(viewsets.ModelViewSet):
| Q(participants=user)
| Q(include_all_users=True)
).distinct()
# Context-based чаты (комментарии к постам и т.д.)
# Проверка доступа к context_object будет в permissions
context_based_chats = Chat.objects.filter(
Q(type='comments') & ~Q(context_object_id=None)
).distinct()
# Объединяем оба набора
all_chats = accessible_chats | context_based_chats

View File

@ -22,7 +22,6 @@ def test_ldap_connection():
print(f"\n✓ LDAP подключение успешно")
print(f"✓ Сервер: {conn.server}")
print(f"✓ Пользователь: {conn.user}")
return True
except Exception as e:
pytest.fail(f"Не удалось подключиться к LDAP: {e}")

View File

@ -6,11 +6,14 @@ API endpoints для системы уведомлений v2.0
- verb вместо category/type
- UserChannelPreferences для настроек каналов
"""
from datetime import datetime
from drf_spectacular.utils import OpenApiParameter, extend_schema
from django.db.models import Count, Q
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from ..models import Notification, UserChannelPreferences
from .serializers import (
@ -33,11 +36,23 @@ def _get_notification_title(notification):
# Приоритет: title из data (задается при создании уведомления)
if notification.data and 'title' in notification.data:
return notification.data['title']
# Fallback: генерируем простой заголовок из verb
return notification.verb.replace('_', ' ').title()
def _format_time_or_none(value):
if value is None:
return None
return value.strftime('%H:%M')
def _parse_time_or_none(value):
if not value:
return None
return datetime.strptime(value, '%H:%M').time()
@extend_schema(
tags=["Notifications"],
summary="Получить список уведомлений",
@ -54,8 +69,8 @@ def _get_notification_title(notification):
@permission_classes([IsAuthenticated])
def get_notifications(request):
"""
Получить список уведомлений с пагинацией и фильтрами
Получить список уведомлений с пагинацией и фильтрами.
Query params:
- page: номер страницы (default: 1)
- page_size: размер страницы (default: 20)
@ -84,10 +99,10 @@ def get_notifications(request):
# Фильтры
if verb:
queryset = queryset.filter(verb=verb)
if unread_only:
queryset = queryset.unread()
if search:
queryset = queryset.filter(description__icontains=search)
@ -110,8 +125,8 @@ def get_notifications(request):
'title': _get_notification_title(n),
'message': n.description,
'short_message': (
n.description[:100] + '...'
if len(n.description) > 100
n.description[:100] + '...'
if len(n.description) > 100
else n.description
),
'is_read': not n.unread,
@ -174,12 +189,12 @@ def mark_as_read(request, notification_id):
recipient=request.user
)
notification.mark_as_read()
return Response({
'status': 'success',
'notification_id': notification_id
})
except Notification.DoesNotExist:
return Response(
{'status': 'error', 'message': 'Notification not found'},
@ -203,12 +218,12 @@ def mark_as_unread(request, notification_id):
recipient=request.user
)
notification.mark_as_unread()
return Response({
'status': 'success',
'notification_id': notification_id
})
except Notification.DoesNotExist:
return Response(
{'status': 'error', 'message': 'Notification not found'},
@ -227,16 +242,16 @@ def mark_as_unread(request, notification_id):
def mark_all_as_read(request):
"""Отметить все уведомления как прочитанные"""
verb = request.data.get('verb') # Опционально - только определенный тип
queryset = Notification.objects.filter(
recipient=request.user,
unread=True,
deleted=False,
)
if verb:
queryset = queryset.filter(verb=verb)
count = queryset.mark_all_as_read()
return Response({
@ -309,9 +324,9 @@ def delete_notification(request, notification_id):
)
notification.deleted = True
notification.save(update_fields=['deleted'])
return Response({'status': 'success'})
except Notification.DoesNotExist:
return Response(
{'status': 'error', 'message': 'Notification not found'},
@ -349,12 +364,10 @@ def delete_all_read(request):
@permission_classes([IsAuthenticated])
def get_verb_types(request):
"""
Получить список типов (verb) уведомлений с количеством
Получить список типов (verb) уведомлений с количеством.
Возвращает статистику по типам уведомлений пользователя.
"""
from django.db.models import Count, Q
verb_stats = Notification.objects.filter(
recipient=request.user,
deleted=False,
@ -394,11 +407,11 @@ def get_verb_types(request):
@permission_classes([IsAuthenticated])
def channel_preferences(request):
"""
Получить или обновить настройки каналов уведомлений
Получить или обновить настройки каналов уведомлений.
GET: Возвращает текущие настройки
PUT: Обновляет настройки
Настройки:
- web_enabled: WebSocket уведомления (bool)
- email_enabled: Email уведомления (bool)
@ -410,10 +423,10 @@ def channel_preferences(request):
- disabled_verbs: Список отключенных типов (array)
"""
# Получаем или создаем настройки
prefs, created = UserChannelPreferences.objects.get_or_create(
prefs, _created = UserChannelPreferences.objects.get_or_create(
user=request.user
)
if request.method == 'GET':
data = {
'web_enabled': prefs.web_enabled,
@ -421,72 +434,76 @@ def channel_preferences(request):
'email_frequency': prefs.email_frequency,
'push_enabled': prefs.push_enabled,
'dnd_enabled': prefs.dnd_enabled,
'dnd_start_time': prefs.dnd_start_time.strftime('%H:%M') if prefs.dnd_start_time else None,
'dnd_end_time': prefs.dnd_end_time.strftime('%H:%M') if prefs.dnd_end_time else None,
'dnd_start_time': _format_time_or_none(prefs.dnd_start_time),
'dnd_end_time': _format_time_or_none(prefs.dnd_end_time),
'disabled_verbs': prefs.disabled_verbs,
}
return Response(data)
elif request.method == 'PUT':
if request.method == 'PUT':
# Обновляем настройки
if 'web_enabled' in request.data:
prefs.web_enabled = request.data.get('web_enabled')
if 'email_enabled' in request.data:
prefs.email_enabled = request.data.get('email_enabled')
if 'email_frequency' in request.data:
frequency = request.data.get('email_frequency')
if frequency in ['instant', 'daily', 'weekly', 'disabled']:
prefs.email_frequency = frequency
if 'push_enabled' in request.data:
prefs.push_enabled = request.data.get('push_enabled')
if 'dnd_enabled' in request.data:
prefs.dnd_enabled = request.data.get('dnd_enabled')
if 'dnd_start_time' in request.data:
from datetime import datetime
time_str = request.data.get('dnd_start_time')
if time_str:
prefs.dnd_start_time = datetime.strptime(time_str, '%H:%M').time()
prefs.dnd_start_time = _parse_time_or_none(time_str)
if 'dnd_end_time' in request.data:
from datetime import datetime
time_str = request.data.get('dnd_end_time')
if time_str:
prefs.dnd_end_time = datetime.strptime(time_str, '%H:%M').time()
prefs.dnd_end_time = _parse_time_or_none(time_str)
if 'disabled_verbs' in request.data:
prefs.disabled_verbs = request.data.get('disabled_verbs')
prefs.save()
return Response({
'status': 'success',
'message': 'Preferences updated'
})
return Response(
{'status': 'error', 'message': 'Method not allowed'},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
@extend_schema(
tags=["Notifications"],
summary="Получить публичный VAPID ключ",
responses={200: VapidPublicKeyResponseSerializer, 503: VapidPublicKeyResponseSerializer},
responses={
200: VapidPublicKeyResponseSerializer,
503: VapidPublicKeyResponseSerializer,
},
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_vapid_public_key(request):
"""
Получить публичный VAPID ключ для Web Push подписки
Получить публичный VAPID ключ для Web Push подписки.
Returns:
- vapid_public_key: публичный ключ в формате base64url
"""
from django.conf import settings
vapid_key = getattr(settings, 'VAPID_PUBLIC_KEY', None)
if not vapid_key:
return Response(
{
@ -496,7 +513,7 @@ def get_vapid_public_key(request):
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
return Response({
'status': 'success',
'vapid_public_key': vapid_key
@ -513,25 +530,25 @@ def get_vapid_public_key(request):
@permission_classes([IsAuthenticated])
def subscribe_push(request):
"""
Подписка на Web Push уведомления
Подписка на Web Push уведомления.
Body:
- endpoint: URL для отправки push
- keys: {p256dh, auth} ключи шифрования
- device_name: название устройства/браузера (опционально)
"""
from push_notifications.models import WebPushDevice
endpoint = request.data.get('endpoint')
keys = request.data.get('keys', {})
device_name = request.data.get('device_name', 'unknown')
if not endpoint or not keys:
return Response(
{'status': 'error', 'message': 'endpoint and keys required'},
status=status.HTTP_400_BAD_REQUEST
)
# Регистрируем устройство
device, created = WebPushDevice.objects.update_or_create(
user=request.user,
@ -543,10 +560,14 @@ def subscribe_push(request):
'active': True,
}
)
return Response({
'status': 'success',
'message': 'Push subscription created' if created else 'Push subscription updated',
'message': (
'Push subscription created'
if created
else 'Push subscription updated'
),
'created': created,
'device_id': device.id
})
@ -564,9 +585,9 @@ def subscribe_push(request):
def unsubscribe_push(request):
"""Отписка от Web Push уведомлений"""
from push_notifications.models import WebPushDevice
endpoint = request.data.get('endpoint')
if endpoint:
deleted_count, _ = WebPushDevice.objects.filter(
user=request.user,
@ -577,7 +598,7 @@ def unsubscribe_push(request):
deleted_count, _ = WebPushDevice.objects.filter(
user=request.user
).delete()
return Response({
'status': 'success',
'message': f'Deleted {deleted_count} push subscription(s)',

View File

@ -25,3 +25,9 @@ norecursedirs = .git .venv __pycache__ node_modules staticfiles media scripts te
# Опции для вывода
console_output_style = progress
filterwarnings =
ignore:tagMap is deprecated\. Please use TAG_MAP instead\.:DeprecationWarning:pyasn1\.codec\.ber\.encoder
ignore:typeMap is deprecated\. Please use TYPE_MAP instead\.:DeprecationWarning:pyasn1\.codec\.ber\.encoder
ignore:datetime\.datetime\.utcfromtimestamp\(\) is deprecated:DeprecationWarning:ldapdb\.models\.fields
ignore:The 'django-fsm' package has been integrated into 'viewflow' as 'viewflow\.fsm'.*:UserWarning:django_fsm