1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-14 17:18:33 +00:00

Resolve "Real-time support for row change history"

This commit is contained in:
Petr Stribny 2023-10-16 08:01:49 +00:00
parent bc21092f4f
commit a8940f2887
36 changed files with 1827 additions and 342 deletions

View file

@ -25,3 +25,4 @@ markers =
once_per_day_in_ci: All tests that are run once per day in CI
undo_redo: All tests related to undo/redo functionality
row_history: All tests related to row history functionality
websockets: All tests related to handeling web socket connections

View file

@ -411,10 +411,11 @@ class DatabaseConfig(AppConfig):
application_type_registry.register(DatabaseApplicationType())
from .ws.pages import PublicViewPageType, TablePageType
from .ws.pages import PublicViewPageType, RowPageType, TablePageType
page_registry.register(TablePageType())
page_registry.register(PublicViewPageType())
page_registry.register(RowPageType())
from .export.table_exporters.csv_table_exporter import CsvTableExporter

View file

@ -8,12 +8,12 @@ from django.dispatch import receiver
from opentelemetry import trace
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.rows.actions import UpdateRowsActionType
from baserow.contrib.database.rows.models import RowHistory
from baserow.contrib.database.rows.signals import rows_history_updated
from baserow.core.action.signals import ActionCommandType, action_done
from baserow.core.telemetry.utils import baserow_trace
from .actions import UpdateRowsActionType
from .models import RowHistory
tracer = trace.get_tracer(__name__)
FieldName = NewType("FieldName", str)
@ -155,7 +155,12 @@ class RowHistoryHandler:
row_history_entries.append(entry)
if row_history_entries:
RowHistory.objects.bulk_create(row_history_entries)
row_history_entries = RowHistory.objects.bulk_create(row_history_entries)
rows_history_updated.send(
RowHistoryHandler,
table_id=params.table_id,
row_history_entries=row_history_entries,
)
@classmethod
@baserow_trace(tracer)

View file

@ -10,3 +10,5 @@ rows_updated = Signal()
rows_deleted = Signal()
row_orders_recalculated = Signal()
rows_history_updated = Signal()

View file

