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

Realtime Public View Sharing

This commit is contained in:
Nigel Gott 2022-01-11 20:13:08 +00:00 committed by Bram Wiepjes
parent b450ac0393
commit 60eb85da4f
44 changed files with 3012 additions and 142 deletions

View file

@ -378,3 +378,7 @@ WEBHOOKS_REQUEST_TIMEOUT_SECONDS = 5
# https://stackoverflow.com/questions/62337379/how-to-append-nginx-ip-to-x-forwarded
# -for-in-kubernetes-nginx-ingress-controller
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS = bool(
os.getenv("DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS", "")
)

View file

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

View file

@ -28,7 +28,13 @@ from .exceptions import (
)
from .models import Field, SelectOption
from .registries import field_type_registry, field_converter_registry
from .signals import field_created, field_updated, field_deleted, field_restored
from .signals import (
field_created,
field_updated,
field_deleted,
field_restored,
before_field_deleted,
)
logger = logging.getLogger(__name__)
@ -491,6 +497,13 @@ class FieldHandler:
field_cache=update_collector
)
before_return = before_field_deleted.send(
self,
field_id=field.id,
field=field,
user=user,
)
TrashHandler.trash(
user,
group,
@ -521,6 +534,7 @@ class FieldHandler:
field=field,
related_fields=updated_fields,
user=user,
before_return=before_return,
)
update_collector.send_additional_field_updated_signals()
return updated_fields

View file

@ -5,3 +5,4 @@ field_created = Signal()
field_restored = Signal()
field_updated = Signal()
field_deleted = Signal()
before_field_deleted = Signal()

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.6 on 2022-01-11 11:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("database", "0058_fix_hanging_formula_field_metadata"),
]
operations = [
migrations.AlterField(
model_name="view",
name="public",
field=models.BooleanField(
db_index=True,
default=False,
help_text="Indicates whether the view is publicly accessible to "
"visitors.",
),
),
]

View file

@ -460,18 +460,25 @@ class RowHandler:
except model.DoesNotExist:
raise RowDoesNotExist(f"The row with id {row_id} does not exist.")
updated_fields = []
updated_field_ids = set()
for field_id, field in model._field_objects.items():
if field_id in values or field["name"] in values:
updated_field_ids.add(field_id)
updated_fields.append(field["field"])
before_return = before_row_update.send(
self, row=row, user=user, table=table, model=model
self,
row=row,
user=user,
table=table,
model=model,
updated_field_ids=updated_field_ids,
)
if user_field_names:
values = self.map_user_field_name_dict_to_internal(
model._field_objects, values
)
updated_fields = [
field["field"]
for field_id, field in model._field_objects.items()
if field_id in values or field["name"] in values
]
values = self.prepare_values(model._field_objects, values)
values, manytomany_values = self.extract_manytomany_values(values, model)
@ -508,6 +515,7 @@ class RowHandler:
table=table,
model=model,
before_return=before_return,
updated_field_ids=updated_field_ids,
)
return row
@ -544,7 +552,7 @@ class RowHandler:
raise RowDoesNotExist(f"The row with id {row_id} does not exist.")
before_return = before_row_update.send(
self, row=row, user=user, table=table, model=model
self, row=row, user=user, table=table, model=model, updated_field_ids=[]
)
row.order = self.get_order_before_row(before, model)
@ -574,6 +582,7 @@ class RowHandler:
table=table,
model=model,
before_return=before_return,
updated_field_ids=[],
)
return row

View file

