diff --git a/backend/backend/settings.py b/backend/backend/settings.py index 0142811..39bb63b 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -14,8 +14,24 @@ DEBUG = os.getenv('DEBUG', default=False) -ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', default='localhost,127.0.0.1,').split(',') - +ALLOWED_HOSTS = os.getenv( + 'ALLOWED_HOSTS', default='localhost,127.0.0.1,').split(',') + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'file': { + 'level': 'DEBUG', + 'class': 'logging.FileHandler', + 'filename': 'debug.log', + }, + }, + 'root': { + 'handlers': ['file'], + 'level': 'DEBUG', + }, +} INSTALLED_APPS = [ "daphne", 'django.contrib.admin', diff --git a/backend/chats/admin.py b/backend/chats/admin.py index 9e84fe9..bd63a75 100644 --- a/backend/chats/admin.py +++ b/backend/chats/admin.py @@ -1,15 +1,45 @@ -"""Административные настройки приложения chats.""" - from django.contrib import admin -from django.contrib.auth import get_user_model from .models import Attachment, Chat, GroupChat, Message, PersonalChat -User = get_user_model() +class ChatAdmin(admin.ModelAdmin): + filter_horizontal = ('blocked_users',) + + def get_name(self, obj): + return str(obj) + + get_name.short_description = 'Имя' + + def get_blocked_users(self, obj): + return ", ".join([str(user) for user in obj.blocked_users.all()]) + + get_blocked_users.short_description = 'Заблокированные участники' + + def initiator(self, obj): + return obj.initiator + + initiator.short_description = 'Инициатор' + initiator.admin_order_field = 'initiator' + + +class PersonalChatAdmin(ChatAdmin): + list_display = ('id', 'get_name', 'get_blocked_users', + 'initiator', 'receiver', 'date_created') + + +class GroupChatAdmin(ChatAdmin): + list_display = ('id', 'get_name', 'get_blocked_users', + 'initiator', 'display_members', 'date_created') + + def display_members(self, obj): + return ", ".join([str(user) for user in obj.members.all()]) + + display_members.short_description = 'Участники' -admin.site.register(Chat) + +admin.site.register(Chat, ChatAdmin) admin.site.register(Attachment) admin.site.register(Message) -admin.site.register(PersonalChat) -admin.site.register(GroupChat) +admin.site.register(PersonalChat, PersonalChatAdmin) +admin.site.register(GroupChat, GroupChatAdmin) diff --git a/backend/chats/consumers.py b/backend/chats/consumers.py index 13a8adf..a9d66dd 100644 --- a/backend/chats/consumers.py +++ b/backend/chats/consumers.py @@ -41,6 +41,19 @@ def disconnect(self, close_code): def receive(self, text_data=None, bytes_data=None): # parse the json data into dictionary object text_data_json = json.loads(text_data) + if text_data_json['type'] == 'block_user': + user_slug = text_data_json['user_slug'] + blocked = text_data_json['blocked'] + + # Отправить уведомление о блокировке/разблокировке через WebSocket + async_to_sync(self.channel_layer.group_send)( + self.room_group_name, + { + 'type': 'block_user_notification', + 'user_slug': user_slug, + 'blocked': blocked, + } + ) # Send message to room group chat_type = {"type": "chat_message"} @@ -90,3 +103,12 @@ def chat_message(self, event): serializer.data ) ) + + def block_user_notification(self, event): + user_slug = event['user_slug'] + blocked = event['blocked'] + self.send(text_data=json.dumps({ + 'type': 'block_user', + 'user_slug': user_slug, + 'blocked': blocked + })) diff --git a/backend/chats/models.py b/backend/chats/models.py index 293ae89..c1268ee 100644 --- a/backend/chats/models.py +++ b/backend/chats/models.py @@ -20,6 +20,24 @@ class Chat(DateCreatedModel, DateEditedModel): null=True, related_name="chat_starter" ) + blocked_users = models.ManyToManyField( + User, + verbose_name='Заблокированные пользователи', + related_name='bloked_chats', + blank=True, + help_text='Список пользователей, которых вы заблокировали в этом чате.' + ) + + def block_user(self, user): + if user not in self.blocked_users.all(): + self.blocked_users.add(user) + + def unblock_user(self, user): + if user in self.blocked_users.all(): + self.blocked_users.remove(user) + + def is_user_blocked(self, user): + return user in self.blocked_users.all() objects = InheritanceManager() @@ -49,6 +67,10 @@ class Meta: verbose_name = 'Личный чат' verbose_name_plural = 'Личные чаты' + def block_user(self, user): + if user not in [self.initiator, self.receiver]: + self.blocked_users.add(user) + class GroupChat(Chat): """Модель группового чата.""" @@ -124,6 +146,19 @@ class Message(DateEditedModel): verbose_name='Фото для отправки', help_text='Фото для отправки' ) + voice_message = models.FileField( + upload_to='voice_messages/', + blank=True, + null=True, + verbose_name='Голосовое сообщение', + help_text='Голосовое сообщение' + ) + emojis = models.CharField( + max_length=255, + blank=True, + verbose_name='Смайлы', + help_text='Текстовые символы смайлов' + ) responding_to = models.ForeignKey( 'self', on_delete=models.CASCADE, diff --git a/backend/chats/serializers.py b/backend/chats/serializers.py index 881d1a4..48c80ce 100644 --- a/backend/chats/serializers.py +++ b/backend/chats/serializers.py @@ -1,15 +1,22 @@ """Сериализаторы приложения chats.""" + from django.contrib.auth import get_user_model from rest_framework import serializers -from chats.models import Attachment, GroupChat, Message, PersonalChat +from chats.models import GroupChat, Message, PersonalChat from core.constants import MAX_MESSAGE_LENGTH from users.serializers import UserShortSerializer +from .validators import (validate_audio_extension, validate_file_size, + validate_image_extension, validate_pdf_extension) + # from django.shortcuts import get_object_or_404 +# from rest_framework.exceptions import PermissionDenied +# from django.shortcuts import get_object_or_404 +# from .models import Chat User = get_user_model() @@ -20,6 +27,7 @@ class MessageSerializer(serializers.ModelSerializer): allow_null=True, max_length=10000 ) + is_read = serializers.SerializerMethodField() read_by = UserShortSerializer( many=True, read_only=True @@ -27,19 +35,39 @@ class MessageSerializer(serializers.ModelSerializer): file_to_send = serializers.FileField( write_only=False, required=False, - allow_empty_file=True + allow_empty_file=True, + validators=[validate_file_size, validate_pdf_extension] ) + photo_to_send = serializers.ImageField( write_only=False, required=False, - allow_empty_file=True + allow_empty_file=True, + validators=[validate_file_size, validate_image_extension] + ) + voice_message = serializers.FileField( + required=False, + allow_empty_file=True, + validators=[validate_file_size, validate_audio_extension] ) + emojis = serializers.CharField(max_length=255, required=False) + sender = serializers.SlugRelatedField( slug_field='slug', read_only=True ) chat = serializers.HiddenField(default=None) + def get_is_read(self, instance): + user = self.context.get( + 'request').user if self.context.get('request') else None + if user is None: + return False + return ( + instance.read_by.filter(id=user.id).exists() and + user != instance.sender + ) + class Meta: model = Message fields = [ @@ -48,6 +76,8 @@ class Meta: 'text', 'file_to_send', 'photo_to_send', + 'voice_message', + 'emojis', 'responding_to', 'sender_keep', 'is_read', @@ -69,60 +99,91 @@ class Meta: 'timestamp', ) - # def create(self, validated_data): - # file_to_send = validated_data.pop('file_to_send', None) - # photo_to_send = validated_data.pop('photo_to_send', None) - # chatname = validated_data['chat'] - # chat = get_object_or_404(Chat, name=chatname) - - # if not chat.messages.exists() and (file_to_send or photo_to_send): - # raise serializers.ValidationError( - # "Нельзя отправить фото или файл первым сообщением" - # ) - - # validated_data['sender'] = self.context['request'].user - # message = Message.objects.create(**validated_data) + def create(self, validated_data): - # if file_to_send: - # Attachment.objects.create( - # name=file_to_send.name, - # content=file_to_send.read(), - # message=message - # ) - - # if photo_to_send: - # Attachment.objects.create( - # name=photo_to_send.name, - # content=photo_to_send.read(), - # message=message - # ) - - # return message - - # def update(self, instance, validated_data): - # file_to_send = validated_data.pop('file_to_send', None) - # photo_to_send = validated_data.pop('photo_to_send', None) - - # for key, value in validated_data.items(): - # setattr(instance, key, value) - - # if file_to_send: - # Attachment.objects.create( - # name=file_to_send.name, - # content=file_to_send.read(), - # message=instance - # ) - - # if photo_to_send: - # Attachment.objects.create( - # name=photo_to_send.name, - # content=photo_to_send.read(), - # message=instance - # ) - - # instance.save() + file_to_send = validated_data.get('file_to_send', None) + photo_to_send = validated_data.get('photo_to_send', None) + voice_message = validated_data.get('voice_message', None) + emojis = validated_data.get('emojis', None) + text = validated_data.get('text', '') + # chatname = validated_data['chat'] - # return instance + validated_data['sender_keep'] = True + + chat = self.context.get('chat') + + if not chat: + raise serializers.ValidationError( + "Chat object is missing in the context") + validated_data['chat'] = chat + if not chat.messages.exists() and (file_to_send or photo_to_send): + raise serializers.ValidationError( + "Нельзя отправить фото или файл первым сообщением" + ) + if voice_message: + text = f'[Voice Message: {voice_message.name}]' + if emojis: + text += emojis + + validated_data['sender'] = self.context['request'].user + message = Message.objects.create(**validated_data) + + if file_to_send: + Attachment.objects.create( + name=file_to_send.name, + content=file_to_send.read(), + message=message + ) + + if photo_to_send: + Attachment.objects.create( + name=photo_to_send.name, + content=photo_to_send.read(), + message=message + ) + + message.text = text + message.save() + return message + + def update(self, instance, validated_data): + file_to_send = validated_data.get('file_to_send', None) + photo_to_send = validated_data.get('photo_to_send', None) + voice_message = validated_data.get('voice_message', None) + emojis = validated_data.get('emojis', None) + text = validated_data.get('text', '') + chat = self.context.get('chat') + if not chat: + raise serializers.ValidationError( + "Chat object is missing in the context") + + for key, value in validated_data.items(): + setattr(instance, key, value) + + if voice_message: + text = f'[Voice Message: {voice_message.name}]' + if emojis: + text += emojis + + instance.text = text + + if file_to_send: + Attachment.objects.create( + name=file_to_send.name, + content=file_to_send.read(), + message=instance + ) + + if photo_to_send: + Attachment.objects.create( + name=photo_to_send.name, + content=photo_to_send.read(), + message=instance + ) + + instance.save() + + return instance class ChatListSerializer(serializers.ModelSerializer): @@ -161,6 +222,9 @@ class ChatSerializer(serializers.ModelSerializer): many=True, read_only=True ) + blocked_users = serializers.StringRelatedField( + many=True + ) class Meta: model = PersonalChat @@ -169,6 +233,7 @@ class Meta: 'initiator', 'receiver', "messages", + "blocked_users", ) read_only_fields = ( 'id', @@ -199,9 +264,26 @@ class Meta: ) +class GroupChatSerializer(serializers.ModelSerializer): + """Сериализатор для группового чата.""" + + initiator = UserShortSerializer(many=False, read_only=True) + members = UserShortSerializer(many=True, read_only=True) + + class Meta: + model = GroupChat + fields = ( + 'id', + 'name', + 'initiator', + 'members', + ) + + class GroupChatCreateSerializer(serializers.ModelSerializer): """Сериализатор для создания группового чата.""" + initiator = UserShortSerializer(many=False, read_only=True) members = serializers.SlugRelatedField( slug_field='slug', queryset=User.objects.all(), @@ -212,38 +294,6 @@ class Meta: model = GroupChat fields = ( 'name', + 'initiator', "members", ) - - # def to_representation(self, instance): - # return ChatReprSerializer( - # instance, - # context={'request': self.context.get('request')} - # ).data - - # def create(self, validated_data): - # chat = Chat.objects.create(**validated_data) - # # ChatMembers.objects.create( - # # chat=chat, - # # member=creator, - # # is_creator=True - # # ) - # # ChatMembers.objects.create( - # # chat=chat, - # # member=companion - # # ) - # return chat - - -class AttachmentSerializer(serializers.ModelSerializer): - """Сериализатор модели Attachment.""" - - content = serializers.CharField(write_only=True) - - class Meta: - model = Attachment - fields = [ - 'name', - 'content', - 'message' - ] diff --git a/backend/chats/validators.py b/backend/chats/validators.py new file mode 100644 index 0000000..1dd9c67 --- /dev/null +++ b/backend/chats/validators.py @@ -0,0 +1,25 @@ +from django.core.exceptions import ValidationError + + +def validate_file_size(value): + max_size = 20 * 1024 * 1024 # 20 MB + if value.size > max_size: + raise ValidationError( + 'Файл слишком большой. Максимальный размер: 20 МБ.') + + +def validate_file_extension(value, allowed_extensions): + if not value.name.lower().endswith(allowed_extensions): + raise ValidationError('Недопустимый формат файла.') + + +def validate_pdf_extension(value): + validate_file_extension(value, ('.pdf',)) + + +def validate_image_extension(value): + validate_file_extension(value, ('.jpg', '.jpeg', '.png', '.gif')) + + +def validate_audio_extension(value): + validate_file_extension(value, ('.mp3', '.m4a')) diff --git a/backend/chats/views.py b/backend/chats/views.py index 34a712c..b687ad2 100644 --- a/backend/chats/views.py +++ b/backend/chats/views.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from django.db.models import Q +from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 from asgiref.sync import async_to_sync @@ -13,9 +14,10 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from chats.models import Chat, Message, PersonalChat +from chats.models import Chat, GroupChat, Message, PersonalChat from chats.serializers import (ChatListSerializer, ChatSerializer, - ChatStartSerializer, MessageSerializer) + ChatStartSerializer, GroupChatCreateSerializer, + GroupChatSerializer, MessageSerializer) from core.pagination import LimitPagination # from core.permissions import ActiveChatOrReceiverOnly @@ -47,7 +49,7 @@ class ChatViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): serializer_class = ChatSerializer - http_method_names = ['get', 'post', 'head'] + http_method_names = ['get', 'post', 'head', 'put'] permission_classes = [ IsAuthenticated, ] @@ -82,6 +84,8 @@ def get_serializer_class(self): return MessageSerializer case 'start_personal_chat': return ChatStartSerializer + case 'start_group_chat': + return GroupChatCreateSerializer return ChatSerializer def list(self, request, *args, **kwargs): @@ -133,6 +137,36 @@ def start_personal_chat(self, request, slug=None): status=status.HTTP_201_CREATED ) + @action( + methods=['post'], + detail=False, + permission_classes=(IsAuthenticated,), + serializer_class=GroupChatCreateSerializer, + url_path='start-group-chat' + ) + def start_group_chat(self, request): + """Создание группового чата.""" + current_user = request.user + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + chat_name = serializer.validated_data["name"] + existing_chat = GroupChat.objects.filter(name=chat_name).first() + + if existing_chat: + return Response( + {"detail": "Чат с таким названием уже существует."}, + status=status.HTTP_400_BAD_REQUEST + ) + group_chat = GroupChat.objects.create( + initiator=current_user, + name=serializer.validated_data["name"] + ) + group_chat.members.set(serializer.validated_data["members"]) + return Response( + GroupChatSerializer(group_chat).data, + status=status.HTTP_201_CREATED + ) + @action( methods=['post'], detail=True, @@ -143,9 +177,25 @@ def start_personal_chat(self, request, slug=None): def send_message(self, request, pk=None): """Отправить сообщение в чат""" chat = self.get_object() + +# serializer = self.get_serializer( +# data={**request.data}, +# # Передаем chat через контекст +# context={'request': request, 'chat': chat} +# ) + + if chat.is_user_blocked(request.user): + return Response( + { + "detail": "Вы не можете отправлять сообщения," + " так как вы заблокированы в этом чате." + }, + status=status.HTTP_403_FORBIDDEN + ) serializer = self.get_serializer(data={ **request.data }) + serializer.is_valid(raise_exception=True) serializer.save(chat=chat, sender=request.user) @@ -161,3 +211,153 @@ def send_message(self, request, pk=None): ChatSerializer(chat).data, status=status.HTTP_201_CREATED ) + + @action(detail=True, methods=['put']) + def update_message(self, request, pk=None): + """Обновить сообщение в чате""" + message_id = request.data.get('message_id') + chat = self.get_object() + + try: + message = chat.messages.get(id=message_id) + except Message.DoesNotExist: + return Response( + {"detail": "Message not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + serializer = MessageSerializer( + instance=message, + data=request.data, + context={'request': request}, + partial=True + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['get']) + def view_chat(self, request, pk=None): + """Просмотреть чат и обновить статус 'прочитано' для получателя""" + chat = self.get_object() + + user = self.request.user + + if chat.initiator == user or chat.receiver == user: + for message in chat.messages.exclude(read_by=user): + message.read_by.add(user) + return Response({"detail": "Chat read status updated."}) + + return HttpResponseForbidden( + "You don't have permission to access this chat." + ) + + @action(detail=True, methods=['post']) + def block_user(self, request, pk=None): + """Блокировка пользователя в чате""" + chat = self.get_object() + user_slug = request.data.get('slug') + user_to_block = get_object_or_404(User, slug=user_slug) + + if request.user == user_to_block: + return Response( + {"detail": "Нельзя заблокировать самого себя."}, + status=status.HTTP_400_BAD_REQUEST + ) + + if chat.initiator == request.user or chat.receiver == request.user: + if ( + user_to_block == chat.initiator + or user_to_block == chat.receiver + ): + # if chat.members.filter(id=user_to_block.id).exists(): + if user_to_block in chat.blocked_users.all(): + return Response( + {"detail": "Пользователь уже заблокирован " + "в этом чате."}, + status=status.HTTP_400_BAD_REQUEST + ) + + chat.blocked_users.add(user_to_block) + # Отправить обновление через веб-сокеты + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + f"chat_{chat.pk}", + { + "type": "block_user", + "user_slug": user_slug, + "blocked": True + } + ) + + return Response( + {"detail": "Пользователь заблокирован в этом чате."}, + status=status.HTTP_201_CREATED + ) + + return Response( + {"detail": "Пользователь не является участником чата"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return Response( + {"detail": "У вас нет прав для блокировки участников этого чата."}, + status=status.HTTP_403_FORBIDDEN + ) + + @action(detail=True, methods=['post']) + def unblock_user(self, request, pk=None): + """ + Разблокировка пользователя в чате + """ + chat = self.get_object() + user_slug = request.data.get('slug') + user_to_unblock = get_object_or_404(User, slug=user_slug) + + if request.user == user_to_unblock: + return Response( + {"detail": "Нельзя разблокировать самого себя"}, + status=status.HTTP_400_BAD_REQUEST + ) + + if chat.initiator == request.user or chat.receiver == request.user: + if ( + user_to_unblock == chat.initiator + or user_to_unblock == chat.receiver + ): + # if chat.members.filter(id=user_to_unblock.id).exists(): + if user_to_unblock in chat.blocked_users.all(): + chat.blocked_users.remove(user_to_unblock) + + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + f"chat_{chat.pk}", + { + "type": "block_user", + "user_slug": user_slug, + "blocked": False + } + ) + return Response( + {"detail": "Пользователь разблокирован в этом чате"}, + status=status.HTTP_200_OK + ) + + return Response( + {"detail": "Пользователь не заблокирован в этом чате"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return Response( + {"detail": "Пользователь не является участником чата"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return Response( + {"detail": "Вы не имеете права " + "разблокировать участников в этом чате"}, + status=status.HTTP_403_FORBIDDEN + ) diff --git a/backend/core/permissions.py b/backend/core/permissions.py index 1765881..15bedaa 100644 --- a/backend/core/permissions.py +++ b/backend/core/permissions.py @@ -4,10 +4,13 @@ class ActiveChatOrReceiverOnly(permissions.BasePermission): - """ - Разрешение на отправку сообщений для участников активного чата - или только для получателя. - """ + # """ + # Разрешение на отправку сообщений для участников активного чата + # или только для получателя. + # """ + + # def has_permission(self, request, view): + # return request.user.is_authenticated def has_permission(self, request, view): return request.user.is_authenticated diff --git a/backend/users/admin.py b/backend/users/admin.py index 9382646..e3ce78f 100644 --- a/backend/users/admin.py +++ b/backend/users/admin.py @@ -6,7 +6,7 @@ from django.contrib.auth.admin import UserAdmin from .models import (BlacklistEntry, Country, Goal, Interest, Language, Report, - User) + Review, User) class UserLanguageInlineAdmin(admin.TabularInline): @@ -125,6 +125,43 @@ def _languages(self, obj): # _foreign_languages.short_description = 'Изучаемые языки' +@admin.register(Review) +class ReviewAdmin(admin.ModelAdmin): + list_display = ('id', 'get_author_username', + 'get_recipient_username', 'get_text', 'is_approved') + list_filter = ('is_approved',) + search_fields = ('author__username', 'recipient__username', 'text') + + def get_author_username(self, obj): + return obj.author.username if obj.author else None + + get_author_username.short_description = 'Автор' + + def get_recipient_username(self, obj): + return obj.recipient.username if obj.recipient else None + + get_recipient_username.short_description = 'Получатель' + + def get_text(self, obj): + return obj.text + + get_text.short_description = 'Текст' + + actions = ['approve_reviews', 'reject_reviews'] + + def approve_reviews(self, request, queryset): + queryset.update(is_approved=True) + self.message_user(request, "Выбранные отзывы одобрены") + + approve_reviews.short_description = "Одобрить выбранные отзывы" + + def reject_reviews(self, request, queryset): + queryset.update(is_approved=False) + self.message_user(request, "Выбранные отзывы отклонены") + + reject_reviews.short_description = "Отклонить выбранные отзывы" + + admin.site.register(Language) admin.site.register(Country) admin.site.register(BlacklistEntry) diff --git a/backend/users/filters.py b/backend/users/filters.py index 39e1726..13e71d7 100644 --- a/backend/users/filters.py +++ b/backend/users/filters.py @@ -44,7 +44,7 @@ class UserFilter(df.FilterSet): age = AgeFilter() country = CustomFilterList( - field_name='country__code', lookup_expr='in') + field_name='country__name', lookup_expr='in') languages = CustomFilterList( field_name='languages__isocode', lookup_expr='in') skill_level = df.ChoiceFilter( diff --git a/backend/users/models.py b/backend/users/models.py index ff861ba..2de6516 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -384,6 +384,14 @@ def __str__(self): class Report(DateCreatedModel, DateEditedModel): + REASON_CHOICES = [ + ('pornography', 'Порнография'), + ('spam', 'Рассылка спама'), + ('fraud', 'Мошенничество'), + ('offensive_behavior', 'Оскорбительное поведение'), + ('copyright_violation', 'Нарушение авторских прав'), + ] + user = models.ForeignKey( User, on_delete=models.CASCADE, @@ -400,13 +408,19 @@ class Report(DateCreatedModel, DateEditedModel): ) reason = models.CharField( max_length=100, + choices=REASON_CHOICES, verbose_name='Причина жалобы', - help_text='Укажите причину данной жалобы.', + help_text='Выберите причину данной жалобы.', ) description = models.TextField( verbose_name='Описание', max_length=1000, help_text='Подробное описание проблемы или причины жалобы.', + blank=True, + ) + close_user_access = models.BooleanField( + default=False, + verbose_name='Закрыть пользователю доступ к моей странице' ) def __str__(self): @@ -415,3 +429,20 @@ def __str__(self): class Meta: verbose_name = 'Жалоба на пользователя' verbose_name_plural = 'Жалобы на пользователей' + + +class Review(DateCreatedModel, DateEditedModel): + author = models.ForeignKey( + User, + on_delete=models.CASCADE + ) + recipient = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name='received_reviews' + ) + text = models.TextField() + is_approved = models.BooleanField( + default=False, verbose_name='Модерация отзыва') + + def __str__(self): + return f"Review from {self.author} to {self.recipient}" diff --git a/backend/users/serializers.py b/backend/users/serializers.py index c9cb6f4..b027ba1 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -11,7 +11,9 @@ MAX_NATIVE_LANGUAGES, MIN_AGE) from users.fields import Base64ImageField, CreatableSlugRelatedField from users.models import (BlacklistEntry, Country, Goal, Interest, Language, - Report, User, UserLanguage) + Report, Review, User, UserLanguage) + +from .validators import ReportDescriptionValidator, ReviewTextValidator class LanguageSerializer(serializers.ModelSerializer): @@ -88,6 +90,36 @@ class Meta: read_only_fields = fields +class UserShortSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ( + 'slug', + 'username', + 'first_name', + 'avatar', + ) + read_only_fields = fields + + +class ReviewSerializer(serializers.ModelSerializer): + author = UserShortSerializer() + recipient = UserShortSerializer() + + class Meta: + model = Review + fields = '__all__' + + +class ReviewCreateSerializer(serializers.ModelSerializer): + text = serializers.CharField(validators=[ReviewTextValidator()]) + is_approved = serializers.BooleanField(read_only=True) + + class Meta: + model = Review + fields = ('text', 'is_approved') + + class UserReprSerializer(serializers.ModelSerializer): """Сериализатор для просмотра пользователя.""" @@ -113,6 +145,10 @@ class UserReprSerializer(serializers.ModelSerializer): source='get_role_display', read_only=True ) + is_blocked = serializers.BooleanField( + source='get_is_blocked', + read_only=True + ) class Meta: model = User @@ -133,6 +169,7 @@ class Meta: 'gender_is_hidden', 'age_is_hidden', 'role', + 'is_blocked', ) read_only_fields = fields @@ -149,6 +186,12 @@ def get_age(self, obj): return int(age_days / 365) return None + def get_is_blocked(self, obj): + current_user = self.context['request'].user + return obj.blacklist_entries_received.filter( + user=current_user + ).exists() + class UserProfileSerializer(DjoserSerializer, UserReprSerializer): """Сериализатор для заполнения профиля пользователя.""" @@ -161,7 +204,7 @@ class UserProfileSerializer(DjoserSerializer, UserReprSerializer): many=False, read_only=False, required=False, - slug_field='code', + slug_field='name', queryset=Country.objects.all() ) interests = CreatableSlugRelatedField( @@ -184,6 +227,7 @@ class UserProfileSerializer(DjoserSerializer, UserReprSerializer): read_only=False, required=False ) + reviews = ReviewSerializer(many=True, read_only=True) default_error_messages = { 'out_of_range': ( @@ -198,6 +242,7 @@ class Meta: model = User fields = UserReprSerializer.Meta.fields + ( 'birth_date', + 'reviews', ) read_only_fields = ( 'username', @@ -255,19 +300,8 @@ def update(self, instance, validated_data): return super().update(instance, validated_data) -class UserShortSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ( - 'slug', - 'username', - 'first_name', - 'avatar', - ) - read_only_fields = fields - - class BlacklistEntrySerializer(serializers.ModelSerializer): + """Сериализатор блокировки""" class Meta: model = BlacklistEntry fields = '__all__' @@ -275,9 +309,19 @@ class Meta: class ReportSerializer(serializers.ModelSerializer): + """Сериализатор жалоб""" + description = serializers.CharField( + required=False, + allow_blank=True, + validators=[ReportDescriptionValidator()] + ) + close_user_access = serializers.BooleanField( + help_text="Закрыть пользователю доступ к моей странице", + ) + class Meta: model = Report - fields = ('reason', 'description') + fields = ('reason', 'description', 'close_user_access') read_only_fields = ('reported_user',) diff --git a/backend/users/validators.py b/backend/users/validators.py index 186bea0..0b699ef 100644 --- a/backend/users/validators.py +++ b/backend/users/validators.py @@ -126,3 +126,42 @@ def validate_first_name(value): ): raise ValidationError( _('Длина имени должна быть от 2 до 12 символов.')) + + +class ReportDescriptionValidator: + """ + Валадатор поля description + """ + + def __call__(self, value): + + valid_chars = re.compile( + r'^[0-9a-zA-ZА-Яа-я?!@#%^$*+&_\-\\();:,./\s]*$') + if not valid_chars.match(value): + raise ValidationError( + "Описание содержит недопустимые символы.") + + if len(value) < 6: + raise ValidationError( + "Описание слишком короткое. Минимум 6 символов.") + + if len(value) > 1000: + raise ValidationError( + "Описание слишком длинное. Максимум 1000 символов.") + + +class ReviewTextValidator: + def __call__(self, value): + + allowed_chars_pattern = ( + r'^[0-9a-zA-Zа-яА-Я?!\@\#%\^\$\*\+\&\_\-\(\)\[\]\{\}\/\:\;,.' + + r']+$' + ) + + if not re.match(allowed_chars_pattern, value): + raise ValidationError( + "В отзыве присутствуют недопустимые символы.") + + if len(value) < 6 or len(value) > 1000: + raise ValidationError( + "Отзыв должен содержать от 6 до 1000 символов.") diff --git a/backend/users/views.py b/backend/users/views.py index 24e16d6..c3e26ca 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -1,5 +1,8 @@ """View-функции приложения users.""" +from datetime import timedelta + +from django.contrib.auth.models import AnonymousUser from django.db.models import Count, Q from django.utils import timezone @@ -10,17 +13,20 @@ inline_serializer) from rest_framework import filters, mixins, serializers, status, viewsets from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response +from chats.models import PersonalChat from core.permissions import (CanAccessProfileDetails, IsAdminOrModeratorReadOnly) from users.filters import UserFilter from users.models import (BlacklistEntry, Country, Goal, Interest, Language, - Report, User) + Report, Review, User) from users.serializers import (CountrySerializer, GoalSerializer, InterestSerializer, LanguageSerializer, - ReportSerializer, UserProfileSerializer, + ReportSerializer, ReviewCreateSerializer, + ReviewSerializer, UserProfileSerializer, UserReprSerializer) @@ -112,12 +118,39 @@ class UserViewSet(DjoserViewSet): ordering_fields = ['date_joined'] ordering = ['?'] lookup_field = 'slug' - http_method_names = ['get', 'post', 'patch', 'delete'] + http_method_names = ['get', 'post', 'patch', 'delete', 'put'] def get_queryset(self): """Исключение админов и модераторов из выборки.""" return User.objects.filter(Q(is_staff=False) | Q(role="User")) + # def get_permissions(self): + + # if self.action == 'reviews': + # return [UnauthenticatedUserReviewPermission()] + + # return [CanAccessProfileDetails()] + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + current_user = request.user + + if instance == current_user: + serializer = self.get_serializer(instance) + return Response(serializer.data) + + blocked_chats = PersonalChat.objects.filter( + Q(initiator=instance, blocked_users=current_user) | + Q(receiver=instance, blocked_users=current_user) + ) + + if blocked_chats.exists(): + raise PermissionDenied( + "Вы заблокированы и не можете просматривать этот профиль.") + + serializer = self.get_serializer(instance) + return Response(serializer.data) + @extend_schema( summary='Редактировать свой профиль', description='Редактировать свой профиль', @@ -126,7 +159,7 @@ def get_queryset(self): @extend_schema( summary='Просмотреть свой профиль', description='Просмотреть свой профиль', - methods=["get"], + methods=["get"] ) @extend_schema( summary='Удалить свой аккаунт', @@ -233,6 +266,90 @@ def unblock_user(self, request, slug=None): status=status.HTTP_400_BAD_REQUEST ) + @action( + detail=True, methods=['get', 'post', 'put'], + permission_classes=[AllowAny] + ) + def reviews(self, request, slug=None): + user = self.get_object() + + if request.method == 'POST': + if isinstance(request.user, AnonymousUser): + return Response( + {"detail": "Только аутентифицированные пользователи могут" + " оставлять отзывы."}, + status=status.HTTP_401_UNAUTHORIZED + ) + + if user == request.user: + return Response( + {"detail": "Вы не можете оставить отзыв на самого себя."}, + status=status.HTTP_400_BAD_REQUEST + ) + + last_review = Review.objects.filter( + author=request.user, recipient=user + ).order_by('-date_created').first() + if last_review: + time_since_last_review = ( + timezone.now() - last_review.date_created + ) + + if time_since_last_review < timedelta(days=7): + return Response( + {"detail": "Вы можете отправить новый" + " отзыв только раз в неделю."}, + status=status.HTTP_400_BAD_REQUEST + ) + serializer = ReviewCreateSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + recipient=user, author=request.user, is_approved=False) + return Response( + {"detail": "Отзыв успешно создан и ожидает модерации"}, + status=status.HTTP_201_CREATED + ) + return Response( + serializer.errors, + status=status.HTTP_400_BAD_REQUEST + ) + elif request.method == 'PUT': + if isinstance(request.user, AnonymousUser): + return Response( + {"detail": "Только аутентифицированные пользователи могут" + " редактировать отзывы."}, + status=status.HTTP_401_UNAUTHORIZED + ) + + review_id = request.data.get('review_id') + review = Review.objects.get(id=review_id) + + if review.author == request.user: + review.is_approved = False + review.text = request.data.get('text') + review.save() + return Response( + {"detail": "Отзыв успешно обновлен и и ожидает модерации"}, + status=status.HTTP_200_OK + ) + else: + return Response( + {"detail": "Вы не можете редактировать этот отзыв," + " так как вы не являетесь его автором."}, + status=status.HTTP_400_BAD_REQUEST + ) + + reviews = Review.objects.filter(recipient=user, is_approved=True) + if not reviews.exists(): + return Response( + {"detail": + "Отзывов пока нет, или проходят модерацию"}, + status=status.HTTP_200_OK + ) + + serializer = ReviewSerializer(reviews, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + @extend_schema( summary='Просмотреть все жалобы на пользователя', description=( @@ -250,45 +367,58 @@ def unblock_user(self, request, slug=None): methods=["post", "get"], detail=True, permission_classes=(IsAuthenticated, IsAdminOrModeratorReadOnly), - serializer_class=None + serializer_class=ReportSerializer ) def report_user(self, request, slug=None): """Просмотр и отправка жалоб.""" user = self.get_object() current_user = request.user + if user == current_user: + return Response( + {"detail": "Вы не можете отправлять жалобу на самого себя."}, + status=status.HTTP_400_BAD_REQUEST + ) + if request.method == 'POST': existing_report = Report.objects.filter( user=current_user, reported_user=user).first() if existing_report and ( - existing_report.date_created + timezone.timedelta(weeks=1) - > timezone.now() + existing_report.date_created + + timezone.timedelta(weeks=1) + > timezone.now() ): return Response( {"detail": "Вы не можете отправлять жалобу часто."}, status=status.HTTP_400_BAD_REQUEST ) - - if existing_report: - existing_report.date_created = timezone.now() - existing_report.reason = request.data.get( - 'reason', existing_report.reason) - existing_report.description = request.data.get( - 'description', existing_report.description) - existing_report.save() - else: - serializer = ReportSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(user=current_user, reported_user=user) - else: - return Response( - serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) - + serializer = ReportSerializer( + data=request.data, context={'request': request}) + if serializer.is_valid(): + close_user_access = serializer.validated_data.get( + "close_user_access", False) + + existing_block_entry = BlacklistEntry.objects.filter( + user=current_user, blocked_user=user + ).first() + + if close_user_access: + if existing_block_entry: + existing_block_entry.save() + else: + BlacklistEntry.objects.create( + user=current_user, + blocked_user=user, + ) + + serializer.save(user=current_user, reported_user=user) + return Response( + {"detail": "Жалоба успешно отправлена."}, + status=status.HTTP_200_OK + ) return Response( - {"detail": "Жалоба успешно отправлена."}, - status=status.HTTP_200_OK + serializer.errors, status=status.HTTP_400_BAD_REQUEST ) reports = Report.objects.filter(reported_user=user)