From b7f8fd748e6c3dc9387f1dd0e6b0988df3c5f8d7 Mon Sep 17 00:00:00 2001 From: yeezy-na-izi Date: Thu, 27 Oct 2022 03:52:04 +0300 Subject: [PATCH 01/87] Add chat app with some funcs --- chats/__init__.py | 0 chats/admin.py | 1 + chats/apps.py | 7 +++ chats/migrations/0001_initial.py | 78 +++++++++++++++++++++++++++ chats/migrations/__init__.py | 0 chats/models.py | 49 +++++++++++++++++ chats/serializers.py | 49 +++++++++++++++++ chats/tests.py | 1 + chats/urls.py | 12 +++++ chats/views.py | 90 ++++++++++++++++++++++++++++++++ procollab/settings.py | 1 + procollab/urls.py | 1 + 12 files changed, 289 insertions(+) create mode 100644 chats/__init__.py create mode 100644 chats/admin.py create mode 100644 chats/apps.py create mode 100644 chats/migrations/0001_initial.py create mode 100644 chats/migrations/__init__.py create mode 100644 chats/models.py create mode 100644 chats/serializers.py create mode 100644 chats/tests.py create mode 100644 chats/urls.py create mode 100644 chats/views.py diff --git a/chats/__init__.py b/chats/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/chats/admin.py b/chats/admin.py new file mode 100644 index 00000000..846f6b40 --- /dev/null +++ b/chats/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/chats/apps.py b/chats/apps.py new file mode 100644 index 00000000..420b4fbf --- /dev/null +++ b/chats/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ChatsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "chats" + verbose_name = "Чаты" diff --git a/chats/migrations/0001_initial.py b/chats/migrations/0001_initial.py new file mode 100644 index 00000000..2c10b126 --- /dev/null +++ b/chats/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 4.1.2 on 2022-10-27 00:09 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Chat", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(blank=True, max_length=255, null=True)), + ( + "users", + models.ManyToManyField( + related_name="chats", to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "verbose_name": "Чат", + "verbose_name_plural": "Чаты", + }, + ), + migrations.CreateModel( + name="Message", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="chats.chat", + ), + ), + ], + options={ + "verbose_name": "Сообщение", + "verbose_name_plural": "Сообщения", + }, + ), + ] diff --git a/chats/migrations/__init__.py b/chats/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/chats/models.py b/chats/models.py new file mode 100644 index 00000000..ee158970 --- /dev/null +++ b/chats/models.py @@ -0,0 +1,49 @@ +from django.db import models + + +class Chat(models.Model): + """ + Chat model + + Attributes: + name: A CharField name of the chat. + users: A ManyToManyField referring to the User model. + """ + + users = models.ManyToManyField("users.CustomUser", related_name="chats") + name = models.CharField(max_length=255, blank=True, null=True) + + def __str__(self): + return self.name or f"Chat {self.pk}" + + class Meta: + verbose_name = "Чат" + verbose_name_plural = "Чаты" + + +class Message(models.Model): + """ + Message model + + Attributes: + chat: A ForeignKey referring to the Chat model. + author: A ForeignKey referring to the User model. + text: A TextField containing message text. + created_at: A DateTimeField indicating date of creation. + + """ + + chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name="messages") + author = models.ForeignKey( + "users.CustomUser", on_delete=models.CASCADE, related_name="messages" + ) + text = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Message {self.pk}" + + class Meta: + verbose_name = "Сообщение" + verbose_name_plural = "Сообщения" + ordering = ["-created_at"] diff --git a/chats/serializers.py b/chats/serializers.py new file mode 100644 index 00000000..a801117b --- /dev/null +++ b/chats/serializers.py @@ -0,0 +1,49 @@ +from rest_framework import serializers + +from chats.models import Chat, Message + + +class ChatSerializer(serializers.ModelSerializer): + class Meta: + model = Chat + fields = [ + "id", + "name", + "users", + ] + + +class MessageInChatSerializer(serializers.ModelSerializer): + class Meta: + model = Message + fields = [ + "id", + "text", + "author", + "created_at", + ] + + +class ChatDetailSerializer(serializers.ModelSerializer): + messages = MessageInChatSerializer(many=True, read_only=True) + + class Meta: + model = Chat + fields = [ + "id", + "name", + "users", + "messages", + ] + + +class MessageSerializer(serializers.ModelSerializer): + class Meta: + model = Message + fields = [ + "id", + "chat", + "author", + "text", + "created_at", + ] diff --git a/chats/tests.py b/chats/tests.py new file mode 100644 index 00000000..a39b155a --- /dev/null +++ b/chats/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/chats/urls.py b/chats/urls.py new file mode 100644 index 00000000..979ca744 --- /dev/null +++ b/chats/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from chats.views import ChatList, ChatDetail, MessageList, MessageDetail + +app_name = "chats" + +urlpatterns = [ + path("", ChatList.as_view()), + path("/", ChatDetail.as_view()), + path("/messages/", MessageList.as_view()), + path("/messages//", MessageDetail.as_view()), +] diff --git a/chats/views.py b/chats/views.py new file mode 100644 index 00000000..b8a711f2 --- /dev/null +++ b/chats/views.py @@ -0,0 +1,90 @@ +from rest_framework import generics, permissions, mixins, status +from rest_framework.response import Response + +from chats.models import Chat, Message +from chats.serializers import ChatSerializer, MessageSerializer, ChatDetailSerializer + + +class ChatList(generics.ListCreateAPIView): + queryset = Chat.objects.all() + serializer_class = ChatSerializer + permission_classes = [permissions.IsAuthenticatedOrReadOnly] + + +class ChatDetail(generics.RetrieveUpdateDestroyAPIView): + serializer_class = ChatDetailSerializer + permission_classes = [permissions.IsAuthenticatedOrReadOnly] + + def get_queryset(self): + return ( + Chat.objects.all() + .prefetch_related("messages") + .prefetch_related("users") + .all() + ) + + def get(self, request, *args, **kwargs): + """ + Get chat by id + You can set first_obj and count to get messages from chat + Args: + request: request + *args: args + **kwargs: kwargs + + Returns: + Response with chat + """ + + instance = self.get_object() + serializer = self.get_serializer(instance) + return Response(serializer.data) + + +class MessageList( + mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView +): + permission_classes = [permissions.IsAuthenticatedOrReadOnly] + serializer_class = MessageSerializer + + def get_queryset(self): + return Message.objects.p.filter(chat_id=self.kwargs["pk"]) + + def post(self, request, *args, **kwargs): + try: + request.data["chat"] = str(self.kwargs["pk"]) + except AttributeError: + pass + + chat = Chat.objects.get(pk=request.data["chat"]) + if request.user not in chat.users.all(): + return Response(status=status.HTTP_403_FORBIDDEN) + + return self.create(request, *args, **kwargs) + + +class MessageDetail(generics.RetrieveUpdateDestroyAPIView): + queryset = Message.objects.all() + serializer_class = MessageSerializer + permission_classes = [permissions.IsAuthenticatedOrReadOnly] + + def patch(self, request, *args, **kwargs): + message = Message.objects.get(pk=self.kwargs["message_id"]) + if request.user != message.author: + return Response(status=status.HTTP_403_FORBIDDEN) + return self.partial_update(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + message = Message.objects.get(pk=self.kwargs["message_id"]) + + if request.user != message.author: + return Response(status=status.HTTP_403_FORBIDDEN) + return self.destroy(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + message = Message.objects.get(pk=self.kwargs["message_id"]) + + if request.user != message.author: + return Response(status=status.HTTP_403_FORBIDDEN) + + return self.update(request, *args, **kwargs) diff --git a/procollab/settings.py b/procollab/settings.py index d976636f..c1eea69f 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -41,6 +41,7 @@ "projects.apps.ProjectsConfig", "news.apps.NewsConfig", "vacancy.apps.VacancyConfig", + "chats.apps.ChatsConfig", # Rest framework "rest_framework", "rest_framework_simplejwt", diff --git a/procollab/urls.py b/procollab/urls.py index 8d178012..eb873db3 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -42,6 +42,7 @@ path("projects/", include("projects.urls", namespace="projects")), path("vacancies/", include("vacancy.urls", namespace="vacancies")), path("auth/", include(("users.urls", "users"), namespace="users")), + path("chats/", include("chats.urls", namespace="chats")), path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), From 7fcb7929227f4655e5909d422fbbc77c24adf163 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Fri, 28 Oct 2022 17:11:16 +0300 Subject: [PATCH 02/87] fixed UserAdmin.list_filter --- users/admin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/users/admin.py b/users/admin.py index 00aa4ad0..16b84494 100644 --- a/users/admin.py +++ b/users/admin.py @@ -83,6 +83,7 @@ class CustomUserAdmin(admin.ModelAdmin): ) list_filter = ( - "email", - "id", + "is_staff", + "is_superuser", + "city", ) From fa5662be8d44dcc7f35fcef826fe3364bf003f48 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Fri, 28 Oct 2022 17:17:44 +0300 Subject: [PATCH 03/87] added admin for Message & Chat models, also added new field Chat.created_at --- chats/admin.py | 16 +++++++++- ...2_alter_message_options_chat_created_at.py | 31 +++++++++++++++++++ chats/models.py | 10 +++++- 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 chats/migrations/0002_alter_message_options_chat_created_at.py diff --git a/chats/admin.py b/chats/admin.py index 846f6b40..a5d95449 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -1 +1,15 @@ -# Register your models here. +from django.contrib import admin + +from chats.models import Chat, Message + + +@admin.register(Chat) +class ChatAdmin(admin.ModelAdmin): + list_display = ("name", "users_str", "created_at") + list_display_links = ("name",) + + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + list_display = ("id", "chat", "author", "text", "created_at") + list_display_links = ("id",) diff --git a/chats/migrations/0002_alter_message_options_chat_created_at.py b/chats/migrations/0002_alter_message_options_chat_created_at.py new file mode 100644 index 00000000..ac761e80 --- /dev/null +++ b/chats/migrations/0002_alter_message_options_chat_created_at.py @@ -0,0 +1,31 @@ +# Generated by Django 4.1.2 on 2022-10-28 14:16 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="message", + options={ + "ordering": ["-created_at"], + "verbose_name": "Сообщение", + "verbose_name_plural": "Сообщения", + }, + ), + migrations.AddField( + model_name="chat", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, + default=datetime.datetime(2022, 10, 28, 17, 16, 5, 705433), + ), + preserve_default=False, + ), + ] diff --git a/chats/models.py b/chats/models.py index ee158970..b0256e3b 100644 --- a/chats/models.py +++ b/chats/models.py @@ -8,14 +8,23 @@ class Chat(models.Model): Attributes: name: A CharField name of the chat. users: A ManyToManyField referring to the User model. + created_at: A DateTimeField indicating date of creation. """ users = models.ManyToManyField("users.CustomUser", related_name="chats") + # do we really want this? maybe just set a default chat name + # (e.g. f"chat {self.users_str()}") on creation? name = models.CharField(max_length=255, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + def __str__(self): return self.name or f"Chat {self.pk}" + @property + def users_str(self): + return ", ".join([i.email for i in self.users.all()]) + class Meta: verbose_name = "Чат" verbose_name_plural = "Чаты" @@ -30,7 +39,6 @@ class Message(models.Model): author: A ForeignKey referring to the User model. text: A TextField containing message text. created_at: A DateTimeField indicating date of creation. - """ chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name="messages") From d918b7780bc878d1487187fdb66f91296410e9bc Mon Sep 17 00:00:00 2001 From: yeezy-na-izi Date: Sat, 29 Oct 2022 10:42:07 +0300 Subject: [PATCH 04/87] Update permissions in chat routers --- chats/serializers.py | 6 ++++++ chats/views.py | 17 ++++++++++------- core/permissions.py | 22 ++++++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/chats/serializers.py b/chats/serializers.py index a801117b..2a225c43 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -47,3 +47,9 @@ class Meta: "text", "created_at", ] + + def create(self, validated_data): + chat_id = self.context["request"].data["chat"] + chat = Chat.objects.get(id=chat_id) + message = Message.objects.create(chat=chat, **validated_data) + return message diff --git a/chats/views.py b/chats/views.py index b8a711f2..f61838f5 100644 --- a/chats/views.py +++ b/chats/views.py @@ -3,6 +3,7 @@ from chats.models import Chat, Message from chats.serializers import ChatSerializer, MessageSerializer, ChatDetailSerializer +from core.permissions import IsMessageOwner, IsUserInChat class ChatList(generics.ListCreateAPIView): @@ -44,11 +45,14 @@ def get(self, request, *args, **kwargs): class MessageList( mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView ): - permission_classes = [permissions.IsAuthenticatedOrReadOnly] + permission_classes = [IsUserInChat] serializer_class = MessageSerializer + def get(self, request, *args, **kwargs): + return self.list(self, request, *args, **kwargs) + def get_queryset(self): - return Message.objects.p.filter(chat_id=self.kwargs["pk"]) + return Message.objects.filter(chat_id=self.kwargs["pk"]) def post(self, request, *args, **kwargs): try: @@ -56,22 +60,20 @@ def post(self, request, *args, **kwargs): except AttributeError: pass - chat = Chat.objects.get(pk=request.data["chat"]) - if request.user not in chat.users.all(): - return Response(status=status.HTTP_403_FORBIDDEN) - return self.create(request, *args, **kwargs) class MessageDetail(generics.RetrieveUpdateDestroyAPIView): queryset = Message.objects.all() serializer_class = MessageSerializer - permission_classes = [permissions.IsAuthenticatedOrReadOnly] + permission_classes = [IsMessageOwner] def patch(self, request, *args, **kwargs): message = Message.objects.get(pk=self.kwargs["message_id"]) + if request.user != message.author: return Response(status=status.HTTP_403_FORBIDDEN) + return self.partial_update(request, *args, **kwargs) def delete(self, request, *args, **kwargs): @@ -79,6 +81,7 @@ def delete(self, request, *args, **kwargs): if request.user != message.author: return Response(status=status.HTTP_403_FORBIDDEN) + return self.destroy(request, *args, **kwargs) def put(self, request, *args, **kwargs): diff --git a/core/permissions.py b/core/permissions.py index 2686f3de..6ac06645 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -35,3 +35,25 @@ def has_object_permission(self, request, view, obj) -> bool: if request.method in SAFE_METHODS or request.user and request.user.id == obj.id: return True return False + + +class IsMessageOwner(BasePermission): + """ + Allows access to update only to himself. + """ + + def has_object_permission(self, request, view, obj) -> bool: + if request.user and request.user.id == obj.author.id: + return True + return False + + +class IsUserInChat(BasePermission): + """ + Allows access to update only to himself. + """ + + def has_object_permission(self, request, view, obj) -> bool: + if request.user in obj.chat.users.all(): + return True + return False From 57a0f186d52ef90224ab2c2de0e5743a8450c260 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Mon, 19 Dec 2022 19:16:14 +0300 Subject: [PATCH 05/87] add channels dependency --- poetry.lock | 22 +++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 64dd3ecf..3b0fe8cc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -69,6 +69,22 @@ category = "main" optional = false python-versions = ">=3.6.1" +[[package]] +name = "channels" +version = "4.0.0" +description = "Brings async, event-driven capabilities to Django 3.2 and up." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +asgiref = ">=3.5.0,<4" +Django = ">=3.2" + +[package.extras] +daphne = ["daphne (>=4.0.0)"] +tests = ["async-timeout", "coverage (>=4.5,<5.0)", "pytest", "pytest-asyncio", "pytest-django"] + [[package]] name = "charset-normalizer" version = "2.1.1" @@ -699,7 +715,7 @@ testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7 [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "1cfc9232e20273656efa48ab38de5e9de0e399797fa2dd7ab8c4a0fb1661a6a8" +content-hash = "7ecb28b900f6610c4aa2f2a32e63a666c150f4b081dee5f5c31f926db01d51d1" [metadata.files] asgiref = [ @@ -821,6 +837,10 @@ cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] +channels = [ + {file = "channels-4.0.0-py3-none-any.whl", hash = "sha256:2253334ac76f67cba68c2072273f7e0e67dbdac77eeb7e318f511d2f9a53c5e4"}, + {file = "channels-4.0.0.tar.gz", hash = "sha256:0ce53507a7da7b148eaa454526e0e05f7da5e5d1c23440e4886cf146981d8420"}, +] charset-normalizer = [ {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, diff --git a/pyproject.toml b/pyproject.toml index d983e188..a1e77d56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ django-filter = "^22.1" setuptools = "^65.5.0" drf-yasg = "^1.21.4" sentry-sdk = "^1.10.1" +channels = "^4.0.0" [build-system] From 23c9b8d531aa15e801f68e444b689672bb08f081 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Wed, 21 Dec 2022 09:57:59 +0300 Subject: [PATCH 06/87] added CustomUserManager.get_active() --- users/managers.py | 3 +++ users/views.py | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/users/managers.py b/users/managers.py index bbd6ea6c..4f0d964a 100644 --- a/users/managers.py +++ b/users/managers.py @@ -10,6 +10,9 @@ def create_user(self, email, password=None, **extra_fields): return self._create_user(email, password, **extra_fields) + def get_active(self): + return self.get_queryset().filter(is_active=True) + def create_superuser(self, email, password=None, **extra_fields): extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_superuser", True) diff --git a/users/views.py b/users/views.py index 8f98299a..2db7dac3 100644 --- a/users/views.py +++ b/users/views.py @@ -46,8 +46,7 @@ class UserList(ListCreateAPIView): - # TODO: add to manager - queryset = User.objects.filter(is_active=True) + queryset = User.objects.get_active() permission_classes = [AllowAny] # FIXME: change to IsAuthorized serializer_class = UserListSerializer filter_backends = (filters.DjangoFilterBackend,) From 0d696dd1cb4ec3ee29f4cda24c3103630ab7a3c0 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Wed, 21 Dec 2022 10:15:55 +0300 Subject: [PATCH 07/87] dependencies --- poetry.lock | 443 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 + 2 files changed, 444 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 0ec3610b..6c43a363 100644 --- a/poetry.lock +++ b/poetry.lock @@ -62,6 +62,47 @@ docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] +[[package]] +name = "autobahn" +version = "22.12.1" +description = "WebSocket client & server library, WAMP real-time framework" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +cryptography = ">=3.4.6" +hyperlink = ">=21.0.0" +setuptools = "*" +txaio = ">=21.2.1" + +[package.extras] +all = ["PyGObject (>=3.40.0)", "argon2_cffi (>=20.1.0)", "attrs (>=20.3.0)", "base58 (>=2.1.0)", "cbor2 (>=5.2.0)", "cffi (>=1.14.5)", "click (>=8.1.2)", "ecdsa (>=0.16.1)", "eth-abi (>=2.1.1)", "flatbuffers (>=22.12.6)", "hkdf (>=0.0.3)", "jinja2 (>=2.11.3)", "mnemonic (>=0.19)", "msgpack (>=1.0.2)", "passlib (>=1.7.4)", "py-ecc (>=5.1.0)", "py-eth-sig-utils (>=0.4.0)", "py-multihash (>=2.0.1)", "py-ubjson (>=0.16.1)", "pynacl (>=1.4.0)", "pyopenssl (>=20.0.1)", "python-snappy (>=0.6.0)", "pytrie (>=0.4.0)", "qrcode (>=7.3.1)", "rlp (>=2.0.1)", "service_identity (>=18.1.0)", "spake2 (>=0.8)", "twisted (>=20.3.0)", "ujson (>=4.0.2)", "web3 (>=5.29.0)", "xbr (>=21.2.1)", "yapf (==0.29.0)", "zlmdb (>=21.2.1)", "zope.interface (>=5.2.0)"] +compress = ["python-snappy (>=0.6.0)"] +dev = ["awscli", "backports.tempfile (>=1.0)", "bumpversion (>=0.5.3)", "codecov (>=2.0.15)", "flake8 (<5)", "humanize (>=0.5.1)", "mypy (>=0.610)", "passlib", "pep8-naming (>=0.3.3)", "pip (>=9.0.1)", "pyenchant (>=1.6.6)", "pyflakes (>=1.0.0)", "pyinstaller (>=4.2)", "pylint (>=1.9.2)", "pytest (>=3.4.2)", "pytest-aiohttp", "pytest-asyncio (>=0.14.0)", "pytest-runner (>=2.11.1)", "pyyaml (>=4.2b4)", "qualname", "sphinx (>=1.7.1)", "sphinx-autoapi (>=1.7.0)", "sphinx_rtd_theme (>=0.1.9)", "sphinxcontrib-images (>=0.9.1)", "tox (>=2.9.1)", "tox-gh-actions (>=2.2.0)", "twine (>=3.3.0)", "twisted (>=18.7.0)", "txaio (>=20.4.1)", "watchdog (>=0.8.3)", "wheel (>=0.36.2)", "yapf (==0.29.0)"] +encryption = ["pynacl (>=1.4.0)", "pyopenssl (>=20.0.1)", "pytrie (>=0.4.0)", "qrcode (>=7.3.1)", "service_identity (>=18.1.0)"] +nvx = ["cffi (>=1.14.5)"] +scram = ["argon2_cffi (>=20.1.0)", "cffi (>=1.14.5)", "passlib (>=1.7.4)"] +serialization = ["cbor2 (>=5.2.0)", "flatbuffers (>=22.12.6)", "msgpack (>=1.0.2)", "py-ubjson (>=0.16.1)", "ujson (>=4.0.2)"] +twisted = ["attrs (>=20.3.0)", "twisted (>=20.3.0)", "zope.interface (>=5.2.0)"] +ui = ["PyGObject (>=3.40.0)"] +xbr = ["base58 (>=2.1.0)", "cbor2 (>=5.2.0)", "click (>=8.1.2)", "ecdsa (>=0.16.1)", "eth-abi (>=2.1.1)", "hkdf (>=0.0.3)", "jinja2 (>=2.11.3)", "mnemonic (>=0.19)", "py-ecc (>=5.1.0)", "py-eth-sig-utils (>=0.4.0)", "py-multihash (>=2.0.1)", "rlp (>=2.0.1)", "spake2 (>=0.8)", "twisted (>=20.3.0)", "web3 (>=5.29.0)", "xbr (>=21.2.1)", "yapf (==0.29.0)", "zlmdb (>=21.2.1)"] + +[[package]] +name = "automat" +version = "22.10.0" +description = "Self-service finite-state machines for the programmer on the go." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +attrs = ">=19.2.0" +six = "*" + +[package.extras] +visualize = ["Twisted (>=16.1.1)", "graphviz (>0.5.1)"] + [[package]] name = "backports.zoneinfo" version = "0.2.1" @@ -134,6 +175,41 @@ category = "main" optional = false python-versions = ">=3.6.1" +[[package]] +name = "channels" +version = "4.0.0" +description = "Brings async, event-driven capabilities to Django 3.2 and up." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +asgiref = ">=3.5.0,<4" +daphne = {version = ">=4.0.0", optional = true, markers = "extra == \"daphne\""} +Django = ">=3.2" + +[package.extras] +daphne = ["daphne (>=4.0.0)"] +tests = ["async-timeout", "coverage (>=4.5,<5.0)", "pytest", "pytest-asyncio", "pytest-django"] + +[[package]] +name = "channels-redis" +version = "4.0.0" +description = "Redis-backed ASGI channel layer implementation" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +asgiref = ">=3.2.10,<4" +channels = "*" +msgpack = ">=1.0,<2.0" +redis = ">=4.2.0" + +[package.extras] +cryptography = ["cryptography (>=1.3.0)"] +tests = ["async-timeout", "cryptography (>=1.3.0)", "pytest", "pytest-asyncio"] + [[package]] name = "charset-normalizer" version = "2.1.1" @@ -164,6 +240,14 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "constantly" +version = "15.1.0" +description = "Symbolic constants in Python" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "coreapi" version = "2.3.3" @@ -208,6 +292,22 @@ sdist = ["setuptools-rust (>=0.11.4)"] ssh = ["bcrypt (>=3.1.5)"] test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"] +[[package]] +name = "daphne" +version = "4.0.0" +description = "Django ASGI (HTTP/WebSocket) server" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +asgiref = ">=3.5.2,<4" +autobahn = ">=22.4.2" +twisted = {version = ">=22.4", extras = ["tls"]} + +[package.extras] +tests = ["django", "hypothesis", "pytest", "pytest-asyncio"] + [[package]] name = "distlib" version = "0.3.6" @@ -374,6 +474,17 @@ category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "hyperlink" +version = "21.0.0" +description = "A featureful, immutable, and correct URL for Python." +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +idna = ">=2.5" + [[package]] name = "identify" version = "2.5.6" @@ -393,6 +504,18 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "incremental" +version = "22.10.0" +description = "\"A small library that versions your Python projects.\"" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +mypy = ["click (>=6.0)", "mypy (==0.812)", "twisted (>=16.4.0)"] +scripts = ["click (>=6.0)", "twisted (>=16.4.0)"] + [[package]] name = "inflection" version = "0.5.1" @@ -439,6 +562,14 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "msgpack" +version = "1.0.4" +description = "MessagePack serializer" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "multidict" version = "6.0.2" @@ -533,6 +664,25 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "pyasn1" +version = "0.4.8" +description = "ASN.1 types and codecs" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pyasn1-modules" +version = "0.2.8" +description = "A collection of ASN.1-based protocols modules." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.5.0" + [[package]] name = "pycodestyle" version = "2.9.1" @@ -571,6 +721,21 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.3.1)", "pre-commit", "pyte docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +[[package]] +name = "pyopenssl" +version = "22.1.0" +description = "Python wrapper module around the OpenSSL library" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cryptography = ">=38.0.0,<39" + +[package.extras] +docs = ["sphinx (!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] +test = ["flaky", "pretend", "pytest (>=3.0.1)"] + [[package]] name = "pyparsing" version = "3.0.9" @@ -606,6 +771,21 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "redis" +version = "4.4.0" +description = "Python client for Redis database and key-value store" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +async-timeout = ">=4.0.2" + +[package.extras] +hiredis = ["hiredis (>=1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + [[package]] name = "requests" version = "2.28.1" @@ -679,6 +859,27 @@ sqlalchemy = ["sqlalchemy (>=1.2)"] starlette = ["starlette (>=0.19.1)"] tornado = ["tornado (>=5)"] +[[package]] +name = "service-identity" +version = "21.1.0" +description = "Service identity verification for pyOpenSSL & cryptography." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +attrs = ">=19.1.0" +cryptography = "*" +pyasn1 = "*" +pyasn1-modules = "*" +six = "*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "furo", "idna", "pyOpenSSL", "pytest", "sphinx"] +docs = ["furo", "sphinx"] +idna = ["idna"] +tests = ["coverage[toml] (>=5.0.2)", "pytest"] + [[package]] name = "setuptools" version = "65.5.0" @@ -724,6 +925,65 @@ category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "twisted" +version = "22.10.0" +description = "An asynchronous networking framework written in Python" +category = "main" +optional = false +python-versions = ">=3.7.1" + +[package.dependencies] +attrs = ">=19.2.0" +Automat = ">=0.8.0" +constantly = ">=15.1" +hyperlink = ">=17.1.1" +idna = {version = ">=2.4", optional = true, markers = "extra == \"tls\""} +incremental = ">=21.3.0" +pyopenssl = {version = ">=21.0.0", optional = true, markers = "extra == \"tls\""} +service-identity = {version = ">=18.1.0", optional = true, markers = "extra == \"tls\""} +twisted-iocpsupport = {version = ">=1.0.2,<2", markers = "platform_system == \"Windows\""} +typing-extensions = ">=3.6.5" +"zope.interface" = ">=4.4.2" + +[package.extras] +all-non-platform = ["PyHamcrest (>=1.9.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "contextvars (>=2.4,<3)", "cryptography (>=2.6)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.0,<7.0)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "pyasn1", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "service-identity (>=18.1.0)"] +conch = ["appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "cryptography (>=2.6)", "pyasn1"] +conch-nacl = ["PyNaCl", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "cryptography (>=2.6)", "pyasn1"] +contextvars = ["contextvars (>=2.4,<3)"] +dev = ["coverage (>=6b1,<7)", "pydoctor (>=22.9.0,<22.10.0)", "pyflakes (>=2.2,<3.0)", "python-subunit (>=1.4,<2.0)", "readthedocs-sphinx-ext (>=2.1,<3.0)", "sphinx (>=5.0,<6)", "sphinx-rtd-theme (>=1.0,<2.0)", "towncrier (>=22.8,<23.0)", "twistedchecker (>=0.7,<1.0)"] +dev-release = ["pydoctor (>=22.9.0,<22.10.0)", "readthedocs-sphinx-ext (>=2.1,<3.0)", "sphinx (>=5.0,<6)", "sphinx-rtd-theme (>=1.0,<2.0)", "towncrier (>=22.8,<23.0)"] +gtk-platform = ["PyHamcrest (>=1.9.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "contextvars (>=2.4,<3)", "cryptography (>=2.6)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.0,<7.0)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "pyasn1", "pygobject", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "service-identity (>=18.1.0)"] +http2 = ["h2 (>=3.0,<5.0)", "priority (>=1.1.0,<2.0)"] +macos-platform = ["PyHamcrest (>=1.9.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "contextvars (>=2.4,<3)", "cryptography (>=2.6)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.0,<7.0)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "pyasn1", "pyobjc-core", "pyobjc-framework-CFNetwork", "pyobjc-framework-Cocoa", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "service-identity (>=18.1.0)"] +mypy = ["PyHamcrest (>=1.9.0)", "PyNaCl", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "contextvars (>=2.4,<3)", "coverage (>=6b1,<7)", "cryptography (>=2.6)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.0,<7.0)", "idna (>=2.4)", "mypy (==0.930)", "mypy-zope (==0.3.4)", "priority (>=1.1.0,<2.0)", "pyasn1", "pydoctor (>=22.9.0,<22.10.0)", "pyflakes (>=2.2,<3.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "python-subunit (>=1.4,<2.0)", "pywin32 (!=226)", "readthedocs-sphinx-ext (>=2.1,<3.0)", "service-identity (>=18.1.0)", "sphinx (>=5.0,<6)", "sphinx-rtd-theme (>=1.0,<2.0)", "towncrier (>=22.8,<23.0)", "twistedchecker (>=0.7,<1.0)", "types-pyOpenSSL", "types-setuptools"] +osx-platform = ["PyHamcrest (>=1.9.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "contextvars (>=2.4,<3)", "cryptography (>=2.6)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.0,<7.0)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "pyasn1", "pyobjc-core", "pyobjc-framework-CFNetwork", "pyobjc-framework-Cocoa", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "service-identity (>=18.1.0)"] +serial = ["pyserial (>=3.0)", "pywin32 (!=226)"] +test = ["PyHamcrest (>=1.9.0)", "cython-test-exception-raiser (>=1.0.2,<2)", "hypothesis (>=6.0,<7.0)"] +tls = ["idna (>=2.4)", "pyopenssl (>=21.0.0)", "service-identity (>=18.1.0)"] +windows-platform = ["PyHamcrest (>=1.9.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.0.0)", "contextvars (>=2.4,<3)", "cryptography (>=2.6)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.0,<7.0)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "pyasn1", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "pywin32 (!=226)", "service-identity (>=18.1.0)"] + +[[package]] +name = "twisted-iocpsupport" +version = "1.0.2" +description = "An extension for use in the twisted I/O Completion Ports reactor." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "txaio" +version = "22.2.1" +description = "Compatibility API between asyncio/Twisted/Trollius" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +all = ["twisted (>=20.3.0)", "zope.interface (>=5.2.0)"] +dev = ["pep8 (>=1.6.2)", "pyenchant (>=1.6.6)", "pytest (>=2.6.4)", "pytest-cov (>=1.8.1)", "sphinx (>=1.2.3)", "sphinx-rtd-theme (>=0.1.9)", "sphinxcontrib-spelling (>=2.1.2)", "tox (>=2.1.1)", "tox-gh-actions (>=2.2.0)", "twine (>=1.6.5)", "wheel"] +twisted = ["twisted (>=20.3.0)", "zope.interface (>=5.2.0)"] + [[package]] name = "typing-extensions" version = "4.4.0" @@ -801,10 +1061,26 @@ python-versions = ">=3.7" idna = ">=2.0" multidict = ">=4.0" +[[package]] +name = "zope-interface" +version = "5.5.2" +description = "Interfaces for Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +setuptools = "*" + +[package.extras] +docs = ["Sphinx", "repoze.sphinx.autointerface"] +test = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] +testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] + [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "2de15c5a231a0a9b41351e7cb01aa3d48ffcf133a136559a1c943919e151dd48" +content-hash = "2afc534838a528b118d9a750d374a9d07cd7108e16ad6fb22dfe995ad15861da" [metadata.files] aiohttp = [ @@ -912,6 +1188,13 @@ attrs = [ {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, ] +autobahn = [ + {file = "autobahn-22.12.1.tar.gz", hash = "sha256:43b4e8b1aeaeb20a0cc0a81572e613dc958057c0ab248a7d6b41b2763270f925"}, +] +automat = [ + {file = "Automat-22.10.0-py2.py3-none-any.whl", hash = "sha256:c3164f8742b9dc440f3682482d32aaff7bb53f71740dd018533f9de286b64180"}, + {file = "Automat-22.10.0.tar.gz", hash = "sha256:e56beb84edad19dcc11d30e8d9b895f75deeb5ef5e96b84a467066b3b84bb04e"}, +] "backports.zoneinfo" = [ {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, @@ -1050,6 +1333,14 @@ cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] +channels = [ + {file = "channels-4.0.0-py3-none-any.whl", hash = "sha256:2253334ac76f67cba68c2072273f7e0e67dbdac77eeb7e318f511d2f9a53c5e4"}, + {file = "channels-4.0.0.tar.gz", hash = "sha256:0ce53507a7da7b148eaa454526e0e05f7da5e5d1c23440e4886cf146981d8420"}, +] +channels-redis = [ + {file = "channels_redis-4.0.0-py3-none-any.whl", hash = "sha256:81b59d68f53313e1aa891f23591841b684abb936b42e4d1a966d9e4dc63a95ec"}, + {file = "channels_redis-4.0.0.tar.gz", hash = "sha256:122414f29f525f7b9e0c9d59cdcfc4dc1b0eecba16fbb6a1c23f1d9b58f49dcb"}, +] charset-normalizer = [ {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, @@ -1062,6 +1353,10 @@ colorama = [ {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, ] +constantly = [ + {file = "constantly-15.1.0-py2.py3-none-any.whl", hash = "sha256:dd2fa9d6b1a51a83f0d7dd76293d734046aa176e384bf6e33b7e44880eb37c5d"}, + {file = "constantly-15.1.0.tar.gz", hash = "sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35"}, +] coreapi = [ {file = "coreapi-2.3.3-py2.py3-none-any.whl", hash = "sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3"}, {file = "coreapi-2.3.3.tar.gz", hash = "sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb"}, @@ -1098,6 +1393,10 @@ cryptography = [ {file = "cryptography-38.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:52e7bee800ec869b4031093875279f1ff2ed12c1e2f74923e8f49c916afd1d3b"}, {file = "cryptography-38.0.1.tar.gz", hash = "sha256:1db3d807a14931fa317f96435695d9ec386be7b84b618cc61cfa5d08b0ae33d7"}, ] +daphne = [ + {file = "daphne-4.0.0-py3-none-any.whl", hash = "sha256:a288ece46012b6b719c37150be67c69ebfca0793a8521bf821533bad983179b2"}, + {file = "daphne-4.0.0.tar.gz", hash = "sha256:cce9afc8f49a4f15d4270b8cfb0e0fe811b770a5cc795474e97e4da287497666"}, +] distlib = [ {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, @@ -1222,6 +1521,10 @@ frozenlist = [ {file = "frozenlist-1.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:cfe33efc9cb900a4c46f91a5ceba26d6df370ffddd9ca386eb1d4f0ad97b9ea9"}, {file = "frozenlist-1.3.3.tar.gz", hash = "sha256:58bcc55721e8a90b88332d6cd441261ebb22342e238296bb330968952fbb3a6a"}, ] +hyperlink = [ + {file = "hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4"}, + {file = "hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b"}, +] identify = [ {file = "identify-2.5.6-py2.py3-none-any.whl", hash = "sha256:b276db7ec52d7e89f5bc4653380e33054ddc803d25875952ad90b0f012cbcdaa"}, {file = "identify-2.5.6.tar.gz", hash = "sha256:6c32dbd747aa4ceee1df33f25fed0b0f6e0d65721b15bd151307ff7056d50245"}, @@ -1230,6 +1533,10 @@ idna = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] +incremental = [ + {file = "incremental-22.10.0-py2.py3-none-any.whl", hash = "sha256:b864a1f30885ee72c5ac2835a761b8fe8aa9c28b9395cacf27286602688d3e51"}, + {file = "incremental-22.10.0.tar.gz", hash = "sha256:912feeb5e0f7e0188e6f42241d2f450002e11bbc0937c65865045854c24c0bd0"}, +] inflection = [ {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, @@ -1288,6 +1595,60 @@ mccabe = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +msgpack = [ + {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ab251d229d10498e9a2f3b1e68ef64cb393394ec477e3370c457f9430ce9250"}, + {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:112b0f93202d7c0fef0b7810d465fde23c746a2d482e1e2de2aafd2ce1492c88"}, + {file = "msgpack-1.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:002b5c72b6cd9b4bafd790f364b8480e859b4712e91f43014fe01e4f957b8467"}, + {file = "msgpack-1.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35bc0faa494b0f1d851fd29129b2575b2e26d41d177caacd4206d81502d4c6a6"}, + {file = "msgpack-1.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4733359808c56d5d7756628736061c432ded018e7a1dff2d35a02439043321aa"}, + {file = "msgpack-1.0.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb514ad14edf07a1dbe63761fd30f89ae79b42625731e1ccf5e1f1092950eaa6"}, + {file = "msgpack-1.0.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c23080fdeec4716aede32b4e0ef7e213c7b1093eede9ee010949f2a418ced6ba"}, + {file = "msgpack-1.0.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:49565b0e3d7896d9ea71d9095df15b7f75a035c49be733051c34762ca95bbf7e"}, + {file = "msgpack-1.0.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:aca0f1644d6b5a73eb3e74d4d64d5d8c6c3d577e753a04c9e9c87d07692c58db"}, + {file = "msgpack-1.0.4-cp310-cp310-win32.whl", hash = "sha256:0dfe3947db5fb9ce52aaea6ca28112a170db9eae75adf9339a1aec434dc954ef"}, + {file = "msgpack-1.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:4dea20515f660aa6b7e964433b1808d098dcfcabbebeaaad240d11f909298075"}, + {file = "msgpack-1.0.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e83f80a7fec1a62cf4e6c9a660e39c7f878f603737a0cdac8c13131d11d97f52"}, + {file = "msgpack-1.0.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c11a48cf5e59026ad7cb0dc29e29a01b5a66a3e333dc11c04f7e991fc5510a9"}, + {file = "msgpack-1.0.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1276e8f34e139aeff1c77a3cefb295598b504ac5314d32c8c3d54d24fadb94c9"}, + {file = "msgpack-1.0.4-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c9566f2c39ccced0a38d37c26cc3570983b97833c365a6044edef3574a00c08"}, + {file = "msgpack-1.0.4-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:fcb8a47f43acc113e24e910399376f7277cf8508b27e5b88499f053de6b115a8"}, + {file = "msgpack-1.0.4-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:76ee788122de3a68a02ed6f3a16bbcd97bc7c2e39bd4d94be2f1821e7c4a64e6"}, + {file = "msgpack-1.0.4-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:0a68d3ac0104e2d3510de90a1091720157c319ceeb90d74f7b5295a6bee51bae"}, + {file = "msgpack-1.0.4-cp36-cp36m-win32.whl", hash = "sha256:85f279d88d8e833ec015650fd15ae5eddce0791e1e8a59165318f371158efec6"}, + {file = "msgpack-1.0.4-cp36-cp36m-win_amd64.whl", hash = "sha256:c1683841cd4fa45ac427c18854c3ec3cd9b681694caf5bff04edb9387602d661"}, + {file = "msgpack-1.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a75dfb03f8b06f4ab093dafe3ddcc2d633259e6c3f74bb1b01996f5d8aa5868c"}, + {file = "msgpack-1.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9667bdfdf523c40d2511f0e98a6c9d3603be6b371ae9a238b7ef2dc4e7a427b0"}, + {file = "msgpack-1.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11184bc7e56fd74c00ead4f9cc9a3091d62ecb96e97653add7a879a14b003227"}, + {file = "msgpack-1.0.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac5bd7901487c4a1dd51a8c58f2632b15d838d07ceedaa5e4c080f7190925bff"}, + {file = "msgpack-1.0.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1e91d641d2bfe91ba4c52039adc5bccf27c335356055825c7f88742c8bb900dd"}, + {file = "msgpack-1.0.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2a2df1b55a78eb5f5b7d2a4bb221cd8363913830145fad05374a80bf0877cb1e"}, + {file = "msgpack-1.0.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:545e3cf0cf74f3e48b470f68ed19551ae6f9722814ea969305794645da091236"}, + {file = "msgpack-1.0.4-cp37-cp37m-win32.whl", hash = "sha256:2cc5ca2712ac0003bcb625c96368fd08a0f86bbc1a5578802512d87bc592fe44"}, + {file = "msgpack-1.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:eba96145051ccec0ec86611fe9cf693ce55f2a3ce89c06ed307de0e085730ec1"}, + {file = "msgpack-1.0.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7760f85956c415578c17edb39eed99f9181a48375b0d4a94076d84148cf67b2d"}, + {file = "msgpack-1.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:449e57cc1ff18d3b444eb554e44613cffcccb32805d16726a5494038c3b93dab"}, + {file = "msgpack-1.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d603de2b8d2ea3f3bcb2efe286849aa7a81531abc52d8454da12f46235092bcb"}, + {file = "msgpack-1.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f5d88c99f64c456413d74a975bd605a9b0526293218a3b77220a2c15458ba9"}, + {file = "msgpack-1.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916c78f33602ecf0509cc40379271ba0f9ab572b066bd4bdafd7434dee4bc6e"}, + {file = "msgpack-1.0.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81fc7ba725464651190b196f3cd848e8553d4d510114a954681fd0b9c479d7e1"}, + {file = "msgpack-1.0.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d5b5b962221fa2c5d3a7f8133f9abffc114fe218eb4365e40f17732ade576c8e"}, + {file = "msgpack-1.0.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:77ccd2af37f3db0ea59fb280fa2165bf1b096510ba9fe0cc2bf8fa92a22fdb43"}, + {file = "msgpack-1.0.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b17be2478b622939e39b816e0aa8242611cc8d3583d1cd8ec31b249f04623243"}, + {file = "msgpack-1.0.4-cp38-cp38-win32.whl", hash = "sha256:2bb8cdf50dd623392fa75525cce44a65a12a00c98e1e37bf0fb08ddce2ff60d2"}, + {file = "msgpack-1.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:26b8feaca40a90cbe031b03d82b2898bf560027160d3eae1423f4a67654ec5d6"}, + {file = "msgpack-1.0.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:462497af5fd4e0edbb1559c352ad84f6c577ffbbb708566a0abaaa84acd9f3ae"}, + {file = "msgpack-1.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2999623886c5c02deefe156e8f869c3b0aaeba14bfc50aa2486a0415178fce55"}, + {file = "msgpack-1.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f0029245c51fd9473dc1aede1160b0a29f4a912e6b1dd353fa6d317085b219da"}, + {file = "msgpack-1.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed6f7b854a823ea44cf94919ba3f727e230da29feb4a99711433f25800cf747f"}, + {file = "msgpack-1.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0df96d6eaf45ceca04b3f3b4b111b86b33785683d682c655063ef8057d61fd92"}, + {file = "msgpack-1.0.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a4192b1ab40f8dca3f2877b70e63799d95c62c068c84dc028b40a6cb03ccd0f"}, + {file = "msgpack-1.0.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e3590f9fb9f7fbc36df366267870e77269c03172d086fa76bb4eba8b2b46624"}, + {file = "msgpack-1.0.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1576bd97527a93c44fa856770197dec00d223b0b9f36ef03f65bac60197cedf8"}, + {file = "msgpack-1.0.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:63e29d6e8c9ca22b21846234913c3466b7e4ee6e422f205a2988083de3b08cae"}, + {file = "msgpack-1.0.4-cp39-cp39-win32.whl", hash = "sha256:fb62ea4b62bfcb0b380d5680f9a4b3f9a2d166d9394e9bbd9666c0ee09a3645c"}, + {file = "msgpack-1.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:4d5834a2a48965a349da1c5a79760d94a1a0172fbb5ab6b5b33cbf8447e109ce"}, + {file = "msgpack-1.0.4.tar.gz", hash = "sha256:f5d869c18f030202eb412f08b28d2afeea553d6613aee89e200d7aca7ef01f5f"}, +] multidict = [ {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"}, {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"}, @@ -1494,6 +1855,14 @@ psycopg2-binary = [ {file = "psycopg2_binary-2.9.4-cp39-cp39-win32.whl", hash = "sha256:e02f77b620ad6b36564fe41980865436912e21a3b1138cdde175cf24afde1bc5"}, {file = "psycopg2_binary-2.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:44f5dc9b4384bafca8429759ce76c8960ffc2b583fcad9e5dfb3e5f4894269e4"}, ] +pyasn1 = [ + {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, + {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, +] +pyasn1-modules = [ + {file = "pyasn1-modules-0.2.8.tar.gz", hash = "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e"}, + {file = "pyasn1_modules-0.2.8-py2.py3-none-any.whl", hash = "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"}, +] pycodestyle = [ {file = "pycodestyle-2.9.1-py2.py3-none-any.whl", hash = "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"}, {file = "pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785"}, @@ -1510,6 +1879,10 @@ pyjwt = [ {file = "PyJWT-2.5.0-py3-none-any.whl", hash = "sha256:8d82e7087868e94dd8d7d418e5088ce64f7daab4b36db654cbaedb46f9d1ca80"}, {file = "PyJWT-2.5.0.tar.gz", hash = "sha256:e77ab89480905d86998442ac5788f35333fa85f65047a534adc38edf3c88fc3b"}, ] +pyopenssl = [ + {file = "pyOpenSSL-22.1.0-py3-none-any.whl", hash = "sha256:b28437c9773bb6c6958628cf9c3bebe585de661dba6f63df17111966363dd15e"}, + {file = "pyOpenSSL-22.1.0.tar.gz", hash = "sha256:7a83b7b272dd595222d672f5ce29aa030f1fb837630ef229f62e72e395ce8968"}, +] pyparsing = [ {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, @@ -1564,6 +1937,10 @@ PyYAML = [ {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] +redis = [ + {file = "redis-4.4.0-py3-none-any.whl", hash = "sha256:cae3ee5d1f57d8caf534cd8764edf3163c77e073bdd74b6f54a87ffafdc5e7d9"}, + {file = "redis-4.4.0.tar.gz", hash = "sha256:7b8c87d19c45d3f1271b124858d2a5c13160c4e74d4835e28273400fa34d5228"}, +] requests = [ {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, @@ -1611,6 +1988,10 @@ sentry-sdk = [ {file = "sentry-sdk-1.10.1.tar.gz", hash = "sha256:105faf7bd7b7fa25653404619ee261527266b14103fe1389e0ce077bd23a9691"}, {file = "sentry_sdk-1.10.1-py2.py3-none-any.whl", hash = "sha256:06c0fa9ccfdc80d7e3b5d2021978d6eb9351fa49db9b5847cf4d1f2a473414ad"}, ] +service-identity = [ + {file = "service-identity-21.1.0.tar.gz", hash = "sha256:6e6c6086ca271dc11b033d17c3a8bea9f24ebff920c587da090afc9519419d34"}, + {file = "service_identity-21.1.0-py2.py3-none-any.whl", hash = "sha256:f0b0caac3d40627c3c04d7a51b6e06721857a0e10a8775f2d1d7e72901b3a7db"}, +] setuptools = [ {file = "setuptools-65.5.0-py3-none-any.whl", hash = "sha256:f62ea9da9ed6289bfe868cd6845968a2c854d1427f8548d52cae02a42b4f0356"}, {file = "setuptools-65.5.0.tar.gz", hash = "sha256:512e5536220e38146176efb833d4a62aa726b7bbff82cfbc8ba9eaa3996e0b17"}, @@ -1631,6 +2012,28 @@ tomli = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +twisted = [ + {file = "Twisted-22.10.0-py3-none-any.whl", hash = "sha256:86c55f712cc5ab6f6d64e02503352464f0400f66d4f079096d744080afcccbd0"}, + {file = "Twisted-22.10.0.tar.gz", hash = "sha256:32acbd40a94f5f46e7b42c109bfae2b302250945561783a8b7a059048f2d4d31"}, +] +twisted-iocpsupport = [ + {file = "twisted-iocpsupport-1.0.2.tar.gz", hash = "sha256:72068b206ee809c9c596b57b5287259ea41ddb4774d86725b19f35bf56aa32a9"}, + {file = "twisted_iocpsupport-1.0.2-cp310-cp310-win32.whl", hash = "sha256:985c06a33f5c0dae92c71a036d1ea63872ee86a21dd9b01e1f287486f15524b4"}, + {file = "twisted_iocpsupport-1.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:81b3abe3527b367da0220482820cb12a16c661672b7bcfcde328902890d63323"}, + {file = "twisted_iocpsupport-1.0.2-cp36-cp36m-win32.whl", hash = "sha256:9dbb8823b49f06d4de52721b47de4d3b3026064ef4788ce62b1a21c57c3fff6f"}, + {file = "twisted_iocpsupport-1.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:b9fed67cf0f951573f06d560ac2f10f2a4bbdc6697770113a2fc396ea2cb2565"}, + {file = "twisted_iocpsupport-1.0.2-cp37-cp37m-win32.whl", hash = "sha256:b76b4eed9b27fd63ddb0877efdd2d15835fdcb6baa745cb85b66e5d016ac2878"}, + {file = "twisted_iocpsupport-1.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:851b3735ca7e8102e661872390e3bce88f8901bece95c25a0c8bb9ecb8a23d32"}, + {file = "twisted_iocpsupport-1.0.2-cp38-cp38-win32.whl", hash = "sha256:bf4133139d77fc706d8f572e6b7d82871d82ec7ef25d685c2351bdacfb701415"}, + {file = "twisted_iocpsupport-1.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:306becd6e22ab6e8e4f36b6bdafd9c92e867c98a5ce517b27fdd27760ee7ae41"}, + {file = "twisted_iocpsupport-1.0.2-cp39-cp39-win32.whl", hash = "sha256:3c61742cb0bc6c1ac117a7e5f422c129832f0c295af49e01d8a6066df8cfc04d"}, + {file = "twisted_iocpsupport-1.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:b435857b9efcbfc12f8c326ef0383f26416272260455bbca2cd8d8eca470c546"}, + {file = "twisted_iocpsupport-1.0.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:7d972cfa8439bdcb35a7be78b7ef86d73b34b808c74be56dfa785c8a93b851bf"}, +] +txaio = [ + {file = "txaio-22.2.1-py2.py3-none-any.whl", hash = "sha256:41223af4a9d5726e645a8ee82480f413e5e300dd257db94bc38ae12ea48fb2e5"}, + {file = "txaio-22.2.1.tar.gz", hash = "sha256:2e4582b70f04b2345908254684a984206c0d9b50e3074a24a4c55aba21d24d01"}, +] typing-extensions = [ {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, @@ -1716,3 +2119,41 @@ yarl = [ {file = "yarl-1.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:de49d77e968de6626ba7ef4472323f9d2e5a56c1d85b7c0e2a190b2173d3b9be"}, {file = "yarl-1.8.1.tar.gz", hash = "sha256:af887845b8c2e060eb5605ff72b6f2dd2aab7a761379373fd89d314f4752abbf"}, ] +zope-interface = [ + {file = "zope.interface-5.5.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:a2ad597c8c9e038a5912ac3cf166f82926feff2f6e0dabdab956768de0a258f5"}, + {file = "zope.interface-5.5.2-cp27-cp27m-win_amd64.whl", hash = "sha256:65c3c06afee96c654e590e046c4a24559e65b0a87dbff256cd4bd6f77e1a33f9"}, + {file = "zope.interface-5.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d514c269d1f9f5cd05ddfed15298d6c418129f3f064765295659798349c43e6f"}, + {file = "zope.interface-5.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5334e2ef60d3d9439c08baedaf8b84dc9bb9522d0dacbc10572ef5609ef8db6d"}, + {file = "zope.interface-5.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc26c8d44472e035d59d6f1177eb712888447f5799743da9c398b0339ed90b1b"}, + {file = "zope.interface-5.5.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:17ebf6e0b1d07ed009738016abf0d0a0f80388e009d0ac6e0ead26fc162b3b9c"}, + {file = "zope.interface-5.5.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f98d4bd7bbb15ca701d19b93263cc5edfd480c3475d163f137385f49e5b3a3a7"}, + {file = "zope.interface-5.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:696f3d5493eae7359887da55c2afa05acc3db5fc625c49529e84bd9992313296"}, + {file = "zope.interface-5.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7579960be23d1fddecb53898035a0d112ac858c3554018ce615cefc03024e46d"}, + {file = "zope.interface-5.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:765d703096ca47aa5d93044bf701b00bbce4d903a95b41fff7c3796e747b1f1d"}, + {file = "zope.interface-5.5.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e945de62917acbf853ab968d8916290548df18dd62c739d862f359ecd25842a6"}, + {file = "zope.interface-5.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:655796a906fa3ca67273011c9805c1e1baa047781fca80feeb710328cdbed87f"}, + {file = "zope.interface-5.5.2-cp35-cp35m-win_amd64.whl", hash = "sha256:0fb497c6b088818e3395e302e426850f8236d8d9f4ef5b2836feae812a8f699c"}, + {file = "zope.interface-5.5.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:008b0b65c05993bb08912f644d140530e775cf1c62a072bf9340c2249e613c32"}, + {file = "zope.interface-5.5.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:404d1e284eda9e233c90128697c71acffd55e183d70628aa0bbb0e7a3084ed8b"}, + {file = "zope.interface-5.5.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:3218ab1a7748327e08ef83cca63eea7cf20ea7e2ebcb2522072896e5e2fceedf"}, + {file = "zope.interface-5.5.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d169ccd0756c15bbb2f1acc012f5aab279dffc334d733ca0d9362c5beaebe88e"}, + {file = "zope.interface-5.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:e1574980b48c8c74f83578d1e77e701f8439a5d93f36a5a0af31337467c08fcf"}, + {file = "zope.interface-5.5.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:0217a9615531c83aeedb12e126611b1b1a3175013bbafe57c702ce40000eb9a0"}, + {file = "zope.interface-5.5.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:311196634bb9333aa06f00fc94f59d3a9fddd2305c2c425d86e406ddc6f2260d"}, + {file = "zope.interface-5.5.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6373d7eb813a143cb7795d3e42bd8ed857c82a90571567e681e1b3841a390d16"}, + {file = "zope.interface-5.5.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:959697ef2757406bff71467a09d940ca364e724c534efbf3786e86eee8591452"}, + {file = "zope.interface-5.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:dbaeb9cf0ea0b3bc4b36fae54a016933d64c6d52a94810a63c00f440ecb37dd7"}, + {file = "zope.interface-5.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:604cdba8f1983d0ab78edc29aa71c8df0ada06fb147cea436dc37093a0100a4e"}, + {file = "zope.interface-5.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e74a578172525c20d7223eac5f8ad187f10940dac06e40113d62f14f3adb1e8f"}, + {file = "zope.interface-5.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0980d44b8aded808bec5059018d64692f0127f10510eca71f2f0ace8fb11188"}, + {file = "zope.interface-5.5.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6e972493cdfe4ad0411fd9abfab7d4d800a7317a93928217f1a5de2bb0f0d87a"}, + {file = "zope.interface-5.5.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9d783213fab61832dbb10d385a319cb0e45451088abd45f95b5bb88ed0acca1a"}, + {file = "zope.interface-5.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:a16025df73d24795a0bde05504911d306307c24a64187752685ff6ea23897cb0"}, + {file = "zope.interface-5.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:40f4065745e2c2fa0dff0e7ccd7c166a8ac9748974f960cd39f63d2c19f9231f"}, + {file = "zope.interface-5.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8a2ffadefd0e7206adc86e492ccc60395f7edb5680adedf17a7ee4205c530df4"}, + {file = "zope.interface-5.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d692374b578360d36568dd05efb8a5a67ab6d1878c29c582e37ddba80e66c396"}, + {file = "zope.interface-5.5.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4087e253bd3bbbc3e615ecd0b6dd03c4e6a1e46d152d3be6d2ad08fbad742dcc"}, + {file = "zope.interface-5.5.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fb68d212efd057596dee9e6582daded9f8ef776538afdf5feceb3059df2d2e7b"}, + {file = "zope.interface-5.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:7e66f60b0067a10dd289b29dceabd3d0e6d68be1504fc9d0bc209cf07f56d189"}, + {file = "zope.interface-5.5.2.tar.gz", hash = "sha256:bfee1f3ff62143819499e348f5b8a7f3aa0259f9aca5e0ddae7391d059dce671"}, +] diff --git a/pyproject.toml b/pyproject.toml index 16c26a08..e04012c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,8 @@ whitenoise = "^6.2.0" six = "^1.16.0" aiohttp = "^3.8.3" django = {extras = ["bcrypt"], version = "^4.1.3"} +channels-redis = "^4.0.0" +channels = {extras = ["daphne"], version = "^4.0.0"} [build-system] From aa25a65297e408145ba940ba9797c990d4a51257 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Wed, 21 Dec 2022 11:05:26 +0300 Subject: [PATCH 08/87] basic chat functionality? --- chats/consumers.py | 41 +++++++++++++++++++++++++++++++++++++++++ chats/routing.py | 7 +++++++ metrics/admin.py | 0 procollab/asgi.py | 13 ++++++++++++- procollab/settings.py | 16 ++++++++++++++++ 5 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 chats/consumers.py create mode 100644 chats/routing.py delete mode 100644 metrics/admin.py diff --git a/chats/consumers.py b/chats/consumers.py new file mode 100644 index 00000000..d93914dc --- /dev/null +++ b/chats/consumers.py @@ -0,0 +1,41 @@ +# chat/consumers.py +import json + +from asgiref.sync import async_to_sync +from channels.generic.websocket import WebsocketConsumer + + +class ChatConsumer(WebsocketConsumer): + def connect(self): + self.room_name = self.scope["url_route"]["kwargs"]["room_name"] + self.room_group_name = "chat_%s" % self.room_name + + # Join room group + async_to_sync(self.channel_layer.group_add)( + self.room_group_name, self.channel_name + ) + + self.accept() + + def disconnect(self, close_code): + # Leave room group + async_to_sync(self.channel_layer.group_discard)( + self.room_group_name, self.channel_name + ) + + # Receive message from WebSocket + def receive(self, text_data): + text_data_json = json.loads(text_data) + message = text_data_json["message"] + + # Send message to room group + async_to_sync(self.channel_layer.group_send)( + self.room_group_name, {"type": "chat_message", "message": message} + ) + + # Receive message from room group + def chat_message(self, event): + message = event["message"] + + # Send message to WebSocket + self.send(text_data=json.dumps({"message": message})) diff --git a/chats/routing.py b/chats/routing.py new file mode 100644 index 00000000..9f08ddfa --- /dev/null +++ b/chats/routing.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from . import consumers + +websocket_urlpatterns = [ + re_path(r"ws/chat/(?P\w+)/$", consumers.ChatConsumer.as_asgi()), +] diff --git a/metrics/admin.py b/metrics/admin.py deleted file mode 100644 index e69de29b..00000000 diff --git a/procollab/asgi.py b/procollab/asgi.py index a867f68f..7dbc95fc 100644 --- a/procollab/asgi.py +++ b/procollab/asgi.py @@ -1,7 +1,18 @@ import os +import chats.routing +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.security.websocket import AllowedHostsOriginValidator from django.core.asgi import get_asgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "procollab.settings") -application = get_asgi_application() +application = ProtocolTypeRouter( + { + "http": get_asgi_application(), + "websocket": AllowedHostsOriginValidator( + AuthMiddlewareStack(URLRouter(chats.routing.websocket_urlpatterns)) + ), + } +) diff --git a/procollab/settings.py b/procollab/settings.py index 67a2e491..2c5f9b93 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -31,6 +31,7 @@ "localhost", "0.0.0.0", "api.procollab.ru", + "127.0.0.1:8000", ] PASSWORD_HASHERS = [ @@ -53,6 +54,9 @@ ) INSTALLED_APPS = [ + # daphne is required for channels, should be installed before django.contrib.staticfiles + "daphne", + # django apps "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -81,8 +85,20 @@ "corsheaders", "django_filters", "drf_yasg", + "channels", ] +# django channels +ASGI_APPLICATION = "procollab.asgi.application" +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [("127.0.0.1", 6379)], + }, + }, +} + MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", From c2e65c8a6d45922e793b5bb7823079751ca7c04b Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sat, 24 Dec 2022 00:00:14 +0300 Subject: [PATCH 09/87] using json websocket consumer --- chats/consumers.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index d93914dc..4ecd1891 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -1,14 +1,14 @@ -# chat/consumers.py import json from asgiref.sync import async_to_sync -from channels.generic.websocket import WebsocketConsumer +from channels.generic.websocket import JsonWebsocketConsumer -class ChatConsumer(WebsocketConsumer): +class ChatConsumer(JsonWebsocketConsumer): def connect(self): + # 1. check that user is authenticated self.room_name = self.scope["url_route"]["kwargs"]["room_name"] - self.room_group_name = "chat_%s" % self.room_name + self.room_group_name = f"chat_{self.room_name}" # Join room group async_to_sync(self.channel_layer.group_add)( @@ -24,9 +24,8 @@ def disconnect(self, close_code): ) # Receive message from WebSocket - def receive(self, text_data): - text_data_json = json.loads(text_data) - message = text_data_json["message"] + def receive_json(self, content, **kwargs): + message = content["message"] # Send message to room group async_to_sync(self.channel_layer.group_send)( From abf5521c8f19e6b53223bed5526d0e291b85bdcb Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sat, 24 Dec 2022 00:36:03 +0300 Subject: [PATCH 10/87] started working on DirectChat and ProjectChat --- chats/models.py | 64 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/chats/models.py b/chats/models.py index b0256e3b..9998153d 100644 --- a/chats/models.py +++ b/chats/models.py @@ -1,7 +1,7 @@ from django.db import models -class Chat(models.Model): +class BaseChat(models.Model): """ Chat model @@ -11,15 +11,14 @@ class Chat(models.Model): created_at: A DateTimeField indicating date of creation. """ - users = models.ManyToManyField("users.CustomUser", related_name="chats") - # do we really want this? maybe just set a default chat name - # (e.g. f"chat {self.users_str()}") on creation? - name = models.CharField(max_length=255, blank=True, null=True) - created_at = models.DateTimeField(auto_now_add=True) + # has to be overriden in child classes + def get_chat_users(self): + raise NotImplementedError + def __str__(self): - return self.name or f"Chat {self.pk}" + return f"Chat {self.pk}" @property def users_str(self): @@ -28,6 +27,54 @@ def users_str(self): class Meta: verbose_name = "Чат" verbose_name_plural = "Чаты" + abstract = True + + +class ProjectChat(BaseChat): + """ + ProjectChat model + + Attributes: + users: A ManyToManyField referring to the User model. + created_at: A DateTimeField indicating date of creation. + """ + + project = models.ForeignKey( + "projects.Project", on_delete=models.CASCADE, related_name="chats" + ) + + def get_chat_users(self): + # TODO: return all collaborators from project + leader + pass + + def __str__(self): + return f"Chat {self.pk} ({self.name})" + + class Meta: + verbose_name = "Чат проекта" + verbose_name_plural = "Чаты проектов" + + +class DirectChat(BaseChat): + """ + DirectChat model + + Attributes: + users: A ManyToManyField referring to the User model. + created_at: A DateTimeField indicating date of creation. + """ + + users = models.ManyToManyField("users.CustomUser", related_name="chats") + + def get_chat_users(self): + return self.users.all() + + def __str__(self): + return f"Direct chat {self.pk} ({self.users_str})" + + class Meta: + verbose_name = "Личный чат" + verbose_name_plural = "Личные чаты" class Message(models.Model): @@ -41,7 +88,8 @@ class Message(models.Model): created_at: A DateTimeField indicating date of creation. """ - chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name="messages") + # TODO: add chat foreign key + # chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name="messages") author = models.ForeignKey( "users.CustomUser", on_delete=models.CASCADE, related_name="messages" ) From b5235c6d3cb49b3b307f63cbe7d08cded0848fda Mon Sep 17 00:00:00 2001 From: Yakser Date: Sat, 24 Dec 2022 01:27:13 +0300 Subject: [PATCH 11/87] Refactor BaseChat, ProjectChat and DirectChat models --- chats/consumers.py | 2 +- chats/models.py | 35 +++++++++++++++-------------------- manage.py | 2 +- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 4ecd1891..ca04fb11 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -6,7 +6,7 @@ class ChatConsumer(JsonWebsocketConsumer): def connect(self): - # 1. check that user is authenticated + # TODO 1. check that user is authenticated self.room_name = self.scope["url_route"]["kwargs"]["room_name"] self.room_group_name = f"chat_{self.room_name}" diff --git a/chats/models.py b/chats/models.py index 9998153d..679e4f36 100644 --- a/chats/models.py +++ b/chats/models.py @@ -1,32 +1,27 @@ +from django.contrib.auth import get_user_model from django.db import models +User = get_user_model() + class BaseChat(models.Model): """ - Chat model + Base Chat model Attributes: - name: A CharField name of the chat. - users: A ManyToManyField referring to the User model. created_at: A DateTimeField indicating date of creation. """ created_at = models.DateTimeField(auto_now_add=True) # has to be overriden in child classes - def get_chat_users(self): + def get_users(self): raise NotImplementedError def __str__(self): - return f"Chat {self.pk}" - - @property - def users_str(self): - return ", ".join([i.email for i in self.users.all()]) + return f"BaseChat {self.pk}" class Meta: - verbose_name = "Чат" - verbose_name_plural = "Чаты" abstract = True @@ -35,7 +30,6 @@ class ProjectChat(BaseChat): ProjectChat model Attributes: - users: A ManyToManyField referring to the User model. created_at: A DateTimeField indicating date of creation. """ @@ -43,12 +37,12 @@ class ProjectChat(BaseChat): "projects.Project", on_delete=models.CASCADE, related_name="chats" ) - def get_chat_users(self): + def get_users(self): # TODO: return all collaborators from project + leader pass def __str__(self): - return f"Chat {self.pk} ({self.name})" + return f"ProjectChat<{self.project.id}> - {self.project.name}" class Meta: verbose_name = "Чат проекта" @@ -60,17 +54,18 @@ class DirectChat(BaseChat): DirectChat model Attributes: - users: A ManyToManyField referring to the User model. created_at: A DateTimeField indicating date of creation. """ + first_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="direct_chats") + second_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="direct_chats") - users = models.ManyToManyField("users.CustomUser", related_name="chats") - - def get_chat_users(self): - return self.users.all() + def get_users(self): + return [self.first_user, self.second_user] def __str__(self): - return f"Direct chat {self.pk} ({self.users_str})" + return f"DirectChat<{self.first_user.id}-{self.second_user.id}> -" \ + f" {self.first_user.first_name} {self.first_user.last_name} -" \ + f" {self.second_user.first_name} {self.second_user.last_name}" class Meta: verbose_name = "Личный чат" diff --git a/manage.py b/manage.py index 46ec69e7..042faec4 100644 --- a/manage.py +++ b/manage.py @@ -10,7 +10,7 @@ def main(): from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( - "Couldn't import Django. Are you sure it's installed and " + "Couэldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc From a64b3d4229a8e914db0e5c65a58cb2796a67af58 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sat, 24 Dec 2022 03:00:14 +0300 Subject: [PATCH 12/87] Comment serializers.py --- chats/models.py | 4 +- chats/serializers.py | 110 +++++++++++++++++++++---------------------- 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/chats/models.py b/chats/models.py index 679e4f36..bfb7dfce 100644 --- a/chats/models.py +++ b/chats/models.py @@ -86,13 +86,13 @@ class Message(models.Model): # TODO: add chat foreign key # chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name="messages") author = models.ForeignKey( - "users.CustomUser", on_delete=models.CASCADE, related_name="messages" + User, on_delete=models.CASCADE, related_name="messages" ) text = models.TextField() created_at = models.DateTimeField(auto_now_add=True) def __str__(self): - return f"Message {self.pk}" + return f"Message<{self.pk}>" class Meta: verbose_name = "Сообщение" diff --git a/chats/serializers.py b/chats/serializers.py index 2a225c43..bb2d788c 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -1,55 +1,55 @@ -from rest_framework import serializers - -from chats.models import Chat, Message - - -class ChatSerializer(serializers.ModelSerializer): - class Meta: - model = Chat - fields = [ - "id", - "name", - "users", - ] - - -class MessageInChatSerializer(serializers.ModelSerializer): - class Meta: - model = Message - fields = [ - "id", - "text", - "author", - "created_at", - ] - - -class ChatDetailSerializer(serializers.ModelSerializer): - messages = MessageInChatSerializer(many=True, read_only=True) - - class Meta: - model = Chat - fields = [ - "id", - "name", - "users", - "messages", - ] - - -class MessageSerializer(serializers.ModelSerializer): - class Meta: - model = Message - fields = [ - "id", - "chat", - "author", - "text", - "created_at", - ] - - def create(self, validated_data): - chat_id = self.context["request"].data["chat"] - chat = Chat.objects.get(id=chat_id) - message = Message.objects.create(chat=chat, **validated_data) - return message +# from rest_framework import serializers +# +# from chats.models import Chat, Message +# +# +# class ChatSerializer(serializers.ModelSerializer): +# class Meta: +# model = Chat +# fields = [ +# "id", +# "name", +# "users", +# ] +# +# +# class MessageInChatSerializer(serializers.ModelSerializer): +# class Meta: +# model = Message +# fields = [ +# "id", +# "text", +# "author", +# "created_at", +# ] +# +# +# class ChatDetailSerializer(serializers.ModelSerializer): +# messages = MessageInChatSerializer(many=True, read_only=True) +# +# class Meta: +# model = Chat +# fields = [ +# "id", +# "name", +# "users", +# "messages", +# ] +# +# +# class MessageSerializer(serializers.ModelSerializer): +# class Meta: +# model = Message +# fields = [ +# "id", +# "chat", +# "author", +# "text", +# "created_at", +# ] +# +# def create(self, validated_data): +# chat_id = self.context["request"].data["chat"] +# chat = Chat.objects.get(id=chat_id) +# message = Message.objects.create(chat=chat, **validated_data) +# return message From 37a1e4aa1815b675366f04fd70a4d9ef38d744de Mon Sep 17 00:00:00 2001 From: VeryBigSad Date: Sat, 24 Dec 2022 16:58:55 +0300 Subject: [PATCH 13/87] add CustomUser.get_full_name() --- users/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/users/models.py b/users/models.py index c220d362..cb047eb0 100644 --- a/users/models.py +++ b/users/models.py @@ -93,6 +93,9 @@ def get_key_skills(self) -> list[str]: def __str__(self): return f"User<{self.id}> - {self.first_name} {self.last_name}" + def get_full_name(self) -> str: + return self.first_name + " " + self.last_name + class Meta: verbose_name = "Пользователь" verbose_name_plural = "Пользователи" From f5658ce2ae24a7320aed608ed626b82262d3bc4f Mon Sep 17 00:00:00 2001 From: VeryBigSad Date: Sat, 24 Dec 2022 17:06:46 +0300 Subject: [PATCH 14/87] work on message models --- chats/models.py | 75 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/chats/models.py b/chats/models.py index bfb7dfce..5413ecc0 100644 --- a/chats/models.py +++ b/chats/models.py @@ -30,6 +30,7 @@ class ProjectChat(BaseChat): ProjectChat model Attributes: + project: A ForeignKey to Project model, indicating project, which chat belongs to. created_at: A DateTimeField indicating date of creation. """ @@ -38,8 +39,14 @@ class ProjectChat(BaseChat): ) def get_users(self): - # TODO: return all collaborators from project + leader - pass + """returns all collaborators and leader of the project + + Returns: + List[CustomUser]: list of users, who are collaborators or leader of the project + """ + collaborators = self.project.collaborators.all() + users = [collaborator.user for collaborator in collaborators] + return users + [self.project.leader] def __str__(self): return f"ProjectChat<{self.project.id}> - {self.project.name}" @@ -55,6 +62,11 @@ class DirectChat(BaseChat): Attributes: created_at: A DateTimeField indicating date of creation. + first_user: A ForeignKey to CustomUser model, indicating first user in chat. + second_user: A ForeignKey to CustomUser model, indicating second user in chat. + + Methods: + get_users: returns list of users, who are in chat """ first_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="direct_chats") second_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="direct_chats") @@ -63,32 +75,27 @@ def get_users(self): return [self.first_user, self.second_user] def __str__(self): - return f"DirectChat<{self.first_user.id}-{self.second_user.id}> -" \ - f" {self.first_user.first_name} {self.first_user.last_name} -" \ - f" {self.second_user.first_name} {self.second_user.last_name}" + return f"DirectChat with {self.first_user.get_full_name()} and {self.second_user.get_full_name()}" class Meta: verbose_name = "Личный чат" verbose_name_plural = "Личные чаты" -class Message(models.Model): +class BaseMessage(models.Model): """ - Message model + Base message model Attributes: - chat: A ForeignKey referring to the Chat model. author: A ForeignKey referring to the User model. text: A TextField containing message text. created_at: A DateTimeField indicating date of creation. """ - # TODO: add chat foreign key - # chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name="messages") author = models.ForeignKey( User, on_delete=models.CASCADE, related_name="messages" ) - text = models.TextField() + text = models.TextField(max_length=8192) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): @@ -98,3 +105,49 @@ class Meta: verbose_name = "Сообщение" verbose_name_plural = "Сообщения" ordering = ["-created_at"] + + +class ProjectChatMessage(BaseMessage): + """ + ProjectMessage model + + Attributes: + chat: A ForeignKey to ProjectChat model, indicating chat, which message belongs to. + author: A ForeignKey referring to the User model. + text: A TextField containing message text. + created_at: A DateTimeField indicating date of creation. + """ + + chat = models.ForeignKey( + ProjectChat, on_delete=models.CASCADE, related_name="messages" + ) + + def __str__(self): + return f"ProjectChatMessage<{self.pk}>" + + class Meta: + verbose_name = "Сообщение в чате проекта" + verbose_name_plural = "Сообщения в чатах проектов" + + +class DirectChatMessage(BaseMessage): + """ + DirectChatMessage model + + Attributes: + chat: A ForeignKey to DirectChat model, indicating chat, which message belongs to. + author: A ForeignKey referring to the User model. + text: A TextField containing message text. + created_at: A DateTimeField indicating date of creation. + """ + + chat = models.ForeignKey( + DirectChat, on_delete=models.CASCADE, related_name="messages" + ) + + def __str__(self): + return f"DirectChatMessage<{self.pk}>" + + class Meta: + verbose_name = "Сообщение в личном чате" + verbose_name_plural = "Сообщения в личных чатах" From 225ab0e7cc0856523fdcb03c20934d6878fe1178 Mon Sep 17 00:00:00 2001 From: VeryBigSad Date: Sat, 24 Dec 2022 17:13:50 +0300 Subject: [PATCH 15/87] fix admin for chats --- chats/admin.py | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/chats/admin.py b/chats/admin.py index a5d95449..a7df79fb 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -1,15 +1,37 @@ from django.contrib import admin -from chats.models import Chat, Message +from chats.models import ProjectChat, DirectChat, ProjectChatMessage, DirectChatMessage -@admin.register(Chat) +@admin.display(description='Список пользователей') +def project_chat_users(obj): + return f"{obj.get_users()}" + + +@admin.display(description='Количество сообщений') +def chat_message_count(obj): + return obj.messages.count() + + +@admin.register(ProjectChat) class ChatAdmin(admin.ModelAdmin): - list_display = ("name", "users_str", "created_at") - list_display_links = ("name",) + list_display = ("id", "project", project_chat_users, chat_message_count, "created_at") + list_display_links = ("id", "project",) + + +@admin.register(DirectChat) +class DirectChatAdmin(admin.ModelAdmin): + list_display = ("id", "first_user", "second_user", chat_message_count, "created_at") + list_display_links = ("id", "first_user", "second_user") + + +@admin.register(ProjectChatMessage) +class ProjectChatMessageAdmin(admin.ModelAdmin): + list_display = ("id", "author", "chat", "created_at") + list_display_links = ("id", "author", "chat") -@admin.register(Message) -class MessageAdmin(admin.ModelAdmin): - list_display = ("id", "chat", "author", "text", "created_at") - list_display_links = ("id",) +@admin.register(DirectChatMessage) +class DirectChatMessageAdmin(admin.ModelAdmin): + list_display = ("id", "author", "chat", "created_at") + list_display_links = ("id", "author", "chat") From 0692557114bdc0df4ddf444bf4e8d11613d12e28 Mon Sep 17 00:00:00 2001 From: VeryBigSad Date: Sat, 24 Dec 2022 17:16:05 +0300 Subject: [PATCH 16/87] maybe fix tests? --- chats/urls.py | 9 +--- chats/views.py | 130 ++++++++++++++++++++++++------------------------- 2 files changed, 66 insertions(+), 73 deletions(-) diff --git a/chats/urls.py b/chats/urls.py index 979ca744..5db93a89 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -1,12 +1,5 @@ from django.urls import path -from chats.views import ChatList, ChatDetail, MessageList, MessageDetail - app_name = "chats" -urlpatterns = [ - path("", ChatList.as_view()), - path("/", ChatDetail.as_view()), - path("/messages/", MessageList.as_view()), - path("/messages//", MessageDetail.as_view()), -] +urlpatterns = [] diff --git a/chats/views.py b/chats/views.py index f61838f5..7279cf55 100644 --- a/chats/views.py +++ b/chats/views.py @@ -1,93 +1,93 @@ from rest_framework import generics, permissions, mixins, status from rest_framework.response import Response -from chats.models import Chat, Message -from chats.serializers import ChatSerializer, MessageSerializer, ChatDetailSerializer -from core.permissions import IsMessageOwner, IsUserInChat +# from chats.models import Chat, Message +# from chats.serializers import ChatSerializer, MessageSerializer, ChatDetailSerializer +# from core.permissions import IsMessageOwner, IsUserInChat -class ChatList(generics.ListCreateAPIView): - queryset = Chat.objects.all() - serializer_class = ChatSerializer - permission_classes = [permissions.IsAuthenticatedOrReadOnly] +# class ChatList(generics.ListCreateAPIView): +# queryset = Chat.objects.all() +# serializer_class = ChatSerializer +# permission_classes = [permissions.IsAuthenticatedOrReadOnly] -class ChatDetail(generics.RetrieveUpdateDestroyAPIView): - serializer_class = ChatDetailSerializer - permission_classes = [permissions.IsAuthenticatedOrReadOnly] +# class ChatDetail(generics.RetrieveUpdateDestroyAPIView): +# serializer_class = ChatDetailSerializer +# permission_classes = [permissions.IsAuthenticatedOrReadOnly] - def get_queryset(self): - return ( - Chat.objects.all() - .prefetch_related("messages") - .prefetch_related("users") - .all() - ) +# def get_queryset(self): +# return ( +# Chat.objects.all() +# .prefetch_related("messages") +# .prefetch_related("users") +# .all() +# ) - def get(self, request, *args, **kwargs): - """ - Get chat by id - You can set first_obj and count to get messages from chat - Args: - request: request - *args: args - **kwargs: kwargs +# def get(self, request, *args, **kwargs): +# """ +# Get chat by id +# You can set first_obj and count to get messages from chat +# Args: +# request: request +# *args: args +# **kwargs: kwargs - Returns: - Response with chat - """ +# Returns: +# Response with chat +# """ - instance = self.get_object() - serializer = self.get_serializer(instance) - return Response(serializer.data) +# instance = self.get_object() +# serializer = self.get_serializer(instance) +# return Response(serializer.data) -class MessageList( - mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView -): - permission_classes = [IsUserInChat] - serializer_class = MessageSerializer +# class MessageList( +# mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView +# ): +# permission_classes = [IsUserInChat] +# serializer_class = MessageSerializer - def get(self, request, *args, **kwargs): - return self.list(self, request, *args, **kwargs) +# def get(self, request, *args, **kwargs): +# return self.list(self, request, *args, **kwargs) - def get_queryset(self): - return Message.objects.filter(chat_id=self.kwargs["pk"]) +# def get_queryset(self): +# return Message.objects.filter(chat_id=self.kwargs["pk"]) - def post(self, request, *args, **kwargs): - try: - request.data["chat"] = str(self.kwargs["pk"]) - except AttributeError: - pass +# def post(self, request, *args, **kwargs): +# try: +# request.data["chat"] = str(self.kwargs["pk"]) +# except AttributeError: +# pass - return self.create(request, *args, **kwargs) +# return self.create(request, *args, **kwargs) -class MessageDetail(generics.RetrieveUpdateDestroyAPIView): - queryset = Message.objects.all() - serializer_class = MessageSerializer - permission_classes = [IsMessageOwner] +# class MessageDetail(generics.RetrieveUpdateDestroyAPIView): +# queryset = Message.objects.all() +# serializer_class = MessageSerializer +# permission_classes = [IsMessageOwner] - def patch(self, request, *args, **kwargs): - message = Message.objects.get(pk=self.kwargs["message_id"]) +# def patch(self, request, *args, **kwargs): +# message = Message.objects.get(pk=self.kwargs["message_id"]) - if request.user != message.author: - return Response(status=status.HTTP_403_FORBIDDEN) +# if request.user != message.author: +# return Response(status=status.HTTP_403_FORBIDDEN) - return self.partial_update(request, *args, **kwargs) +# return self.partial_update(request, *args, **kwargs) - def delete(self, request, *args, **kwargs): - message = Message.objects.get(pk=self.kwargs["message_id"]) +# def delete(self, request, *args, **kwargs): +# message = Message.objects.get(pk=self.kwargs["message_id"]) - if request.user != message.author: - return Response(status=status.HTTP_403_FORBIDDEN) +# if request.user != message.author: +# return Response(status=status.HTTP_403_FORBIDDEN) - return self.destroy(request, *args, **kwargs) +# return self.destroy(request, *args, **kwargs) - def put(self, request, *args, **kwargs): - message = Message.objects.get(pk=self.kwargs["message_id"]) +# def put(self, request, *args, **kwargs): +# message = Message.objects.get(pk=self.kwargs["message_id"]) - if request.user != message.author: - return Response(status=status.HTTP_403_FORBIDDEN) +# if request.user != message.author: +# return Response(status=status.HTTP_403_FORBIDDEN) - return self.update(request, *args, **kwargs) +# return self.update(request, *args, **kwargs) From f1225d762f5d35b8706a5939a83a66d25968325d Mon Sep 17 00:00:00 2001 From: VeryBigSad Date: Sat, 24 Dec 2022 17:33:58 +0300 Subject: [PATCH 17/87] first_user & second_user -> users --- chats/admin.py | 4 ++-- chats/models.py | 22 ++++++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/chats/admin.py b/chats/admin.py index a7df79fb..d4436fbf 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -21,8 +21,8 @@ class ChatAdmin(admin.ModelAdmin): @admin.register(DirectChat) class DirectChatAdmin(admin.ModelAdmin): - list_display = ("id", "first_user", "second_user", chat_message_count, "created_at") - list_display_links = ("id", "first_user", "second_user") + list_display = ("id", "users", chat_message_count, "created_at") + list_display_links = ("id",) @admin.register(ProjectChatMessage) diff --git a/chats/models.py b/chats/models.py index 5413ecc0..004c75ac 100644 --- a/chats/models.py +++ b/chats/models.py @@ -68,14 +68,28 @@ class DirectChat(BaseChat): Methods: get_users: returns list of users, who are in chat """ - first_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="direct_chats") - second_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="direct_chats") + + users = models.ManyToManyField(User, related_name="direct_chats") def get_users(self): - return [self.first_user, self.second_user] + """returns all users in chat + + Returns: + List[CustomUser]: list of users, who are in chat + """ + return self.users.all() + + def get_users_str(self): + """returns string of users, who are in chat + + Returns: + str: string of users, who are in chat + """ + users = self.get_users() + return f"{users[0].get_full_name()}, {users[1].get_full_name()}" def __str__(self): - return f"DirectChat with {self.first_user.get_full_name()} and {self.second_user.get_full_name()}" + return f"DirectChat with {self.get_users_str()}" class Meta: verbose_name = "Личный чат" From d52ec36593eed6ddf68842c6b6aff8b044969295 Mon Sep 17 00:00:00 2001 From: VeryBigSad Date: Sat, 24 Dec 2022 17:36:56 +0300 Subject: [PATCH 18/87] change admin for chats --- chats/admin.py | 10 +++++----- chats/models.py | 18 +++++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/chats/admin.py b/chats/admin.py index d4436fbf..d185a573 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -3,9 +3,9 @@ from chats.models import ProjectChat, DirectChat, ProjectChatMessage, DirectChatMessage -@admin.display(description='Список пользователей') -def project_chat_users(obj): - return f"{obj.get_users()}" +@admin.display(description='Пользователи чата') +def chat_users(obj): + return f"{obj.get_users_str()}" @admin.display(description='Количество сообщений') @@ -15,13 +15,13 @@ def chat_message_count(obj): @admin.register(ProjectChat) class ChatAdmin(admin.ModelAdmin): - list_display = ("id", "project", project_chat_users, chat_message_count, "created_at") + list_display = ("id", "project", chat_users, chat_message_count, "created_at") list_display_links = ("id", "project",) @admin.register(DirectChat) class DirectChatAdmin(admin.ModelAdmin): - list_display = ("id", "users", chat_message_count, "created_at") + list_display = ("id", chat_users, chat_message_count, "created_at") list_display_links = ("id",) diff --git a/chats/models.py b/chats/models.py index 004c75ac..69e5320b 100644 --- a/chats/models.py +++ b/chats/models.py @@ -17,6 +17,15 @@ class BaseChat(models.Model): # has to be overriden in child classes def get_users(self): raise NotImplementedError + + def get_users_str(self): + """returns string of users, who are in chat + + Returns: + str: string of users, who are in chat + """ + users = self.get_users() + return ", ".join([user.get_full_name() for user in users]) def __str__(self): return f"BaseChat {self.pk}" @@ -78,15 +87,6 @@ def get_users(self): List[CustomUser]: list of users, who are in chat """ return self.users.all() - - def get_users_str(self): - """returns string of users, who are in chat - - Returns: - str: string of users, who are in chat - """ - users = self.get_users() - return f"{users[0].get_full_name()}, {users[1].get_full_name()}" def __str__(self): return f"DirectChat with {self.get_users_str()}" From e5d6b526f2af43708b2bcbb1b5db314455fd47c7 Mon Sep 17 00:00:00 2001 From: VeryBigSad Date: Sat, 24 Dec 2022 18:13:58 +0300 Subject: [PATCH 19/87] refactoring --- chats/admin.py | 9 ++++++--- chats/models.py | 14 ++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/chats/admin.py b/chats/admin.py index d185a573..8759ddc8 100644 --- a/chats/admin.py +++ b/chats/admin.py @@ -3,12 +3,12 @@ from chats.models import ProjectChat, DirectChat, ProjectChatMessage, DirectChatMessage -@admin.display(description='Пользователи чата') +@admin.display(description="Пользователи чата") def chat_users(obj): return f"{obj.get_users_str()}" -@admin.display(description='Количество сообщений') +@admin.display(description="Количество сообщений") def chat_message_count(obj): return obj.messages.count() @@ -16,7 +16,10 @@ def chat_message_count(obj): @admin.register(ProjectChat) class ChatAdmin(admin.ModelAdmin): list_display = ("id", "project", chat_users, chat_message_count, "created_at") - list_display_links = ("id", "project",) + list_display_links = ( + "id", + "project", + ) @admin.register(DirectChat) diff --git a/chats/models.py b/chats/models.py index 69e5320b..453d24ec 100644 --- a/chats/models.py +++ b/chats/models.py @@ -17,13 +17,13 @@ class BaseChat(models.Model): # has to be overriden in child classes def get_users(self): raise NotImplementedError - + def get_users_str(self): """returns string of users, who are in chat Returns: str: string of users, who are in chat - """ + """ users = self.get_users() return ", ".join([user.get_full_name() for user in users]) @@ -52,7 +52,7 @@ def get_users(self): Returns: List[CustomUser]: list of users, who are collaborators or leader of the project - """ + """ collaborators = self.project.collaborators.all() users = [collaborator.user for collaborator in collaborators] return users + [self.project.leader] @@ -85,7 +85,7 @@ def get_users(self): Returns: List[CustomUser]: list of users, who are in chat - """ + """ return self.users.all() def __str__(self): @@ -106,9 +106,7 @@ class BaseMessage(models.Model): created_at: A DateTimeField indicating date of creation. """ - author = models.ForeignKey( - User, on_delete=models.CASCADE, related_name="messages" - ) + author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="messages") text = models.TextField(max_length=8192) created_at = models.DateTimeField(auto_now_add=True) @@ -131,7 +129,7 @@ class ProjectChatMessage(BaseMessage): text: A TextField containing message text. created_at: A DateTimeField indicating date of creation. """ - + chat = models.ForeignKey( ProjectChat, on_delete=models.CASCADE, related_name="messages" ) From 6603ad85c0fcd5a754a71767bef0704aa6cdae61 Mon Sep 17 00:00:00 2001 From: VeryBigSad Date: Sat, 24 Dec 2022 18:15:22 +0300 Subject: [PATCH 20/87] refactoring, so that pylint will work --- chats/urls.py | 2 +- chats/views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/chats/urls.py b/chats/urls.py index 5db93a89..4f737912 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +# from django.urls import path app_name = "chats" diff --git a/chats/views.py b/chats/views.py index 7279cf55..36d0742e 100644 --- a/chats/views.py +++ b/chats/views.py @@ -1,5 +1,5 @@ -from rest_framework import generics, permissions, mixins, status -from rest_framework.response import Response +# from rest_framework import generics, permissions, mixins, status +# from rest_framework.response import Response # from chats.models import Chat, Message # from chats.serializers import ChatSerializer, MessageSerializer, ChatDetailSerializer From 810eebd6bc5316f3e118186d78ff110e7869a2f7 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sun, 25 Dec 2022 02:34:06 +0300 Subject: [PATCH 21/87] Replaced string concatenation to f-string in CustomUser. get_full_name() --- users/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/users/models.py b/users/models.py index cb047eb0..bb7454a8 100644 --- a/users/models.py +++ b/users/models.py @@ -94,7 +94,7 @@ def __str__(self): return f"User<{self.id}> - {self.first_name} {self.last_name}" def get_full_name(self) -> str: - return self.first_name + " " + self.last_name + return f"{self.first_name} {self.last_name}" class Meta: verbose_name = "Пользователь" From fe9b06a3d52f36c5aceb6d8a47ad86139e1d1917 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sun, 25 Dec 2022 02:42:03 +0300 Subject: [PATCH 22/87] Little refactoring --- chats/models.py | 12 +++++++----- users/models.py | 5 +++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/chats/models.py b/chats/models.py index 453d24ec..d2dc4aac 100644 --- a/chats/models.py +++ b/chats/models.py @@ -28,7 +28,7 @@ def get_users_str(self): return ", ".join([user.get_full_name() for user in users]) def __str__(self): - return f"BaseChat {self.pk}" + return f"BaseChat<{self.pk}>" class Meta: abstract = True @@ -48,11 +48,13 @@ class ProjectChat(BaseChat): ) def get_users(self): - """returns all collaborators and leader of the project + """ + Returns all collaborators and leader of the project. Returns: List[CustomUser]: list of users, who are collaborators or leader of the project """ + collaborators = self.project.collaborators.all() users = [collaborator.user for collaborator in collaborators] return users + [self.project.leader] @@ -71,8 +73,6 @@ class DirectChat(BaseChat): Attributes: created_at: A DateTimeField indicating date of creation. - first_user: A ForeignKey to CustomUser model, indicating first user in chat. - second_user: A ForeignKey to CustomUser model, indicating second user in chat. Methods: get_users: returns list of users, who are in chat @@ -81,11 +81,13 @@ class DirectChat(BaseChat): users = models.ManyToManyField(User, related_name="direct_chats") def get_users(self): - """returns all users in chat + """ + Returns all users in chat. Returns: List[CustomUser]: list of users, who are in chat """ + return self.users.all() def __str__(self): diff --git a/users/models.py b/users/models.py index bb7454a8..7a60af8f 100644 --- a/users/models.py +++ b/users/models.py @@ -90,11 +90,12 @@ class CustomUser(AbstractUser): def get_key_skills(self) -> list[str]: return [skill.strip() for skill in self.key_skills.split(",") if skill.strip()] + def get_full_name(self) -> str: + return f"{self.first_name} {self.last_name}" + def __str__(self): return f"User<{self.id}> - {self.first_name} {self.last_name}" - def get_full_name(self) -> str: - return f"{self.first_name} {self.last_name}" class Meta: verbose_name = "Пользователь" From 85a813e03b6a15f762eefff084ac1a9f429315b9 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sun, 25 Dec 2022 02:48:21 +0300 Subject: [PATCH 23/87] Fixed linter errors --- users/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/users/models.py b/users/models.py index 7a60af8f..aee25e71 100644 --- a/users/models.py +++ b/users/models.py @@ -96,7 +96,6 @@ def get_full_name(self) -> str: def __str__(self): return f"User<{self.id}> - {self.first_name} {self.last_name}" - class Meta: verbose_name = "Пользователь" verbose_name_plural = "Пользователи" From 16de7bc33f3d1cc6034d6ee62e8bf30c942c4e1a Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sun, 25 Dec 2022 19:40:07 +0300 Subject: [PATCH 24/87] better models & methods --- chats/models.py | 61 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/chats/models.py b/chats/models.py index d2dc4aac..5f173593 100644 --- a/chats/models.py +++ b/chats/models.py @@ -14,8 +14,13 @@ class BaseChat(models.Model): created_at = models.DateTimeField(auto_now_add=True) - # has to be overriden in child classes def get_users(self): + """ + Returns all collaborators and leader of the project. + + Returns: + List[CustomUser]: list of users, who are collaborators or leader of the project + """ raise NotImplementedError def get_users_str(self): @@ -27,6 +32,18 @@ def get_users_str(self): users = self.get_users() return ", ".join([user.get_full_name() for user in users]) + def get_avatar(self, user): + """ + Returns avatar of the chat for given user + + Args: + user: User who will see the avatar + + Returns: + str: link to avatar of the chat for given user + """ + raise NotImplementedError + def __str__(self): return f"BaseChat<{self.pk}>" @@ -48,17 +65,13 @@ class ProjectChat(BaseChat): ) def get_users(self): - """ - Returns all collaborators and leader of the project. - - Returns: - List[CustomUser]: list of users, who are collaborators or leader of the project - """ - collaborators = self.project.collaborators.all() users = [collaborator.user for collaborator in collaborators] return users + [self.project.leader] + def get_avatar(self, user): + return self.project.image_address + def __str__(self): return f"ProjectChat<{self.project.id}> - {self.project.name}" @@ -78,17 +91,39 @@ class DirectChat(BaseChat): get_users: returns list of users, who are in chat """ - users = models.ManyToManyField(User, related_name="direct_chats") + id = models.CharField(primary_key=True, max_length=64) + users = models.ManyToManyField(User, related_name="direct_chats", primary_key=True) def get_users(self): + return self.users.all() + + def get_avatar(self, user): + other_user = self.get_users().exclude(pk=user.pk).first() + return other_user.avatar + + @classmethod + def get_chat(cls, user1, user2) -> "DirectChat": """ - Returns all users in chat. + Returns chat between two users. + + Args: + user1 (CustomUser): first user, who is in chat + user2 (CustomUser): second user, who is in chat Returns: - List[CustomUser]: list of users, who are in chat + DirectChat: chat between two users """ - - return self.users.all() + # maybe use .get_or_create() here? + try: + return cls.objects.get(pk="_".join(sorted([str(user1.pk), str(user2.pk)]))) + except cls.DoesNotExist: + return cls.objects.create(users=[user1, user2]) + + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + self.id = "_".join(sorted([str(user.pk) for user in self.users.all()])) + super().save(force_insert, force_update, using, update_fields) def __str__(self): return f"DirectChat with {self.get_users_str()}" From f9592b470eb90758ad9f9dc32995192b5cfeea00 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sun, 25 Dec 2022 19:42:00 +0300 Subject: [PATCH 25/87] better docstrings for core.permissions --- core/permissions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/permissions.py b/core/permissions.py index bf793cfd..4760fad8 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -14,7 +14,7 @@ def has_permission(self, request, view) -> bool: class IsOwnerOrReadOnly(BasePermission): """ - Allows access to update only to himself. + Access to update only to the owner. """ def has_object_permission(self, request, view, obj) -> bool: @@ -25,7 +25,7 @@ def has_object_permission(self, request, view, obj) -> bool: class IsMessageOwner(BasePermission): """ - Allows access to update only to himself. + Access to update only message owner. """ def has_object_permission(self, request, view, obj) -> bool: @@ -36,7 +36,7 @@ def has_object_permission(self, request, view, obj) -> bool: class IsUserInChat(BasePermission): """ - Allows access to update only to himself. + Access to update only to users in chat. """ def has_object_permission(self, request, view, obj) -> bool: From 9904ce91664b2bc69be0b0b407c7e382c5463154 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sun, 25 Dec 2022 19:42:41 +0300 Subject: [PATCH 26/87] authorization for consumers --- chats/consumers.py | 55 ++++++++++++++++++++++++--- chats/middleware.py | 92 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 chats/middleware.py diff --git a/chats/consumers.py b/chats/consumers.py index ca04fb11..04cd55d1 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -5,8 +5,17 @@ class ChatConsumer(JsonWebsocketConsumer): + def __init__(self, *args, **kwargs): + super().__init__(args, kwargs) + self.room_name = None + self.user = None + def connect(self): - # TODO 1. check that user is authenticated + self.user = self.scope["user"] + # check that user is authenticated + if not self.user.is_authenticated: + return + self.room_name = self.scope["url_route"]["kwargs"]["room_name"] self.room_group_name = f"chat_{self.room_name}" @@ -25,12 +34,31 @@ def disconnect(self, close_code): # Receive message from WebSocket def receive_json(self, content, **kwargs): - message = content["message"] + message_type = content["type"] - # Send message to room group - async_to_sync(self.channel_layer.group_send)( - self.room_group_name, {"type": "chat_message", "message": message} - ) + if message_type == "chat_message": + # TODO + # message = Message.objects.create( + # author=self.user, + # content=content["message"], + # chat= + # ) + async_to_sync(self.channel_layer.group_send)( + self.room_name, + { + "type": "chat_message_echo", + "name": content["name"], + "message": content["message"], + }, + ) + elif message_type == "typing": + # TODO + pass + elif message_type == "read": + # TODO + pass + + return super().receive_json(content, **kwargs) # Receive message from room group def chat_message(self, event): @@ -38,3 +66,18 @@ def chat_message(self, event): # Send message to WebSocket self.send(text_data=json.dumps({"message": message})) + + +class NotificationConsumer(JsonWebsocketConsumer): + def __init__(self, *args, **kwargs): + super().__init__(args, kwargs) + self.user = None + + def connect(self): + self.user = self.scope["user"] + if not self.user.is_authenticated: + return + + self.accept() + + # Send count of unread messages diff --git a/chats/middleware.py b/chats/middleware.py new file mode 100644 index 00000000..11788058 --- /dev/null +++ b/chats/middleware.py @@ -0,0 +1,92 @@ +from urllib.parse import parse_qs + +from channels.db import database_sync_to_async +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ +from rest_framework.exceptions import AuthenticationFailed + +User = get_user_model() + + +class TokenAuthentication: + """ + Simple token based authentication. + + Clients should authenticate by passing the token key in the query parameters. + For example: + + ?token=401f7ac837da42b97f613d789819ff93537bee6a + """ + + model = None + + def get_model(self): + if self.model is not None: + return self.model + from rest_framework.authtoken.models import Token + + return Token + + """ + A custom token model may be used, but must have the following properties. + + * key -- The string identifying the token + * user -- The user to which the token belongs + """ + + def authenticate_credentials(self, key): + model = self.get_model() + try: + token = model.objects.select_related("user").get(key=key) + except model.DoesNotExist: + raise AuthenticationFailed(_("Invalid token.")) + + if not token.user.is_active: + raise AuthenticationFailed(_("User inactive or deleted.")) + + return token.user + + +@database_sync_to_async +def get_user(scope): + """ + Return the user model instance associated with the given scope. + If no user is retrieved, return an instance of `AnonymousUser`. + """ + # postpone model import to avoid ImproperlyConfigured error before Django + # setup is complete. + from django.contrib.auth.models import AnonymousUser + + if "token" not in scope: + raise ValueError( + "Cannot find token in scope. You should wrap your consumer in " + "TokenAuthMiddleware." + ) + token = scope["token"] + user = None + try: + auth = TokenAuthentication() + user = auth.authenticate(token) + except AuthenticationFailed: + pass + return user or AnonymousUser() + + +class TokenAuthMiddleware: + """ + Custom middleware that takes a token from the query string and authenticates via Django Rest Framework authtoken. + """ + + def __init__(self, app): + # Store the ASGI application we were passed + self.app = app + + async def __call__(self, scope, receive, send): + # Look up user from query string (you should also do things like + # checking if it is a valid user ID, or if scope["user"] is already + # populated). + query_params = parse_qs(scope["query_string"].decode()) + token = query_params["token"][0] + scope["token"] = token + scope["user"] = await get_user(scope) + return await self.app(scope, receive, send) From 997ccd525c8081219de06a0dc414bda5e5b9c91f Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sun, 25 Dec 2022 20:33:32 +0300 Subject: [PATCH 27/87] work on ChatConsumer --- chats/consumers.py | 46 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 04cd55d1..fcdff298 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -3,34 +3,62 @@ from asgiref.sync import async_to_sync from channels.generic.websocket import JsonWebsocketConsumer +from chats.models import ProjectChat, DirectChat +from projects.models import Project +from users.models import CustomUser + class ChatConsumer(JsonWebsocketConsumer): + ROOM_TYPES = { + "DIRECT": "direct", + "PROJECT": "project", + } + def __init__(self, *args, **kwargs): super().__init__(args, kwargs) self.room_name = None self.user = None + self.chat_type = None + self.chat = None def connect(self): + # authentication self.user = self.scope["user"] - # check that user is authenticated if not self.user.is_authenticated: return - self.room_name = self.scope["url_route"]["kwargs"]["room_name"] - self.room_group_name = f"chat_{self.room_name}" + room_name = self.scope["url_route"]["kwargs"]["room_name"] + if "direct_" in room_name: + to_user_id = int(room_name.split("_")[1]) + other_user = CustomUser.objects.filter(id=to_user_id).first() + if not other_user or to_user_id == self.user.id: + # such user does not exist / user tries to chat with himself + self.close() + return + user1_id = min(self.user.pk, to_user_id) + user2_id = max(self.user.pk, to_user_id) + self.room_name = f"direct_{user1_id}_{user2_id}" + self.chat_type = "direct" + self.chat = DirectChat.objects.get_chat(self.user, other_user) + + elif "project_" in room_name: + project_id = int(room_name.split("_")[1]) + if not Project.objects.filter(id=project_id).exists(): + # project does not exist + self.close() + return + self.room_name = f"project_{project_id}" + self.chat_type = "project" + self.chat = ProjectChat.objects.get(project_id=project_id) # Join room group - async_to_sync(self.channel_layer.group_add)( - self.room_group_name, self.channel_name - ) + async_to_sync(self.channel_layer.group_add)(self.room_name, self.channel_name) self.accept() def disconnect(self, close_code): # Leave room group - async_to_sync(self.channel_layer.group_discard)( - self.room_group_name, self.channel_name - ) + async_to_sync(self.channel_layer.group_discard)(self.room_name, self.channel_name) # Receive message from WebSocket def receive_json(self, content, **kwargs): From 204fbecf4f05665c33cae6dfbf2b69e1ab25e6cc Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sun, 25 Dec 2022 20:38:38 +0300 Subject: [PATCH 28/87] creating message objects on chat messages --- chats/consumers.py | 43 +++++++++++++++++++------------------------ 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index fcdff298..d2dbb2f8 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -1,9 +1,7 @@ -import json - from asgiref.sync import async_to_sync from channels.generic.websocket import JsonWebsocketConsumer -from chats.models import ProjectChat, DirectChat +from chats.models import ProjectChat, DirectChat, DirectChatMessage, ProjectChatMessage from projects.models import Project from users.models import CustomUser @@ -65,20 +63,7 @@ def receive_json(self, content, **kwargs): message_type = content["type"] if message_type == "chat_message": - # TODO - # message = Message.objects.create( - # author=self.user, - # content=content["message"], - # chat= - # ) - async_to_sync(self.channel_layer.group_send)( - self.room_name, - { - "type": "chat_message_echo", - "name": content["name"], - "message": content["message"], - }, - ) + self.__process_new_message(content) elif message_type == "typing": # TODO pass @@ -86,14 +71,24 @@ def receive_json(self, content, **kwargs): # TODO pass - return super().receive_json(content, **kwargs) - - # Receive message from room group - def chat_message(self, event): - message = event["message"] + def __process_new_message(self, content): + if self.chat_type == "direct": # TODO: replace with enum + DirectChatMessage.objects.create( + chat=self.chat, author=self.user, text=content["text"] + ) + else: + ProjectChatMessage.objects.create( + chat=self.chat, author=self.user, text=content["text"] + ) - # Send message to WebSocket - self.send(text_data=json.dumps({"message": message})) + async_to_sync(self.channel_layer.group_send)( + self.room_name, + { + "type": "chat_message_echo", + "name": content["name"], + "message": content["message"], + }, + ) class NotificationConsumer(JsonWebsocketConsumer): From 5163de590a5f388e6ae9a08462ac4d6d0c750635 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sun, 25 Dec 2022 20:53:54 +0300 Subject: [PATCH 29/87] fix tests? --- chats/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chats/models.py b/chats/models.py index 5f173593..97bb5eb9 100644 --- a/chats/models.py +++ b/chats/models.py @@ -92,7 +92,7 @@ class DirectChat(BaseChat): """ id = models.CharField(primary_key=True, max_length=64) - users = models.ManyToManyField(User, related_name="direct_chats", primary_key=True) + users = models.ManyToManyField(User, related_name="direct_chats") def get_users(self): return self.users.all() From 31dd59cce18b3f7c929ba56a68baf20e7b8d4cd3 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sun, 25 Dec 2022 23:43:50 +0400 Subject: [PATCH 30/87] Added chat and event types, message validation Refactoring --- chats/consumers.py | 58 ++++++++++++++++++++++++------------ chats/models.py | 6 ++-- chats/routing.py | 4 +-- chats/utils.py | 14 +++++++++ chats/websockets_settings.py | 12 ++++++++ 5 files changed, 70 insertions(+), 24 deletions(-) create mode 100644 chats/utils.py create mode 100644 chats/websockets_settings.py diff --git a/chats/consumers.py b/chats/consumers.py index d2dbb2f8..4e5afcf8 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -2,6 +2,8 @@ from channels.generic.websocket import JsonWebsocketConsumer from chats.models import ProjectChat, DirectChat, DirectChatMessage, ProjectChatMessage +from chats.utils import clean_message_text, validate_message_text +from chats.websockets_settings import ChatType, EventType from projects.models import Project from users.models import CustomUser @@ -26,25 +28,41 @@ def connect(self): return room_name = self.scope["url_route"]["kwargs"]["room_name"] - if "direct_" in room_name: - to_user_id = int(room_name.split("_")[1]) - other_user = CustomUser.objects.filter(id=to_user_id).first() - if not other_user or to_user_id == self.user.id: + if room_name.startswith("direct"): + other_user_id = int(room_name.split("_")[1]) + other_user = CustomUser.objects.filter(id=other_user_id).first() + + if not other_user or other_user_id == self.user.id: # such user does not exist / user tries to chat with himself self.close() return - user1_id = min(self.user.pk, to_user_id) - user2_id = max(self.user.pk, to_user_id) + + user1_id = min(self.user.pk, other_user_id) + user2_id = max(self.user.pk, other_user_id) + self.room_name = f"direct_{user1_id}_{user2_id}" self.chat_type = "direct" self.chat = DirectChat.objects.get_chat(self.user, other_user) - elif "project_" in room_name: + elif room_name.startswith("project"): project_id = int(room_name.split("_")[1]) - if not Project.objects.filter(id=project_id).exists(): + + filtered_projects = Project.objects.filter(id=project_id) + + if not filtered_projects.exists(): # project does not exist self.close() return + + if ( + not filtered_projects.first() + .collaborators.filter(user=self.user) + .exists() + ): + # user is not a collaborator + self.close() + return + self.room_name = f"project_{project_id}" self.chat_type = "project" self.chat = ProjectChat.objects.get(project_id=project_id) @@ -62,31 +80,33 @@ def disconnect(self, close_code): def receive_json(self, content, **kwargs): message_type = content["type"] - if message_type == "chat_message": + if message_type == EventType.CHAT_MESSAGE.value: self.__process_new_message(content) - elif message_type == "typing": + elif message_type == EventType.TYPING.value: # TODO pass - elif message_type == "read": + elif message_type == EventType.READ.value: # TODO pass def __process_new_message(self, content): - if self.chat_type == "direct": # TODO: replace with enum - DirectChatMessage.objects.create( - chat=self.chat, author=self.user, text=content["text"] - ) + text = clean_message_text(content["text"]) + if not validate_message_text(text): + return + + if self.chat_type == ChatType.DIRECT.value: # TODO: replace with enum + DirectChatMessage.objects.create(chat=self.chat, author=self.user, text=text) + elif self.chat_type == ChatType.PROJECT.value: + ProjectChatMessage.objects.create(chat=self.chat, author=self.user, text=text) else: - ProjectChatMessage.objects.create( - chat=self.chat, author=self.user, text=content["text"] - ) + return async_to_sync(self.channel_layer.group_send)( self.room_name, { "type": "chat_message_echo", "name": content["name"], - "message": content["message"], + "message": text, }, ) diff --git a/chats/models.py b/chats/models.py index 97bb5eb9..1402e19a 100644 --- a/chats/models.py +++ b/chats/models.py @@ -1,6 +1,8 @@ from django.contrib.auth import get_user_model from django.db import models +from projects.models import Project + User = get_user_model() @@ -60,9 +62,7 @@ class ProjectChat(BaseChat): created_at: A DateTimeField indicating date of creation. """ - project = models.ForeignKey( - "projects.Project", on_delete=models.CASCADE, related_name="chats" - ) + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="chats") def get_users(self): collaborators = self.project.collaborators.all() diff --git a/chats/routing.py b/chats/routing.py index 9f08ddfa..46cb7686 100644 --- a/chats/routing.py +++ b/chats/routing.py @@ -1,7 +1,7 @@ from django.urls import re_path -from . import consumers +from consumers import ChatConsumer websocket_urlpatterns = [ - re_path(r"ws/chat/(?P\w+)/$", consumers.ChatConsumer.as_asgi()), + re_path(r"ws/chat/(?P\w+)/$", ChatConsumer.as_asgi()), ] diff --git a/chats/utils.py b/chats/utils.py new file mode 100644 index 00000000..408eddf7 --- /dev/null +++ b/chats/utils.py @@ -0,0 +1,14 @@ +def clean_message_text(text: str) -> str: + """ + Cleans message text. + """ + + return text.strip() + + +def validate_message_text(text: str) -> bool: + """ + Validates message text. + """ + + return len(text) > 0 and len(text) <= 8192 diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py new file mode 100644 index 00000000..805a45ef --- /dev/null +++ b/chats/websockets_settings.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class ChatType(Enum): + DIRECT = "direct" + PROJECT = "project" + + +class EventType(Enum): + CHAT_MESSAGE = "chat_message" + TYPING = "typing" + READ = "read" From 8e1ec1b056765adfa974ff0a2f718fce347a17f5 Mon Sep 17 00:00:00 2001 From: Yakser Date: Mon, 26 Dec 2022 00:51:09 +0400 Subject: [PATCH 31/87] Removed unused constant, divided connect function --- chats/consumers.py | 89 +++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 45 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 4e5afcf8..32da6733 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -1,7 +1,7 @@ from asgiref.sync import async_to_sync from channels.generic.websocket import JsonWebsocketConsumer -from chats.models import ProjectChat, DirectChat, DirectChatMessage, ProjectChatMessage +from chats.models import DirectChat, DirectChatMessage, ProjectChat, ProjectChatMessage from chats.utils import clean_message_text, validate_message_text from chats.websockets_settings import ChatType, EventType from projects.models import Project @@ -9,11 +9,6 @@ class ChatConsumer(JsonWebsocketConsumer): - ROOM_TYPES = { - "DIRECT": "direct", - "PROJECT": "project", - } - def __init__(self, *args, **kwargs): super().__init__(args, kwargs) self.room_name = None @@ -28,44 +23,10 @@ def connect(self): return room_name = self.scope["url_route"]["kwargs"]["room_name"] - if room_name.startswith("direct"): - other_user_id = int(room_name.split("_")[1]) - other_user = CustomUser.objects.filter(id=other_user_id).first() - - if not other_user or other_user_id == self.user.id: - # such user does not exist / user tries to chat with himself - self.close() - return - - user1_id = min(self.user.pk, other_user_id) - user2_id = max(self.user.pk, other_user_id) - - self.room_name = f"direct_{user1_id}_{user2_id}" - self.chat_type = "direct" - self.chat = DirectChat.objects.get_chat(self.user, other_user) - - elif room_name.startswith("project"): - project_id = int(room_name.split("_")[1]) - - filtered_projects = Project.objects.filter(id=project_id) - - if not filtered_projects.exists(): - # project does not exist - self.close() - return - - if ( - not filtered_projects.first() - .collaborators.filter(user=self.user) - .exists() - ): - # user is not a collaborator - self.close() - return - - self.room_name = f"project_{project_id}" - self.chat_type = "project" - self.chat = ProjectChat.objects.get(project_id=project_id) + if room_name.startswith(ChatType.DIRECT.value): + self.__connect_to_direct_chat() + elif room_name.startswith(ChatType.PROJECT.value): + self.__connect_to_project_chat() # Join room group async_to_sync(self.channel_layer.group_add)(self.room_name, self.channel_name) @@ -94,7 +55,7 @@ def __process_new_message(self, content): if not validate_message_text(text): return - if self.chat_type == ChatType.DIRECT.value: # TODO: replace with enum + if self.chat_type == ChatType.DIRECT.value: DirectChatMessage.objects.create(chat=self.chat, author=self.user, text=text) elif self.chat_type == ChatType.PROJECT.value: ProjectChatMessage.objects.create(chat=self.chat, author=self.user, text=text) @@ -110,6 +71,44 @@ def __process_new_message(self, content): }, ) + def __connect_to_direct_chat(self): + # room name looks like "direct_{other_user_id}" + room_name = self.scope["url_route"]["kwargs"]["room_name"] + other_user_id = int(room_name.split("_")[1]) + other_user = CustomUser.objects.filter(id=other_user_id).first() + + if not other_user or other_user_id == self.user.id: + # such user does not exist / user tries to chat with himself + self.close() + return + + user1_id = min(self.user.pk, other_user_id) + user2_id = max(self.user.pk, other_user_id) + + self.room_name = f"direct_{user1_id}_{user2_id}" + self.chat_type = "direct" + self.chat = DirectChat.objects.get_chat(self.user, other_user) + + def __connect_to_project_chat(self): + # room name looks like "project_{project_id}" + room_name = self.scope["url_route"]["kwargs"]["room_name"] + project_id = int(room_name.split("_")[1]) + filtered_projects = Project.objects.filter(id=project_id) + + if not filtered_projects.exists(): + # project does not exist + self.close() + return + + if not filtered_projects.first().collaborators.filter(user=self.user).exists(): + # user is not a collaborator + self.close() + return + + self.room_name = f"project_{project_id}" + self.chat_type = "project" + self.chat = ProjectChat.objects.get(project_id=project_id) + class NotificationConsumer(JsonWebsocketConsumer): def __init__(self, *args, **kwargs): From 8800c5fcf98404c1e0370eae682b8be22debd6ed Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Mon, 26 Dec 2022 00:33:15 +0300 Subject: [PATCH 32/87] fix of possible bug --- chats/consumers.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 32da6733..803a572c 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -24,15 +24,15 @@ def connect(self): room_name = self.scope["url_route"]["kwargs"]["room_name"] if room_name.startswith(ChatType.DIRECT.value): - self.__connect_to_direct_chat() + if not self.__connect_to_direct_chat(): + return elif room_name.startswith(ChatType.PROJECT.value): - self.__connect_to_project_chat() - + if not self.__connect_to_project_chat(): + return + self.accept() # Join room group async_to_sync(self.channel_layer.group_add)(self.room_name, self.channel_name) - self.accept() - def disconnect(self, close_code): # Leave room group async_to_sync(self.channel_layer.group_discard)(self.room_name, self.channel_name) @@ -71,7 +71,7 @@ def __process_new_message(self, content): }, ) - def __connect_to_direct_chat(self): + def __connect_to_direct_chat(self) -> bool: # room name looks like "direct_{other_user_id}" room_name = self.scope["url_route"]["kwargs"]["room_name"] other_user_id = int(room_name.split("_")[1]) @@ -80,7 +80,7 @@ def __connect_to_direct_chat(self): if not other_user or other_user_id == self.user.id: # such user does not exist / user tries to chat with himself self.close() - return + return False user1_id = min(self.user.pk, other_user_id) user2_id = max(self.user.pk, other_user_id) @@ -88,8 +88,9 @@ def __connect_to_direct_chat(self): self.room_name = f"direct_{user1_id}_{user2_id}" self.chat_type = "direct" self.chat = DirectChat.objects.get_chat(self.user, other_user) + return True - def __connect_to_project_chat(self): + def __connect_to_project_chat(self) -> bool: # room name looks like "project_{project_id}" room_name = self.scope["url_route"]["kwargs"]["room_name"] project_id = int(room_name.split("_")[1]) @@ -98,16 +99,17 @@ def __connect_to_project_chat(self): if not filtered_projects.exists(): # project does not exist self.close() - return + return False if not filtered_projects.first().collaborators.filter(user=self.user).exists(): # user is not a collaborator self.close() - return + return False self.room_name = f"project_{project_id}" self.chat_type = "project" self.chat = ProjectChat.objects.get(project_id=project_id) + return True class NotificationConsumer(JsonWebsocketConsumer): From ab849fc19ea4595a954dc7223b8bd7ffbeeb8d58 Mon Sep 17 00:00:00 2001 From: Yakser Date: Tue, 27 Dec 2022 12:26:27 +0400 Subject: [PATCH 33/87] Fixed import --- chats/routing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chats/routing.py b/chats/routing.py index 46cb7686..0f70c53b 100644 --- a/chats/routing.py +++ b/chats/routing.py @@ -1,6 +1,6 @@ from django.urls import re_path -from consumers import ChatConsumer +from chats.consumers import ChatConsumer websocket_urlpatterns = [ re_path(r"ws/chat/(?P\w+)/$", ChatConsumer.as_asgi()), From 51768fd9562630a5e6229f3c70305f3101c12935 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Wed, 11 Jan 2023 21:14:24 +0300 Subject: [PATCH 34/87] work on chats: reading/typing/new message events for consumers.py --- chats/consumers.py | 16 ++++++++++++---- chats/models.py | 3 +++ chats/utils.py | 2 +- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 803a572c..1b452aa5 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -44,13 +44,20 @@ def receive_json(self, content, **kwargs): if message_type == EventType.CHAT_MESSAGE.value: self.__process_new_message(content) elif message_type == EventType.TYPING.value: - # TODO - pass + self.__process_typing_event(content) elif message_type == EventType.READ.value: - # TODO - pass + self.__process_read_event(content) + + def __process_typing_event(self, content): + """Send typing event to room group.""" + pass + + def __process_read_event(self, content): + """Send message read event to room group.""" + pass def __process_new_message(self, content): + """Send new message to everyone""" text = clean_message_text(content["text"]) if not validate_message_text(text): return @@ -113,6 +120,7 @@ def __connect_to_project_chat(self) -> bool: class NotificationConsumer(JsonWebsocketConsumer): + # TODO: implement def __init__(self, *args, **kwargs): super().__init__(args, kwargs) self.user = None diff --git a/chats/models.py b/chats/models.py index 1402e19a..8e9c99cf 100644 --- a/chats/models.py +++ b/chats/models.py @@ -146,6 +146,9 @@ class BaseMessage(models.Model): author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="messages") text = models.TextField(max_length=8192) created_at = models.DateTimeField(auto_now_add=True) + reply_to = models.ForeignKey( + "self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies" + ) def __str__(self): return f"Message<{self.pk}>" diff --git a/chats/utils.py b/chats/utils.py index 408eddf7..87d38215 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -11,4 +11,4 @@ def validate_message_text(text: str) -> bool: Validates message text. """ - return len(text) > 0 and len(text) <= 8192 + return 0 < len(text) <= 8192 From 3886f7cec00a7f3afc2182b6850514e1e26d528f Mon Sep 17 00:00:00 2001 From: Yakser Date: Wed, 11 Jan 2023 22:49:25 +0400 Subject: [PATCH 35/87] Trying to add ChatList --- chats/models.py | 3 +++ chats/serializers.py | 32 +++++++++++++++++--------------- chats/urls.py | 8 ++++++-- chats/views.py | 24 +++++++++++++++++------- users/managers.py | 1 + 5 files changed, 44 insertions(+), 24 deletions(-) diff --git a/chats/models.py b/chats/models.py index 8e9c99cf..dc59fc90 100644 --- a/chats/models.py +++ b/chats/models.py @@ -16,6 +16,9 @@ class BaseChat(models.Model): created_at = models.DateTimeField(auto_now_add=True) + def get_last_message(self): + return self.messages.last() + def get_users(self): """ Returns all collaborators and leader of the project. diff --git a/chats/serializers.py b/chats/serializers.py index bb2d788c..976267e0 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -1,18 +1,20 @@ -# from rest_framework import serializers -# -# from chats.models import Chat, Message -# -# -# class ChatSerializer(serializers.ModelSerializer): -# class Meta: -# model = Chat -# fields = [ -# "id", -# "name", -# "users", -# ] -# -# +from rest_framework import serializers + +from chats.models import BaseChat + + +class ChatListSerializer(serializers.ModelSerializer): + last_message = serializers.SerializerMethodField() + + @classmethod + def get_last_message(cls, chat: BaseChat): + return chat.get_last_message() + + class Meta: + model = BaseChat + fields = ["id", "users", "last_message"] + + # class MessageInChatSerializer(serializers.ModelSerializer): # class Meta: # model = Message diff --git a/chats/urls.py b/chats/urls.py index 4f737912..764c509d 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -1,5 +1,9 @@ -# from django.urls import path +from django.urls import path + +from chats.views import ChatList app_name = "chats" -urlpatterns = [] +urlpatterns = [ + path("my/", ChatList.as_view()), +] diff --git a/chats/views.py b/chats/views.py index 36d0742e..1406e6f3 100644 --- a/chats/views.py +++ b/chats/views.py @@ -1,15 +1,25 @@ # from rest_framework import generics, permissions, mixins, status # from rest_framework.response import Response +from rest_framework import status -# from chats.models import Chat, Message -# from chats.serializers import ChatSerializer, MessageSerializer, ChatDetailSerializer -# from core.permissions import IsMessageOwner, IsUserInChat +# from rest_framework.generics import ListAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +# from chats.models import DirectChat, ProjectChat +# from chats.serializers import ChatListSerializer -# class ChatList(generics.ListCreateAPIView): -# queryset = Chat.objects.all() -# serializer_class = ChatSerializer -# permission_classes = [permissions.IsAuthenticatedOrReadOnly] + +class ChatList(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + # queryset1 = DirectChat.objects.filter(users=request.user) + # queryset2 = ProjectChat.objects.filter(project__collaborator__user=request.user) + # serializer = ChatListSerializer(queryset, many=True) + # serializer.data + return Response([], status=status.HTTP_200_OK) # class ChatDetail(generics.RetrieveUpdateDestroyAPIView): diff --git a/users/managers.py b/users/managers.py index 4f0d964a..30b4ed8f 100644 --- a/users/managers.py +++ b/users/managers.py @@ -15,6 +15,7 @@ def get_active(self): def create_superuser(self, email, password=None, **extra_fields): extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("birthday", "1900-01-01") extra_fields.setdefault("is_superuser", True) extra_fields.setdefault("is_active", True) From f77cd23474fb6ad06d7650860bf7213604d324f6 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Wed, 11 Jan 2023 21:50:24 +0300 Subject: [PATCH 36/87] work on chats: reading/typing/new message events for consumers.py --- chats/consumers.py | 28 +++++++++++++++++---- chats/managers.py | 6 +++++ chats/models.py | 48 +++++++++++++++++++++++++++--------- chats/websockets_settings.py | 1 + 4 files changed, 66 insertions(+), 17 deletions(-) create mode 100644 chats/managers.py diff --git a/chats/consumers.py b/chats/consumers.py index 1b452aa5..62401fe0 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -1,7 +1,15 @@ +from typing import Optional + from asgiref.sync import async_to_sync from channels.generic.websocket import JsonWebsocketConsumer -from chats.models import DirectChat, DirectChatMessage, ProjectChat, ProjectChatMessage +from chats.models import ( + DirectChat, + DirectChatMessage, + ProjectChat, + ProjectChatMessage, + BaseChat, +) from chats.utils import clean_message_text, validate_message_text from chats.websockets_settings import ChatType, EventType from projects.models import Project @@ -11,12 +19,13 @@ class ChatConsumer(JsonWebsocketConsumer): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) - self.room_name = None - self.user = None + self.room_name: str = None + self.user: Optional[CustomUser] = None self.chat_type = None - self.chat = None + self.chat: Optional[BaseChat] = None def connect(self): + # Join room group # authentication self.user = self.scope["user"] if not self.user.is_authenticated: @@ -30,7 +39,16 @@ def connect(self): if not self.__connect_to_project_chat(): return self.accept() - # Join room group + messages = self.chat.get_last_messages(30) # TODO: set 30 as a constant somewhere + has_more_messages = self.chat.messages.all().count() > 30 + # ugly way to paginate messages + self.send_json( + { + "type": EventType.LAST_30_MESSAGES.value, # TODO: use enum here + "messages": messages, + "has_more": has_more_messages > 5, + } + ) async_to_sync(self.channel_layer.group_add)(self.room_name, self.channel_name) def disconnect(self, close_code): diff --git a/chats/managers.py b/chats/managers.py new file mode 100644 index 00000000..654659d6 --- /dev/null +++ b/chats/managers.py @@ -0,0 +1,6 @@ +# from django.db import models + + +# class BaseChatManager(models.Manager): +# def get_last_messages(self): +# return self.filter(chat=chat).order_by("-created_at")[:10] diff --git a/chats/models.py b/chats/models.py index 8e9c99cf..24330035 100644 --- a/chats/models.py +++ b/chats/models.py @@ -1,3 +1,6 @@ +from abc import abstractmethod +from typing import List + from django.contrib.auth import get_user_model from django.db import models @@ -16,17 +19,8 @@ class BaseChat(models.Model): created_at = models.DateTimeField(auto_now_add=True) - def get_users(self): - """ - Returns all collaborators and leader of the project. - - Returns: - List[CustomUser]: list of users, who are collaborators or leader of the project - """ - raise NotImplementedError - def get_users_str(self): - """returns string of users, who are in chat + """Returns string of users separated by a comma, who are in chat Returns: str: string of users, who are in chat @@ -34,6 +28,17 @@ def get_users_str(self): users = self.get_users() return ", ".join([user.get_full_name() for user in users]) + @abstractmethod + def get_users(self): + """ + Returns all collaborators and leader of the project. + + Returns: + List[CustomUser]: list of users, who are collaborators or leader of the project + """ + pass + + @abstractmethod def get_avatar(self, user): """ Returns avatar of the chat for given user @@ -44,7 +49,20 @@ def get_avatar(self, user): Returns: str: link to avatar of the chat for given user """ - raise NotImplementedError + pass + + @abstractmethod + def get_last_messages(self, message_count): + """ + Returns last messages of the chat + + Args: + message_count: number of messages to return + + Returns: + List[Message]: list of messages + """ + pass def __str__(self): return f"BaseChat<{self.pk}>" @@ -72,6 +90,9 @@ def get_users(self): def get_avatar(self, user): return self.project.image_address + def get_last_messages(self, message_count) -> List["BaseMessage"]: + return self.messages.order_by("-created_at")[:message_count] + def __str__(self): return f"ProjectChat<{self.project.id}> - {self.project.name}" @@ -113,12 +134,15 @@ def get_chat(cls, user1, user2) -> "DirectChat": Returns: DirectChat: chat between two users """ - # maybe use .get_or_create() here? + # TODO: use .get_or_create() here try: return cls.objects.get(pk="_".join(sorted([str(user1.pk), str(user2.pk)]))) except cls.DoesNotExist: return cls.objects.create(users=[user1, user2]) + def get_last_messages(self, message_count): + return self.messages.order_by("-created_at")[:message_count] + def save( self, force_insert=False, force_update=False, using=None, update_fields=None ): diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py index 805a45ef..93785c2b 100644 --- a/chats/websockets_settings.py +++ b/chats/websockets_settings.py @@ -10,3 +10,4 @@ class EventType(Enum): CHAT_MESSAGE = "chat_message" TYPING = "typing" READ = "read" + LAST_30_MESSAGES = "last_30_messages" From 7baf193aca290dc7bd4c82f454268d406be9487a Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Wed, 11 Jan 2023 23:05:36 +0300 Subject: [PATCH 37/87] setting up caches --- procollab/settings.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/procollab/settings.py b/procollab/settings.py index 2c5f9b93..0e49d9a6 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -169,7 +169,20 @@ "NAME": "db.sqlite3", } } + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } else: + CACHES = { + "default": { + # TODO: setup proper redis caching + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": "redis://127.0.0.1:6379", + } + } + REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = [ "rest_framework.renderers.JSONRenderer", ] From 10e3fa1d52d6cfdbcaa594d7112cc2fadd3f800b Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Wed, 11 Jan 2023 23:45:49 +0300 Subject: [PATCH 38/87] more work towards chats --- chats/consumers.py | 37 +++++++++++++++++++---- chats/models.py | 4 ++- chats/serializers.py | 72 ++++++++++++++++++++++++-------------------- chats/views.py | 31 ++++++++++--------- core/constants.py | 1 + 5 files changed, 91 insertions(+), 54 deletions(-) create mode 100644 core/constants.py diff --git a/chats/consumers.py b/chats/consumers.py index 62401fe0..599712a5 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -2,6 +2,7 @@ from asgiref.sync import async_to_sync from channels.generic.websocket import JsonWebsocketConsumer +from django.core.cache import cache from chats.models import ( DirectChat, @@ -12,6 +13,7 @@ ) from chats.utils import clean_message_text, validate_message_text from chats.websockets_settings import ChatType, EventType +from core.constants import ONE_DAY_IN_SECONDS from projects.models import Project from users.models import CustomUser @@ -38,21 +40,41 @@ def connect(self): elif room_name.startswith(ChatType.PROJECT.value): if not self.__connect_to_project_chat(): return - self.accept() - messages = self.chat.get_last_messages(30) # TODO: set 30 as a constant somewhere - has_more_messages = self.chat.messages.all().count() > 30 # ugly way to paginate messages + # messages = self.chat.get_last_messages(30) # TODO: set 30 as a constant somewhere + # has_more_messages = self.chat.messages.all().count() > 30 + self.accept() + + cache_key = self.__get_cache_key() + if self.chat_type == ChatType.DIRECT.value: + current_online_list = cache.get(cache_key, []) + current_online_list.append(self.user.pk) + cache.set(cache_key, current_online_list, ONE_DAY_IN_SECONDS) + elif self.chat_type == ChatType.PROJECT.value: + current_online_list = cache.get(cache_key, []) + current_online_list.append(self.user.pk) + cache.set(cache_key, current_online_list, ONE_DAY_IN_SECONDS) + else: + raise ValueError("Chat type is not supported! Something went terribly wrong!") + self.send_json( { - "type": EventType.LAST_30_MESSAGES.value, # TODO: use enum here - "messages": messages, - "has_more": has_more_messages > 5, + "type": EventType.LAST_30_MESSAGES.value, + "online_users": current_online_list, } ) + async_to_sync(self.channel_layer.group_add)(self.room_name, self.channel_name) def disconnect(self, close_code): # Leave room group + + # remove user from online cache + cache_key = self.__get_cache_key() + current_online_list = cache.get(cache_key, []) + current_online_list.remove(self.user.pk) + cache.set(cache_key, current_online_list, ONE_DAY_IN_SECONDS) + async_to_sync(self.channel_layer.group_discard)(self.room_name, self.channel_name) # Receive message from WebSocket @@ -96,6 +118,9 @@ def __process_new_message(self, content): }, ) + def __get_cache_key(self): + return f"online_list_{self.chat_type}_{self.chat.pk}" + def __connect_to_direct_chat(self) -> bool: # room name looks like "direct_{other_user_id}" room_name = self.scope["url_route"]["kwargs"]["room_name"] diff --git a/chats/models.py b/chats/models.py index cfd02d39..4a0f982c 100644 --- a/chats/models.py +++ b/chats/models.py @@ -83,7 +83,9 @@ class ProjectChat(BaseChat): created_at: A DateTimeField indicating date of creation. """ - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="chats") + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_name="project_chats" + ) def get_users(self): collaborators = self.project.collaborators.all() diff --git a/chats/serializers.py b/chats/serializers.py index 976267e0..257adeb3 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -1,31 +1,56 @@ from rest_framework import serializers -from chats.models import BaseChat +from chats.models import DirectChat, ProjectChat, DirectChatMessage, ProjectChatMessage -class ChatListSerializer(serializers.ModelSerializer): +class DirectChatListSerializer(serializers.ModelSerializer): last_message = serializers.SerializerMethodField() @classmethod - def get_last_message(cls, chat: BaseChat): + def get_last_message(cls, chat: DirectChat): return chat.get_last_message() class Meta: - model = BaseChat + model = DirectChat fields = ["id", "users", "last_message"] -# class MessageInChatSerializer(serializers.ModelSerializer): -# class Meta: -# model = Message -# fields = [ -# "id", -# "text", -# "author", -# "created_at", -# ] -# -# +class ProjectChatListSerializer(serializers.ModelSerializer): + last_message = serializers.SerializerMethodField() + + @classmethod + def get_last_message(cls, chat: ProjectChat): + return chat.get_last_message() + + class Meta: + model = ProjectChat + fields = ["id", "project", "last_message"] + + +class DirectChatMessageListSerializer(serializers.ModelSerializer): + class Meta: + model = DirectChatMessage + fields = [ + "id", + "author", + "text", + "reply_to", + "created_at", + ] + + +class ProjectChatMessageListSerializer(serializers.ModelSerializer): + class Meta: + model = ProjectChatMessage + fields = [ + "id", + "author", + "text", + "reply_to", + "created_at", + ] + + # class ChatDetailSerializer(serializers.ModelSerializer): # messages = MessageInChatSerializer(many=True, read_only=True) # @@ -38,20 +63,3 @@ class Meta: # "messages", # ] # -# -# class MessageSerializer(serializers.ModelSerializer): -# class Meta: -# model = Message -# fields = [ -# "id", -# "chat", -# "author", -# "text", -# "created_at", -# ] -# -# def create(self, validated_data): -# chat_id = self.context["request"].data["chat"] -# chat = Chat.objects.get(id=chat_id) -# message = Message.objects.create(chat=chat, **validated_data) -# return message diff --git a/chats/views.py b/chats/views.py index 1406e6f3..edc90cb0 100644 --- a/chats/views.py +++ b/chats/views.py @@ -1,25 +1,26 @@ -# from rest_framework import generics, permissions, mixins, status -# from rest_framework.response import Response -from rest_framework import status +from rest_framework.generics import ListAPIView -# from rest_framework.generics import ListAPIView from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView -# from chats.models import DirectChat, ProjectChat -# from chats.serializers import ChatListSerializer +from chats.serializers import DirectChatListSerializer, ProjectChatListSerializer -class ChatList(APIView): +class DirectChatList(ListAPIView): + serializer_class = DirectChatListSerializer permission_classes = [IsAuthenticated] - def get(self, request, *args, **kwargs): - # queryset1 = DirectChat.objects.filter(users=request.user) - # queryset2 = ProjectChat.objects.filter(project__collaborator__user=request.user) - # serializer = ChatListSerializer(queryset, many=True) - # serializer.data - return Response([], status=status.HTTP_200_OK) + def get_queryset(self): + user = self.request.user + return user.direct_chats.all() + + +class ProjectChatList(ListAPIView): + serializer_class = ProjectChatListSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + user = self.request.user + return user.project_chats.all() # class ChatDetail(generics.RetrieveUpdateDestroyAPIView): diff --git a/core/constants.py b/core/constants.py new file mode 100644 index 00000000..bbc4eafa --- /dev/null +++ b/core/constants.py @@ -0,0 +1 @@ +ONE_DAY_IN_SECONDS = 60 * 60 * 24 From 63a86b511887f5a381cf56cdd450f9a13e83f86e Mon Sep 17 00:00:00 2001 From: VeryBigSad Date: Tue, 17 Jan 2023 10:47:01 +0300 Subject: [PATCH 39/87] more serializers --- chats/views.py | 58 ++++++++++---------------------------------------- 1 file changed, 11 insertions(+), 47 deletions(-) diff --git a/chats/views.py b/chats/views.py index edc90cb0..d8c69356 100644 --- a/chats/views.py +++ b/chats/views.py @@ -2,7 +2,7 @@ from rest_framework.permissions import IsAuthenticated -from chats.serializers import DirectChatListSerializer, ProjectChatListSerializer +from chats.serializers import DirectChatListSerializer, DirectChatMessageListSerializer, ProjectChatListSerializer, ProjectChatMessageListSerializer class DirectChatList(ListAPIView): @@ -23,56 +23,20 @@ def get_queryset(self): return user.project_chats.all() -# class ChatDetail(generics.RetrieveUpdateDestroyAPIView): -# serializer_class = ChatDetailSerializer -# permission_classes = [permissions.IsAuthenticatedOrReadOnly] - -# def get_queryset(self): -# return ( -# Chat.objects.all() -# .prefetch_related("messages") -# .prefetch_related("users") -# .all() -# ) - -# def get(self, request, *args, **kwargs): -# """ -# Get chat by id -# You can set first_obj and count to get messages from chat -# Args: -# request: request -# *args: args -# **kwargs: kwargs - -# Returns: -# Response with chat -# """ - -# instance = self.get_object() -# serializer = self.get_serializer(instance) -# return Response(serializer.data) - - -# class MessageList( -# mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView -# ): -# permission_classes = [IsUserInChat] -# serializer_class = MessageSerializer - -# def get(self, request, *args, **kwargs): -# return self.list(self, request, *args, **kwargs) +class DirectChatMessageList(ListAPIView): + serializer_class = DirectChatMessageListSerializer + permission_classes = [IsAuthenticated] -# def get_queryset(self): -# return Message.objects.filter(chat_id=self.kwargs["pk"]) + def get_queryset(self): + return self.request.user.direct_chats.get(id=self.kwargs["chat_id"]).messages.all() -# def post(self, request, *args, **kwargs): -# try: -# request.data["chat"] = str(self.kwargs["pk"]) -# except AttributeError: -# pass -# return self.create(request, *args, **kwargs) +class ProjectChatMessageList(ListAPIView): + serializer_class = ProjectChatMessageListSerializer + permission_classes = [IsAuthenticated] + def get_queryset(self): + return self.request.user.project_chats.get(id=self.kwargs["chat_id"]).messages.all() # class MessageDetail(generics.RetrieveUpdateDestroyAPIView): # queryset = Message.objects.all() From f17db4ed7a1e936c76646bc61baaf9c13952f906 Mon Sep 17 00:00:00 2001 From: VeryBigSad Date: Tue, 17 Jan 2023 10:52:31 +0300 Subject: [PATCH 40/87] black formatter --- chats/views.py | 16 +++++++++++++--- projects/managers.py | 26 +++++++++++++++----------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/chats/views.py b/chats/views.py index d8c69356..c720373b 100644 --- a/chats/views.py +++ b/chats/views.py @@ -2,7 +2,12 @@ from rest_framework.permissions import IsAuthenticated -from chats.serializers import DirectChatListSerializer, DirectChatMessageListSerializer, ProjectChatListSerializer, ProjectChatMessageListSerializer +from chats.serializers import ( + DirectChatListSerializer, + DirectChatMessageListSerializer, + ProjectChatListSerializer, + ProjectChatMessageListSerializer, +) class DirectChatList(ListAPIView): @@ -28,7 +33,9 @@ class DirectChatMessageList(ListAPIView): permission_classes = [IsAuthenticated] def get_queryset(self): - return self.request.user.direct_chats.get(id=self.kwargs["chat_id"]).messages.all() + return self.request.user.direct_chats.get( + id=self.kwargs["chat_id"] + ).messages.all() class ProjectChatMessageList(ListAPIView): @@ -36,7 +43,10 @@ class ProjectChatMessageList(ListAPIView): permission_classes = [IsAuthenticated] def get_queryset(self): - return self.request.user.project_chats.get(id=self.kwargs["chat_id"]).messages.all() + return self.request.user.project_chats.get( + id=self.kwargs["chat_id"] + ).messages.all() + # class MessageDetail(generics.RetrieveUpdateDestroyAPIView): # queryset = Message.objects.all() diff --git a/projects/managers.py b/projects/managers.py index 0cddea5a..9dd2f81c 100644 --- a/projects/managers.py +++ b/projects/managers.py @@ -42,17 +42,21 @@ def get_projects_for_list_view(self): ) def get_user_projects_for_list_view(self): - return self.get_queryset().prefetch_related( - Prefetch( - "industry", - queryset=Industry.objects.only("name").all(), - ), - Prefetch( - "leader", - queryset=CustomUser.objects.only("id").all(), - ), - Prefetch("collaborator_set"), - ).distinct() + return ( + self.get_queryset() + .prefetch_related( + Prefetch( + "industry", + queryset=Industry.objects.only("name").all(), + ), + Prefetch( + "leader", + queryset=CustomUser.objects.only("id").all(), + ), + Prefetch("collaborator_set"), + ) + .distinct() + ) def get_projects_for_detail_view(self): return ( From f0d6caa8dd0ac53cd9e2040aad529c9163ebfb71 Mon Sep 17 00:00:00 2001 From: VeryBigSad Date: Tue, 17 Jan 2023 11:00:37 +0300 Subject: [PATCH 41/87] added views to chats --- chats/serializers.py | 21 ++++++--------------- chats/urls.py | 7 +++++-- chats/views.py | 30 ------------------------------ 3 files changed, 11 insertions(+), 47 deletions(-) diff --git a/chats/serializers.py b/chats/serializers.py index 257adeb3..f80896fc 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -16,7 +16,12 @@ class Meta: class ProjectChatListSerializer(serializers.ModelSerializer): - last_message = serializers.SerializerMethodField() + last_message = serializers.SerializerMethodField(read_only=True) + users = serializers.SerializerMethodField(read_only=True) + + @classmethod + def get_users(cls, chat: ProjectChat): + return chat.get_users() @classmethod def get_last_message(cls, chat: ProjectChat): @@ -49,17 +54,3 @@ class Meta: "reply_to", "created_at", ] - - -# class ChatDetailSerializer(serializers.ModelSerializer): -# messages = MessageInChatSerializer(many=True, read_only=True) -# -# class Meta: -# model = Chat -# fields = [ -# "id", -# "name", -# "users", -# "messages", -# ] -# diff --git a/chats/urls.py b/chats/urls.py index 764c509d..f7542806 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -1,9 +1,12 @@ from django.urls import path -from chats.views import ChatList +from chats.views import DirectChatList, DirectChatMessageList, ProjectChatList, ProjectChatMessageList app_name = "chats" urlpatterns = [ - path("my/", ChatList.as_view()), + path("direct-chats/", DirectChatList.as_view(), name="chat-list"), + path("project-chats/", ProjectChatList.as_view(), name="chat-list"), + path("direct-chat-messages/", DirectChatMessageList.as_view(), name="chat-list"), + path("project-chat-messages/", ProjectChatMessageList.as_view(), name="chat-list"), ] diff --git a/chats/views.py b/chats/views.py index c720373b..5a669942 100644 --- a/chats/views.py +++ b/chats/views.py @@ -46,33 +46,3 @@ def get_queryset(self): return self.request.user.project_chats.get( id=self.kwargs["chat_id"] ).messages.all() - - -# class MessageDetail(generics.RetrieveUpdateDestroyAPIView): -# queryset = Message.objects.all() -# serializer_class = MessageSerializer -# permission_classes = [IsMessageOwner] - -# def patch(self, request, *args, **kwargs): -# message = Message.objects.get(pk=self.kwargs["message_id"]) - -# if request.user != message.author: -# return Response(status=status.HTTP_403_FORBIDDEN) - -# return self.partial_update(request, *args, **kwargs) - -# def delete(self, request, *args, **kwargs): -# message = Message.objects.get(pk=self.kwargs["message_id"]) - -# if request.user != message.author: -# return Response(status=status.HTTP_403_FORBIDDEN) - -# return self.destroy(request, *args, **kwargs) - -# def put(self, request, *args, **kwargs): -# message = Message.objects.get(pk=self.kwargs["message_id"]) - -# if request.user != message.author: -# return Response(status=status.HTTP_403_FORBIDDEN) - -# return self.update(request, *args, **kwargs) From 8a508f30d8b71c1dd4a98374d05aa85df021ebbc Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Wed, 18 Jan 2023 15:44:00 +0300 Subject: [PATCH 42/87] something --- chats/serializers.py | 5 +++++ chats/views.py | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/chats/serializers.py b/chats/serializers.py index f80896fc..7709a1fc 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -5,6 +5,11 @@ class DirectChatListSerializer(serializers.ModelSerializer): last_message = serializers.SerializerMethodField() + users = serializers.SerializerMethodField(read_only=True) + + @classmethod + def get_users(cls, chat: ProjectChat): + return chat.get_users() @classmethod def get_last_message(cls, chat: DirectChat): diff --git a/chats/views.py b/chats/views.py index 5a669942..3b6aae69 100644 --- a/chats/views.py +++ b/chats/views.py @@ -1,6 +1,8 @@ -from rest_framework.generics import ListAPIView +from rest_framework import status +from rest_framework.generics import ListAPIView, ListCreateAPIView from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from chats.serializers import ( DirectChatListSerializer, @@ -28,7 +30,7 @@ def get_queryset(self): return user.project_chats.all() -class DirectChatMessageList(ListAPIView): +class DirectChatMessageList(ListCreateAPIView): serializer_class = DirectChatMessageListSerializer permission_classes = [IsAuthenticated] @@ -38,7 +40,7 @@ def get_queryset(self): ).messages.all() -class ProjectChatMessageList(ListAPIView): +class ProjectChatMessageList(ListCreateAPIView): serializer_class = ProjectChatMessageListSerializer permission_classes = [IsAuthenticated] @@ -46,3 +48,11 @@ def get_queryset(self): return self.request.user.project_chats.get( id=self.kwargs["chat_id"] ).messages.all() + + def post(self, request, *args, **kwargs): + # TODO: try to create a message in a chat. If chat doesn't exist, create it and then create a message. + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) From 73aeec8ad95fe07f21bc80a3f84c9ec7a15e9be2 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sat, 21 Jan 2023 15:22:39 +0400 Subject: [PATCH 43/87] Added REDIS_HOST to .env variables in debug mode added in memory channel layer --- .env.example | 4 +++- procollab/settings.py | 31 +++++++++++++++++-------------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 866a7434..a70c75de 100644 --- a/.env.example +++ b/.env.example @@ -17,4 +17,6 @@ DATABASE_PORT= SELECTEL_ACCOUNT_ID= SELECTEL_CONTAINER_NAME= SELECTEL_CONTAINER_PASSWORD= -SELECTEL_CONTAINER_USERNAME= \ No newline at end of file +SELECTEL_CONTAINER_USERNAME= + +REDIS_HOST= \ No newline at end of file diff --git a/procollab/settings.py b/procollab/settings.py index 0e49d9a6..8b80d40b 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -88,17 +88,6 @@ "channels", ] -# django channels -ASGI_APPLICATION = "procollab.asgi.application" -CHANNEL_LAYERS = { - "default": { - "BACKEND": "channels_redis.core.RedisChannelLayer", - "CONFIG": { - "hosts": [("127.0.0.1", 6379)], - }, - }, -} - MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", @@ -161,7 +150,9 @@ "rest_framework.renderers.AdminRenderer", ], } -# Database + +ASGI_APPLICATION = "procollab.asgi.application" + if DEBUG: DATABASES = { "default": { @@ -169,17 +160,29 @@ "NAME": "db.sqlite3", } } + CACHES = { "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", } } + + CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} else: + CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [("127.0.0.1", 6379)], + }, + }, + } + + REDIS_HOST = config("REDIS_HOST", cast=str, default="127.0.0.1") CACHES = { "default": { - # TODO: setup proper redis caching "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": "redis://127.0.0.1:6379", + "LOCATION": f"redis://{REDIS_HOST}:6379", } } From f36172b1e1fc7f1bcf2335a4bb9c9a54991fa8a1 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sat, 21 Jan 2023 17:42:49 +0400 Subject: [PATCH 44/87] Added ProjectChatDetail view and serializer, added get_project_chats function in debug mode added in memory channel layer --- chats/serializers.py | 25 +++++++++++++++++++++++-- chats/urls.py | 27 ++++++++++++++++++++++----- chats/views.py | 15 +++++++++++++-- projects/models.py | 10 ++++++++-- users/managers.py | 3 --- users/models.py | 4 ++++ 6 files changed, 70 insertions(+), 14 deletions(-) diff --git a/chats/serializers.py b/chats/serializers.py index 7709a1fc..e25f1885 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -17,11 +17,27 @@ def get_last_message(cls, chat: DirectChat): class Meta: model = DirectChat - fields = ["id", "users", "last_message"] + fields = [ + "id", + "users", + "last_message", + ] class ProjectChatListSerializer(serializers.ModelSerializer): last_message = serializers.SerializerMethodField(read_only=True) + + @classmethod + def get_last_message(cls, chat: ProjectChat): + return chat.get_last_message() + + class Meta: + model = ProjectChat + fields = ["id", "project", "last_message"] + + +class ProjectChatDetailSerializer(serializers.ModelSerializer): + last_message = serializers.SerializerMethodField(read_only=True) users = serializers.SerializerMethodField(read_only=True) @classmethod @@ -34,7 +50,12 @@ def get_last_message(cls, chat: ProjectChat): class Meta: model = ProjectChat - fields = ["id", "project", "last_message"] + fields = [ + "id", + "project", + "last_message", + "users", + ] class DirectChatMessageListSerializer(serializers.ModelSerializer): diff --git a/chats/urls.py b/chats/urls.py index f7542806..36839ddd 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -1,12 +1,29 @@ from django.urls import path -from chats.views import DirectChatList, DirectChatMessageList, ProjectChatList, ProjectChatMessageList +from chats.views import ( + DirectChatList, + DirectChatMessageList, + ProjectChatList, + ProjectChatMessageList, + ProjectChatDetail, +) app_name = "chats" urlpatterns = [ - path("direct-chats/", DirectChatList.as_view(), name="chat-list"), - path("project-chats/", ProjectChatList.as_view(), name="chat-list"), - path("direct-chat-messages/", DirectChatMessageList.as_view(), name="chat-list"), - path("project-chat-messages/", ProjectChatMessageList.as_view(), name="chat-list"), + path("direct_chats/", DirectChatList.as_view(), name="direct-chat-list"), + path("project_chats/", ProjectChatList.as_view(), name="project-chat-list"), + path( + "project_chats//", ProjectChatDetail.as_view(), name="project-chat-detail" + ), + path( + "direct_chat_messages/", + DirectChatMessageList.as_view(), + name="direct-chat-messages", + ), + path( + "project_chat_messages/", + ProjectChatMessageList.as_view(), + name="project-chat-messages", + ), ] diff --git a/chats/views.py b/chats/views.py index 3b6aae69..80c9ece2 100644 --- a/chats/views.py +++ b/chats/views.py @@ -1,14 +1,16 @@ from rest_framework import status -from rest_framework.generics import ListAPIView, ListCreateAPIView +from rest_framework.generics import ListAPIView, ListCreateAPIView, RetrieveAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from chats.models import ProjectChat from chats.serializers import ( DirectChatListSerializer, DirectChatMessageListSerializer, ProjectChatListSerializer, ProjectChatMessageListSerializer, + ProjectChatDetailSerializer, ) @@ -27,7 +29,16 @@ class ProjectChatList(ListAPIView): def get_queryset(self): user = self.request.user - return user.project_chats.all() + return user.get_project_chats() + + +class ProjectChatDetail(RetrieveAPIView): + queryset = ProjectChat.objects.all() + serializer_class = ProjectChatDetailSerializer + permission_classes = [IsAuthenticated] + + def get_object(self): + return self.get_queryset().get(pk=self.kwargs["pk"]) class DirectChatMessageList(ListCreateAPIView): diff --git a/projects/models.py b/projects/models.py index 1e29e4df..b14a26ff 100644 --- a/projects/models.py +++ b/projects/models.py @@ -120,7 +120,8 @@ class Meta: class Collaborator(models.Model): - """Project collaborator model + """ + Project collaborator model Attributes: user: A ForeignKey referencing the user who is collaborating in the project @@ -130,7 +131,12 @@ class Collaborator(models.Model): datetime_updated: A DateTimeField indicating date of update. """ - user = models.ForeignKey(CustomUser, models.CASCADE, verbose_name="Пользователь") + user = models.ForeignKey( + CustomUser, + models.CASCADE, + verbose_name="Пользователь", + related_name="collaborations", + ) project = models.ForeignKey(Project, models.CASCADE, verbose_name="Проект") role = models.CharField("Роль", max_length=1024, blank=True, null=True) diff --git a/users/managers.py b/users/managers.py index 30b4ed8f..396faf09 100644 --- a/users/managers.py +++ b/users/managers.py @@ -34,9 +34,6 @@ def get_users_for_detail_view(self): self.get_queryset() .select_related("member", "investor", "expert", "mentor") .prefetch_related( - # "member__preferred_industries", - # "expert__preferred_industries", - # "investor__preferred_industries", "achievements", ) .all() diff --git a/users/models.py b/users/models.py index 4d5bd5b2..6d4751d6 100644 --- a/users/models.py +++ b/users/models.py @@ -86,6 +86,10 @@ class CustomUser(AbstractUser): objects = CustomUserManager() + def get_project_chats(self): + collaborations = self.collaborations.all() + return [collaboration.project.project_chats for collaboration in collaborations] + def get_key_skills(self) -> list[str]: return [skill.strip() for skill in self.key_skills.split(",") if skill.strip()] From b2db07225bae8461d809fca8eb1508a7c1934d4a Mon Sep 17 00:00:00 2001 From: Yakser Date: Sat, 21 Jan 2023 17:44:36 +0400 Subject: [PATCH 45/87] Added is_read field for messages in debug mode added in memory channel layer --- ...message_directchat_projectchat_and_more.py | 176 ++++++++++++++++++ chats/models.py | 2 + .../0010_alter_collaborator_user.py | 26 +++ 3 files changed, 204 insertions(+) create mode 100644 chats/migrations/0003_basemessage_directchat_projectchat_and_more.py create mode 100644 projects/migrations/0010_alter_collaborator_user.py diff --git a/chats/migrations/0003_basemessage_directchat_projectchat_and_more.py b/chats/migrations/0003_basemessage_directchat_projectchat_and_more.py new file mode 100644 index 00000000..bfe3ee8b --- /dev/null +++ b/chats/migrations/0003_basemessage_directchat_projectchat_and_more.py @@ -0,0 +1,176 @@ +# Generated by Django 4.1.3 on 2023-01-21 13:43 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("projects", "0010_alter_collaborator_user"), + ("chats", "0002_alter_message_options_chat_created_at"), + ] + + operations = [ + migrations.CreateModel( + name="BaseMessage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField(max_length=8192)), + ("is_read", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "reply_to", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="replies", + to="chats.basemessage", + ), + ), + ], + options={ + "verbose_name": "Сообщение", + "verbose_name_plural": "Сообщения", + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="DirectChat", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "id", + models.CharField(max_length=64, primary_key=True, serialize=False), + ), + ( + "users", + models.ManyToManyField( + related_name="direct_chats", to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "verbose_name": "Личный чат", + "verbose_name_plural": "Личные чаты", + }, + ), + migrations.CreateModel( + name="ProjectChat", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_chats", + to="projects.project", + ), + ), + ], + options={ + "verbose_name": "Чат проекта", + "verbose_name_plural": "Чаты проектов", + }, + ), + migrations.RemoveField( + model_name="message", + name="author", + ), + migrations.RemoveField( + model_name="message", + name="chat", + ), + migrations.CreateModel( + name="DirectChatMessage", + fields=[ + ( + "basemessage_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="chats.basemessage", + ), + ), + ( + "chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="chats.directchat", + ), + ), + ], + options={ + "verbose_name": "Сообщение в личном чате", + "verbose_name_plural": "Сообщения в личных чатах", + }, + bases=("chats.basemessage",), + ), + migrations.CreateModel( + name="ProjectChatMessage", + fields=[ + ( + "basemessage_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="chats.basemessage", + ), + ), + ( + "chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="chats.projectchat", + ), + ), + ], + options={ + "verbose_name": "Сообщение в чате проекта", + "verbose_name_plural": "Сообщения в чатах проектов", + }, + bases=("chats.basemessage",), + ), + migrations.DeleteModel( + name="Chat", + ), + migrations.DeleteModel( + name="Message", + ), + ] diff --git a/chats/models.py b/chats/models.py index 4a0f982c..9ce1c355 100644 --- a/chats/models.py +++ b/chats/models.py @@ -169,11 +169,13 @@ class BaseMessage(models.Model): Attributes: author: A ForeignKey referring to the User model. text: A TextField containing message text. + is_read: A BooleanField indicating whether message is read. created_at: A DateTimeField indicating date of creation. """ author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="messages") text = models.TextField(max_length=8192) + is_read = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) reply_to = models.ForeignKey( "self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies" diff --git a/projects/migrations/0010_alter_collaborator_user.py b/projects/migrations/0010_alter_collaborator_user.py new file mode 100644 index 00000000..27442317 --- /dev/null +++ b/projects/migrations/0010_alter_collaborator_user.py @@ -0,0 +1,26 @@ +# Generated by Django 4.1.3 on 2023-01-21 13:43 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("projects", "0009_remove_project_short_description"), + ] + + operations = [ + migrations.AlterField( + model_name="collaborator", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="collaborations", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), + ] From 8d28af18ad88a0f83b4590b0715ca313eef1ac7c Mon Sep 17 00:00:00 2001 From: yeezy-na-izi Date: Sat, 21 Jan 2023 17:30:37 +0300 Subject: [PATCH 46/87] CU-861m7qfcb Add views_count for project --- .../migrations/0010_project_views_count.py | 18 ++++++++++++++++++ projects/models.py | 6 ++++++ projects/serializers.py | 8 +++++++- projects/views.py | 6 ++++++ 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 projects/migrations/0010_project_views_count.py diff --git a/projects/migrations/0010_project_views_count.py b/projects/migrations/0010_project_views_count.py new file mode 100644 index 00000000..5366db7f --- /dev/null +++ b/projects/migrations/0010_project_views_count.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.3 on 2023-01-21 14:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0009_remove_project_short_description"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="views_count", + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/projects/models.py b/projects/models.py index 1e29e4df..c6762325 100644 --- a/projects/models.py +++ b/projects/models.py @@ -65,6 +65,12 @@ class Project(models.Model): objects = ProjectManager() + views_count = models.PositiveIntegerField(default=0) + + def increment_views_count(self): + self.views_count += 1 + self.save() + def get_short_description(self) -> Optional[str]: return self.description[:90] if self.description else None diff --git a/projects/serializers.py b/projects/serializers.py index cdd710a8..16a20fef 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -99,8 +99,14 @@ class Meta: "vacancies", "datetime_created", "datetime_updated", + "views_count", + ] + read_only_fields = [ + "leader", + "views_count", + "datetime_created", + "datetime_updated", ] - read_only_fields = ["leader"] class ProjectListSerializer(serializers.ModelSerializer): diff --git a/projects/views.py b/projects/views.py index 1ca9470f..c684f0d3 100644 --- a/projects/views.py +++ b/projects/views.py @@ -79,6 +79,12 @@ class ProjectDetail(generics.RetrieveUpdateDestroyAPIView): permission_classes = [HasInvolvementInProjectOrReadOnly] serializer_class = ProjectDetailSerializer + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + instance.increment_views_count() + serializer = self.get_serializer(instance) + return Response(serializer.data) + def put(self, request, pk, **kwargs): # bootleg version of updating achievements via project if request.data.get("achievements") is not None: From acab684a80820c6a74bc1af38ce20e6087c3f39b Mon Sep 17 00:00:00 2001 From: yeezy-na-izi Date: Sat, 21 Jan 2023 17:59:07 +0300 Subject: [PATCH 47/87] CU-861m7qfcb Add like route and like model for project and user --- projects/managers.py | 29 ++++++++------ projects/urls.py | 2 + projects/views.py | 24 ++++++++++++ users/managers.py | 18 +++++++++ users/migrations/0027_likesonproject.py | 52 +++++++++++++++++++++++++ users/models.py | 45 ++++++++++++++++++++- users/urls.py | 2 + users/views.py | 15 ++++++- 8 files changed, 173 insertions(+), 14 deletions(-) create mode 100644 users/migrations/0027_likesonproject.py diff --git a/projects/managers.py b/projects/managers.py index 0cddea5a..3bc8642d 100644 --- a/projects/managers.py +++ b/projects/managers.py @@ -42,17 +42,21 @@ def get_projects_for_list_view(self): ) def get_user_projects_for_list_view(self): - return self.get_queryset().prefetch_related( - Prefetch( - "industry", - queryset=Industry.objects.only("name").all(), - ), - Prefetch( - "leader", - queryset=CustomUser.objects.only("id").all(), - ), - Prefetch("collaborator_set"), - ).distinct() + return ( + self.get_queryset() + .prefetch_related( + Prefetch( + "industry", + queryset=Industry.objects.only("name").all(), + ), + Prefetch( + "leader", + queryset=CustomUser.objects.only("id").all(), + ), + Prefetch("collaborator_set"), + ) + .distinct() + ) def get_projects_for_detail_view(self): return ( @@ -66,6 +70,9 @@ def check_if_owns_any_projects(self, user) -> bool: # I don't think this should work but the function has no usages, so I'll let it be return user.leader_projects.exists() + def get_projects_from_list_of_ids(self, ids): + return self.get_queryset().filter(id__in=ids) + class AchievementManager(Manager): def get_achievements_for_list_view(self): diff --git a/projects/urls.py b/projects/urls.py index 124bc9a2..8e155866 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -9,12 +9,14 @@ ProjectCollaborators, ProjectCountView, ProjectVacancyResponses, + SetLikeOnProject, ) app_name = "projects" urlpatterns = [ path("", ProjectList.as_view()), + path("/like/", SetLikeOnProject.as_view()), path("/collaborators/", ProjectCollaborators.as_view()), path("/", ProjectDetail.as_view()), path("count/", ProjectCountView.as_view()), diff --git a/projects/views.py b/projects/views.py index c684f0d3..2815ee8e 100644 --- a/projects/views.py +++ b/projects/views.py @@ -20,6 +20,7 @@ AchievementDetailSerializer, ProjectCollaboratorSerializer, ) +from users.models import LikesOnProject from vacancy.models import VacancyResponse from vacancy.serializers import VacancyResponseListSerializer @@ -106,6 +107,29 @@ def put(self, request, pk, **kwargs): return super(ProjectDetail, self).put(request, pk) +class SetLikeOnProject(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + """ + Поставить лайк на проект + + --- + + Args: + request: + pk - id проекта + + Returns: + ProjectListSerializer + + """ + project = Project.objects.get(pk=pk) + LikesOnProject.objects.change_like(request.user, project) + + return Response(ProjectListSerializer(project).data) + + class ProjectCountView(generics.GenericAPIView): queryset = Project.objects.get_projects_for_count_view() serializer_class = ProjectListSerializer diff --git a/users/managers.py b/users/managers.py index bbd6ea6c..d56a9be1 100644 --- a/users/managers.py +++ b/users/managers.py @@ -60,3 +60,21 @@ def get_achievements_for_detail_view(self): .select_related("user") .only("id", "title", "status", "user") ) + + +class LikesOnProjectManager(Manager): + def get_likes_for_list_view(self): + return ( + self.get_queryset() + .select_related("user") + .only("id", "user__id", "project__id") + ) + + def get_or_create(self, user, project): + return super().get_or_create(user=user, project=project) + + def change_like(self, user, project): + like, created = self.get_or_create(user=user, project=project) + if not created: + like.swap_like() + return like diff --git a/users/migrations/0027_likesonproject.py b/users/migrations/0027_likesonproject.py new file mode 100644 index 00000000..e465fc3e --- /dev/null +++ b/users/migrations/0027_likesonproject.py @@ -0,0 +1,52 @@ +# Generated by Django 4.1.3 on 2023-01-21 14:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0010_project_views_count"), + ("users", "0026_remove_member_preferred_industries_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="LikesOnProject", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("like", models.BooleanField(default=True)), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="likes", + to="projects.project", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="likes_on_projects", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Лайк на проект", + "verbose_name_plural": "Лайки на проекты", + "unique_together": {("user", "project")}, + }, + ), + ] diff --git a/users/models.py b/users/models.py index f51e4cb2..9b6cddef 100644 --- a/users/models.py +++ b/users/models.py @@ -12,7 +12,11 @@ VERBOSE_ROLE_TYPES, VERBOSE_USER_TYPES, ) -from users.managers import CustomUserManager, UserAchievementManager +from users.managers import ( + CustomUserManager, + UserAchievementManager, + LikesOnProjectManager, +) from users.validators import user_birthday_validator @@ -151,6 +155,45 @@ class Meta: abstract = True +class LikesOnProject(models.Model): + """ + LikesOnProject model + + This model is used to store the user's likes on projects. + + Attributes: + user: ForeignKey instance of user. + project: ForeignKey instance of project. + """ + + like = models.BooleanField(default=True) + + user = models.ForeignKey( + CustomUser, + on_delete=models.CASCADE, + related_name="likes_on_projects", + ) + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="likes", + ) + + objects = LikesOnProjectManager() + + def swap_like(self): + self.like = not self.like + self.save() + + def __str__(self): + return f"LikesOnProject<{self.id}>" + + class Meta: + verbose_name = "Лайк на проект" + verbose_name_plural = "Лайки на проекты" + unique_together = ("user", "project") + + class Member(models.Model): """ Member model diff --git a/users/urls.py b/users/urls.py index 51a1fffe..d61f21a4 100644 --- a/users/urls.py +++ b/users/urls.py @@ -14,6 +14,7 @@ UserTypesView, VerifyEmail, LogoutView, + LikesProjectList, ) app_name = "users" @@ -24,6 +25,7 @@ ), # this url actually returns mentors, experts and investors path("users/", UserList.as_view()), path("users/projects/", UserProjectsList.as_view()), + path("users/likes/", LikesProjectList.as_view()), path("users/roles/", UserAdditionalRolesView.as_view()), path("users/types/", UserTypesView.as_view()), path("users//", UserDetail.as_view()), diff --git a/users/views.py b/users/views.py index 8f98299a..c7f62384 100644 --- a/users/views.py +++ b/users/views.py @@ -27,7 +27,7 @@ from core.utils import Email from projects.serializers import ProjectListSerializer from users.helpers import VERBOSE_ROLE_TYPES, VERBOSE_USER_TYPES -from users.models import UserAchievement +from users.models import UserAchievement, LikesOnProject from users.permissions import IsAchievementOwnerOrReadOnly from users.serializers import ( AchievementDetailSerializer, @@ -38,7 +38,6 @@ UserListSerializer, VerifyEmailSerializer, ) - from .filters import UserFilter User = get_user_model() @@ -82,6 +81,18 @@ def post(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) +class LikesProjectList(ListAPIView): + serializer_class = ProjectListSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + projects_ids_list = LikesOnProject.objects.filter( + user=self.request.user, like=True + ).values_list("project", flat=True) + + return Project.objects.get_projects_from_list_of_ids(projects_ids_list) + + class UserAdditionalRolesView(APIView): permission_classes = [AllowAny] From dde54e2489dc88c532c91e5ac7cba2883afe70b1 Mon Sep 17 00:00:00 2001 From: yeezy-na-izi Date: Sat, 21 Jan 2023 18:07:01 +0300 Subject: [PATCH 48/87] CU-861m7qfcb Add likes counter --- projects/serializers.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/projects/serializers.py b/projects/serializers.py index 16a20fef..5faf14f7 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -4,6 +4,7 @@ from industries.models import Industry from projects.models import Project, Achievement, Collaborator from projects.validators import validate_project +from users.models import LikesOnProject from vacancy.serializers import ProjectVacancyListSerializer @@ -65,6 +66,7 @@ class ProjectDetailSerializer(serializers.ModelSerializer): vacancies = ProjectVacancyListSerializer(many=True, read_only=True) short_description = serializers.SerializerMethodField() industry_id = serializers.IntegerField(required=False) + likes_count = serializers.SerializerMethodField(method_name="count_likes") def validate(self, data): super().validate(data) @@ -74,6 +76,9 @@ def validate(self, data): def get_short_description(cls, project): return project.get_short_description() + def count_likes(self, project): + return LikesOnProject.objects.filter(project=project, like=True).count() + def update(self, instance, validated_data): instance = super().update(instance, validated_data) instance.save() @@ -100,6 +105,7 @@ class Meta: "datetime_created", "datetime_updated", "views_count", + "likes_count", ] read_only_fields = [ "leader", @@ -111,6 +117,7 @@ class Meta: class ProjectListSerializer(serializers.ModelSerializer): collaborators = serializers.SerializerMethodField(method_name="get_collaborators") + likes_count = serializers.SerializerMethodField(method_name="count_likes") collaborator_count = serializers.SerializerMethodField( method_name="get_collaborator_count" ) @@ -126,6 +133,9 @@ def get_short_description(cls, project): def get_collaborator_count(cls, obj): return len(obj.collaborator_set.all()) + def count_likes(self, obj): + return LikesOnProject.objects.filter(project=obj, like=True).count() + def get_collaborators(self, obj): max_collaborator_count = 4 return CollaboratorSerializer( @@ -148,6 +158,7 @@ class Meta: "collaborators", "vacancies", "datetime_created", + "likes_count", ] read_only_fields = [ From afd876682a97c8ca2e07844f4012e1dfb7bd9beb Mon Sep 17 00:00:00 2001 From: yeezy-na-izi Date: Sat, 21 Jan 2023 21:31:47 +0300 Subject: [PATCH 49/87] CU-861m7qfcb Fix naming --- projects/serializers.py | 4 ++-- projects/views.py | 8 ++++---- users/managers.py | 4 ++-- users/models.py | 6 +++--- users/urls.py | 4 ++-- users/views.py | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/projects/serializers.py b/projects/serializers.py index 5faf14f7..5d9f7e26 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -77,7 +77,7 @@ def get_short_description(cls, project): return project.get_short_description() def count_likes(self, project): - return LikesOnProject.objects.filter(project=project, like=True).count() + return LikesOnProject.objects.filter(project=project, is_liked=True).count() def update(self, instance, validated_data): instance = super().update(instance, validated_data) @@ -134,7 +134,7 @@ def get_collaborator_count(cls, obj): return len(obj.collaborator_set.all()) def count_likes(self, obj): - return LikesOnProject.objects.filter(project=obj, like=True).count() + return LikesOnProject.objects.filter(project=obj, is_liked=True).count() def get_collaborators(self, obj): max_collaborator_count = 4 diff --git a/projects/views.py b/projects/views.py index 2815ee8e..48160e3f 100644 --- a/projects/views.py +++ b/projects/views.py @@ -112,20 +112,20 @@ class SetLikeOnProject(APIView): def post(self, request, pk): """ - Поставить лайк на проект + Set like on project --- Args: request: - pk - id проекта + pk - project id Returns: - ProjectListSerializer + Response """ project = Project.objects.get(pk=pk) - LikesOnProject.objects.change_like(request.user, project) + LikesOnProject.objects.toggle_like(request.user, project) return Response(ProjectListSerializer(project).data) diff --git a/users/managers.py b/users/managers.py index d56a9be1..bc94c02a 100644 --- a/users/managers.py +++ b/users/managers.py @@ -73,8 +73,8 @@ def get_likes_for_list_view(self): def get_or_create(self, user, project): return super().get_or_create(user=user, project=project) - def change_like(self, user, project): + def toggle_like(self, user, project): like, created = self.get_or_create(user=user, project=project) if not created: - like.swap_like() + like.toggle_like() return like diff --git a/users/models.py b/users/models.py index 9b6cddef..43c754c8 100644 --- a/users/models.py +++ b/users/models.py @@ -166,7 +166,7 @@ class LikesOnProject(models.Model): project: ForeignKey instance of project. """ - like = models.BooleanField(default=True) + is_liked = models.BooleanField(default=True) user = models.ForeignKey( CustomUser, @@ -181,8 +181,8 @@ class LikesOnProject(models.Model): objects = LikesOnProjectManager() - def swap_like(self): - self.like = not self.like + def toggle_like(self): + self.is_liked = not self.is_liked self.save() def __str__(self): diff --git a/users/urls.py b/users/urls.py index d61f21a4..ac24bdf5 100644 --- a/users/urls.py +++ b/users/urls.py @@ -14,7 +14,7 @@ UserTypesView, VerifyEmail, LogoutView, - LikesProjectList, + LikedProjectList, ) app_name = "users" @@ -25,7 +25,7 @@ ), # this url actually returns mentors, experts and investors path("users/", UserList.as_view()), path("users/projects/", UserProjectsList.as_view()), - path("users/likes/", LikesProjectList.as_view()), + path("users/liked/", LikedProjectList.as_view()), path("users/roles/", UserAdditionalRolesView.as_view()), path("users/types/", UserTypesView.as_view()), path("users//", UserDetail.as_view()), diff --git a/users/views.py b/users/views.py index c7f62384..78674cd1 100644 --- a/users/views.py +++ b/users/views.py @@ -81,13 +81,13 @@ def post(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) -class LikesProjectList(ListAPIView): +class LikedProjectList(ListAPIView): serializer_class = ProjectListSerializer permission_classes = [IsAuthenticated] def get_queryset(self): projects_ids_list = LikesOnProject.objects.filter( - user=self.request.user, like=True + user=self.request.user, is_liked=True ).values_list("project", flat=True) return Project.objects.get_projects_from_list_of_ids(projects_ids_list) From 31ba4febae833936da60bfc3c50411c25808e8f6 Mon Sep 17 00:00:00 2001 From: yeezy-na-izi Date: Sat, 21 Jan 2023 21:32:36 +0300 Subject: [PATCH 50/87] CU-861m7qfcb Fix naming --- ...0028_rename_like_likesonproject_is_liked.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 users/migrations/0028_rename_like_likesonproject_is_liked.py diff --git a/users/migrations/0028_rename_like_likesonproject_is_liked.py b/users/migrations/0028_rename_like_likesonproject_is_liked.py new file mode 100644 index 00000000..1923857e --- /dev/null +++ b/users/migrations/0028_rename_like_likesonproject_is_liked.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.3 on 2023-01-21 18:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0027_likesonproject"), + ] + + operations = [ + migrations.RenameField( + model_name="likesonproject", + old_name="like", + new_name="is_liked", + ), + ] From 2183b57d6edad3f01af769200864c02e174546da Mon Sep 17 00:00:00 2001 From: Yakser Date: Mon, 23 Jan 2023 21:17:56 +0400 Subject: [PATCH 51/87] Fixed ProjectChat creation and get_project_chats function --- chats/consumers.py | 2 +- chats/models.py | 2 +- projects/models.py | 12 ------------ projects/signals.py | 19 +++++++++++++++++++ users/models.py | 5 ++++- 5 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 projects/signals.py diff --git a/chats/consumers.py b/chats/consumers.py index 599712a5..035189aa 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -151,7 +151,7 @@ def __connect_to_project_chat(self) -> bool: self.close() return False - if not filtered_projects.first().collaborators.filter(user=self.user).exists(): + if not filtered_projects.first().collaborator_set.filter(user=self.user).exists(): # user is not a collaborator self.close() return False diff --git a/chats/models.py b/chats/models.py index 9ce1c355..11ee8be2 100644 --- a/chats/models.py +++ b/chats/models.py @@ -88,7 +88,7 @@ class ProjectChat(BaseChat): ) def get_users(self): - collaborators = self.project.collaborators.all() + collaborators = self.project.collaborator_set.all() users = [collaborator.user for collaborator in collaborators] return users + [self.project.leader] diff --git a/projects/models.py b/projects/models.py index b14a26ff..da902bf7 100644 --- a/projects/models.py +++ b/projects/models.py @@ -3,9 +3,6 @@ from django.contrib.auth import get_user_model from django.db import models from django.db.models import UniqueConstraint -from django.db.models.signals import post_save -from django.dispatch import receiver - from industries.models import Industry from projects.helpers import VERBOSE_STEPS from projects.managers import AchievementManager, ProjectManager @@ -159,12 +156,3 @@ class Meta: name="unique_collaborator", ) ] - - -@receiver(post_save, sender=Project) -def create_project(sender, instance, created, **kwargs): - """Creates collaborator for the project leader on project creation""" - if created: - Collaborator.objects.create( - user=instance.leader, project=instance, role="Основатель" - ) diff --git a/projects/signals.py b/projects/signals.py new file mode 100644 index 00000000..ed676e7e --- /dev/null +++ b/projects/signals.py @@ -0,0 +1,19 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from chats.models import ProjectChat +from projects.models import Collaborator, Project + + +@receiver(post_save, sender=Project) +def create_project(sender, instance, created, **kwargs): + """ + Creates collaborator for the project leader and ProjectChat on project creation + """ + + if created: + Collaborator.objects.create( + user=instance.leader, project=instance, role="Основатель" + ) + + ProjectChat.objects.create(project=instance) diff --git a/users/models.py b/users/models.py index 6d4751d6..88046afb 100644 --- a/users/models.py +++ b/users/models.py @@ -88,7 +88,10 @@ class CustomUser(AbstractUser): def get_project_chats(self): collaborations = self.collaborations.all() - return [collaboration.project.project_chats for collaboration in collaborations] + projects = [] + for collaboration in collaborations: + projects.extend(list(collaboration.project.project_chats.all())) + return projects def get_key_skills(self) -> list[str]: return [skill.strip() for skill in self.key_skills.split(",") if skill.strip()] From 02177d1ef07688ebac3f22d9d0e2a0f8257863a9 Mon Sep 17 00:00:00 2001 From: Yakser Date: Mon, 23 Jan 2023 21:19:27 +0400 Subject: [PATCH 52/87] Fixed ProjectChat creation --- projects/apps.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/projects/apps.py b/projects/apps.py index 7a65e869..8e62a884 100644 --- a/projects/apps.py +++ b/projects/apps.py @@ -5,3 +5,6 @@ class ProjectsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "projects" verbose_name = "Проекты" + + def ready(self): + import projects.signals # noqa: F401 From 94fc0c662b6ca8f407983d896e622a1564428f1c Mon Sep 17 00:00:00 2001 From: Yakser Date: Mon, 23 Jan 2023 22:22:52 +0400 Subject: [PATCH 53/87] Finished chats detailed views --- chats/models.py | 4 +++- chats/serializers.py | 19 ++++++++++++++++++- chats/urls.py | 14 +++++++------- chats/views.py | 45 +++++++++++++++++++++++++++++++++++++++++--- users/models.py | 4 ++-- 5 files changed, 72 insertions(+), 14 deletions(-) diff --git a/chats/models.py b/chats/models.py index 11ee8be2..f0bb23ba 100644 --- a/chats/models.py +++ b/chats/models.py @@ -143,7 +143,9 @@ def get_chat(cls, user1, user2) -> "DirectChat": try: return cls.objects.get(pk="_".join(sorted([str(user1.pk), str(user2.pk)]))) except cls.DoesNotExist: - return cls.objects.create(users=[user1, user2]) + chat = cls.objects.create() + chat.users.set([user1, user2]) + return chat def get_last_messages(self, message_count): return self.messages.order_by("-created_at")[:message_count] diff --git a/chats/serializers.py b/chats/serializers.py index e25f1885..125e64a3 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from chats.models import DirectChat, ProjectChat, DirectChatMessage, ProjectChatMessage +from users.serializers import UserListSerializer class DirectChatListSerializer(serializers.ModelSerializer): @@ -24,6 +25,22 @@ class Meta: ] +class DirectChatDetailSerializer(serializers.ModelSerializer): + users = serializers.SerializerMethodField(read_only=True) + + @classmethod + def get_users(cls, chat: ProjectChat): + return UserListSerializer(chat.get_users(), many=True).data + + class Meta: + model = DirectChat + fields = [ + "id", + "users", + "messages", + ] + + class ProjectChatListSerializer(serializers.ModelSerializer): last_message = serializers.SerializerMethodField(read_only=True) @@ -42,7 +59,7 @@ class ProjectChatDetailSerializer(serializers.ModelSerializer): @classmethod def get_users(cls, chat: ProjectChat): - return chat.get_users() + return UserListSerializer(chat.get_users(), many=True).data @classmethod def get_last_message(cls, chat: ProjectChat): diff --git a/chats/urls.py b/chats/urls.py index 36839ddd..af5ff0b0 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -6,23 +6,23 @@ ProjectChatList, ProjectChatMessageList, ProjectChatDetail, + DirectChatDetail, ) app_name = "chats" urlpatterns = [ - path("direct_chats/", DirectChatList.as_view(), name="direct-chat-list"), - path("project_chats/", ProjectChatList.as_view(), name="project-chat-list"), + path("directs/", DirectChatList.as_view(), name="direct-chat-list"), + 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"), path( - "project_chats//", ProjectChatDetail.as_view(), name="project-chat-detail" - ), - path( - "direct_chat_messages/", + "directs/messages/", DirectChatMessageList.as_view(), name="direct-chat-messages", ), path( - "project_chat_messages/", + "projects/messages/", ProjectChatMessageList.as_view(), name="project-chat-messages", ), diff --git a/chats/views.py b/chats/views.py index 80c9ece2..06a0b2a8 100644 --- a/chats/views.py +++ b/chats/views.py @@ -1,18 +1,22 @@ +from django.contrib.auth import get_user_model from rest_framework import status from rest_framework.generics import ListAPIView, ListCreateAPIView, RetrieveAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from chats.models import ProjectChat +from chats.models import ProjectChat, DirectChat from chats.serializers import ( DirectChatListSerializer, DirectChatMessageListSerializer, ProjectChatListSerializer, ProjectChatMessageListSerializer, ProjectChatDetailSerializer, + DirectChatDetailSerializer, ) +User = get_user_model() + class DirectChatList(ListAPIView): serializer_class = DirectChatListSerializer @@ -37,8 +41,43 @@ class ProjectChatDetail(RetrieveAPIView): serializer_class = ProjectChatDetailSerializer permission_classes = [IsAuthenticated] - def get_object(self): - return self.get_queryset().get(pk=self.kwargs["pk"]) + +class DirectChatDetail(RetrieveAPIView): + queryset = DirectChat.objects.all() + serializer_class = DirectChatDetailSerializer + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs) -> Response: + try: + assert "_" in self.kwargs["pk"], "pk must contain underscore" + + user1_id, user2_id = map(int, self.kwargs["pk"].split("_")) + + user1 = User.objects.get(pk=user1_id) + user2 = User.objects.get(pk=user2_id) + + return Response( + status=status.HTTP_200_OK, + data=DirectChatDetailSerializer(DirectChat.get_chat(user1, user2)).data, + ) + + except ValueError: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={"detail": "pk must contain two integers separated by underscore"}, + ) + except AssertionError as e: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(e)}) + except User.DoesNotExist: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={"detail": "One or both users do not exist"}, + ) + except DirectChat.DoesNotExist: + return Response( + status=status.HTTP_404_NOT_FOUND, + data={"detail": "Direct chat does not exist"}, + ) class DirectChatMessageList(ListCreateAPIView): diff --git a/users/models.py b/users/models.py index 88046afb..bc6b443e 100644 --- a/users/models.py +++ b/users/models.py @@ -86,7 +86,7 @@ class CustomUser(AbstractUser): objects = CustomUserManager() - def get_project_chats(self): + def get_project_chats(self) -> list: collaborations = self.collaborations.all() projects = [] for collaboration in collaborations: @@ -99,7 +99,7 @@ def get_key_skills(self) -> list[str]: def get_full_name(self) -> str: return f"{self.first_name} {self.last_name}" - def __str__(self): + def __str__(self) -> str: return f"User<{self.id}> - {self.first_name} {self.last_name}" class Meta: From 61a1315f195c80d018a2d3df4dd6207557004687 Mon Sep 17 00:00:00 2001 From: Yakser Date: Mon, 23 Jan 2023 22:31:52 +0400 Subject: [PATCH 54/87] Added chat name and image url for project chat detail --- chats/serializers.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/chats/serializers.py b/chats/serializers.py index 125e64a3..04c63e74 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -54,23 +54,29 @@ class Meta: class ProjectChatDetailSerializer(serializers.ModelSerializer): - last_message = serializers.SerializerMethodField(read_only=True) users = serializers.SerializerMethodField(read_only=True) + name = serializers.SerializerMethodField(read_only=True) + image_address = serializers.SerializerMethodField(read_only=True) @classmethod - def get_users(cls, chat: ProjectChat): - return UserListSerializer(chat.get_users(), many=True).data + def get_image_address(cls, chat: ProjectChat): + return chat.project.image_address @classmethod - def get_last_message(cls, chat: ProjectChat): - return chat.get_last_message() + def get_name(cls, chat: ProjectChat): + return chat.project.name + + @classmethod + def get_users(cls, chat: ProjectChat): + return UserListSerializer(chat.get_users(), many=True).data class Meta: model = ProjectChat fields = [ "id", - "project", - "last_message", + "name", + "image_address", + "messages", "users", ] From 757f38a5920ed8719636d30c2472e87330378a9e Mon Sep 17 00:00:00 2001 From: Yakser Date: Thu, 26 Jan 2023 01:11:33 +0400 Subject: [PATCH 55/87] made one supermassive cringe consumer --- chats/consumers.py | 127 ++++++++++++++++++++--------------- chats/routing.py | 4 +- chats/utils.py | 12 ++++ chats/views.py | 13 +++- chats/websockets_settings.py | 32 +++++++-- 5 files changed, 127 insertions(+), 61 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 035189aa..857145d7 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -5,14 +5,14 @@ from django.core.cache import cache from chats.models import ( + BaseChat, DirectChat, DirectChatMessage, ProjectChat, ProjectChatMessage, - BaseChat, ) -from chats.utils import clean_message_text, validate_message_text -from chats.websockets_settings import ChatType, EventType +from chats.utils import clean_message_text, get_user_id_from_token, validate_message_text +from chats.websockets_settings import ChatType, Content, Event, EventType, Headers from core.constants import ONE_DAY_IN_SECONDS from projects.models import Project from users.models import CustomUser @@ -21,72 +21,89 @@ class ChatConsumer(JsonWebsocketConsumer): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) - self.room_name: str = None + self.room_name: str = "" self.user: Optional[CustomUser] = None self.chat_type = None self.chat: Optional[BaseChat] = None def connect(self): # Join room group - # authentication - self.user = self.scope["user"] - if not self.user.is_authenticated: - return + self.accept() - room_name = self.scope["url_route"]["kwargs"]["room_name"] - if room_name.startswith(ChatType.DIRECT.value): + def disconnect(self, close_code): + pass + # Leave room group + # + # # remove user from online cache + # if self.chat: + # cache_key = self.__get_cache_key() + # current_online_list = cache.get(cache_key, []) + # current_online_list.remove(self.user.pk) + # cache.set(cache_key, current_online_list, ONE_DAY_IN_SECONDS) + # + # async_to_sync(self.channel_layer.group_discard)(self.room_name, self.channel_name) + + # Receive message from WebSocket + def receive_json(self, content, **kwargs): + event = Event( + type=content["type"], + headers=Headers(content["headers"]), + content=Content(**content["content"]), + ) + token = event.headers.Authorization + user_id = get_user_id_from_token(token) + + if event.type == EventType.NEW_MESSAGE: + room_name = f"{EventType.NEW_MESSAGE}_{event.content.chat_id}" + elif event.type == EventType.TYPING: + room_name = f"{EventType.TYPING}_{event.content.chat_id}" + elif event.type == EventType.READ_MESSAGE: + room_name = f"{EventType.READ_MESSAGE}_{event.content.chat_id}" + elif event.type == EventType.DELETE_MESSAGE: + room_name = f"{EventType.DELETE_MESSAGE}_{event.content.chat_id}" + elif event.type == EventType.SET_ONLINE: + room_name = f"{EventType.SET_ONLINE}_{user_id}" + elif event.type == EventType.SET_OFFLINE: + room_name = f"{EventType.SET_OFFLINE}_{user_id}" + else: + return self.disconnect(400) + + if room_name.startswith(ChatType.DIRECT): if not self.__connect_to_direct_chat(): - return - elif room_name.startswith(ChatType.PROJECT.value): + return self.disconnect(400) + elif room_name.startswith(ChatType.PROJECT): if not self.__connect_to_project_chat(): - return - # ugly way to paginate messages - # messages = self.chat.get_last_messages(30) # TODO: set 30 as a constant somewhere - # has_more_messages = self.chat.messages.all().count() > 30 - self.accept() + return self.disconnect(400) + + if event.type == EventType.NEW_MESSAGEЫ: + self.__process_new_message(content) + elif event.type == EventType.TYPING: + self.__process_typing_event(content) + elif event.type == EventType.READ_MESSAGE: + self.__process_read_event(content) + + def __set_user_online(self): + room_name = f"{EventType.SET_ONLINE}_{self.user.pk}" + async_to_sync(self.channel_layer.group_add)(room_name, self.channel_name) cache_key = self.__get_cache_key() - if self.chat_type == ChatType.DIRECT.value: + if self.chat_type == ChatType.DIRECT: current_online_list = cache.get(cache_key, []) current_online_list.append(self.user.pk) cache.set(cache_key, current_online_list, ONE_DAY_IN_SECONDS) - elif self.chat_type == ChatType.PROJECT.value: + elif self.chat_type == ChatType.PROJECT: current_online_list = cache.get(cache_key, []) current_online_list.append(self.user.pk) cache.set(cache_key, current_online_list, ONE_DAY_IN_SECONDS) else: raise ValueError("Chat type is not supported! Something went terribly wrong!") - self.send_json( - { - "type": EventType.LAST_30_MESSAGES.value, - "online_users": current_online_list, - } - ) - - async_to_sync(self.channel_layer.group_add)(self.room_name, self.channel_name) + def __process_connection_event(self): + """ - def disconnect(self, close_code): - # Leave room group - - # remove user from online cache - cache_key = self.__get_cache_key() - current_online_list = cache.get(cache_key, []) - current_online_list.remove(self.user.pk) - cache.set(cache_key, current_online_list, ONE_DAY_IN_SECONDS) + Send connection event to everyone - async_to_sync(self.channel_layer.group_discard)(self.room_name, self.channel_name) - - # Receive message from WebSocket - def receive_json(self, content, **kwargs): - message_type = content["type"] - - if message_type == EventType.CHAT_MESSAGE.value: - self.__process_new_message(content) - elif message_type == EventType.TYPING.value: - self.__process_typing_event(content) - elif message_type == EventType.READ.value: - self.__process_read_event(content) + """ def __process_typing_event(self, content): """Send typing event to room group.""" @@ -102,9 +119,9 @@ def __process_new_message(self, content): if not validate_message_text(text): return - if self.chat_type == ChatType.DIRECT.value: + if self.chat_type == ChatType.DIRECT: DirectChatMessage.objects.create(chat=self.chat, author=self.user, text=text) - elif self.chat_type == ChatType.PROJECT.value: + elif self.chat_type == ChatType.PROJECT: ProjectChatMessage.objects.create(chat=self.chat, author=self.user, text=text) else: return @@ -123,9 +140,13 @@ def __get_cache_key(self): def __connect_to_direct_chat(self) -> bool: # room name looks like "direct_{other_user_id}" - room_name = self.scope["url_route"]["kwargs"]["room_name"] - other_user_id = int(room_name.split("_")[1]) - other_user = CustomUser.objects.filter(id=other_user_id).first() + # room_name = self.scope["url_route"]["kwargs"]["room_name"] + try: + other_user_id = int(self.room_name.split("_")[1]) + other_user = CustomUser.objects.filter(id=other_user_id).first() + except (IndexError, ValueError): + # todo meaningful error message + return False if not other_user or other_user_id == self.user.id: # such user does not exist / user tries to chat with himself @@ -144,7 +165,7 @@ def __connect_to_project_chat(self) -> bool: # room name looks like "project_{project_id}" room_name = self.scope["url_route"]["kwargs"]["room_name"] project_id = int(room_name.split("_")[1]) - filtered_projects = Project.objects.filter(id=project_id) + filtered_projects = Project.objects.filter(pk=project_id) if not filtered_projects.exists(): # project does not exist diff --git a/chats/routing.py b/chats/routing.py index 0f70c53b..591d1770 100644 --- a/chats/routing.py +++ b/chats/routing.py @@ -1,7 +1,7 @@ -from django.urls import re_path +from django.urls import path from chats.consumers import ChatConsumer websocket_urlpatterns = [ - re_path(r"ws/chat/(?P\w+)/$", ChatConsumer.as_asgi()), + path("ws/chat/", ChatConsumer.as_asgi()), ] diff --git a/chats/utils.py b/chats/utils.py index 87d38215..b1b11ecb 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -1,3 +1,7 @@ +import jwt +from django.conf import settings + + def clean_message_text(text: str) -> str: """ Cleans message text. @@ -12,3 +16,11 @@ def validate_message_text(text: str) -> bool: """ return 0 < len(text) <= 8192 + + +def get_user_id_from_token(token: str) -> int: + """ + Returns user id from token. + """ + + return jwt.decode(jwt=token, key=settings.SECRET_KEY, algorithms=["HS256"])["user_id"] diff --git a/chats/views.py b/chats/views.py index 06a0b2a8..b3650acb 100644 --- a/chats/views.py +++ b/chats/views.py @@ -56,9 +56,20 @@ def get(self, request, *args, **kwargs) -> Response: user1 = User.objects.get(pk=user1_id) user2 = User.objects.get(pk=user2_id) + data = DirectChatDetailSerializer(DirectChat.get_chat(user1, user2)).data + + if user1 == request.user: + # may be is better to use serializer or return dict + # {"first_name": user2.first_name, "last_name": user2.last_name} + data["name"] = f"{user2.first_name} {user2.last_name}" + data["image_address"] = user2.avatar + else: + data["name"] = f"{user1.first_name} {user1.last_name}" + data["image_address"] = user1.avatar + return Response( status=status.HTTP_200_OK, - data=DirectChatDetailSerializer(DirectChat.get_chat(user1, user2)).data, + data=data, ) except ValueError: diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py index 93785c2b..6677f793 100644 --- a/chats/websockets_settings.py +++ b/chats/websockets_settings.py @@ -1,13 +1,35 @@ +from dataclasses import dataclass from enum import Enum +from typing import Optional -class ChatType(Enum): +class ChatType(str, Enum): DIRECT = "direct" PROJECT = "project" -class EventType(Enum): - CHAT_MESSAGE = "chat_message" +class EventType(str, Enum): + NEW_MESSAGE = "new_message" TYPING = "typing" - READ = "read" - LAST_30_MESSAGES = "last_30_messages" + READ_MESSAGE = "read" + DELETE_MESSAGE = "delete_message" + SET_ONLINE = "set_online" + SET_OFFLINE = "set_offline" + + +@dataclass(slots=True, frozen=True) +class Content: + chat_id: str + message: str + + +@dataclass(slots=True, frozen=True) +class Headers: + Authorization: str + + +@dataclass(slots=True, frozen=True) +class Event: + type: EventType + headers: Headers + content: Optional[Content] From 9a69aa229b974423ed031691f095780d2968171f Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Fri, 27 Jan 2023 22:10:36 +0300 Subject: [PATCH 56/87] made conusmers async --- chats/consumers.py | 71 +++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 857145d7..f6223807 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -1,7 +1,6 @@ from typing import Optional -from asgiref.sync import async_to_sync -from channels.generic.websocket import JsonWebsocketConsumer +from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.core.cache import cache from chats.models import ( @@ -18,7 +17,7 @@ from users.models import CustomUser -class ChatConsumer(JsonWebsocketConsumer): +class ChatConsumer(AsyncJsonWebsocketConsumer): def __init__(self, *args, **kwargs): super().__init__(args, kwargs) self.room_name: str = "" @@ -26,11 +25,11 @@ def __init__(self, *args, **kwargs): self.chat_type = None self.chat: Optional[BaseChat] = None - def connect(self): + async def connect(self): # Join room group - self.accept() + await self.accept() - def disconnect(self, close_code): + async def disconnect(self, close_code): pass # Leave room group # @@ -44,7 +43,7 @@ def disconnect(self, close_code): # async_to_sync(self.channel_layer.group_discard)(self.room_name, self.channel_name) # Receive message from WebSocket - def receive_json(self, content, **kwargs): + async def receive_json(self, content, **kwargs): event = Event( type=content["type"], headers=Headers(content["headers"]), @@ -75,16 +74,16 @@ def receive_json(self, content, **kwargs): if not self.__connect_to_project_chat(): return self.disconnect(400) - if event.type == EventType.NEW_MESSAGEЫ: - self.__process_new_message(content) + if event.type == EventType.NEW_MESSAGE: + await self.__process_new_message(content) elif event.type == EventType.TYPING: - self.__process_typing_event(content) + await self.__process_typing_event(content) elif event.type == EventType.READ_MESSAGE: - self.__process_read_event(content) + await self.__process_read_event(content) - def __set_user_online(self): + async def __set_user_online(self): room_name = f"{EventType.SET_ONLINE}_{self.user.pk}" - async_to_sync(self.channel_layer.group_add)(room_name, self.channel_name) + self.channel_layer.group_add(room_name, self.channel_name) cache_key = self.__get_cache_key() if self.chat_type == ChatType.DIRECT: @@ -98,22 +97,22 @@ def __set_user_online(self): else: raise ValueError("Chat type is not supported! Something went terribly wrong!") - def __process_connection_event(self): + async def __process_connection_event(self): """ Send connection event to everyone """ - def __process_typing_event(self, content): + async def __process_typing_event(self, content): """Send typing event to room group.""" pass - def __process_read_event(self, content): + async def __process_read_event(self, content): """Send message read event to room group.""" pass - def __process_new_message(self, content): + async def __process_new_message(self, content): """Send new message to everyone""" text = clean_message_text(content["text"]) if not validate_message_text(text): @@ -126,7 +125,7 @@ def __process_new_message(self, content): else: return - async_to_sync(self.channel_layer.group_send)( + self.channel_layer.group_send( self.room_name, { "type": "chat_message_echo", @@ -135,10 +134,10 @@ def __process_new_message(self, content): }, ) - def __get_cache_key(self): + async def __get_cache_key(self): return f"online_list_{self.chat_type}_{self.chat.pk}" - def __connect_to_direct_chat(self) -> bool: + async def __connect_to_direct_chat(self) -> bool: # room name looks like "direct_{other_user_id}" # room_name = self.scope["url_route"]["kwargs"]["room_name"] try: @@ -150,7 +149,7 @@ def __connect_to_direct_chat(self) -> bool: if not other_user or other_user_id == self.user.id: # such user does not exist / user tries to chat with himself - self.close() + await self.close() return False user1_id = min(self.user.pk, other_user_id) @@ -161,7 +160,7 @@ def __connect_to_direct_chat(self) -> bool: self.chat = DirectChat.objects.get_chat(self.user, other_user) return True - def __connect_to_project_chat(self) -> bool: + async def __connect_to_project_chat(self) -> bool: # room name looks like "project_{project_id}" room_name = self.scope["url_route"]["kwargs"]["room_name"] project_id = int(room_name.split("_")[1]) @@ -169,12 +168,12 @@ def __connect_to_project_chat(self) -> bool: if not filtered_projects.exists(): # project does not exist - self.close() + await self.close() return False if not filtered_projects.first().collaborator_set.filter(user=self.user).exists(): # user is not a collaborator - self.close() + await self.close() return False self.room_name = f"project_{project_id}" @@ -183,17 +182,17 @@ def __connect_to_project_chat(self) -> bool: return True -class NotificationConsumer(JsonWebsocketConsumer): - # TODO: implement - def __init__(self, *args, **kwargs): - super().__init__(args, kwargs) - self.user = None - - def connect(self): - self.user = self.scope["user"] - if not self.user.is_authenticated: - return - - self.accept() +class NotificationConsumer(AsyncJsonWebsocketConsumer): + # # TODO: implement + # def __init__(self, *args, **kwargs): + # super().__init__(args, kwargs) + # self.user = None + async def connect(self): + # self.user = self.scope["user"] + # if not self.user.is_authenticated: + # return + # + # self.accept() + pass # Send count of unread messages From 4962f15c3d74a78c2d7754c6418df8ed380361c7 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Fri, 27 Jan 2023 23:31:46 +0300 Subject: [PATCH 57/87] Added authorization for consumer by token Co-authored-by: 53031664+Yakser@users.noreply.github.com --- chats/consumers.py | 186 +++++++++++++---------------------- chats/middleware.py | 23 +++++ chats/tests.py | 1 - chats/utils.py | 14 +-- chats/websockets_settings.py | 5 + procollab/asgi.py | 5 +- 6 files changed, 98 insertions(+), 136 deletions(-) delete mode 100644 chats/tests.py diff --git a/chats/consumers.py b/chats/consumers.py index f6223807..b65c093c 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -5,15 +5,16 @@ from chats.models import ( BaseChat, - DirectChat, - DirectChatMessage, - ProjectChat, - ProjectChatMessage, ) -from chats.utils import clean_message_text, get_user_id_from_token, validate_message_text -from chats.websockets_settings import ChatType, Content, Event, EventType, Headers +from chats.websockets_settings import ( + ChatType, + Content, + Event, + EventType, + Headers, + EventGroupType, +) from core.constants import ONE_DAY_IN_SECONDS -from projects.models import Project from users.models import CustomUser @@ -26,61 +27,42 @@ def __init__(self, *args, **kwargs): self.chat: Optional[BaseChat] = None async def connect(self): - # Join room group + """User connected to websocket""" + if self.scope.user.is_anonymous: + return self.disconnect(403) + + await self.channel_layer.group_add( + EventGroupType.GENERAL_EVENTS, self.channel_name + ) await self.accept() async def disconnect(self, close_code): + """User disconnected from websocket, Don't have to do anything here""" pass - # Leave room group - # - # # remove user from online cache - # if self.chat: - # cache_key = self.__get_cache_key() - # current_online_list = cache.get(cache_key, []) - # current_online_list.remove(self.user.pk) - # cache.set(cache_key, current_online_list, ONE_DAY_IN_SECONDS) - # - # async_to_sync(self.channel_layer.group_discard)(self.room_name, self.channel_name) - # Receive message from WebSocket async def receive_json(self, content, **kwargs): + """Receive message from WebSocket in JSON format""" event = Event( type=content["type"], headers=Headers(content["headers"]), content=Content(**content["content"]), ) - token = event.headers.Authorization - user_id = get_user_id_from_token(token) - - if event.type == EventType.NEW_MESSAGE: - room_name = f"{EventType.NEW_MESSAGE}_{event.content.chat_id}" - elif event.type == EventType.TYPING: - room_name = f"{EventType.TYPING}_{event.content.chat_id}" - elif event.type == EventType.READ_MESSAGE: - room_name = f"{EventType.READ_MESSAGE}_{event.content.chat_id}" - elif event.type == EventType.DELETE_MESSAGE: - room_name = f"{EventType.DELETE_MESSAGE}_{event.content.chat_id}" - elif event.type == EventType.SET_ONLINE: - room_name = f"{EventType.SET_ONLINE}_{user_id}" - elif event.type == EventType.SET_OFFLINE: - room_name = f"{EventType.SET_OFFLINE}_{user_id}" + + # two event types - related to group chat and related to leave/connect + if event.type in [ + EventType.NEW_MESSAGE, + EventType.TYPING, + EventType.READ_MESSAGE, + EventType.DELETE_MESSAGE, + ]: + room_name = f"{EventGroupType.CHATS_RELATED}_{event.content.chat_id}" + await self.__process_chat_related_event(event, room_name) + elif event.type in [EventType.SET_ONLINE, EventType.SET_OFFLINE]: + room_name = EventGroupType.GENERAL_EVENTS + await self.__process_general_event(event, room_name) else: return self.disconnect(400) - if room_name.startswith(ChatType.DIRECT): - if not self.__connect_to_direct_chat(): - return self.disconnect(400) - elif room_name.startswith(ChatType.PROJECT): - if not self.__connect_to_project_chat(): - return self.disconnect(400) - - if event.type == EventType.NEW_MESSAGE: - await self.__process_new_message(content) - elif event.type == EventType.TYPING: - await self.__process_typing_event(content) - elif event.type == EventType.READ_MESSAGE: - await self.__process_read_event(content) - async def __set_user_online(self): room_name = f"{EventType.SET_ONLINE}_{self.user.pk}" self.channel_layer.group_add(room_name, self.channel_name) @@ -98,11 +80,7 @@ async def __set_user_online(self): raise ValueError("Chat type is not supported! Something went terribly wrong!") async def __process_connection_event(self): - """ - - Send connection event to everyone - - """ + """Send connection event to everyone""" async def __process_typing_event(self, content): """Send typing event to room group.""" @@ -112,74 +90,42 @@ async def __process_read_event(self, content): """Send message read event to room group.""" pass - async def __process_new_message(self, content): - """Send new message to everyone""" - text = clean_message_text(content["text"]) - if not validate_message_text(text): - return - - if self.chat_type == ChatType.DIRECT: - DirectChatMessage.objects.create(chat=self.chat, author=self.user, text=text) - elif self.chat_type == ChatType.PROJECT: - ProjectChatMessage.objects.create(chat=self.chat, author=self.user, text=text) - else: - return - - self.channel_layer.group_send( - self.room_name, - { - "type": "chat_message_echo", - "name": content["name"], - "message": text, - }, - ) + # async def __process_new_message(self, content): + # """Send new message to everyone""" + # text = clean_message_text(content["text"]) + # chat_id = content["chat_id"] + # chat_type = content["chat_type"] + # user = self.scope["user"] + # # check user has access to that chat + # + # if not validate_message_text(text): + # return + # + # if self.chat_type == ChatType.DIRECT: + # DirectChatMessage.objects.create(chat=chat_id, author=self.user, text=text) + # elif self.chat_type == ChatType.PROJECT: + # ProjectChatMessage.objects.create(chat=chat_id, author=self.user, text=text) + # else: + # return + # + # self.channel_layer.group_send( + # self.room_name, + # { + # "type": "chat_message_echo", + # "name": content["name"], + # "message": text, + # }, + # ) + + async def __process_chat_related_event(self, event, room_name): + pass - async def __get_cache_key(self): - return f"online_list_{self.chat_type}_{self.chat.pk}" - - async def __connect_to_direct_chat(self) -> bool: - # room name looks like "direct_{other_user_id}" - # room_name = self.scope["url_route"]["kwargs"]["room_name"] - try: - other_user_id = int(self.room_name.split("_")[1]) - other_user = CustomUser.objects.filter(id=other_user_id).first() - except (IndexError, ValueError): - # todo meaningful error message - return False - - if not other_user or other_user_id == self.user.id: - # such user does not exist / user tries to chat with himself - await self.close() - return False - - user1_id = min(self.user.pk, other_user_id) - user2_id = max(self.user.pk, other_user_id) - - self.room_name = f"direct_{user1_id}_{user2_id}" - self.chat_type = "direct" - self.chat = DirectChat.objects.get_chat(self.user, other_user) - return True - - async def __connect_to_project_chat(self) -> bool: - # room name looks like "project_{project_id}" - room_name = self.scope["url_route"]["kwargs"]["room_name"] - project_id = int(room_name.split("_")[1]) - filtered_projects = Project.objects.filter(pk=project_id) - - if not filtered_projects.exists(): - # project does not exist - await self.close() - return False - - if not filtered_projects.first().collaborator_set.filter(user=self.user).exists(): - # user is not a collaborator - await self.close() - return False - - self.room_name = f"project_{project_id}" - self.chat_type = "project" - self.chat = ProjectChat.objects.get(project_id=project_id) - return True + async def __process_general_event(self, event, room_name): + if event.type == EventType.SET_ONLINE: + # sent everyone online event that user X is online + self.channel_layer.group_send( + room_name, {"type": "set_online", "user_id": self.user.pk} + ) class NotificationConsumer(AsyncJsonWebsocketConsumer): diff --git a/chats/middleware.py b/chats/middleware.py index 11788058..eee73bb3 100644 --- a/chats/middleware.py +++ b/chats/middleware.py @@ -1,6 +1,8 @@ from urllib.parse import parse_qs +import jwt from channels.db import database_sync_to_async +from django.conf import settings from django.contrib.auth import get_user_model from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import AuthenticationFailed @@ -46,6 +48,27 @@ def authenticate_credentials(self, key): return token.user + def authenticate(self, token): + """ + Returns a `User` if a correct username and password have been supplied + Args: + token: token key + + Returns: + User: A user instance. + """ + try: + user_id = jwt.decode( + jwt=token, key=settings.SECRET_KEY, algorithms=["HS256"] + )["user_id"] + except jwt.exceptions.DecodeError: + raise AuthenticationFailed(_("Invalid token.")) + except jwt.exceptions.ExpiredSignatureError: + raise AuthenticationFailed(_("Token expired.")) + + user = User.objects.get(pk=user_id) + return user + @database_sync_to_async def get_user(scope): diff --git a/chats/tests.py b/chats/tests.py deleted file mode 100644 index a39b155a..00000000 --- a/chats/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/chats/utils.py b/chats/utils.py index b1b11ecb..786c2ed9 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -1,7 +1,3 @@ -import jwt -from django.conf import settings - - def clean_message_text(text: str) -> str: """ Cleans message text. @@ -14,13 +10,5 @@ def validate_message_text(text: str) -> bool: """ Validates message text. """ - + # TODO: add bad word filter return 0 < len(text) <= 8192 - - -def get_user_id_from_token(token: str) -> int: - """ - Returns user id from token. - """ - - return jwt.decode(jwt=token, key=settings.SECRET_KEY, algorithms=["HS256"])["user_id"] diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py index 6677f793..627449af 100644 --- a/chats/websockets_settings.py +++ b/chats/websockets_settings.py @@ -17,6 +17,11 @@ class EventType(str, Enum): SET_OFFLINE = "set_offline" +class EventGroupType(str, Enum): + CHATS_RELATED = "CHATS_RELATED" + GENERAL_EVENTS = "GENERAL_EVENTS" + + @dataclass(slots=True, frozen=True) class Content: chat_id: str diff --git a/procollab/asgi.py b/procollab/asgi.py index 7dbc95fc..e72e29e2 100644 --- a/procollab/asgi.py +++ b/procollab/asgi.py @@ -1,18 +1,19 @@ import os import chats.routing -from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter from channels.security.websocket import AllowedHostsOriginValidator from django.core.asgi import get_asgi_application +from chats.middleware import TokenAuthMiddleware + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "procollab.settings") application = ProtocolTypeRouter( { "http": get_asgi_application(), "websocket": AllowedHostsOriginValidator( - AuthMiddlewareStack(URLRouter(chats.routing.websocket_urlpatterns)) + TokenAuthMiddleware(URLRouter(chats.routing.websocket_urlpatterns)) ), } ) From a25e08a2c02fead6757489e21120b773b943f795 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Fri, 27 Jan 2023 23:46:04 +0300 Subject: [PATCH 58/87] Fix authorization bug for ws Co-authored-by: Yakser 53031664+Yakser@users.noreply.github.com --- chats/consumers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index b65c093c..e2ff3564 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -28,8 +28,8 @@ def __init__(self, *args, **kwargs): async def connect(self): """User connected to websocket""" - if self.scope.user.is_anonymous: - return self.disconnect(403) + if self.scope["user"].is_anonymous: + return await self.close(403) await self.channel_layer.group_add( EventGroupType.GENERAL_EVENTS, self.channel_name From 5df56cd05594d6d4c1e9934f9a4939787f8b737a Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Fri, 27 Jan 2023 23:46:50 +0300 Subject: [PATCH 59/87] refactoring Co-authored-by: Yakser --- chats/consumers.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index e2ff3564..47b83014 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -90,33 +90,6 @@ async def __process_read_event(self, content): """Send message read event to room group.""" pass - # async def __process_new_message(self, content): - # """Send new message to everyone""" - # text = clean_message_text(content["text"]) - # chat_id = content["chat_id"] - # chat_type = content["chat_type"] - # user = self.scope["user"] - # # check user has access to that chat - # - # if not validate_message_text(text): - # return - # - # if self.chat_type == ChatType.DIRECT: - # DirectChatMessage.objects.create(chat=chat_id, author=self.user, text=text) - # elif self.chat_type == ChatType.PROJECT: - # ProjectChatMessage.objects.create(chat=chat_id, author=self.user, text=text) - # else: - # return - # - # self.channel_layer.group_send( - # self.room_name, - # { - # "type": "chat_message_echo", - # "name": content["name"], - # "message": text, - # }, - # ) - async def __process_chat_related_event(self, event, room_name): pass From b54cae807303713861fdffc6f306e0c6b3355268 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sat, 28 Jan 2023 23:47:47 +0400 Subject: [PATCH 60/87] Added online/offline events + cache Co-authored-by: Mikhail Khromov --- chats/consumers.py | 35 ++++++++++++++++------------------- chats/middleware.py | 13 ++++++++++--- chats/websockets_settings.py | 3 +++ 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 47b83014..0c7cc3f4 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -7,12 +7,12 @@ BaseChat, ) from chats.websockets_settings import ( - ChatType, Content, Event, EventType, Headers, EventGroupType, + ONLINE_USER_CACHE_KEY_PREFIX, ) from core.constants import ONE_DAY_IN_SECONDS from users.models import CustomUser @@ -28,13 +28,14 @@ def __init__(self, *args, **kwargs): async def connect(self): """User connected to websocket""" + if self.scope["user"].is_anonymous: return await self.close(403) + await self.accept() await self.channel_layer.group_add( EventGroupType.GENERAL_EVENTS, self.channel_name ) - await self.accept() async def disconnect(self, close_code): """User disconnected from websocket, Don't have to do anything here""" @@ -63,22 +64,6 @@ async def receive_json(self, content, **kwargs): else: return self.disconnect(400) - async def __set_user_online(self): - room_name = f"{EventType.SET_ONLINE}_{self.user.pk}" - self.channel_layer.group_add(room_name, self.channel_name) - - cache_key = self.__get_cache_key() - if self.chat_type == ChatType.DIRECT: - current_online_list = cache.get(cache_key, []) - current_online_list.append(self.user.pk) - cache.set(cache_key, current_online_list, ONE_DAY_IN_SECONDS) - elif self.chat_type == ChatType.PROJECT: - current_online_list = cache.get(cache_key, []) - current_online_list.append(self.user.pk) - cache.set(cache_key, current_online_list, ONE_DAY_IN_SECONDS) - else: - raise ValueError("Chat type is not supported! Something went terribly wrong!") - async def __process_connection_event(self): """Send connection event to everyone""" @@ -94,11 +79,23 @@ async def __process_chat_related_event(self, event, room_name): pass async def __process_general_event(self, event, room_name): + cache_key = f"{ONLINE_USER_CACHE_KEY_PREFIX}{self.user.pk}" if event.type == EventType.SET_ONLINE: + cache.set(cache_key, True, ONE_DAY_IN_SECONDS) + # sent everyone online event that user X is online self.channel_layer.group_send( - room_name, {"type": "set_online", "user_id": self.user.pk} + room_name, {"type": EventType.SET_ONLINE, "user_id": self.user.pk} ) + elif event.type == EventType.SET_OFFLINE: + cache.delete(cache_key) + + # sent everyone online event that user X is offline + self.channel_layer.group_send( + room_name, {"type": EventType.SET_OFFLINE, "user_id": self.user.pk} + ) + else: + raise ValueError("Unknown event type") class NotificationConsumer(AsyncJsonWebsocketConsumer): diff --git a/chats/middleware.py b/chats/middleware.py index eee73bb3..c30bca2a 100644 --- a/chats/middleware.py +++ b/chats/middleware.py @@ -109,7 +109,14 @@ async def __call__(self, scope, receive, send): # checking if it is a valid user ID, or if scope["user"] is already # populated). query_params = parse_qs(scope["query_string"].decode()) - token = query_params["token"][0] - scope["token"] = token - scope["user"] = await get_user(scope) + try: + token = query_params["token"][0] + scope["token"] = token + scope["user"] = await get_user(scope) + except KeyError: + # Token is missing from query string + from django.contrib.auth.models import AnonymousUser + + scope["user"] = AnonymousUser() + return await self.app(scope, receive, send) diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py index 627449af..048687e8 100644 --- a/chats/websockets_settings.py +++ b/chats/websockets_settings.py @@ -38,3 +38,6 @@ class Event: type: EventType headers: Headers content: Optional[Content] + + +ONLINE_USER_CACHE_KEY_PREFIX = "online_user_" From 00904efc6fd4eab79697b50a9477b07944065cdc Mon Sep 17 00:00:00 2001 From: Yakser Date: Sun, 29 Jan 2023 00:10:32 +0400 Subject: [PATCH 61/87] Moved token from qstring to headers Co-authored-by: Mikhail Khromov --- chats/middleware.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/chats/middleware.py b/chats/middleware.py index c30bca2a..4beb505e 100644 --- a/chats/middleware.py +++ b/chats/middleware.py @@ -1,5 +1,3 @@ -from urllib.parse import parse_qs - import jwt from channels.db import database_sync_to_async from django.conf import settings @@ -108,13 +106,21 @@ async def __call__(self, scope, receive, send): # Look up user from query string (you should also do things like # checking if it is a valid user ID, or if scope["user"] is already # populated). - query_params = parse_qs(scope["query_string"].decode()) + headers = scope["headers"] try: - token = query_params["token"][0] + token = None + for name, value in headers: + if name == b"authorization": + token = value.decode() + break + + if token is None: + raise ValueError("Token is missing from headers") + scope["token"] = token scope["user"] = await get_user(scope) - except KeyError: - # Token is missing from query string + except ValueError: + # Token is missing from headers from django.contrib.auth.models import AnonymousUser scope["user"] = AnonymousUser() From 60df5cd2514f7d8cacbf89d4a8413250d66a8c45 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sun, 29 Jan 2023 00:42:15 +0400 Subject: [PATCH 62/87] Add is_online field to users serializers Co-authored-by: Mikhail Khromov --- chats/consumers.py | 14 ++++++-------- chats/websockets_settings.py | 13 ++----------- core/utils.py | 4 ++++ users/serializers.py | 21 ++++++++++++++++++++- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 0c7cc3f4..3a7ff73b 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -10,11 +10,10 @@ Content, Event, EventType, - Headers, EventGroupType, - ONLINE_USER_CACHE_KEY_PREFIX, ) from core.constants import ONE_DAY_IN_SECONDS +from core.utils import get_user_online_cache_key from users.models import CustomUser @@ -32,6 +31,8 @@ async def connect(self): if self.scope["user"].is_anonymous: return await self.close(403) + self.user = self.scope["user"] + await self.accept() await self.channel_layer.group_add( EventGroupType.GENERAL_EVENTS, self.channel_name @@ -45,8 +46,7 @@ async def receive_json(self, content, **kwargs): """Receive message from WebSocket in JSON format""" event = Event( type=content["type"], - headers=Headers(content["headers"]), - content=Content(**content["content"]), + content=Content(**content.get("content", {"chat_id": None, "message": None})), ) # two event types - related to group chat and related to leave/connect @@ -64,9 +64,6 @@ async def receive_json(self, content, **kwargs): else: return self.disconnect(400) - async def __process_connection_event(self): - """Send connection event to everyone""" - async def __process_typing_event(self, content): """Send typing event to room group.""" pass @@ -79,8 +76,9 @@ async def __process_chat_related_event(self, event, room_name): pass async def __process_general_event(self, event, room_name): - cache_key = f"{ONLINE_USER_CACHE_KEY_PREFIX}{self.user.pk}" + cache_key = get_user_online_cache_key(self.user) if event.type == EventType.SET_ONLINE: + print("set online", cache_key) cache.set(cache_key, True, ONE_DAY_IN_SECONDS) # sent everyone online event that user X is online diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py index 048687e8..1cd66906 100644 --- a/chats/websockets_settings.py +++ b/chats/websockets_settings.py @@ -24,20 +24,11 @@ class EventGroupType(str, Enum): @dataclass(slots=True, frozen=True) class Content: - chat_id: str - message: str - - -@dataclass(slots=True, frozen=True) -class Headers: - Authorization: str + chat_id: Optional[str] + message: Optional[str] @dataclass(slots=True, frozen=True) class Event: type: EventType - headers: Headers content: Optional[Content] - - -ONLINE_USER_CACHE_KEY_PREFIX = "online_user_" diff --git a/core/utils.py b/core/utils.py index 61b4ee6f..2f235037 100644 --- a/core/utils.py +++ b/core/utils.py @@ -8,3 +8,7 @@ def send_email(data): subject=data["email_subject"], body=data["email_body"], to=[data["to_email"]] ) email.send() + + +def get_user_online_cache_key(user) -> str: + return f"online_user_{user.pk}" diff --git a/users/serializers.py b/users/serializers.py index 668cefe9..c7128486 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,6 +1,8 @@ from django.forms.models import model_to_dict from rest_framework import serializers +from django.core.cache import cache +from core.utils import get_user_online_cache_key from .models import CustomUser, Expert, Investor, Member, Mentor, UserAchievement @@ -51,7 +53,6 @@ class Meta: class ExpertSerializer(serializers.ModelSerializer): - preferred_industries = CustomListField( child=serializers.CharField(max_length=255), ) @@ -82,6 +83,14 @@ class UserDetailSerializer(serializers.ModelSerializer): mentor = MentorSerializer(required=False) achievements = AchievementListSerializer(required=False, many=True) key_skills = KeySkillsField(required=False) + is_online = serializers.SerializerMethodField() + + @classmethod + def get_is_online(cls, user: CustomUser): + cache_key = get_user_online_cache_key(user) + print(cache_key) + is_online = cache.get(cache_key, False) + return is_online class Meta: model = CustomUser @@ -100,6 +109,7 @@ class Meta: "avatar", "city", "is_active", + "is_online", "member", "investor", "expert", @@ -181,6 +191,14 @@ def update(self, instance, validated_data): class UserListSerializer(serializers.ModelSerializer): member = MemberSerializer(required=False) key_skills = KeySkillsField(required=False) + is_online = serializers.SerializerMethodField() + + @classmethod + def get_is_online(cls, user: CustomUser): + cache_key = get_user_online_cache_key(user) + print(cache_key) + is_online = cache.get(cache_key, False) + return is_online def create(self, validated_data): user = CustomUser(**validated_data) @@ -203,6 +221,7 @@ class Meta: "speciality", "birthday", "is_active", + "is_online", "member", "password", ] From f15cf0e57126e02f50441f3c66b279da81d8c095 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sat, 28 Jan 2023 23:57:57 +0300 Subject: [PATCH 63/87] =?UTF-8?q?=F0=9F=9A=80=F0=9F=9A=80=F0=9F=9A=80?= =?UTF-8?q?=F0=9F=9A=80=F0=9F=9A=80=20When=20someone=20is=20online/offline?= =?UTF-8?q?,=20everyone=20gets=20a=20notification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yakser --- chats/consumers.py | 29 ++++++++++++----------------- users/serializers.py | 1 - 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 3a7ff73b..0ca0a533 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -1,3 +1,4 @@ +import json from typing import Optional from channels.generic.websocket import AsyncJsonWebsocketConsumer @@ -33,10 +34,10 @@ async def connect(self): self.user = self.scope["user"] - await self.accept() await self.channel_layer.group_add( EventGroupType.GENERAL_EVENTS, self.channel_name ) + await self.accept() async def disconnect(self, close_code): """User disconnected from websocket, Don't have to do anything here""" @@ -75,21 +76,26 @@ async def __process_read_event(self, content): async def __process_chat_related_event(self, event, room_name): pass + async def set_online(self, event): + await self.send(json.dumps(event)) + + async def set_offline(self, event): + await self.send(json.dumps(event)) + async def __process_general_event(self, event, room_name): cache_key = get_user_online_cache_key(self.user) if event.type == EventType.SET_ONLINE: - print("set online", cache_key) cache.set(cache_key, True, ONE_DAY_IN_SECONDS) # sent everyone online event that user X is online - self.channel_layer.group_send( + await self.channel_layer.group_send( room_name, {"type": EventType.SET_ONLINE, "user_id": self.user.pk} ) elif event.type == EventType.SET_OFFLINE: cache.delete(cache_key) # sent everyone online event that user X is offline - self.channel_layer.group_send( + await self.channel_layer.group_send( room_name, {"type": EventType.SET_OFFLINE, "user_id": self.user.pk} ) else: @@ -97,16 +103,5 @@ async def __process_general_event(self, event, room_name): class NotificationConsumer(AsyncJsonWebsocketConsumer): - # # TODO: implement - # def __init__(self, *args, **kwargs): - # super().__init__(args, kwargs) - # self.user = None - - async def connect(self): - # self.user = self.scope["user"] - # if not self.user.is_authenticated: - # return - # - # self.accept() - pass - # Send count of unread messages + # TODO: implement this + pass diff --git a/users/serializers.py b/users/serializers.py index c7128486..5cfa6f24 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -88,7 +88,6 @@ class UserDetailSerializer(serializers.ModelSerializer): @classmethod def get_is_online(cls, user: CustomUser): cache_key = get_user_online_cache_key(user) - print(cache_key) is_online = cache.get(cache_key, False) return is_online From 8b1aea4056349ecfa3d48bbb02a30187c62cafab Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sun, 29 Jan 2023 01:00:02 +0300 Subject: [PATCH 64/87] started making a messenger Co-authored-by: Yakser --- chats/consumers.py | 59 ++++++++++++++++++++++++++++++++++-- chats/models.py | 3 ++ chats/utils.py | 4 +++ chats/websockets_settings.py | 3 +- core/constants.py | 1 + 5 files changed, 66 insertions(+), 4 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 0ca0a533..b440865b 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -1,19 +1,23 @@ import json from typing import Optional +from asgiref.sync import sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.core.cache import cache from chats.models import ( BaseChat, + DirectChatMessage, ) +from chats.utils import get_user_channel_cache_key from chats.websockets_settings import ( Content, Event, EventType, EventGroupType, + ChatType, ) -from core.constants import ONE_DAY_IN_SECONDS +from core.constants import ONE_DAY_IN_SECONDS, ONE_WEEK_IN_SECONDS from core.utils import get_user_online_cache_key from users.models import CustomUser @@ -33,6 +37,9 @@ async def connect(self): return await self.close(403) self.user = self.scope["user"] + cache.set( + get_user_channel_cache_key(self.user), self.channel_name, ONE_WEEK_IN_SECONDS + ) await self.channel_layer.group_add( EventGroupType.GENERAL_EVENTS, self.channel_name @@ -47,7 +54,11 @@ async def receive_json(self, content, **kwargs): """Receive message from WebSocket in JSON format""" event = Event( type=content["type"], - content=Content(**content.get("content", {"chat_id": None, "message": None})), + content=Content( + **content.get( + "content", {"chat_id": None, "message": None, "chat_type": None} + ) + ), ) # two event types - related to group chat and related to leave/connect @@ -74,7 +85,49 @@ async def __process_read_event(self, content): pass async def __process_chat_related_event(self, event, room_name): - pass + if event.type == EventType.NEW_MESSAGE: + if event.content.chat_type == ChatType.DIRECT: + # create new message + direct_chat = 1 + other_user = sync_to_async(direct_chat.get_other_user(self.user)) + # try: + # direct_chat = await sync_to_async(DirectChat.objects.get)( + # pk=event.content.chat_id + # ) + # except DirectChat.DoesNotExist: + # # create a chat + # direct_chat = await sync_to_async(DirectChat.objects.create)( + # user1=self.user, user2_id=event.content.chat_id + # ) + + msg = await sync_to_async(DirectChatMessage.objects.create)( + chat_id=event.content.chat_id, + sender=self.user, + message=event.content.message, + ) + # send message to user's channel + other_user_channel = cache.get( + get_user_channel_cache_key(other_user), None + ) + if other_user_channel is None: + return + + await self.channel_layer.send( + other_user_channel, + { + "type": "chat_message", + "message": { + "id": msg.id, + "chat_id": msg.chat_id, + "author": msg.author, + "text": msg.message, + "created_at": msg.created_at, + }, + }, + ) + + async def chat_message(self, event): + await self.send(json.dumps(event)) async def set_online(self, event): await self.send(json.dumps(event)) diff --git a/chats/models.py b/chats/models.py index f0bb23ba..7879d560 100644 --- a/chats/models.py +++ b/chats/models.py @@ -150,6 +150,9 @@ def get_chat(cls, user1, user2) -> "DirectChat": def get_last_messages(self, message_count): return self.messages.order_by("-created_at")[:message_count] + def get_other_user(self, user): + return self.users.exclude(pk=user.pk).first() + def save( self, force_insert=False, force_update=False, using=None, update_fields=None ): diff --git a/chats/utils.py b/chats/utils.py index 786c2ed9..7058e79f 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -12,3 +12,7 @@ def validate_message_text(text: str) -> bool: """ # TODO: add bad word filter return 0 < len(text) <= 8192 + + +def get_user_channel_cache_key(user) -> str: + return f"user_channel_{user.pk}" diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py index 1cd66906..c253681d 100644 --- a/chats/websockets_settings.py +++ b/chats/websockets_settings.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional +from typing import Optional, Union class ChatType(str, Enum): @@ -25,6 +25,7 @@ class EventGroupType(str, Enum): @dataclass(slots=True, frozen=True) class Content: chat_id: Optional[str] + chat_type: Optional[Union[ChatType.DIRECT, ChatType.PROJECT]] message: Optional[str] diff --git a/core/constants.py b/core/constants.py index bbc4eafa..1cdae74e 100644 --- a/core/constants.py +++ b/core/constants.py @@ -1 +1,2 @@ ONE_DAY_IN_SECONDS = 60 * 60 * 24 +ONE_WEEK_IN_SECONDS = ONE_DAY_IN_SECONDS * 7 From 270431d5b34d95a0ffdf9a39f394bc07933b7a95 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sun, 29 Jan 2023 02:54:14 +0400 Subject: [PATCH 65/87] Trying to make messenger work Co-authored-by: Mikhail Khromov --- chats/consumers.py | 33 ++++++++++++++++++++++----------- chats/exceptions.py | 6 ++++++ chats/models.py | 8 +++++++- 3 files changed, 35 insertions(+), 12 deletions(-) create mode 100644 chats/exceptions.py diff --git a/chats/consumers.py b/chats/consumers.py index b440865b..3bf2a043 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -5,9 +5,11 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.core.cache import cache +from chats.exceptions import NonMatchingDirectChatIdException from chats.models import ( BaseChat, DirectChatMessage, + DirectChat, ) from chats.utils import get_user_channel_cache_key from chats.websockets_settings import ( @@ -88,17 +90,26 @@ async def __process_chat_related_event(self, event, room_name): if event.type == EventType.NEW_MESSAGE: if event.content.chat_type == ChatType.DIRECT: # create new message - direct_chat = 1 - other_user = sync_to_async(direct_chat.get_other_user(self.user)) - # try: - # direct_chat = await sync_to_async(DirectChat.objects.get)( - # pk=event.content.chat_id - # ) - # except DirectChat.DoesNotExist: - # # create a chat - # direct_chat = await sync_to_async(DirectChat.objects.create)( - # user1=self.user, user2_id=event.content.chat_id - # ) + chat_id = event.content.chat_id + + # todo add try/except + user1_id, user2_id = map(int, chat_id.split("_")) + if user1_id == self.user.id or user2_id == self.user.id: + other_user = await sync_to_async(CustomUser.objects.get)( + id=user1_id if user1_id != self.user.id else user2_id + ) + else: + raise NonMatchingDirectChatIdException + + # check if chat exists + try: + await sync_to_async(DirectChat.objects.get)(pk=event.content.chat_id) + except DirectChat.DoesNotExist: + # create a chat + print(self.user, other_user) + await sync_to_async(DirectChat.create_from_two_users)( + self.user, other_user + ) msg = await sync_to_async(DirectChatMessage.objects.create)( chat_id=event.content.chat_id, diff --git a/chats/exceptions.py b/chats/exceptions.py new file mode 100644 index 00000000..878e1baf --- /dev/null +++ b/chats/exceptions.py @@ -0,0 +1,6 @@ +class ChatException(Exception): + pass + + +class NonMatchingDirectChatIdException(ChatException): + pass diff --git a/chats/models.py b/chats/models.py index 7879d560..5d174fae 100644 --- a/chats/models.py +++ b/chats/models.py @@ -143,7 +143,7 @@ def get_chat(cls, user1, user2) -> "DirectChat": try: return cls.objects.get(pk="_".join(sorted([str(user1.pk), str(user2.pk)]))) except cls.DoesNotExist: - chat = cls.objects.create() + chat = cls.objects.create(pk="_".join(sorted([str(user1.pk), str(user2.pk)]))) chat.users.set([user1, user2]) return chat @@ -153,6 +153,12 @@ def get_last_messages(self, message_count): def get_other_user(self, user): return self.users.exclude(pk=user.pk).first() + @classmethod + def create_from_two_users(cls, user1, user2): + chat = cls.objects.create(pk="_".join(sorted([str(user1.pk), str(user2.pk)]))) + chat.users.set([user1, user2]) + return chat + def save( self, force_insert=False, force_update=False, using=None, update_fields=None ): From 7c802d6e418f527209bdf294dbe68b1251a962ee Mon Sep 17 00:00:00 2001 From: Yakser Date: Sun, 29 Jan 2023 03:03:46 +0400 Subject: [PATCH 66/87] Increased ACCESS_TOKEN_LIFETIME in debug mode Co-authored-by: Mikhail Khromov --- procollab/settings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/procollab/settings.py b/procollab/settings.py index 8b80d40b..1d67c0f9 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -272,6 +272,10 @@ "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), } +if DEBUG: + SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"] = timedelta(weeks=1) + + SESSION_COOKIE_SECURE = False EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" From c694ee5a65e14590a0491c6c14067cbe2ee453fd Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sun, 29 Jan 2023 02:12:54 +0300 Subject: [PATCH 67/87] =?UTF-8?q?=F0=9F=8E=89=F0=9F=8E=89=F0=9F=8E=89?= =?UTF-8?q?=F0=9F=8E=89=F0=9F=8E=89=F0=9F=8D=BA=F0=9F=8D=BA=F0=9F=8D=BA?= =?UTF-8?q?=F0=9F=8D=BA=F0=9F=8D=BA=F0=9F=8D=BA=20Direct=20Chats=20working?= =?UTF-8?q?=20now,=20project=20chats=20yet=20to=20be=20implemented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yakser --- chats/consumers.py | 33 ++++++++++++++++++++++++--------- chats/exceptions.py | 4 ++++ chats/models.py | 20 ++++++++++++++------ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 3bf2a043..67720709 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -5,7 +5,7 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.core.cache import cache -from chats.exceptions import NonMatchingDirectChatIdException +from chats.exceptions import NonMatchingDirectChatIdException, WrongChatIdException from chats.models import ( BaseChat, DirectChatMessage, @@ -101,20 +101,22 @@ async def __process_chat_related_event(self, event, room_name): else: raise NonMatchingDirectChatIdException + if chat_id != DirectChat.get_chat_id_from_users(self.user, other_user): + raise WrongChatIdException + # check if chat exists try: await sync_to_async(DirectChat.objects.get)(pk=event.content.chat_id) except DirectChat.DoesNotExist: - # create a chat - print(self.user, other_user) + # if not, create such chat await sync_to_async(DirectChat.create_from_two_users)( self.user, other_user ) msg = await sync_to_async(DirectChatMessage.objects.create)( - chat_id=event.content.chat_id, - sender=self.user, - message=event.content.message, + chat_id=chat_id, + author=self.user, + text=event.content.message, ) # send message to user's channel other_user_channel = cache.get( @@ -130,9 +132,22 @@ async def __process_chat_related_event(self, event, room_name): "message": { "id": msg.id, "chat_id": msg.chat_id, - "author": msg.author, - "text": msg.message, - "created_at": msg.created_at, + "author_id": msg.author.pk, + "text": msg.text, + "created_at": msg.created_at.timestamp(), + }, + }, + ) + await self.channel_layer.send( + self.channel_name, + { + "type": "chat_message", + "message": { + "id": msg.id, + "chat_id": msg.chat_id, + "author_id": msg.author.pk, + "text": msg.text, + "created_at": msg.created_at.timestamp(), }, }, ) diff --git a/chats/exceptions.py b/chats/exceptions.py index 878e1baf..dbea3848 100644 --- a/chats/exceptions.py +++ b/chats/exceptions.py @@ -4,3 +4,7 @@ class ChatException(Exception): class NonMatchingDirectChatIdException(ChatException): pass + + +class WrongChatIdException(ChatException): + pass diff --git a/chats/models.py b/chats/models.py index 5d174fae..c42d4eb6 100644 --- a/chats/models.py +++ b/chats/models.py @@ -8,6 +8,8 @@ User = get_user_model() +id = models.AutoField(primary_key=True) + class BaseChat(models.Model): """ @@ -155,15 +157,21 @@ def get_other_user(self, user): @classmethod def create_from_two_users(cls, user1, user2): - chat = cls.objects.create(pk="_".join(sorted([str(user1.pk), str(user2.pk)]))) + chat = cls.objects.create(pk=cls.get_chat_id_from_users(user1, user2)) chat.users.set([user1, user2]) return chat - def save( - self, force_insert=False, force_update=False, using=None, update_fields=None - ): - self.id = "_".join(sorted([str(user.pk) for user in self.users.all()])) - super().save(force_insert, force_update, using, update_fields) + @classmethod + def get_chat_id_from_users(cls, user1, user2): + first_user = user1 if user1.pk < user2.pk else user2 + second_user = user2 if user1.pk < user2.pk else user1 + return f"{first_user.pk}_{second_user.pk}" + + # def save( + # self, force_insert=False, force_update=False, using=None, update_fields=None + # ): + # self.id = self.get_chat_id_from_users(*self.users.all()) + # super().save(force_insert, force_update, using, update_fields) def __str__(self): return f"DirectChat with {self.get_users_str()}" From 29247c33ae721f6150cd5349b672980244188a0c Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sun, 29 Jan 2023 02:16:16 +0300 Subject: [PATCH 68/87] altered id field on ProjectChat --- chats/migrations/0004_alter_projectchat_id.py | 20 +++++++++++++++++++ chats/models.py | 7 +++++++ 2 files changed, 27 insertions(+) create mode 100644 chats/migrations/0004_alter_projectchat_id.py diff --git a/chats/migrations/0004_alter_projectchat_id.py b/chats/migrations/0004_alter_projectchat_id.py new file mode 100644 index 00000000..c91aa451 --- /dev/null +++ b/chats/migrations/0004_alter_projectchat_id.py @@ -0,0 +1,20 @@ +# Generated by Django 4.1.3 on 2023-01-28 23:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0003_basemessage_directchat_projectchat_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="projectchat", + name="id", + field=models.PositiveIntegerField( + primary_key=True, serialize=False, unique=True + ), + ), + ] diff --git a/chats/models.py b/chats/models.py index c42d4eb6..b5b3c9a7 100644 --- a/chats/models.py +++ b/chats/models.py @@ -85,6 +85,7 @@ class ProjectChat(BaseChat): created_at: A DateTimeField indicating date of creation. """ + id = models.PositiveIntegerField(primary_key=True, unique=True) project = models.ForeignKey( Project, on_delete=models.CASCADE, related_name="project_chats" ) @@ -103,6 +104,12 @@ def get_last_messages(self, message_count) -> List["BaseMessage"]: def __str__(self): return f"ProjectChat<{self.project.id}> - {self.project.name}" + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + self.id = self.project.id + super().save(force_insert, force_update, using, update_fields) + class Meta: verbose_name = "Чат проекта" verbose_name_plural = "Чаты проектов" From d741ffd468039d381a751cabd4892c9e5924ab19 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sun, 29 Jan 2023 03:04:33 +0300 Subject: [PATCH 69/87] added message.reply_to --- chats/consumers.py | 23 +++++++++++++++++++++-- chats/websockets_settings.py | 1 + 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 67720709..23f0bd0f 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -21,6 +21,7 @@ ) from core.constants import ONE_DAY_IN_SECONDS, ONE_WEEK_IN_SECONDS from core.utils import get_user_online_cache_key +from projects.models import Collaborator from users.models import CustomUser @@ -42,6 +43,17 @@ async def connect(self): cache.set( get_user_channel_cache_key(self.user), self.channel_name, ONE_WEEK_IN_SECONDS ) + # get all projects that user is a member of + project_ids_list = await sync_to_async( + Collaborator.objects.filter(user=self.user).values_list("project", flat=True) + )() + for project_id in project_ids_list: + # join room for each project + # It's currently not possible to do this in a single call, + # so we have to do it in a loop (e.g. that's O(N) calls to layer backend, redis cache that would be) + await self.channel_layer.group_add( + f"{EventGroupType.CHATS_RELATED}_{project_id}", self.channel_name + ) await self.channel_layer.group_add( EventGroupType.GENERAL_EVENTS, self.channel_name @@ -93,7 +105,10 @@ async def __process_chat_related_event(self, event, room_name): chat_id = event.content.chat_id # todo add try/except - user1_id, user2_id = map(int, chat_id.split("_")) + try: + user1_id, user2_id = map(int, chat_id.split("_")) + except ValueError: + raise WrongChatIdException if user1_id == self.user.id or user2_id == self.user.id: other_user = await sync_to_async(CustomUser.objects.get)( id=user1_id if user1_id != self.user.id else user2_id @@ -112,8 +127,9 @@ async def __process_chat_related_event(self, event, room_name): await sync_to_async(DirectChat.create_from_two_users)( self.user, other_user ) - + # TODO: check that content.reply_to is a message in this chat. maybe constraint? msg = await sync_to_async(DirectChatMessage.objects.create)( + reply_to=event.content.reply_to, chat_id=chat_id, author=self.user, text=event.content.message, @@ -177,6 +193,9 @@ async def __process_general_event(self, event, room_name): await self.channel_layer.group_send( room_name, {"type": EventType.SET_OFFLINE, "user_id": self.user.pk} ) + + # TODO: close connection here? + # await self.close(200) else: raise ValueError("Unknown event type") diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py index c253681d..e23dac13 100644 --- a/chats/websockets_settings.py +++ b/chats/websockets_settings.py @@ -27,6 +27,7 @@ class Content: chat_id: Optional[str] chat_type: Optional[Union[ChatType.DIRECT, ChatType.PROJECT]] message: Optional[str] + reply_to: Optional[int] @dataclass(slots=True, frozen=True) From 7f6c843732227fcc62e57caa6cfaad1f47b317a5 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sun, 29 Jan 2023 12:47:50 +0300 Subject: [PATCH 70/87] optimized project chat creation --- projects/signals.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/projects/signals.py b/projects/signals.py index ed676e7e..5682c296 100644 --- a/projects/signals.py +++ b/projects/signals.py @@ -11,9 +11,12 @@ def create_project(sender, instance, created, **kwargs): Creates collaborator for the project leader and ProjectChat on project creation """ + if not instance.draft: + # if not a draft, check if project chat exists and if not create it + if not ProjectChat.objects.filter(project=instance).exists(): + ProjectChat.objects.create(project=instance) + if created: Collaborator.objects.create( user=instance.leader, project=instance, role="Основатель" ) - - ProjectChat.objects.create(project=instance) From edfec70ddffeb9d871128ab71b8f0931afb599d3 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sun, 29 Jan 2023 13:53:25 +0300 Subject: [PATCH 71/87] added project messages, improved error handling, --- chats/consumers.py | 87 +++++++++++++++++++++++++++++++++++++-------- chats/exceptions.py | 7 +++- 2 files changed, 78 insertions(+), 16 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 23f0bd0f..f43f9d96 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -5,11 +5,18 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.core.cache import cache -from chats.exceptions import NonMatchingDirectChatIdException, WrongChatIdException +from chats.exceptions import ( + NonMatchingDirectChatIdException, + WrongChatIdException, + ChatException, + UserNotInChatException, +) from chats.models import ( BaseChat, DirectChatMessage, DirectChat, + ProjectChat, + ProjectChatMessage, ) from chats.utils import get_user_channel_cache_key from chats.websockets_settings import ( @@ -44,10 +51,11 @@ async def connect(self): get_user_channel_cache_key(self.user), self.channel_name, ONE_WEEK_IN_SECONDS ) # get all projects that user is a member of - project_ids_list = await sync_to_async( - Collaborator.objects.filter(user=self.user).values_list("project", flat=True) - )() - for project_id in project_ids_list: + project_ids_list = Collaborator.objects.filter(user=self.user).values_list( + "project", flat=True + ) + async for project_id in project_ids_list: + # FIXME: if a user is a leader but not a collaborator, this doesn't work # join room for each project # It's currently not possible to do this in a single call, # so we have to do it in a loop (e.g. that's O(N) calls to layer backend, redis cache that would be) @@ -70,7 +78,13 @@ async def receive_json(self, content, **kwargs): type=content["type"], content=Content( **content.get( - "content", {"chat_id": None, "message": None, "chat_type": None} + "content", + { + "chat_id": None, + "message": None, + "chat_type": None, + "reply_to": None, + }, ) ), ) @@ -83,7 +97,11 @@ async def receive_json(self, content, **kwargs): EventType.DELETE_MESSAGE, ]: room_name = f"{EventGroupType.CHATS_RELATED}_{event.content.chat_id}" - await self.__process_chat_related_event(event, room_name) + try: + await self.__process_chat_related_event(event, room_name) + except ChatException as e: + await self.send_json({"error": str(e.get_error())}) + elif event.type in [EventType.SET_ONLINE, EventType.SET_OFFLINE]: room_name = EventGroupType.GENERAL_EVENTS await self.__process_general_event(event, room_name) @@ -108,20 +126,25 @@ async def __process_chat_related_event(self, event, room_name): try: user1_id, user2_id = map(int, chat_id.split("_")) except ValueError: - raise WrongChatIdException + raise WrongChatIdException( + f'Chat id "{chat_id}" is not in the format of' + f" _, where user1_id < user2_id" + ) if user1_id == self.user.id or user2_id == self.user.id: other_user = await sync_to_async(CustomUser.objects.get)( id=user1_id if user1_id != self.user.id else user2_id ) else: - raise NonMatchingDirectChatIdException + raise NonMatchingDirectChatIdException( + f"User {self.user.id} is not a member of chat {chat_id}" + ) - if chat_id != DirectChat.get_chat_id_from_users(self.user, other_user): - raise WrongChatIdException + # if chat_id == 17_7, then chat_id will be == 7_17 + chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) # check if chat exists try: - await sync_to_async(DirectChat.objects.get)(pk=event.content.chat_id) + await sync_to_async(DirectChat.objects.get)(pk=chat_id) except DirectChat.DoesNotExist: # if not, create such chat await sync_to_async(DirectChat.create_from_two_users)( @@ -129,7 +152,7 @@ async def __process_chat_related_event(self, event, room_name): ) # TODO: check that content.reply_to is a message in this chat. maybe constraint? msg = await sync_to_async(DirectChatMessage.objects.create)( - reply_to=event.content.reply_to, + # reply_to=event.content.reply_to, chat_id=chat_id, author=self.user, text=event.content.message, @@ -138,6 +161,20 @@ async def __process_chat_related_event(self, event, room_name): other_user_channel = cache.get( get_user_channel_cache_key(other_user), None ) + await self.channel_layer.send( + self.channel_name, + { + "type": "chat_message", + "message": { + "id": msg.id, + "chat_id": msg.chat_id, + "author_id": msg.author.pk, + "text": msg.text, + "created_at": msg.created_at.timestamp(), + }, + }, + ) + if other_user_channel is None: return @@ -148,25 +185,45 @@ async def __process_chat_related_event(self, event, room_name): "message": { "id": msg.id, "chat_id": msg.chat_id, + "chat_type": ChatType.DIRECT, "author_id": msg.author.pk, "text": msg.text, "created_at": msg.created_at.timestamp(), }, }, ) - await self.channel_layer.send( - self.channel_name, + else: + # create new message + chat_id = event.content.chat_id + chat = await sync_to_async(ProjectChat.objects.get)(pk=chat_id) + # check that user is in this chat + users = await sync_to_async(chat.get_users)() + if self.user not in users: + raise UserNotInChatException( + f"User {self.user.id} is not in project chat {chat_id}" + ) + + msg = await sync_to_async(ProjectChatMessage.objects.create)( + reply_to=event.content.reply_to, + chat=chat, + author=self.user, + text=event.content.message, + ) + await self.channel_layer.group_send( + room_name, { "type": "chat_message", "message": { "id": msg.id, "chat_id": msg.chat_id, + "chat_type": ChatType.PROJECT, "author_id": msg.author.pk, "text": msg.text, "created_at": msg.created_at.timestamp(), }, }, ) + pass async def chat_message(self, event): await self.send(json.dumps(event)) diff --git a/chats/exceptions.py b/chats/exceptions.py index dbea3848..b46ef848 100644 --- a/chats/exceptions.py +++ b/chats/exceptions.py @@ -1,5 +1,6 @@ class ChatException(Exception): - pass + def get_error(self): + return self.args[0] class NonMatchingDirectChatIdException(ChatException): @@ -8,3 +9,7 @@ class NonMatchingDirectChatIdException(ChatException): class WrongChatIdException(ChatException): pass + + +class UserNotInChatException(ChatException): + pass From 438a1f0c153335d11507060998a260ab4760f3fd Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Sun, 29 Jan 2023 14:00:32 +0300 Subject: [PATCH 72/87] Made BaseMessage abstract, removed old migrations --- chats/migrations/0001_initial.py | 106 +++++++++-- ...2_alter_message_options_chat_created_at.py | 31 --- ...message_directchat_projectchat_and_more.py | 176 ------------------ chats/migrations/0004_alter_projectchat_id.py | 20 -- chats/models.py | 31 ++- 5 files changed, 119 insertions(+), 245 deletions(-) delete mode 100644 chats/migrations/0002_alter_message_options_chat_created_at.py delete mode 100644 chats/migrations/0003_basemessage_directchat_projectchat_and_more.py delete mode 100644 chats/migrations/0004_alter_projectchat_id.py diff --git a/chats/migrations/0001_initial.py b/chats/migrations/0001_initial.py index 2c10b126..bd5c6261 100644 --- a/chats/migrations/0001_initial.py +++ b/chats/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.2 on 2022-10-27 00:09 +# Generated by Django 4.1.3 on 2023-01-29 10:59 from django.conf import settings from django.db import migrations, models @@ -11,11 +11,56 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("projects", "0010_alter_collaborator_user"), ] operations = [ migrations.CreateModel( - name="Chat", + name="DirectChat", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "id", + models.CharField(max_length=64, primary_key=True, serialize=False), + ), + ( + "users", + models.ManyToManyField( + related_name="direct_chats", to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "verbose_name": "Личный чат", + "verbose_name_plural": "Личные чаты", + }, + ), + migrations.CreateModel( + name="ProjectChat", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "id", + models.PositiveIntegerField( + primary_key=True, serialize=False, unique=True + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_chats", + to="projects.project", + ), + ), + ], + options={ + "verbose_name": "Чат проекта", + "verbose_name_plural": "Чаты проектов", + }, + ), + migrations.CreateModel( + name="ProjectChatMessage", fields=[ ( "id", @@ -26,21 +71,43 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("name", models.CharField(blank=True, max_length=255, null=True)), + ("text", models.TextField(max_length=8192)), + ("is_read", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), ( - "users", - models.ManyToManyField( - related_name="chats", to=settings.AUTH_USER_MODEL + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_messages", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="chats.projectchat", + ), + ), + ( + "reply_to", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_replies", + to="chats.projectchatmessage", ), ), ], options={ - "verbose_name": "Чат", - "verbose_name_plural": "Чаты", + "verbose_name": "Сообщение в чате проекта", + "verbose_name_plural": "Сообщения в чатах проектов", }, ), migrations.CreateModel( - name="Message", + name="DirectChatMessage", fields=[ ( "id", @@ -51,13 +118,14 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("text", models.TextField()), + ("text", models.TextField(max_length=8192)), + ("is_read", models.BooleanField(default=False)), ("created_at", models.DateTimeField(auto_now_add=True)), ( "author", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name="messages", + related_name="direct_messages", to=settings.AUTH_USER_MODEL, ), ), @@ -66,13 +134,23 @@ class Migration(migrations.Migration): models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="messages", - to="chats.chat", + to="chats.directchat", + ), + ), + ( + "reply_to", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="direct_replies", + to="chats.directchatmessage", ), ), ], options={ - "verbose_name": "Сообщение", - "verbose_name_plural": "Сообщения", + "verbose_name": "Сообщение в личном чате", + "verbose_name_plural": "Сообщения в личных чатах", }, ), ] diff --git a/chats/migrations/0002_alter_message_options_chat_created_at.py b/chats/migrations/0002_alter_message_options_chat_created_at.py deleted file mode 100644 index ac761e80..00000000 --- a/chats/migrations/0002_alter_message_options_chat_created_at.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 4.1.2 on 2022-10-28 14:16 - -import datetime -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("chats", "0001_initial"), - ] - - operations = [ - migrations.AlterModelOptions( - name="message", - options={ - "ordering": ["-created_at"], - "verbose_name": "Сообщение", - "verbose_name_plural": "Сообщения", - }, - ), - migrations.AddField( - model_name="chat", - name="created_at", - field=models.DateTimeField( - auto_now_add=True, - default=datetime.datetime(2022, 10, 28, 17, 16, 5, 705433), - ), - preserve_default=False, - ), - ] diff --git a/chats/migrations/0003_basemessage_directchat_projectchat_and_more.py b/chats/migrations/0003_basemessage_directchat_projectchat_and_more.py deleted file mode 100644 index bfe3ee8b..00000000 --- a/chats/migrations/0003_basemessage_directchat_projectchat_and_more.py +++ /dev/null @@ -1,176 +0,0 @@ -# Generated by Django 4.1.3 on 2023-01-21 13:43 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("projects", "0010_alter_collaborator_user"), - ("chats", "0002_alter_message_options_chat_created_at"), - ] - - operations = [ - migrations.CreateModel( - name="BaseMessage", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("text", models.TextField(max_length=8192)), - ("is_read", models.BooleanField(default=False)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ( - "author", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="messages", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "reply_to", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="replies", - to="chats.basemessage", - ), - ), - ], - options={ - "verbose_name": "Сообщение", - "verbose_name_plural": "Сообщения", - "ordering": ["-created_at"], - }, - ), - migrations.CreateModel( - name="DirectChat", - fields=[ - ("created_at", models.DateTimeField(auto_now_add=True)), - ( - "id", - models.CharField(max_length=64, primary_key=True, serialize=False), - ), - ( - "users", - models.ManyToManyField( - related_name="direct_chats", to=settings.AUTH_USER_MODEL - ), - ), - ], - options={ - "verbose_name": "Личный чат", - "verbose_name_plural": "Личные чаты", - }, - ), - migrations.CreateModel( - name="ProjectChat", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ( - "project", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="project_chats", - to="projects.project", - ), - ), - ], - options={ - "verbose_name": "Чат проекта", - "verbose_name_plural": "Чаты проектов", - }, - ), - migrations.RemoveField( - model_name="message", - name="author", - ), - migrations.RemoveField( - model_name="message", - name="chat", - ), - migrations.CreateModel( - name="DirectChatMessage", - fields=[ - ( - "basemessage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="chats.basemessage", - ), - ), - ( - "chat", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="messages", - to="chats.directchat", - ), - ), - ], - options={ - "verbose_name": "Сообщение в личном чате", - "verbose_name_plural": "Сообщения в личных чатах", - }, - bases=("chats.basemessage",), - ), - migrations.CreateModel( - name="ProjectChatMessage", - fields=[ - ( - "basemessage_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="chats.basemessage", - ), - ), - ( - "chat", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="messages", - to="chats.projectchat", - ), - ), - ], - options={ - "verbose_name": "Сообщение в чате проекта", - "verbose_name_plural": "Сообщения в чатах проектов", - }, - bases=("chats.basemessage",), - ), - migrations.DeleteModel( - name="Chat", - ), - migrations.DeleteModel( - name="Message", - ), - ] diff --git a/chats/migrations/0004_alter_projectchat_id.py b/chats/migrations/0004_alter_projectchat_id.py deleted file mode 100644 index c91aa451..00000000 --- a/chats/migrations/0004_alter_projectchat_id.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.1.3 on 2023-01-28 23:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("chats", "0003_basemessage_directchat_projectchat_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="projectchat", - name="id", - field=models.PositiveIntegerField( - primary_key=True, serialize=False, unique=True - ), - ), - ] diff --git a/chats/models.py b/chats/models.py index b5b3c9a7..b4fa02bf 100644 --- a/chats/models.py +++ b/chats/models.py @@ -196,16 +196,18 @@ class BaseMessage(models.Model): author: A ForeignKey referring to the User model. text: A TextField containing message text. is_read: A BooleanField indicating whether message is read. + # is_deleted: A BooleanField indicating whether message is deleted. created_at: A DateTimeField indicating date of creation. """ - author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="messages") text = models.TextField(max_length=8192) is_read = models.BooleanField(default=False) + # is_deleted = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) - reply_to = models.ForeignKey( - "self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies" - ) + # author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="messages") + # reply_to = models.ForeignKey( + # "self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies" + # ) def __str__(self): return f"Message<{self.pk}>" @@ -214,6 +216,7 @@ class Meta: verbose_name = "Сообщение" verbose_name_plural = "Сообщения" ordering = ["-created_at"] + abstract = True class ProjectChatMessage(BaseMessage): @@ -230,6 +233,16 @@ class ProjectChatMessage(BaseMessage): chat = models.ForeignKey( ProjectChat, on_delete=models.CASCADE, related_name="messages" ) + author = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="project_messages" + ) + reply_to = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="project_replies", + ) def __str__(self): return f"ProjectChatMessage<{self.pk}>" @@ -253,6 +266,16 @@ class DirectChatMessage(BaseMessage): chat = models.ForeignKey( DirectChat, on_delete=models.CASCADE, related_name="messages" ) + author = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="direct_messages" + ) + reply_to = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="direct_replies", + ) def __str__(self): return f"DirectChatMessage<{self.pk}>" From 718890da63464e2df8ef3518908bbf3708e490ab Mon Sep 17 00:00:00 2001 From: Yakser Date: Sun, 29 Jan 2023 18:56:53 +0400 Subject: [PATCH 73/87] Added is_delete field to the messages models Added check that replied message is in the same chat --- chats/consumers.py | 227 ++++++++++-------- chats/exceptions.py | 4 + ...2_directchatmessage_is_deleted_and_more.py | 23 ++ chats/models.py | 35 ++- chats/websockets_settings.py | 7 +- 5 files changed, 172 insertions(+), 124 deletions(-) create mode 100644 chats/migrations/0002_directchatmessage_is_deleted_and_more.py diff --git a/chats/consumers.py b/chats/consumers.py index f43f9d96..3a28f7af 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -4,12 +4,14 @@ from asgiref.sync import sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.core.cache import cache +from django.core.exceptions import ValidationError from chats.exceptions import ( NonMatchingDirectChatIdException, WrongChatIdException, ChatException, UserNotInChatException, + NonMatchingReplyChatIdException, ) from chats.models import ( BaseChat, @@ -118,112 +120,129 @@ async def __process_read_event(self, content): async def __process_chat_related_event(self, event, room_name): if event.type == EventType.NEW_MESSAGE: - if event.content.chat_type == ChatType.DIRECT: - # create new message - chat_id = event.content.chat_id - - # todo add try/except - try: - user1_id, user2_id = map(int, chat_id.split("_")) - except ValueError: - raise WrongChatIdException( - f'Chat id "{chat_id}" is not in the format of' - f" _, where user1_id < user2_id" - ) - if user1_id == self.user.id or user2_id == self.user.id: - other_user = await sync_to_async(CustomUser.objects.get)( - id=user1_id if user1_id != self.user.id else user2_id - ) - else: - raise NonMatchingDirectChatIdException( - f"User {self.user.id} is not a member of chat {chat_id}" - ) - - # if chat_id == 17_7, then chat_id will be == 7_17 - chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) - - # check if chat exists - try: - await sync_to_async(DirectChat.objects.get)(pk=chat_id) - except DirectChat.DoesNotExist: - # if not, create such chat - await sync_to_async(DirectChat.create_from_two_users)( - self.user, other_user - ) - # TODO: check that content.reply_to is a message in this chat. maybe constraint? - msg = await sync_to_async(DirectChatMessage.objects.create)( - # reply_to=event.content.reply_to, - chat_id=chat_id, - author=self.user, - text=event.content.message, - ) - # send message to user's channel - other_user_channel = cache.get( - get_user_channel_cache_key(other_user), None - ) - await self.channel_layer.send( - self.channel_name, - { - "type": "chat_message", - "message": { - "id": msg.id, - "chat_id": msg.chat_id, - "author_id": msg.author.pk, - "text": msg.text, - "created_at": msg.created_at.timestamp(), - }, - }, - ) + await self.__process_new_message_event(event, room_name) - if other_user_channel is None: - return + async def __process_new_message_event(self, event, room_name): + if event.content.chat_type == ChatType.DIRECT: + await self.__process_new_direct_message_event(event) + else: + await self.__process_new_project_message_event(event, room_name) - await self.channel_layer.send( - other_user_channel, - { - "type": "chat_message", - "message": { - "id": msg.id, - "chat_id": msg.chat_id, - "chat_type": ChatType.DIRECT, - "author_id": msg.author.pk, - "text": msg.text, - "created_at": msg.created_at.timestamp(), - }, - }, - ) - else: - # create new message - chat_id = event.content.chat_id - chat = await sync_to_async(ProjectChat.objects.get)(pk=chat_id) - # check that user is in this chat - users = await sync_to_async(chat.get_users)() - if self.user not in users: - raise UserNotInChatException( - f"User {self.user.id} is not in project chat {chat_id}" - ) - - msg = await sync_to_async(ProjectChatMessage.objects.create)( - reply_to=event.content.reply_to, - chat=chat, - author=self.user, - text=event.content.message, - ) - await self.channel_layer.group_send( - room_name, - { - "type": "chat_message", - "message": { - "id": msg.id, - "chat_id": msg.chat_id, - "chat_type": ChatType.PROJECT, - "author_id": msg.author.pk, - "text": msg.text, - "created_at": msg.created_at.timestamp(), - }, - }, - ) - pass + async def __process_new_direct_message_event(self, event): + # create new message + chat_id = event.content.chat_id + + try: + user1_id, user2_id = map(int, chat_id.split("_")) + except ValueError: + raise WrongChatIdException( + f'Chat id "{chat_id}" is not in the format of' + f" _, where user1_id < user2_id" + ) + if user1_id == self.user.id or user2_id == self.user.id: + other_user = await sync_to_async(CustomUser.objects.get)( + id=user1_id if user1_id != self.user.id else user2_id + ) + else: + raise NonMatchingDirectChatIdException( + f"User {self.user.id} is not a member of chat {chat_id}" + ) + + # if chat_id == 17_7, then chat_id will be == 7_17 + chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) + + # check if chat exists + try: + await sync_to_async(DirectChat.objects.get)(pk=chat_id) + except DirectChat.DoesNotExist: + # if not, create such chat + await sync_to_async(DirectChat.create_from_two_users)(self.user, other_user) + + try: + msg = await sync_to_async(DirectChatMessage.objects.create)( + chat_id=chat_id, + author=self.user, + text=event.content.message, + reply_to=event.content.reply_to, + ) + except ValidationError: + raise NonMatchingReplyChatIdException( + f"Message {event.content.reply_to} is not in chat {chat_id}" + ) + + # send message to user's channel + other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) + await self.channel_layer.send( + self.channel_name, + { + "type": "chat_message", + "message": { + "id": msg.id, + "chat_id": msg.chat_id, + "author_id": msg.author.pk, + "text": msg.text, + "created_at": msg.created_at.timestamp(), + }, + }, + ) + + if other_user_channel is None: + return + + await self.channel_layer.send( + other_user_channel, + { + "type": "chat_message", + "message": { + "id": msg.id, + "chat_id": msg.chat_id, + "chat_type": ChatType.DIRECT, + "author_id": msg.author.pk, + "text": msg.text, + "created_at": msg.created_at.timestamp(), + }, + }, + ) + + async def __process_new_project_message_event(self, event, room_name): + # create new message + chat_id = event.content.chat_id + chat = await sync_to_async(ProjectChat.objects.get)(pk=chat_id) + # check that user is in this chat + users = await sync_to_async(chat.get_users)() + if self.user not in users: + raise UserNotInChatException( + f"User {self.user.id} is not in project chat {chat_id}" + ) + + try: + # todo: may be pass chat_id=chat_id and move message creation to function + # that will raise NonMatchingReplyChatIdException ? + msg = await sync_to_async(ProjectChatMessage.objects.create)( + chat=chat, + author=self.user, + text=event.content.message, + reply_to=event.content.reply_to, + ) + except ValidationError: + raise NonMatchingReplyChatIdException( + f"Message {event.content.reply_to} is not in chat {chat_id}" + ) + + await self.channel_layer.group_send( + room_name, + { + "type": "chat_message", + "message": { + "id": msg.id, + "chat_id": msg.chat_id, + "chat_type": ChatType.PROJECT, + "author_id": msg.author.pk, + "text": msg.text, + "created_at": msg.created_at.timestamp(), + }, + }, + ) async def chat_message(self, event): await self.send(json.dumps(event)) diff --git a/chats/exceptions.py b/chats/exceptions.py index b46ef848..1529e12d 100644 --- a/chats/exceptions.py +++ b/chats/exceptions.py @@ -13,3 +13,7 @@ class WrongChatIdException(ChatException): class UserNotInChatException(ChatException): pass + + +class NonMatchingReplyChatIdException(ChatException): + pass diff --git a/chats/migrations/0002_directchatmessage_is_deleted_and_more.py b/chats/migrations/0002_directchatmessage_is_deleted_and_more.py new file mode 100644 index 00000000..e2a0f14d --- /dev/null +++ b/chats/migrations/0002_directchatmessage_is_deleted_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.3 on 2023-01-29 14:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="directchatmessage", + name="is_deleted", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="projectchatmessage", + name="is_deleted", + field=models.BooleanField(default=False), + ), + ] diff --git a/chats/models.py b/chats/models.py index b4fa02bf..9cc9c89d 100644 --- a/chats/models.py +++ b/chats/models.py @@ -2,14 +2,13 @@ from typing import List from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.db import models from projects.models import Project User = get_user_model() -id = models.AutoField(primary_key=True) - class BaseChat(models.Model): """ @@ -148,11 +147,12 @@ def get_chat(cls, user1, user2) -> "DirectChat": Returns: DirectChat: chat between two users """ - # TODO: use .get_or_create() here + + pk = "_".join(sorted([str(user1.pk), str(user2.pk)])) try: - return cls.objects.get(pk="_".join(sorted([str(user1.pk), str(user2.pk)]))) + return cls.objects.get(pk=pk) except cls.DoesNotExist: - chat = cls.objects.create(pk="_".join(sorted([str(user1.pk), str(user2.pk)]))) + chat = cls.objects.create(pk=pk) chat.users.set([user1, user2]) return chat @@ -174,12 +174,6 @@ def get_chat_id_from_users(cls, user1, user2): second_user = user2 if user1.pk < user2.pk else user1 return f"{first_user.pk}_{second_user.pk}" - # def save( - # self, force_insert=False, force_update=False, using=None, update_fields=None - # ): - # self.id = self.get_chat_id_from_users(*self.users.all()) - # super().save(force_insert, force_update, using, update_fields) - def __str__(self): return f"DirectChat with {self.get_users_str()}" @@ -193,21 +187,16 @@ class BaseMessage(models.Model): Base message model Attributes: - author: A ForeignKey referring to the User model. text: A TextField containing message text. is_read: A BooleanField indicating whether message is read. - # is_deleted: A BooleanField indicating whether message is deleted. + is_deleted: A BooleanField indicating whether message is deleted. created_at: A DateTimeField indicating date of creation. """ text = models.TextField(max_length=8192) is_read = models.BooleanField(default=False) - # is_deleted = models.BooleanField(default=False) + is_deleted = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) - # author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="messages") - # reply_to = models.ForeignKey( - # "self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies" - # ) def __str__(self): return f"Message<{self.pk}>" @@ -244,6 +233,11 @@ class ProjectChatMessage(BaseMessage): related_name="project_replies", ) + def clean(self): + # check that replied message is in the same chat + if self.reply_to and self.reply_to.chat != self.chat: + raise ValidationError("Reply to message from another chat") + def __str__(self): return f"ProjectChatMessage<{self.pk}>" @@ -277,6 +271,11 @@ class DirectChatMessage(BaseMessage): related_name="direct_replies", ) + def clean(self): + # check that replied message is in the same chat + if self.reply_to and self.reply_to.chat != self.chat: + raise ValidationError("Reply to message from another chat") + def __str__(self): return f"DirectChatMessage<{self.pk}>" diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py index e23dac13..48a89c54 100644 --- a/chats/websockets_settings.py +++ b/chats/websockets_settings.py @@ -9,10 +9,13 @@ class ChatType(str, Enum): class EventType(str, Enum): + # CHATS RELATED EVENTS NEW_MESSAGE = "new_message" - TYPING = "typing" - READ_MESSAGE = "read" DELETE_MESSAGE = "delete_message" + READ_MESSAGE = "read" + TYPING = "typing" + + # GENERAL EVENTS SET_ONLINE = "set_online" SET_OFFLINE = "set_offline" From fe0064a6f8bbd6938455f30e3e79b55b36a6d10f Mon Sep 17 00:00:00 2001 From: Yakser Date: Sun, 29 Jan 2023 19:51:45 +0400 Subject: [PATCH 74/87] Moved message creation to utils --- chats/consumers.py | 42 ++++++++++++++------------------------ chats/utils.py | 51 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 28 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 3a28f7af..75e070e7 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -4,14 +4,12 @@ from asgiref.sync import sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.core.cache import cache -from django.core.exceptions import ValidationError from chats.exceptions import ( NonMatchingDirectChatIdException, WrongChatIdException, ChatException, UserNotInChatException, - NonMatchingReplyChatIdException, ) from chats.models import ( BaseChat, @@ -20,7 +18,7 @@ ProjectChat, ProjectChatMessage, ) -from chats.utils import get_user_channel_cache_key +from chats.utils import get_user_channel_cache_key, create_message from chats.websockets_settings import ( Content, Event, @@ -158,17 +156,13 @@ async def __process_new_direct_message_event(self, event): # if not, create such chat await sync_to_async(DirectChat.create_from_two_users)(self.user, other_user) - try: - msg = await sync_to_async(DirectChatMessage.objects.create)( - chat_id=chat_id, - author=self.user, - text=event.content.message, - reply_to=event.content.reply_to, - ) - except ValidationError: - raise NonMatchingReplyChatIdException( - f"Message {event.content.reply_to} is not in chat {chat_id}" - ) + msg = await create_message( + chat_id=chat_id, + chat_model=DirectChatMessage, + author=self.user, + text=event.content.message, + reply_to=event.content.reply_to, + ) # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) @@ -215,19 +209,13 @@ async def __process_new_project_message_event(self, event, room_name): f"User {self.user.id} is not in project chat {chat_id}" ) - try: - # todo: may be pass chat_id=chat_id and move message creation to function - # that will raise NonMatchingReplyChatIdException ? - msg = await sync_to_async(ProjectChatMessage.objects.create)( - chat=chat, - author=self.user, - text=event.content.message, - reply_to=event.content.reply_to, - ) - except ValidationError: - raise NonMatchingReplyChatIdException( - f"Message {event.content.reply_to} is not in chat {chat_id}" - ) + msg = await create_message( + chat_id=chat_id, + chat_model=ProjectChatMessage, + author=self.user, + text=event.content.message, + reply_to=event.content.reply_to, + ) await self.channel_layer.group_send( room_name, diff --git a/chats/utils.py b/chats/utils.py index 7058e79f..316e67ca 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -1,3 +1,15 @@ +from typing import Union, Type + +from asgiref.sync import sync_to_async +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError + +from chats.exceptions import NonMatchingReplyChatIdException +from chats.models import DirectChatMessage, ProjectChatMessage + +User = get_user_model() + + def clean_message_text(text: str) -> str: """ Cleans message text. @@ -14,5 +26,42 @@ def validate_message_text(text: str) -> bool: return 0 < len(text) <= 8192 -def get_user_channel_cache_key(user) -> str: +def get_user_channel_cache_key(user: User) -> str: return f"user_channel_{user.pk}" + + +async def create_message( + chat_id: int, + chat_model: Union[Type[DirectChatMessage], Type[ProjectChatMessage]], + author: User, + text: str, + reply_to: int = None, +) -> Union[DirectChatMessage, ProjectChatMessage]: + """ + Creates message. + + Args: + chat_id: An integer representing chat id. + chat_model: A chat model. + author: A user instance. + text: A string representing message text. + reply_to: An integer representing message id to reply to. + + Returns: + A message instance. + + Raises: + NonMatchingReplyChatIdException: If reply_to message is not in chat. + """ + + try: + return await sync_to_async(chat_model.objects.create)( + chat_id=chat_id, + author=author, + text=text, + reply_to=reply_to, + ) + except ValidationError: + raise NonMatchingReplyChatIdException( + f"Message {reply_to} is not in chat {chat_id}" + ) From 13e0e56c1434862f4a1094b5af72a028f46a7272 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sun, 29 Jan 2023 20:06:07 +0400 Subject: [PATCH 75/87] Changed TIME_ZONE to Moscow --- procollab/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/procollab/settings.py b/procollab/settings.py index 1d67c0f9..1110a466 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -227,7 +227,7 @@ LANGUAGE_CODE = "ru-ru" -TIME_ZONE = "UTC" +TIME_ZONE = "Europe/Moscow" USE_I18N = True From 0b1371bf4823535ec2104b20191aafb7346d77fb Mon Sep 17 00:00:00 2001 From: Yakser Date: Sun, 29 Jan 2023 20:23:17 +0400 Subject: [PATCH 76/87] Little refactoring --- chats/consumers.py | 44 +++++++++++++++++++++++++++----------------- chats/utils.py | 2 +- projects/models.py | 6 +++--- projects/views.py | 4 +--- 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 75e070e7..781f74fc 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -56,6 +56,7 @@ async def connect(self): ) async for project_id in project_ids_list: # FIXME: if a user is a leader but not a collaborator, this doesn't work + # upd: it seems not possible to be a leader without being a collaborator # join room for each project # It's currently not possible to do this in a single call, # so we have to do it in a loop (e.g. that's O(N) calls to layer backend, redis cache that would be) @@ -74,6 +75,7 @@ async def disconnect(self, close_code): async def receive_json(self, content, **kwargs): """Receive message from WebSocket in JSON format""" + event = Event( type=content["type"], content=Content( @@ -108,28 +110,26 @@ async def receive_json(self, content, **kwargs): else: return self.disconnect(400) - async def __process_typing_event(self, content): - """Send typing event to room group.""" - pass - - async def __process_read_event(self, content): - """Send message read event to room group.""" - pass - async def __process_chat_related_event(self, event, room_name): if event.type == EventType.NEW_MESSAGE: await self.__process_new_message_event(event, room_name) + elif event.type == EventType.TYPING: + await self.__process_typing_event(event, room_name) + elif event.type == EventType.READ_MESSAGE: + await self.__process_read_message_event(event, room_name) async def __process_new_message_event(self, event, room_name): if event.content.chat_type == ChatType.DIRECT: await self.__process_new_direct_message_event(event) - else: + elif event.content.chat_type == ChatType.PROJECT: await self.__process_new_project_message_event(event, room_name) + else: + raise ValueError("Chat type is not supported") - async def __process_new_direct_message_event(self, event): - # create new message + async def __process_new_direct_message_event(self, event: Event): chat_id = event.content.chat_id + # check if chat_id is in the format of _ try: user1_id, user2_id = map(int, chat_id.split("_")) except ValueError: @@ -137,6 +137,8 @@ async def __process_new_direct_message_event(self, event): f'Chat id "{chat_id}" is not in the format of' f" _, where user1_id < user2_id" ) + + # check if user is a member of this chat and get other user if user1_id == self.user.id or user2_id == self.user.id: other_user = await sync_to_async(CustomUser.objects.get)( id=user1_id if user1_id != self.user.id else user2_id @@ -198,10 +200,10 @@ async def __process_new_direct_message_event(self, event): }, ) - async def __process_new_project_message_event(self, event, room_name): - # create new message + async def __process_new_project_message_event(self, event: Event, room_name: str): chat_id = event.content.chat_id chat = await sync_to_async(ProjectChat.objects.get)(pk=chat_id) + # check that user is in this chat users = await sync_to_async(chat.get_users)() if self.user not in users: @@ -232,16 +234,24 @@ async def __process_new_project_message_event(self, event, room_name): }, ) - async def chat_message(self, event): + async def __process_typing_event(self, event: Event, room_name: str): + """Send typing event to room group.""" + pass + + async def __process_read_message_event(self, event: Event, room_name: str): + """Send message read event to room group.""" + pass + + async def chat_message(self, event: Event): await self.send(json.dumps(event)) - async def set_online(self, event): + async def set_online(self, event: Event): await self.send(json.dumps(event)) - async def set_offline(self, event): + async def set_offline(self, event: Event): await self.send(json.dumps(event)) - async def __process_general_event(self, event, room_name): + async def __process_general_event(self, event: Event, room_name: str): cache_key = get_user_online_cache_key(self.user) if event.type == EventType.SET_ONLINE: cache.set(cache_key, True, ONE_DAY_IN_SECONDS) diff --git a/chats/utils.py b/chats/utils.py index 316e67ca..3a2685d3 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -31,7 +31,7 @@ def get_user_channel_cache_key(user: User) -> str: async def create_message( - chat_id: int, + chat_id: Union[str, int], chat_model: Union[Type[DirectChatMessage], Type[ProjectChatMessage]], author: User, text: str, diff --git a/projects/models.py b/projects/models.py index da902bf7..099a419f 100644 --- a/projects/models.py +++ b/projects/models.py @@ -121,9 +121,9 @@ class Collaborator(models.Model): Project collaborator model Attributes: - user: A ForeignKey referencing the user who is collaborating in the project - project: A ForeignKey referencing the project the user is collaborating in - role: A CharField meaning the role the user is fulfilling in the project + user: A ForeignKey referencing the user who is collaborating in the project. + project: A ForeignKey referencing the project the user is collaborating in. + role: A CharField meaning the role the user is fulfilling in the project. datetime_created: A DateTimeField indicating date of creation. datetime_updated: A DateTimeField indicating date of update. """ diff --git a/projects/views.py b/projects/views.py index 1ca9470f..e0196fb3 100644 --- a/projects/views.py +++ b/projects/views.py @@ -27,8 +27,6 @@ class ProjectList(generics.ListCreateAPIView): queryset = Project.objects.get_projects_for_list_view() serializer_class = ProjectListSerializer - # TODO: using this permission could result in a user not having verified email - # creating a project; probably should make IsUserVerifiedOrReadOnly permission_classes = [permissions.IsAuthenticatedOrReadOnly] filter_backends = (filters.DjangoFilterBackend,) filterset_class = ProjectFilter @@ -41,7 +39,7 @@ def create(self, request, *args, **kwargs): self.perform_create(serializer) headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=201, headers=headers) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def post(self, request, *args, **kwargs): """ From d92c92f373baa6f2c5ffffff3de01f613c8c53fd Mon Sep 17 00:00:00 2001 From: Yakser Date: Mon, 30 Jan 2023 01:03:59 +0400 Subject: [PATCH 77/87] Added delete message event Co-authored-by: Mikhail Khromov --- chats/consumers.py | 151 +++++++++++++++++++++++------------ chats/urls.py | 4 +- chats/views.py | 16 ++-- chats/websockets_settings.py | 4 +- 4 files changed, 116 insertions(+), 59 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 781f74fc..859a076a 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -20,7 +20,6 @@ ) from chats.utils import get_user_channel_cache_key, create_message from chats.websockets_settings import ( - Content, Event, EventType, EventGroupType, @@ -76,20 +75,8 @@ async def disconnect(self, close_code): async def receive_json(self, content, **kwargs): """Receive message from WebSocket in JSON format""" - event = Event( - type=content["type"], - content=Content( - **content.get( - "content", - { - "chat_id": None, - "message": None, - "chat_type": None, - "reply_to": None, - }, - ) - ), - ) + # todo reply_to key is not required + event = Event(type=content["type"], content=content.get("content")) # two event types - related to group chat and related to leave/connect if event.type in [ @@ -98,7 +85,7 @@ async def receive_json(self, content, **kwargs): EventType.READ_MESSAGE, EventType.DELETE_MESSAGE, ]: - room_name = f"{EventGroupType.CHATS_RELATED}_{event.content.chat_id}" + room_name = f"{EventGroupType.CHATS_RELATED}_{event.content.get('chat_id')}" try: await self.__process_chat_related_event(event, room_name) except ChatException as e: @@ -117,17 +104,19 @@ async def __process_chat_related_event(self, event, room_name): await self.__process_typing_event(event, room_name) elif event.type == EventType.READ_MESSAGE: await self.__process_read_message_event(event, room_name) + elif event.type == EventType.DELETE_MESSAGE: + await self.__process_delete_message_event(event, room_name) async def __process_new_message_event(self, event, room_name): - if event.content.chat_type == ChatType.DIRECT: + if event.content["chat_type"] == ChatType.DIRECT: await self.__process_new_direct_message_event(event) - elif event.content.chat_type == ChatType.PROJECT: + elif event.content["chat_type"] == ChatType.PROJECT: await self.__process_new_project_message_event(event, room_name) else: raise ValueError("Chat type is not supported") async def __process_new_direct_message_event(self, event: Event): - chat_id = event.content.chat_id + chat_id = event.content["chat_id"] # check if chat_id is in the format of _ try: @@ -162,18 +151,15 @@ async def __process_new_direct_message_event(self, event: Event): chat_id=chat_id, chat_model=DirectChatMessage, author=self.user, - text=event.content.message, - reply_to=event.content.reply_to, + text=event.content["message"], + reply_to=event.content["reply_to"], ) - # send message to user's channel - other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) - await self.channel_layer.send( - self.channel_name, + content = ( { - "type": "chat_message", - "message": { - "id": msg.id, + "type": EventType.NEW_MESSAGE, + "content": { + "message_id": msg.id, "chat_id": msg.chat_id, "author_id": msg.author.pk, "text": msg.text, @@ -181,27 +167,17 @@ async def __process_new_direct_message_event(self, event: Event): }, }, ) + # send message to user's channel + other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) + await self.channel_layer.send(self.channel_name, content) if other_user_channel is None: return - await self.channel_layer.send( - other_user_channel, - { - "type": "chat_message", - "message": { - "id": msg.id, - "chat_id": msg.chat_id, - "chat_type": ChatType.DIRECT, - "author_id": msg.author.pk, - "text": msg.text, - "created_at": msg.created_at.timestamp(), - }, - }, - ) + await self.channel_layer.send(other_user_channel, content) async def __process_new_project_message_event(self, event: Event, room_name: str): - chat_id = event.content.chat_id + chat_id = event.content["chat_id"] chat = await sync_to_async(ProjectChat.objects.get)(pk=chat_id) # check that user is in this chat @@ -215,16 +191,16 @@ async def __process_new_project_message_event(self, event: Event, room_name: str chat_id=chat_id, chat_model=ProjectChatMessage, author=self.user, - text=event.content.message, - reply_to=event.content.reply_to, + text=event.content["message"], + reply_to=event.content["reply_to"], ) await self.channel_layer.group_send( room_name, { - "type": "chat_message", - "message": { - "id": msg.id, + "type": EventType.NEW_MESSAGE, + "content": { + "message_id": msg.id, "chat_id": msg.chat_id, "chat_type": ChatType.PROJECT, "author_id": msg.author.pk, @@ -242,7 +218,84 @@ async def __process_read_message_event(self, event: Event, room_name: str): """Send message read event to room group.""" pass - async def chat_message(self, event: Event): + async def __process_delete_message_event(self, event: Event, room_name: str): + if event.content["chat_type"] == ChatType.DIRECT: + await self.__process_delete_direct_message_event(event) + elif event.content["chat_type"] == ChatType.PROJECT: + await self.__process_delete_project_message_event(event, room_name) + + async def __process_delete_direct_message_event(self, event): + message_id = event.content["message_id"] + + message = await sync_to_async(DirectChatMessage.objects.get)(pk=message_id) + message.is_deleted = True + await sync_to_async(message.save)() + + chat_id = event.content["chat_id"] + + # check if chat_id is in the format of _ + try: + user1_id, user2_id = map(int, chat_id.split("_")) + except ValueError: + raise WrongChatIdException( + f'Chat id "{chat_id}" is not in the format of' + f" _, where user1_id < user2_id" + ) + + # check if user is a member of this chat and get other user + if user1_id == self.user.id or user2_id == self.user.id: + other_user = await sync_to_async(CustomUser.objects.get)( + id=user1_id if user1_id != self.user.id else user2_id + ) + else: + raise NonMatchingDirectChatIdException( + f"User {self.user.id} is not a member of chat {chat_id}" + ) + + # send message to user's channel + other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) + content = { + "type": EventType.DELETE_MESSAGE, + "content": { + "message_id": message_id, + }, + } + await self.channel_layer.send(self.channel_name, content) + + if other_user_channel is None: + return + + await self.channel_layer.send(other_user_channel, content) + + async def __process_delete_project_message_event(self, event: Event, room_name: str): + chat_id = event.content["chat_id"] + chat = await sync_to_async(ProjectChat.objects.get)(pk=chat_id) + + # check that user is in this chat + users = await sync_to_async(chat.get_users)() + if self.user not in users: + raise UserNotInChatException( + f"User {self.user.id} is not in project chat {chat_id}" + ) + + message = await sync_to_async(ProjectChatMessage.objects.get)( + pk=event.content["message_id"] + ) + message.is_deleted = True + await sync_to_async(message.save)() + + await self.channel_layer.group_send( + room_name, + { + "type": EventType.NEW_MESSAGE, + "content": {"message_id": event.content["message_id"]}, + }, + ) + + async def new_message(self, event: Event): + await self.send(json.dumps(event)) + + async def delete_message(self, event: Event): await self.send(json.dumps(event)) async def set_online(self, event: Event): diff --git a/chats/urls.py b/chats/urls.py index af5ff0b0..a66949ae 100644 --- a/chats/urls.py +++ b/chats/urls.py @@ -17,12 +17,12 @@ path("projects/", ProjectChatList.as_view(), name="project-chat-list"), path("projects//", ProjectChatDetail.as_view(), name="project-chat-detail"), path( - "directs/messages/", + "directs//messages/", DirectChatMessageList.as_view(), name="direct-chat-messages", ), path( - "projects/messages/", + "projects//messages/", ProjectChatMessageList.as_view(), name="project-chat-messages", ), diff --git a/chats/views.py b/chats/views.py index b3650acb..3e9c3a47 100644 --- a/chats/views.py +++ b/chats/views.py @@ -96,9 +96,11 @@ class DirectChatMessageList(ListCreateAPIView): permission_classes = [IsAuthenticated] def get_queryset(self): - return self.request.user.direct_chats.get( - id=self.kwargs["chat_id"] - ).messages.all() + return ( + self.request.user.direct_chats.get(id=self.kwargs["pk"]) + .messages.filter(is_deleted=False) + .all() + ) class ProjectChatMessageList(ListCreateAPIView): @@ -106,9 +108,11 @@ class ProjectChatMessageList(ListCreateAPIView): permission_classes = [IsAuthenticated] def get_queryset(self): - return self.request.user.project_chats.get( - id=self.kwargs["chat_id"] - ).messages.all() + return ( + ProjectChat.objects.get(id=self.kwargs["pk"]) + .messages.filter(is_deleted=False) + .all() + ) def post(self, request, *args, **kwargs): # TODO: try to create a message in a chat. If chat doesn't exist, create it and then create a message. diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py index 48a89c54..b9371b21 100644 --- a/chats/websockets_settings.py +++ b/chats/websockets_settings.py @@ -26,7 +26,7 @@ class EventGroupType(str, Enum): @dataclass(slots=True, frozen=True) -class Content: +class NewMessageEventContent: chat_id: Optional[str] chat_type: Optional[Union[ChatType.DIRECT, ChatType.PROJECT]] message: Optional[str] @@ -36,4 +36,4 @@ class Content: @dataclass(slots=True, frozen=True) class Event: type: EventType - content: Optional[Content] + content: dict From a73b3db10df2826e9c34f382b457cb020507bbd8 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Mon, 30 Jan 2023 00:20:55 +0300 Subject: [PATCH 78/87] maybe working message read/typing Co-authored-by: Yakser --- chats/consumers.py | 133 +++++++++++++++++++++++------------ chats/utils.py | 30 +++++++- chats/websockets_settings.py | 4 +- 3 files changed, 120 insertions(+), 47 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 859a076a..64ede587 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -1,12 +1,13 @@ +import datetime import json from typing import Optional from asgiref.sync import sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.core.cache import cache +from django.utils import timezone from chats.exceptions import ( - NonMatchingDirectChatIdException, WrongChatIdException, ChatException, UserNotInChatException, @@ -18,7 +19,11 @@ ProjectChat, ProjectChatMessage, ) -from chats.utils import get_user_channel_cache_key, create_message +from chats.utils import ( + get_user_channel_cache_key, + create_message, + get_chat_and_user_ids_from_content, +) from chats.websockets_settings import ( Event, EventType, @@ -116,26 +121,9 @@ async def __process_new_message_event(self, event, room_name): raise ValueError("Chat type is not supported") async def __process_new_direct_message_event(self, event: Event): - chat_id = event.content["chat_id"] - - # check if chat_id is in the format of _ - try: - user1_id, user2_id = map(int, chat_id.split("_")) - except ValueError: - raise WrongChatIdException( - f'Chat id "{chat_id}" is not in the format of' - f" _, where user1_id < user2_id" - ) - - # check if user is a member of this chat and get other user - if user1_id == self.user.id or user2_id == self.user.id: - other_user = await sync_to_async(CustomUser.objects.get)( - id=user1_id if user1_id != self.user.id else user2_id - ) - else: - raise NonMatchingDirectChatIdException( - f"User {self.user.id} is not a member of chat {chat_id}" - ) + chat_id, other_user = await get_chat_and_user_ids_from_content( + event.content, self.user + ) # if chat_id == 17_7, then chat_id will be == 7_17 chat_id = DirectChat.get_chat_id_from_users(self.user, other_user) @@ -212,11 +200,79 @@ async def __process_new_project_message_event(self, event: Event, room_name: str async def __process_typing_event(self, event: Event, room_name: str): """Send typing event to room group.""" - pass + await self.channel_layer.group_send( + room_name, + { + "type": EventType.TYPING, + "content": { + "chat_id": event.content["chat_id"], + "chat_type": event.content["chat_type"], + "user_id": self.user.id, + "end_time": ( + timezone.now() + datetime.timedelta(seconds=5) + ).timestamp(), + }, + }, + ) async def __process_read_message_event(self, event: Event, room_name: str): """Send message read event to room group.""" - pass + if event.content["chat_type"] == ChatType.DIRECT: + chat_id, other_user = await get_chat_and_user_ids_from_content( + event.content, self.user + ) + msg = await sync_to_async(DirectChatMessage.objects.get)( + pk=event.content["message_id"] + ) + if msg.chat_id != chat_id or msg.author_id != other_user: + raise WrongChatIdException( + "Some of chat/message ids are wrong, you can't access this message" + ) + msg.is_read = True + await sync_to_async(msg.save)() + # send 2 events to user's channel + other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) + json_thingy = { + "type": EventType.READ_MESSAGE, + "content": { + "chat_id": event.content["chat_id"], + "chat_type": event.content["chat_type"], + "user_id": self.user.id, + "message_id": event.content["message_id"], + }, + } + await self.channel_layer.send(self.channel_name, json_thingy) + if other_user_channel is None: + return + await self.channel_layer.send(other_user_channel, json_thingy) + elif event.content["chat_type"] == ChatType.PROJECT: + msg = await sync_to_async(ProjectChatMessage.objects.get)( + pk=event.content["message_id"] + ) + # check that user is in this chat + users = await sync_to_async(msg.chat.get_users)() + if self.user not in users: + raise UserNotInChatException( + f"User {self.user.id} is not in project chat {msg.chat_id}" + ) + if msg.chat_id != event.content["chat_id"]: + raise WrongChatIdException( + "Some of chat/message ids are wrong, you can't access this message" + ) + msg.is_read = True + await sync_to_async(msg.save)() + await self.channel_layer.group_send( + room_name, + { + "type": EventType.READ_MESSAGE, + "content": { + "chat_id": event.content["chat_id"], + "chat_type": event.content["chat_type"], + "user_id": self.user.id, + "message_id": event.content["message_id"], + }, + }, + ) async def __process_delete_message_event(self, event: Event, room_name: str): if event.content["chat_type"] == ChatType.DIRECT: @@ -231,26 +287,9 @@ async def __process_delete_direct_message_event(self, event): message.is_deleted = True await sync_to_async(message.save)() - chat_id = event.content["chat_id"] - - # check if chat_id is in the format of _ - try: - user1_id, user2_id = map(int, chat_id.split("_")) - except ValueError: - raise WrongChatIdException( - f'Chat id "{chat_id}" is not in the format of' - f" _, where user1_id < user2_id" - ) - - # check if user is a member of this chat and get other user - if user1_id == self.user.id or user2_id == self.user.id: - other_user = await sync_to_async(CustomUser.objects.get)( - id=user1_id if user1_id != self.user.id else user2_id - ) - else: - raise NonMatchingDirectChatIdException( - f"User {self.user.id} is not a member of chat {chat_id}" - ) + chat_id, other_user = await get_chat_and_user_ids_from_content( + event.content, self.user + ) # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) @@ -292,6 +331,12 @@ async def __process_delete_project_message_event(self, event: Event, room_name: }, ) + async def message_read(self, event: Event): + await self.send(json.dumps(event)) + + async def user_typing(self, event: Event): + await self.send(json.dumps(event)) + async def new_message(self, event: Event): await self.send(json.dumps(event)) diff --git a/chats/utils.py b/chats/utils.py index 3a2685d3..f5427a83 100644 --- a/chats/utils.py +++ b/chats/utils.py @@ -4,7 +4,11 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError -from chats.exceptions import NonMatchingReplyChatIdException +from chats.exceptions import ( + NonMatchingReplyChatIdException, + WrongChatIdException, + NonMatchingDirectChatIdException, +) from chats.models import DirectChatMessage, ProjectChatMessage User = get_user_model() @@ -65,3 +69,27 @@ async def create_message( raise NonMatchingReplyChatIdException( f"Message {reply_to} is not in chat {chat_id}" ) + + +async def get_chat_and_user_ids_from_content(content, current_user) -> tuple[str, User]: + chat_id = content["chat_id"] + + # check if chat_id is in the format of _ + try: + user1_id, user2_id = map(int, chat_id.split("_")) + except ValueError: + raise WrongChatIdException( + f'Chat id "{chat_id}" is not in the format of' + f" _, where user1_id < user2_id" + ) + + # check if user is a member of this chat and get other user + if user1_id == current_user.id or user2_id == current_user.id: + other_user = await sync_to_async(User.objects.get)( + id=user1_id if user1_id != current_user.id else user2_id + ) + else: + raise NonMatchingDirectChatIdException( + f"User {current_user.id} is not a member of chat {chat_id}" + ) + return chat_id, other_user diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py index b9371b21..cf9e8a25 100644 --- a/chats/websockets_settings.py +++ b/chats/websockets_settings.py @@ -12,8 +12,8 @@ class EventType(str, Enum): # CHATS RELATED EVENTS NEW_MESSAGE = "new_message" DELETE_MESSAGE = "delete_message" - READ_MESSAGE = "read" - TYPING = "typing" + READ_MESSAGE = "message_read" + TYPING = "user_typing" # GENERAL EVENTS SET_ONLINE = "set_online" From f474cc48989f75ae4dd934ea32cd04890b86db80 Mon Sep 17 00:00:00 2001 From: Yakser Date: Mon, 30 Jan 2023 01:32:40 +0400 Subject: [PATCH 79/87] Finished read/typing events Co-authored-by: Mikhail Khromov --- chats/consumers.py | 3 ++- chats/serializers.py | 6 +++--- users/serializers.py | 5 ++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 64ede587..8c4762a4 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -249,8 +249,9 @@ async def __process_read_message_event(self, event: Event, room_name: str): msg = await sync_to_async(ProjectChatMessage.objects.get)( pk=event.content["message_id"] ) + chat = await sync_to_async(ProjectChat.objects.get)(pk=msg.chat_id) # check that user is in this chat - users = await sync_to_async(msg.chat.get_users)() + users = await sync_to_async(chat.get_users)() if self.user not in users: raise UserNotInChatException( f"User {self.user.id} is not in project chat {msg.chat_id}" diff --git a/chats/serializers.py b/chats/serializers.py index 04c63e74..edf6c367 100644 --- a/chats/serializers.py +++ b/chats/serializers.py @@ -10,11 +10,11 @@ class DirectChatListSerializer(serializers.ModelSerializer): @classmethod def get_users(cls, chat: ProjectChat): - return chat.get_users() + return UserListSerializer(chat.get_users(), many=True).data @classmethod def get_last_message(cls, chat: DirectChat): - return chat.get_last_message() + return DirectChatMessageListSerializer(chat.get_last_message()).data class Meta: model = DirectChat @@ -46,7 +46,7 @@ class ProjectChatListSerializer(serializers.ModelSerializer): @classmethod def get_last_message(cls, chat: ProjectChat): - return chat.get_last_message() + return ProjectChatMessageListSerializer(chat.get_last_message()).data class Meta: model = ProjectChat diff --git a/users/serializers.py b/users/serializers.py index 5cfa6f24..27a2fbe4 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -193,13 +193,12 @@ class UserListSerializer(serializers.ModelSerializer): is_online = serializers.SerializerMethodField() @classmethod - def get_is_online(cls, user: CustomUser): + def get_is_online(cls, user: CustomUser) -> bool: cache_key = get_user_online_cache_key(user) - print(cache_key) is_online = cache.get(cache_key, False) return is_online - def create(self, validated_data): + def create(self, validated_data) -> CustomUser: user = CustomUser(**validated_data) user.set_password(validated_data["password"]) user.save() From efa34fa806532f2ea52173e3d8f9c3bcd25ea5f0 Mon Sep 17 00:00:00 2001 From: Yakser Date: Mon, 30 Jan 2023 12:50:30 +0400 Subject: [PATCH 80/87] Fixed content assignment --- chats/consumers.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/chats/consumers.py b/chats/consumers.py index 8c4762a4..d41df1ae 100644 --- a/chats/consumers.py +++ b/chats/consumers.py @@ -143,18 +143,16 @@ async def __process_new_direct_message_event(self, event: Event): reply_to=event.content["reply_to"], ) - content = ( - { - "type": EventType.NEW_MESSAGE, - "content": { - "message_id": msg.id, - "chat_id": msg.chat_id, - "author_id": msg.author.pk, - "text": msg.text, - "created_at": msg.created_at.timestamp(), - }, + content = { + "type": EventType.NEW_MESSAGE, + "content": { + "message_id": msg.id, + "chat_id": msg.chat_id, + "author_id": msg.author.pk, + "text": msg.text, + "created_at": msg.created_at.timestamp(), }, - ) + } # send message to user's channel other_user_channel = cache.get(get_user_channel_cache_key(other_user), None) await self.channel_layer.send(self.channel_name, content) From 01ec775989d5b8dfa289cf66b382d03826484b80 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Wed, 8 Feb 2023 16:07:21 +0300 Subject: [PATCH 81/87] probably working reply_to constraint migration, but doesn't work on sqlite3 --- chats/migrations/0003_reply_to_constaint.py | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 chats/migrations/0003_reply_to_constaint.py diff --git a/chats/migrations/0003_reply_to_constaint.py b/chats/migrations/0003_reply_to_constaint.py new file mode 100644 index 00000000..0b27e96a --- /dev/null +++ b/chats/migrations/0003_reply_to_constaint.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.3 on 2023-02-08 12:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("chats", "0002_directchatmessage_is_deleted_and_more"), + ] + + operations = [ + # migrations.RunSQL( + # sql=""" + # ALTER TABLE chats_directchatmessage + # ADD CONSTRAINT my_constraint + # CHECK ( + # (chats_directchatmessage.reply_to_id IS NULL) OR + # (chats_directchatmessage.chat_id = (SELECT chats_directchatmessage.chat_id FROM chats_directchatmessage WHERE chats_directchatmessage.id = chats_directchatmessage.reply_to_id)) + # ) + # """, + # reverse_sql=""" + # ALTER TABLE chats_directchatmessage + # DROP CONSTRAINT my_constraint + # """) + ] + From f71a5867cbd6b54dfadb7d401eebbc63fdad7fd9 Mon Sep 17 00:00:00 2001 From: VeryBigSad Date: Thu, 23 Feb 2023 16:03:37 +0300 Subject: [PATCH 82/87] adding structure for docs --- README.md | 4 ++++ docs/chats.md | 0 docs/readme.md | 7 +++++++ 3 files changed, 11 insertions(+) create mode 100644 docs/chats.md create mode 100644 docs/readme.md diff --git a/README.md b/README.md index b133c4de..c0c63aa9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Procollab backend service +## Документация + +[Здесь](/docs/readme.md) + ## Usage ### Clone project diff --git a/docs/chats.md b/docs/chats.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/readme.md b/docs/readme.md new file mode 100644 index 00000000..74c72713 --- /dev/null +++ b/docs/readme.md @@ -0,0 +1,7 @@ +# Документация + +### REST API +Чтобы посмотреть документацию через swagger, включите проект и зайдите на http://localhost:8000/swagger + +### WebSockets для чатов +[Вот здесь](/docs/chats.md) From d2865f74f3bb74b28c80e29f7d981d5fae3446a9 Mon Sep 17 00:00:00 2001 From: VeryBigSad Date: Thu, 23 Feb 2023 16:23:35 +0300 Subject: [PATCH 83/87] chats docs --- docs/chats.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/chats.md b/docs/chats.md index e69de29b..db335471 100644 --- a/docs/chats.md +++ b/docs/chats.md @@ -0,0 +1,38 @@ +# Документация по вебсокетам чатов + +## Общая инфа +URL для всего вебсокет-релейтед - `/ws/` + +В данный момент есть только 1 Consumer (т.е. View, но для вебсокетов). Это ChatsConsumer, живет на `/ws/chats/`. + +# ChatsConsumer +`/ws/chats/` + +### Подключение +Чтобы законнектиться, укажите в хедерах авторизацию по Bearer токену (как и для всех других запросов в REST API). + +### Events +Есть два типа ивентов, которые можно кидать - general events и chat-related events. Первые состоят только из user_online и user_offline, вторые содержат все остальное: новое сообщение, печатание, чтение и удаление (пока без редактирования) + +Структура любого Event, который должен кидаться на вебсокет выглядит так: +```py +class Event: + type: EventType + content: dict +``` +И соответсвенно EventType вот такой: +```py +# эти строки указывать в {"type": event_type} + +class EventType(str, Enum): + # CHATS RELATED EVENTS + NEW_MESSAGE = "new_message" + DELETE_MESSAGE = "delete_message" + READ_MESSAGE = "message_read" + TYPING = "user_typing" + + # GENERAL EVENTS + SET_ONLINE = "set_online" + SET_OFFLINE = "set_offline" +``` + From 1f5ed51f06ccf0a9f74e9966142fb6ae3ac2f332 Mon Sep 17 00:00:00 2001 From: Mikhail Khromov Date: Thu, 23 Feb 2023 22:14:31 +0300 Subject: [PATCH 84/87] =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/chats.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/chats.md b/docs/chats.md index db335471..819febb6 100644 --- a/docs/chats.md +++ b/docs/chats.md @@ -20,7 +20,7 @@ class Event: type: EventType content: dict ``` -И соответсвенно EventType вот такой: +И соответственно EventType вот такой: ```py # эти строки указывать в {"type": event_type} @@ -35,4 +35,42 @@ class EventType(str, Enum): SET_ONLINE = "set_online" SET_OFFLINE = "set_offline" ``` +Пример того, как выглядит Event на новое сообщение +```json +{ + "type": "new_message", + "content": { + "chat_type": "direct", + "chat_id": "12_23", + "message": "hello world", + "reply_to": 54 + } +} +``` + +## Методы e.g. Ивенты + +### SET_ONLINE/SET_OFFLINE +Без параметров. + +### NEW_MESSAGE +- `chat_type: str`\ +`"direct"` или `"project"`, зависит от типа чата +- `chat_id: int/str`\ +Если тип `"project"`, то тип будет `int` и это айди проекта, которому принадлежит чат. Если тип `"direct"`, то это `str`. Выглядит как `{user1_id}_{user2_id}`, **где первое число всегда меньше второго**. +- `message: str` текст сообщения +- `reply_to: Optional[int]` айди сообщения, на которое кидается ответ. Если его нет, то обязательно кидать `None` + +### TYPING +- `chat_type` см выше +- `chat_id` см выше + +### READ_MESSAGE +- `chat_type` см выше +- `chat_id` см выше +- `message_id: int` айди сообщение, которое прочитали +### DELETE_MESSAGE +- `chat_type` см выше +- `chat_id` см выше +- `message_id: int` айди сообщения, которое удаляем From 83fa88db45f234523e1839bbff871f7b19d70b05 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sat, 25 Feb 2023 01:03:44 +0300 Subject: [PATCH 85/87] Finished simple stupid user recommendation --- projects/{helpers.py => constants.py} | 2 ++ projects/models.py | 2 +- projects/permissions.py | 16 +++++++++++- projects/urls.py | 2 ++ projects/views.py | 37 ++++++++++++++++++++++++++- users/managers.py | 5 ++++ 6 files changed, 61 insertions(+), 3 deletions(-) rename projects/{helpers.py => constants.py} (89%) diff --git a/projects/helpers.py b/projects/constants.py similarity index 89% rename from projects/helpers.py rename to projects/constants.py index a7906e91..0dd9684e 100644 --- a/projects/helpers.py +++ b/projects/constants.py @@ -5,3 +5,5 @@ (3, "Первые продажи"), (4, "Масштабирование"), ) + +RECOMMENDATIONS_COUNT = 5 diff --git a/projects/models.py b/projects/models.py index 099a419f..852d8e31 100644 --- a/projects/models.py +++ b/projects/models.py @@ -4,7 +4,7 @@ from django.db import models from django.db.models import UniqueConstraint from industries.models import Industry -from projects.helpers import VERBOSE_STEPS +from projects.constants import VERBOSE_STEPS from projects.managers import AchievementManager, ProjectManager from users.models import CustomUser diff --git a/projects/permissions.py b/projects/permissions.py index 3cad987c..88913193 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -17,7 +17,22 @@ def has_object_permission(self, request, view, obj): or obj.collaborator_set.filter(user=request.user).exists() or obj.invite_set.filter(user=request.user).exists() ): + return True + return False + + +class IsProjectLeader(BasePermission): + """ + Allows access to get only to project leader. + """ + def has_permission(self, request, view) -> bool: + if request.user and request.user.id: + return True + return False + + def has_object_permission(self, request, view, obj): + if obj.leader == request.user: return True return False @@ -44,6 +59,5 @@ def has_object_permission(self, request, view, obj): or obj.collaborator_set.filter(user=request.user).exists() or obj.invite_set.filter(user=request.user).exists() ): - return True return False diff --git a/projects/urls.py b/projects/urls.py index 124bc9a2..2cd9be0e 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -9,6 +9,7 @@ ProjectCollaborators, ProjectCountView, ProjectVacancyResponses, + ProjectRecommendedUsers, ) app_name = "projects" @@ -17,6 +18,7 @@ path("", ProjectList.as_view()), path("/collaborators/", ProjectCollaborators.as_view()), path("/", ProjectDetail.as_view()), + path("/recommended_users", ProjectRecommendedUsers.as_view()), path("count/", ProjectCountView.as_view()), path("steps/", ProjectSteps.as_view()), path("achievements/", AchievementList.as_view()), diff --git a/projects/views.py b/projects/views.py index e0196fb3..84a9aefe 100644 --- a/projects/views.py +++ b/projects/views.py @@ -1,3 +1,6 @@ +from random import sample + +from django.contrib.auth import get_user_model from django.db.models import Q from django_filters import rest_framework as filters from rest_framework import generics, permissions, status @@ -7,11 +10,12 @@ from core.permissions import IsStaffOrReadOnly from projects.filters import ProjectFilter -from projects.helpers import VERBOSE_STEPS +from projects.constants import VERBOSE_STEPS, RECOMMENDATIONS_COUNT from projects.models import Project, Achievement from projects.permissions import ( IsProjectLeaderOrReadOnlyForNonDrafts, HasInvolvementInProjectOrReadOnly, + IsProjectLeader, ) from projects.serializers import ( ProjectDetailSerializer, @@ -20,9 +24,12 @@ AchievementDetailSerializer, ProjectCollaboratorSerializer, ) +from users.serializers import UserListSerializer from vacancy.models import VacancyResponse from vacancy.serializers import VacancyResponseListSerializer +User = get_user_model() + class ProjectList(generics.ListCreateAPIView): queryset = Project.objects.get_projects_for_list_view() @@ -98,6 +105,34 @@ def put(self, request, pk, **kwargs): return super(ProjectDetail, self).put(request, pk) +class ProjectRecommendedUsers(generics.RetrieveAPIView): + queryset = Project.objects.all() + permission_classes = [IsProjectLeader] + serializer_class = UserListSerializer + + def get(self, request, pk, **kwargs): + project = self.get_object() + # fixme: store key_skills and required_skills more convenient, not just as a string' + + all_needed_skills = set() + for vacancy in project.vacancies.all(): + all_needed_skills.update(set(vacancy.required_skills.lower().split(","))) + + recommended_users = [] + for user in User.objects.get_members(): + if user == request.user or not user.key_skills: + continue + skills = set(user.key_skills.lower().split(",")) + if skills.intersection(all_needed_skills): + recommended_users.append(user) + sampled_recommended_users = sample( + recommended_users, min(RECOMMENDATIONS_COUNT, len(recommended_users)) + ) + + serializer = self.get_serializer(sampled_recommended_users, many=True) + return Response(status=status.HTTP_200_OK, data=serializer.data) + + class ProjectCountView(generics.GenericAPIView): queryset = Project.objects.get_projects_for_count_view() serializer_class = ProjectListSerializer diff --git a/users/managers.py b/users/managers.py index 396faf09..8a642d54 100644 --- a/users/managers.py +++ b/users/managers.py @@ -2,6 +2,8 @@ from django.contrib.auth.models import UserManager from django.db.models import Manager +from users.helpers import MEMBER + class CustomUserManager(UserManager): def create_user(self, email, password=None, **extra_fields): @@ -39,6 +41,9 @@ def get_users_for_detail_view(self): .all() ) + def get_members(self): + return self.get_queryset().filter(user_type=MEMBER) + def _create_user(self, email, password, **extra_fields): email = self.normalize_email(email) user = self.model(email=email, **extra_fields) From 187411c2fde477ac85346c04c05f6e848cd80b00 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sat, 25 Feb 2023 01:08:37 +0300 Subject: [PATCH 86/87] Edited comments --- projects/views.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/projects/views.py b/projects/views.py index 84a9aefe..d5840f5f 100644 --- a/projects/views.py +++ b/projects/views.py @@ -112,8 +112,8 @@ class ProjectRecommendedUsers(generics.RetrieveAPIView): def get(self, request, pk, **kwargs): project = self.get_object() - # fixme: store key_skills and required_skills more convenient, not just as a string' + # fixme: store key_skills and required_skills more convenient, not just as a string all_needed_skills = set() for vacancy in project.vacancies.all(): all_needed_skills.update(set(vacancy.required_skills.lower().split(","))) @@ -122,9 +122,12 @@ def get(self, request, pk, **kwargs): for user in User.objects.get_members(): if user == request.user or not user.key_skills: continue + skills = set(user.key_skills.lower().split(",")) if skills.intersection(all_needed_skills): recommended_users.append(user) + + # get some random users sampled_recommended_users = sample( recommended_users, min(RECOMMENDATIONS_COUNT, len(recommended_users)) ) @@ -136,8 +139,6 @@ def get(self, request, pk, **kwargs): class ProjectCountView(generics.GenericAPIView): queryset = Project.objects.get_projects_for_count_view() serializer_class = ProjectListSerializer - # TODO: using this permission could result in a user not having verified email - # creating a project; probably should make IsUserVerifiedOrReadOnly permission_classes = [permissions.IsAuthenticated] def get(self, request): From ee6c7f40594117ee61e419ca317b4a517d2522b8 Mon Sep 17 00:00:00 2001 From: Yakser Date: Sat, 25 Feb 2023 20:27:38 +0300 Subject: [PATCH 87/87] Moved recommendations logic to helpers --- projects/helpers.py | 35 +++++++++++++++++++++++++++++++++++ projects/views.py | 28 ++++------------------------ 2 files changed, 39 insertions(+), 24 deletions(-) create mode 100644 projects/helpers.py diff --git a/projects/helpers.py b/projects/helpers.py new file mode 100644 index 00000000..47b43cf5 --- /dev/null +++ b/projects/helpers.py @@ -0,0 +1,35 @@ +from random import sample + +from django.contrib.auth import get_user_model + +from projects.constants import RECOMMENDATIONS_COUNT +from projects.models import Project + +User = get_user_model() + + +def get_recommended_users(project: Project) -> list[User]: + """ + Searches for users by matching their key_skills and vacancies required_skills + """ + + # fixme: store key_skills and required_skills more convenient, not just as a string + all_needed_skills = set() + for vacancy in project.vacancies.all(): + all_needed_skills.update(set(vacancy.required_skills.lower().split(","))) + + recommended_users = [] + for user in User.objects.get_members(): + if user == project.leader or not user.key_skills: + continue + + skills = set(user.key_skills.lower().split(",")) + if skills.intersection(all_needed_skills): + recommended_users.append(user) + + # get some random users + sampled_recommended_users = sample( + recommended_users, min(RECOMMENDATIONS_COUNT, len(recommended_users)) + ) + + return sampled_recommended_users diff --git a/projects/views.py b/projects/views.py index d5840f5f..2bf9d5e0 100644 --- a/projects/views.py +++ b/projects/views.py @@ -1,5 +1,3 @@ -from random import sample - from django.contrib.auth import get_user_model from django.db.models import Q from django_filters import rest_framework as filters @@ -10,7 +8,8 @@ from core.permissions import IsStaffOrReadOnly from projects.filters import ProjectFilter -from projects.constants import VERBOSE_STEPS, RECOMMENDATIONS_COUNT +from projects.constants import VERBOSE_STEPS +from projects.helpers import get_recommended_users from projects.models import Project, Achievement from projects.permissions import ( IsProjectLeaderOrReadOnlyForNonDrafts, @@ -112,27 +111,8 @@ class ProjectRecommendedUsers(generics.RetrieveAPIView): def get(self, request, pk, **kwargs): project = self.get_object() - - # fixme: store key_skills and required_skills more convenient, not just as a string - all_needed_skills = set() - for vacancy in project.vacancies.all(): - all_needed_skills.update(set(vacancy.required_skills.lower().split(","))) - - recommended_users = [] - for user in User.objects.get_members(): - if user == request.user or not user.key_skills: - continue - - skills = set(user.key_skills.lower().split(",")) - if skills.intersection(all_needed_skills): - recommended_users.append(user) - - # get some random users - sampled_recommended_users = sample( - recommended_users, min(RECOMMENDATIONS_COUNT, len(recommended_users)) - ) - - serializer = self.get_serializer(sampled_recommended_users, many=True) + recommended_users = get_recommended_users(project) + serializer = self.get_serializer(recommended_users, many=True) return Response(status=status.HTTP_200_OK, data=serializer.data)