@ -22,7 +22,7 @@ from baserow.core.registries import (
subject_type_registry,
)
from baserow.core.subjects import UserSubjectType
from baserow.ws.tasks import closing_group_send
from baserow.ws.tasks import send_message_to_channel_group
@app.task(queue="export")
@ -89,7 +89,9 @@ def unsubscribe_subject_from_tables_currently_subscribed_to(
channel_group_names_users_dict = defaultdict(set)
for user in users:
for table in tables:
channel_group_name = TablePageType().get_group_name(table.id)
channel_group_name = TablePageType().get_permission_channel_group_name(
table.id
)
if permission_manager is None:
channel_group_names_users_dict[channel_group_name].add(user.id)
else:
@ -106,28 +108,29 @@ def unsubscribe_subject_from_tables_currently_subscribed_to(
channel_layer = get_channel_layer()
for channel_group_name, user_ids in channel_group_names_users_dict.items():
async_to_sync(closing_group_send)(
async_to_sync(send_message_to_channel_group)(
channel_layer,
channel_group_name,
{
"type": "remove_user_from_group",
"type": "users_removed_from_permission_group",
"user_ids_to_remove": list(user_ids),
"permission_group_name": channel_group_name,
},
)
@app.task(bind=True)
def unsubscribe_user_from_table_currently_subscribed_to(
def unsubscribe_user_from_tables_when_removed_from_workspace(
self,
user_id: int,
workspace_id: int,
):
"""
Unsubscribe all users associated with the subject from the table they are currently
viewing.
Task that will unsubscribe the provided user from web socket
CoreConsumer pages that belong to the provided workspace.
:param user_id: The id of the user that is supposed to be unsubscribed
:param workspace_id: The id of the workspace the user belongs to
:param user_id: The id of the user that is supposed to be unsubscribed.
:param workspace_id: The id of the workspace the user belonged to.
"""
unsubscribe_subject_from_tables_currently_subscribed_to(

View file

@ -1,5 +1,7 @@
from django.conf import settings
from baserow.contrib.database.rows.exceptions import RowDoesNotExist
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.table.exceptions import TableDoesNotExist
from baserow.contrib.database.table.handler import TableHandler
from baserow.contrib.database.table.operations import (
@ -46,6 +48,9 @@ class TablePageType(PageType):
def get_group_name(self, table_id, **kwargs):
return f"table-{table_id}"
def get_permission_channel_group_name(self, table_id, **kwargs):
return f"permissions-table-{table_id}"
class PublicViewPageType(PageType):
type = "view"
@ -54,7 +59,7 @@ class PublicViewPageType(PageType):
def can_add(self, user, web_socket_id, slug, token=None, **kwargs):
"""
The user should only have access to this page if the view exists and:
- the user have access to the group
- the user have access to the workspace
- the view is public and not password protected
- the view is public, password protected and the token provided is valid.
"""
@ -82,6 +87,43 @@ class PublicViewPageType(PageType):
def get_group_name(self, slug, **kwargs):
return f"view-{slug}"
def broadcast_to_views(self, payload, view_slugs):
for view_slug in view_slugs:
self.broadcast(payload, ignore_web_socket_id=None, slug=view_slug)
class RowPageType(PageType):
type = "row"
parameters = ["table_id", "row_id"]
def can_add(self, user, web_socket_id, table_id, row_id, **kwargs):
"""
The user should only have access to this page if the table and row exist
and if he has access to the table.
"""
if not table_id:
return False
try:
handler = TableHandler()
table = handler.get_table(table_id)
CoreHandler().check_permissions(
user,
ListenToAllDatabaseTableEventsOperationType.type,
workspace=table.database.workspace,
context=table,
)
row_handler = RowHandler()
row_handler.get_row(user, table, row_id)
except (
UserNotInWorkspace,
TableDoesNotExist,
PermissionDenied,
RowDoesNotExist,
):
return False
return True
def get_group_name(self, table_id, row_id, **kwargs):
return f"table-{table_id}-row-{row_id}"
def get_permission_channel_group_name(self, table_id, **kwargs):
return f"permissions-table-{table_id}"

View file

@ -4,6 +4,7 @@ from django.db import transaction
from django.dispatch import receiver
from baserow.contrib.database.api.rows.serializers import (
RowHistorySerializer,
RowSerializer,
get_row_serializer_class,
)
@ -23,7 +24,7 @@ def rows_created(
model,
send_realtime_update=True,
send_webhook_events=True,
**kwargs
**kwargs,
):
if not send_realtime_update:
return
@ -57,7 +58,7 @@ def rows_updated(
before_return,
updated_field_ids,
before_rows_values,
**kwargs
**kwargs,
):
table_page_type = page_registry.get("table")
transaction.on_commit(
@ -111,6 +112,32 @@ def row_orders_recalculated(sender, table, **kwargs):
)
@receiver(row_signals.rows_history_updated)
def rows_history_updated(
sender,
table_id,
row_history_entries,
**kwargs,
):
row_page_type = page_registry.get("row")
def send_by_row():
for row_history_entry in row_history_entries:
serialized_entry = RowHistorySerializer(row_history_entry).data
row_page_type.broadcast(
{
"type": "row_history_updated",
"row_history_entry": serialized_entry,
"table_id": table_id,
"row_id": row_history_entry.row_id,
},
table_id=table_id,
row_id=row_history_entry.row_id,
)
transaction.on_commit(send_by_row)
class RealtimeRowMessages:
"""
A collection of functions which construct the payloads for the realtime

View file

@ -8,7 +8,7 @@ from baserow.contrib.database.table.models import Table
from baserow.contrib.database.table.object_scopes import DatabaseTableObjectScopeType
from baserow.contrib.database.table.operations import ReadDatabaseTableOperationType
from baserow.contrib.database.table.tasks import (
unsubscribe_user_from_table_currently_subscribed_to,
unsubscribe_user_from_tables_when_removed_from_workspace,
)
from baserow.core import signals as core_signals
from baserow.core.utils import generate_hash
@ -92,11 +92,9 @@ def tables_reordered(sender, database, order, user, **kwargs):
@receiver(core_signals.workspace_user_deleted)
def user_deleted_unsubscribe_from_page(
sender, workspace_user_id, workspace_user, user, **kwargs
):
def workspace_user_deleted(sender, workspace_user_id, workspace_user, user, **kwargs):
transaction.on_commit(
lambda: unsubscribe_user_from_table_currently_subscribed_to.delay(
lambda: unsubscribe_user_from_tables_when_removed_from_workspace.delay(
workspace_user.user_id, workspace_user.workspace_id
)
)

View file

@ -1,7 +1,129 @@
from dataclasses import dataclass
from operator import attrgetter
from typing import Optional
from django.contrib.auth.models import AbstractUser
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from baserow.ws.registries import page_registry
from baserow.ws.registries import PageType, page_registry
@dataclass
class PageContext:
"""
The context about a page and a user when the user
intends to be subscribed or unsubscribed from
getting changes relevant to the page.
"""
web_socket_id: str
user: Optional[AbstractUser]
resolved_page_type: PageType
page_scope: "PageScope"
@dataclass
class PageScope:
"""
Represents one page that a user can be subscribed to.
Different page parameters can be used to represent
subscriptions to the same page types with different
values (e.g. being subscribed to a table page with
table_id=1 or table_id=2)
"""
page_type: str
page_parameters: dict[str, any]
class SubscribedPages:
"""
Holds information about all pages a user is subscribed to.
"""
def __init__(self):
self.pages: list[PageScope] = []
def add(self, page_scope: PageScope):
"""
Adds a page to the list of subscribed pages.
:param page_scope: Page to add.
"""
if page_scope not in self.pages:
self.pages.append(page_scope)
def remove(self, page_scope: PageScope):
"""
Removes a page from the list of subscribed pages.
:param page_scope: Page to remove.
"""
try:
self.pages.remove(page_scope)
except ValueError:
pass
def is_page_in_permission_group(
self, page_scope: PageScope, group_name_to_check: str
) -> bool:
"""
Checks whether an instance of PageScope belongs to the provided
permission group.
:param page_scope: The page to check.
:param group_name_to_check: The permission group name that will be
compared to permission group name of the page.
:return: True if the page has the same permission group name.
"""
try:
page_type = page_registry.get(page_scope.page_type)
except page_registry.does_not_exist_exception_class:
return False
page_perm_group_name = page_type.get_permission_channel_group_name(
**page_scope.page_parameters
)
if page_perm_group_name == group_name_to_check:
return True
return False
def has_pages_with_permission_group(self, group_name_to_check: str) -> bool:
"""
Utility method that determines whether the list of subscribed
pages contains any page with the provided permission group.
This is useful to know for consumers using this class to determine
if by unsubscribing to a page they should unsubscribe from a
permission group as well or there are still pages that need the
same permission group.
:param group_name_to_check: The permission group name that will be
compared to permission group names of the subscribed pages.
:return: True if the list of subscribed pages contains any page matching
the provided permission group name.
"""
return any(
self.is_page_in_permission_group(page, group_name_to_check)
for page in self.pages
)
def copy(self):
new = SubscribedPages()
new.pages = self.pages.copy()
return new
def __len__(self):
return len(self.pages)
def __iter__(self):
return iter(self.pages)
class CoreConsumer(AsyncJsonWebsocketConsumer):
@ -23,43 +145,80 @@ class CoreConsumer(AsyncJsonWebsocketConsumer):
await self.close()
return
self.scope["pages"] = SubscribedPages()
await self.channel_layer.group_add("users", self.channel_name)
async def disconnect(self, message):
await self._remove_all_page_scopes()
await self.channel_layer.group_discard("users", self.channel_name)
async def receive_json(self, content, **parameters):
if "page" in content:
await self.add_to_page(content)
async def add_to_page(self, content):
"""
Subscribes the connection to a page abstraction. Based on the provided the page
type we can figure out to which page the connection wants to subscribe to. This
is for example used when the users visits a page that they might want to
receive real time updates for.
Processes incoming messages.
"""
:param content: The provided payload by the user. This should contain the page
type and additional parameters.
:type content: dict
if "page" in content:
await self._add_page_scope(content)
if "remove_page" in content:
await self._remove_page_scope(content)
async def _get_page_context(
self, content: dict, page_name_attr: str
) -> Optional[PageContext]:
"""
Helper method that will construct a PageContext object for adding
or removing page scopes from the consumer.
:param content: Dictionary representing the JSON that the client sent.
:param page_name_attr: Identifies the name of the parameter in the
content dictionary that refers to the page type.
"""
user = self.scope["user"]
web_socket_id = self.scope["web_socket_id"]
if not user:
return
# If the user has already joined another page we need to discard that
# page first before we can join a new one.
await self.discard_current_page()
return None
try:
page_type = page_registry.get(content["page"])
page_type = page_registry.get(content[page_name_attr])
except page_registry.does_not_exist_exception_class:
return
return None
parameters = {
parameter: content.get(parameter) for parameter in page_type.parameters
}
return PageContext(
page_scope=PageScope(
page_type=content[page_name_attr],
page_parameters=parameters,
),
resolved_page_type=page_type,
user=user,
web_socket_id=web_socket_id,
)
async def _add_page_scope(self, content: dict):
"""
Subscribes the connection to a page abstraction. Based on the provided page
type we can figure out to which page the connection wants to subscribe to. This
is for example used when the users visits a page that they might want to
receive real time updates for.
:param content: The provided payload by the user. This should contain the page
type and additional parameters.
"""
context = await self._get_page_context(content, "page")
if not context:
return
user, web_socket_id, page_type, parameters = attrgetter(
"user", "web_socket_id", "resolved_page_type", "page_scope.page_parameters"
)(context)
can_add = await database_sync_to_async(page_type.can_add)(
user, web_socket_id, **parameters
)
@ -69,40 +228,107 @@ class CoreConsumer(AsyncJsonWebsocketConsumer):
group_name = page_type.get_group_name(**parameters)
await self.channel_layer.group_add(group_name, self.channel_name)
self.scope["page"] = page_type
self.scope["page_parameters"] = parameters
permission_group_name = page_type.get_permission_channel_group_name(
**parameters
)
if permission_group_name:
await self.channel_layer.group_add(permission_group_name, self.channel_name)
page_scope = PageScope(page_type=page_type.type, page_parameters=parameters)
self.scope["pages"].add(page_scope)
await self.send_json(
{"type": "page_add", "page": page_type.type, "parameters": parameters}
)
async def discard_current_page(self, send_confirmation=True):
async def _remove_page_scope(self, content: dict, send_confirmation=True):
"""
If the user has subscribed to another page then they will be unsubscribed from
the last page.
Unsubscribes the connection from a page. Based on the provided page
type and its params we can figure out to which page the connection wants
to unsubscribe from.
:param content: The provided payload by the user. This should contain the page
type and additional parameters.
:param send_confirmation: If True, the client will receive a confirmation
message about unsubscribing.
"""
page = self.scope.get("page")
if not page:
context = await self._get_page_context(content, "remove_page")
if not context:
return
page_type = page.type
page_parameters = self.scope["page_parameters"]
user, page_type, parameters = attrgetter(
"user", "resolved_page_type", "page_scope.page_parameters"
)(context)
group_name = page.get_group_name(**self.scope["page_parameters"])
group_name = page_type.get_group_name(**parameters)
await self.channel_layer.group_discard(group_name, self.channel_name)
del self.scope["page"]
del self.scope["page_parameters"]
page_scope = PageScope(page_type=page_type.type, page_parameters=parameters)
self.scope["pages"].remove(page_scope)
permission_group_name = page_type.get_permission_channel_group_name(
**parameters
)
if permission_group_name and not self.scope[
"pages"
].has_pages_with_permission_group(permission_group_name):
await self.channel_layer.group_discard(
permission_group_name, self.channel_name
)
if send_confirmation:
await self.send_json(
{
"type": "page_discard",
"page": page_type,
"parameters": page_parameters,
"page": page_type.type,
"parameters": parameters,
}
)
async def _remove_all_page_scopes(self):
"""
Unsubscribes the connection from all currently subscribed pages.
"""
if self.scope.get("pages"):
for page_scope in self.scope["pages"].copy():
content = {
"user": self.scope["user"],
"web_socket_id": self.scope["web_socket_id"],
"remove_page": page_scope.page_type,
**page_scope.page_parameters,
}
await self._remove_page_scope(content, send_confirmation=True)
async def _remove_page_scopes_associated_with_perm_group(
self, permission_group_name: str
):
"""
Unsubscribes the connection from all currently subscribed pages associated
with the provided permission channel group.
:param permission_group_name: The name of the permission channel group.
"""
if self.scope.get("pages"):
for page_scope in self.scope["pages"].copy():
if self.scope["pages"].is_page_in_permission_group(
page_scope, permission_group_name
):
content = {
"user": self.scope["user"],
"web_socket_id": self.scope["web_socket_id"],
"remove_page": page_scope.page_type,
**page_scope.page_parameters,
}
await self._remove_page_scope(content, send_confirmation=True)
# Event handlers
async def broadcast_to_users(self, event):
"""
Broadcasts a message to all the users that are in the provided user_ids list.
@ -166,17 +392,20 @@ class CoreConsumer(AsyncJsonWebsocketConsumer):
if not ignore_web_socket_id or ignore_web_socket_id != web_socket_id:
await self.send_json(payload)
async def remove_user_from_group(self, event):
async def users_removed_from_permission_group(self, event):
"""
Event handler that reacts to a situation when one or many users were
revoked access to resources associated with a permission channel group.
When that happens the consumer has to check whether it is the consumer of
the involved user and if so, remove itself from all pages associated with
the permission channel group.
"""
user_ids_to_remove = event["user_ids_to_remove"]
user_id = self.scope["user"].id
page = self.scope.get("page")
if not page:
return
if user_id in user_ids_to_remove:
return await self.discard_current_page(True)
async def disconnect(self, message):
await self.discard_current_page(send_confirmation=False)
await self.channel_layer.group_discard("users", self.channel_name)
await self._remove_page_scopes_associated_with_perm_group(
event["permission_group_name"]
)

View file

@ -1,3 +1,5 @@
from typing import Optional
from baserow.core.registry import Instance, Registry
from baserow.ws.tasks import broadcast_to_channel_group
@ -60,6 +62,22 @@ class PageType(Instance):
"Each web socket page must have his own get_group_name method."
)
def get_permission_channel_group_name(self, **kwargs) -> Optional[str]:
"""
The generated name will be used by the core consumer to add the connected
client to a permission channel group so that the consumer can then listen
to permission changes and unsubscribe itself from channel groups where
permissions have been revoked.
The permission channel group is optional and so None can be returned which
will not add the consumer subscribing to the page to any permission groups.
:param kwargs: The additional parameters including their provided values.
:return: The permission group name relevant to the page.
"""
return None
def broadcast(self, payload, ignore_web_socket_id=None, **kwargs):
"""
Broadcasts a payload to everyone within the group.

View file

@ -3,14 +3,23 @@ from typing import Any, Dict, Iterable, List, Optional
from baserow.config.celery import app
async def closing_group_send(channel_layer, channel, message):
async def send_message_to_channel_group(
channel_layer, channel_group_name: str, message: dict
):
"""
Sends a message to a channel group.
All channel_layer.*send* methods must have close_pools called after due to a
bug in channels 4.0.0 as recommended on
https://github.com/django/channels_redis/issues/332
:param channel_layer: The channel layer instance to use.
:param channel_group_name: The channel group name identifying the channel group
that should receive the message.
:param messsage: JSON to send.
"""
await channel_layer.group_send(channel, message)
await channel_layer.group_send(channel_group_name, message)
if hasattr(channel_layer, "close_pools"):
# The inmemory channel layer in tests does not have this function.
await channel_layer.close_pools()
@ -42,7 +51,7 @@ def broadcast_to_users(
from channels.layers import get_channel_layer
channel_layer = get_channel_layer()
async_to_sync(closing_group_send)(
async_to_sync(send_message_to_channel_group)(
channel_layer,
"users",
{
@ -138,7 +147,7 @@ def broadcast_to_users_individual_payloads(
from channels.layers import get_channel_layer
channel_layer = get_channel_layer()
async_to_sync(closing_group_send)(
async_to_sync(send_message_to_channel_group)(
channel_layer,
"users",
{
@ -170,7 +179,7 @@ def broadcast_to_channel_group(self, workspace, payload, ignore_web_socket_id=No
from channels.layers import get_channel_layer
channel_layer = get_channel_layer()
async_to_sync(closing_group_send)(
async_to_sync(send_message_to_channel_group)(
channel_layer,
workspace,
{

View file

@ -76,7 +76,6 @@ def test_forwards_migration(data_fixture, migrator, teardown_table_metadata):
# noinspection PyPep8Naming
@pytest.mark.run(order=1)
@pytest.mark.once_per_day_in_ci
def test_backwards_migration(data_fixture, migrator, teardown_table_metadata):
migrate_from = [

View file

@ -0,0 +1,351 @@
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
import pytest
from channels.testing import WebsocketCommunicator
from baserow.config.asgi import application
from baserow.ws.auth import ANONYMOUS_USER_TOKEN
from baserow.ws.registries import page_registry
# TablePageType
@pytest.mark.django_db
@pytest.mark.websockets
def test_table_page_can_add(data_fixture):
table_page = page_registry.get("table")
user_1, token_1 = data_fixture.create_user_and_token()
user_2, token_2 = data_fixture.create_user_and_token()
user_1_websocket_id = 234
user_2_websocket_id = 235
anonymous_websocket_id = 123
table_1 = data_fixture.create_database_table(user=user_1)
# Success
table_page.can_add(user_1, user_1_websocket_id, table_1.id) is True
# Table doesn't exist
table_page.can_add(user_1, user_1_websocket_id, 999) is False
# User not in workspace
table_page.can_add(user_2, user_2_websocket_id, table_1.id) is False
# Permission denied
table_page.can_add(AnonymousUser(), anonymous_websocket_id, table_1.id) is False
@pytest.mark.websockets
def test_table_page_get_group_name(data_fixture):
table_page = page_registry.get("table")
table_id = 22
assert table_page.get_group_name(table_id) == "table-22"
@pytest.mark.websockets
def test_table_page_get_permission_channel_group_name(data_fixture):
table_page = page_registry.get("table")
table_id = 22
assert (
table_page.get_permission_channel_group_name(table_id) == "permissions-table-22"
)
@patch("baserow.ws.registries.broadcast_to_channel_group")
@pytest.mark.websockets
def test_table_page_broadcast(mock_broadcast_to_channel_group, data_fixture):
table_page = page_registry.get("table")
ignore_web_socket_id = 999
payload = {"sample": "payload"}
kwargs = {"table_id": 22}
table_page.broadcast(payload, ignore_web_socket_id, **kwargs)
mock_broadcast_to_channel_group.delay.assert_called_once()
args = mock_broadcast_to_channel_group.delay.call_args
assert args[0][0] == "table-22"
assert args[0][1] == payload
assert args[0][2] == ignore_web_socket_id
# PublicViewPageType
@pytest.mark.django_db
@pytest.mark.websockets
def test_public_view_page_can_add(data_fixture):
view_page = page_registry.get("view")
user_1, token_1 = data_fixture.create_user_and_token()
table_1 = data_fixture.create_database_table(user=user_1)
public_grid_view = data_fixture.create_grid_view(user_1, table=table_1, public=True)
non_public_grid_view = data_fixture.create_grid_view(
user_1, table=table_1, public=False
)
public_form_view_which_cant_be_subbed = data_fixture.create_form_view(
user_1, table=table_1, public=True
)
(
password_protected_grid_view,
public_view_token,
) = data_fixture.create_public_password_protected_grid_view_with_token( # nosec
user_1, table=table_1, password="99999999"
)
user_2, token_2 = data_fixture.create_user_and_token()
user_1_websocket_id = 234
user_2_websocket_id = 235
anonymous_websocket_id = 123
# Success
view_page.can_add(user_1, user_1_websocket_id, public_grid_view.slug) is True
view_page.can_add(
AnonymousUser(), anonymous_websocket_id, public_grid_view.slug
) is True
view_page.can_add(
user_1, user_1_websocket_id, password_protected_grid_view.slug
) is True
# View doesn't exist
view_page.can_add(user_1, user_1_websocket_id, "non-existing-slug") is False
# Not a public view
view_page.can_add(user_1, user_1_websocket_id, non_public_grid_view.slug) is False
# Some views don't have realtime events
view_page.can_add(
user_1, user_1_websocket_id, public_form_view_which_cant_be_subbed.slug
) is False
view_page.can_add(
AnonymousUser(),
anonymous_websocket_id,
public_form_view_which_cant_be_subbed.slug,
) is False
# Not allowed when view is password protected
view_page.can_add(
user_2, user_2_websocket_id, password_protected_grid_view.slug
) is False
view_page.can_add(
AnonymousUser(), anonymous_websocket_id, password_protected_grid_view.slug
) is False
@pytest.mark.websockets
def test_public_view_page_get_group_name(data_fixture):
view_page = page_registry.get("view")
slug = "public-view-slug"
assert view_page.get_group_name(slug) == "view-public-view-slug"
@patch("baserow.ws.registries.broadcast_to_channel_group")
@pytest.mark.websockets
def test_public_view_page_broadcast(mock_broadcast_to_channel_group, data_fixture):
view_page = page_registry.get("view")
ignore_web_socket_id = 999
payload = {"sample": "payload"}
kwargs = {"slug": "public-view-slug"}
view_page.broadcast(payload, ignore_web_socket_id, **kwargs)
mock_broadcast_to_channel_group.delay.assert_called_once()
args = mock_broadcast_to_channel_group.delay.call_args
assert args[0][0] == "view-public-view-slug"
assert args[0][1] == payload
assert args[0][2] == ignore_web_socket_id
# RowPageType
@pytest.mark.django_db
@pytest.mark.websockets
def test_row_page_can_add(data_fixture):
row_page = page_registry.get("row")
user_1, token_1 = data_fixture.create_user_and_token()
user_2, token_2 = data_fixture.create_user_and_token()
user_1_websocket_id = 234
user_2_websocket_id = 235
anonymous_websocket_id = 123
table_1 = data_fixture.create_database_table(user=user_1)
model = table_1.get_model()
row_1 = model.objects.create()
# Success
row_page.can_add(user_1, user_1_websocket_id, table_1.id, row_1.id) is True
# Row doesn't exist
row_page.can_add(user_1, user_1_websocket_id, table_1.id, 999) is False
# Table doesn't exist
row_page.can_add(user_1, user_1_websocket_id, 999, row_1.id) is False
# User not in workspace
row_page.can_add(user_2, user_2_websocket_id, table_1.id, row_1.id) is False
# Permission denied
row_page.can_add(
AnonymousUser(), anonymous_websocket_id, table_1.id, row_1.id
) is False
@pytest.mark.websockets
def test_row_page_get_group_name(data_fixture):
row_page = page_registry.get("row")
table_id = 22
row_id = 2
assert row_page.get_group_name(table_id, row_id) == "table-22-row-2"
@pytest.mark.websockets
def test_row_page_get_permission_channel_group_name(data_fixture):
row_page = page_registry.get("row")
table_id = 22
assert (
row_page.get_permission_channel_group_name(table_id) == "permissions-table-22"
)
@patch("baserow.ws.registries.broadcast_to_channel_group")
@pytest.mark.websockets
def test_row_page_broadcast(mock_broadcast_to_channel_group, data_fixture):
row_page = page_registry.get("row")
ignore_web_socket_id = 999
payload = {"sample": "payload"}
kwargs = {"table_id": 22, "row_id": 2}
row_page.broadcast(payload, ignore_web_socket_id, **kwargs)
mock_broadcast_to_channel_group.delay.assert_called_once()
args = mock_broadcast_to_channel_group.delay.call_args
assert args[0][0] == "table-22-row-2"
assert args[0][1] == payload
assert args[0][2] == ignore_web_socket_id
# Integration tests via CoreConsumer
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_join_view_page_as_anonymous_user(data_fixture):
user_1, token_1 = data_fixture.create_user_and_token()
table_1 = data_fixture.create_database_table(user=user_1)
public_grid_view = data_fixture.create_grid_view(user_1, table=table_1, public=True)
non_public_grid_view = data_fixture.create_grid_view(
user_1, table=table_1, public=False
)
public_form_view_which_cant_be_subbed = data_fixture.create_form_view(
user_1, table=table_1, public=True
)
(
password_protected_grid_view,
public_view_token,
) = data_fixture.create_public_password_protected_grid_view_with_token( # nosec
user_1, table=table_1, password="99999999"
)
communicator_1 = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={ANONYMOUS_USER_TOKEN}",
headers=[(b"origin", b"http://localhost")],
)
await communicator_1.connect()
await communicator_1.receive_json_from()
# Join the public view page.
await communicator_1.send_json_to({"page": "view", "slug": public_grid_view.slug})
response = await communicator_1.receive_json_from(0.1)
assert response["type"] == "page_add"
assert response["page"] == "view"
assert response["parameters"]["slug"] == public_grid_view.slug
# Cant join a table page.
await communicator_1.send_json_to({"page": "table", "table_id": table_1.id})
assert communicator_1.output_queue.qsize() == 0
# Can't join a non public grid view page
await communicator_1.send_json_to(
{"page": "view", "slug": non_public_grid_view.slug}
)
assert communicator_1.output_queue.qsize() == 0
# Can't join a public form view page as it has
# `FormViewTypewhen_shared_publicly_requires_realtime_events=False`
await communicator_1.send_json_to(
{"page": "view", "slug": public_form_view_which_cant_be_subbed.slug}
)
assert communicator_1.output_queue.qsize() == 0
# Can't join an invalid view page
await communicator_1.send_json_to({"page": "view", "slug": "invalid slug"})
assert communicator_1.output_queue.qsize() == 0
# Can't join a view page without a slug
await communicator_1.send_json_to({"page": "view"})
assert communicator_1.output_queue.qsize() == 0
# Can't join an invalid page
await communicator_1.send_json_to({"page": ""})
assert communicator_1.output_queue.qsize() == 0
# Can't join a password protected view page without a token
await communicator_1.send_json_to(
{"page": "view", "slug": password_protected_grid_view.slug}
)
assert communicator_1.output_queue.qsize() == 0
# Can't join a password protected view page with an invalid token
await communicator_1.send_json_to(
{
"page": "view",
"slug": password_protected_grid_view.slug,
"token": "invalid token",
}
)
assert communicator_1.output_queue.qsize() == 0
# Can connect to a password protected view page with a valid token
communicator_2 = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={ANONYMOUS_USER_TOKEN}",
headers=[(b"origin", b"http://localhost")],
)
await communicator_2.connect()
await communicator_2.receive_json_from()
await communicator_2.send_json_to(
{
"page": "view",
"slug": password_protected_grid_view.slug,
"token": public_view_token,
}
)
response = await communicator_2.receive_json_from(0.1)
assert response["type"] == "page_add"
assert response["page"] == "view"
assert response["parameters"]["slug"] == password_protected_grid_view.slug
# the owner of the view can still connect to the password protected view page
communicator_3 = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={token_1}",
headers=[(b"origin", b"http://localhost")],
)
await communicator_3.connect()
await communicator_3.receive_json_from()
await communicator_3.send_json_to(
{"page": "view", "slug": password_protected_grid_view.slug}
)
response = await communicator_3.receive_json_from(0.1)
assert response["type"] == "page_add"
assert response["page"] == "view"
assert response["parameters"]["slug"] == password_protected_grid_view.slug
await communicator_1.disconnect()
await communicator_2.disconnect()
await communicator_3.disconnect()

View file

@ -1,16 +1,21 @@
from collections import OrderedDict
from typing import Any, Dict, List
from unittest.mock import patch
from unittest.mock import call, patch
from django.db import transaction
import pytest
from freezegun import freeze_time
from rest_framework import serializers
from rest_framework.fields import Field
from baserow.contrib.database.rows.actions import UpdateRowsActionType
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.rows.registries import (
RowMetadataType,
row_metadata_registry,
)
from baserow.test_utils.helpers import register_instance_temporarily
from baserow.test_utils.helpers import AnyInt, register_instance_temporarily
@pytest.mark.django_db(transaction=True)
@ -191,3 +196,116 @@ def test_row_orders_recalculated(mock_broadcast_to_channel_group, data_fixture):
assert args[0][0] == f"table-{table.id}"
assert args[0][1]["type"] == "row_orders_recalculated"
assert args[0][1]["table_id"] == table.id
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_rows_history_updated(mock_broadcast_channel_group, data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
field = data_fixture.create_text_field(table=table)
model = table.get_model()
row1 = model.objects.create(**{f"field_{field.id}": "row 1"})
row2 = model.objects.create(**{f"field_{field.id}": "row 2"})
rows_values = [
{
"id": row1.id,
f"field_{field.id}": "row 1 updated",
},
{
"id": row2.id,
f"field_{field.id}": "row 2 updated",
},
]
with freeze_time("2023-03-30 00:00:00"), transaction.atomic():
UpdateRowsActionType.do(user, table, rows_values, model)
table_and_row_broadcast_calls = [
call.delay(
f"table-{table.id}",
{
"type": "rows_updated",
"table_id": table.id,
"rows_before_update": [
OrderedDict(
[
("id", row1.id),
("order", "1.00000000000000000000"),
(f"field_{field.id}", "row 1"),
]
),
OrderedDict(
[
("id", row2.id),
("order", "1.00000000000000000000"),
(f"field_{field.id}", "row 2"),
]
),
],
"rows": [
OrderedDict(
[
("id", row1.id),
("order", "1.00000000000000000000"),
(f"field_{field.id}", "row 1 updated"),
]
),
OrderedDict(
[
("id", row2.id),
("order", "1.00000000000000000000"),
(f"field_{field.id}", "row 2 updated"),
]
),
],
"metadata": {},
},
None,
),
call.delay(
f"table-{table.id}-row-{row1.id}",
{
"type": "row_history_updated",
"row_history_entry": {
"id": AnyInt(),
"action_type": "update_rows",
"user": OrderedDict([("id", user.id), ("name", user.first_name)]),
"timestamp": "2023-03-30T00:00:00Z",
"before": {f"field_{field.id}": "row 1"},
"after": {f"field_{field.id}": "row 1 updated"},
"fields_metadata": {
f"field_{field.id}": {"id": field.id, "type": "text"}
},
},
"table_id": table.id,
"row_id": row1.id,
},
None,
),
call.delay(
f"table-{table.id}-row-{row2.id}",
{
"type": "row_history_updated",
"row_history_entry": {
"id": AnyInt(),
"action_type": "update_rows",
"user": OrderedDict([("id", user.id), ("name", user.first_name)]),
"timestamp": "2023-03-30T00:00:00Z",
"before": {f"field_{field.id}": "row 2"},
"after": {f"field_{field.id}": "row 2 updated"},
"fields_metadata": {
f"field_{field.id}": {"id": field.id, "type": "text"}
},
},
"table_id": table.id,
"row_id": row2.id,
},
None,
),
]
assert mock_broadcast_channel_group.mock_calls == table_and_row_broadcast_calls

View file

@ -0,0 +1,106 @@
import pytest
from asgiref.sync import sync_to_async
from channels.layers import get_channel_layer
from channels.testing import WebsocketCommunicator
from baserow.config.asgi import application
from baserow.contrib.database.table.tasks import (
unsubscribe_user_from_tables_when_removed_from_workspace,
)
from baserow.ws.tasks import send_message_to_channel_group
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_unsubscribe_user_from_tables_and_rows_when_removed_from_workspace(
data_fixture,
):
channel_layer = get_channel_layer()
user_1, token_1 = data_fixture.create_user_and_token()
workspace_1 = data_fixture.create_workspace(user=user_1)
workspace_2 = data_fixture.create_workspace(user=user_1)
application_1 = data_fixture.create_database_application(
workspace=workspace_1, order=1
)
application_2 = data_fixture.create_database_application(
workspace=workspace_2, order=1
)
table_1 = data_fixture.create_database_table(database=application_1)
table_1_model = table_1.get_model()
row_1 = table_1_model.objects.create()
table_2 = data_fixture.create_database_table(database=application_2)
communicator = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={token_1}",
headers=[(b"origin", b"http://localhost")],
)
await communicator.connect()
response = await communicator.receive_json_from(timeout=0.1)
# Subscribe user to a table and a row from workspace 1
await communicator.send_json_to({"page": "table", "table_id": table_1.id})
response = await communicator.receive_json_from(timeout=0.1)
await communicator.send_json_to(
{"page": "row", "table_id": table_1.id, "row_id": row_1.id}
)
response = await communicator.receive_json_from(timeout=0.1)
# Subscribe user to a table from workspace 2
await communicator.send_json_to({"page": "table", "table_id": table_2.id})
response = await communicator.receive_json_from(timeout=0.1)
# Send a message to consumers that user was removed from the workspace 1
await sync_to_async(unsubscribe_user_from_tables_when_removed_from_workspace)(
user_1.id, workspace_1.id
)
# Receiving messages about being removed from the pages
response = await communicator.receive_json_from(timeout=0.1)
assert response == {
"page": "table",
"parameters": {
"table_id": table_1.id,
},
"type": "page_discard",
}
response = await communicator.receive_json_from(timeout=0.1)
assert response == {
"page": "row",
"parameters": {
"table_id": table_1.id,
"row_id": row_1.id,
},
"type": "page_discard",
}
# User should not receive any messages to a table in workspace 1
await send_message_to_channel_group(
channel_layer, f"table-{table_1.id}", {"test": "message"}
)
await communicator.receive_nothing(timeout=0.1)
# User should not receive any messages to a row in workspace 1
await send_message_to_channel_group(
channel_layer, f"table-{table_1.id}-row-{row_1.id}", {"test": "message"}
)
await communicator.receive_nothing(timeout=0.1)
# User should still receive messages to a table in workspace 2
await send_message_to_channel_group(
channel_layer,
f"table-{table_2.id}",
{
"type": "broadcast_to_group",
"payload": {"test": "message"},
"ignore_web_socket_id": None,
},
)
response = await communicator.receive_json_from(timeout=0.1)
assert response == {"test": "message"}
assert communicator.output_queue.qsize() == 0
await communicator.disconnect()

View file

@ -5,9 +5,9 @@ from baserow.config.asgi import application
from baserow.ws.auth import ANONYMOUS_USER_TOKEN, get_user
@pytest.mark.run(order=1)
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_get_user(data_fixture):
user, token = data_fixture.create_user_and_token()
@ -20,9 +20,9 @@ async def test_get_user(data_fixture):
assert anonymous_token_user.is_anonymous
@pytest.mark.run(order=2)
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_token_auth_middleware(data_fixture, settings):
user, token = data_fixture.create_user_and_token()

View file

@ -0,0 +1,399 @@
from unittest.mock import AsyncMock
import pytest
from channels.testing import WebsocketCommunicator
from baserow.config.asgi import application
from baserow.ws.auth import ANONYMOUS_USER_TOKEN
from baserow.ws.consumers import CoreConsumer, PageContext, PageScope, SubscribedPages
from baserow.ws.registries import PageType, page_registry
class AcceptingTestPageType(PageType):
type = "test_page_type"
parameters = ["test_param"]
def can_add(self, user, web_socket_id, test_param, **kwargs):
return True
def get_group_name(self, test_param, **kwargs):
return f"test-page-{test_param}"
def get_permission_channel_group_name(self, test_param, **kwargs):
return f"permissions-test-page-{test_param}"
class NotAcceptingTestPageType(AcceptingTestPageType):
type = "test_page_type_not_accepting"
def can_add(self, user, web_socket_id, test_param, **kwargs):
return False
class DifferentPermissionsGroupTestPageType(PageType):
type = "diff_perm_page_type"
parameters = ["test_param"]
def get_group_name(self, test_param, **kwargs):
return f"different-perm-group-{test_param}"
def get_permission_channel_group_name(self, test_param, **kwargs):
return f"permissions-different-perm-group-{test_param}"
@pytest.fixture
def test_page_types():
page_types = (
AcceptingTestPageType(),
NotAcceptingTestPageType(),
DifferentPermissionsGroupTestPageType(),
)
page_registry.register(page_types[0])
page_registry.register(page_types[1])
page_registry.register(page_types[2])
yield page_types
page_registry.unregister(AcceptingTestPageType.type)
page_registry.unregister(NotAcceptingTestPageType.type)
page_registry.unregister(DifferentPermissionsGroupTestPageType.type)
# Core consumer
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_core_consumer_connect_not_authenticated(data_fixture):
communicator = WebsocketCommunicator(
application,
f"ws/core/?jwt_token=",
headers=[(b"origin", b"http://localhost")],
)
connected, subprotocol = await communicator.connect()
assert connected is True
response = await communicator.receive_json_from()
assert response["type"] == "authentication"
assert response["success"] is False
assert response["web_socket_id"] is None
await communicator.disconnect()
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_core_consumer_connect_authenticated(data_fixture):
user_1, token_1 = data_fixture.create_user_and_token()
communicator = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={token_1}",
headers=[(b"origin", b"http://localhost")],
)
connected, subprotocol = await communicator.connect()
assert connected is True
response = await communicator.receive_json_from()
assert response["type"] == "authentication"
assert response["success"] is True
assert response["web_socket_id"] is not None
await communicator.disconnect()
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_core_consumer_connect_authenticated_anonymous(data_fixture):
user_1, token_1 = data_fixture.create_user_and_token()
communicator = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={ANONYMOUS_USER_TOKEN}",
headers=[(b"origin", b"http://localhost")],
)
connected, subprotocol = await communicator.connect()
assert connected is True
response = await communicator.receive_json_from()
assert response["type"] == "authentication"
assert response["success"] is True
assert response["web_socket_id"] is not None
await communicator.disconnect()
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_core_consumer_add_to_page_success(data_fixture, test_page_types):
user_1, token_1 = data_fixture.create_user_and_token()
communicator = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={token_1}",
headers=[(b"origin", b"http://localhost")],
)
await communicator.connect()
await communicator.receive_json_from()
await communicator.send_json_to({"page": "test_page_type", "test_param": 1})
response = await communicator.receive_json_from(timeout=0.1)
assert response["type"] == "page_add"
assert response["page"] == "test_page_type"
assert response["parameters"]["test_param"] == 1
# Adding again will have the same behavior
# but the page will still be subscribed only once
await communicator.send_json_to({"page": "test_page_type", "test_param": 1})
response = await communicator.receive_json_from(timeout=0.1)
assert response["type"] == "page_add"
assert response["page"] == "test_page_type"
assert response["parameters"]["test_param"] == 1
await communicator.disconnect()
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_core_consumer_add_page_doesnt_exist(data_fixture):
# When trying to subscribe to not existing page
# we do not expect the confirmation
communicator = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={ANONYMOUS_USER_TOKEN}",
headers=[(b"origin", b"http://localhost")],
)
await communicator.connect()
await communicator.receive_json_from()
await communicator.send_json_to({"page": "doesnt_exist", "test_param": 1})
assert communicator.output_queue.qsize() == 0
await communicator.disconnect()
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_core_consumer_add_to_page_failure(data_fixture, test_page_types):
user_1, token_1 = data_fixture.create_user_and_token()
communicator = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={token_1}",
headers=[(b"origin", b"http://localhost")],
)
await communicator.connect()
await communicator.receive_json_from()
# Page that will return False from can_add()
# won't send the confirmation
await communicator.send_json_to(
{"page": "test_page_type_not_accepting", "test_param": 1}
)
assert communicator.output_queue.qsize() == 0
await communicator.disconnect()
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_core_consumer_remove_page_success(data_fixture, test_page_types):
user_1, token_1 = data_fixture.create_user_and_token()
communicator = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={token_1}",
headers=[(b"origin", b"http://localhost")],
)
await communicator.connect()
await communicator.receive_json_from()
await communicator.send_json_to({"page": "test_page_type", "test_param": 1})
response = await communicator.receive_json_from(timeout=0.1)
await communicator.send_json_to({"remove_page": "test_page_type", "test_param": 1})
response = await communicator.receive_json_from(timeout=0.1)
assert response["type"] == "page_discard"
assert response["page"] == "test_page_type"
assert response["parameters"]["test_param"] == 1
# Removing a page will send a confirmation again
# even if it is unsubscribed already
await communicator.send_json_to({"remove_page": "test_page_type", "test_param": 1})
response = await communicator.receive_json_from(timeout=0.1)
assert response["type"] == "page_discard"
assert response["page"] == "test_page_type"
assert response["parameters"]["test_param"] == 1
await communicator.disconnect()
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_core_consumer_remove_page_doesnt_exist(data_fixture):
# When trying to unsubscribe from not existing page
# we do not expect the confirmation
communicator = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={ANONYMOUS_USER_TOKEN}",
headers=[(b"origin", b"http://localhost")],
)
await communicator.connect()
await communicator.receive_json_from()
await communicator.send_json_to({"page_remove": "doesnt_exist", "test_param": 1})
assert communicator.output_queue.qsize() == 0
await communicator.disconnect()
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_get_page_context(data_fixture, test_page_types):
page_type = test_page_types[0]
user_1, token_1 = data_fixture.create_user_and_token()
consumer = CoreConsumer()
consumer.scope = {}
consumer.scope["user"] = user_1
consumer.scope["web_socket_id"] = 234
content = {
"page": page_type.type,
"test_param": 2,
}
result = await consumer._get_page_context(content, "page")
assert result == PageContext(
resolved_page_type=page_type,
page_scope=PageScope(
page_type=page_type.type, page_parameters={"test_param": 2}
),
user=user_1,
web_socket_id=234,
)
# Not existing page type
content = {
"page": "doesnt_exist",
"test_param": 2,
}
result = await consumer._get_page_context(content, "page")
assert result is None
# Missing user
consumer.scope["user"] = None
content = {
"page": page_type.type,
"test_param": 2,
}
result = await consumer._get_page_context(content, "page")
assert result is None
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_core_consumer_remove_all_page_scopes(data_fixture, test_page_types):
user_1, token_1 = data_fixture.create_user_and_token()
scope_1 = PageScope("test_page_type", {"test_param": 1})
scope_2 = PageScope("test_page_type", {"test_param": 2})
pages = SubscribedPages()
pages.add(scope_1)
pages.add(scope_2)
consumer = CoreConsumer()
consumer.scope = {"pages": pages, "user": user_1, "web_socket_id": 123}
consumer.channel_name = "test_channel_name"
consumer.channel_layer = AsyncMock()
async def base_send(message):
pass
consumer.base_send = base_send
assert len(consumer.scope["pages"]) == 2
await consumer._remove_all_page_scopes()
assert len(consumer.scope["pages"]) == 0
# SubscribedPages
@pytest.mark.websockets
def test_subscribed_pages_adds_page_without_duplicates():
scope_1 = PageScope("test_page_type", {"test_param": 1})
scope_2 = PageScope("test_page_type", {"test_param": 2})
scope_3 = PageScope("test_page_type_not_accepting", {"test_param": 1})
scope_4 = PageScope("test_page_type", {"test_param": 1})
pages = SubscribedPages()
pages.add(scope_1)
pages.add(scope_2)
pages.add(scope_3)
pages.add(scope_4)
assert len(pages) == 3
@pytest.mark.websockets
def test_subscribed_pages_removes_pages_by_parameters():
scope_1 = PageScope("test_page_type", {"test_param": 1})
scope_2 = PageScope("test_page_type", {"test_param": 2})
scope_3 = PageScope("test_page_type", {"test_param": 3})
pages = SubscribedPages()
pages.add(scope_1)
pages.add(scope_2)
pages.add(scope_3)
assert len(pages) == 3
pages.remove(scope_2)
assert scope_1 in pages.pages
assert scope_2 not in pages.pages
assert scope_3 in pages.pages
@pytest.mark.websockets
def test_subscribed_pages_removes_pages_without_error():
scope_1 = PageScope("test_page_type", {"test_param": 1})
scope_2 = PageScope("test_page_type", {"test_param": 2})
pages = SubscribedPages()
pages.add(scope_1)
pages.add(scope_2)
assert len(pages) == 2
# should not throw error
pages.remove(scope_1)
pages.remove(scope_1)
pages.remove(scope_2)
pages.remove(scope_2)
assert len(pages) == 0
@pytest.mark.websockets
def test_subscribed_pages_is_page_in_permission_group(test_page_types):
scope_1 = PageScope("test_page_type", {"test_param": 1})
pages = SubscribedPages()
assert pages.is_page_in_permission_group(scope_1, "permissions-test-page-1") is True
assert (
pages.is_page_in_permission_group(scope_1, "permissions-different-perm-group-1")
is False
)
@pytest.mark.websockets
def test_subscribed_pages_has_pages_with_permission_group(test_page_types):
scope_1 = PageScope("test_page_type", {"test_param": 1})
pages = SubscribedPages()
pages.add(scope_1)
assert pages.has_pages_with_permission_group("permissions-test-page-1") is True
assert (
pages.has_pages_with_permission_group("permissions-different-perm-group-1")
is False
)

View file

@ -1,174 +0,0 @@
import pytest
from channels.testing import WebsocketCommunicator
from baserow.config.asgi import application
from baserow.ws.auth import ANONYMOUS_USER_TOKEN
@pytest.mark.run(order=3)
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
async def test_join_page(data_fixture):
user_1, token_1 = data_fixture.create_user_and_token()
table_1 = data_fixture.create_database_table(user=user_1)
communicator_1 = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={token_1}",
headers=[(b"origin", b"http://localhost")],
)
await communicator_1.connect()
await communicator_1.receive_json_from()
# Join the table page.
await communicator_1.send_json_to({"page": "table", "table_id": table_1.id})
response = await communicator_1.receive_json_from(0.1)
assert response["type"] == "page_add"
assert response["page"] == "table"
assert response["parameters"]["table_id"] == table_1.id
# When switching to a not existing page we expect to be discarded from the
# current page.
await communicator_1.send_json_to({"page": ""})
response = await communicator_1.receive_json_from(0.1)
assert response["type"] == "page_discard"
assert response["page"] == "table"
assert response["parameters"]["table_id"] == table_1.id
# When switching to a not existing page we do not expect the confirmation.
await communicator_1.send_json_to({"page": "NOT_EXISTING_PAGE"})
assert communicator_1.output_queue.qsize() == 0
await communicator_1.disconnect()
@pytest.mark.run(order=4)
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
async def test_join_page_as_anonymous_user(data_fixture):
user_1, token_1 = data_fixture.create_user_and_token()
table_1 = data_fixture.create_database_table(user=user_1)
public_grid_view = data_fixture.create_grid_view(user_1, table=table_1, public=True)
non_public_grid_view = data_fixture.create_grid_view(
user_1, table=table_1, public=False
)
public_form_view_which_cant_be_subbed = data_fixture.create_form_view(
user_1, table=table_1, public=True
)
(
password_protected_grid_view,
public_view_token,
) = data_fixture.create_public_password_protected_grid_view_with_token( # nosec
user_1, table=table_1, password="99999999"
)
communicator_1 = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={ANONYMOUS_USER_TOKEN}",
headers=[(b"origin", b"http://localhost")],
)
await communicator_1.connect()
await communicator_1.receive_json_from()
# Join the public view page.
await communicator_1.send_json_to({"page": "view", "slug": public_grid_view.slug})
response = await communicator_1.receive_json_from(0.1)
assert response["type"] == "page_add"
assert response["page"] == "view"
assert response["parameters"]["slug"] == public_grid_view.slug
# Cant join a table page.
# When switching to a page where the user cannot join we expect to be discarded from
# the current page.
await communicator_1.send_json_to({"page": "table", "table_id": table_1.id})
response = await communicator_1.receive_json_from(0.1)
assert response["type"] == "page_discard"
assert response["page"] == "view"
assert response["parameters"]["slug"] == public_grid_view.slug
# Can't join a non public grid view page
await communicator_1.send_json_to(
{"page": "view", "slug": non_public_grid_view.slug}
)
assert communicator_1.output_queue.qsize() == 0
await communicator_1.disconnect()
# Can't join a public form view page as it has
# `FormViewTypewhen_shared_publicly_requires_realtime_events=False`
await communicator_1.send_json_to(
{"page": "view", "slug": public_form_view_which_cant_be_subbed.slug}
)
assert communicator_1.output_queue.qsize() == 0
await communicator_1.disconnect()
# Can't join an invalid view page
await communicator_1.send_json_to({"page": "view", "slug": "invalid slug"})
assert communicator_1.output_queue.qsize() == 0
await communicator_1.disconnect()
# Can't join a view page without a slug
await communicator_1.send_json_to({"page": "view"})
assert communicator_1.output_queue.qsize() == 0
await communicator_1.disconnect()
# Can't join an invalid page
await communicator_1.send_json_to({"page": ""})
assert communicator_1.output_queue.qsize() == 0
await communicator_1.disconnect()
# Can't join a password protected view page without a token
await communicator_1.send_json_to(
{"page": "view", "slug": password_protected_grid_view.slug}
)
assert communicator_1.output_queue.qsize() == 0
await communicator_1.disconnect()
# Can't join a password protected view page with an invalid token
await communicator_1.send_json_to(
{
"page": "view",
"slug": password_protected_grid_view.slug,
"token": "invalid token",
}
)
assert communicator_1.output_queue.qsize() == 0
await communicator_1.disconnect()
# Can connect to a password protected view page with a valid token
communicator_2 = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={ANONYMOUS_USER_TOKEN}",
headers=[(b"origin", b"http://localhost")],
)
await communicator_2.connect()
await communicator_2.receive_json_from()
await communicator_2.send_json_to(
{
"page": "view",
"slug": password_protected_grid_view.slug,
"token": public_view_token,
}
)
response = await communicator_2.receive_json_from(0.1)
assert response["type"] == "page_add"
assert response["page"] == "view"
assert response["parameters"]["slug"] == password_protected_grid_view.slug
await communicator_2.disconnect()
# the owner of the view can still connect to the password protected view page
communicator_3 = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={token_1}",
headers=[(b"origin", b"http://localhost")],
)
await communicator_3.connect()
await communicator_3.receive_json_from()
await communicator_3.send_json_to(
{"page": "view", "slug": password_protected_grid_view.slug}
)
response = await communicator_3.receive_json_from(0.1)
assert response["type"] == "page_add"
assert response["page"] == "view"
assert response["parameters"]["slug"] == password_protected_grid_view.slug
await communicator_3.disconnect()

View file

@ -1,21 +0,0 @@
from unittest.mock import patch
from baserow.ws.registries import page_registry
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_broadcast(mock_broadcast, data_fixture):
table_page = page_registry.get("table")
table_page.broadcast({"message": "test"}, table_id=1)
mock_broadcast.delay.assert_called_once()
args = mock_broadcast.delay.call_args
assert args[0][0] == "table-1"
assert args[0][1]["message"] == "test"
assert args[0][2] is None
table_page.broadcast({"message": "test2"}, ignore_web_socket_id="123", table_id=2)
args = mock_broadcast.delay.call_args
assert args[0][0] == "table-2"
assert args[0][1]["message"] == "test2"
assert args[0][2] == "123"

View file

@ -17,8 +17,12 @@ from baserow.core.utils import generate_hash
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_group")
@patch("baserow.ws.signals.broadcast_to_groups")
def test_user_updated_name(mock_broadcast_to_workspaces, data_fixture):
@pytest.mark.websockets
def test_user_updated_name(
mock_broadcast_to_workspaces, mock_broadcast_to_workspace, data_fixture
):
user = data_fixture.create_user(first_name="Albert")
workspace_user = CoreHandler().create_workspace(user=user, name="Test")
workspace_user_2 = CoreHandler().create_workspace(user=user, name="Test 2")
@ -34,8 +38,12 @@ def test_user_updated_name(mock_broadcast_to_workspaces, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_group")
@patch("baserow.ws.signals.broadcast_to_groups")
def test_schedule_user_deletion(mock_broadcast_to_workspaces, data_fixture):
@pytest.mark.websockets
def test_schedule_user_deletion(
mock_broadcast_to_workspaces, mock_broadcast_to_workspace, data_fixture
):
user = data_fixture.create_user(first_name="Albert", password="albert")
workspace_user = CoreHandler().create_workspace(user=user, name="Test")
workspace_user_2 = CoreHandler().create_workspace(user=user, name="Test 2")
@ -50,8 +58,12 @@ def test_schedule_user_deletion(mock_broadcast_to_workspaces, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_group")
@patch("baserow.ws.signals.broadcast_to_groups")
def test_cancel_user_deletion(mock_broadcast_to_workspaces, data_fixture):
@pytest.mark.websockets
def test_cancel_user_deletion(
mock_broadcast_to_workspaces, mock_broadcast_to_workspace, data_fixture
):
user = data_fixture.create_user(first_name="Albert", password="albert")
user.profile.to_be_deleted = True
user.save()
@ -68,8 +80,12 @@ def test_cancel_user_deletion(mock_broadcast_to_workspaces, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_group")
@patch("baserow.ws.signals.broadcast_to_groups")
def test_user_permanently_deleted(mock_broadcast_to_workspaces, data_fixture):
@pytest.mark.websockets
def test_user_permanently_deleted(
mock_broadcast_to_workspaces, mock_broadcast_to_workspace, data_fixture
):
user = data_fixture.create_user(first_name="Albert", password="albert")
user.profile.to_be_deleted = True
user.profile.save()
@ -91,6 +107,7 @@ def test_user_permanently_deleted(mock_broadcast_to_workspaces, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_group")
@pytest.mark.websockets
def test_workspace_created(mock_broadcast_to_workspace, data_fixture):
user = data_fixture.create_user()
workspace_user = CoreHandler().create_workspace(user=user, name="Test")
@ -105,11 +122,12 @@ def test_workspace_created(mock_broadcast_to_workspace, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_users")
@pytest.mark.websockets
def test_workspace_restored(mock_broadcast_to_users, data_fixture):
user = data_fixture.create_user()
member_user = data_fixture.create_user()
# This user should not be sent the restore signal
data_fixture.create_user()
not_included_user = data_fixture.create_user()
workspace = data_fixture.create_workspace()
workspace_user = data_fixture.create_user_workspace(
user=user, workspace=workspace, permissions=WORKSPACE_USER_PERMISSION_ADMIN
@ -159,6 +177,7 @@ def test_workspace_restored(mock_broadcast_to_users, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_group")
@pytest.mark.websockets
def test_workspace_updated(mock_broadcast_to_workspace, data_fixture):
user = data_fixture.create_user()
user.web_socket_id = "test"
@ -182,6 +201,7 @@ def test_workspace_updated(mock_broadcast_to_workspace, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_users")
@pytest.mark.websockets
def test_workspace_deleted(mock_broadcast_to_users, data_fixture):
user = data_fixture.create_user()
workspace = data_fixture.create_workspace(user=user)
@ -199,8 +219,16 @@ def test_workspace_deleted(mock_broadcast_to_users, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.core.notifications.receivers.broadcast_to_users")
@patch("baserow.ws.signals.broadcast_to_users")
@patch("baserow.ws.signals.broadcast_to_group")
def test_workspace_user_added(mock_broadcast_to_workspace, data_fixture):
@pytest.mark.websockets
def test_workspace_user_added(
mock_broadcast_to_workspace,
mock_broadcast_to_users,
mock_broadcast_to_users2,
data_fixture,
):
user_1 = data_fixture.create_user()
user_2 = data_fixture.create_user()
workspace = data_fixture.create_workspace()
@ -231,8 +259,12 @@ def test_workspace_user_added(mock_broadcast_to_workspace, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow_enterprise.role.receivers.broadcast_to_users")
@patch("baserow.ws.signals.broadcast_to_group")
def test_workspace_user_updated(mock_broadcast_to_workspace, data_fixture):
@pytest.mark.websockets
def test_workspace_user_updated(
mock_broadcast_to_workspace, mock_broadcast_to_users, data_fixture
):
user_1 = data_fixture.create_user()
user_2 = data_fixture.create_user()
workspace = data_fixture.create_workspace()
@ -262,6 +294,7 @@ def test_workspace_user_updated(mock_broadcast_to_workspace, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_group")
@patch("baserow.ws.signals.broadcast_to_users")
@pytest.mark.websockets
def test_workspace_user_deleted(
mock_broadcast_to_users, mock_broadcast_to_workspace, data_fixture
):
@ -303,6 +336,7 @@ def test_workspace_user_deleted(
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_group")
@patch("baserow.ws.signals.broadcast_to_users")
@pytest.mark.websockets
def test_user_leaves_workspace(
mock_broadcast_to_users, mock_broadcast_to_workspace, data_fixture
):
@ -343,6 +377,7 @@ def test_user_leaves_workspace(
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_users")
@pytest.mark.websockets
def test_workspaces_reordered(mock_broadcast_to_users, data_fixture):
user = data_fixture.create_user()
workspace_1 = data_fixture.create_workspace(user=user)
@ -368,6 +403,7 @@ def test_workspaces_reordered(mock_broadcast_to_users, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_application_created")
@pytest.mark.websockets
def test_application_created(mock_broadcast_application_created, data_fixture):
user = data_fixture.create_user()
workspace = data_fixture.create_workspace(user=user)
@ -385,6 +421,7 @@ def test_application_created(mock_broadcast_application_created, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_permitted_users")
@pytest.mark.websockets
def test_application_updated(mock_broadcast_to_permitted_users, data_fixture):
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)
@ -399,6 +436,7 @@ def test_application_updated(mock_broadcast_to_permitted_users, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_permitted_users")
@pytest.mark.websockets
def test_application_deleted(mock_broadcast_to_permitted_users, data_fixture):
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)
@ -413,6 +451,7 @@ def test_application_deleted(mock_broadcast_to_permitted_users, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_to_group")
@pytest.mark.websockets
def test_applications_reordered(mock_broadcast_to_channel_group, data_fixture):
user = data_fixture.create_user()
workspace = data_fixture.create_workspace(user=user)
@ -432,6 +471,7 @@ def test_applications_reordered(mock_broadcast_to_channel_group, data_fixture):
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.signals.broadcast_application_created")
@pytest.mark.websockets
def test_duplicate_application(mock_broadcast_to_permitted_users, data_fixture):
user = data_fixture.create_user()
workspace = data_fixture.create_workspace(user=user)

View file

@ -13,9 +13,9 @@ from baserow.ws.tasks import (
)
@pytest.mark.run(order=4)
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_broadcast_to_users(data_fixture):
user_1, token_1 = data_fixture.create_user_and_token()
user_2, token_2 = data_fixture.create_user_and_token()
@ -59,9 +59,9 @@ async def test_broadcast_to_users(data_fixture):
await communicator_2.disconnect()
@pytest.mark.run(order=5)
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_broadcast_to_channel_group(data_fixture):
user_1, token_1 = data_fixture.create_user_and_token()
user_2, token_2 = data_fixture.create_user_and_token()
@ -123,10 +123,7 @@ async def test_broadcast_to_channel_group(data_fixture):
await communicator_2.receive_nothing(0.1)
await communicator_1.send_json_to({"page": "table", "table_id": table_3.id})
response = await communicator_1.receive_json_from(0.1)
assert response["type"] == "page_discard"
assert response["page"] == "table"
assert response["parameters"]["table_id"] == table_1.id
response = await communicator_1.receive_json_from(0.1)
assert response["type"] == "page_add"
assert response["page"] == "table"
@ -166,9 +163,9 @@ async def test_broadcast_to_channel_group(data_fixture):
await communicator_2.disconnect()
@pytest.mark.run(order=6)
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_broadcast_to_workspace(data_fixture):
user_1, token_1 = data_fixture.create_user_and_token()
user_2, token_2 = data_fixture.create_user_and_token()
@ -240,9 +237,9 @@ async def test_broadcast_to_workspace(data_fixture):
await communicator_3.disconnect()
@pytest.mark.run(order=6)
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_broadcast_to_workspaces(data_fixture):
user_1, token_1 = data_fixture.create_user_and_token()
user_2, token_2 = data_fixture.create_user_and_token()
@ -328,9 +325,9 @@ async def test_broadcast_to_workspaces(data_fixture):
await communicator_4.disconnect()
@pytest.mark.run(order=7)
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_can_broadcast_to_every_single_user(data_fixture):
user_1, token_1 = data_fixture.create_user_and_token()
user_2, token_2 = data_fixture.create_user_and_token()
@ -369,9 +366,9 @@ async def test_can_broadcast_to_every_single_user(data_fixture):
await communicator_2.disconnect()
@pytest.mark.run(order=8)
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_can_still_ignore_when_sending_to_all_users(data_fixture):
user_1, token_1 = data_fixture.create_user_and_token()
user_2, token_2 = data_fixture.create_user_and_token()
@ -411,9 +408,9 @@ async def test_can_still_ignore_when_sending_to_all_users(data_fixture):
await communicator_2.disconnect()
@pytest.mark.run(order=10)
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_broadcast_to_users_individual_payloads(data_fixture):
user_1, token_1 = data_fixture.create_user_and_token()
user_2, token_2 = data_fixture.create_user_and_token()

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Add ability to subscribe to multiple pages via websockets",
"issue_number": 2019,
"bullet_points": [],
"created_at": "2023-10-10"
}

View file

@ -88,14 +88,15 @@ WebSocketId: 934254ab-0c87-4dbc-9d71-7eeab029296c
A user will receive all the core messages related to workspaces and application by default,
but we also have messages related to certain pages, for example to the table page.
Because we don't want to cause an overload of messages you can subscribe to a page. If
successful you will only receive messages related to that page and you will
automatically be unsubscribed as soon as you subscribe to another page.
Because we don't want to cause an overload of messages you can subscribe to a page of your interest. It is also possible to be subscribed to multiple pages. If
successful you will receive messages related to that page. You will need to manually unsubscribe from a page to stop receiving updates.
### Table page
At the moment there is only one page, which is the table page and it expects a
`table_id` parameter. Below you will find an example how to subscribe to that page.
Subscribing to a table page will request updates related to a Baserow table that will
give you information about new rows, row updates, and other relevant information.
A table page expects the`table_id` parameter. Below you will find an example how to subscribe to that page.
```json
{
@ -117,6 +118,46 @@ are subscribed to the page.
}
```
### Row page
Subscribing to a Row page will request additional updates related to a Baserow row of a particular table that will give you information like row history updates. Please note that to get updates such as row deletions and similar, you should use the table page described above.
A Row page expects the`table_id` and `row_id` parameters. Below you will find an example how to subscribe to that page.
```json
{
"page": "row",
"table_id": 1,
"row_id": 1,
}
```
Once successfully subscribed you will receive a confirmation message indicating that you
are subscribed to the page.
```json
{
"type": "page_add",
"page": "row",
"parameters": {
"table_id": 1,
"row_id": 1,
}
}
```
## Unsubscribing from a page
To stop receiving updates related to a page you are subscribed to, you will need to send a `remove_page` message with the same page parameters that led to the subscription:
```json
{
"remove_page": "row",
"table_id": 1,
"row_id": 1,
}
```
## Messages types
* `authentication`
@ -154,6 +195,7 @@ are subscribed to the page.
* `rows_deleted`
* `before_rows_update`
* `before_rows_delete`
* `row_history_updated`
* `view_created`
* `view_updated`
* `view_deleted`

View file

@ -27,6 +27,7 @@ def use_async_event_loop_here(async_event_loop):
@pytest.mark.django_db(transaction=True)
@pytest.mark.asyncio
@pytest.mark.websockets
async def test_database_updated_message_not_leaking(data_fixture):
user = data_fixture.create_user()
user_excluded, token = data_fixture.create_user_and_token()
@ -57,6 +58,7 @@ async def test_database_updated_message_not_leaking(data_fixture):
@pytest.mark.django_db(transaction=True)
@pytest.mark.asyncio
@pytest.mark.websockets
async def test_database_deleted_message_not_leaking(data_fixture):
user = data_fixture.create_user()
user_excluded, token = data_fixture.create_user_and_token()
@ -87,6 +89,7 @@ async def test_database_deleted_message_not_leaking(data_fixture):
@pytest.mark.django_db(transaction=True)
@pytest.mark.asyncio
@pytest.mark.websockets
async def test_database_created_message_not_leaking(data_fixture):
user = data_fixture.create_user()
user_excluded, token = data_fixture.create_user_and_token()
@ -121,6 +124,7 @@ async def test_database_created_message_not_leaking(data_fixture):
@pytest.mark.django_db(transaction=True)
@pytest.mark.asyncio
@pytest.mark.websockets
async def test_workspace_restored_applications_arent_leaked(data_fixture):
user_excluded, token = data_fixture.create_user_and_token()
workspace = data_fixture.create_workspace(user=user_excluded)

View file

@ -3,10 +3,12 @@ from django.db import transaction
import pytest
from asgiref.sync import sync_to_async
from channels.layers import get_channel_layer
from channels.testing import WebsocketCommunicator
from baserow.config.asgi import application
from baserow.core.apps import sync_operations_after_migrate
from baserow.ws.tasks import send_message_to_channel_group
from baserow_enterprise.apps import sync_default_roles_after_migrate
from baserow_enterprise.role.constants import NO_ROLE_LOW_PRIORITY_ROLE_UID
from baserow_enterprise.role.handler import RoleAssignmentHandler
@ -49,6 +51,7 @@ def use_async_event_loop_here(async_event_loop):
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_unsubscribe_subject_from_table_role_deleted(data_fixture):
user, token = data_fixture.create_user_and_token()
workspace = data_fixture.create_workspace(members=[user])
@ -83,6 +86,7 @@ async def test_unsubscribe_subject_from_table_role_deleted(data_fixture):
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_unsubscribe_subject_from_table_role_no_role(data_fixture):
user, token = data_fixture.create_user_and_token()
workspace = data_fixture.create_workspace(members=[user])
@ -120,6 +124,7 @@ async def test_unsubscribe_subject_from_table_role_no_role(data_fixture):
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_unsubscribe_subject_from_table_unrelated_user(data_fixture):
user = data_fixture.create_user()
unrelated_user, token = data_fixture.create_user_and_token()
@ -155,6 +160,7 @@ async def test_unsubscribe_subject_from_table_unrelated_user(data_fixture):
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_unsubscribe_subject_from_table_new_role_no_access(data_fixture):
user, token = data_fixture.create_user_and_token()
workspace = data_fixture.create_workspace(members=[user])
@ -186,6 +192,7 @@ async def test_unsubscribe_subject_from_table_new_role_no_access(data_fixture):
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_unsubscribe_subject_from_table_role_updated(data_fixture):
user, token = data_fixture.create_user_and_token()
workspace = data_fixture.create_workspace(members=[user])
@ -223,6 +230,7 @@ async def test_unsubscribe_subject_from_table_role_updated(data_fixture):
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_unsubscribe_subject_from_table_should_still_have_access(data_fixture):
user, token = data_fixture.create_user_and_token()
workspace = data_fixture.create_workspace(user=user)
@ -260,6 +268,7 @@ async def test_unsubscribe_subject_from_table_should_still_have_access(data_fixt
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_unsubscribe_subject_from_table_teams(
data_fixture, enterprise_data_fixture
):
@ -300,6 +309,7 @@ async def test_unsubscribe_subject_from_table_teams(
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_unsubscribe_subject_from_table_teams_when_team_trashed(
data_fixture, enterprise_data_fixture
):
@ -338,6 +348,7 @@ async def test_unsubscribe_subject_from_table_teams_when_team_trashed(
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_unsubscribe_subject_from_table_teams_still_connected(
data_fixture, enterprise_data_fixture
):
@ -378,6 +389,7 @@ async def test_unsubscribe_subject_from_table_teams_still_connected(
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_unsubscribe_subject_from_table_teams_multiple_users(
data_fixture, enterprise_data_fixture
):
@ -431,3 +443,183 @@ async def test_unsubscribe_subject_from_table_teams_multiple_users(
assert await received_message(communicator_2, "page_discard") is True
await communicator.disconnect()
await communicator_2.disconnect()
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_unsubscribe_user_from_tables_and_rows_when_role_updated(data_fixture):
channel_layer = get_channel_layer()
user_1, token_1 = data_fixture.create_user_and_token()
workspace_1 = data_fixture.create_workspace(members=[user_1])
application_1 = data_fixture.create_database_application(
workspace=workspace_1, order=1
)
table_1 = data_fixture.create_database_table(database=application_1)
table_1_model = table_1.get_model()
row_1 = table_1_model.objects.create()
builder_role = Role.objects.get(uid="BUILDER")
no_access_role = Role.objects.get(uid="NO_ACCESS")
# Assign an initial role to the user
await sync_to_async(RoleAssignmentHandler().assign_role)(
user_1, workspace_1, builder_role
)
communicator = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={token_1}",
headers=[(b"origin", b"http://localhost")],
)
await communicator.connect()
response = await communicator.receive_json_from(timeout=0.1)
# Subscribe user to a table and a row from workspace 1
await communicator.send_json_to({"page": "table", "table_id": table_1.id})
response = await communicator.receive_json_from(timeout=0.1)
await communicator.send_json_to(
{"page": "row", "table_id": table_1.id, "row_id": row_1.id}
)
response = await communicator.receive_json_from(timeout=0.1)
# Remove role from user
await sync_to_async(RoleAssignmentHandler().assign_role)(
user_1, workspace_1, no_access_role
)
response = await communicator.receive_json_from(timeout=0.1)
# Receiving messages about being removed from the pages
response = await communicator.receive_json_from(timeout=0.1)
assert response == {
"page": "table",
"parameters": {
"table_id": table_1.id,
},
"type": "page_discard",
}
response = await communicator.receive_json_from(timeout=0.1)
assert response == {
"page": "row",
"parameters": {
"table_id": table_1.id,
"row_id": row_1.id,
},
"type": "page_discard",
}
response = await communicator.receive_json_from(timeout=0.1)
assert response == {
"group_id": workspace_1.id,
"type": "permissions_updated",
"workspace_id": workspace_1.id,
}
# User should not receive any messages to a table in workspace 1
await send_message_to_channel_group(
channel_layer, f"table-{table_1.id}", {"test": "message"}
)
await communicator.receive_nothing(timeout=0.1)
# User should not receive any messages to a row in workspace 1
await send_message_to_channel_group(
channel_layer, f"table-{table_1.id}-row-{row_1.id}", {"test": "message"}
)
await communicator.receive_nothing(timeout=0.1)
assert communicator.output_queue.qsize() == 0
await communicator.disconnect()
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
@pytest.mark.websockets
async def test_unsubscribe_user_from_tables_and_rows_when_team_trashed(
data_fixture, enterprise_data_fixture
):
channel_layer = get_channel_layer()
user_1, token_1 = data_fixture.create_user_and_token()
workspace_1 = data_fixture.create_workspace(
custom_permissions=[(user_1, "NO_ACCESS")]
)
team = enterprise_data_fixture.create_team(workspace=workspace_1)
application_1 = data_fixture.create_database_application(
workspace=workspace_1, order=1
)
table_1 = data_fixture.create_database_table(database=application_1)
table_1_model = table_1.get_model()
row_1 = table_1_model.objects.create()
builder_role = Role.objects.get(uid="BUILDER")
# Add user to team
enterprise_data_fixture.create_subject(team, user_1)
# Set initial role for team
await sync_to_async(RoleAssignmentHandler().assign_role)(
team, workspace_1, builder_role, table_1
)
communicator = WebsocketCommunicator(
application,
f"ws/core/?jwt_token={token_1}",
headers=[(b"origin", b"http://localhost")],
)
await communicator.connect()
response = await communicator.receive_json_from(timeout=0.1)
# Subscribe user to a table and a row from workspace 1
await communicator.send_json_to({"page": "table", "table_id": table_1.id})
response = await communicator.receive_json_from(timeout=0.1)
await communicator.send_json_to(
{"page": "row", "table_id": table_1.id, "row_id": row_1.id}
)
response = await communicator.receive_json_from(timeout=0.1)
# Team deleted
await sync_to_async(TeamHandler().delete_team)(user_1, team)
# Receiving messages about being removed from the pages
response = await communicator.receive_json_from(timeout=0.1)
assert response == {
"page": "table",
"parameters": {
"table_id": table_1.id,
},
"type": "page_discard",
}
response = await communicator.receive_json_from(timeout=0.1)
assert response == {
"page": "row",
"parameters": {
"table_id": table_1.id,
"row_id": row_1.id,
},
"type": "page_discard",
}
response = await communicator.receive_json_from(timeout=0.1)
assert response == {
"group_id": workspace_1.id,
"type": "permissions_updated",
"workspace_id": workspace_1.id,
}
# User should not receive any messages to a table in workspace 1
await send_message_to_channel_group(
channel_layer, f"table-{table_1.id}", {"test": "message"}
)
await communicator.receive_nothing(timeout=0.1)
# User should not receive any messages to a row in workspace 1
await send_message_to_channel_group(
channel_layer, f"table-{table_1.id}-row-{row_1.id}", {"test": "message"}
)
await communicator.receive_nothing(timeout=0.1)
assert communicator.output_queue.qsize() == 0
await communicator.disconnect()

View file

@ -1,11 +1,3 @@
.row-history-field-boolean__checkbox-icon {
margin: auto;
color: $color-success-600;
@include center-text(10px, 10px, 10px);
@include rounded($rounded);
}
.row-history-field-boolean__diff {
padding: 5px;
line-height: 10px;

View file

@ -10,9 +10,8 @@ export class RealTimeHandler {
this.reconnectTimeout = null
this.attempts = 0
this.events = {}
this.page = null
this.pageParameters = {}
this.subscribedToPage = true
this.pages = []
this.subscribedToPages = true
this.lastToken = null
this.authenticationSuccess = true
this.registerCoreEvents()
@ -64,8 +63,8 @@ export class RealTimeHandler {
// If the client needs to be subscribed to a page we can do that directly
// after connecting.
if (!this.subscribedToPage) {
this.subscribeToPage()
if (!this.subscribedToPages) {
this.subscribeToPages()
}
}
@ -99,7 +98,7 @@ export class RealTimeHandler {
this.connected = false
// By default the user not subscribed to a page a.k.a `null`, so if the current
// page is already null we can mark it as subscribed.
this.subscribedToPage = this.page === null
this.subscribedToPages = this.pages.length === 0
this.delayedReconnect()
}
}
@ -131,28 +130,72 @@ export class RealTimeHandler {
* opens a table page.
*/
subscribe(page, parameters) {
this.page = page
this.pageParameters = parameters
this.subscribedToPage = false
const pageScope = {
page,
parameters,
}
// If the client is already connected we can directly subscribe to the page.
if (this.connected) {
this.subscribeToPage()
if (
!this.pages.some(
(elem) => JSON.stringify(elem) === JSON.stringify(pageScope)
)
) {
this.pages.push(pageScope)
// If the client is already connected we can
// subscribe to updates for all pages.
if (this.connected) {
this.subscribeToPage(page, parameters)
} else {
this.subscribedToPages = false
}
}
}
/**
* Sends a request to the real time server that updates for a certain page +
* parameters must be received.
* Unsubscribes the client from a given page. The client will
* stop receiving updates related to that page.
*/
subscribeToPage() {
unsubscribe(page, parameters) {
this.pages = this.pages.filter(
(item) => JSON.stringify(item) !== JSON.stringify({ page, parameters })
)
this.socket.send(
JSON.stringify({
page: this.page === null ? '' : this.page,
...this.pageParameters,
remove_page: page,
...parameters,
})
)
this.subscribedToPage = true
}
/*
* Subscribes the client to a new page if the client is
* connected.
*/
subscribeToPage(page, parameters) {
if (this.connected) {
this.socket.send(
JSON.stringify({
page: page === null ? '' : page,
...parameters,
})
)
}
}
/**
* Requests real time updates for the list of pages that
* have been collected by the subscribe() call.
*/
subscribeToPages() {
if (this.subscribedToPages) {
return
}
for (const { page, parameters } of this.pages) {
this.subscribeToPage(page, parameters)
}
this.subscribedToPages = true
}
/**

View file

@ -1,5 +1,5 @@
<template>
<Modal @hidden="stopPollIfRunning()">
<Modal @hidden="hidden">
<div v-if="loadingViews" class="loading-overlay"></div>
<h2 class="box__title">Export {{ table.name }}</h2>
<Error :error="error"></Error>
@ -89,9 +89,8 @@ export default {
})
return show
},
hide(...args) {
hidden(...args) {
this.stopPollIfRunning()
return modal.methods.hide.call(this, ...args)
},
async fetchViews() {
if (this.table._.selected) {

View file

@ -6,7 +6,7 @@
:content-scrollable="hasRightSidebar"
:right-sidebar-scrollable="false"
:collapsible-right-sidebar="true"
@hidden="$emit('hidden', { row })"
@hidden="hidden"
>
<template #content>
<div v-if="enableNavigation" class="row-edit-modal__navigation">
@ -220,6 +220,13 @@ export default {
)
return activeSidebarTypes.length > 0
},
canSubscribeToRowUpdates() {
return this.$hasPermission(
'database.table.listen_to_all',
this.table,
this.database.workspace.id
)
},
},
watch: {
/**
@ -252,6 +259,22 @@ export default {
})
}
},
rowId(newValue, oldValue) {
if (this.canSubscribeToRowUpdates) {
if (oldValue > 0) {
this.$realtime.unsubscribe('row', {
table_id: this.table.id,
row_id: oldValue,
})
}
if (newValue > 0) {
this.$realtime.subscribe('row', {
table_id: this.table.id,
row_id: newValue,
})
}
}
},
},
methods: {
show(rowId, rowFallback = {}, ...args) {
@ -263,11 +286,23 @@ export default {
row: row || rowFallback,
exists: !!row,
})
if (this.canSubscribeToRowUpdates) {
this.$realtime.subscribe('row', {
table_id: this.table.id,
row_id: rowId,
})
}
this.getRootModal().show(...args)
},
hide(...args) {
hidden(...args) {
if (this.canSubscribeToRowUpdates) {
this.$realtime.unsubscribe('row', {
table_id: this.table.id,
row_id: this.rowId,
})
}
this.$store.dispatch('rowModal/clear', { componentId: this._uid })
this.getRootModal().hide(...args)
this.$emit('hidden', { row: this.row })
},
/**
* Because the modal can't update values by himself, an event will be called to

View file

@ -4,14 +4,14 @@
<div
class="row-history-entry__diff row-history-entry__diff--removed row-history-field-boolean__diff"
>
<i class="fas fa-check row-history-field-boolean__checkbox-icon"></i>
<i class="iconoir-check"></i>
</div>
</div>
<div v-if="entry.after[fieldIdentifier]">
<div
class="row-history-entry__diff row-history-entry__diff--added row-history-field-boolean__diff"
>
<i class="fas fa-check row-history-field-boolean__checkbox-icon"></i>
<i class="iconoir-check"></i>
</div>
</div>
</div>

View file

@ -115,6 +115,11 @@ export default {
return entriesToRender
},
},
watch: {
row(newRow, oldRow) {
this.initialLoad()
},
},
async created() {
await this.initialLoad()
},

View file

@ -226,7 +226,7 @@ export default {
this.$realtime.subscribe('table', { table_id: this.table.id })
},
beforeDestroy() {
this.$realtime.subscribe(null)
this.$realtime.unsubscribe('table', { table_id: this.table.id })
},
methods: {
selectedView(view) {

View file

@ -238,6 +238,15 @@ export const registerRealtimeEvents = (realtime) => {
}
})
realtime.registerEvent('row_history_updated', ({ store }, data) => {
const rowHistoryEntry = data.row_history_entry
store.dispatch('rowHistory/forceCreate', {
rowHistoryEntry,
rowId: data.row_id,
tableId: data.table_id,
})
})
realtime.registerEvent('view_created', ({ store }, data) => {
if (store.getters['table/getSelectedId'] === data.view.table_id) {
store.dispatch('view/forceCreate', { data: data.view })

View file

@ -56,14 +56,12 @@ export class HistoryRowModalSidebarType extends RowModalSidebarType {
isDeactivated(database, table) {
const featureFlags = getFeatureFlags(this.app.$config)
const featureFlagEnabled = featureFlagIsEnabled(featureFlags, 'row_history')
return !featureFlagEnabled
// TODO:
// return this.app.$hasPermission(
// 'database.table.read_row_history',
// table,
// database.workspace.id
// )
const hasPermissions = this.app.$hasPermission(
'database.table.read_row_history',
table,
database.workspace.id
)
return !featureFlagEnabled || !hasPermissions
}
isSelectedByDefault(database) {

View file

@ -1,3 +1,7 @@
import _ from 'lodash'
import moment from '@baserow/modules/core/moment'
import RowHistoryService from '@baserow/modules/database/services/rowHistory'
export const state = () => ({
@ -49,7 +53,6 @@ export const actions = {
const { data } = await RowHistoryService(this.$client).fetchAll({
tableId,
rowId,
limit: 5,
})
commit('ADD_ENTRIES', { entries: data.results })
commit('SET_TOTAL_COUNT', data.count)
@ -65,7 +68,6 @@ export const actions = {
const { data } = await RowHistoryService(this.$client).fetchAll({
tableId,
rowId,
limit: 5,
offset: getters.getCurrentCount,
})
commit('ADD_ENTRIES', { entries: data.results })
@ -74,11 +76,17 @@ export const actions = {
commit('SET_LOADING', false)
}
},
forceCreate({ commit, state }, { rowHistoryEntry, rowId, tableId }) {
if (state.loadedTableId === tableId && state.loadedRowId === rowId) {
commit('ADD_ENTRIES', { entries: [rowHistoryEntry] })
commit('SET_TOTAL_COUNT', state.totalCount + 1)
}
},
}
export const getters = {
getSortedEntries(state) {
return state.entries
return _.sortBy(state.entries, (e) => -moment.utc(e.timestamp))
},
getCurrentCount(state) {
return state.entries.length

View file

@ -88,6 +88,7 @@ export class TestApp {
this._realtime = {
registerEvent(e, f) {},
subscribe(e, f) {},
unsubscribe(e, f) {},
connect(a, b) {},
disconnect() {},
}