diff --git a/lego/apps/articles/serializers.py b/lego/apps/articles/serializers.py index b93adb2b9..e40e386ba 100644 --- a/lego/apps/articles/serializers.py +++ b/lego/apps/articles/serializers.py @@ -2,7 +2,7 @@ from rest_framework.fields import CharField from lego.apps.articles.models import Article -from lego.apps.comments.serializers import CommentSerializer +from lego.apps.comments.serializers.comments import CommentSerializer from lego.apps.content.fields import ContentSerializerField from lego.apps.files.fields import ImageField from lego.apps.tags.serializers import TagSerializerMixin diff --git a/lego/apps/comments/action_handlers.py b/lego/apps/comments/action_handlers.py index 1118554e0..778bce22c 100644 --- a/lego/apps/comments/action_handlers.py +++ b/lego/apps/comments/action_handlers.py @@ -1,7 +1,9 @@ from lego.apps.action_handlers.handler import Handler from lego.apps.action_handlers.registry import register_handler +from lego.apps.comments.constants import SOCKET_ADD_SUCCESS, SOCKET_DELETE_SUCCESS from lego.apps.comments.models import Comment from lego.apps.comments.notifications import CommentReplyNotification +from lego.apps.comments.websockets import notify_comment from lego.apps.feeds.activity import Activity from lego.apps.feeds.feed_manager import feed_manager from lego.apps.feeds.models import NotificationFeed, UserFeed @@ -25,6 +27,7 @@ def get_activity(comment, reply=False): ) def handle_create(self, instance, **kwargs): + notify_comment(SOCKET_ADD_SUCCESS, instance) activity = self.get_activity(instance) author = instance.created_by for feeds, recipients in self.get_feeds_and_recipients(instance): @@ -47,6 +50,7 @@ def handle_create(self, instance, **kwargs): reply_notification.notify() def handle_delete(self, instance, **kwargs): + notify_comment(SOCKET_DELETE_SUCCESS, instance) if not ( isinstance(instance.parent, ObjectPermissionsModel) and not instance.parent.require_auth diff --git a/lego/apps/comments/constants.py b/lego/apps/comments/constants.py new file mode 100644 index 000000000..5744e931f --- /dev/null +++ b/lego/apps/comments/constants.py @@ -0,0 +1,4 @@ +SOCKET_ADD_SUCCESS = "Comment.SOCKET_ADD.SUCCESS" +SOCKET_ADD_FAILURE = "Comment.SOCKET_ADD.FAILURE" +SOCKET_DELETE_SUCCESS = "Comment.SOCKET_DELETE.SUCCESS" +SOCKET_DELETE_FAILURE = "Comment.SOCKET_DELETE.FAILURE" diff --git a/lego/apps/comments/serializers/__init__.py b/lego/apps/comments/serializers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lego/apps/comments/serializers.py b/lego/apps/comments/serializers/comments.py similarity index 100% rename from lego/apps/comments/serializers.py rename to lego/apps/comments/serializers/comments.py diff --git a/lego/apps/comments/serializers/sockets.py b/lego/apps/comments/serializers/sockets.py new file mode 100644 index 000000000..1f2934070 --- /dev/null +++ b/lego/apps/comments/serializers/sockets.py @@ -0,0 +1,6 @@ +from lego.apps.comments.serializers.comments import CommentSerializer +from lego.apps.websockets.serializers import WebsocketSerializer + + +class CommentSocketSerializer(WebsocketSerializer): + payload = CommentSerializer() diff --git a/lego/apps/comments/views.py b/lego/apps/comments/views.py index 0df1a9803..f9094c2ad 100644 --- a/lego/apps/comments/views.py +++ b/lego/apps/comments/views.py @@ -1,7 +1,10 @@ from rest_framework import mixins, viewsets from lego.apps.comments.models import Comment -from lego.apps.comments.serializers import CommentSerializer, UpdateCommentSerializer +from lego.apps.comments.serializers.comments import ( + CommentSerializer, + UpdateCommentSerializer, +) from lego.apps.permissions.api.views import AllowedPermissionsMixin diff --git a/lego/apps/comments/websockets.py b/lego/apps/comments/websockets.py new file mode 100644 index 000000000..8001a59b2 --- /dev/null +++ b/lego/apps/comments/websockets.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from lego.apps.comments.serializers.sockets import CommentSocketSerializer +from lego.apps.websockets.groups import group_for_content_model +from lego.apps.websockets.notifiers import notify_group + +if TYPE_CHECKING: + from lego.apps.comments.models import Comment + + +def notify_comment(action_type: str, comment: Comment, **kwargs): + # group = "global" + group = group_for_content_model(comment) + serializer = CommentSocketSerializer( + { + "type": action_type, + "payload": comment, + "meta": kwargs, + } + ) + data = serializer.data + notify_group(group, data) diff --git a/lego/apps/companies/serializers.py b/lego/apps/companies/serializers.py index ad26bd204..3766eec69 100644 --- a/lego/apps/companies/serializers.py +++ b/lego/apps/companies/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from rest_framework.fields import CharField -from lego.apps.comments.serializers import CommentSerializer +from lego.apps.comments.serializers.comments import CommentSerializer from lego.apps.companies.constants import INTERESTED, NOT_CONTACTED from lego.apps.companies.fields import SemesterField from lego.apps.companies.models import ( diff --git a/lego/apps/events/serializers/events.py b/lego/apps/events/serializers/events.py index ee03b6a5d..f4870c782 100644 --- a/lego/apps/events/serializers/events.py +++ b/lego/apps/events/serializers/events.py @@ -4,7 +4,7 @@ from rest_framework import serializers from rest_framework.fields import CharField -from lego.apps.comments.serializers import CommentSerializer +from lego.apps.comments.serializers.comments import CommentSerializer from lego.apps.companies.fields import CompanyField from lego.apps.companies.models import Company from lego.apps.content.fields import ContentSerializerField diff --git a/lego/apps/forums/serializers.py b/lego/apps/forums/serializers.py index d7b537ec7..15b816a9b 100644 --- a/lego/apps/forums/serializers.py +++ b/lego/apps/forums/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from rest_framework.fields import CharField -from lego.apps.comments.serializers import CommentSerializer +from lego.apps.comments.serializers.comments import CommentSerializer from lego.apps.content.fields import ContentSerializerField from lego.apps.forums.models import Forum, Thread from lego.apps.users.serializers.users import PublicUserSerializer diff --git a/lego/apps/gallery/serializers.py b/lego/apps/gallery/serializers.py index 6ce8b5504..eadebd589 100644 --- a/lego/apps/gallery/serializers.py +++ b/lego/apps/gallery/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from rest_framework.fields import CharField -from lego.apps.comments.serializers import CommentSerializer +from lego.apps.comments.serializers.comments import CommentSerializer from lego.apps.events.fields import PublicEventField from lego.apps.events.models import Event from lego.apps.files.fields import FileField, ImageField diff --git a/lego/apps/meetings/serializers.py b/lego/apps/meetings/serializers.py index e2abd9303..4964d2438 100644 --- a/lego/apps/meetings/serializers.py +++ b/lego/apps/meetings/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from rest_framework.fields import CharField -from lego.apps.comments.serializers import CommentSerializer +from lego.apps.comments.serializers.comments import CommentSerializer from lego.apps.content.fields import ContentSerializerField from lego.apps.meetings import constants from lego.apps.meetings.models import Meeting, MeetingInvitation, ReportChangelog diff --git a/lego/apps/polls/serializers.py b/lego/apps/polls/serializers.py index fec958d2b..9858b9c5d 100644 --- a/lego/apps/polls/serializers.py +++ b/lego/apps/polls/serializers.py @@ -4,7 +4,7 @@ from rest_framework import serializers from rest_framework.fields import CharField, IntegerField -from lego.apps.comments.serializers import CommentSerializer +from lego.apps.comments.serializers.comments import CommentSerializer from lego.apps.polls.models import Option, Poll from lego.apps.tags.serializers import TagSerializerMixin from lego.utils.serializers import BasisModelSerializer diff --git a/lego/apps/websockets/constants.py b/lego/apps/websockets/constants.py new file mode 100644 index 000000000..5a0020b39 --- /dev/null +++ b/lego/apps/websockets/constants.py @@ -0,0 +1,13 @@ +WS_STATUS_CONNECTED = "CONNECTED" +WS_STATUS_CLOSED = "CLOSED" +WS_STATUS_ERROR = "ERROR" + +WS_GROUP_TYPES = ["global" "user", "event", "comment"] + +WS_GROUP_JOIN_BEGIN = "Websockets.GROUP_JOIN.BEGIN" +WS_GROUP_JOIN_SUCCESS = "Websockets.GROUP_JOIN.SUCCESS" +WS_GROUP_JOIN_FAILURE = "Websockets.GROUP_JOIN.FAILURE" + +WS_GROUP_LEAVE_BEGIN = "Websockets.GROUP_LEAVE.BEGIN" +WS_GROUP_LEAVE_SUCCESS = "Websockets.GROUP_LEAVE.SUCCESS" +WS_GROUP_LEAVE_FAILURE = "Websockets.GROUP_LEAVE.FAILURE" diff --git a/lego/apps/websockets/consumers.py b/lego/apps/websockets/consumers.py index f841c475b..d98cd6f59 100644 --- a/lego/apps/websockets/consumers.py +++ b/lego/apps/websockets/consumers.py @@ -1,24 +1,76 @@ from asgiref.sync import AsyncToSync -from channels.generic.websocket import WebsocketConsumer +from channels.generic.websocket import JsonWebsocketConsumer from lego.apps.events.websockets import find_event_groups from lego.apps.users.models import User -from lego.apps.websockets.groups import group_for_user +from lego.apps.websockets import constants +from lego.apps.websockets.groups import group_for_user, verify_group_access def find_groups(user: User): return ["global", group_for_user(user.pk)] + find_event_groups(user) -class GroupConsumer(WebsocketConsumer): +class GroupConsumer(JsonWebsocketConsumer): + """ + Custom consumer for handling websocket groups. + + Create own logic for tracking user groups as the WebsocketConsumer groups functionality + does not have access to the user object. + """ + + user_groups = set() + user = None + + def debug(self, message): + if self.user: + print(f"[{self.user.username.upper()}] {message}") + else: + print(f"[NO USER IN SCOPE] {message}") + def connect(self): - self.accept() - for group in find_groups(self.scope["user"]): + user = self.scope["user"] + for group in find_groups(user): AsyncToSync(self.channel_layer.group_add)(group, self.channel_name) + self.user = user + self.user_groups = set(find_groups(user)) + self.accept() - def disconnect(self, message): - for group in find_groups(self.scope["user"]): + def disconnect(self, code): + for group in self.user_groups: AsyncToSync(self.channel_layer.group_discard)(group, self.channel_name) + self.user_groups.clear() + + def receive_json(self, content, **kwargs): + type = content.get("type") + payload = content.get("payload") + + if type == constants.WS_GROUP_JOIN_BEGIN: + group = payload.get("group") + if self.user and verify_group_access(self.user, group): + AsyncToSync(self.channel_layer.group_add)(group, self.channel_name) + self.user_groups.add(group) + self.send_message( + constants.WS_GROUP_JOIN_SUCCESS, payload={"group": group} + ) + else: + self.send_message( + constants.WS_GROUP_JOIN_FAILURE, payload={"group": group} + ) + + if type == constants.WS_GROUP_LEAVE_BEGIN: + group = payload.get("group") + if group in self.groups: + AsyncToSync(self.channel_layer.group_discard)(group, self.channel_name) + self.user_groups.remove(group) + self.send_message(constants.WS_GROUP_LEAVE_SUCCESS) + + def send_message(self, type: str, payload=None, meta=None): + """ + Send message on standardised format. + """ + content = {"type": type, "payload": payload, "meta": meta} + self.send_json(content) def notification_message(self, event): self.send(text_data=event["text"]) diff --git a/lego/apps/websockets/groups.py b/lego/apps/websockets/groups.py index c147737ce..3d97123a2 100644 --- a/lego/apps/websockets/groups.py +++ b/lego/apps/websockets/groups.py @@ -2,6 +2,10 @@ from typing import TYPE_CHECKING +from lego.apps.permissions.constants import LIST +from lego.apps.users.models import User +from lego.utils.content_types import instance_to_string, string_to_instance + if TYPE_CHECKING: from lego.apps.events.models import Event @@ -12,3 +16,40 @@ def group_for_user(user_id: str) -> str: def group_for_event(event: Event, has_registrations_access: bool) -> str: return f"event-{'full' if has_registrations_access else 'limited'}-{event.pk}" + + +def group_for_content_model(model) -> str: + modelname = model._meta.model_name + content_target_string = instance_to_string(model.content_object) + return f"{modelname}-{content_target_string}" + + +def verify_group_access(user: User, group): + if not group: + return False + + group_type, rest = group.split("-", 1) + + if group_type == "comment": + content_target = string_to_instance(rest) + if user and content_target: + return user.has_perm(LIST, content_target) + + # if group_type == WS_GROUP_TYPE_USER: + # user_id = rest + # return user_id == str(user.pk) + + # if group_type == WS_GROUP_TYPE_EVENT: + # event_access, event_id = rest.split("-", 1) + # """Not implemented""" + + return False + + +def stringify_group(group): + pass + + # if content_target: + # return f"{group.type}-{content_target}" + + # return str(group.type) diff --git a/lego/utils/serializers.py b/lego/utils/serializers.py index d683a46ba..36d039864 100644 --- a/lego/utils/serializers.py +++ b/lego/utils/serializers.py @@ -3,7 +3,7 @@ from lego.apps.users.fields import AbakusGroupField, PublicUserField from lego.apps.users.models import AbakusGroup, User -from lego.utils.content_types import string_to_instance +from lego.utils.content_types import instance_to_string, string_to_instance class GenericRelationField(serializers.CharField): @@ -14,11 +14,13 @@ class GenericRelationField(serializers.CharField): } def __init__(self, *args, **kwargs): - kwargs["write_only"] = True super().__init__(*args, **kwargs) def to_representation(self, value): - return None + try: + return instance_to_string(value) + except Exception: + pass def to_internal_value(self, data): try: