diff --git a/chats/admin.py b/chats/admin.py index 8759ddc8..3609acda 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -1,6 +1,12 @@ from django.contrib import admin -from chats.models import ProjectChat, DirectChat, ProjectChatMessage, DirectChatMessage +from chats.models import ( + ProjectChat, + DirectChat, + ProjectChatMessage, + DirectChatMessage, + FileToMessage, +) @admin.display(description="Пользователи чата") @@ -38,3 +44,17 @@ class ProjectChatMessageAdmin(admin.ModelAdmin): class DirectChatMessageAdmin(admin.ModelAdmin): list_display = ("id", "author", "chat", "created_at") list_display_links = ("id", "author", "chat") + + +@admin.register(FileToMessage) +class FileToMessageAdmin(admin.ModelAdmin): + list_display = ( + "file", + "direct_message", + "project_message", + ) + list_display_links = ( + "file", + "direct_message", + "project_message", + ) diff --git a/chats/consumers/event_types/DirectEvent.py b/chats/consumers/event_types/DirectEvent.py index d16f90da..97d155dc 100644 --- a/chats/consumers/event_types/DirectEvent.py +++ b/chats/consumers/event_types/DirectEvent.py @@ -7,6 +7,7 @@ get_user_channel_cache_key, create_message, get_chat_and_user_ids_from_content, + match_files_and_messages, ) from chats.serializers import DirectChatMessageListSerializer @@ -47,9 +48,16 @@ async def process_new_message_event(self, event: Event, room_name: str): reply_to=reply_to_message, ) + messages = { + "direct_message": msg, + "project_message": None, + } + await match_files_and_messages(event.content["file_urls"], messages) + message_data = await sync_to_async( lambda: (DirectChatMessageListSerializer(msg)).data )() + content = { "chat_id": chat_id, "message": message_data, diff --git a/chats/consumers/event_types/ProjectEvent.py b/chats/consumers/event_types/ProjectEvent.py index 67e8e88f..bba09096 100644 --- a/chats/consumers/event_types/ProjectEvent.py +++ b/chats/consumers/event_types/ProjectEvent.py @@ -1,15 +1,15 @@ from asgiref.sync import sync_to_async from chats.models import ProjectChat, ProjectChatMessage -from chats.utils import create_message +from chats.utils import create_message, match_files_and_messages from chats.websockets_settings import Event, EventType from chats.exceptions import ( WrongChatIdException, UserNotInChatException, UserNotMessageAuthorException, ) + from chats.serializers import ( ProjectChatMessageListSerializer, - DirectChatMessageListSerializer, ) @@ -45,8 +45,14 @@ async def process_new_message_event(self, event: Event, room_name: str): reply_to=reply_to_message, ) + messages = { + "direct_message": None, + "project_message": msg, + } + await match_files_and_messages(event.content["file_urls"], messages) + message_data = await sync_to_async( - lambda: (DirectChatMessageListSerializer(msg)).data + lambda: (ProjectChatMessageListSerializer(msg)).data )() content = { "chat_id": chat_id, diff --git a/chats/migrations/0005_chatattachment.py b/chats/migrations/0005_chatattachment.py index d38322b6..15263184 100644 --- a/chats/migrations/0005_chatattachment.py +++ b/chats/migrations/0005_chatattachment.py @@ -26,9 +26,6 @@ class Migration(migrations.Migration): to="files.userfile", ), ), - ("name", models.CharField(default="file", max_length=512)), - ("extension", models.CharField(blank=True, default="", max_length=32)), - ("size", models.PositiveBigIntegerField(default=1)), ], bases=("files.userfile",), ), diff --git a/chats/migrations/0008_remove_directchatmessage_file_url_and_more.py b/chats/migrations/0008_remove_directchatmessage_file_url_and_more.py new file mode 100644 index 00000000..57b77f07 --- /dev/null +++ b/chats/migrations/0008_remove_directchatmessage_file_url_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.1.3 on 2023-03-27 18:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0007_remove_directchatmessage_file_url_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="directchatmessage", + name="file_url", + ), + migrations.RemoveField( + model_name="projectchatmessage", + name="file_url", + ), + ] diff --git a/chats/migrations/0009_filetomessage.py b/chats/migrations/0009_filetomessage.py new file mode 100644 index 00000000..d43a820f --- /dev/null +++ b/chats/migrations/0009_filetomessage.py @@ -0,0 +1,52 @@ +# Generated by Django 4.1.3 on 2023-03-28 14:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0003_userfile_extension_userfile_name_userfile_size"), + ("chats", "0008_remove_directchatmessage_file_url_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="FileToMessage", + fields=[ + ( + "file", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="file_to_message", + serialize=False, + to="files.userfile", + ), + ), + ( + "direct_message", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="file_to_direct_message", + to="chats.directchatmessage", + ), + ), + ( + "project_message", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="file_to_direct_message", + to="chats.projectchatmessage", + ), + ), + ], + options={ + "verbose_name": "Связка файла и сообщения", + "verbose_name_plural": "Связки файлов и сообщений", + }, + ), + ] diff --git a/chats/migrations/0010_alter_filetomessage_file.py b/chats/migrations/0010_alter_filetomessage_file.py new file mode 100644 index 00000000..0c5cda16 --- /dev/null +++ b/chats/migrations/0010_alter_filetomessage_file.py @@ -0,0 +1,26 @@ +# Generated by Django 4.1.3 on 2023-03-28 14:07 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0003_userfile_extension_userfile_name_userfile_size"), + ("chats", "0009_filetomessage"), + ] + + operations = [ + migrations.AlterField( + model_name="filetomessage", + name="file", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="file_to_message", + serialize=False, + to="files.userfile", + ), + ), + ] diff --git a/chats/models.py b/chats/models.py index 88ac9549..9ee20d2f 100644 --- a/chats/models.py +++ b/chats/models.py @@ -5,6 +5,7 @@ from django.core.exceptions import ValidationError from django.db import models +from files.models import UserFile from projects.models import Project User = get_user_model() @@ -283,3 +284,32 @@ def __str__(self): class Meta: verbose_name = "Сообщение в личном чате" verbose_name_plural = "Сообщения в личных чатах" + + +class FileToMessage(models.Model): + file = models.OneToOneField( + UserFile, + on_delete=models.CASCADE, + related_name="file_to_message", + primary_key=True, + null=False, + ) + direct_message = models.ForeignKey( + DirectChatMessage, + on_delete=models.CASCADE, + related_name="file_to_direct_message", + null=True, + ) + project_message = models.ForeignKey( + ProjectChatMessage, + on_delete=models.CASCADE, + related_name="file_to_direct_message", + null=True, + ) + + def __str__(self): + return f"FileToMessage<{self.file}>" + + class Meta: + verbose_name = "Связка файла и сообщения" + verbose_name_plural = "Связки файлов и сообщений" diff --git a/chats/serializers.py b/chats/serializers.py index cf1ead43..5471589b 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -1,6 +1,12 @@ from rest_framework import serializers -from chats.models import DirectChat, ProjectChat, DirectChatMessage, ProjectChatMessage +from chats.models import ( + DirectChat, + ProjectChat, + DirectChatMessage, + ProjectChatMessage, +) +from files.serializers import UserFileSerializer from users.serializers import UserListSerializer, UserDetailSerializer @@ -105,6 +111,15 @@ class Meta: class DirectChatMessageListSerializer(serializers.ModelSerializer): author = UserDetailSerializer() reply_to = DirectChatMessageSerializer(allow_null=True) + files = serializers.SerializerMethodField() + + @classmethod + def get_files(cls, message: DirectChatMessage): + data = [] + for file_to_message in message.file_to_direct_message.all(): + file_data = UserFileSerializer(file_to_message.file).data + data.append(file_data) + return data class Meta: model = DirectChatMessage @@ -113,6 +128,7 @@ class Meta: "author", "text", "reply_to", + "files", "is_edited", "is_read", "is_deleted", @@ -144,6 +160,15 @@ class Meta: class ProjectChatMessageListSerializer(serializers.ModelSerializer): author = UserDetailSerializer() reply_to = ProjectChatMessageSerializer(allow_null=True) + files = serializers.SerializerMethodField() + + @classmethod + def get_files(cls, message: DirectChatMessage): + data = [] + for file_to_message in message.file_to_direct_message.all(): + file_data = UserFileSerializer(file_to_message.file).data + data.append(file_data) + return data class Meta: model = ProjectChatMessage @@ -151,6 +176,7 @@ class Meta: "id", "author", "text", + "files", "reply_to", "is_edited", "is_read", diff --git a/chats/urls.py b/chats/urls.py index 9211fe6f..a66949ae 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -7,14 +7,12 @@ ProjectChatMessageList, ProjectChatDetail, DirectChatDetail, - # ChatAttachmentView, ) app_name = "chats" urlpatterns = [ path("directs/", DirectChatList.as_view(), name="direct-chat-list"), - # path("attachments/", ChatAttachmentView.as_view(), name="chat-attachments"), path("directs//", DirectChatDetail.as_view(), name="direct-chat-detail"), path("projects/", ProjectChatList.as_view(), name="project-chat-list"), path("projects//", ProjectChatDetail.as_view(), name="project-chat-detail"), diff --git a/chats/utils.py b/chats/utils.py index f5427a83..993531e7 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -9,7 +9,8 @@ WrongChatIdException, NonMatchingDirectChatIdException, ) -from chats.models import DirectChatMessage, ProjectChatMessage +from chats.models import DirectChatMessage, ProjectChatMessage, FileToMessage +from files.models import UserFile User = get_user_model() @@ -93,3 +94,24 @@ async def get_chat_and_user_ids_from_content(content, current_user) -> tuple[str f"User {current_user.id} is not a member of chat {chat_id}" ) return chat_id, other_user + + +async def create_file_to_message( + direct_message: Union[str, None, DirectChatMessage], + project_message: Union[str, None, ProjectChatMessage], + file: str, +) -> FileToMessage: + return await sync_to_async(FileToMessage.objects.create)( + direct_message=direct_message, project_message=project_message, file=file + ) + + +async def match_files_and_messages(file_urls, messages): + for url in file_urls: + file = await sync_to_async(UserFile.objects.get)(pk=url) + # implicitly matches a file and a message + await create_file_to_message( + direct_message=messages["direct_message"], + project_message=messages["project_message"], + file=file, + ) diff --git a/chats/views.py b/chats/views.py index 2757f24b..b3ad5477 100644 --- a/chats/views.py +++ b/chats/views.py @@ -136,50 +136,3 @@ def post(self, request, *args, **kwargs): self.perform_create(serializer) headers = self.get_success_headers(serializer.data) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - - -# class ChatAttachmentView(GenericAPIView): -# permission_classes = [IsAuthenticatedOrReadOnly] -# serializer_class = ChatAttachmentSerializer -# queryset = ChatAttachment.objects.all() -# -# @transaction.atomic -# def post(self, request): -# """creates a UserFile object and uploads the file to selectel""" -# -# file = request.FILES["file"] -# file_api = FileAPI(file, request.user) -# status_code, url = file_api.upload() -# -# if status_code == 201: -# file_data = get_file_data(file) -# ChatAttachment.objects.create( -# user=request.user, -# link=url, -# name=file_data["name"], -# extension=file_data["extension"], -# size=file_data["size"], -# ) -# -# return Response("Failed to upload file", status=status.HTTP_409_CONFLICT) -# -# def delete(self, request, *args, **kwargs): -# """deletes the file (only if the request is sent by the user who owns it!) -# The link has to be specified in the JSON body, not in the URL arguments. -# """ -# # get the link from the query -# if request.query_params and (request.query_params.get("link") is not None): -# link = request.query_params.get("link") -# else: -# return Response( -# { -# "error": "you have to pass the link of the object you want to delete in query parameters" -# }, -# status=status.HTTP_400_BAD_REQUEST, -# ) -# instance = get_object_or_404(self.get_queryset(), link=link) -# if instance.user != request.user: -# return Response(status=status.HTTP_403_FORBIDDEN) -# FileAPI.delete(instance.link) # delete the file via api -# instance.delete() # delete the UserFile object -# return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/files/helpers.py b/files/helpers.py index beffbbca..1385a73f 100644 --- a/files/helpers.py +++ b/files/helpers.py @@ -83,3 +83,13 @@ def _generate_selectel_swift_file_url(self) -> str: link + f"{abs(hash(self.user.email))}/{abs(hash(self.file.name))}_{abs(hash(time.time()))}{extension}" ) + + +def get_file_info(request): + name, ext = request.name.split(".") + + return { + "size": request.size, + "name": name, + "extension": ext, + } diff --git a/files/migrations/0003_userfile_extension_userfile_name_userfile_size.py b/files/migrations/0003_userfile_extension_userfile_name_userfile_size.py new file mode 100644 index 00000000..b5837942 --- /dev/null +++ b/files/migrations/0003_userfile_extension_userfile_name_userfile_size.py @@ -0,0 +1,28 @@ +# Generated by Django 4.1.3 on 2023-03-27 18:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0002_remove_userfile_id_alter_userfile_link"), + ] + + operations = [ + migrations.AddField( + model_name="userfile", + name="extension", + field=models.TextField(blank=True, default=""), + ), + migrations.AddField( + model_name="userfile", + name="name", + field=models.TextField(default="file"), + ), + migrations.AddField( + model_name="userfile", + name="size", + field=models.PositiveBigIntegerField(blank=True, default=1), + ), + ] diff --git a/files/models.py b/files/models.py index 420045d4..42b35a08 100644 --- a/files/models.py +++ b/files/models.py @@ -17,6 +17,9 @@ class UserFile(models.Model): user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) link = models.URLField(primary_key=True, null=False) datetime_uploaded = models.DateTimeField(auto_now_add=True) + name = models.TextField(blank=False, default="file") + extension = models.TextField(blank=True, default="") + size = models.PositiveBigIntegerField(null=False, blank=True, default=1) def __str__(self): return f"UserFile by {self.user}, {self.link}" diff --git a/files/serializers.py b/files/serializers.py index 256e8a8b..76857d8f 100644 --- a/files/serializers.py +++ b/files/serializers.py @@ -6,4 +6,11 @@ class UserFileSerializer(ModelSerializer): class Meta: model = UserFile - fields = ["user", "link", "datetime_uploaded"] + fields = [ + "name", + "extension", + "size", + "link", + "user", + "datetime_uploaded", + ] diff --git a/files/views.py b/files/views.py index 2e72556a..f67d870d 100644 --- a/files/views.py +++ b/files/views.py @@ -4,7 +4,7 @@ from rest_framework.generics import get_object_or_404 from rest_framework.response import Response -from files.helpers import FileAPI +from files.helpers import FileAPI, get_file_info from files.models import UserFile from files.serializers import UserFileSerializer @@ -21,7 +21,14 @@ def post(self, request): status_code, url = file_api.upload() if status_code == 201: - UserFile.objects.create(user=request.user, link=url) + info = get_file_info(request.FILES["file"]) + UserFile.objects.create( + user=request.user, + link=url, + name=info["name"], + size=info["size"], + extension=info["extension"], + ) return Response({"url": url}, status=status.HTTP_201_CREATED) return Response("Failed to upload file", status=status.HTTP_409_CONFLICT) @@ -45,4 +52,5 @@ def delete(self, request, *args, **kwargs): return Response(status=status.HTTP_403_FORBIDDEN) FileAPI.delete(instance.link) # delete the file via api instance.delete() # delete the UserFile object + return Response(status=status.HTTP_204_NO_CONTENT)