@ -1,3 +1,7 @@
from collections import defaultdict
from copy import deepcopy
from typing import Dict, Any, List, Optional, Iterable
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.db.models import F
@ -44,6 +48,7 @@ from .signals import (
view_field_options_updated,
)
from .validators import EMPTY_VALUES
from ..table.models import Table, GeneratedTableModel
class ViewHandler:
@ -921,3 +926,147 @@ class ViewHandler:
)
return instance
def get_public_views_row_checker(self, table, model, updated_field_ids=None):
"""
Returns a CachingPublicViewRowChecker object which will have precalculated
information about the public views in the provided table to aid with quickly
checking which views a row in that table is visible in. If you will be updating
the row and reusing the checker you must provide an iterable of the field ids
that you will be updating in the row, otherwise the checker will cache the
first check per view/row.
:param table: The table the row is in.
:param model: The model of the table including all fields.
:param updated_field_ids: An optional iterable of field ids which will be
updated on rows passed to the checker. If the checker is used on the same
row multiple times and that row has been updated it will return invalid
results unless you have correctly populated this argument.
:return: A list of non-specific public view instances.
"""
return CachingPublicViewRowChecker(table, model, updated_field_ids)
def restrict_row_for_view(
self, view: View, serialized_row: Dict[str, Any]
) -> Dict[Any, Any]:
"""
Removes any fields which are hidden in the view from the provided serialized
row ensuring no data is leaked according to the views field options.
:param view: The view to restrict the row by.
:param serialized_row: A python dictionary which is the result of serializing
the row containing `field_XXX` keys per field value. It must not be a
serialized using user_field_names=True.
:return: A copy of the serialized_row with all hidden fields removed.
"""
return self.restrict_rows_for_view(view, [serialized_row])[0]
def restrict_rows_for_view(
self, view: View, serialized_rows: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""
Removes any fields which are hidden in the view from the provided serialized
row ensuring no data is leaked according to the views field options.
:param view: The view to restrict the row by.
:param serialized_rows: A list of python dictionaries which are the result of
serializing the rows containing `field_XXX` keys per field value. They
must not be serialized using user_field_names=True.
:return: A copy of the serialized_row with all hidden fields removed.
"""
view_type = view_type_registry.get_by_model(view.specific_class)
hidden_field_options = view_type.get_hidden_field_options(view)
restricted_rows = []
for serialized_row in serialized_rows:
row_copy = deepcopy(serialized_row)
for hidden_field_option in hidden_field_options:
row_copy.pop(f"field_{hidden_field_option.field_id}", None)
restricted_rows.append(row_copy)
return restricted_rows
class CachingPublicViewRowChecker:
"""
A helper class to check which public views a row is visible in. Will pre-calculate
upfront for a specific table which public views are always visible, which public
views can have row check results cached for and finally will pre-construct and
reuse querysets for performance reasons.
"""
def __init__(
self,
table: Table,
model: GeneratedTableModel,
updated_field_ids: Optional[Iterable[int]] = None,
):
self._public_views = (
table.view_set.filter(public=True).prefetch_related("viewfilter_set").all()
)
self._updated_field_ids = updated_field_ids
self._views_with_filters = []
self._always_visible_views = []
self._view_row_check_cache = defaultdict(dict)
handler = ViewHandler()
for view in self._public_views:
if len(view.viewfilter_set.all()) == 0:
# If there are no view filters for this view then any row must always
# be visible in this view
self._always_visible_views.append(view)
else:
filter_qs = handler.apply_filters(view, model.objects)
self._views_with_filters.append(
(
view,
filter_qs,
self._view_row_checks_can_be_cached(view),
)
)
def get_public_views_where_row_is_visible(self, row):
"""
WARNING: If you are reusing the same checker and calling this method with the
same row multiple times you must have correctly set which fields in the row
might be updated in the checkers initials `updated_field_ids` attribute. This
is because for a given view, if we know none of the fields it filters on
will be updated we can cache the first check of if that row exists as any
further changes to the row wont be affecting filtered fields. Hence
`updated_field_ids` needs to be set if you are ever changing the row and
reusing the same CachingPublicViewRowChecker instance.
:param row: A row in the checkers table.
:return: A list of views where the row is visible for this checkers table.
"""
views = []
for view, filter_qs, can_use_cache in self._views_with_filters:
if can_use_cache:
if row.id not in self._view_row_check_cache[view.id]:
self._view_row_check_cache[view.id][
row.id
] = self._check_row_visible(filter_qs, row)
if self._view_row_check_cache[view.id][row.id]:
views.append(view)
elif self._check_row_visible(filter_qs, row):
views.append(view)
return views + self._always_visible_views
# noinspection PyMethodMayBeStatic
def _check_row_visible(self, filter_qs, row):
return filter_qs.filter(id=row.id).exists()
def _view_row_checks_can_be_cached(self, view):
if self._updated_field_ids is None:
return True
for view_filter in view.viewfilter_set.all():
if view_filter.field_id in self._updated_field_ids:
# We found a view filter for a field which will be updated hence we
# need to check both before and after a row update occurs
return False
# Every single updated field does not have a filter on it, hence
# we only need to check if a given row is visible in the view once
# as any changes to the fields in said row wont be for fields with
# filters and so the result of the first check will be still
# valid for any subsequent checks.
return True

View file

@ -80,6 +80,7 @@ class View(
public = models.BooleanField(
default=False,
help_text="Indicates whether the view is publicly accessible to visitors.",
db_index=True,
)
def rotate_slug(self):
@ -93,22 +94,21 @@ class View(
queryset = View.objects.filter(table=table)
return cls.get_highest_order_of_queryset(queryset) + 1
def get_field_options(self, create_if_not_exists=False, fields=None):
def get_field_options(self, create_if_missing=False, fields=None):
"""
Each field can have unique options per view. This method returns those
options per field type and can optionally create the missing ones. This method
only works if the `field_options` property is a ManyToManyField with a relation
to a field options model.
:param create_if_not_exists: If true the missing GridViewFieldOptions are
:param create_if_missing: If true the missing GridViewFieldOptions are
going to be created. If a fields has been created at a later moment it
could be possible that they don't exist yet. If this value is True, the
missing relationships are created in that case.
:type create_if_not_exists: bool
:type create_if_missing: bool
:param fields: If all the fields related to the table of this grid view have
already been fetched, they can be provided here to avoid having to fetch
them for a second time. This is only needed if `create_if_not_exists` is
True.
them for a second time. This is only needed if `create_if_missing` is True.
:type fields: list
:return: A queryset containing all the field options of view.
:rtype: QuerySet
@ -135,7 +135,7 @@ class View(
field_options = get_queryset()
if create_if_not_exists:
if create_if_missing:
fields_queryset = Field.objects.filter(table_id=self.table.id)
if fields is None:

View file

@ -338,6 +338,35 @@ class ViewType(
:param view: The newly created view instance.
"""
def get_visible_field_options_in_order(self, view):
"""
Should return a queryset of all field options which are visible in the
provided view and in the order they appear in the view.
:param view: The view to query.
:type view: View
:return: A queryset of the views specific view options which are 'visible'
and in order.
"""
raise NotImplementedError(
"An exportable or publicly sharable view must " "implement this"
)
def get_hidden_field_options(self, view):
"""
Should return a queryset of all field options which are hidden in the
provided view.
:param view: The view to query.
:type view: View
:return: A queryset of the views specific view options which are 'hidden'.
"""
raise NotImplementedError(
"An exportable or publicly sharable view must " "implement this"
)
class ViewTypeRegistry(
APIUrlsRegistryMixin, CustomFieldsRegistryMixin, ModelRegistryMixin, Registry

View file

@ -118,11 +118,14 @@ class GridViewType(ViewType):
def get_visible_field_options_in_order(self, grid_view):
return (
grid_view.get_field_options(create_if_not_exists=True)
grid_view.get_field_options(create_if_missing=True)
.filter(hidden=False)
.order_by("-field__primary", "order", "field__id")
)
def get_hidden_field_options(self, grid_view):
return grid_view.get_field_options(create_if_missing=False).filter(hidden=True)
class GalleryViewType(ViewType):
type = "gallery"
@ -231,7 +234,7 @@ class GalleryViewType(ViewType):
visible.
"""
field_options = view.get_field_options(create_if_not_exists=True).order_by(
field_options = view.get_field_options(create_if_missing=True).order_by(
"field__id"
)
ids_to_update = [f.id for f in field_options[0:3]]

View file

@ -1,11 +1,14 @@
from django.dispatch import receiver
from typing import Iterable, Optional, Type
from django.db import transaction
from django.dispatch import receiver
from rest_framework.serializers import Serializer
from baserow.ws.registries import page_registry
from baserow.contrib.database.fields import signals as field_signals
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.api.fields.serializers import FieldSerializer
from baserow.contrib.database.fields import signals as field_signals
from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.ws.registries import page_registry
@receiver(field_signals.field_created)
@ -13,16 +16,10 @@ def field_created(sender, field, related_fields, user, **kwargs):
table_page_type = page_registry.get("table")
transaction.on_commit(
lambda: table_page_type.broadcast(
{
"type": "field_created",
"field": field_type_registry.get_serializer(
field, FieldSerializer
).data,
"related_fields": [
field_type_registry.get_serializer(f, FieldSerializer).data
for f in related_fields
],
},
RealtimeFieldMessages.field_created(
field,
related_fields,
),
getattr(user, "web_socket_id", None),
table_id=field.table_id,
)
@ -34,16 +31,10 @@ def field_restored(sender, field, related_fields, user, **kwargs):
table_page_type = page_registry.get("table")
transaction.on_commit(
lambda: table_page_type.broadcast(
{
"type": "field_restored",
"field": field_type_registry.get_serializer(
field, FieldSerializer
).data,
"related_fields": [
field_type_registry.get_serializer(f, FieldSerializer).data
for f in related_fields
],
},
RealtimeFieldMessages.field_restored(
field,
related_fields,
),
getattr(user, "web_socket_id", None),
table_id=field.table_id,
)
@ -55,17 +46,10 @@ def field_updated(sender, field, related_fields, user, **kwargs):
table_page_type = page_registry.get("table")
transaction.on_commit(
lambda: table_page_type.broadcast(
{
"type": "field_updated",
"field_id": field.id,
"field": field_type_registry.get_serializer(
field, FieldSerializer
).data,
"related_fields": [
field_type_registry.get_serializer(f, FieldSerializer).data
for f in related_fields
],
},
RealtimeFieldMessages.field_updated(
field,
related_fields,
),
getattr(user, "web_socket_id", None),
table_id=field.table_id,
)
@ -73,20 +57,110 @@ def field_updated(sender, field, related_fields, user, **kwargs):
@receiver(field_signals.field_deleted)
def field_deleted(sender, field_id, field, related_fields, user, **kwargs):
def field_deleted(
sender, field_id, field, related_fields, user, before_return, **kwargs
):
table_page_type = page_registry.get("table")
transaction.on_commit(
lambda: table_page_type.broadcast(
{
"type": "field_deleted",
"table_id": field.table_id,
"field_id": field_id,
"related_fields": [
field_type_registry.get_serializer(f, FieldSerializer).data
for f in related_fields
],
},
RealtimeFieldMessages.field_deleted(
field.table_id,
field_id,
related_fields,
),
getattr(user, "web_socket_id", None),
table_id=field.table_id,
)
)
class RealtimeFieldMessages:
"""
A collection of functions which construct the payloads for the realtime
websocket messages related to fields.
"""
@staticmethod
def serialize_field_for_websockets(
field: Field, field_serializer_class: Optional[Type[Serializer]] = None
):
if field_serializer_class is None:
field_serializer_class = FieldSerializer
return field_type_registry.get_serializer(field, field_serializer_class).data
@staticmethod
def serialize_fields_for_websockets(
fields: Iterable[Field],
field_serializer_class: Optional[Type[Serializer]] = None,
):
if field_serializer_class is None:
field_serializer_class = FieldSerializer
return [
field_type_registry.get_serializer(f, field_serializer_class).data
for f in fields
]
@staticmethod
def field_created(
field: Field,
related_fields: Iterable[Field],
field_serializer_class: Optional[Type[Serializer]] = None,
):
return {
"type": "field_created",
"field": RealtimeFieldMessages.serialize_field_for_websockets(
field, field_serializer_class=field_serializer_class
),
"related_fields": RealtimeFieldMessages.serialize_fields_for_websockets(
related_fields, field_serializer_class=field_serializer_class
),
}
@staticmethod
def field_restored(
field: Field,
related_fields: Iterable[Field],
field_serializer_class: Optional[Type[Serializer]] = None,
):
return {
"type": "field_restored",
"field": RealtimeFieldMessages.serialize_field_for_websockets(
field, field_serializer_class=field_serializer_class
),
"related_fields": RealtimeFieldMessages.serialize_fields_for_websockets(
related_fields, field_serializer_class=field_serializer_class
),
}
@staticmethod
def field_updated(
field: Field,
related_fields: Iterable[Field],
field_serializer_class: Optional[Type[Serializer]] = None,
):
return {
"type": "field_updated",
"field_id": field.id,
"field": RealtimeFieldMessages.serialize_field_for_websockets(
field, field_serializer_class=field_serializer_class
),
"related_fields": RealtimeFieldMessages.serialize_fields_for_websockets(
related_fields, field_serializer_class=field_serializer_class
),
}
@staticmethod
def field_deleted(
table_id: int,
field_id: int,
related_fields: Iterable[Field],
field_serializer_class: Optional[Type[Serializer]] = None,
):
return {
"type": "field_deleted",
"table_id": table_id,
"field_id": field_id,
"related_fields": RealtimeFieldMessages.serialize_fields_for_websockets(
related_fields, field_serializer_class=field_serializer_class
),
}

View file

@ -1,3 +1,8 @@
from django.conf import settings
from rest_framework.exceptions import NotAuthenticated
from baserow.contrib.database.views.exceptions import ViewDoesNotExist
from baserow.contrib.database.views.handler import ViewHandler
from baserow.ws.registries import PageType
from baserow.core.exceptions import UserNotInGroup
@ -22,10 +27,42 @@ class TablePageType(PageType):
handler = TableHandler()
table = handler.get_table(table_id)
table.database.group.has_user(user, raise_error=True)
except (UserNotInGroup, TableDoesNotExist):
except (UserNotInGroup, TableDoesNotExist, NotAuthenticated):
return False
return True
def get_group_name(self, table_id, **kwargs):
return f"table-{table_id}"
class PublicViewPageType(PageType):
type = "view"
parameters = ["slug"]
def can_add(self, user, web_socket_id, slug, **kwargs):
"""
The user should only have access to this page if the view exists and it is
public or they have access to the group.
"""
if settings.DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS:
return False
if not slug:
return False
try:
handler = ViewHandler()
handler.get_public_view_by_slug(user, slug)
except ViewDoesNotExist:
return False
return True
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)

View file

@ -0,0 +1,148 @@
from typing import List, Tuple, Set, Optional, Dict, Any, Iterable
from django.db import transaction
from django.dispatch import receiver
from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID
from baserow.contrib.database.api.views.grid.serializers import PublicFieldSerializer
from baserow.contrib.database.fields import signals as field_signals
from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.views.models import View
from baserow.contrib.database.views.registries import view_type_registry
from baserow.contrib.database.ws.fields.signals import RealtimeFieldMessages
from baserow.ws.registries import page_registry
def _broadcast_payload_to_views_with_restricted_related_fields(
payload: Dict[str, Any],
serialized_related_fields: List[Dict[str, Any]],
views_with_hidden_fields: List[Tuple[View, Set[int]]],
):
view_page_type = page_registry.get("view")
for view, hidden_fields in views_with_hidden_fields:
payload["related_fields"] = [
f for f in serialized_related_fields if f["id"] not in hidden_fields
]
view_page_type.broadcast(
payload,
None,
slug=view.slug,
)
def _send_payload_to_public_views_where_field_not_hidden(
field: Field, payload: Dict[str, Any]
):
related_fields = payload.pop("related_fields", [])
related_field_ids = [f["id"] for f in related_fields]
views_with_hidden_fields = _get_views_where_field_visible_and_hidden_fields_in_view(
field,
# Only bother calculating the hidden_fields set for the related_fields
hidden_fields_field_ids_filter=related_field_ids,
)
_broadcast_payload_to_views_with_restricted_related_fields(
payload, related_fields, views_with_hidden_fields
)
def _get_views_where_field_visible_and_hidden_fields_in_view(
field: Field,
hidden_fields_field_ids_filter: Optional[Iterable[int]] = None,
) -> List[Tuple[View, Set[int]]]:
"""
Finds all views where field is visible and also attaches the set of fields which
are hidden in said view.
:param field: All views where this field is visible will be returned.
:param hidden_fields_field_ids_filter: An optional filter which restricts the
calculation of whether a field is hidden or not in a returned view down to just
checking the fields in this iterable.
:return: A list of tuples where the first value is a view where field is visible
and the second is the set of field ids which are hidden in said view.
"""
views_where_field_was_visible = []
for view in field.table.view_set.filter(public=True):
view = view.specific
view_type = view_type_registry.get_by_model(view)
hidden_field_options_qs = view_type.get_hidden_field_options(view)
if hidden_fields_field_ids_filter is not None:
hidden_field_options_qs = hidden_field_options_qs.filter(
field_id__in=[field.id, *hidden_fields_field_ids_filter]
)
hidden_fields = set(hidden_field_options_qs.values_list("field_id", flat=True))
if field.id not in hidden_fields:
views_where_field_was_visible.append((view, hidden_fields))
return views_where_field_was_visible
@receiver(field_signals.field_created)
def public_field_created(sender, field, related_fields, user, **kwargs):
transaction.on_commit(
lambda: _send_payload_to_public_views_where_field_not_hidden(
field,
RealtimeFieldMessages.field_created(
field, related_fields, field_serializer_class=PublicFieldSerializer
),
)
)
@receiver(field_signals.field_restored)
def public_field_restored(sender, field, related_fields, user, **kwargs):
transaction.on_commit(
lambda: _send_payload_to_public_views_where_field_not_hidden(
field,
RealtimeFieldMessages.field_restored(
field, related_fields, field_serializer_class=PublicFieldSerializer
),
)
)
@receiver(field_signals.field_updated)
def public_field_updated(sender, field, related_fields, user, **kwargs):
transaction.on_commit(
lambda: _send_payload_to_public_views_where_field_not_hidden(
field,
RealtimeFieldMessages.field_updated(
field, related_fields, field_serializer_class=PublicFieldSerializer
),
)
)
@receiver(field_signals.before_field_deleted)
def public_before_field_deleted(sender, field_id, field, user, **kwargs):
# We have to check where the field is visible before it is deleted.
return _get_views_where_field_visible_and_hidden_fields_in_view(
field,
# We don't know yet which fields will be related_fields so calculate the
# hidden_fields set for all fields in the view as any could potentially be
# a related_field.
hidden_fields_field_ids_filter=None,
)
@receiver(field_signals.field_deleted)
def public_field_deleted(
sender, field_id, field, related_fields, user, before_return, **kwargs
):
def send_deleted():
views = dict(before_return)[public_before_field_deleted]
payload = RealtimeFieldMessages.field_deleted(
PUBLIC_PLACEHOLDER_ENTITY_ID,
field_id,
related_fields,
field_serializer_class=PublicFieldSerializer,
)
serialized_related_fields = payload.pop("related_fields", [])
_broadcast_payload_to_views_with_restricted_related_fields(
payload, serialized_related_fields, views
)
transaction.on_commit(send_deleted)

View file

@ -0,0 +1,182 @@
from typing import Optional, Any, Dict, Iterable
from django.db import transaction
from django.dispatch import receiver
from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID
from baserow.contrib.database.api.rows.serializers import (
get_row_serializer_class,
RowSerializer,
)
from baserow.contrib.database.rows import signals as row_signals
from baserow.contrib.database.table.models import GeneratedTableModel
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.models import View
from baserow.contrib.database.ws.rows.signals import RealtimeRowMessages
from baserow.ws.registries import page_registry
def _serialize_row(model, row):
return get_row_serializer_class(model, RowSerializer, is_response=True)(row).data
def _send_row_created_event_to_views(
serialized_row: Dict[Any, Any],
before: Optional[GeneratedTableModel],
public_views: Iterable[View],
):
view_page_type = page_registry.get("view")
handler = ViewHandler()
for public_view in public_views:
restricted_serialized_row = handler.restrict_row_for_view(
public_view, serialized_row
)
view_page_type.broadcast(
RealtimeRowMessages.row_created(
table_id=PUBLIC_PLACEHOLDER_ENTITY_ID,
serialized_row=restricted_serialized_row,
metadata={},
before=before,
),
slug=public_view.slug,
)
def _send_row_deleted_event_to_views(
serialized_deleted_row: Dict[Any, Any], public_views: Iterable[View]
):
view_page_type = page_registry.get("view")
handler = ViewHandler()
for public_view in public_views:
restricted_serialized_deleted_row = handler.restrict_row_for_view(
public_view, serialized_deleted_row
)
view_page_type.broadcast(
RealtimeRowMessages.row_deleted(
table_id=PUBLIC_PLACEHOLDER_ENTITY_ID,
serialized_row=restricted_serialized_deleted_row,
),
slug=public_view.slug,
)
@receiver(row_signals.row_created)
def public_row_created(sender, row, before, user, table, model, **kwargs):
row_checker = ViewHandler().get_public_views_row_checker(table, model)
transaction.on_commit(
lambda: _send_row_created_event_to_views(
_serialize_row(model, row),
before,
row_checker.get_public_views_where_row_is_visible(row),
),
)
@receiver(row_signals.before_row_delete)
def public_before_row_delete(sender, row, user, table, model, **kwargs):
row_checker = ViewHandler().get_public_views_row_checker(table, model)
return {
"deleted_row_public_views": (
row_checker.get_public_views_where_row_is_visible(row)
),
"deleted_row": _serialize_row(model, row),
}
@receiver(row_signals.row_deleted)
def public_row_deleted(
sender, row_id, row, user, table, model, before_return, **kwargs
):
public_views = dict(before_return)[public_before_row_delete][
"deleted_row_public_views"
]
serialized_deleted_row = dict(before_return)[public_before_row_delete][
"deleted_row"
]
transaction.on_commit(
lambda: _send_row_deleted_event_to_views(serialized_deleted_row, public_views)
)
@receiver(row_signals.before_row_update)
def public_before_row_update(
sender, row, user, table, model, updated_field_ids, **kwargs
):
# Generate a serialized version of the row before it is updated. The
# `row_updated` receiver needs this serialized version because it can't serialize
# the old row after it has been updated.
row_checker = ViewHandler().get_public_views_row_checker(
table, model, updated_field_ids=updated_field_ids
)
return {
"old_row": _serialize_row(model, row),
"old_row_public_views": row_checker.get_public_views_where_row_is_visible(row),
"caching_row_checker": row_checker,
}
@receiver(row_signals.row_updated)
def public_row_updated(
sender, row, user, table, model, before_return, updated_field_ids, **kwargs
):
before_return_dict = dict(before_return)[public_before_row_update]
serialized_old_row = before_return_dict["old_row"]
serialized_updated_row = _serialize_row(model, row)
old_row_public_views = before_return_dict["old_row_public_views"]
existing_checker = before_return_dict["caching_row_checker"]
views = existing_checker.get_public_views_where_row_is_visible(row)
updated_row_public_views = {view.slug: view for view in views}
# When a row is updated from the point of view of a public view it might not always
# result in a `row_updated` event. For example if the row was previously not visible
# in the public view due to its filters, but the row update makes it now match
# the filters we want to send a `row_created` event to that views page as the
# clients won't know anything about the row and hence a `row_updated` event makes
# no sense for them.
public_views_where_row_was_deleted = []
public_views_where_row_was_updated = []
for old_row_view in old_row_public_views:
updated_row_view = updated_row_public_views.pop(old_row_view.slug, None)
if updated_row_view is None:
# The updated row is no longer visible in `old_row_view` hence we should
# send that view a deleted event.
public_views_where_row_was_deleted.append(old_row_view)
else:
# The updated row is still visible so here we want a normal updated event.
public_views_where_row_was_updated.append(old_row_view)
# Any remaining views in the updated_row_public_views dict are views which
# previously didn't show the old row, but now show the new row, so we want created.
public_views_where_row_was_created = updated_row_public_views.values()
def _send_created_updated_deleted_row_signals_to_views():
_send_row_deleted_event_to_views(
serialized_old_row, public_views_where_row_was_deleted
)
_send_row_created_event_to_views(
serialized_updated_row,
before=None,
public_views=public_views_where_row_was_created,
)
view_page_type = page_registry.get("view")
handler = ViewHandler()
for public_view in public_views_where_row_was_updated:
(
visible_fields_only_updated_row,
visible_fields_only_old_row,
) = handler.restrict_rows_for_view(
public_view, [serialized_updated_row, serialized_old_row]
)
view_page_type.broadcast(
RealtimeRowMessages.row_updated(
table_id=PUBLIC_PLACEHOLDER_ENTITY_ID,
serialized_row_before_update=visible_fields_only_old_row,
serialized_row=visible_fields_only_updated_row,
metadata={},
),
slug=public_view.slug,
)
transaction.on_commit(_send_created_updated_deleted_row_signals_to_views)

View file

@ -0,0 +1,58 @@
from django.db import transaction
from django.dispatch import receiver
from baserow.contrib.database.api.views.grid.serializers import PublicFieldSerializer
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.views import signals as view_signals
from baserow.contrib.database.views.registries import view_type_registry
from baserow.ws.registries import page_registry
def _send_force_rows_refresh_if_view_public(view):
view_page_type = page_registry.get("view")
if view.public:
transaction.on_commit(
lambda: view_page_type.broadcast(
{"type": "force_view_rows_refresh", "view_id": view.slug},
None,
slug=view.slug,
)
)
@receiver(view_signals.view_updated)
def public_view_updated(sender, view, user, **kwargs):
_send_force_rows_refresh_if_view_public(view)
@receiver(view_signals.view_filter_created)
def public_view_filter_created(sender, view_filter, user, **kwargs):
_send_force_rows_refresh_if_view_public(view_filter.view)
@receiver(view_signals.view_filter_updated)
def public_view_filter_updated(sender, view_filter, user, **kwargs):
_send_force_rows_refresh_if_view_public(view_filter.view)
@receiver(view_signals.view_filter_deleted)
def public_view_filter_deleted(sender, view_filter_id, view_filter, user, **kwargs):
_send_force_rows_refresh_if_view_public(view_filter.view)
@receiver(view_signals.view_field_options_updated)
def public_view_field_options_updated(sender, view, user, **kwargs):
if view.public:
view = view.specific
view_page_type = page_registry.get("view")
view_type = view_type_registry.get_by_model(view)
field_options = view_type.get_visible_field_options_in_order(view)
fields = [
field_type_registry.get_serializer(o.field, PublicFieldSerializer).data
for o in field_options.select_related("field")
]
view_page_type.broadcast(
{"type": "force_view_refresh", "view_id": view.slug, "fields": fields},
None,
slug=view.slug,
)

View file

@ -1,14 +1,16 @@
from django.dispatch import receiver
from typing import Dict, Any, Optional
from django.db import transaction
from django.dispatch import receiver
from baserow.contrib.database.rows.registries import row_metadata_registry
from baserow.ws.registries import page_registry
from baserow.contrib.database.rows import signals as row_signals
from baserow.contrib.database.api.rows.serializers import (
get_row_serializer_class,
RowSerializer,
)
from baserow.contrib.database.rows import signals as row_signals
from baserow.contrib.database.rows.registries import row_metadata_registry
from baserow.contrib.database.table.models import GeneratedTableModel
from baserow.ws.registries import page_registry
@receiver(row_signals.row_created)
@ -16,17 +18,16 @@ def row_created(sender, row, before, user, table, model, **kwargs):
table_page_type = page_registry.get("table")
transaction.on_commit(
lambda: table_page_type.broadcast(
{
"type": "row_created",
"table_id": table.id,
"row": get_row_serializer_class(model, RowSerializer, is_response=True)(
row
).data,
"metadata": row_metadata_registry.generate_and_merge_metadata_for_row(
RealtimeRowMessages.row_created(
table_id=table.id,
serialized_row=get_row_serializer_class(
model, RowSerializer, is_response=True
)(row).data,
metadata=row_metadata_registry.generate_and_merge_metadata_for_row(
table, row.id
),
"before_row_id": before.id if before else None,
},
before=before,
),
getattr(user, "web_socket_id", None),
table_id=table.id,
)
@ -34,7 +35,7 @@ def row_created(sender, row, before, user, table, model, **kwargs):
@receiver(row_signals.before_row_update)
def before_row_update(sender, row, user, table, model, **kwargs):
def before_row_update(sender, row, user, table, model, updated_field_ids, **kwargs):
# Generate a serialized version of the row before it is updated. The
# `row_updated` receiver needs this serialized version because it can't serialize
# the old row after it has been updated.
@ -42,24 +43,22 @@ def before_row_update(sender, row, user, table, model, **kwargs):
@receiver(row_signals.row_updated)
def row_updated(sender, row, user, table, model, before_return, **kwargs):
def row_updated(
sender, row, user, table, model, before_return, updated_field_ids, **kwargs
):
table_page_type = page_registry.get("table")
transaction.on_commit(
lambda: table_page_type.broadcast(
{
"type": "row_updated",
"table_id": table.id,
# The web-frontend expects a serialized version of the row before it
# was updated in order the estimate what position the row had in the
# view.
"row_before_update": dict(before_return)[before_row_update],
"row": get_row_serializer_class(model, RowSerializer, is_response=True)(
row
).data,
"metadata": row_metadata_registry.generate_and_merge_metadata_for_row(
RealtimeRowMessages.row_updated(
table_id=table.id,
serialized_row_before_update=dict(before_return)[before_row_update],
serialized_row=get_row_serializer_class(
model, RowSerializer, is_response=True
)(row).data,
metadata=row_metadata_registry.generate_and_merge_metadata_for_row(
table, row.id
),
},
),
getattr(user, "web_socket_id", None),
table_id=table.id,
)
@ -79,15 +78,62 @@ def row_deleted(sender, row_id, row, user, table, model, before_return, **kwargs
table_page_type = page_registry.get("table")
transaction.on_commit(
lambda: table_page_type.broadcast(
{
"type": "row_deleted",
"table_id": table.id,
"row_id": row_id,
# The web-frontend expects a serialized version of the row that is
# deleted in order the estimate what position the row had in the view.
"row": dict(before_return)[before_row_delete],
},
RealtimeRowMessages.row_deleted(
table_id=table.id, serialized_row=dict(before_return)[before_row_delete]
),
getattr(user, "web_socket_id", None),
table_id=table.id,
)
)
class RealtimeRowMessages:
"""
A collection of functions which construct the payloads for the realtime
websocket messages related to rows.
"""
@staticmethod
def row_deleted(table_id: int, serialized_row: Dict[str, Any]) -> Dict[str, Any]:
return {
"type": "row_deleted",
"table_id": table_id,
"row_id": serialized_row["id"],
# The web-frontend expects a serialized version of the row that is
# deleted in order the estimate what position the row had in the view,
# or find which kanban column the row was in etc.
"row": serialized_row,
}
@staticmethod
def row_created(
table_id: int,
serialized_row: Dict[str, Any],
metadata: Dict[str, Any],
before: Optional[GeneratedTableModel],
) -> Dict[str, Any]:
return {
"type": "row_created",
"table_id": table_id,
"row": serialized_row,
"metadata": metadata,
"before_row_id": before.id if before else None,
}
@staticmethod
def row_updated(
table_id: int,
serialized_row_before_update: Dict[str, Any],
serialized_row: Dict[str, Any],
metadata: Dict[str, Any],
) -> Dict[str, Any]:
return {
"type": "row_updated",
"table_id": table_id,
# The web-frontend expects a serialized version of the row before it
# was updated in order the estimate what position the row had in the
# view.
"row_before_update": serialized_row_before_update,
"row": serialized_row,
"metadata": metadata,
}

View file

@ -1,8 +1,51 @@
from django.conf import settings
from .table.signals import table_created, table_updated, table_deleted
from .views.signals import view_created, views_reordered, view_updated, view_deleted
from .rows.signals import row_created, row_updated, row_deleted
from .fields.signals import field_created, field_updated, field_deleted
if settings.DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS:
PUBLIC_SIGNALS = []
else:
# noinspection PyUnresolvedReferences
from .public.rows.signals import ( # noqa: F401
public_row_created,
public_row_deleted,
public_row_updated,
)
# noinspection PyUnresolvedReferences
from .public.views.signals import ( # noqa: F401
public_view_filter_created,
public_view_filter_deleted,
public_view_filter_updated,
public_view_updated,
public_view_field_options_updated,
)
# noinspection PyUnresolvedReferences
from .public.fields.signals import ( # noqa: F401
public_field_created,
public_field_deleted,
public_field_updated,
public_field_restored,
)
PUBLIC_SIGNALS = [
"public_row_created",
"public_row_deleted",
"public_row_updated",
"public_view_filter_updated",
"public_view_filter_deleted",
"public_view_filter_created",
"public_view_updated",
"public_view_field_options_updated",
"public_field_created",
"public_field_deleted",
"public_field_updated",
"public_field_restored",
]
__all__ = [
"table_created",
@ -18,4 +61,5 @@ __all__ = [
"field_created",
"field_updated",
"field_deleted",
*PUBLIC_SIGNALS,
]

View file

@ -4,12 +4,15 @@ from urllib.parse import parse_qs
import jwt
from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware
from django.conf import settings
from django.contrib.auth import get_user_model
from rest_framework_jwt.settings import api_settings
jwt_get_username_from_payload = api_settings.JWT_PAYLOAD_GET_USERNAME_HANDLER
jwt_decode_token = api_settings.JWT_DECODE_HANDLER
ANONYMOUS_USER_TOKEN = "anonymous"
@database_sync_to_async
def get_user(token):
@ -23,26 +26,35 @@ def get_user(token):
:rtype: User or None
"""
try:
payload = jwt_decode_token(token)
except jwt.InvalidTokenError:
return
anonymous = token == ANONYMOUS_USER_TOKEN
if anonymous:
if settings.DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS:
return
else:
from django.contrib.auth.models import AnonymousUser
User = get_user_model()
username = jwt_get_username_from_payload(payload)
return AnonymousUser()
else:
try:
payload = jwt_decode_token(token)
except jwt.InvalidTokenError:
return
if not username:
return
User = get_user_model()
username = jwt_get_username_from_payload(payload)
try:
user = User.objects.get_by_natural_key(username)
except User.DoesNotExist:
return
if not username:
return
if not user.is_active:
return
try:
user = User.objects.get_by_natural_key(username)
except User.DoesNotExist:
return
return user
if not user.is_active:
return
return user
class JWTTokenAuthMiddleware(BaseMiddleware):

View file

@ -70,7 +70,7 @@ class PageType(Instance):
:type payload: dict
:param ignore_web_socket_id: If provided then the payload will not be broad
casted to that web socket id. This is often the sender.
:type ignore_web_socket_id: str
:type ignore_web_socket_id: Optional[str]
:param kwargs: The additional parameters including their provided values.
:type kwargs: dict
"""

View file

@ -4,8 +4,6 @@ import pytest
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from baserow.test_utils.fixtures.row import RowFixture
from baserow.contrib.database.fields.handler import FieldHandler
@ -24,9 +22,6 @@ def test_forwards_migration(data_fixture, transactional_db, migrate_to_latest_at
migrate_to = [("database", "0050_remove_multiselect_missing_options")]
field_handler = FieldHandler()
row_fixture = RowFixture()
migrate(migrate_from)
# The models used by the data_fixture below are not touched by this migration so
# it is safe to use the latest version in the test.
@ -44,7 +39,7 @@ def test_forwards_migration(data_fixture, transactional_db, migrate_to_latest_at
option_b = data_fixture.create_select_option(field=field, value="B", color="blue")
option_c = data_fixture.create_select_option(field=field, value="C", color="green")
row_fixture.create_row_for_many_to_many_field(
data_fixture.create_row_for_many_to_many_field(
table=table,
field=field,
values=[option_a.id, option_b.id, option_c.id],
@ -53,6 +48,8 @@ def test_forwards_migration(data_fixture, transactional_db, migrate_to_latest_at
option_c.delete()
migrate(migrate_from)
# We check that we still have a link with the deleted option
with connection.cursor() as cursor:
cursor.execute(

View file

@ -1,10 +1,21 @@
from unittest.mock import patch
import pytest
from django.conf import settings
from django.db import connection
from django.urls import reverse
from django.utils import timezone
from freezegun import freeze_time
from rest_framework.status import HTTP_200_OK
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.fields.models import Field, TextField, LinkRowField
from baserow.contrib.database.fields.models import (
Field,
TextField,
LinkRowField,
LookupField,
FormulaField,
)
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.table.models import Table
from baserow.contrib.database.views.handler import ViewHandler
@ -1116,3 +1127,82 @@ def test_can_delete_tables_and_rows_in_the_same_perm_delete_batch(
assert TrashEntry.objects.count() == 0
assert table.get_database_table_name() not in connection.introspection.table_names()
@pytest.mark.django_db
def test_perm_delete_lookup_row_field(data_fixture, api_client):
user, token = data_fixture.create_user_and_token()
database = data_fixture.create_database_application(user=user, name="Placeholder")
table = data_fixture.create_database_table(name="Example", database=database)
customers_table = data_fixture.create_database_table(
name="Customers", database=database
)
cars_table = data_fixture.create_database_table(name="Cars", database=database)
data_fixture.create_database_table(name="Unrelated")
field_handler = FieldHandler()
row_handler = RowHandler()
# Create a primary field and some example data for the customers table.
customers_primary_field = field_handler.create_field(
user=user, table=customers_table, type_name="text", name="Name", primary=True
)
row_handler.create_row(
user=user,
table=customers_table,
values={f"field_{customers_primary_field.id}": "John"},
)
row_handler.create_row(
user=user,
table=customers_table,
values={f"field_{customers_primary_field.id}": "Jane"},
)
# Create a primary field and some example data for the cars table.
cars_primary_field = field_handler.create_field(
user=user, table=cars_table, type_name="text", name="Name", primary=True
)
row_handler.create_row(
user=user, table=cars_table, values={f"field_{cars_primary_field.id}": "BMW"}
)
row_handler.create_row(
user=user, table=cars_table, values={f"field_{cars_primary_field.id}": "Audi"}
)
link_field_1 = field_handler.create_field(
user=user,
table=table,
type_name="link_row",
name="Customer",
link_row_table=customers_table,
)
lookup_field = field_handler.create_field(
user=user,
table=table,
type_name="lookup",
name="Lookup",
through_field_id=link_field_1.id,
target_field_id=customers_primary_field.id,
)
trashed_at = timezone.now()
plus_one_hour_over = timezone.timedelta(
hours=settings.HOURS_UNTIL_TRASH_PERMANENTLY_DELETED + 1
)
with freeze_time(trashed_at):
url = reverse(
"api:database:fields:item",
kwargs={"field_id": link_field_1.link_row_related_field.id},
)
response = api_client.delete(url, HTTP_AUTHORIZATION=f"JWT {token}")
assert response.status_code == HTTP_200_OK
url = reverse("api:database:fields:item", kwargs={"field_id": lookup_field.id})
response = api_client.delete(url, HTTP_AUTHORIZATION=f"JWT {token}")
assert response.status_code == HTTP_200_OK
datetime_when_trash_item_old_enough_to_be_deleted = trashed_at + plus_one_hour_over
with freeze_time(datetime_when_trash_item_old_enough_to_be_deleted):
TrashHandler.mark_old_trash_for_permanent_deletion()
TrashHandler.permanently_delete_marked_trash()
assert LinkRowField.objects_and_trash.all().count() == 0
assert LookupField.objects_and_trash.all().count() == 0
assert FormulaField.objects_and_trash.all().count() == 0

View file

@ -4,6 +4,7 @@ from decimal import Decimal
from django.core.exceptions import ValidationError
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.views.view_types import GridViewType
from baserow.core.exceptions import UserNotInGroup
from baserow.contrib.database.views.handler import ViewHandler
@ -1384,3 +1385,331 @@ def test_submit_form_view(send_mock, data_fixture):
assert getattr(all[1], f"field_{text_field.id}") == "Another value"
assert getattr(all[1], f"field_{number_field.id}") == 10
assert not getattr(all[1], f"field_{boolean_field.id}")
@pytest.mark.django_db
def test_get_public_views_which_include_row(data_fixture, django_assert_num_queries):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table)
hidden_field = data_fixture.create_text_field(table=table)
public_view1 = data_fixture.create_grid_view(
user,
create_options=False,
table=table,
public=True,
order=0,
)
public_view2 = data_fixture.create_grid_view(
user, table=table, public=True, order=1
)
public_view3 = data_fixture.create_grid_view(
user, table=table, public=True, order=2
)
data_fixture.create_grid_view(user, table=table)
data_fixture.create_grid_view_field_option(public_view1, hidden_field, hidden=True)
data_fixture.create_grid_view_field_option(public_view2, hidden_field, hidden=True)
# Public View 1 has filters which match row 1
data_fixture.create_view_filter(
view=public_view1, field=visible_field, type="equal", value="Visible"
)
data_fixture.create_view_filter(
view=public_view1, field=hidden_field, type="equal", value="Hidden"
)
# Public View 2 has filters which match row 2
data_fixture.create_view_filter(
view=public_view2, field=visible_field, type="equal", value="Visible"
)
data_fixture.create_view_filter(
view=public_view2, field=hidden_field, type="equal", value="Not Match"
)
# Public View 3 has filters which match both rows
data_fixture.create_view_filter(
view=public_view2, field=visible_field, type="equal", value="Visible"
)
# Private View 1 has no filters so matches both rows
row = RowHandler().create_row(
user=user,
table=table,
values={
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "Hidden",
},
)
row2 = RowHandler().create_row(
user=user,
table=table,
values={
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "Not Match",
},
)
model = table.get_model()
checker = ViewHandler().get_public_views_row_checker(
table,
model,
)
assert checker.get_public_views_where_row_is_visible(row) == [
public_view1.view_ptr,
public_view3.view_ptr,
]
assert checker.get_public_views_where_row_is_visible(row2) == [
public_view2.view_ptr,
public_view3.view_ptr,
]
@pytest.mark.django_db
def test_public_view_row_checker_caches_when_only_unfiltered_fields_updated(
data_fixture, django_assert_num_queries
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
filtered_field = data_fixture.create_text_field(table=table)
unfiltered_field = data_fixture.create_text_field(table=table)
public_grid_view = data_fixture.create_grid_view(
user,
table=table,
public=True,
)
data_fixture.create_view_filter(
view=public_grid_view, field=filtered_field, type="equal", value="FilterValue"
)
model = table.get_model()
visible_row = model.objects.create(
**{
f"field_{filtered_field.id}": "FilterValue",
f"field_{unfiltered_field.id}": "any",
}
)
invisible_row = model.objects.create(
**{
f"field_{filtered_field.id}": "NotFilterValue",
f"field_{unfiltered_field.id}": "any",
}
)
row_checker = ViewHandler().get_public_views_row_checker(
table, model, updated_field_ids=[unfiltered_field.id]
)
assert row_checker.get_public_views_where_row_is_visible(visible_row) == [
public_grid_view.view_ptr
]
assert row_checker.get_public_views_where_row_is_visible(invisible_row) == []
# Because we've already checked these rows and we've told the checker we'll only
# be changing unfiltered_field it knows it can cache the results
with django_assert_num_queries(0):
assert row_checker.get_public_views_where_row_is_visible(visible_row) == [
public_grid_view.view_ptr
]
assert row_checker.get_public_views_where_row_is_visible(invisible_row) == []
@pytest.mark.django_db
def test_public_view_row_checker_includes_public_views_with_no_filters_with_no_queries(
data_fixture, django_assert_num_queries
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
filtered_field = data_fixture.create_text_field(table=table)
unfiltered_field = data_fixture.create_text_field(table=table)
public_grid_view = data_fixture.create_grid_view(
user,
table=table,
public=True,
)
model = table.get_model()
visible_row = model.objects.create(
**{
f"field_{filtered_field.id}": "any",
f"field_{unfiltered_field.id}": "any",
}
)
other_row = model.objects.create(
**{
f"field_{filtered_field.id}": "any",
f"field_{unfiltered_field.id}": "any",
}
)
row_checker = ViewHandler().get_public_views_row_checker(
table, model, updated_field_ids=[unfiltered_field.id]
)
# It should precalculate that this view is always visible.
with django_assert_num_queries(0):
assert row_checker.get_public_views_where_row_is_visible(visible_row) == [
public_grid_view.view_ptr
]
assert row_checker.get_public_views_where_row_is_visible(other_row) == [
public_grid_view.view_ptr
]
@pytest.mark.django_db
def test_public_view_row_checker_does_not_cache_when_any_filtered_fields_updated(
data_fixture, django_assert_num_queries
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
filtered_field = data_fixture.create_text_field(table=table)
unfiltered_field = data_fixture.create_text_field(table=table)
public_grid_view = data_fixture.create_grid_view(
user,
table=table,
public=True,
)
data_fixture.create_view_filter(
view=public_grid_view, field=filtered_field, type="equal", value="FilterValue"
)
model = table.get_model()
visible_row = model.objects.create(
**{
f"field_{filtered_field.id}": "FilterValue",
f"field_{unfiltered_field.id}": "any",
}
)
invisible_row = model.objects.create(
**{
f"field_{filtered_field.id}": "NotFilterValue",
f"field_{unfiltered_field.id}": "any",
}
)
row_checker = ViewHandler().get_public_views_row_checker(
table, model, updated_field_ids=[filtered_field.id, unfiltered_field.id]
)
assert row_checker.get_public_views_where_row_is_visible(visible_row) == [
public_grid_view.view_ptr
]
assert row_checker.get_public_views_where_row_is_visible(invisible_row) == []
# Now update the rows so they swap and the invisible one becomes visible and vice
# versa
setattr(invisible_row, f"field_{filtered_field.id}", "FilterValue")
invisible_row.save()
setattr(visible_row, f"field_{filtered_field.id}", "NotFilterValue")
visible_row.save()
assert row_checker.get_public_views_where_row_is_visible(invisible_row) == [
public_grid_view.view_ptr
]
assert row_checker.get_public_views_where_row_is_visible(visible_row) == []
@pytest.mark.django_db
def test_public_view_row_checker_runs_expected_queries_on_init(
data_fixture, django_assert_num_queries
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
filtered_field = data_fixture.create_text_field(table=table)
unfiltered_field = data_fixture.create_text_field(table=table)
public_grid_view = data_fixture.create_grid_view(
user, table=table, public=True, order=0
)
data_fixture.create_view_filter(
view=public_grid_view, field=filtered_field, type="equal", value="FilterValue"
)
model = table.get_model()
with django_assert_num_queries(2):
# First query to get the public views, second query to get their filters.
ViewHandler().get_public_views_row_checker(
table, model, updated_field_ids=[filtered_field.id, unfiltered_field.id]
)
another_public_grid_view = data_fixture.create_grid_view(
user, table=table, public=True, order=1
)
# Public View 1 has filters which match row 1
data_fixture.create_view_filter(
view=another_public_grid_view,
field=filtered_field,
type="equal",
value="FilterValue",
)
# Adding another view shouldn't result in more queries
with django_assert_num_queries(2):
# First query to get the public views, second query to get their filters.
ViewHandler().get_public_views_row_checker(
table, model, updated_field_ids=[filtered_field.id, unfiltered_field.id]
)
@pytest.mark.django_db
def test_public_view_row_checker_runs_expected_queries_when_checking_rows(
data_fixture, django_assert_num_queries
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
filtered_field = data_fixture.create_text_field(table=table)
unfiltered_field = data_fixture.create_text_field(table=table)
public_grid_view = data_fixture.create_grid_view(
user, table=table, public=True, order=0
)
# Public View 1 has filters which match row 1
data_fixture.create_view_filter(
view=public_grid_view, field=filtered_field, type="equal", value="FilterValue"
)
model = table.get_model()
visible_row = model.objects.create(
**{
f"field_{filtered_field.id}": "FilterValue",
f"field_{unfiltered_field.id}": "any",
}
)
invisible_row = model.objects.create(
**{
f"field_{filtered_field.id}": "NotFilterValue",
f"field_{unfiltered_field.id}": "any",
}
)
row_checker = ViewHandler().get_public_views_row_checker(
table, model, updated_field_ids=[filtered_field.id, unfiltered_field.id]
)
with django_assert_num_queries(1):
# Only should run a single exists query to check if the row is in the single
# public view
assert row_checker.get_public_views_where_row_is_visible(visible_row) == [
public_grid_view.view_ptr
]
with django_assert_num_queries(1):
# Only should run a single exists query to check if the row is in the single
# public view
assert row_checker.get_public_views_where_row_is_visible(invisible_row) == []
another_public_grid_view = data_fixture.create_grid_view(
user, table=table, public=True, order=1
)
data_fixture.create_view_filter(
view=another_public_grid_view,
field=filtered_field,
type="equal",
value="FilterValue",
)
row_checker = ViewHandler().get_public_views_row_checker(
table, model, updated_field_ids=[filtered_field.id, unfiltered_field.id]
)
with django_assert_num_queries(2):
# Now should run two queries, one per public view
assert row_checker.get_public_views_where_row_is_visible(visible_row) == [
public_grid_view.view_ptr,
another_public_grid_view.view_ptr,
]
with django_assert_num_queries(2):
# Now should run two queries, one per public view
assert row_checker.get_public_views_where_row_is_visible(invisible_row) == []

View file

@ -25,19 +25,19 @@ def test_view_get_field_options(data_fixture):
field_options = grid_view.get_field_options()
assert len(field_options) == 2
field_options = grid_view.get_field_options(create_if_not_exists=True)
field_options = grid_view.get_field_options(create_if_missing=True)
assert len(field_options) == 3
assert field_options[0].field_id == field_1.id
assert field_options[1].field_id == field_2.id
assert field_options[2].field_id == field_3.id
field_options = grid_view.get_field_options(create_if_not_exists=False)
field_options = grid_view.get_field_options(create_if_missing=False)
assert len(field_options) == 3
assert field_options[0].field_id == field_1.id
assert field_options[1].field_id == field_2.id
assert field_options[2].field_id == field_3.id
field_options = form_view.get_field_options(create_if_not_exists=False)
field_options = form_view.get_field_options(create_if_missing=False)
assert len(field_options) == 2
assert field_options[0].field_id == field_1.id
assert field_options[0].name == ""
@ -45,7 +45,7 @@ def test_view_get_field_options(data_fixture):
assert field_options[0].field_id == field_1.id
assert field_options[1].field_id == field_2.id
field_options = form_view.get_field_options(create_if_not_exists=True)
field_options = form_view.get_field_options(create_if_missing=True)
assert len(field_options) == 3
assert field_options[0].field_id == field_1.id
assert field_options[0].field_id == field_1.id

View file

@ -0,0 +1,245 @@
import pytest
from unittest.mock import patch, call, ANY
from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID
from baserow.contrib.database.fields.dependencies.handler import FieldDependencyHandler
from baserow.contrib.database.fields.field_cache import FieldCache
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.core.trash.handler import TrashHandler
class MatchDictSubSet(object):
def __init__(self, sub_set):
self.sub_set = sub_set
def __eq__(self, other):
return all(
self.sub_set[k] == other[k] for k in self.sub_set.keys() & other.keys()
)
def __repr__(self):
return f"MatchDictSubSet({self.sub_set})"
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_field_created_public_views_are_sent_field_created_with_restricted_related(
mock_broadcast_to_channel_group, data_fixture, django_assert_num_queries
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
data_fixture.create_text_field(table=table, order=0)
hidden_broken_field = data_fixture.create_formula_field(
table=table, formula="field('a')", name="hidden_broken"
)
visible_broken_field = data_fixture.create_formula_field(
table=table, formula="field('a')", name="visible_broken"
)
field_cache = FieldCache()
FieldDependencyHandler().rebuild_dependencies(hidden_broken_field, field_cache)
FieldDependencyHandler().rebuild_dependencies(visible_broken_field, field_cache)
public_view = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
data_fixture.create_grid_view_field_option(
public_view, hidden_broken_field, hidden=True, order=0
)
new_visible_field = FieldHandler().create_field(
user, table, "text", name="a", order=1
)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view.slug}",
{
"type": "field_created",
"field": MatchDictSubSet(
{
"id": new_visible_field.id,
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"name": new_visible_field.name,
}
),
"related_fields": [
MatchDictSubSet(
{
"id": visible_broken_field.id,
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"name": visible_broken_field.name,
}
),
],
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_field_deleted_public_views_are_field_deleted_with_restricted_related(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table, order=0, name="visible")
hidden_broken_field = data_fixture.create_formula_field(
table=table, formula="field('visible')", name="hidden_broken"
)
visible_broken_field = data_fixture.create_formula_field(
table=table, formula="field('visible')", name="visible_broken"
)
field_cache = FieldCache()
FieldDependencyHandler().rebuild_dependencies(hidden_broken_field, field_cache)
FieldDependencyHandler().rebuild_dependencies(visible_broken_field, field_cache)
public_view = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
data_fixture.create_grid_view_field_option(
public_view, hidden_broken_field, hidden=True, order=0
)
deleted_field_id = visible_field.id
FieldHandler().delete_field(user, visible_field)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view.slug}",
{
"type": "field_deleted",
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"field_id": deleted_field_id,
"related_fields": [
MatchDictSubSet(
{
"id": visible_broken_field.id,
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"name": visible_broken_field.name,
}
),
],
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_field_restored_public_views_sent_event_with_restricted_related_fields(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table, order=0, name="visible")
hidden_broken_field = data_fixture.create_formula_field(
table=table, formula="field('visible')", name="hidden_broken"
)
visible_broken_field = data_fixture.create_formula_field(
table=table, formula="field('visible')", name="visible_broken"
)
field_cache = FieldCache()
FieldDependencyHandler().rebuild_dependencies(hidden_broken_field, field_cache)
FieldDependencyHandler().rebuild_dependencies(visible_broken_field, field_cache)
public_view = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
data_fixture.create_grid_view_field_option(
public_view, hidden_broken_field, hidden=True, order=0
)
deleted_field_id = visible_field.id
FieldHandler().delete_field(user, visible_field)
TrashHandler().restore_item(user, "field", visible_field.id)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(f"view-{public_view.slug}", ANY, ANY),
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view.slug}",
{
"type": "field_restored",
"field": MatchDictSubSet(
{
"id": deleted_field_id,
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"name": visible_field.name,
}
),
"related_fields": [
MatchDictSubSet(
{
"id": visible_broken_field.id,
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"name": visible_broken_field.name,
}
),
],
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_field_updated_public_views_are_sent_event_with_restricted_related(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table, order=0, name="b")
hidden_broken_field = data_fixture.create_formula_field(
table=table, formula="field('a')", name="hidden_broken"
)
visible_broken_field = data_fixture.create_formula_field(
table=table, formula="field('a')", name="visible_broken"
)
field_cache = FieldCache()
FieldDependencyHandler().rebuild_dependencies(hidden_broken_field, field_cache)
FieldDependencyHandler().rebuild_dependencies(visible_broken_field, field_cache)
public_view = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
data_fixture.create_grid_view_field_option(
public_view, hidden_broken_field, hidden=True, order=0
)
updated_field = FieldHandler().update_field(user, visible_field, name="a")
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view.slug}",
{
"type": "field_updated",
"field_id": updated_field.id,
"field": MatchDictSubSet(
{
"id": updated_field.id,
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"name": updated_field.name,
}
),
"related_fields": [
MatchDictSubSet(
{
"id": visible_broken_field.id,
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"name": visible_broken_field.name,
}
),
],
},
None,
),
]
)

View file

@ -0,0 +1,822 @@
from unittest.mock import patch, call, ANY
import pytest
from django.db import transaction
from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.views.handler import ViewHandler
from baserow.core.trash.handler import TrashHandler
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_row_created_public_views_receive_restricted_row_created_ws_event(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table)
hidden_field = data_fixture.create_text_field(table=table)
public_view_only_showing_one_field = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
public_view_showing_all_fields = data_fixture.create_grid_view(
user, table=table, public=True, order=1
)
data_fixture.create_grid_view_field_option(
public_view_only_showing_one_field, hidden_field, hidden=True
)
row = RowHandler().create_row(
user=user,
table=table,
values={
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "Hidden",
},
)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view_only_showing_one_field.slug}",
{
"type": "row_created",
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"row": {
"id": row.id,
"order": "1.00000000000000000000",
# Only the visible field should be sent
f"field_{visible_field.id}": "Visible",
},
"metadata": {},
"before_row_id": None,
},
None,
),
call(
f"view-{public_view_showing_all_fields.slug}",
{
"type": "row_created",
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"row": {
"id": row.id,
"order": "1.00000000000000000000",
f"field_{visible_field.id}": "Visible",
# This field is not hidden for this public view and so should be
# included
f"field_{hidden_field.id}": "Hidden",
},
"metadata": {},
"before_row_id": None,
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_row_created_public_views_receive_row_created_only_when_filters_match(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table)
hidden_field = data_fixture.create_text_field(table=table)
public_view_showing_row = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
public_view_hiding_row = data_fixture.create_grid_view(
user, table=table, public=True, order=1
)
data_fixture.create_grid_view_field_option(
public_view_showing_row, hidden_field, hidden=True
)
data_fixture.create_grid_view_field_option(
public_view_hiding_row, hidden_field, hidden=True
)
# Match the visible field
data_fixture.create_view_filter(
view=public_view_hiding_row, field=visible_field, type="equal", value="Visible"
)
# But filter out based on the hidden field
data_fixture.create_view_filter(
view=public_view_hiding_row, field=hidden_field, type="equal", value="Not Match"
)
# Match
data_fixture.create_view_filter(
view=public_view_showing_row, field=visible_field, type="equal", value="Visible"
)
# Match
data_fixture.create_view_filter(
view=public_view_showing_row, field=hidden_field, type="equal", value="Hidden"
)
row = RowHandler().create_row(
user=user,
table=table,
values={
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "Hidden",
},
)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view_showing_row.slug}",
{
"type": "row_created",
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"row": {
"id": row.id,
"order": "1.00000000000000000000",
# Only the visible field should be sent
f"field_{visible_field.id}": "Visible",
},
"metadata": {},
"before_row_id": None,
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_row_deleted_public_views_receive_restricted_row_deleted_ws_event(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table)
hidden_field = data_fixture.create_text_field(table=table)
public_view_only_showing_one_field = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
public_view_showing_all_fields = data_fixture.create_grid_view(
user, table=table, public=True, order=1
)
data_fixture.create_grid_view_field_option(
public_view_only_showing_one_field, hidden_field, hidden=True
)
model = table.get_model()
row = model.objects.create(
**{
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "Hidden",
},
)
RowHandler().delete_row(user, table, row.id, model)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view_only_showing_one_field.slug}",
{
"type": "row_deleted",
"row_id": row.id,
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"row": {
"id": row.id,
"order": "1.00000000000000000000",
# Only the visible field should be sent
f"field_{visible_field.id}": "Visible",
},
},
None,
),
call(
f"view-{public_view_showing_all_fields.slug}",
{
"type": "row_deleted",
"row_id": row.id,
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"row": {
"id": row.id,
"order": "1.00000000000000000000",
f"field_{visible_field.id}": "Visible",
# This field is not hidden for this public view and so should be
# included
f"field_{hidden_field.id}": "Hidden",
},
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_row_deleted_public_views_receive_row_deleted_only_when_filters_match(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table)
hidden_field = data_fixture.create_text_field(table=table)
public_view_showing_row = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
public_view_hiding_row = data_fixture.create_grid_view(
user, table=table, public=True, order=1
)
data_fixture.create_grid_view_field_option(
public_view_showing_row, hidden_field, hidden=True
)
data_fixture.create_grid_view_field_option(
public_view_hiding_row, hidden_field, hidden=True
)
# Match the visible field
data_fixture.create_view_filter(
view=public_view_hiding_row, field=visible_field, type="equal", value="Visible"
)
# But filter out based on the hidden field
data_fixture.create_view_filter(
view=public_view_hiding_row, field=hidden_field, type="equal", value="Not Match"
)
# Match
data_fixture.create_view_filter(
view=public_view_showing_row, field=visible_field, type="equal", value="Visible"
)
# Match
data_fixture.create_view_filter(
view=public_view_showing_row, field=hidden_field, type="equal", value="Hidden"
)
model = table.get_model()
row = model.objects.create(
**{
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "Hidden",
},
)
RowHandler().delete_row(user, table, row.id, model)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view_showing_row.slug}",
{
"type": "row_deleted",
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"row_id": row.id,
"row": {
"id": row.id,
"order": "1.00000000000000000000",
# Only the visible field should be sent
f"field_{visible_field.id}": "Visible",
},
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_given_row_not_visible_in_public_view_when_updated_to_be_visible_event_sent(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table)
hidden_field = data_fixture.create_text_field(table=table)
public_view_with_filters_initially_hiding_all_rows = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
data_fixture.create_grid_view_field_option(
public_view_with_filters_initially_hiding_all_rows, hidden_field, hidden=True
)
# Match the visible field
data_fixture.create_view_filter(
view=public_view_with_filters_initially_hiding_all_rows,
field=visible_field,
type="equal",
value="Visible",
)
# But filter out based on the hidden field
data_fixture.create_view_filter(
view=public_view_with_filters_initially_hiding_all_rows,
field=hidden_field,
type="equal",
value="ValueWhichMatchesFilter",
)
model = table.get_model()
initially_hidden_row = model.objects.create(
**{
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "ValueWhichDoesntMatchFilter",
},
)
# Double check the row isn't visible in any views to begin with
row_checker = ViewHandler().get_public_views_row_checker(table, model)
assert row_checker.get_public_views_where_row_is_visible(initially_hidden_row) == []
RowHandler().update_row(
user,
table,
initially_hidden_row.id,
values={f"field_{hidden_field.id}": "ValueWhichMatchesFilter"},
)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view_with_filters_initially_hiding_all_rows.slug}",
{
# The row should appear as a created event as for the public view
# it effectively has been created as it didn't exist before.
"type": "row_created",
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"row": {
"id": initially_hidden_row.id,
"order": "1.00000000000000000000",
# Only the visible field should be sent
f"field_{visible_field.id}": "Visible",
},
"metadata": {},
"before_row_id": None,
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_given_row_visible_in_public_view_when_updated_to_be_not_visible_event_sent(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table)
hidden_field = data_fixture.create_text_field(table=table)
public_view_with_row_showing = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
data_fixture.create_grid_view_field_option(
public_view_with_row_showing, hidden_field, hidden=True
)
# Match the visible field
data_fixture.create_view_filter(
view=public_view_with_row_showing,
field=visible_field,
type="contains",
value="Visible",
)
# But filter out based on the hidden field
data_fixture.create_view_filter(
view=public_view_with_row_showing,
field=hidden_field,
type="equal",
value="ValueWhichMatchesFilter",
)
model = table.get_model()
initially_visible_row = model.objects.create(
**{
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "ValueWhichMatchesFilter",
},
)
# Double check the row is visible in the view to start with
row_checker = ViewHandler().get_public_views_row_checker(table, model)
assert row_checker.get_public_views_where_row_is_visible(initially_visible_row) == [
public_view_with_row_showing.view_ptr
]
# Update the row so it is no longer visible
RowHandler().update_row(
user,
table,
initially_visible_row.id,
values={
f"field_{hidden_field.id}": "ValueWhichDoesNotMatchFilter",
f"field_{visible_field.id}": "StillVisibleButNew",
},
)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view_with_row_showing.slug}",
{
# The row should appear as a deleted event as for the public view
# it effectively has been.
"type": "row_deleted",
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"row_id": initially_visible_row.id,
"row": {
"id": initially_visible_row.id,
"order": "1.00000000000000000000",
# Only the visible field should be sent in its state before it
# was updated
f"field_{visible_field.id}": "Visible",
},
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_given_row_visible_in_public_view_when_updated_to_still_be_visible_event_sent(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table)
hidden_field = data_fixture.create_text_field(table=table)
public_view_with_row_showing = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
data_fixture.create_grid_view_field_option(
public_view_with_row_showing, hidden_field, hidden=True
)
# Match the visible field
data_fixture.create_view_filter(
view=public_view_with_row_showing,
field=visible_field,
type="contains",
value="Visible",
)
# But filter out based on the hidden field
data_fixture.create_view_filter(
view=public_view_with_row_showing,
field=hidden_field,
type="contains",
value="e",
)
model = table.get_model()
initially_visible_row = model.objects.create(
**{
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "e",
},
)
# Double check the row is visible in the view to start with
row_checker = ViewHandler().get_public_views_row_checker(table, model)
assert row_checker.get_public_views_where_row_is_visible(initially_visible_row) == [
public_view_with_row_showing.view_ptr
]
# Update the row so it is still visible but changed
RowHandler().update_row(
user,
table,
initially_visible_row.id,
values={
f"field_{hidden_field.id}": "eee",
f"field_{visible_field.id}": "StillVisibleButUpdated",
},
)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view_with_row_showing.slug}",
{
"type": "row_updated",
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"row_before_update": {
"id": initially_visible_row.id,
"order": "1.00000000000000000000",
# Only the visible field should be sent
f"field_{visible_field.id}": "Visible",
},
"row": {
"id": initially_visible_row.id,
"order": "1.00000000000000000000",
# Only the visible field should be sent
f"field_{visible_field.id}": "StillVisibleButUpdated",
},
"metadata": {},
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_row_restored_public_views_receive_restricted_row_created_ws_event(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table)
hidden_field = data_fixture.create_text_field(table=table)
public_view_only_showing_one_field = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
public_view_showing_all_fields = data_fixture.create_grid_view(
user, table=table, public=True, order=1
)
data_fixture.create_grid_view_field_option(
public_view_only_showing_one_field, hidden_field, hidden=True
)
model = table.get_model()
row = model.objects.create(
**{
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "Hidden",
},
)
TrashHandler.trash(
user, table.database.group, table.database, row, parent_id=table.id
)
TrashHandler.restore_item(user, "row", row.id, parent_trash_item_id=table.id)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view_only_showing_one_field.slug}",
{
"type": "row_created",
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"row": {
"id": row.id,
"order": "1.00000000000000000000",
# Only the visible field should be sent
f"field_{visible_field.id}": "Visible",
},
"metadata": {},
"before_row_id": None,
},
None,
),
call(
f"view-{public_view_showing_all_fields.slug}",
{
"type": "row_created",
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"row": {
"id": row.id,
"order": "1.00000000000000000000",
f"field_{visible_field.id}": "Visible",
# This field is not hidden for this public view and so should be
# included
f"field_{hidden_field.id}": "Hidden",
},
"metadata": {},
"before_row_id": None,
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_row_restored_public_views_receive_row_created_only_when_filters_match(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table)
hidden_field = data_fixture.create_text_field(table=table)
public_view_showing_row = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
public_view_hiding_row = data_fixture.create_grid_view(
user, table=table, public=True, order=1
)
data_fixture.create_grid_view_field_option(
public_view_showing_row, hidden_field, hidden=True
)
data_fixture.create_grid_view_field_option(
public_view_hiding_row, hidden_field, hidden=True
)
# Match the visible field
data_fixture.create_view_filter(
view=public_view_hiding_row, field=visible_field, type="equal", value="Visible"
)
# But filter out based on the hidden field
data_fixture.create_view_filter(
view=public_view_hiding_row, field=hidden_field, type="equal", value="Not Match"
)
# Match
data_fixture.create_view_filter(
view=public_view_showing_row, field=visible_field, type="equal", value="Visible"
)
# Match
data_fixture.create_view_filter(
view=public_view_showing_row, field=hidden_field, type="equal", value="Hidden"
)
model = table.get_model()
row = model.objects.create(
**{
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "Hidden",
},
)
TrashHandler.trash(
user, table.database.group, table.database, row, parent_id=table.id
)
TrashHandler.restore_item(user, "row", row.id, parent_trash_item_id=table.id)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view_showing_row.slug}",
{
"type": "row_created",
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"row": {
"id": row.id,
"order": "1.00000000000000000000",
# Only the visible field should be sent
f"field_{visible_field.id}": "Visible",
},
"metadata": {},
"before_row_id": None,
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_given_row_visible_in_public_view_when_moved_row_updated_sent(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table)
hidden_field = data_fixture.create_text_field(table=table)
public_view = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
data_fixture.create_grid_view_field_option(public_view, hidden_field, hidden=True)
# Match the visible field
data_fixture.create_view_filter(
view=public_view,
field=visible_field,
type="contains",
value="Visible",
)
# But filter out based on the hidden field
data_fixture.create_view_filter(
view=public_view,
field=hidden_field,
type="equal",
value="ValueWhichMatchesFilter",
)
model = table.get_model()
visible_moving_row = model.objects.create(
**{
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "ValueWhichMatchesFilter",
},
)
invisible_row = model.objects.create(
**{
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "ValueWhichDoesNotMatchesFilter",
},
)
# Double check the row is visible in the view to start with
row_checker = ViewHandler().get_public_views_row_checker(table, model)
assert row_checker.get_public_views_where_row_is_visible(visible_moving_row) == [
public_view.view_ptr
]
# Move the visible row behind the invisible one
with transaction.atomic():
RowHandler().move_row(
user, table, visible_moving_row.id, before=invisible_row, model=model
)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view.slug}",
{
# The row should appear as a deleted event as for the public view
# it effectively has been.
"type": "row_updated",
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"row_before_update": {
"id": visible_moving_row.id,
"order": "1.00000000000000000000",
# Only the visible field should be sent
f"field_{visible_field.id}": "Visible",
},
"row": {
"id": visible_moving_row.id,
"order": "0.99999999999999999999",
# Only the visible field should be sent
f"field_{visible_field.id}": "Visible",
},
"metadata": {},
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_given_row_invisible_in_public_view_when_moved_no_update_sent(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table)
hidden_field = data_fixture.create_text_field(table=table)
public_view = data_fixture.create_grid_view(
user, create_options=False, table=table, public=True, order=0
)
data_fixture.create_grid_view_field_option(public_view, hidden_field, hidden=True)
# Match the visible field
data_fixture.create_view_filter(
view=public_view,
field=visible_field,
type="contains",
value="Visible",
)
# But filter out based on the hidden field
data_fixture.create_view_filter(
view=public_view,
field=hidden_field,
type="equal",
value="ValueWhichMatchesFilter",
)
model = table.get_model()
visible_row = model.objects.create(
**{
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "ValueWhichMatchesFilter",
},
)
invisible_moving_row = model.objects.create(
**{
f"field_{visible_field.id}": "Visible",
f"field_{hidden_field.id}": "ValueWhichDoesNotMatchesFilter",
},
)
# Double check the row is visible in the view to start with
row_checker = ViewHandler().get_public_views_row_checker(table, model)
assert row_checker.get_public_views_where_row_is_visible(invisible_moving_row) == []
# Move the invisible row
with transaction.atomic():
RowHandler().move_row(
user, table, invisible_moving_row.id, before=visible_row, model=model
)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
]
)

View file

@ -0,0 +1,216 @@
from unittest.mock import patch, call, ANY
import pytest
from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID
from baserow.contrib.database.views.handler import ViewHandler
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_view_filter_created_for_public_view_force_refresh_sent(
mock_broadcast_to_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)
public_view = data_fixture.create_grid_view(user=user, table=table, public=True)
ViewHandler().create_filter(
user=user, view=public_view, type_name="equal", value="test", field=field
)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view.slug}",
{"type": "force_view_rows_refresh", "view_id": public_view.slug},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_view_filter_updated_for_public_view_force_refresh_event_sent(
mock_broadcast_to_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)
public_view = data_fixture.create_grid_view(user=user, table=table, public=True)
view_filter = data_fixture.create_view_filter(
user=user, view=public_view, field=field
)
ViewHandler().update_filter(user=user, view_filter=view_filter, value="test2")
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view.slug}",
{"type": "force_view_rows_refresh", "view_id": public_view.slug},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_view_filter_deleted_for_public_view_force_refresh_event_sent(
mock_broadcast_to_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)
public_view = data_fixture.create_grid_view(user=user, table=table, public=True)
view_filter = data_fixture.create_view_filter(
user=user, view=public_view, field=field
)
ViewHandler().delete_filter(user=user, view_filter=view_filter)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_view.slug}",
{"type": "force_view_rows_refresh", "view_id": public_view.slug},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_field_hidden_in_public_view_field_force_refresh_sent(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table)
public_grid_view = data_fixture.create_grid_view(table=table, public=True)
ViewHandler().update_field_options(
user=user,
view=public_grid_view,
field_options={str(text_field.id): {"hidden": True}},
)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_grid_view.slug}",
{
"type": "force_view_refresh",
"view_id": public_grid_view.slug,
"fields": [],
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_field_unhidden_in_public_view_force_refresh_sent(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table)
public_grid_view = data_fixture.create_grid_view(
table=table, public=True, create_options=False
)
data_fixture.create_grid_view_field_option(
grid_view=public_grid_view, field=text_field, hidden=True
)
ViewHandler().update_field_options(
user=user,
view=public_grid_view,
field_options={str(text_field.id): {"hidden": False}},
)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_grid_view.slug}",
{
"type": "force_view_refresh",
"view_id": public_grid_view.slug,
"fields": [
{
"id": text_field.id,
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"name": text_field.name,
"order": 0,
"type": "text",
"primary": False,
"text_default": "",
}
],
},
None,
),
]
)
@pytest.mark.django_db(transaction=True)
@patch("baserow.ws.registries.broadcast_to_channel_group")
def test_when_only_field_options_updated_in_public_grid_view_force_refresh_sent(
mock_broadcast_to_channel_group, data_fixture
):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
visible_field = data_fixture.create_text_field(table=table)
hidden_field = data_fixture.create_text_field(table=table)
public_grid_view = data_fixture.create_grid_view(
table=table, public=True, create_options=False
)
data_fixture.create_grid_view_field_option(
grid_view=public_grid_view, field=visible_field, hidden=False
)
data_fixture.create_grid_view_field_option(
grid_view=public_grid_view, field=hidden_field, hidden=True
)
ViewHandler().update_field_options(
user=user,
view=public_grid_view,
field_options={
str(visible_field.id): {"width": 100},
str(hidden_field.id): {"width": 100},
},
)
mock_broadcast_to_channel_group.delay.assert_has_calls(
[
call(f"table-{table.id}", ANY, ANY),
call(
f"view-{public_grid_view.slug}",
{
"type": "force_view_refresh",
"view_id": public_grid_view.slug,
"fields": [
{
"id": visible_field.id,
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"name": visible_field.name,
"order": 0,
"type": "text",
"primary": False,
"text_default": "",
}
],
},
None,
),
]
)

View file

@ -0,0 +1,177 @@
import pytest
from pyinstrument import Profiler
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.rows.handler import RowHandler
@pytest.mark.django_db
@pytest.mark.slow
# You must add --runslow -s to pytest to run this test, you can do this in intellij by
# editing the run config for this test and adding --runslow -s to additional args.
def test_creating_many_rows_in_public_filtered_views(
data_fixture, django_assert_num_queries
):
user = data_fixture.create_user()
table, fields, rows = data_fixture.build_table(
columns=[("number", "number")], rows=[["0"]], user=user
)
num_public_views = 10
views = []
for i in range(num_public_views):
views.append(data_fixture.create_grid_view(user=user, table=table, public=True))
num_fields = 20
for i in range(num_fields):
last_field = FieldHandler().create_field(
user=user,
table=table,
name=f"field{i}",
type_name="text",
)
for view in views:
data_fixture.create_view_filter(
view=view, field=last_field, type="equal", value=i
)
handler = RowHandler()
num_rows = 1000
rows = []
for i in range(num_rows):
row = {}
for j in range(num_fields):
row[f"field_{j}"] = (i + j) % num_fields
rows.append(row)
model = table.get_model()
profiler = Profiler()
profiler.start()
for i in range(num_rows):
handler.create_row(user, table, rows[i], model=model)
profiler.stop()
print(profiler.output_text(unicode=True, color=True))
@pytest.mark.django_db
@pytest.mark.slow
# You must add --runslow -s to pytest to run this test, you can do this in intellij by
# editing the run config for this test and adding --runslow -s to additional args.
def test_updating_many_rows_in_public_filtered_views(
data_fixture, django_assert_num_queries
):
user = data_fixture.create_user()
table, fields, rows = data_fixture.build_table(
columns=[("number", "number")], rows=[["0"]], user=user
)
num_public_views = 10
num_fields = 100
num_rows = 1000
num_fields_to_filter = 3
num_views_to_filter = 6
num_row_updates_to_profile = 1
views = []
for i in range(num_public_views):
views.append(data_fixture.create_grid_view(user=user, table=table, public=True))
for i in range(num_fields):
last_field = FieldHandler().create_field(
user=user,
table=table,
name=f"field{i}",
type_name="text",
)
if i < num_fields_to_filter:
for view in views[:num_views_to_filter]:
data_fixture.create_view_filter(
view=view, field=last_field, type="equal", value=i
)
handler = RowHandler()
rows = []
for i in range(num_rows):
row = {}
for j in range(num_fields):
row[f"field_{j}"] = (i + j) % num_fields
rows.append(row)
model = table.get_model()
for i in range(num_rows):
rows[i] = handler.create_row(user, table, rows[i], model=model)
profiler = Profiler()
profiler.start()
run_row_updates = 0
for i in range(num_rows):
for k in range(num_fields):
handler.update_row(user, table, rows[i].id, {f"field_{k}": 0}, model=model)
run_row_updates += 1
if run_row_updates >= num_row_updates_to_profile:
break
if run_row_updates >= num_row_updates_to_profile:
break
profiler.stop()
print(profiler.output_text(unicode=True, color=True))
"""
Profiling result on 11/01/2021:
- Dev: Nigel
- Machine: 32gb RAM, Ubuntu 21.04, AMD 5900X
- Variables:
num_public_views = 10
num_fields = 100
num_rows = 1000
num_fields_to_filter = 3
num_views_to_filter = 6
num_row_updates_to_profile = 1
0.018 test_updating_many_rows_in_public_filtered_views test_public_sharing_perform
0.018 update_row baserow/contrib/database/rows/handler.py:426
0.015 send django/dispatch/dispatcher.py:159
[2 frames hidden] django
0.015 <listcomp> django/dispatch/dispatcher.py:180
0.009 public_before_row_update baserow/contrib/database/ws/public/rows
0.004 get_public_views_row_checker baserow/contrib/database/views/han
0.004 __init__ baserow/contrib/database/views/handler.py:985
0.002 apply_filters baserow/contrib/database/views/handler.py:2
0.002 apply_to_queryset baserow/contrib/database/fields/fiel
0.002 filter django/db/models/query.py:935
[14 frames hidden] django
0.002 __iter__ django/db/models/query.py:265
[16 frames hidden] django
0.001 get_prefetch_queryset django/db/models/fields/relat
0.001 get_queryset baserow/core/managers.py:29
0.001 filter django/db/models/query.py:935
[9 frames hidden] django
0.003 _serialize_row baserow/contrib/database/ws/public/rows/signals
0.002 data rest_framework/serializers.py:546
[18 frames hidden] rest_framework, django, copy, <built-in>
0.001 get_row_serializer_class baserow/contrib/database/api/rows/
0.001 get_response_serializer_field baserow/contrib/database/fi
0.002 get_public_views_where_row_is_visible baserow/contrib/database/
0.002 _check_row_visible baserow/contrib/database/views/handler.py
0.002 exists django/db/models/query.py:806
[19 frames hidden] django, copy
0.003 public_row_updated baserow/contrib/database/ws/public/rows/signals
0.002 _serialize_row baserow/contrib/database/ws/public/rows/signals.
0.001 get_row_serializer_class baserow/contrib/database/api/rows/s
0.001 get_response_serializer_field baserow/contrib/database/fi
0.001 get_serializer_field baserow/contrib/database/fields/f
0.001 __init__ rest_framework/fields.py:773
[3 frames hidden] rest_framework
0.001 data rest_framework/serializers.py:546
[15 frames hidden] rest_framework, django, copy, <built-in>
0.001 on_commit django/db/transaction.py:123
[7 frames hidden] django, asgiref
0.003 before_row_update baserow/contrib/database/ws/rows/signals.py:37
0.002 data rest_framework/serializers.py:546
[13 frames hidden] rest_framework, django, copy
0.001 get_row_serializer_class baserow/contrib/database/api/rows/seri
0.001 get_serializer_class baserow/api/utils.py:254
0.002 get django/db/models/query.py:414
[18 frames hidden] django
0.001 save django/db/models/base.py:672
[11 frames hidden] django
"""

View file

@ -2,7 +2,7 @@ import pytest
from channels.testing import WebsocketCommunicator
from baserow.config.asgi import application
from baserow.ws.auth import get_user
from baserow.ws.auth import get_user, ANONYMOUS_USER_TOKEN
@pytest.mark.run(order=1)
@ -16,11 +16,14 @@ async def test_get_user(data_fixture):
u = await get_user(token)
assert user.id == u.id
anonymous_token_user = await get_user(ANONYMOUS_USER_TOKEN)
assert anonymous_token_user.is_anonymous
@pytest.mark.run(order=2)
@pytest.mark.asyncio
@pytest.mark.django_db(transaction=True)
async def test_token_auth_middleware(data_fixture):
async def test_token_auth_middleware(data_fixture, settings):
user, token = data_fixture.create_user_and_token()
communicator = WebsocketCommunicator(application, f"ws/core/")
@ -57,3 +60,27 @@ async def test_token_auth_middleware(data_fixture):
assert json["type"] == "authentication"
assert json["web_socket_id"] is not None
await communicator.disconnect()
# Test anonymous connections
communicator = WebsocketCommunicator(
application, f"ws/core/?jwt_token={ANONYMOUS_USER_TOKEN}"
)
connected, subprotocol = await communicator.connect()
assert connected
json = await communicator.receive_json_from()
assert json["type"] == "authentication"
assert json["success"]
assert json["web_socket_id"] is not None
await communicator.disconnect()
# Test cant connect as anonymous user if feature disabled.
settings.DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS = True
communicator = WebsocketCommunicator(
application, f"ws/core/?jwt_token={ANONYMOUS_USER_TOKEN}"
)
connected, subprotocol = await communicator.connect()
assert connected
json = await communicator.receive_json_from()
assert json["type"] == "authentication"
assert not json["success"]
await communicator.disconnect()

View file

@ -3,6 +3,7 @@ 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)
@ -39,3 +40,61 @@ async def test_join_page(data_fixture):
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
)
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 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()

View file

@ -48,6 +48,7 @@ services:
- EMAIL_SMTP_USER
- EMAIL_SMTP_PASSWORD
- FROM_EMAIL
- DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS
ports:
- "${HOST_PUBLISH_IP:-127.0.0.1}:${BACKEND_PORT:-8000}:8000"
depends_on:
@ -71,6 +72,7 @@ services:
- EMAIL_SMTP_USER
- EMAIL_SMTP_PASSWORD
- FROM_EMAIL
- DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS
build:
dockerfile: ./backend/Dockerfile
context: .
@ -99,6 +101,7 @@ services:
- EMAIL_SMTP_USER
- EMAIL_SMTP_PASSWORD
- FROM_EMAIL
- DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS
volumes:
- media:/baserow/media
networks:
@ -121,6 +124,7 @@ services:
- EMAIL_SMTP_USER
- EMAIL_SMTP_PASSWORD
- FROM_EMAIL
- DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS
volumes:
- media:/baserow/media
networks:
@ -135,6 +139,7 @@ services:
- PUBLIC_BACKEND_URL=${PUBLIC_BACKEND_URL:-http://localhost:8000}
- PUBLIC_WEB_FRONTEND_URL=${PUBLIC_WEB_FRONTEND_URL:-http://localhost:3000}
- ADDITIONAL_MODULES
- DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS
ports:
- "${HOST_PUBLISH_IP:-127.0.0.1}:${WEB_FRONTEND_PORT:-3000}:3000"
depends_on:

View file

@ -135,3 +135,5 @@ are accepted.
* `EMAIL_SMTP_PASSWORD` (default ``): The password of the SMTP server.
* `HOURS_UNTIL_TRASH_PERMANENTLY_DELETED` (default 72): The number of hours to keep
trashed items until they are permanently deleted.
* `DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS` (default ``): If set to 'true' will
disable realtime events being sent to publicly shared views.

View file

@ -140,6 +140,7 @@ for what these variables do.
- `EMAIL_SMTP_USER`
- `EMAIL_SMTP_PASSWORD`
- `FROM_EMAIL`
- `DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS`
### Dev Only Variables

View file

@ -126,7 +126,7 @@ class KanbanViewType(ViewType):
When a kanban view is created, we want to set the first three fields as visible.
"""
field_options = view.get_field_options(create_if_not_exists=True).order_by(
field_options = view.get_field_options(create_if_missing=True).order_by(
"field__id"
)
ids_to_update = [f.id for f in field_options[0:3]]

View file

@ -61,6 +61,10 @@ export default function CoreModule(options) {
key: 'HOURS_UNTIL_TRASH_PERMANENTLY_DELETED',
default: 24 * 3,
},
{
key: 'DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS',
default: false,
},
],
},
])

View file

@ -6,6 +6,7 @@ export class RealTimeHandler {
this.socket = null
this.connected = false
this.reconnect = false
this.anonymous = false
this.reconnectTimeout = null
this.attempts = 0
this.events = {}
@ -21,10 +22,13 @@ export class RealTimeHandler {
* Creates a new connection with to the web socket so that real time updates can be
* received.
*/
connect(reconnect = true) {
connect(reconnect = true, anonymous = false) {
this.reconnect = reconnect
this.anonymous = anonymous
const token = this.context.store.getters['auth/token']
const token = anonymous
? 'anonymous'
: this.context.store.getters['auth/token']
// If the user is already connected to the web socket, we don't have to do
// anything.
@ -115,7 +119,7 @@ export class RealTimeHandler {
this.reconnectTimeout = setTimeout(
() => {
this.connect(true)
this.connect(true, this.anonymous)
},
// After the first try, we want to try again every 5 seconds.
this.attempts > 1 ? 5000 : 0

View file

@ -5,8 +5,8 @@
<Table
:database="database"
:table="table"
:fields="fields"
:primary="primary"
:fields="fields || startingFields"
:primary="primary || startingPrimary"
:views="[view]"
:view="view"
:read-only="true"
@ -18,6 +18,7 @@
</template>
<script>
import { mapGetters } from 'vuex'
import Notifications from '@baserow/modules/core/components/notifications/Notifications'
import Table from '@baserow/modules/database/components/table/Table'
import GridService from '@baserow/modules/database/services/view/grid'
@ -67,8 +68,8 @@ export default {
database,
table,
view,
primary,
fields,
startingFields: fields,
startingPrimary: primary,
}
} catch (e) {
if (e.response && e.response.status === 404) {
@ -78,5 +79,23 @@ export default {
}
}
},
computed: {
...mapGetters({
primary: 'field/getPrimary',
fields: 'field/getAll',
}),
},
mounted() {
if (!this.$env.DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS) {
this.$realtime.connect(true, true)
this.$realtime.subscribe('view', { slug: this.$route.params.slug })
}
},
beforeDestroy() {
if (!this.$env.DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS) {
this.$realtime.subscribe(null)
this.$realtime.disconnect()
}
},
}
</script>

View file

@ -227,6 +227,34 @@ export const registerRealtimeEvents = (realtime) => {
}
})
realtime.registerEvent('force_view_rows_refresh', ({ store, app }, data) => {
const view = store.getters['view/get'](data.view_id)
if (view !== undefined) {
if (store.getters['view/getSelectedId'] === view.id) {
app.$bus.$emit('table-refresh', {
tableId: store.getters['table/getSelectedId'],
})
}
}
})
realtime.registerEvent('force_view_refresh', ({ store, app }, data) => {
const view = store.getters['view/get'](data.view_id)
if (view !== undefined) {
if (store.getters['view/getSelectedId'] === view.id) {
app.$bus.$emit('table-refresh', {
tableId: store.getters['table/getSelectedId'],
includeFieldOptions: true,
async callback() {
await store.dispatch('field/forceSetFields', {
fields: data.fields,
})
},
})
}
}
})
realtime.registerEvent('view_filter_created', ({ store, app }, data) => {
const view = store.getters['view/get'](data.view_filter.view)
if (view !== undefined) {

View file

@ -347,17 +347,23 @@ export class GridViewType extends ViewType {
}
async fieldRestored(
{ dispatch },
{ dispatch, rootGetters },
table,
selectedView,
field,
fieldType,
storePrefix = ''
) {
// There might be new filters and sorts associated with the restored field,
// ensure we fetch them. For now we have to fetch all filters/sorts however in the
// future we should instead just fetch them for this particular restored field.
await dispatch('view/refreshView', { view: selectedView }, { root: true })
// Dont refresh if we are public view as filters wont be exposed and sorts are
// treated as defaults from the server so not a big deal if we don't know what
// it is for a restored field.
const isPublic = rootGetters[storePrefix + 'view/grid/isPublic']
if (!isPublic) {
// There might be new filters and sorts associated with the restored field,
// ensure we fetch them. For now we have to fetch all filters/sorts however in the
// future we should instead just fetch them for this particular restored field.
await dispatch('view/refreshView', { view: selectedView }, { root: true })
}
}
async fieldCreated({ dispatch }, table, field, fieldType, storePrefix = '') {

View file

@ -66,6 +66,8 @@ export class TestApp {
this._realtime = {
registerEvent(e, f) {},
subscribe(e, f) {},
connect(a, b) {},
disconnect() {},
}
// Various stub and mock attributes which will be injected into components
// mounted using TestApp.
@ -81,6 +83,9 @@ export class TestApp {
t: (key) => key,
tc: (key) => key,
},
$route: {
params: {},
},
}
this._vueContext = bootstrapVueContext()
this.store = _createBaserowStoreAndRegistry(this._app, this._vueContext)