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:
parent
bc21092f4f
commit
a8940f2887
36 changed files with 1827 additions and 342 deletions
backend
changelog/entries/unreleased/feature
docs/apis
enterprise/backend/tests/baserow_enterprise_tests/ws
web-frontend
modules
core
database
test/helpers
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -10,3 +10,5 @@ rows_updated = Signal()
|
|||
rows_deleted = Signal()
|
||||
|
||||
row_orders_recalculated = Signal()
|
||||
|
||||
rows_history_updated = Signal()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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"]
|
||||
)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
{
|
||||
|
|
|
@ -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 = [
|
||||
|
|
351
backend/tests/baserow/contrib/database/ws/test_ws_pages.py
Normal file
351
backend/tests/baserow/contrib/database/ws/test_ws_pages.py
Normal 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()
|
|
@ -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
|
||||
|
|
106
backend/tests/baserow/contrib/database/ws/test_ws_table_tasks.py
Normal file
106
backend/tests/baserow/contrib/database/ws/test_ws_table_tasks.py
Normal 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()
|
|
@ -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()
|
||||
|
||||
|
|
399
backend/tests/baserow/ws/test_ws_consumers.py
Normal file
399
backend/tests/baserow/ws/test_ws_consumers.py
Normal 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
|
||||
)
|
|
@ -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()
|
|
@ -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"
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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`
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -115,6 +115,11 @@ export default {
|
|||
return entriesToRender
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
row(newRow, oldRow) {
|
||||
this.initialLoad()
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
await this.initialLoad()
|
||||
},
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -88,6 +88,7 @@ export class TestApp {
|
|||
this._realtime = {
|
||||
registerEvent(e, f) {},
|
||||
subscribe(e, f) {},
|
||||
unsubscribe(e, f) {},
|
||||
connect(a, b) {},
|
||||
disconnect() {},
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue