- |
+ |
@@ -204,15 +201,13 @@
{{ confirmation_url }}
- {% if show_baserow_description %}
-
+ {% if show_baserow_description %}
|
{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}
|
- {% endif %}
-
+ {% endif %}
diff --git a/backend/src/baserow/core/templates/baserow/core/user/email_pending_verification.html b/backend/src/baserow/core/templates/baserow/core/user/email_pending_verification.html
index 84478db6ca..b57ac559cd 100644
--- a/backend/src/baserow/core/templates/baserow/core/user/email_pending_verification.html
+++ b/backend/src/baserow/core/templates/baserow/core/user/email_pending_verification.html
@@ -1,10 +1,9 @@
{% load i18n %}
-
+
-
-
+
@@ -99,7 +98,7 @@
}
-
-
+
@@ -132,7 +129,7 @@
-
+
|
@@ -187,7 +184,7 @@
- |
+ |
diff --git a/backend/src/baserow/core/templates/baserow/core/user/reset_password.html b/backend/src/baserow/core/templates/baserow/core/user/reset_password.html
index 985f0d46ab..b91bd32939 100644
--- a/backend/src/baserow/core/templates/baserow/core/user/reset_password.html
+++ b/backend/src/baserow/core/templates/baserow/core/user/reset_password.html
@@ -1,10 +1,9 @@
{% load i18n %}
-
+
-
-
+
@@ -99,7 +98,7 @@
}
-
-
+
@@ -132,7 +129,7 @@
-
+
|
@@ -187,7 +184,7 @@
- |
+ |
@@ -204,15 +201,13 @@
{{ reset_url }}
- {% if show_baserow_description %}
-
+ {% if show_baserow_description %}
|
{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}
|
- {% endif %}
-
+ {% endif %}
diff --git a/backend/src/baserow/core/templates/baserow/core/workspace_invitation.html b/backend/src/baserow/core/templates/baserow/core/workspace_invitation.html
index b9c96003c2..0b4106c71e 100644
--- a/backend/src/baserow/core/templates/baserow/core/workspace_invitation.html
+++ b/backend/src/baserow/core/templates/baserow/core/workspace_invitation.html
@@ -1,10 +1,9 @@
{% load i18n %}
-
+
-
-
+
@@ -99,7 +98,7 @@
}
-
-
+
@@ -132,7 +129,7 @@
-
+
|
@@ -181,17 +178,15 @@
{% blocktrans trimmed with invitation.invited_by.first_name as first_name and invitation.workspace.name as workspace_name %} {{ first_name }} has invited you to collaborate on {{ workspace_name }}. {% endblocktrans %}
- {% if invitation.message %}
-
+ {% if invitation.message %}
|
"{{ invitation.message }}"
|
- {% endif %}
-
+ {% endif %}
- |
+ |
@@ -208,15 +203,13 @@
{{ public_accept_url }}
- {% if show_baserow_description %}
-
+ {% if show_baserow_description %}
|
{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}
|
- {% endif %}
-
+ {% endif %}
diff --git a/backend/src/baserow/core/user_files/managers.py b/backend/src/baserow/core/user_files/managers.py
index 854649b054..f85632020e 100644
--- a/backend/src/baserow/core/user_files/managers.py
+++ b/backend/src/baserow/core/user_files/managers.py
@@ -1,8 +1,10 @@
from django.db import models
from django.db.models import Q
+from django_cte import CTEQuerySet
-class UserFileQuerySet(models.QuerySet):
+
+class UserFileQuerySet(CTEQuerySet, models.QuerySet):
def name(self, *names):
if len(names) == 0:
raise ValueError("At least one name must be provided.")
diff --git a/backend/src/baserow/core/user_files/models.py b/backend/src/baserow/core/user_files/models.py
index 0f671bfae6..ff5e260565 100644
--- a/backend/src/baserow/core/user_files/models.py
+++ b/backend/src/baserow/core/user_files/models.py
@@ -13,7 +13,7 @@
class UserFile(models.Model):
original_name = models.CharField(max_length=255)
original_extension = models.CharField(max_length=64)
- unique = models.CharField(max_length=32)
+ unique = models.CharField(max_length=32, db_index=True)
size = models.PositiveBigIntegerField()
mime_type = models.CharField(max_length=127, blank=True)
is_image = models.BooleanField(default=False)
diff --git a/backend/src/baserow/ws/tasks.py b/backend/src/baserow/ws/tasks.py
index a7ef44cf79..5ffa90a22b 100644
--- a/backend/src/baserow/ws/tasks.py
+++ b/backend/src/baserow/ws/tasks.py
@@ -203,7 +203,7 @@ def broadcast_many_to_channel_group(
Broadcasts a list of JSON payloads to all the users within the channel workspace
having the provided name for each payload.
- :param payload: A list of pairs: channel workspace and payload dictionary
+ :param payloads: A list of pairs: channel workspace and payload dictionary
containing data that must be broadcast. Each pair can be sent to a different
channel group.
:param ignore_web_socket_id: The web socket id to which messages must not be
diff --git a/backend/tests/baserow/contrib/database/field/test_autonumber_field_type.py b/backend/tests/baserow/contrib/database/field/test_autonumber_field_type.py
index 4c4c7391ed..cc51fb6f82 100644
--- a/backend/tests/baserow/contrib/database/field/test_autonumber_field_type.py
+++ b/backend/tests/baserow/contrib/database/field/test_autonumber_field_type.py
@@ -552,37 +552,37 @@ def test_autonumber_field_view_filters(data_fixture):
view=view, field=autonumber_field, type="equal", value=1
)
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == [row_1.id]
view_filter.type = "not_equal"
view_filter.save(update_fields=["type"])
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == [row_2.id]
view_filter.type = "lower_than"
view_filter.save(update_fields=["type"])
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == []
view_filter.type = "higher_than"
view_filter.save(update_fields=["type"])
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == [row_2.id]
view_filter.type = "contains"
view_filter.save(update_fields=["type"])
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == [row_1.id]
view_filter.type = "contains_not"
view_filter.save(update_fields=["type"])
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == [row_2.id]
diff --git a/backend/tests/baserow/contrib/database/field/test_duration_field_type.py b/backend/tests/baserow/contrib/database/field/test_duration_field_type.py
index 4d823d91c5..2afea589c7 100644
--- a/backend/tests/baserow/contrib/database/field/test_duration_field_type.py
+++ b/backend/tests/baserow/contrib/database/field/test_duration_field_type.py
@@ -805,19 +805,19 @@ def test_duration_field_view_filters(data_fixture):
view=view, field=field, type="equal", value="0:0:1.123"
)
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == [rows[1].id, rows[2].id]
view_filter.value = "1.123" # it will be considered as a number of seconds
view_filter.save(update_fields=["value"])
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == [rows[1].id, rows[2].id]
view_filter.type = "not_equal"
view_filter.save(update_fields=["type"])
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == [
rows[0].id,
rows[3].id,
@@ -830,13 +830,13 @@ def test_duration_field_view_filters(data_fixture):
view_filter.type = "empty"
view_filter.save(update_fields=["type"])
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == [rows[0].id]
view_filter.type = "not_empty"
view_filter.save(update_fields=["type"])
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == [
rows[1].id,
rows[2].id,
@@ -851,7 +851,7 @@ def test_duration_field_view_filters(data_fixture):
view_filter.value = "3600" # 1 hour
view_filter.save()
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == [
rows[4].id,
rows[5].id,
@@ -862,7 +862,7 @@ def test_duration_field_view_filters(data_fixture):
view_filter.value = "1:00:00"
view_filter.save()
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == [
rows[4].id,
rows[5].id,
@@ -872,7 +872,7 @@ def test_duration_field_view_filters(data_fixture):
view_filter.type = "lower_than"
view_filter.save(update_fields=["type"])
- qs = ViewHandler().get_queryset(view, model=model)
+ qs = ViewHandler().get_queryset(user, view, model=model)
assert list(qs.values_list("id", flat=True)) == [
rows[1].id,
rows[2].id,
diff --git a/backend/tests/baserow/contrib/database/field/test_duration_formula_field_filters.py b/backend/tests/baserow/contrib/database/field/test_duration_formula_field_filters.py
index ded72748e7..413ef799e0 100644
--- a/backend/tests/baserow/contrib/database/field/test_duration_formula_field_filters.py
+++ b/backend/tests/baserow/contrib/database/field/test_duration_formula_field_filters.py
@@ -87,7 +87,7 @@ def duration_formula_filter_proc(
send_realtime_update=False,
)
- q = t.view_handler.get_queryset(t.grid_view)
+ q = t.view_handler.get_queryset(t.user, t.grid_view)
actual_names = [getattr(r, refname) for r in q]
actual_duration_values = [getattr(r, t.data_source_field.db_column) for r in q]
actual_formula_values = [getattr(r, t.formula_field.db_column) for r in q]
diff --git a/backend/tests/baserow/contrib/database/field/test_formula_field_type.py b/backend/tests/baserow/contrib/database/field/test_formula_field_type.py
index 8aa8a9ceda..1a17129ca2 100644
--- a/backend/tests/baserow/contrib/database/field/test_formula_field_type.py
+++ b/backend/tests/baserow/contrib/database/field/test_formula_field_type.py
@@ -119,7 +119,7 @@ def test_changing_type_of_other_field_still_results_in_working_filter(data_fixtu
# filter on the referencing formula field is now and invalid and should be deleted
FieldHandler().update_field(user, first_formula_field, formula="1")
- queryset = ViewHandler().get_queryset(grid_view)
+ queryset = ViewHandler().get_queryset(user, grid_view)
assert not queryset.exists()
assert queryset.count() == 0
@@ -142,7 +142,7 @@ def test_can_use_complex_date_filters_on_formula_field(data_fixture):
value="Europe/London",
)
- queryset = ViewHandler().get_queryset(grid_view)
+ queryset = ViewHandler().get_queryset(user, grid_view)
assert not queryset.exists()
assert queryset.count() == 0
@@ -167,7 +167,7 @@ def test_can_use_complex_date_filters_on_formula_field_with_lookup(data_fixture)
table.get_model()
- queryset = ViewHandler().get_queryset(grid_view)
+ queryset = ViewHandler().get_queryset(user, grid_view)
assert not queryset.exists()
assert queryset.count() == 0
@@ -197,7 +197,7 @@ def test_can_use_complex_contains_filters_on_formula_field(data_fixture):
value="23",
)
- queryset = ViewHandler().get_queryset(grid_view)
+ queryset = ViewHandler().get_queryset(user, grid_view)
assert not queryset.exists()
assert queryset.count() == 0
diff --git a/backend/tests/baserow/contrib/database/field/test_number_lookup_field_filters.py b/backend/tests/baserow/contrib/database/field/test_number_lookup_field_filters.py
index f0edd87bec..9e3e48fcf0 100644
--- a/backend/tests/baserow/contrib/database/field/test_number_lookup_field_filters.py
+++ b/backend/tests/baserow/contrib/database/field/test_number_lookup_field_filters.py
@@ -92,7 +92,7 @@ def get_linked_rows(*indexes) -> list[int]:
t.row_handler.create_rows(user=t.user, table=t.table, rows_values=rows)
- clean_query = t.view_handler.get_queryset(t.grid_view)
+ clean_query = t.view_handler.get_queryset(t.user, t.grid_view)
t.view_handler.create_filter(
t.user,
@@ -102,7 +102,7 @@ def get_linked_rows(*indexes) -> list[int]:
value=test_value,
)
- q = t.view_handler.get_queryset(t.grid_view)
+ q = t.view_handler.get_queryset(t.user, t.grid_view)
print(f"filter {filter_type_name} with value: {(test_value,)}")
print(f"expected: {expected_rows}")
print(f"filtered: {[getattr(item, row_name) for item in q]}")
diff --git a/backend/tests/baserow/contrib/database/field/test_url_lookup_field_filters.py b/backend/tests/baserow/contrib/database/field/test_url_lookup_field_filters.py
index 8b92660967..1fa2119609 100644
--- a/backend/tests/baserow/contrib/database/field/test_url_lookup_field_filters.py
+++ b/backend/tests/baserow/contrib/database/field/test_url_lookup_field_filters.py
@@ -47,7 +47,7 @@ def url_formula_field_filter_proc(
send_realtime_update=False,
)
- q = view_handler.get_queryset(grid_view)
+ q = view_handler.get_queryset(user, grid_view)
assert len(q) == len(expected_rows)
assert set([getattr(r, path_field.db_column) for r in q]) == set(expected_rows)
diff --git a/backend/tests/baserow/contrib/database/test_cachalot.py b/backend/tests/baserow/contrib/database/test_cachalot.py
index f75a783cf5..f71438d12d 100644
--- a/backend/tests/baserow/contrib/database/test_cachalot.py
+++ b/backend/tests/baserow/contrib/database/test_cachalot.py
@@ -59,7 +59,7 @@ def get_mocked_query_cache_key(compiler):
table_model = table_a.get_model()
table_model.objects.create()
- queryset = ViewHandler().get_queryset(view=grid_view)
+ queryset = ViewHandler().get_queryset(user=user, view=grid_view)
def assert_cachalot_cache_queryset_count_of(expected_count):
# count() should save the result of the query in the cache
diff --git a/backend/tests/baserow/contrib/database/view/test_view_array_filters.py b/backend/tests/baserow/contrib/database/view/test_view_array_filters.py
index 4e0eb400c3..32c3687efb 100644
--- a/backend/tests/baserow/contrib/database/view/test_view_array_filters.py
+++ b/backend/tests/baserow/contrib/database/view/test_view_array_filters.py
@@ -153,7 +153,7 @@ def boolean_lookup_filter_proc(
type_name=filter_type_name,
value=test_value,
)
- q = test_setup.view_handler.get_queryset(test_setup.grid_view)
+ q = test_setup.view_handler.get_queryset(test_setup.user, test_setup.grid_view)
assert len(q) == len(selected)
assert set([r.id for r in q]) == set([r.id for r in selected])
diff --git a/backend/tests/baserow/contrib/database/view/test_view_filters.py b/backend/tests/baserow/contrib/database/view/test_view_filters.py
index 31b254f522..fdb15fdd07 100644
--- a/backend/tests/baserow/contrib/database/view/test_view_filters.py
+++ b/backend/tests/baserow/contrib/database/view/test_view_filters.py
@@ -6912,7 +6912,7 @@ def test_all_view_filters_can_accept_strings_as_filter_value(data_fixture):
# We should be able to load the view without any errors
handler = ViewHandler()
try:
- handler.get_queryset(view)
+ handler.get_queryset(user, view)
except Exception as e:
pytest.fail(f"Exception raised: {e}")
diff --git a/backend/tests/baserow/contrib/database/view/test_view_handler.py b/backend/tests/baserow/contrib/database/view/test_view_handler.py
index dc31035809..875df488b9 100755
--- a/backend/tests/baserow/contrib/database/view/test_view_handler.py
+++ b/backend/tests/baserow/contrib/database/view/test_view_handler.py
@@ -43,11 +43,7 @@
ViewTypeDoesNotExist,
)
from baserow.contrib.database.views.filters import AdHocFilters
-from baserow.contrib.database.views.handler import (
- PublicViewRows,
- ViewHandler,
- ViewIndexingHandler,
-)
+from baserow.contrib.database.views.handler import ViewHandler, ViewIndexingHandler
from baserow.contrib.database.views.models import (
DEFAULT_SORT_TYPE_KEY,
OWNERSHIP_TYPE_COLLABORATIVE,
@@ -65,11 +61,13 @@
view_filter_type_registry,
view_type_registry,
)
+from baserow.contrib.database.views.row_checker import FilteredViewRows
from baserow.contrib.database.views.signals import view_loaded
from baserow.contrib.database.views.view_ownership_types import (
CollaborativeViewOwnershipType,
)
from baserow.contrib.database.views.view_types import GridViewType
+from baserow.contrib.database.ws.views.rows.handler import ViewRealtimeRowsHandler
from baserow.core.db import get_collation_name
from baserow.core.exceptions import PermissionDenied, UserNotInWorkspace
from baserow.core.trash.handler import TrashHandler
@@ -1808,14 +1806,14 @@ def test_get_public_views_which_include_row(data_fixture, django_assert_num_quer
)
model = table.get_model()
- checker = ViewHandler().get_public_views_row_checker(
+ checker = ViewRealtimeRowsHandler().get_views_row_checker(
table, model, only_include_views_which_want_realtime_events=True
)
- assert checker.get_public_views_where_row_is_visible(row) == [
+ assert checker.get_filtered_views_where_row_is_visible(row) == [
public_view1.view_ptr.specific,
public_view3.view_ptr.specific,
]
- assert checker.get_public_views_where_row_is_visible(row2) == [
+ assert checker.get_filtered_views_where_row_is_visible(row2) == [
public_view2.view_ptr.specific,
public_view3.view_ptr.specific,
]
@@ -1885,22 +1883,22 @@ def test_get_public_views_which_include_rows(data_fixture):
)
model = table.get_model()
- checker = ViewHandler().get_public_views_row_checker(
+ checker = ViewRealtimeRowsHandler().get_views_row_checker(
table, model, only_include_views_which_want_realtime_events=True
)
- assert checker.get_public_views_where_rows_are_visible([row, row2]) == [
- PublicViewRows(
+ assert checker.get_filtered_views_where_rows_are_visible([row, row2]) == [
+ FilteredViewRows(
view=ViewHandler().get_view_as_user(user, public_view1.id).specific,
allowed_row_ids={1},
),
- PublicViewRows(
+ FilteredViewRows(
view=ViewHandler().get_view_as_user(user, public_view2.id).specific,
allowed_row_ids={2},
),
- PublicViewRows(
+ FilteredViewRows(
view=ViewHandler().get_view_as_user(user, public_view3.id).specific,
- allowed_row_ids=PublicViewRows.ALL_ROWS_ALLOWED,
+ allowed_row_ids=FilteredViewRows.ALL_ROWS_ALLOWED,
),
]
@@ -1937,25 +1935,25 @@ def test_public_view_row_checker_caches_when_only_unfiltered_fields_updated(
f"field_{unfiltered_field.id}": "any",
}
)
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table,
model,
only_include_views_which_want_realtime_events=True,
updated_field_ids=[unfiltered_field.id],
)
- assert row_checker.get_public_views_where_row_is_visible(visible_row) == [
+ assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == [
public_grid_view.view_ptr.specific
]
- assert row_checker.get_public_views_where_row_is_visible(invisible_row) == []
+ assert row_checker.get_filtered_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) == [
+ assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == [
public_grid_view.view_ptr.specific
]
- assert row_checker.get_public_views_where_row_is_visible(invisible_row) == []
+ assert row_checker.get_filtered_views_where_row_is_visible(invisible_row) == []
@pytest.mark.django_db
@@ -1987,7 +1985,7 @@ def test_public_view_row_checker_includes_public_views_with_no_filters_with_no_q
f"field_{unfiltered_field.id}": "any",
}
)
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table,
model,
only_include_views_which_want_realtime_events=True,
@@ -1997,10 +1995,10 @@ def test_public_view_row_checker_includes_public_views_with_no_filters_with_no_q
view_ptr_specific = public_grid_view.view_ptr.specific
# 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) == [
+ assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == [
view_ptr_specific
]
- assert row_checker.get_public_views_where_row_is_visible(other_row) == [
+ assert row_checker.get_filtered_views_where_row_is_visible(other_row) == [
view_ptr_specific
]
@@ -2037,17 +2035,17 @@ def test_public_view_row_checker_does_not_cache_when_any_filtered_fields_updated
f"field_{unfiltered_field.id}": "any",
}
)
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table,
model,
only_include_views_which_want_realtime_events=True,
updated_field_ids=[filtered_field.id, unfiltered_field.id],
)
- assert row_checker.get_public_views_where_row_is_visible(visible_row) == [
+ assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == [
public_grid_view.view_ptr.specific
]
- assert row_checker.get_public_views_where_row_is_visible(invisible_row) == []
+ assert row_checker.get_filtered_views_where_row_is_visible(invisible_row) == []
# Now update the rows so they swap and the invisible one becomes visible and vice
# versa
@@ -2056,10 +2054,10 @@ def test_public_view_row_checker_does_not_cache_when_any_filtered_fields_updated
setattr(visible_row, f"field_{filtered_field.id}", "NotFilterValue")
visible_row.save()
- assert row_checker.get_public_views_where_row_is_visible(invisible_row) == [
+ assert row_checker.get_filtered_views_where_row_is_visible(invisible_row) == [
public_grid_view.view_ptr.specific
]
- assert row_checker.get_public_views_where_row_is_visible(visible_row) == []
+ assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == []
@pytest.mark.django_db
@@ -2080,10 +2078,10 @@ def test_public_view_row_checker_runs_expected_queries_on_init(
view=public_grid_view, field=filtered_field, type="equal", value="FilterValue"
)
model = table.get_model()
- num_queries = 8
+ num_queries = 9
with django_assert_num_queries(num_queries):
# First query to get the public views, second query to get their filters.
- ViewHandler().get_public_views_row_checker(
+ ViewRealtimeRowsHandler().get_views_row_checker(
table,
model,
only_include_views_which_want_realtime_events=True,
@@ -2104,7 +2102,7 @@ def test_public_view_row_checker_runs_expected_queries_on_init(
# Adding another view shouldn't result in more queries
with django_assert_num_queries(num_queries):
# First query to get the public views, second query to get their filters.
- ViewHandler().get_public_views_row_checker(
+ ViewRealtimeRowsHandler().get_views_row_checker(
table,
model,
only_include_views_which_want_realtime_events=True,
@@ -2143,7 +2141,7 @@ def test_public_view_row_checker_runs_expected_queries_when_checking_rows(
f"field_{unfiltered_field.id}": "any",
}
)
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table,
model,
only_include_views_which_want_realtime_events=True,
@@ -2154,13 +2152,13 @@ def test_public_view_row_checker_runs_expected_queries_when_checking_rows(
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) == [
+ assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == [
view_ptr_specific
]
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) == []
+ assert row_checker.get_filtered_views_where_row_is_visible(invisible_row) == []
another_public_grid_view = data_fixture.create_grid_view(
user, table=table, public=True, order=1
@@ -2172,22 +2170,22 @@ def test_public_view_row_checker_runs_expected_queries_when_checking_rows(
value="FilterValue",
)
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table,
model,
only_include_views_which_want_realtime_events=True,
updated_field_ids=[filtered_field.id, unfiltered_field.id],
)
specific_another_view = another_public_grid_view.view_ptr.specific
- with django_assert_num_queries(2):
+ with django_assert_num_queries(1):
# Now should run two queries, one per public view
- assert row_checker.get_public_views_where_row_is_visible(visible_row) == [
+ assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == [
view_ptr_specific,
specific_another_view,
]
- with django_assert_num_queries(2):
+ with django_assert_num_queries(1):
# Now should run two queries, one per public view
- assert row_checker.get_public_views_where_row_is_visible(invisible_row) == []
+ assert row_checker.get_filtered_views_where_row_is_visible(invisible_row) == []
@pytest.mark.django_db
@@ -2423,14 +2421,15 @@ def test_get_public_rows_raises_with_form_view(data_fixture):
@pytest.mark.django_db
def test_get_rows_raises_with_form_view(data_fixture):
- form_view = data_fixture.create_form_view(public=True)
+ user = data_fixture.create_user()
+ form_view = data_fixture.create_form_view(public=True, user=user)
field = data_fixture.create_number_field(table=form_view.table)
model = form_view.table.get_model()
model.objects.create(**{f"field_{field.id}": 1})
with pytest.raises(ViewDoesNotSupportListingRows):
- ViewHandler().get_queryset(form_view)
+ ViewHandler().get_queryset(user, form_view)
@pytest.mark.django_db
@@ -4403,13 +4402,13 @@ def test_get_queryset_apply_sorts(data_fixture):
)
# Don't apply view sorting
- rows = view_handler.get_queryset(grid_view, apply_sorts=False)
+ rows = view_handler.get_queryset(user, grid_view, apply_sorts=False)
row_ids = [row.id for row in rows]
assert row_ids == [row_1.id, row_2.id, row_3.id]
# Apply view sorting
- rows = view_handler.get_queryset(grid_view, apply_sorts=True)
+ rows = view_handler.get_queryset(user, grid_view, apply_sorts=True)
row_ids = [row.id for row in rows]
assert row_ids == [row_3.id, row_2.id, row_1.id]
@@ -4442,14 +4441,14 @@ def test_can_duplicate_views_with_multiple_collaborator_has_filter(data_fixture)
.created_rows
)
- results = ViewHandler().get_queryset(grid)
+ results = ViewHandler().get_queryset(user_1, grid)
assert len(results) == 1
assert list(getattr(results[0], field.db_column).values_list("id", flat=True)) == [
user_1.id
]
new_grid = ViewHandler().duplicate_view(user_1, grid)
- new_results = ViewHandler().get_queryset(new_grid)
+ new_results = ViewHandler().get_queryset(user_1, new_grid)
assert len(new_results) == 1
assert list(
getattr(new_results[0], field.db_column).values_list("id", flat=True)
diff --git a/backend/tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py b/backend/tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py
index 44010cb674..46ed8a1b80 100644
--- a/backend/tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py
+++ b/backend/tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py
@@ -8,7 +8,9 @@
from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.trash.models import TrashedRows
-from baserow.contrib.database.views.handler import PublicViewRows, ViewHandler
+from baserow.contrib.database.views.handler import ViewHandler
+from baserow.contrib.database.views.row_checker import FilteredViewRows
+from baserow.contrib.database.ws.views.rows.handler import ViewRealtimeRowsHandler
from baserow.core.trash.handler import TrashHandler
@@ -747,10 +749,12 @@ def test_given_row_not_visible_in_public_view_when_updated_to_be_visible_event_s
)
# Double check the row isn't visible in any views to begin with
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table, model, only_include_views_which_want_realtime_events=True
)
- assert row_checker.get_public_views_where_row_is_visible(initially_hidden_row) == []
+ assert (
+ row_checker.get_filtered_views_where_row_is_visible(initially_hidden_row) == []
+ )
RowHandler().update_row_by_id(
user,
@@ -841,11 +845,11 @@ def test_batch_update_rows_not_visible_in_public_view_to_be_visible_event_sent(
)
# Double check the row isn't visible in any views to begin with
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table, model, only_include_views_which_want_realtime_events=True
)
assert (
- row_checker.get_public_views_where_rows_are_visible(
+ row_checker.get_filtered_views_where_rows_are_visible(
[initially_hidden_row, initially_hidden_row2]
)
== []
@@ -955,11 +959,11 @@ def test_batch_update_rows_some_not_visible_in_public_view_to_be_visible_event_s
)
# Double check the row isn't visible in any views to begin with
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table, model, only_include_views_which_want_realtime_events=True
)
assert (
- row_checker.get_public_views_where_rows_are_visible([initially_hidden_row])
+ row_checker.get_filtered_views_where_rows_are_visible([initially_hidden_row])
== []
)
@@ -1086,13 +1090,13 @@ def test_batch_update_rows_visible_in_public_view_to_some_not_be_visible_event_s
)
# Double check the row isn't visible in any views to begin with
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table, model, only_include_views_which_want_realtime_events=True
)
- assert row_checker.get_public_views_where_rows_are_visible(
+ assert row_checker.get_filtered_views_where_rows_are_visible(
[initially_visible_row, initially_visible_row2]
) == [
- PublicViewRows(
+ FilteredViewRows(
ViewHandler()
.get_view_as_user(
user, public_view_with_filters_initially_hiding_all_rows.id
@@ -1215,12 +1219,12 @@ def test_given_row_visible_in_public_view_when_updated_to_be_not_visible_event_s
)
# Double check the row is visible in the view to start with
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table, model, only_include_views_which_want_realtime_events=True
)
- assert row_checker.get_public_views_where_row_is_visible(initially_visible_row) == [
- public_view_with_row_showing.view_ptr.specific
- ]
+ assert row_checker.get_filtered_views_where_row_is_visible(
+ initially_visible_row
+ ) == [public_view_with_row_showing.view_ptr.specific]
# Update the row so it is no longer visible
RowHandler().update_row_by_id(
@@ -1313,10 +1317,10 @@ def test_batch_update_rows_visible_in_public_view_to_be_not_visible_event_sent(
)
# Double check the row is visible in any views to begin with
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table, model, only_include_views_which_want_realtime_events=True
)
- public_views = row_checker.get_public_views_where_rows_are_visible(
+ public_views = row_checker.get_filtered_views_where_rows_are_visible(
[initially_visible_row, initially_visible_row2]
)
assert len(public_views) == 1
@@ -1422,12 +1426,12 @@ def test_given_row_visible_in_public_view_when_updated_to_still_be_visible_event
)
# Double check the row is visible in the view to start with
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table, model, only_include_views_which_want_realtime_events=True
)
- assert row_checker.get_public_views_where_row_is_visible(initially_visible_row) == [
- public_view_with_row_showing.view_ptr.specific
- ]
+ assert row_checker.get_filtered_views_where_row_is_visible(
+ initially_visible_row
+ ) == [public_view_with_row_showing.view_ptr.specific]
# Update the row so it is still visible but changed
RowHandler().update_row_by_id(
@@ -1526,10 +1530,10 @@ def test_batch_update_rows_visible_in_public_view_still_be_visible_event_sent(
)
# Double check the rows are visible in the view to start with
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table, model, only_include_views_which_want_realtime_events=True
)
- public_views = row_checker.get_public_views_where_rows_are_visible(
+ public_views = row_checker.get_filtered_views_where_rows_are_visible(
[initially_visible_row, initially_visible_row2]
)
assert len(public_views) == 1
@@ -1628,14 +1632,14 @@ def test_batch_update_subset_rows_visible_in_public_view_no_filters(
)
# Double check the rows are visible in the view to start with
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table, model, only_include_views_which_want_realtime_events=True
)
- public_views = row_checker.get_public_views_where_rows_are_visible(
+ public_views = row_checker.get_filtered_views_where_rows_are_visible(
[initially_visible_row, initially_visible_row2]
)
assert len(public_views) == 1
- assert public_views[0].allowed_row_ids == PublicViewRows.ALL_ROWS_ALLOWED
+ assert public_views[0].allowed_row_ids == FilteredViewRows.ALL_ROWS_ALLOWED
assert public_views[0].view.id == public_view_with_row_showing.id
# Update the row so that they are still visible but changed
@@ -1995,10 +1999,10 @@ def test_given_row_visible_in_public_view_when_moved_row_updated_sent(
)
# Double check the row is visible in the view to start with
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table, model, only_include_views_which_want_realtime_events=True
)
- assert row_checker.get_public_views_where_row_is_visible(visible_moving_row) == [
+ assert row_checker.get_filtered_views_where_row_is_visible(visible_moving_row) == [
public_view.view_ptr.specific
]
@@ -2095,10 +2099,12 @@ def test_given_row_invisible_in_public_view_when_moved_no_update_sent(
)
# Double check the row is visible in the view to start with
- row_checker = ViewHandler().get_public_views_row_checker(
+ row_checker = ViewRealtimeRowsHandler().get_views_row_checker(
table, model, only_include_views_which_want_realtime_events=True
)
- assert row_checker.get_public_views_where_row_is_visible(invisible_moving_row) == []
+ assert (
+ row_checker.get_filtered_views_where_row_is_visible(invisible_moving_row) == []
+ )
# Move the invisible row
with transaction.atomic():
diff --git a/backend/tests/baserow/performance/test_public_sharing_performance.py b/backend/tests/baserow/performance/test_public_sharing_performance.py
index 27837fc6e2..4e5c768986 100644
--- a/backend/tests/baserow/performance/test_public_sharing_performance.py
+++ b/backend/tests/baserow/performance/test_public_sharing_performance.py
@@ -151,7 +151,7 @@ def test_updating_many_rows_in_public_filtered_views(
│ │ │ │ [18 frames hidden] rest_framework, django, copy,
│ │ │ └─ 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 get_filtered_views_where_row_is_visible baserow/contrib/databas
│ │ └─ 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
diff --git a/changelog/entries/unreleased/bug/fix_bug_in_helm_chart_where_assistant_llm_always_set.json b/changelog/entries/unreleased/bug/fix_bug_in_helm_chart_where_assistant_llm_always_set.json
new file mode 100644
index 0000000000..0b5ac2853a
--- /dev/null
+++ b/changelog/entries/unreleased/bug/fix_bug_in_helm_chart_where_assistant_llm_always_set.json
@@ -0,0 +1,9 @@
+{
+ "type": "bug",
+ "message": "Fix bug in the Helm chart where the AI-assistant LLM model was always set.",
+ "issue_origin": "core",
+ "issue_number": null,
+ "domain": "builder",
+ "bullet_points": [],
+ "created_at": "2025-11-25"
+}
diff --git a/changelog/entries/unreleased/bug/improve_pendingsearchvalueupdate_performance.json b/changelog/entries/unreleased/bug/improve_pendingsearchvalueupdate_performance.json
new file mode 100644
index 0000000000..12deec02a3
--- /dev/null
+++ b/changelog/entries/unreleased/bug/improve_pendingsearchvalueupdate_performance.json
@@ -0,0 +1,9 @@
+{
+ "type": "bug",
+ "message": "Improve performance in the `database_pendingsearchvalueupdate` table with many entries.",
+ "issue_origin": "github",
+ "issue_number": null,
+ "domain": "database",
+ "bullet_points": [],
+ "created_at": "2025-12-02"
+}
diff --git a/changelog/entries/unreleased/refactor/improved_storage_usage_update_performance.json b/changelog/entries/unreleased/refactor/improved_storage_usage_update_performance.json
new file mode 100644
index 0000000000..37404fc247
--- /dev/null
+++ b/changelog/entries/unreleased/refactor/improved_storage_usage_update_performance.json
@@ -0,0 +1,8 @@
+{
+ "type": "refactor",
+ "message": "Improved storage usage performance.",
+ "domain": "database",
+ "issue_number": null,
+ "bullet_points": [],
+ "created_at": "2025-12-02"
+}
diff --git a/changelog/entries/unreleased/refactor/refactored_the_element_theme_override_form_so_that_it_works_.json b/changelog/entries/unreleased/refactor/refactored_the_element_theme_override_form_so_that_it_works_.json
new file mode 100644
index 0000000000..cbe87340a9
--- /dev/null
+++ b/changelog/entries/unreleased/refactor/refactored_the_element_theme_override_form_so_that_it_works_.json
@@ -0,0 +1,9 @@
+{
+ "type": "refactor",
+ "message": "Refactored the element theme override form so that it works better on smaller screens.",
+ "issue_origin": "github",
+ "issue_number": null,
+ "domain": "builder",
+ "bullet_points": [],
+ "created_at": "2025-12-02"
+}
\ No newline at end of file
diff --git a/changelog/entries/unreleased/refactor/update_email_compiler_dependencies.json b/changelog/entries/unreleased/refactor/update_email_compiler_dependencies.json
new file mode 100644
index 0000000000..bca0c51630
--- /dev/null
+++ b/changelog/entries/unreleased/refactor/update_email_compiler_dependencies.json
@@ -0,0 +1,9 @@
+{
+ "type": "refactor",
+ "message": "Update email compiler dependencies",
+ "issue_origin": "github",
+ "issue_number": null,
+ "domain": "core",
+ "bullet_points": [],
+ "created_at": "2025-12-03"
+}
diff --git a/deploy/helm/baserow/values.yaml b/deploy/helm/baserow/values.yaml
index 81df5ad01a..241ef84ce3 100644
--- a/deploy/helm/baserow/values.yaml
+++ b/deploy/helm/baserow/values.yaml
@@ -59,7 +59,7 @@ global:
domain: cluster.local
backendDomain: api.cluster.local
objectsDomain: objects.cluster.local
- assistantLLMModel: "groq/openai/gpt-oss-120b"
+ assistantLLMModel: ""
securityContext:
enabled: false
diff --git a/enterprise/backend/src/baserow_enterprise/apps.py b/enterprise/backend/src/baserow_enterprise/apps.py
index 82142e2751..f25db7bfb9 100755
--- a/enterprise/backend/src/baserow_enterprise/apps.py
+++ b/enterprise/backend/src/baserow_enterprise/apps.py
@@ -351,14 +351,30 @@ def ready(self):
assistant_tool_registry.register(ListWorkflowsToolType())
assistant_tool_registry.register(WorkflowToolFactoryToolType())
+ from baserow_enterprise.views.operations import (
+ ListenToAllRestrictedViewEventsOperationType,
+ )
+
+ operation_type_registry.register(ListenToAllRestrictedViewEventsOperationType())
+
from baserow.contrib.database.views.registries import (
view_ownership_type_registry,
)
+ from baserow.contrib.database.ws.views.rows.registries import (
+ view_realtime_rows_registry,
+ )
from baserow.core.feature_flags import feature_flag_is_enabled
+ from baserow.ws.registries import page_registry
from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType
+ from baserow_enterprise.ws.pages import RestrictedViewPageType
+ from baserow_enterprise.ws.restricted_view.rows.view_realtime_rows import (
+ RestrictedViewRealtimeRowsType,
+ )
if feature_flag_is_enabled("view_permissions"):
view_ownership_type_registry.register(RestrictedViewOwnershipType())
+ page_registry.register(RestrictedViewPageType())
+ view_realtime_rows_registry.register(RestrictedViewRealtimeRowsType())
# The signals must always be imported last because they use the registries
# which need to be filled first.
diff --git a/enterprise/backend/src/baserow_enterprise/date_dependency/tasks.py b/enterprise/backend/src/baserow_enterprise/date_dependency/tasks.py
index 58a73c8946..93098dba04 100644
--- a/enterprise/backend/src/baserow_enterprise/date_dependency/tasks.py
+++ b/enterprise/backend/src/baserow_enterprise/date_dependency/tasks.py
@@ -147,10 +147,10 @@ def date_dependency_recalculate_rows(rule_id, table_id):
before_values.append(old_row)
after_values.append(new_row)
cursor.execute(validation_query)
- from baserow.contrib.database.ws.public.rows.signals import (
- public_before_rows_update,
- )
from baserow.contrib.database.ws.rows.signals import serialize_rows_values
+ from baserow.contrib.database.ws.views.rows.signals import (
+ views_before_rows_update,
+ )
before_return_values = {
serialize_rows_values: serialize_rows_values(
@@ -162,7 +162,7 @@ def date_dependency_recalculate_rows(rule_id, table_id):
[rule.duration_field.id],
serialize_only_updated_fields=True,
),
- public_before_rows_update: public_before_rows_update(
+ views_before_rows_update: views_before_rows_update(
None,
before_values,
None,
diff --git a/enterprise/backend/src/baserow_enterprise/migrations/0056_role_hidden.py b/enterprise/backend/src/baserow_enterprise/migrations/0057_role_hidden.py
similarity index 86%
rename from enterprise/backend/src/baserow_enterprise/migrations/0056_role_hidden.py
rename to enterprise/backend/src/baserow_enterprise/migrations/0057_role_hidden.py
index 717e961745..b24088d0b6 100644
--- a/enterprise/backend/src/baserow_enterprise/migrations/0056_role_hidden.py
+++ b/enterprise/backend/src/baserow_enterprise/migrations/0057_role_hidden.py
@@ -5,7 +5,7 @@
class Migration(migrations.Migration):
dependencies = [
- ("baserow_enterprise", "0055_assistantchatmessage_action_group_id_and_more"),
+ ("baserow_enterprise", "0056_alter_knowledgebasedocument_type"),
]
operations = [
diff --git a/enterprise/backend/src/baserow_enterprise/role/default_roles.py b/enterprise/backend/src/baserow_enterprise/role/default_roles.py
index 7b32d95c31..e0556f0b6c 100755
--- a/enterprise/backend/src/baserow_enterprise/role/default_roles.py
+++ b/enterprise/backend/src/baserow_enterprise/role/default_roles.py
@@ -161,12 +161,14 @@
CreateViewFilterOperationType,
CreateViewGroupByOperationType,
CreateViewOperationType,
+ CreateViewRowOperationType,
CreateViewSortOperationType,
DeleteViewDecorationOperationType,
DeleteViewFilterGroupOperationType,
DeleteViewFilterOperationType,
DeleteViewGroupByOperationType,
DeleteViewOperationType,
+ DeleteViewRowOperationType,
DeleteViewSortOperationType,
DuplicateViewOperationType,
ListAggregationsViewOperationType,
@@ -183,6 +185,7 @@
ReadViewFilterOperationType,
ReadViewGroupByOperationType,
ReadViewOperationType,
+ ReadViewRowOperationType,
ReadViewsOrderOperationType,
ReadViewSortOperationType,
RestoreViewOperationType,
@@ -193,6 +196,7 @@
UpdateViewGroupByOperationType,
UpdateViewOperationType,
UpdateViewPublicOperationType,
+ UpdateViewRowOperationType,
UpdateViewSlugOperationType,
UpdateViewSortOperationType,
)
@@ -302,6 +306,9 @@
RestoreTeamOperationType,
UpdateTeamOperationType,
)
+from baserow_enterprise.views.operations import (
+ ListenToAllRestrictedViewEventsOperationType,
+)
default_roles = {
ADMIN_ROLE_UID: [],
@@ -345,7 +352,6 @@
ReadApplicationOperationType,
ReadDatabaseTableOperationType,
ListRowsDatabaseTableOperationType,
- ReadDatabaseRowOperationType,
ReadViewOperationType,
ReadFieldOperationType,
ListViewSortOperationType,
@@ -360,7 +366,6 @@
ReadAdjacentRowDatabaseRowOperationType,
ListRowNamesDatabaseTableOperationType,
ReadViewFilterOperationType,
- ListenToAllDatabaseTableEventsOperationType,
ReadViewsOrderOperationType,
ReadViewSortOperationType,
ListViewGroupByOperationType,
@@ -377,6 +382,8 @@
default_roles[VIEWER_ROLE_UID].extend(
default_roles[READ_ONLY_ROLE_UID]
+ [
+ ListenToAllRestrictedViewEventsOperationType,
+ ListenToAllDatabaseTableEventsOperationType,
ReadMCPEndpointOperationType,
CreateMCPEndpointOperationType,
UpdateMCPEndpointOperationType,
@@ -385,6 +392,8 @@
ReadFieldRuleOperationType,
ExportTableOperationType,
DispatchDashboardDataSourceOperationType,
+ ReadDatabaseRowOperationType,
+ ReadViewRowOperationType,
]
)
default_roles[COMMENTER_ROLE_UID].extend(
@@ -411,6 +420,9 @@
ListTeamSubjectsOperationType,
ReadTeamSubjectOperationType,
CanReceiveNotificationOnSubmitFormViewOperationType,
+ CreateViewRowOperationType,
+ UpdateViewRowOperationType,
+ DeleteViewRowOperationType,
]
)
default_roles[BUILDER_ROLE_UID].extend(
diff --git a/enterprise/backend/src/baserow_enterprise/view_ownership_types.py b/enterprise/backend/src/baserow_enterprise/view_ownership_types.py
index 577910ca58..b681de7325 100644
--- a/enterprise/backend/src/baserow_enterprise/view_ownership_types.py
+++ b/enterprise/backend/src/baserow_enterprise/view_ownership_types.py
@@ -2,10 +2,14 @@
from baserow_premium.license.handler import LicenseHandler
+from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.models import View
+from baserow.contrib.database.views.operations import CreateViewFilterOperationType
from baserow.contrib.database.views.registries import ViewOwnershipType
from baserow.core.exceptions import PermissionDenied
+from baserow.core.handler import CoreHandler
from baserow.core.models import Workspace
+from baserow.core.types import PermissionCheck
from baserow_enterprise.features import RBAC
@@ -26,3 +30,62 @@ def change_ownership_type(self, user: AbstractUser, view: View) -> View:
def view_created(self, user: AbstractUser, view: "View", workspace: Workspace):
LicenseHandler.raise_if_user_doesnt_have_feature(RBAC, user, workspace)
+
+ def enforce_apply_filters(self, user, view):
+ # If the user does not have permissions to create filters in the view, then it
+ # means that the user has the editor role or lower. In that case, the user might
+ # not have access to the full table, so the view filters are enforced. The user
+ # can't change the view filters and can't see them, so they will only have
+ # access to the filtered data. This allows giving the user only access to the
+ # desired rows.
+ return user is not None and not CoreHandler().check_permissions(
+ user,
+ CreateViewFilterOperationType.type,
+ workspace=view.table.database.workspace,
+ context=view,
+ raise_permission_exceptions=False,
+ )
+
+ def prepare_views_for_user(self, user, views):
+ if len(views) == 0 or user is None:
+ return views
+
+ permission_checks = {}
+ for view in views:
+ permission_checks[view.id] = PermissionCheck(
+ user,
+ CreateViewFilterOperationType.type,
+ context=view,
+ )
+
+ check_results = CoreHandler().check_multiple_permissions(
+ permission_checks.values(), workspace=views[0].table.database.workspace
+ )
+
+ for view in views:
+ check_result = check_results[permission_checks[view.id]]
+ # If the user does not have create view filter permissions for the provided
+ # view, then the filters are omitted because the they're forcefully applied
+ # so that the user can only see the rows that match the filter.
+ if not check_result:
+ if not hasattr(view, "_prefetched_objects_cache"):
+ view._prefetched_objects_cache = {}
+ view._prefetched_objects_cache["viewfilter_set"] = []
+ view._prefetched_objects_cache["filter_groups"] = []
+
+ return views
+
+ def can_modify_rows(self, view, row_ids=None):
+ if not row_ids:
+ return True
+
+ # Check if all the provided row_ids actually exist in the filtered queryset.
+ # We don't want to allow modifying rows that are outside the filters because
+ # that is not where the user has access to.
+ model = view.table.get_model()
+ filter_qs = ViewHandler().apply_filters(view, model.objects)
+ rows_in_view = filter_qs.filter(id__in=row_ids).values("id")
+ rows_outside_view = model.objects.filter(id__in=row_ids).exclude(
+ id__in=rows_in_view
+ )
+ return not rows_outside_view.exists()
diff --git a/enterprise/backend/src/baserow_enterprise/views/__init__.py b/enterprise/backend/src/baserow_enterprise/views/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/enterprise/backend/src/baserow_enterprise/views/operations.py b/enterprise/backend/src/baserow_enterprise/views/operations.py
new file mode 100644
index 0000000000..f5c8b4f4c7
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/views/operations.py
@@ -0,0 +1,5 @@
+from baserow.contrib.database.views.operations import ViewOperationType
+
+
+class ListenToAllRestrictedViewEventsOperationType(ViewOperationType):
+ type = "database.table.view.listen_to_all_restricted_view"
diff --git a/enterprise/backend/src/baserow_enterprise/ws/pages.py b/enterprise/backend/src/baserow_enterprise/ws/pages.py
new file mode 100644
index 0000000000..82fcb79a61
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/ws/pages.py
@@ -0,0 +1,51 @@
+from baserow.contrib.database.views.exceptions import ViewDoesNotExist
+from baserow.contrib.database.views.handler import ViewHandler
+from baserow.core.exceptions import PermissionDenied, UserNotInWorkspace
+from baserow.core.handler import CoreHandler
+from baserow.ws.registries import PageType
+from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType
+from baserow_enterprise.views.operations import (
+ ListenToAllRestrictedViewEventsOperationType,
+)
+
+
+class RestrictedViewPageType(PageType):
+ """
+ This page is specifically made for the restricted view ownership type. When the
+ user opens the restricted view, and they don't have permissions to listen for all
+ the table events, then they will use this page to receive real-time events.
+
+ If a row is updated in the table, then it only broadcasts the updates if it
+ matches the filter to make sure the user only receives data that it's supposed to
+ see in the view.
+ """
+
+ type = "restricted_view"
+ parameters = ["restricted_view_id"]
+
+ def can_add(self, user, web_socket_id, restricted_view_id, **kwargs):
+ try:
+ handler = ViewHandler()
+ view = handler.get_view(restricted_view_id)
+
+ if view.ownership_type != RestrictedViewOwnershipType.type:
+ return False
+
+ # Check if the user has any permissions to access the view. If so,
+ # we'll allow the user to listen for events.
+ CoreHandler().check_permissions(
+ user,
+ ListenToAllRestrictedViewEventsOperationType.type,
+ workspace=view.table.database.workspace,
+ context=view,
+ )
+ except (UserNotInWorkspace, ViewDoesNotExist, PermissionDenied):
+ return False
+
+ return True
+
+ def get_group_name(self, restricted_view_id, **kwargs):
+ return f"restricted-view-{restricted_view_id}"
+
+ def get_permission_channel_group_name(self, restricted_view_id, **kwargs):
+ return f"permissions-restricted-view-{restricted_view_id}"
diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/__init__.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/fields/__init__.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/fields/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/fields/signals.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/fields/signals.py
new file mode 100644
index 0000000000..d03c914d6b
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/fields/signals.py
@@ -0,0 +1,89 @@
+from typing import Any, Dict
+
+from django.contrib.auth.models import AbstractUser
+from django.db import transaction
+from django.dispatch import receiver
+
+from baserow.contrib.database.fields import signals as field_signals
+from baserow.contrib.database.views.models import View
+from baserow.contrib.database.ws.fields.signals import RealtimeFieldMessages
+from baserow.ws.registries import page_registry
+from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType
+
+
+def _broadcast_payload_to_all_restricted_views(
+ user: AbstractUser,
+ table_id: int,
+ payload: Dict[str, Any],
+):
+ views = View.objects.filter(
+ table_id=table_id,
+ ownership_type=RestrictedViewOwnershipType.type,
+ ).values_list("id", flat=True)
+
+ view_page_type = page_registry.get("restricted_view")
+ for view_id in views:
+ view_page_type.broadcast(
+ payload,
+ getattr(user, "web_socket_id", None),
+ restricted_view_id=view_id,
+ )
+
+
+@receiver(field_signals.field_created)
+def field_created(sender, field, related_fields, user, **kwargs):
+ transaction.on_commit(
+ lambda: _broadcast_payload_to_all_restricted_views(
+ user,
+ field.table_id,
+ RealtimeFieldMessages.field_created(
+ field,
+ related_fields,
+ ),
+ )
+ )
+
+
+@receiver(field_signals.field_restored)
+def field_restored(sender, field, related_fields, user, **kwargs):
+ transaction.on_commit(
+ lambda: _broadcast_payload_to_all_restricted_views(
+ user,
+ field.table_id,
+ RealtimeFieldMessages.field_restored(
+ field,
+ related_fields,
+ ),
+ )
+ )
+
+
+@receiver(field_signals.field_updated)
+def field_updated(sender, field, related_fields, user, **kwargs):
+ transaction.on_commit(
+ lambda: _broadcast_payload_to_all_restricted_views(
+ user,
+ field.table_id,
+ RealtimeFieldMessages.field_updated(
+ field,
+ related_fields,
+ ),
+ )
+ )
+
+
+@receiver(field_signals.field_deleted)
+def field_deleted(
+ sender, field_id, field, related_fields, user, before_return, **kwargs
+):
+ transaction.on_commit(
+ lambda: _broadcast_payload_to_all_restricted_views(
+ user,
+ field.table_id,
+ RealtimeFieldMessages.field_deleted(
+ field.table_id,
+ field_id,
+ related_fields,
+ ),
+ )
+ )
diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/rows/__init__.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/rows/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/rows/view_realtime_rows.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/rows/view_realtime_rows.py
new file mode 100644
index 0000000000..5acf7125c1
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/rows/view_realtime_rows.py
@@ -0,0 +1,19 @@
+from django.db.models import Q
+
+from baserow.contrib.database.ws.views.rows.registries import ViewRealtimeRowsType
+from baserow.ws.registries import page_registry
+from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType
+
+
+class RestrictedViewRealtimeRowsType(ViewRealtimeRowsType):
+ type = "restricted_view"
+
+ def get_views_filter(self) -> Q:
+ return Q(ownership_type=RestrictedViewOwnershipType.type)
+
+ def broadcast(self, view, payload):
+ view_page_type = page_registry.get("restricted_view")
+ view_page_type.broadcast(
+ payload,
+ restricted_view_id=view.id,
+ )
diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/views/__init__.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/views/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/views/signals.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/views/signals.py
new file mode 100644
index 0000000000..a6e1b1bdbe
--- /dev/null
+++ b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/views/signals.py
@@ -0,0 +1,41 @@
+from django.db import transaction
+from django.dispatch import receiver
+
+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
+from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType
+
+
+def _send_force_rows_refresh_if_view_restricted(view):
+ view_page_type = page_registry.get("restricted_view")
+ view_type = view_type_registry.get_by_model(view.specific_class)
+ if (
+ view.ownership_type == RestrictedViewOwnershipType.type
+ and
+ # This will make sure that the form view is excluded because there is no need
+ # for real-time updates of a row in the form view.
+ view_type.can_filter
+ ):
+ transaction.on_commit(
+ lambda: view_page_type.broadcast(
+ {"type": "force_view_rows_refresh", "view_id": view.id},
+ None,
+ restricted_view_id=view.id,
+ )
+ )
+
+
+@receiver(view_signals.view_filter_created)
+def restricted_view_filter_created(sender, view_filter, user, **kwargs):
+ _send_force_rows_refresh_if_view_restricted(view_filter.view)
+
+
+@receiver(view_signals.view_filter_updated)
+def restricted_view_filter_updated(sender, view_filter, user, **kwargs):
+ _send_force_rows_refresh_if_view_restricted(view_filter.view)
+
+
+@receiver(view_signals.view_filter_deleted)
+def restricted_view_filter_deleted(sender, view_filter_id, view_filter, user, **kwargs):
+ _send_force_rows_refresh_if_view_restricted(view_filter.view)
diff --git a/enterprise/backend/src/baserow_enterprise/ws/signals.py b/enterprise/backend/src/baserow_enterprise/ws/signals.py
index a9a2c5b3bb..da227488b0 100644
--- a/enterprise/backend/src/baserow_enterprise/ws/signals.py
+++ b/enterprise/backend/src/baserow_enterprise/ws/signals.py
@@ -1 +1,21 @@
-__all__ = []
+from .restricted_view.fields.signals import (
+ field_created,
+ field_deleted,
+ field_restored,
+ field_updated,
+)
+from .restricted_view.views.signals import (
+ restricted_view_filter_created,
+ restricted_view_filter_deleted,
+ restricted_view_filter_updated,
+)
+
+__all__ = [
+ "restricted_view_filter_created",
+ "restricted_view_filter_updated",
+ "restricted_view_filter_deleted",
+ "field_created",
+ "field_restored",
+ "field_updated",
+ "field_deleted",
+]
diff --git a/enterprise/backend/tests/baserow_enterprise_tests/api/views/test_enterprise_view_views.py b/enterprise/backend/tests/baserow_enterprise_tests/api/views/test_enterprise_view_views.py
index 421f587270..080f10caaa 100644
--- a/enterprise/backend/tests/baserow_enterprise_tests/api/views/test_enterprise_view_views.py
+++ b/enterprise/backend/tests/baserow_enterprise_tests/api/views/test_enterprise_view_views.py
@@ -4,12 +4,16 @@
import pytest
from rest_framework.status import (
HTTP_200_OK,
+ HTTP_204_NO_CONTENT,
HTTP_401_UNAUTHORIZED,
HTTP_402_PAYMENT_REQUIRED,
)
+from baserow.contrib.database.views.models import View
from baserow.core.subjects import UserSubjectType
+from baserow_enterprise.role.handler import RoleAssignmentHandler
from baserow_enterprise.role.models import Role
+from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType
@pytest.mark.django_db
@@ -136,3 +140,877 @@ def test_cannot_create_view_if_user_has_only_permissions_to_view(
HTTP_AUTHORIZATION=f"JWT {token2}",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_get_row_with_only_view_permissions(api_client, enterprise_data_fixture):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ normal_view = enterprise_data_fixture.create_grid_view(table=table)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+
+ # Create a row to fetch
+ model = table.get_model()
+ row = model.objects.create(**{f"field_{text_field.id}": "Visible value"})
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ workspace = table.database.workspace
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+ # This normally never happens, but for testing purposes, we want to make sure that
+ # if a user has access to a non-restricted view, they still cannot get a row
+ # via that view when they don't have table permissions.
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=normal_view.id),
+ )
+
+ base_url = reverse(
+ "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id}
+ )
+
+ # Expect permission denied when trying to get a row without view parameter
+ response = api_client.get(
+ base_url,
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ # Expect permission denied when trying to get a row via a non-restricted view
+ response = api_client.get(
+ base_url + f"?view={normal_view.id}",
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ # Should succeed when using view parameter with restricted view
+ response = api_client.get(
+ base_url + f"?view={restricted_view.id}",
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_200_OK
+ response_json = response.json()
+ assert response_json["id"] == row.id
+ assert response_json[f"field_{text_field.id}"] == "Visible value"
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_cannot_get_row_outside_of_restricted_view(api_client, enterprise_data_fixture):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ enterprise_data_fixture.create_grid_view(table=table)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+ enterprise_data_fixture.create_view_filter(
+ view=restricted_view, field=text_field, type="equal", value="ABC"
+ )
+
+ # Create rows: one visible in the restricted view, one not
+ model = table.get_model()
+ row_visible = model.objects.create(**{f"field_{text_field.id}": "ABC"})
+ row_hidden = model.objects.create(**{f"field_{text_field.id}": "DEF"})
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+
+ # Should succeed when getting a row that is visible in the restricted view
+ url_visible = reverse(
+ "api:database:rows:item",
+ kwargs={"table_id": table.id, "row_id": row_visible.id},
+ )
+ response = api_client.get(
+ url_visible + f"?view={restricted_view.id}",
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_200_OK
+
+ # Should fail when trying to get a row that is not visible in the restricted view
+ url_hidden = reverse(
+ "api:database:rows:item",
+ kwargs={"table_id": table.id, "row_id": row_hidden.id},
+ )
+ response = api_client.get(
+ url_hidden + f"?view={restricted_view.id}",
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_create_row_with_only_view_permissions(api_client, enterprise_data_fixture):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ normal_view = enterprise_data_fixture.create_grid_view(table=table)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ workspace = table.database.workspace
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+ # This normally never happens, but for testing purposes, we want to make sure that
+ # if a user has access to a view, that they cannot create a row because it's not a
+ # restricted view.
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=normal_view.id),
+ )
+
+ url = reverse("api:database:rows:list", kwargs={"table_id": table.id})
+
+ # Expect permission denied when trying to create a row in the table because the
+ # user does not have access to the table.
+ response = api_client.post(
+ url,
+ {f"field_{text_field.id}": "Test 1"},
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ # Expect permission denied when trying to create a row in the table because this
+ # view ownership type does not allow a user to create a row.
+ response = api_client.post(
+ url + f"?view={normal_view.id}",
+ {f"field_{text_field.id}": "Test 1"},
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ # Should come through because the user has access to the view.
+ response = api_client.post(
+ url + f"?view={restricted_view.id}",
+ {f"field_{text_field.id}": "Test 1"},
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_200_OK
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_create_rows_with_only_view_permissions(api_client, enterprise_data_fixture):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ normal_view = enterprise_data_fixture.create_grid_view(table=table)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ workspace = table.database.workspace
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+
+ url = reverse("api:database:rows:batch", kwargs={"table_id": table.id})
+
+ # Expect permission denied when trying to batch create rows without view parameter
+ response = api_client.post(
+ url,
+ {
+ "items": [
+ {f"field_{text_field.id}": "Test 1"},
+ {f"field_{text_field.id}": "Test 2"},
+ ]
+ },
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ response = api_client.post(
+ url + f"?view={normal_view.id}",
+ {
+ "items": [
+ {f"field_{text_field.id}": "Test 1"},
+ {f"field_{text_field.id}": "Test 2"},
+ ]
+ },
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ # Should succeed when using view parameter with restricted view
+ response = api_client.post(
+ url + f"?view={restricted_view.id}",
+ {
+ "items": [
+ {f"field_{text_field.id}": "Test 1"},
+ {f"field_{text_field.id}": "Test 2"},
+ ]
+ },
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_200_OK
+ assert len(response.json()["items"]) == 2
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_update_row_with_only_view_permissions(api_client, enterprise_data_fixture):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ normal_view = enterprise_data_fixture.create_grid_view(table=table)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+
+ # Create a row to update
+ model = table.get_model()
+ row = model.objects.create(**{f"field_{text_field.id}": "Original Value"})
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+
+ # Expect permission denied when trying to update row without view parameter
+ response = api_client.patch(
+ reverse(
+ "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id}
+ ),
+ {f"field_{text_field.id}": "Updated Value"},
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ response = api_client.patch(
+ reverse(
+ "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id}
+ )
+ + f"?view={normal_view.id}",
+ {f"field_{text_field.id}": "Updated Value"},
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ # Should succeed when using view parameter with restricted view
+ response = api_client.patch(
+ reverse(
+ "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id}
+ )
+ + f"?view={restricted_view.id}",
+ {f"field_{text_field.id}": "Updated Value"},
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_200_OK
+ assert response.json()[f"field_{text_field.id}"] == "Updated Value"
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_cannot_update_row_outside_of_restricted_view(
+ api_client, enterprise_data_fixture
+):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ enterprise_data_fixture.create_grid_view(table=table)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+ enterprise_data_fixture.create_view_filter(
+ view=restricted_view, field=text_field, type="equal", value="ABC"
+ )
+
+ # Create a row to update
+ model = table.get_model()
+ # This row is visible in the view.
+ row1 = model.objects.create(**{f"field_{text_field.id}": "ABC"})
+ # This row is not visible in the view because it does not match the filters.
+ row2 = model.objects.create(**{f"field_{text_field.id}": "DEF"})
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+
+ # Should succeed when using view parameter with restricted view
+ response = api_client.patch(
+ reverse(
+ "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row1.id}
+ )
+ + f"?view={restricted_view.id}",
+ {f"field_{text_field.id}": "Updated Value"},
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_200_OK
+
+ response = api_client.patch(
+ reverse(
+ "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row2.id}
+ )
+ + f"?view={restricted_view.id}",
+ {f"field_{text_field.id}": "Updated Value"},
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_update_rows_with_only_view_permissions(api_client, enterprise_data_fixture):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+ normal_view = enterprise_data_fixture.create_grid_view(table=table)
+
+ # Create rows to update
+ model = table.get_model()
+ row1 = model.objects.create(**{f"field_{text_field.id}": "Original 1"})
+ row2 = model.objects.create(**{f"field_{text_field.id}": "Original 2"})
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+
+ url = reverse("api:database:rows:batch", kwargs={"table_id": table.id})
+
+ # Expect permission denied when trying to batch update rows without view parameter
+ response = api_client.patch(
+ url,
+ {
+ "items": [
+ {"id": row1.id, f"field_{text_field.id}": "Updated 1"},
+ {"id": row2.id, f"field_{text_field.id}": "Updated 2"},
+ ]
+ },
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ response = api_client.patch(
+ url + f"?view={normal_view.id}",
+ {
+ "items": [
+ {"id": row1.id, f"field_{text_field.id}": "Updated 1"},
+ {"id": row2.id, f"field_{text_field.id}": "Updated 2"},
+ ]
+ },
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ # Should succeed when using view parameter with restricted view
+ response = api_client.patch(
+ url + f"?view={restricted_view.id}",
+ {
+ "items": [
+ {"id": row1.id, f"field_{text_field.id}": "Updated 1"},
+ {"id": row2.id, f"field_{text_field.id}": "Updated 2"},
+ ]
+ },
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_200_OK
+ assert len(response.json()["items"]) == 2
+ assert response.json()["items"][0][f"field_{text_field.id}"] == "Updated 1"
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_cannot_update_rows_outside_of_restricted_view_filters(
+ api_client, enterprise_data_fixture
+):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+ enterprise_data_fixture.create_view_filter(
+ view=restricted_view, field=text_field, type="equal", value="ABC"
+ )
+
+ # Create rows to update
+ model = table.get_model()
+ # This row is visible in the view.
+ row1 = model.objects.create(**{f"field_{text_field.id}": "ABC"})
+ # This row is not visible in the view because it does not match the filters.
+ row2 = model.objects.create(**{f"field_{text_field.id}": "DEF"})
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+
+ url = reverse("api:database:rows:batch", kwargs={"table_id": table.id})
+
+ response = api_client.patch(
+ url + f"?view={restricted_view.id}",
+ {
+ "items": [
+ {"id": row1.id, f"field_{text_field.id}": "Updated 1"},
+ ]
+ },
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_200_OK
+
+ response = api_client.patch(
+ url + f"?view={restricted_view.id}",
+ {
+ "items": [
+ {"id": row2.id, f"field_{text_field.id}": "Updated 2"},
+ ]
+ },
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_delete_row_with_only_view_permissions(api_client, enterprise_data_fixture):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ normal_view = enterprise_data_fixture.create_grid_view(table=table)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+
+ # Create a row to delete
+ model = table.get_model()
+ row = model.objects.create(**{f"field_{text_field.id}": "Delete Me"})
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+
+ # Expect permission denied when trying to delete row without view parameter
+ response = api_client.delete(
+ reverse(
+ "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id}
+ ),
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ response = api_client.delete(
+ reverse(
+ "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id}
+ )
+ + f"?view={normal_view.id}",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ # Should succeed when using view parameter with restricted view
+ response = api_client.delete(
+ reverse(
+ "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id}
+ )
+ + f"?view={restricted_view.id}",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_204_NO_CONTENT
+
+ # Verify row was soft deleted (trashed)
+ row.refresh_from_db()
+ assert getattr(row, "trashed") is True
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_cannot_delete_row_outside_of_restricted_view_filters(
+ api_client, enterprise_data_fixture
+):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+ enterprise_data_fixture.create_view_filter(
+ view=restricted_view, field=text_field, type="equal", value="ABC"
+ )
+
+ # Create a row to delete
+ model = table.get_model()
+ # This row is visible in the view.
+ row1 = model.objects.create(**{f"field_{text_field.id}": "ABC"})
+ # This row is not visible in the view because it does not match the filters.
+ row2 = model.objects.create(**{f"field_{text_field.id}": "DEF"})
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+
+ response = api_client.delete(
+ reverse(
+ "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row1.id}
+ )
+ + f"?view={restricted_view.id}",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_204_NO_CONTENT
+
+ # Should succeed when using view parameter with restricted view
+ response = api_client.delete(
+ reverse(
+ "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row2.id}
+ )
+ + f"?view={restricted_view.id}",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_delete_rows_with_only_view_permissions(api_client, enterprise_data_fixture):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ normal_view = enterprise_data_fixture.create_grid_view(table=table)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+
+ # Create rows to delete
+ model = table.get_model()
+ row1 = model.objects.create(**{f"field_{text_field.id}": "Delete 1"})
+ row2 = model.objects.create(**{f"field_{text_field.id}": "Delete 2"})
+ row3 = model.objects.create(**{f"field_{text_field.id}": "Keep 3"})
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+
+ url = reverse("api:database:rows:batch-delete", kwargs={"table_id": table.id})
+
+ # Expect permission denied when trying to batch delete rows without view parameter
+ response = api_client.post(
+ url,
+ {"items": [row1.id, row2.id]},
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ response = api_client.post(
+ url + f"?view={normal_view.id}",
+ {"items": [row1.id, row2.id]},
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+ assert response.json()["error"] == "PERMISSION_DENIED"
+
+ # Should succeed when using view parameter with restricted view
+ response = api_client.post(
+ url + f"?view={restricted_view.id}",
+ {"items": [row1.id, row2.id]},
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_204_NO_CONTENT
+
+ # Verify rows were soft deleted (trashed)
+ row1.refresh_from_db()
+ row2.refresh_from_db()
+ row3.refresh_from_db()
+ assert getattr(row1, "trashed") is True
+ assert getattr(row2, "trashed") is True
+ assert getattr(row3, "trashed") is False
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_cannot_delete_rows_outside_of_restricted_view_filters(
+ api_client, enterprise_data_fixture
+):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ enterprise_data_fixture.create_grid_view(table=table)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+ enterprise_data_fixture.create_view_filter(
+ view=restricted_view, field=text_field, type="equal", value="ABC"
+ )
+
+ # Create rows to delete
+ model = table.get_model()
+ # This row is visible in the view.
+ row1 = model.objects.create(**{f"field_{text_field.id}": "ABC"})
+ # This row is not visible in the view because it does not match the filters.
+ row2 = model.objects.create(**{f"field_{text_field.id}": "DEF"})
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+
+ url = reverse("api:database:rows:batch-delete", kwargs={"table_id": table.id})
+
+ response = api_client.post(
+ url + f"?view={restricted_view.id}",
+ {"items": [row1.id, row2.id]},
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
+
+ response = api_client.post(
+ url + f"?view={restricted_view.id}",
+ {"items": [row1.id]},
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_204_NO_CONTENT
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_cannot_update_rows_in_table_using_unrelated_view(
+ api_client, enterprise_data_fixture
+):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ table2 = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table2, ownership_type=RestrictedViewOwnershipType.type
+ )
+
+ model = table.get_model()
+ row1 = model.objects.create(**{f"field_{text_field.id}": "ABC"})
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+
+ url = reverse("api:database:rows:batch", kwargs={"table_id": table.id})
+
+ # The user does have access to the view, but the view does not belong to the
+ # table, so it should result in an unauthorized error.
+ response = api_client.patch(
+ url + f"?view={restricted_view.id}",
+ {
+ "items": [
+ {"id": row1.id, f"field_{text_field.id}": "Updated 1"},
+ ]
+ },
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_401_UNAUTHORIZED
diff --git a/enterprise/backend/tests/baserow_enterprise_tests/views/test_restricted_view.py b/enterprise/backend/tests/baserow_enterprise_tests/views/test_restricted_view.py
new file mode 100644
index 0000000000..5759d4f9c1
--- /dev/null
+++ b/enterprise/backend/tests/baserow_enterprise_tests/views/test_restricted_view.py
@@ -0,0 +1,429 @@
+from datetime import datetime
+from unittest.mock import ANY, call, patch
+
+from django.test.utils import override_settings
+from django.urls import reverse
+
+import pytest
+from baserow_premium.views.view_types import (
+ CalendarViewType,
+ KanbanViewType,
+ TimelineViewType,
+)
+from starlette.status import HTTP_200_OK
+
+from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID
+from baserow.contrib.database.fields.models import DateField
+from baserow.contrib.database.rows.handler import RowHandler
+from baserow.contrib.database.views.models import View
+from baserow.contrib.database.views.registries import view_type_registry
+from baserow.contrib.database.views.view_ownership_types import (
+ CollaborativeViewOwnershipType,
+)
+from baserow.contrib.database.views.view_types import GalleryViewType, GridViewType
+from baserow.contrib.database.ws.views.rows.handler import ViewRealtimeRowsHandler
+from baserow.core.utils import get_value_at_path
+from baserow_enterprise.role.handler import RoleAssignmentHandler
+from baserow_enterprise.role.models import Role
+from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType
+
+
+@pytest.mark.django_db
+def test_get_public_views_which_include_row(
+ enterprise_data_fixture, django_assert_num_queries
+):
+ """
+ One test to check if the restricted view is included in the
+ `get_filtered_views_where_row_is_visible` is enough because we already have
+ plenty of tests related to the public view, which reuses the same code, in
+ `tests/baserow/contrib/database/view/test_view_handler.py`
+ """
+
+ user = enterprise_data_fixture.create_user()
+ table = enterprise_data_fixture.create_database_table(user=user)
+ visible_field = enterprise_data_fixture.create_text_field(table=table)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ user, table=table, order=0, ownership_type=RestrictedViewOwnershipType.type
+ )
+ enterprise_data_fixture.create_grid_view(
+ user,
+ table=table,
+ order=0,
+ )
+ # Should not appear in any results
+ enterprise_data_fixture.create_form_view(
+ user, table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+ enterprise_data_fixture.create_grid_view(user, table=table)
+
+ # Public View 1 has filters which match row 1
+ enterprise_data_fixture.create_view_filter(
+ view=restricted_view, field=visible_field, type="equal", value="Visible"
+ )
+
+ row = RowHandler().create_row(
+ user=user,
+ table=table,
+ values={
+ f"field_{visible_field.id}": "Visible",
+ },
+ )
+ row2 = RowHandler().create_row(
+ user=user,
+ table=table,
+ values={
+ f"field_{visible_field.id}": "Not Visible",
+ },
+ )
+
+ model = table.get_model()
+ checker = ViewRealtimeRowsHandler().get_views_row_checker(
+ table, model, only_include_views_which_want_realtime_events=True
+ )
+ assert checker.get_filtered_views_where_row_is_visible(row) == [
+ restricted_view,
+ ]
+ assert checker.get_filtered_views_where_row_is_visible(row2) == []
+
+
+@pytest.mark.django_db(transaction=True)
+@patch("baserow.ws.registries.broadcast_to_channel_group")
+def test_when_row_created_restricted_views_receive_restricted_row_ws_event(
+ mock_broadcast_to_channel_group,
+ enterprise_data_fixture,
+):
+ """
+ One test to check if correct payload is broadcasted is enough because we already
+ have plenty of tests related to the public view, which reuses the same code, in
+ `tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py`
+ """
+
+ user = enterprise_data_fixture.create_user()
+ table = enterprise_data_fixture.create_database_table(user=user)
+ visible_field = enterprise_data_fixture.create_text_field(table=table)
+ # Only restricted event should be sent to this view.
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table,
+ ownership_type=RestrictedViewOwnershipType.type,
+ public=False,
+ )
+ # Both public and restricted event should be sent to this view.
+ public_and_restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type, public=True
+ )
+ # No event should be sent to this view.
+ enterprise_data_fixture.create_form_view(
+ table=table,
+ ownership_type=RestrictedViewOwnershipType.type,
+ public=True,
+ )
+ enterprise_data_fixture.create_form_view(
+ table=table,
+ ownership_type=CollaborativeViewOwnershipType.type,
+ public=False,
+ )
+
+ row = RowHandler().create_row(
+ user=user,
+ table=table,
+ values={
+ f"field_{visible_field.id}": "Visible",
+ },
+ )
+
+ assert mock_broadcast_to_channel_group.delay.mock_calls == (
+ [
+ call(f"table-{table.id}", ANY, ANY, None),
+ call(
+ f"restricted-view-{restricted_view.id}",
+ {
+ "type": "rows_created",
+ "table_id": table.id,
+ "rows": [
+ {
+ "id": row.id,
+ "order": "1.00000000000000000000",
+ f"field_{visible_field.id}": "Visible",
+ }
+ ],
+ "metadata": {},
+ "before_row_id": None,
+ },
+ None,
+ None,
+ ),
+ call(
+ f"view-{public_and_restricted_view.slug}",
+ {
+ "type": "rows_created",
+ "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
+ "rows": [
+ {
+ "id": row.id,
+ "order": "1.00000000000000000000",
+ f"field_{visible_field.id}": "Visible",
+ }
+ ],
+ "metadata": {},
+ "before_row_id": None,
+ },
+ None,
+ None,
+ ),
+ call(
+ f"restricted-view-{public_and_restricted_view.id}",
+ {
+ "type": "rows_created",
+ "table_id": table.id,
+ "rows": [
+ {
+ "id": row.id,
+ "order": "1.00000000000000000000",
+ f"field_{visible_field.id}": "Visible",
+ }
+ ],
+ "metadata": {},
+ "before_row_id": None,
+ },
+ None,
+ None,
+ ),
+ ]
+ )
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_filters_are_visible_for_builders_and_up(enterprise_data_fixture, api_client):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ table = enterprise_data_fixture.create_database_table(user=user)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+ enterprise_data_fixture.create_view_filter(
+ view=restricted_view, type="equal", field=text_field
+ )
+ enterprise_data_fixture.create_view_filter_group(view=restricted_view)
+
+ response = api_client.get(
+ reverse("api:database:views:list", kwargs={"table_id": table.id})
+ + "?include=filters",
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token}",
+ )
+ assert response.status_code == HTTP_200_OK
+ response_json = response.json()
+ assert len(response_json)
+ assert len(response_json[0]["filters"]) == 1
+ assert len(response_json[0]["filter_groups"]) == 1
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_filters_are_invisible_for_editors_and_down(
+ enterprise_data_fixture, api_client
+):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+ restricted_view = enterprise_data_fixture.create_grid_view(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+ enterprise_data_fixture.create_view_filter(
+ view=restricted_view, type="equal", field=text_field
+ )
+ enterprise_data_fixture.create_view_filter_group(view=restricted_view)
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ workspace = table.database.workspace
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=restricted_view.id),
+ )
+
+ response = api_client.get(
+ reverse("api:database:views:list", kwargs={"table_id": table.id})
+ + "?include=filters",
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ assert response.status_code == HTTP_200_OK
+ response_json = response.json()
+ assert len(response_json)
+ assert len(response_json[0]["filters"]) == 0
+ assert len(response_json[0]["filter_groups"]) == 0
+
+
+view_type_url_mapping = {
+ GridViewType.type: ("api:database:views:grid:list", "create_grid_view", "results"),
+ GalleryViewType.type: (
+ "api:database:views:gallery:list",
+ "create_gallery_view",
+ "results",
+ ),
+ KanbanViewType.type: (
+ "api:database:views:kanban:list",
+ "create_kanban_view",
+ "rows.null.results",
+ ),
+ CalendarViewType.type: (
+ "api:database:views:calendar:list",
+ "create_calendar_view",
+ "rows.2021-01-01.results",
+ ),
+ TimelineViewType.type: (
+ "api:database:views:timeline:list",
+ "create_timeline_view",
+ "results",
+ ),
+}
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_filters_are_not_forcefully_applied_to_all_views_types_for_builders_and_up(
+ enterprise_data_fixture, premium_data_fixture, api_client
+):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ table = enterprise_data_fixture.create_database_table(user=user)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+
+ RowHandler().create_row(user, table, values={f"field_{text_field.id}": "a"})
+ RowHandler().create_row(user, table, values={f"field_{text_field.id}": "b"})
+
+ for view_type in view_type_registry.get_all():
+ if not view_type.can_filter:
+ continue
+
+ if view_type.type not in view_type_url_mapping:
+ assert False, f"{view_type.type} must be added to `view_type_url_mapping`"
+
+ view_path, fixture_create, response_path = view_type_url_mapping[view_type.type]
+
+ view = getattr(premium_data_fixture, fixture_create)(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+ enterprise_data_fixture.create_view_filter(
+ view=view, type="equal", value="a", field=text_field
+ )
+
+ for field in table.field_set.all():
+ if field.specific_class == DateField:
+ table.get_model().objects.all().update(
+ **{f"field_{field.id}": datetime(2021, 1, 1)}
+ )
+
+ # Adding a filter to the query params should enable the adhoc filtering,
+ # if the user is builder or higher, which results in not applying the
+ # original view filters. We therefore expect both row_1 and row_2 in the
+ # response.
+ query_param = (
+ '?filters={"filter_type":"AND","filters":['
+ '{"type":"not_equal","field":' + str(text_field.id) + ',"value":"c"}'
+ '],"groups":[]}'
+ "&from_timestamp=2021-01-01"
+ "&to_timestamp=2021-02-01"
+ )
+ response = api_client.get(
+ reverse(view_path, kwargs={"view_id": view.id}) + query_param,
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token}",
+ )
+ response_json = response.json()
+ assert response.status_code == HTTP_200_OK
+ # We expect both row_1 and row_2 when applying the query params.
+ assert len(get_value_at_path(response_json, response_path)) == 2, view_type.type
+
+
+@pytest.mark.django_db
+@override_settings(DEBUG=True)
+def test_filters_are_forcefully_applied_to_all_views_types_for_editors_and_down(
+ enterprise_data_fixture,
+ premium_data_fixture,
+ api_client,
+):
+ enterprise_data_fixture.enable_enterprise()
+
+ user, token = enterprise_data_fixture.create_user_and_token()
+ user2, token2 = enterprise_data_fixture.create_user_and_token()
+ workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2])
+ database = enterprise_data_fixture.create_database_application(workspace=workspace)
+ table = enterprise_data_fixture.create_database_table(database=database)
+ text_field = enterprise_data_fixture.create_text_field(table=table, primary=True)
+
+ editor_role = Role.objects.get(uid="EDITOR")
+ no_access_role = Role.objects.get(uid="NO_ACCESS")
+ workspace = table.database.workspace
+ RoleAssignmentHandler().assign_role(
+ user2, workspace, role=no_access_role, scope=workspace
+ )
+
+ RowHandler().create_row(user, table, values={f"field_{text_field.id}": "a"})
+ RowHandler().create_row(user, table, values={f"field_{text_field.id}": "b"})
+
+ for view_type in view_type_registry.get_all():
+ if not view_type.can_filter:
+ continue
+
+ if view_type.type not in view_type_url_mapping:
+ assert False, f"{view_type.type} must be added to `view_type_url_mapping`"
+
+ view_path, fixture_create, response_path = view_type_url_mapping[view_type.type]
+
+ view = getattr(premium_data_fixture, fixture_create)(
+ table=table, ownership_type=RestrictedViewOwnershipType.type
+ )
+ enterprise_data_fixture.create_view_filter(
+ view=view, type="equal", value="a", field=text_field
+ )
+
+ RoleAssignmentHandler().assign_role(
+ user2,
+ workspace,
+ role=editor_role,
+ scope=View.objects.get(id=view.id),
+ )
+
+ for field in table.field_set.all():
+ if field.specific_class == DateField:
+ table.get_model().objects.all().update(
+ **{f"field_{field.id}": datetime(2021, 1, 1)}
+ )
+
+ # Adding a filter to the query params should not enable the adhoc filtering,
+ # if the user is editor or lower, so the view filters are forcefully applied.
+ # We therefore expect only row_1 in the response.
+ query_param = (
+ '?filters={"filter_type":"AND","filters":['
+ '{"type":"not_equal","field":' + str(text_field.id) + ',"value":"c"}'
+ '],"groups":[]}'
+ "&from_timestamp=2021-01-01"
+ "&to_timestamp=2021-02-01"
+ )
+ response = api_client.get(
+ reverse(view_path, kwargs={"view_id": view.id}) + query_param,
+ format="json",
+ HTTP_AUTHORIZATION=f"JWT {token2}",
+ )
+ response_json = response.json()
+ assert response.status_code == HTTP_200_OK
+ # We expect only row_1 to be in there because user2 only has editor permissions
+ # to the view and should therefore not be able to see row 2 because it does not
+ # match the filters of the view.
+ assert len(get_value_at_path(response_json, response_path)) == 1, view_type.type
diff --git a/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElementForm.vue b/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElementForm.vue
index a434769b4e..d874cc7dc3 100644
--- a/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElementForm.vue
+++ b/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElementForm.vue
@@ -1,6 +1,6 @@
| | | |