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/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/metrics/admin.py b/chats/__init__.py similarity index 100% rename from metrics/admin.py rename to chats/__init__.py diff --git a/chats/admin.py b/chats/admin.py new file mode 100644 index 00000000..8759ddc8 --- /dev/null +++ b/chats/admin.py @@ -0,0 +1,40 @@ +from django.contrib import admin + +from chats.models import ProjectChat, DirectChat, ProjectChatMessage, DirectChatMessage + + +@admin.display(description="Пользователи чата") +def chat_users(obj): + return f"{obj.get_users_str()}" + + +@admin.display(description="Количество сообщений") +def chat_message_count(obj): + return obj.messages.count() + + +@admin.register(ProjectChat) +class ChatAdmin(admin.ModelAdmin): + 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", chat_users, chat_message_count, "created_at") + list_display_links = ("id",) + + +@admin.register(ProjectChatMessage) +class ProjectChatMessageAdmin(admin.ModelAdmin): + list_display = ("id", "author", "chat", "created_at") + list_display_links = ("id", "author", "chat") + + +@admin.register(DirectChatMessage) +class DirectChatMessageAdmin(admin.ModelAdmin): + list_display = ("id", "author", "chat", "created_at") + list_display_links = ("id", "author", "chat") 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/consumers.py b/chats/consumers.py new file mode 100644 index 00000000..d41df1ae --- /dev/null +++ b/chats/consumers.py @@ -0,0 +1,376 @@ +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 ( + WrongChatIdException, + ChatException, + UserNotInChatException, +) +from chats.models import ( + BaseChat, + DirectChatMessage, + DirectChat, + ProjectChat, + ProjectChatMessage, +) +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, + EventGroupType, + ChatType, +) +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 + + +class ChatConsumer(AsyncJsonWebsocketConsumer): + def __init__(self, *args, **kwargs): + super().__init__(args, kwargs) + self.room_name: str = "" + self.user: Optional[CustomUser] = None + self.chat_type = None + self.chat: Optional[BaseChat] = None + + async def connect(self): + """User connected to websocket""" + + if self.scope["user"].is_anonymous: + 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 + ) + # get all projects that user is a member of + 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 + # 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) + 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 + ) + await self.accept() + + async def disconnect(self, close_code): + """User disconnected from websocket, Don't have to do anything here""" + pass + + async def receive_json(self, content, **kwargs): + """Receive message from WebSocket in JSON format""" + + # 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 [ + EventType.NEW_MESSAGE, + EventType.TYPING, + EventType.READ_MESSAGE, + EventType.DELETE_MESSAGE, + ]: + 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: + 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) + else: + return self.disconnect(400) + + 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) + 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: + await self.__process_new_direct_message_event(event) + 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, 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) + + # 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) + + msg = await create_message( + chat_id=chat_id, + chat_model=DirectChatMessage, + author=self.user, + text=event.content["message"], + 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(), + }, + } + # 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, content) + + 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: + raise UserNotInChatException( + f"User {self.user.id} is not in project 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, + { + "type": EventType.NEW_MESSAGE, + "content": { + "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 __process_typing_event(self, event: Event, room_name: str): + """Send typing event to room group.""" + 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.""" + 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"] + ) + chat = await sync_to_async(ProjectChat.objects.get)(pk=msg.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 {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: + 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, 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) + 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 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)) + + async def delete_message(self, event: Event): + await self.send(json.dumps(event)) + + async def set_online(self, event: Event): + await self.send(json.dumps(event)) + + async def set_offline(self, event: Event): + await self.send(json.dumps(event)) + + 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) + + # sent everyone online event that user X is online + 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 + 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") + + +class NotificationConsumer(AsyncJsonWebsocketConsumer): + # TODO: implement this + pass diff --git a/chats/exceptions.py b/chats/exceptions.py new file mode 100644 index 00000000..1529e12d --- /dev/null +++ b/chats/exceptions.py @@ -0,0 +1,19 @@ +class ChatException(Exception): + def get_error(self): + return self.args[0] + + +class NonMatchingDirectChatIdException(ChatException): + pass + + +class WrongChatIdException(ChatException): + pass + + +class UserNotInChatException(ChatException): + pass + + +class NonMatchingReplyChatIdException(ChatException): + pass 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/middleware.py b/chats/middleware.py new file mode 100644 index 00000000..4beb505e --- /dev/null +++ b/chats/middleware.py @@ -0,0 +1,128 @@ +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 + +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 + + 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): + """ + 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). + headers = scope["headers"] + try: + 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 ValueError: + # Token is missing from headers + from django.contrib.auth.models import AnonymousUser + + scope["user"] = AnonymousUser() + + return await self.app(scope, receive, send) diff --git a/chats/migrations/0001_initial.py b/chats/migrations/0001_initial.py new file mode 100644 index 00000000..bd5c6261 --- /dev/null +++ b/chats/migrations/0001_initial.py @@ -0,0 +1,156 @@ +# Generated by Django 4.1.3 on 2023-01-29 10:59 + +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), + ("projects", "0010_alter_collaborator_user"), + ] + + operations = [ + 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=[ + ("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", + 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="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": "Сообщения в чатах проектов", + }, + ), + migrations.CreateModel( + name="DirectChatMessage", + 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="direct_messages", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "chat", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + 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": "Сообщения в личных чатах", + }, + ), + ] 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/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 + # """) + ] + 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..9cc9c89d --- /dev/null +++ b/chats/models.py @@ -0,0 +1,284 @@ +from abc import abstractmethod +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() + + +class BaseChat(models.Model): + """ + Base Chat model + + Attributes: + created_at: A DateTimeField indicating date of creation. + """ + + created_at = models.DateTimeField(auto_now_add=True) + + def get_last_message(self): + return self.messages.last() + + def get_users_str(self): + """Returns string of users separated by a comma, 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]) + + @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 + + Args: + user: User who will see the avatar + + Returns: + str: link to avatar of the chat for given user + """ + 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}>" + + class Meta: + abstract = True + + +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. + """ + + id = models.PositiveIntegerField(primary_key=True, unique=True) + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_name="project_chats" + ) + + def get_users(self): + collaborators = self.project.collaborator_set.all() + users = [collaborator.user for collaborator in collaborators] + return users + [self.project.leader] + + 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}" + + 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 = "Чаты проектов" + + +class DirectChat(BaseChat): + """ + DirectChat model + + Attributes: + created_at: A DateTimeField indicating date of creation. + + Methods: + get_users: returns list of users, who are in chat + """ + + id = models.CharField(primary_key=True, max_length=64) + users = models.ManyToManyField(User, related_name="direct_chats") + + 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 chat between two users. + + Args: + user1 (CustomUser): first user, who is in chat + user2 (CustomUser): second user, who is in chat + + Returns: + DirectChat: chat between two users + """ + + pk = "_".join(sorted([str(user1.pk), str(user2.pk)])) + try: + return cls.objects.get(pk=pk) + except cls.DoesNotExist: + chat = cls.objects.create(pk=pk) + chat.users.set([user1, user2]) + return chat + + 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() + + @classmethod + def create_from_two_users(cls, user1, user2): + chat = cls.objects.create(pk=cls.get_chat_id_from_users(user1, user2)) + chat.users.set([user1, user2]) + return chat + + @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 __str__(self): + return f"DirectChat with {self.get_users_str()}" + + class Meta: + verbose_name = "Личный чат" + verbose_name_plural = "Личные чаты" + + +class BaseMessage(models.Model): + """ + Base message model + + Attributes: + 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. + """ + + 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) + + def __str__(self): + return f"Message<{self.pk}>" + + class Meta: + verbose_name = "Сообщение" + verbose_name_plural = "Сообщения" + ordering = ["-created_at"] + abstract = True + + +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" + ) + 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 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}>" + + 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" + ) + 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 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}>" + + class Meta: + verbose_name = "Сообщение в личном чате" + verbose_name_plural = "Сообщения в личных чатах" diff --git a/chats/routing.py b/chats/routing.py new file mode 100644 index 00000000..591d1770 --- /dev/null +++ b/chats/routing.py @@ -0,0 +1,7 @@ +from django.urls import path + +from chats.consumers import ChatConsumer + +websocket_urlpatterns = [ + path("ws/chat/", ChatConsumer.as_asgi()), +] diff --git a/chats/serializers.py b/chats/serializers.py new file mode 100644 index 00000000..edf6c367 --- /dev/null +++ b/chats/serializers.py @@ -0,0 +1,105 @@ +from rest_framework import serializers + +from chats.models import DirectChat, ProjectChat, DirectChatMessage, ProjectChatMessage +from users.serializers import UserListSerializer + + +class DirectChatListSerializer(serializers.ModelSerializer): + last_message = serializers.SerializerMethodField() + users = serializers.SerializerMethodField(read_only=True) + + @classmethod + def get_users(cls, chat: ProjectChat): + return UserListSerializer(chat.get_users(), many=True).data + + @classmethod + def get_last_message(cls, chat: DirectChat): + return DirectChatMessageListSerializer(chat.get_last_message()).data + + class Meta: + model = DirectChat + fields = [ + "id", + "users", + "last_message", + ] + + +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) + + @classmethod + def get_last_message(cls, chat: ProjectChat): + return ProjectChatMessageListSerializer(chat.get_last_message()).data + + class Meta: + model = ProjectChat + fields = ["id", "project", "last_message"] + + +class ProjectChatDetailSerializer(serializers.ModelSerializer): + users = serializers.SerializerMethodField(read_only=True) + name = serializers.SerializerMethodField(read_only=True) + image_address = serializers.SerializerMethodField(read_only=True) + + @classmethod + def get_image_address(cls, chat: ProjectChat): + return chat.project.image_address + + @classmethod + 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", + "name", + "image_address", + "messages", + "users", + ] + + +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", + ] diff --git a/chats/urls.py b/chats/urls.py new file mode 100644 index 00000000..a66949ae --- /dev/null +++ b/chats/urls.py @@ -0,0 +1,29 @@ +from django.urls import path + +from chats.views import ( + DirectChatList, + DirectChatMessageList, + ProjectChatList, + ProjectChatMessageList, + ProjectChatDetail, + DirectChatDetail, +) + +app_name = "chats" + +urlpatterns = [ + 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( + "directs//messages/", + DirectChatMessageList.as_view(), + name="direct-chat-messages", + ), + path( + "projects//messages/", + ProjectChatMessageList.as_view(), + name="project-chat-messages", + ), +] diff --git a/chats/utils.py b/chats/utils.py new file mode 100644 index 00000000..f5427a83 --- /dev/null +++ b/chats/utils.py @@ -0,0 +1,95 @@ +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, + WrongChatIdException, + NonMatchingDirectChatIdException, +) +from chats.models import DirectChatMessage, ProjectChatMessage + +User = get_user_model() + + +def clean_message_text(text: str) -> str: + """ + Cleans message text. + """ + + return text.strip() + + +def validate_message_text(text: str) -> bool: + """ + Validates message text. + """ + # TODO: add bad word filter + return 0 < len(text) <= 8192 + + +def get_user_channel_cache_key(user: User) -> str: + return f"user_channel_{user.pk}" + + +async def create_message( + chat_id: Union[str, 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}" + ) + + +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/views.py b/chats/views.py new file mode 100644 index 00000000..3e9c3a47 --- /dev/null +++ b/chats/views.py @@ -0,0 +1,123 @@ +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, DirectChat +from chats.serializers import ( + DirectChatListSerializer, + DirectChatMessageListSerializer, + ProjectChatListSerializer, + ProjectChatMessageListSerializer, + ProjectChatDetailSerializer, + DirectChatDetailSerializer, +) + +User = get_user_model() + + +class DirectChatList(ListAPIView): + serializer_class = DirectChatListSerializer + permission_classes = [IsAuthenticated] + + 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.get_project_chats() + + +class ProjectChatDetail(RetrieveAPIView): + queryset = ProjectChat.objects.all() + serializer_class = ProjectChatDetailSerializer + permission_classes = [IsAuthenticated] + + +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) + + 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=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): + serializer_class = DirectChatMessageListSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return ( + self.request.user.direct_chats.get(id=self.kwargs["pk"]) + .messages.filter(is_deleted=False) + .all() + ) + + +class ProjectChatMessageList(ListCreateAPIView): + serializer_class = ProjectChatMessageListSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + 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. + 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) diff --git a/chats/websockets_settings.py b/chats/websockets_settings.py new file mode 100644 index 00000000..cf9e8a25 --- /dev/null +++ b/chats/websockets_settings.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional, Union + + +class ChatType(str, Enum): + DIRECT = "direct" + PROJECT = "project" + + +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" + + +class EventGroupType(str, Enum): + CHATS_RELATED = "CHATS_RELATED" + GENERAL_EVENTS = "GENERAL_EVENTS" + + +@dataclass(slots=True, frozen=True) +class NewMessageEventContent: + chat_id: Optional[str] + chat_type: Optional[Union[ChatType.DIRECT, ChatType.PROJECT]] + message: Optional[str] + reply_to: Optional[int] + + +@dataclass(slots=True, frozen=True) +class Event: + type: EventType + content: dict diff --git a/core/constants.py b/core/constants.py new file mode 100644 index 00000000..1cdae74e --- /dev/null +++ b/core/constants.py @@ -0,0 +1,2 @@ +ONE_DAY_IN_SECONDS = 60 * 60 * 24 +ONE_WEEK_IN_SECONDS = ONE_DAY_IN_SECONDS * 7 diff --git a/core/permissions.py b/core/permissions.py index 6477285a..4760fad8 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -14,10 +14,32 @@ 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: if request.method in SAFE_METHODS or request.user and request.user.id == obj.id: return True return False + + +class IsMessageOwner(BasePermission): + """ + Access to update only message owner. + """ + + 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): + """ + Access to update only to users in chat. + """ + + def has_object_permission(self, request, view, obj) -> bool: + if request.user in obj.chat.users.all(): + return True + return False 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/docs/chats.md b/docs/chats.md new file mode 100644 index 00000000..819febb6 --- /dev/null +++ b/docs/chats.md @@ -0,0 +1,76 @@ +# Документация по вебсокетам чатов + +## Общая инфа +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" +``` +Пример того, как выглядит 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` айди сообщения, которое удаляем 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) 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 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/procollab/asgi.py b/procollab/asgi.py index a867f68f..e72e29e2 100644 --- a/procollab/asgi.py +++ b/procollab/asgi.py @@ -1,7 +1,19 @@ import os +import chats.routing +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 = get_asgi_application() +application = ProtocolTypeRouter( + { + "http": get_asgi_application(), + "websocket": AllowedHostsOriginValidator( + TokenAuthMiddleware(URLRouter(chats.routing.websocket_urlpatterns)) + ), + } +) diff --git a/procollab/settings.py b/procollab/settings.py index bb815a0b..1110a466 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", @@ -67,6 +71,7 @@ "projects.apps.ProjectsConfig", "news.apps.NewsConfig", "vacancy.apps.VacancyConfig", + "chats.apps.ChatsConfig", "metrics.apps.MetricsConfig", "invites.apps.InvitesConfig", "files.apps.FilesConfig", @@ -80,6 +85,7 @@ "corsheaders", "django_filters", "drf_yasg", + "channels", ] MIDDLEWARE = [ @@ -144,7 +150,9 @@ "rest_framework.renderers.AdminRenderer", ], } -# Database + +ASGI_APPLICATION = "procollab.asgi.application" + if DEBUG: DATABASES = { "default": { @@ -152,7 +160,32 @@ "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": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": f"redis://{REDIS_HOST}:6379", + } + } + REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = [ "rest_framework.renderers.JSONRenderer", ] @@ -194,7 +227,7 @@ LANGUAGE_CODE = "ru-ru" -TIME_ZONE = "UTC" +TIME_ZONE = "Europe/Moscow" USE_I18N = True @@ -239,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" diff --git a/procollab/urls.py b/procollab/urls.py index ca0c8537..a6375f88 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -44,6 +44,7 @@ path("vacancies/", include("vacancy.urls", namespace="vacancies")), path("invites/", include("invites.urls", namespace="invites")), 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"), 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 diff --git a/projects/constants.py b/projects/constants.py new file mode 100644 index 00000000..0dd9684e --- /dev/null +++ b/projects/constants.py @@ -0,0 +1,9 @@ +VERBOSE_STEPS = ( + (0, "Идея"), + (1, "Прототип"), + (2, "MVP(Минимально жизнеспособный продукт)"), + (3, "Первые продажи"), + (4, "Масштабирование"), +) + +RECOMMENDATIONS_COUNT = 5 diff --git a/projects/helpers.py b/projects/helpers.py index a7906e91..47b43cf5 100644 --- a/projects/helpers.py +++ b/projects/helpers.py @@ -1,7 +1,35 @@ -VERBOSE_STEPS = ( - (0, "Идея"), - (1, "Прототип"), - (2, "MVP(Минимально жизнеспособный продукт)"), - (3, "Первые продажи"), - (4, "Масштабирование"), -) +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/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/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="Пользователь", + ), + ), + ] diff --git a/projects/migrations/0011_project_views_count.py b/projects/migrations/0011_project_views_count.py new file mode 100644 index 00000000..d1f2c40f --- /dev/null +++ b/projects/migrations/0011_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", "0010_alter_collaborator_user"), + ] + + 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..fff54522 100644 --- a/projects/models.py +++ b/projects/models.py @@ -3,11 +3,8 @@ 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.constants import VERBOSE_STEPS from projects.managers import AchievementManager, ProjectManager from users.models import CustomUser @@ -65,6 +62,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 @@ -120,17 +123,23 @@ 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 - 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. """ - 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) @@ -153,12 +162,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/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/serializers.py b/projects/serializers.py index cdd710a8..5d9f7e26 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, is_liked=True).count() + def update(self, instance, validated_data): instance = super().update(instance, validated_data) instance.save() @@ -99,12 +104,20 @@ class Meta: "vacancies", "datetime_created", "datetime_updated", + "views_count", + "likes_count", + ] + read_only_fields = [ + "leader", + "views_count", + "datetime_created", + "datetime_updated", ] - read_only_fields = ["leader"] 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" ) @@ -120,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, is_liked=True).count() + def get_collaborators(self, obj): max_collaborator_count = 4 return CollaboratorSerializer( @@ -142,6 +158,7 @@ class Meta: "collaborators", "vacancies", "datetime_created", + "likes_count", ] read_only_fields = [ diff --git a/projects/signals.py b/projects/signals.py new file mode 100644 index 00000000..5682c296 --- /dev/null +++ b/projects/signals.py @@ -0,0 +1,22 @@ +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 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="Основатель" + ) diff --git a/projects/urls.py b/projects/urls.py index 124bc9a2..305722e6 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -9,14 +9,18 @@ ProjectCollaborators, ProjectCountView, ProjectVacancyResponses, + ProjectRecommendedUsers, + SetLikeOnProject, ) app_name = "projects" urlpatterns = [ path("", ProjectList.as_view()), + path("/like/", SetLikeOnProject.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 1ca9470f..f9f61ba6 100644 --- a/projects/views.py +++ b/projects/views.py @@ -1,3 +1,4 @@ +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 +8,13 @@ from core.permissions import IsStaffOrReadOnly from projects.filters import ProjectFilter -from projects.helpers import VERBOSE_STEPS +from projects.constants import VERBOSE_STEPS +from projects.helpers import get_recommended_users from projects.models import Project, Achievement from projects.permissions import ( IsProjectLeaderOrReadOnlyForNonDrafts, HasInvolvementInProjectOrReadOnly, + IsProjectLeader, ) from projects.serializers import ( ProjectDetailSerializer, @@ -20,15 +23,17 @@ AchievementDetailSerializer, ProjectCollaboratorSerializer, ) +from users.models import LikesOnProject +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() 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 +46,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): """ @@ -79,6 +84,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: @@ -100,6 +111,41 @@ 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() + recommended_users = get_recommended_users(project) + serializer = self.get_serializer(recommended_users, many=True) + return Response(status=status.HTTP_200_OK, data=serializer.data) + + +class SetLikeOnProject(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + """ + Set like on project + + --- + + Args: + request: + pk - project id + + Returns: + Response + + """ + project = Project.objects.get(pk=pk) + LikesOnProject.objects.toggle_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/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] diff --git a/users/managers.py b/users/managers.py index bbd6ea6c..ce3e2a8d 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): @@ -10,8 +12,12 @@ 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("birthday", "1900-01-01") extra_fields.setdefault("is_superuser", True) extra_fields.setdefault("is_active", True) @@ -30,14 +36,14 @@ 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() ) + 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) @@ -60,3 +66,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 toggle_like(self, user, project): + like, created = self.get_or_create(user=user, project=project) + if not created: + like.toggle_like() + return like diff --git a/users/migrations/0027_likesonproject.py b/users/migrations/0027_likesonproject.py new file mode 100644 index 00000000..5b7ce6b5 --- /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", "0011_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/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", + ), + ] diff --git a/users/models.py b/users/models.py index f51e4cb2..65790bbc 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 @@ -86,10 +90,20 @@ class CustomUser(AbstractUser): objects = CustomUserManager() + def get_project_chats(self) -> list: + collaborations = self.collaborations.all() + 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()] - def __str__(self): + def get_full_name(self) -> str: + return f"{self.first_name} {self.last_name}" + + def __str__(self) -> str: return f"User<{self.id}> - {self.first_name} {self.last_name}" class Meta: @@ -151,6 +165,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. + """ + + is_liked = 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 toggle_like(self): + self.is_liked = not self.is_liked + 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/serializers.py b/users/serializers.py index 668cefe9..27a2fbe4 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,13 @@ 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) + is_online = cache.get(cache_key, False) + return is_online class Meta: model = CustomUser @@ -100,6 +108,7 @@ class Meta: "avatar", "city", "is_active", + "is_online", "member", "investor", "expert", @@ -181,8 +190,15 @@ 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) -> bool: + cache_key = get_user_online_cache_key(user) + 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() @@ -203,6 +219,7 @@ class Meta: "speciality", "birthday", "is_active", + "is_online", "member", "password", ] diff --git a/users/urls.py b/users/urls.py index 51a1fffe..ac24bdf5 100644 --- a/users/urls.py +++ b/users/urls.py @@ -14,6 +14,7 @@ UserTypesView, VerifyEmail, LogoutView, + LikedProjectList, ) 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/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 b71160f4..a17bf512 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() @@ -46,8 +45,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,) @@ -82,6 +80,18 @@ def post(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) +class LikedProjectList(ListAPIView): + serializer_class = ProjectListSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + projects_ids_list = LikesOnProject.objects.filter( + user=self.request.user, is_liked=True + ).values_list("project", flat=True) + + return Project.objects.get_projects_from_list_of_ids(projects_ids_list) + + class UserAdditionalRolesView(APIView): permission_classes = [AllowAny]