-
Notifications
You must be signed in to change notification settings - Fork 284
Initial websockets integration #3433
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
bjester
merged 13 commits into
learningequality:websockets
from
ozer550:websockets_initial_setup
Jul 20, 2022
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
760298c
intial_websockets_setup
ozer550 798822e
try_fixin_INSTALLED_APPS_error
ozer550 9d2f27c
updated requirements.in file
ozer550 a3bc91b
FIX_Django_Settings_Error
ozer550 44ef339
FIX_test_cases
ozer550 f8d02ed
send_data_from_frontend
ozer550 b0835e3
inject proper channel_id while making websocket request
ozer550 07041bc
implement handel_changes from sync api in websockets
ozer550 b656473
FIX_unit_tests
ozer550 1b9b63c
update redis version
ozer550 b7a39f1
Add more unit tests
ozer550 1508d41
refine pr
ozer550 11ce747
refine PR
ozer550 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| from channels.auth import AuthMiddlewareStack | ||
| from channels.routing import ProtocolTypeRouter | ||
| from channels.routing import URLRouter | ||
|
|
||
| from contentcuration.viewsets.websockets.routing import websocket_urlpatterns | ||
|
|
||
| application = ProtocolTypeRouter({ | ||
| "websocket": | ||
| AuthMiddlewareStack( | ||
| URLRouter( | ||
| websocket_urlpatterns | ||
| ) | ||
| ), | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
102 changes: 102 additions & 0 deletions
102
contentcuration/contentcuration/tests/test_websocket_consumer.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import os | ||
|
|
||
| import pytest | ||
| from channels.layers import get_channel_layer | ||
| from channels.testing import WebsocketCommunicator | ||
| from django.core.management import call_command | ||
| from django.test import override_settings | ||
| from django.test import TransactionTestCase | ||
|
|
||
| from contentcuration.asgi import application | ||
| from contentcuration.tests import testdata | ||
|
|
||
| os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" | ||
|
|
||
|
|
||
| class WebsocketTestCase(TransactionTestCase): | ||
|
|
||
| def setUp(self): | ||
| call_command("loadconstants") | ||
| self.user = testdata.user("mrtest@testy.com") | ||
|
|
||
| def tearDown(self): | ||
| self.user.delete() | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_authenticated_user_websocket_connection(self): | ||
| self.client.force_login(self.user) | ||
| headers = [(b'cookie', self.client.cookies.output(attrs=["value"], header='', sep='; ').encode())] | ||
| communicator = WebsocketCommunicator(application, 'ws/sync_socket/12312312312123/', headers) | ||
| connected = await communicator.connect() | ||
| assert connected | ||
| await communicator.disconnect() | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_unauthenticated_user_websocket_connection(self): | ||
| headers = [(b'cookie', self.client.cookies.output(attrs=["value"], header='', sep='; ').encode())] | ||
| communicator = WebsocketCommunicator(application, 'ws/sync_socket/12312312312123/', headers) | ||
| connected, _ = await communicator.connect() | ||
| assert connected is False | ||
| await communicator.disconnect() | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_disconnect_websockets(self): | ||
| self.client.force_login(self.user) | ||
| headers = [(b'cookie', self.client.cookies.output(attrs=["value"], header='', sep='; ').encode())] | ||
| channel_layers_setting = { | ||
| "default": {"BACKEND": "channels.layers.InMemoryChannelLayer"} | ||
| } | ||
| with override_settings(CHANNEL_LAYERS=channel_layers_setting): | ||
| communicator = WebsocketCommunicator(application, 'ws/sync_socket/12312312312123/', headers) | ||
| connected, _ = await communicator.connect() | ||
| channel_layer = get_channel_layer() | ||
| assert connected | ||
| await communicator.disconnect() | ||
| assert channel_layer.groups == {} | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_send_payload_websockets(self): | ||
| self.client.force_login(self.user) | ||
| headers = [(b'cookie', self.client.cookies.output(attrs=["value"], header='', sep='; ').encode())] | ||
| communicator = WebsocketCommunicator(application, 'ws/sync_socket/12312312312123/', headers) | ||
| connected = await communicator.connect() | ||
| assert connected | ||
| await communicator.send_json_to({ | ||
| "payload": { | ||
| "changes": [ | ||
| { | ||
| "type": 2, | ||
| "key": "7ae83505f20a4642a004fadde7f151ed", | ||
| "table": "channel", | ||
| "rev": 253, | ||
| "channel_id": "7ae83505f20a4642a004fadde7f151ed", | ||
| "mods": { | ||
| "name": "test" | ||
| } | ||
| } | ||
| ], | ||
| "channel_revs": { | ||
| "7ae83505f20a4642a004fadde7f151ed": 51 | ||
| }, | ||
| "user_rev": 0 | ||
| }}) | ||
| response = await communicator.receive_json_from() | ||
| assert response["response_payload"] | ||
| await communicator.disconnect() | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_channels_groups(self): | ||
| self.client.force_login(self.user) | ||
| headers = [(b'cookie', self.client.cookies.output(attrs=["value"], header='', sep='; ').encode())] | ||
| channel_layers_setting = { | ||
| "default": {"BACKEND": "channels.layers.InMemoryChannelLayer"} | ||
| } | ||
| with override_settings(CHANNEL_LAYERS=channel_layers_setting): | ||
| communicator = WebsocketCommunicator(application, 'ws/sync_socket/12312312312123/', headers) | ||
| connected, _ = await communicator.connect() | ||
| channel_layer = get_channel_layer() | ||
| # check the grou for channel exist | ||
| assert channel_layer.groups['12312312312123'] | ||
| assert channel_layer.groups[f"{self.user.id}"] | ||
| assert connected | ||
| await communicator.disconnect() |
130 changes: 130 additions & 0 deletions
130
contentcuration/contentcuration/viewsets/websockets/consumers.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import json | ||
| import logging as logger | ||
|
|
||
| from asgiref.sync import async_to_sync | ||
| from channels.generic.websocket import WebsocketConsumer | ||
|
|
||
| from contentcuration.models import Change | ||
| from contentcuration.models import Channel | ||
| from contentcuration.tasks import get_or_create_async_task | ||
| from contentcuration.viewsets.sync.constants import CHANNEL | ||
| from contentcuration.viewsets.sync.constants import CREATED | ||
|
|
||
|
|
||
| logging = logger.getLogger(__name__) | ||
|
|
||
|
|
||
| class SyncConsumer(WebsocketConsumer): | ||
| # Initial reset | ||
| def __init__(self, *args, **kwargs): | ||
| super().__init__(*args, **kwargs) | ||
| self.room_group_name = None | ||
| self.indiviual_room_group_name = None | ||
|
|
||
| @property | ||
| def user(self): | ||
| return self.scope["user"] | ||
|
|
||
| # Checks permissions | ||
| def check_authentication(self): | ||
| return self.user.is_authenticated | ||
|
|
||
| def connect(self): | ||
| """ | ||
| Executes when a user tries to make a websocket connection. | ||
| - Creates and joins a group for indiviual user | ||
| - Joins a public group based on channel_id provided in url | ||
| """ | ||
| # Extract the channel_id from url | ||
| self.room_group_name = self.scope['url_route']['kwargs']['channel_id'] | ||
|
|
||
| logging.debug("Connected to channel_id: " + self.room_group_name) | ||
|
|
||
| self.indiviual_room_group_name = str(self.user.id) | ||
|
|
||
| logging.debug("Connected to user " + str(self.user)) | ||
|
|
||
| if self.check_authentication(): | ||
| # Join room group based on channel_id | ||
| async_to_sync(self.channel_layer.group_add)( | ||
| self.room_group_name, | ||
| self.channel_name | ||
| ) | ||
|
|
||
| # Join private room group for indiviual user | ||
| async_to_sync(self.channel_layer.group_add)( | ||
| self.indiviual_room_group_name, | ||
| self.channel_name | ||
| ) | ||
|
|
||
| self.accept() | ||
|
|
||
| else: | ||
| self.close() | ||
|
|
||
| def disconnect(self, close_code): | ||
| """ | ||
| Executed to leave indiviual-user and channel group | ||
| """ | ||
| # Leave channel_id room group | ||
| async_to_sync(self.channel_layer.group_discard)( | ||
| self.room_group_name, | ||
| self.channel_name | ||
| ) | ||
|
|
||
| # Leave indiviual room group | ||
| async_to_sync(self.channel_layer.group_discard)( | ||
| self.indiviual_room_group_name, | ||
| self.channel_name | ||
| ) | ||
|
|
||
| def receive(self, text_data): | ||
| """ | ||
| Executes when data is received from websocket | ||
| """ | ||
| response_payload = { | ||
| "disallowed": [], | ||
| "allowed": [], | ||
| } | ||
| user_id = self.user.id | ||
| session_key = self.scope['cookies']['kolibri_studio_sessionid'] | ||
| text_data_json = json.loads(text_data) | ||
| changes = text_data_json["payload"]["changes"] | ||
|
|
||
| change_channel_ids = set(x.get("channel_id") for x in changes if x.get("channel_id")) | ||
| # Channels that have been created on the client side won't exist on the server yet, so we need to add a special exception for them. | ||
| created_channel_ids = set(x.get("channel_id") for x in changes if x.get("channel_id") and x.get("table") == CHANNEL and x.get("type") == CREATED) | ||
| # However, this would also give people a mechanism to edit existing channels on the server side by adding a channel create event for an | ||
| # already existing channel, so we have to filter out the channel ids that are already created on the server side, regardless of whether | ||
| # the user making the requests has permissions for those channels. | ||
| created_channel_ids = created_channel_ids.difference( | ||
| set(Channel.objects.filter(id__in=created_channel_ids).values_list("id", flat=True).distinct()) | ||
| ) | ||
| allowed_ids = set( | ||
| Channel.filter_edit_queryset(Channel.objects.filter(id__in=change_channel_ids), self.user).values_list("id", flat=True).distinct() | ||
| ).union(created_channel_ids) | ||
| # Allow changes that are either: | ||
| # Not related to a channel and instead related to the user if the user is the current user. | ||
| user_only_changes = [] | ||
| # Related to a channel that the user is an editor for. | ||
| channel_changes = [] | ||
| # Changes that cannot be made | ||
| disallowed_changes = [] | ||
| for c in changes: | ||
| if c.get("channel_id") is None and c.get("user_id") == user_id: | ||
| user_only_changes.append(c) | ||
| elif c.get("channel_id") in allowed_ids: | ||
| channel_changes.append(c) | ||
| else: | ||
| disallowed_changes.append(c) | ||
| change_models = Change.create_changes(user_only_changes + channel_changes, created_by_id=user_id, session_key=session_key) | ||
| if user_only_changes: | ||
| get_or_create_async_task("apply_user_changes", self.user, user_id=user_id) | ||
| for channel_id in allowed_ids: | ||
| get_or_create_async_task("apply_channel_changes", self.user, channel_id=channel_id) | ||
| allowed_changes = [{"rev": c.client_rev, "server_rev": c.server_rev} for c in change_models] | ||
| response_payload.update({"disallowed": disallowed_changes, "allowed": allowed_changes}) | ||
|
|
||
| self.send(json.dumps({ | ||
| 'response_payload': response_payload | ||
| })) |
7 changes: 7 additions & 0 deletions
7
contentcuration/contentcuration/viewsets/websockets/routing.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| from django.urls import re_path | ||
|
|
||
| from . import consumers | ||
|
|
||
| websocket_urlpatterns = [ | ||
| re_path(r'ws/sync_socket/(?P<channel_id>\w+)/$', consumers.SyncConsumer.as_asgi()), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.