diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f43b7453c2..b9b1d47b0e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "websockets==15.0.1", "requests==2.33.0", "itsdangerous==2.2.0", - "Pillow==12.1.1", + "Pillow==12.2.0", "drf-spectacular==0.29.0", "asgiref==3.11.0", "channels[daphne]==4.3.2", diff --git a/backend/src/baserow/contrib/database/api/views/errors.py b/backend/src/baserow/contrib/database/api/views/errors.py index d5f729a17e..99d4a14b63 100644 --- a/backend/src/baserow/contrib/database/api/views/errors.py +++ b/backend/src/baserow/contrib/database/api/views/errors.py @@ -134,3 +134,9 @@ HTTP_400_BAD_REQUEST, "This view type does not support setting default row values.", ) +ERROR_VIEW_OWNERSHIP_TYPE_INCOMPATIBLE_WITH_VIEW_TYPE = ( + "ERROR_VIEW_OWNERSHIP_TYPE_INCOMPATIBLE_WITH_VIEW_TYPE", + HTTP_400_BAD_REQUEST, + "The ownership type {e.ownership_type} is not compatible with " + "view type {e.view_type}.", +) diff --git a/backend/src/baserow/contrib/database/api/views/views.py b/backend/src/baserow/contrib/database/api/views/views.py index 42841dfa91..a8e2d2e7f9 100644 --- a/backend/src/baserow/contrib/database/api/views/views.py +++ b/backend/src/baserow/contrib/database/api/views/views.py @@ -116,6 +116,7 @@ ViewGroupByNotSupported, ViewNotInTable, ViewOwnershipTypeDoesNotExist, + ViewOwnershipTypeNotCompatibleWithViewType, ViewSortDoesNotExist, ViewSortFieldAlreadyExist, ViewSortFieldNotSupported, @@ -164,6 +165,7 @@ ERROR_VIEW_GROUP_BY_NOT_SUPPORTED, ERROR_VIEW_NOT_IN_TABLE, ERROR_VIEW_OWNERSHIP_TYPE_DOES_NOT_EXIST, + ERROR_VIEW_OWNERSHIP_TYPE_INCOMPATIBLE_WITH_VIEW_TYPE, ERROR_VIEW_SORT_DOES_NOT_EXIST, ERROR_VIEW_SORT_FIELD_ALREADY_EXISTS, ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED, @@ -388,9 +390,15 @@ def get( "ERROR_USER_NOT_IN_GROUP", "ERROR_REQUEST_BODY_VALIDATION", "ERROR_FIELD_NOT_IN_TABLE", + "ERROR_VIEW_OWNERSHIP_TYPE_INCOMPATIBLE_WITH_VIEW_TYPE", + ] + ), + 404: get_error_schema( + [ + "ERROR_TABLE_DOES_NOT_EXIST", + "ERROR_VIEW_OWNERSHIP_TYPE_DOES_NOT_EXIST", ] ), - 404: get_error_schema(["ERROR_TABLE_DOES_NOT_EXIST"]), }, ) @transaction.atomic @@ -405,6 +413,7 @@ def get( TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST, UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP, ViewOwnershipTypeDoesNotExist: ERROR_VIEW_OWNERSHIP_TYPE_DOES_NOT_EXIST, + ViewOwnershipTypeNotCompatibleWithViewType: ERROR_VIEW_OWNERSHIP_TYPE_INCOMPATIBLE_WITH_VIEW_TYPE, } ) @allowed_includes("filters", "sortings", "decorations", "group_bys") diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index 96af0d5829..942f403fda 100755 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -840,6 +840,7 @@ def ready(self): CreateViewFilterOperationType, CreateViewGroupByOperationType, CreateViewOperationType, + CreateViewRowCommentOperationType, CreateViewRowOperationType, CreateViewSortOperationType, DeleteViewDecorationOperationType, @@ -847,6 +848,7 @@ def ready(self): DeleteViewFilterOperationType, DeleteViewGroupByOperationType, DeleteViewOperationType, + DeleteViewRowCommentOperationType, DeleteViewRowOperationType, DeleteViewSortOperationType, DuplicateViewOperationType, @@ -867,16 +869,19 @@ def ready(self): ReadViewFilterOperationType, ReadViewGroupByOperationType, ReadViewOperationType, + ReadViewRowCommentsOperationType, ReadViewRowOperationType, ReadViewsOrderOperationType, ReadViewSortOperationType, RestoreViewOperationType, + RestoreViewRowCommentOperationType, UpdateViewDecorationOperationType, UpdateViewFilterGroupOperationType, UpdateViewFilterOperationType, UpdateViewGroupByOperationType, UpdateViewOperationType, UpdateViewPublicOperationType, + UpdateViewRowCommentOperationType, UpdateViewRowOperationType, UpdateViewSlugOperationType, UpdateViewSortOperationType, @@ -897,6 +902,11 @@ def ready(self): operation_type_registry.register(CreateViewRowOperationType()) operation_type_registry.register(UpdateViewRowOperationType()) operation_type_registry.register(DeleteViewRowOperationType()) + operation_type_registry.register(ReadViewRowCommentsOperationType()) + operation_type_registry.register(CreateViewRowCommentOperationType()) + operation_type_registry.register(UpdateViewRowCommentOperationType()) + operation_type_registry.register(DeleteViewRowCommentOperationType()) + operation_type_registry.register(RestoreViewRowCommentOperationType()) operation_type_registry.register(CreateTableDatabaseTableOperationType()) operation_type_registry.register(ListTablesDatabaseTableOperationType()) operation_type_registry.register(OrderTablesDatabaseTableOperationType()) diff --git a/backend/src/baserow/contrib/database/fields/constants.py b/backend/src/baserow/contrib/database/fields/constants.py index d3a008f59b..6a0e8cce01 100644 --- a/backend/src/baserow/contrib/database/fields/constants.py +++ b/backend/src/baserow/contrib/database/fields/constants.py @@ -32,6 +32,13 @@ ] SINGLE_SELECT_SORT_BY_ORDER = "order" +# Maximum number of characters per text column to include in sort expressions used +# for view index creation and ORDER BY clauses. This prevents PostgreSQL btree index +# row size errors (max ~8191 bytes) when sorting on text/long_text fields with large +# values. Values differing only after this prefix will fall through to the "order" +# and "id" tiebreakers. +SORT_INDEX_TEXT_MAX_CHARS = 200 + UNIQUE_WITH_EMPTY_CONSTRAINT_NAME = "unique_with_empty" diff --git a/backend/src/baserow/contrib/database/fields/field_types.py b/backend/src/baserow/contrib/database/fields/field_types.py index 285f478444..d28d313ad6 100755 --- a/backend/src/baserow/contrib/database/fields/field_types.py +++ b/backend/src/baserow/contrib/database/fields/field_types.py @@ -49,7 +49,7 @@ ) from django.db.models.fields import NOT_PROVIDED from django.db.models.fields.related import ManyToManyField -from django.db.models.functions import Cast, Coalesce, RowNumber +from django.db.models.functions import Cast, Coalesce, Left, RowNumber from dateutil import parser from dateutil.parser import ParserError @@ -279,7 +279,9 @@ class CollationSortMixin: def get_order( self, field, field_name, order_direction, sort_type, table_model=None ) -> OptionallyAnnotatedOrderBy: - field_expr = collate_expression(F(field_name)) + from baserow.contrib.database.fields.constants import SORT_INDEX_TEXT_MAX_CHARS + + field_expr = collate_expression(Left(F(field_name), SORT_INDEX_TEXT_MAX_CHARS)) if order_direction == "ASC": field_order_by = field_expr.asc(nulls_first=True) diff --git a/backend/src/baserow/contrib/database/formula/types/formula_types.py b/backend/src/baserow/contrib/database/formula/types/formula_types.py index 7f8bd65a29..a9568f3be3 100644 --- a/backend/src/baserow/contrib/database/formula/types/formula_types.py +++ b/backend/src/baserow/contrib/database/formula/types/formula_types.py @@ -8,7 +8,7 @@ from django.db import models from django.db.models import Expression, F, Func, Q, QuerySet, TextField, Value from django.db.models import Field as DjangoField -from django.db.models.functions import Cast, Concat +from django.db.models.functions import Cast, Concat, Left from dateutil import parser from rest_framework import serializers @@ -144,7 +144,9 @@ def placeholder_empty_baserow_expression( return literal("") def _get_order_field_expression(self, field_name: str) -> Expression | F: - return collate_expression(F(field_name)) + from baserow.contrib.database.fields.constants import SORT_INDEX_TEXT_MAX_CHARS + + return collate_expression(Left(F(field_name), SORT_INDEX_TEXT_MAX_CHARS)) class BaserowFormulaTextType( diff --git a/backend/src/baserow/contrib/database/rows/handler.py b/backend/src/baserow/contrib/database/rows/handler.py index e09f6b8643..d391a53d33 100644 --- a/backend/src/baserow/contrib/database/rows/handler.py +++ b/backend/src/baserow/contrib/database/rows/handler.py @@ -79,7 +79,7 @@ PermissionDenied, ) from baserow.core.handler import CoreHandler -from baserow.core.psycopg import is_unique_violation_error, sql +from baserow.core.psycopg import is_index_row_size_error, is_unique_violation_error, sql from baserow.core.registries import OperationType from baserow.core.telemetry.utils import baserow_trace_methods from baserow.core.trash.handler import TrashHandler @@ -667,7 +667,7 @@ def get_row_names( return {row.id: str(row) for row in queryset} # noinspection PyMethodMayBeStatic - def has_row(self, user, table, row_id, raise_error=False, model=None): + def has_row(self, user, table, row_id, raise_error=False, model=None, view=None): """ Checks if a row with the given id exists and is not trashed in the table. @@ -676,30 +676,28 @@ def has_row(self, user, table, row_id, raise_error=False, model=None): do a much more efficient query to check only if the row exists or not. :param user: The user of whose behalf the row is being checked. - :type user: User :param table: The table where the row must be checked in. - :type table: Table :param row_id: The id of the row that must be checked. - :type row_id: int :param raise_error: Whether or not to raise an Exception if the row does not exist or just return a boolean instead. - :type raise_error: bool :param model: If the correct model has already been generated it can be provided so that it does not have to be generated for a second time. - :type model: Model + :param view: Optionally provide view, if the row is checked in the view. + This can result in different permissions checks. :raises RowDoesNotExist: When the row with the provided id does not exist and raise_error is set to True. :raises UserNotInWorkspace: If the user does not belong to the workspace. :return: If raise_error is False then a boolean indicating if the row does or does not exist. - :rtype: bool """ - CoreHandler().check_permissions( - user, + self._check_permissions_with_view_fallback( ReadDatabaseRowOperationType.type, - workspace=table.database.workspace, - context=table, + ReadViewRowOperationType.type, + user, + table, + view, + [row_id], ) if model is None: @@ -911,12 +909,27 @@ def force_create_row( instance = model(**row_values) field_rules_handler.validate_row(instance) + def safe_save_instance(): + try: + with transaction.atomic(): + instance.save(force_insert=True) + rows_created_counter.add(1) + except Exception as exc: + if is_unique_violation_error(exc): + raise FieldDataConstraintException() + else: + raise exc + try: - instance.save(force_insert=True) - rows_created_counter.add(1) + safe_save_instance() except Exception as exc: - if is_unique_violation_error(exc): - raise FieldDataConstraintException() + if is_index_row_size_error(exc): + from baserow.contrib.database.views.handler import ( + ViewIndexingHandler, + ) + + ViewIndexingHandler.handle_index_row_size_error(model.baserow_table_id) + safe_save_instance() else: raise exc @@ -1156,11 +1169,26 @@ def update_row( setattr(row, LAST_MODIFIED_BY_COLUMN_NAME, user if user.id else None) always_updated_fields.append(LAST_MODIFIED_BY_COLUMN_NAME) + def safe_save_row(): + try: + with transaction.atomic(): + row.save(update_fields=update_row_fields + always_updated_fields) + except Exception as exc: + if is_unique_violation_error(exc): + raise FieldDataConstraintException() + else: + raise exc + try: - row.save(update_fields=update_row_fields + always_updated_fields) + safe_save_row() except Exception as exc: - if is_unique_violation_error(exc): - raise FieldDataConstraintException() + if is_index_row_size_error(exc): + from baserow.contrib.database.views.handler import ( + ViewIndexingHandler, + ) + + ViewIndexingHandler.handle_index_row_size_error(model.baserow_table_id) + safe_save_row() else: raise exc rows_updated_counter.add(1) @@ -1365,21 +1393,35 @@ def force_create_rows( rows = [row for (row, _) in rows_relationships] + def safe_bulk_create(): + try: + with transaction.atomic(): + return model.objects.bulk_create(rows) + except Exception as exc: + if is_unique_violation_error(exc): + if not generate_error_report: + raise FieldDataConstraintException() + + for index, (row, _) in enumerate(rows_relationships): + report[index] = { + "non_field_errors": [ + "Row was not inserted due to conflicts or constraints" + ] + } + return [] + else: + raise exc + try: - with transaction.atomic(): - inserted_rows = model.objects.bulk_create(rows) + inserted_rows = safe_bulk_create() except Exception as exc: - inserted_rows = [] - if is_unique_violation_error(exc): - if not generate_error_report: - raise FieldDataConstraintException() + if is_index_row_size_error(exc): + from baserow.contrib.database.views.handler import ( + ViewIndexingHandler, + ) - for index, (row, _) in enumerate(rows_relationships): - report[index] = { - "non_field_errors": [ - "Row was not inserted due to conflicts or constraints" - ] - } + ViewIndexingHandler.handle_index_row_size_error(model.baserow_table_id) + inserted_rows = safe_bulk_create() else: raise exc @@ -2432,28 +2474,46 @@ def force_update_rows( bulk_update_fields.append(field_name) if len(bulk_update_fields) > 0: + + def safe_bulk_update(): + try: + with transaction.atomic(): + model.objects.bulk_update( + rows_to_update, bulk_update_fields, batch_size=2000 + ) + except Exception as exc: + if is_unique_violation_error(exc): + if generate_error_report: + for idx, row in enumerate(rows_to_update): + report[idx] = { + "non_field_errors": [ + "Row was not updated due to conflicts or constraints" + ] + } + return UpdatedRowsData( + [], + [], + original_row_values_by_id, + fields_metadata_by_row_id, + report, + [], + ) + raise FieldDataConstraintException() + else: + raise exc + try: - model.objects.bulk_update( - rows_to_update, bulk_update_fields, batch_size=2000 - ) + safe_bulk_update() except Exception as exc: - if is_unique_violation_error(exc): - if generate_error_report: - for idx, row in enumerate(rows_to_update): - report[idx] = { - "non_field_errors": [ - "Row was not updated due to conflicts or constraints" - ] - } - return UpdatedRowsData( - [], - [], - original_row_values_by_id, - fields_metadata_by_row_id, - report, - [], - ) - raise FieldDataConstraintException() + if is_index_row_size_error(exc): + from baserow.contrib.database.views.handler import ( + ViewIndexingHandler, + ) + + ViewIndexingHandler.handle_index_row_size_error( + model.baserow_table_id + ) + safe_bulk_update() else: raise exc diff --git a/backend/src/baserow/contrib/database/views/exceptions.py b/backend/src/baserow/contrib/database/views/exceptions.py index 8b064ebde3..cbb83574d5 100644 --- a/backend/src/baserow/contrib/database/views/exceptions.py +++ b/backend/src/baserow/contrib/database/views/exceptions.py @@ -222,6 +222,20 @@ class ViewDoesNotSupportDefaultValues(Exception): """Raised when trying to set default values on a view that doesn't support it.""" +class ViewOwnershipTypeNotCompatibleWithViewType(Exception): + """Raised when the ownership type is not compatible with the view type.""" + + def __init__(self, ownership_type="", view_type="", *args, **kwargs): + self.ownership_type = ownership_type + self.view_type = view_type + super().__init__( + f"The ownership type {ownership_type} is not compatible with " + f"view type {view_type}.", + *args, + **kwargs, + ) + + class InvalidAPIGroupedFiltersFormatException(ValueError): """ Raised when the provided view filters format is invalid. diff --git a/backend/src/baserow/contrib/database/views/handler.py b/backend/src/baserow/contrib/database/views/handler.py index 3427851cbe..ca57089e5d 100644 --- a/backend/src/baserow/contrib/database/views/handler.py +++ b/backend/src/baserow/contrib/database/views/handler.py @@ -12,7 +12,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.exceptions import FieldDoesNotExist, ValidationError -from django.db import connection +from django.db import OperationalError, connection from django.db import models as django_models from django.db.models import Count, Q, prefetch_related_objects from django.db.models.expressions import OrderBy @@ -43,6 +43,7 @@ from baserow.contrib.database.table.models import GeneratedTableModel, Table from baserow.contrib.database.views.exceptions import ( ViewOwnershipTypeDoesNotExist, + ViewOwnershipTypeNotCompatibleWithViewType, ) from baserow.contrib.database.views.filters import AdHocFilters from baserow.contrib.database.views.operations import ( @@ -431,6 +432,56 @@ def schedule_index_update(cls, view: View): schedule_view_index_update(view.pk) + @classmethod + def drop_all_indexes_for_table(cls, table_id: int): + """ + Drop every view-level btree index that belongs to *table_id* and + clear the persisted ``db_index_name`` so the indexes can later be + recreated with the correct (truncated) expressions. + + :param table_id: PK of the database table whose view indexes should + be dropped. + """ + + views_with_index = list( + View.objects.filter( + table_id=table_id, + db_index_name__isnull=False, + ) + ) + + index_names = set(view.db_index_name for view in views_with_index) + + if index_names: + drop_index_sql = sql.SQL("DROP INDEX IF EXISTS {}").format( + sql.SQL(", ").join( + sql.Identifier(index_name) for index_name in index_names + ) + ) + + with connection.cursor() as cursor: + cursor.execute(drop_index_sql) + + if views_with_index: + View.objects.filter(id__in=[v.id for v in views_with_index]).update( + db_index_name=None + ) + + @classmethod + def handle_index_row_size_error(cls, table_id: int): + """ + Called when a row INSERT/UPDATE fails because a legacy view index + (created before the ``Left()`` truncation was applied) exceeds the + btree maximum row size. + + The method drops every view index for the affected table so the + write can be retried. + + :param table_id: PK of the database table that triggered the error. + """ + + cls.drop_all_indexes_for_table(table_id) + @classmethod def create_index_if_not_exists( cls, @@ -455,14 +506,27 @@ def create_index_if_not_exists( if other_view_using_index.exists() or cls.does_index_exist(db_index.name): return db_index.name - with safe_django_schema_editor() as schema_editor: - schema_editor.add_index(model, db_index) - logger.info( - "Created Index {db_index_name} for view {view_pk} of table {view_table_id}", - db_index_name=db_index.name, - view_pk=view.pk, - view_table_id=view.table_id, - ) + try: + with safe_django_schema_editor() as schema_editor: + schema_editor.add_index(model, db_index) + logger.info( + "Created Index {db_index_name} for view {view_pk} of table {view_table_id}", + db_index_name=db_index.name, + view_pk=view.pk, + view_table_id=view.table_id, + ) + except OperationalError as exc: + msg = str(exc) + if "index" in msg and "size" in msg: + logger.warning( + "Failed to create index {db_index_name} for view {view_pk} of " + "table {view_table_id}: {exc}", + db_index_name=db_index.name, + view_pk=view.pk, + view_table_id=view.table_id, + exc=msg, + ) + return None return db_index.name @@ -576,7 +640,7 @@ def update_index(cls, view: View, model: Optional[GeneratedTableModel] = None): # remove the previous and create the new index cls.drop_index_if_unused(view, model) if db_index is not None: - cls.create_index_if_not_exists(view, model, db_index) + new_index_name = cls.create_index_if_not_exists(view, model, db_index) view.db_index_name = new_index_name view.save(update_fields=["db_index_name"]) @@ -884,6 +948,12 @@ def create_view( workspace = table.database.workspace + if not view_ownership_type.is_compatible_with_view_type(view_type): + raise ViewOwnershipTypeNotCompatibleWithViewType( + ownership_type=view_ownership_type_str, + view_type=type_name, + ) + CoreHandler().check_permissions( user, view_ownership_type.get_operation_to_check_to_create_view().type, diff --git a/backend/src/baserow/contrib/database/views/operations.py b/backend/src/baserow/contrib/database/views/operations.py index d1131cc9c3..0adf3a0024 100644 --- a/backend/src/baserow/contrib/database/views/operations.py +++ b/backend/src/baserow/contrib/database/views/operations.py @@ -60,6 +60,26 @@ class DeleteViewRowOperationType(ViewRowOperationType): type = "database.table.view.delete_row" +class ReadViewRowCommentsOperationType(ViewRowOperationType): + type = "database.table.view.list_comments" + + +class CreateViewRowCommentOperationType(ViewRowOperationType): + type = "database.table.view.create_comment" + + +class UpdateViewRowCommentOperationType(ViewRowOperationType): + type = "database.table.view.update_comment" + + +class DeleteViewRowCommentOperationType(ViewRowOperationType): + type = "database.table.view.delete_comment" + + +class RestoreViewRowCommentOperationType(ViewRowOperationType): + type = "database.table.view.restore_comment" + + class CreateViewSortOperationType(ViewOperationType): type = "database.table.view.create_sort" diff --git a/backend/src/baserow/contrib/database/views/registries.py b/backend/src/baserow/contrib/database/views/registries.py index 122f2ef444..0af49dcfdc 100644 --- a/backend/src/baserow/contrib/database/views/registries.py +++ b/backend/src/baserow/contrib/database/views/registries.py @@ -1492,6 +1492,17 @@ def should_broadcast_signal_to( return "table", None + def is_compatible_with_view_type(self, view_type: ViewType) -> bool: + """ + Returns whether this ownership type is compatible with the given view type. + Subclasses can override this to prevent certain combinations. + + :param view_type: The view type instance. + :return: True if compatible, False otherwise. + """ + + return True + def before_form_view_submitted(self, form, request): """ Called before a form view of this ownership type is submitted. Can be used @@ -1624,6 +1635,31 @@ def get_hidden_field_ids_for_user( return set() + def get_users_to_notify_for_row_comment( + self, + table: "Table", + row_id: int, + users: List["AbstractUser"], + ) -> List["AbstractUser"]: + """ + Returns users from `users` who should receive a notification about a new + comment on the given row. This is used for users who do not have table-level + comment-read permission but might have view-level access via this ownership + type. + + The base implementation returns an empty list. Ownership types that grant + view-level access (e.g. restricted views) should override this to check whether + the row is visible in any of their views and whether the users have the + appropriate view-level permission. + + :param table: The table the comment belongs to. + :param row_id: The id of the commented row. + :param users: Users to check for view-level access. + :return: The subset of `users` that should also be notified. + """ + + return [] + def enhance_list_fields_queryset( self, user: "AbstractUser", view: "View", queryset: django_models.QuerySet ) -> django_models.QuerySet: diff --git a/backend/src/baserow/core/psycopg.py b/backend/src/baserow/core/psycopg.py index 4bfedc1cf3..528878a860 100644 --- a/backend/src/baserow/core/psycopg.py +++ b/backend/src/baserow/core/psycopg.py @@ -33,3 +33,21 @@ def is_unique_violation_error(exc: Exception) -> bool: return isinstance(exc, IntegrityError) and isinstance( exc.__cause__, errors.UniqueViolation ) + + +def is_index_row_size_error(exc: Exception) -> bool: + """ + Return True when an error is raised because a btree index + row exceeds the maximum size. + + This happens for example when existing (legacy) indexes were + created before the ``Left()`` truncation was applied to text columns. + """ + + if not ( + isinstance(exc, OperationalError) + and isinstance(exc.__cause__, errors.ProgramLimitExceeded) + ): + return False + msg = str(exc) + return "index" in msg and "size" in msg diff --git a/backend/tests/baserow/contrib/database/api/rows/test_batch_rows_views.py b/backend/tests/baserow/contrib/database/api/rows/test_batch_rows_views.py index 8aa1e7bcd5..2b48debd4f 100644 --- a/backend/tests/baserow/contrib/database/api/rows/test_batch_rows_views.py +++ b/backend/tests/baserow/contrib/database/api/rows/test_batch_rows_views.py @@ -1,3 +1,5 @@ +import base64 +import os from decimal import Decimal from unittest.mock import patch @@ -22,6 +24,8 @@ from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.fields.models import SelectOption from baserow.contrib.database.tokens.handler import TokenHandler +from baserow.contrib.database.views.handler import ViewHandler +from baserow.contrib.database.views.models import OWNERSHIP_TYPE_COLLABORATIVE from baserow.test_utils.helpers import AnyStr, is_dict_subset from tests.baserow.contrib.database.utils import get_deadlock_error @@ -2810,3 +2814,175 @@ def test_batch_create_rows_single_select_default(api_client, data_fixture): row_2 = model.objects.get(pk=2) assert getattr(row_1, f"field_{option_field.id}").id == option_2.id assert getattr(row_2, f"field_{option_field.id}").id == option_1.id + + +@pytest.mark.django_db(transaction=True) +@pytest.mark.api_rows +def test_batch_create_rows_succeeds_when_legacy_view_index_exceeds_max_size( + api_client, data_fixture +): + """ + When a view has legacy indexes and a batch row insert would exceed the btree + maximum entry size, the indexes should be dropped, the insert should succeed, + and the index recreation should be scheduled. + """ + + user, jwt_token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + long_text_field = data_fixture.create_long_text_field(user=user, table=table) + + view_handler = ViewHandler() + grid_view = view_handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + grid_view_2 = view_handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + + table_model = table.get_model() + view_handler.create_sort( + user=user, view=grid_view, field=long_text_field, order="ASC" + ) + view_handler.create_sort( + user=user, view=grid_view_2, field=long_text_field, order="ASC" + ) + + table_name = table_model._meta.db_table + field_column = f"field_{long_text_field.id}" + legacy_index_name = f"legacy_idx_batch_create_{table.id}" + legacy_index_name_2 = f"legacy_idx_batch_create_{table.id}_2" + + # Create "legacy" indexes + with connection.cursor() as cursor: + cursor.execute( + f'CREATE INDEX "{legacy_index_name}" ON "{table_name}" ' + f'("{field_column}", "order", "id") WHERE NOT "trashed"' + ) + cursor.execute( + f'CREATE INDEX "{legacy_index_name_2}" ON "{table_name}" ' + f'("{field_column}", "order", "id") WHERE NOT "trashed"' + ) + + grid_view.db_index_name = legacy_index_name + grid_view.save(update_fields=["db_index_name"]) + + grid_view_2.db_index_name = legacy_index_name_2 + grid_view_2.save(update_fields=["db_index_name"]) + + large_text = base64.b64encode(os.urandom(8000)).decode("ascii") + + url = reverse("api:database:rows:batch", kwargs={"table_id": table.id}) + + response = api_client.post( + url, + {"items": [{f"field_{long_text_field.id}": large_text}]}, + format="json", + HTTP_AUTHORIZATION=f"JWT {jwt_token}", + ) + + assert response.status_code == HTTP_200_OK + assert response.json()["items"][0][f"field_{long_text_field.id}"] == large_text + assert table_model.objects.count() == 1 + + # The legacy indexes should have been dropped + grid_view.refresh_from_db() + assert grid_view.db_index_name is None + + grid_view_2.refresh_from_db() + assert grid_view_2.db_index_name is None + + +@pytest.mark.django_db(transaction=True) +@pytest.mark.api_rows +def test_batch_update_rows_succeeds_when_legacy_view_index_exceeds_max_size( + api_client, data_fixture +): + """ + When a view has legacy indexes and a batch row update would exceed + the btree maximum entry size, the index should be dropped, the update should + succeed, and the index recreation should be scheduled. + """ + + user, jwt_token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + long_text_field = data_fixture.create_long_text_field(user=user, table=table) + + view_handler = ViewHandler() + grid_view = view_handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + grid_view_2 = view_handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + + table_model = table.get_model() + view_handler.create_sort( + user=user, view=grid_view, field=long_text_field, order="ASC" + ) + view_handler.create_sort( + user=user, view=grid_view_2, field=long_text_field, order="ASC" + ) + + # Create a row with a small value first. + row = table_model.objects.create(**{f"field_{long_text_field.id}": "small"}) + + table_name = table_model._meta.db_table + field_column = f"field_{long_text_field.id}" + legacy_index_name = f"legacy_idx_batch_update_{table.id}" + legacy_index_name_2 = f"legacy_idx_batch_update_{table.id}_2" + + # Create "legacy" indexes + with connection.cursor() as cursor: + cursor.execute( + f'CREATE INDEX "{legacy_index_name}" ON "{table_name}" ' + f'("{field_column}", "order", "id") WHERE NOT "trashed"' + ) + cursor.execute( + f'CREATE INDEX "{legacy_index_name_2}" ON "{table_name}" ' + f'("{field_column}", "order", "id") WHERE NOT "trashed"' + ) + + grid_view.db_index_name = legacy_index_name + grid_view.save(update_fields=["db_index_name"]) + + grid_view_2.db_index_name = legacy_index_name_2 + grid_view_2.save(update_fields=["db_index_name"]) + + large_text = base64.b64encode(os.urandom(8000)).decode("ascii") + + url = reverse("api:database:rows:batch", kwargs={"table_id": table.id}) + + response = api_client.patch( + url, + {"items": [{"id": row.id, f"field_{long_text_field.id}": large_text}]}, + format="json", + HTTP_AUTHORIZATION=f"JWT {jwt_token}", + ) + + assert response.status_code == HTTP_200_OK + assert response.json()["items"][0][f"field_{long_text_field.id}"] == large_text + row.refresh_from_db() + assert getattr(row, f"field_{long_text_field.id}") == large_text + + # The legacy indexes should have been dropped + grid_view.refresh_from_db() + assert grid_view.db_index_name is None + + grid_view_2.refresh_from_db() + assert grid_view_2.db_index_name is None diff --git a/backend/tests/baserow/contrib/database/api/rows/test_row_views.py b/backend/tests/baserow/contrib/database/api/rows/test_row_views.py index 5d8316042b..37566c3acf 100644 --- a/backend/tests/baserow/contrib/database/api/rows/test_row_views.py +++ b/backend/tests/baserow/contrib/database/api/rows/test_row_views.py @@ -1,4 +1,6 @@ +import base64 import json +import os from datetime import datetime, timedelta, timezone from decimal import Decimal from unittest.mock import patch @@ -34,6 +36,7 @@ from baserow.contrib.database.table.cache import invalidate_table_in_model_cache from baserow.contrib.database.tokens.handler import TokenHandler from baserow.contrib.database.views.handler import ViewHandler +from baserow.contrib.database.views.models import OWNERSHIP_TYPE_COLLABORATIVE from baserow.core.action.handler import ActionHandler from baserow.core.action.registries import action_type_registry from baserow.test_utils.helpers import ( @@ -5029,3 +5032,176 @@ def test_create_row_with_interesting_table_default_values(api_client, data_fixtu f"{field_name}: created row value {created_value!r} != " f"default value {sent_value!r}" ) + + +@pytest.mark.django_db(transaction=True) +def test_create_row_succeeds_when_legacy_view_index_exceeds_max_size( + api_client, data_fixture +): + """ + When a view has legacy indexes and a row insert would exceed the btree + maximum entry size, the indexes should be dropped, the insert should succeed, + and the index recreation should be scheduled. + """ + + user, jwt_token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + long_text_field = data_fixture.create_long_text_field(user=user, table=table) + + view_handler = ViewHandler() + grid_view = view_handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + grid_view_2 = view_handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid 2", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + + table_model = table.get_model() + view_handler.create_sort( + user=user, view=grid_view, field=long_text_field, order="ASC" + ) + view_handler.create_sort( + user=user, view=grid_view_2, field=long_text_field, order="ASC" + ) + + table_name = table_model._meta.db_table + field_column = f"field_{long_text_field.id}" + legacy_index_name = f"legacy_idx_create_{table.id}" + legacy_index_name_2 = f"legacy_idx_create_{table.id}_2" + + # Create "legacy" indexes + with connection.cursor() as cursor: + cursor.execute( + f'CREATE INDEX "{legacy_index_name}" ON "{table_name}" ' + f'("{field_column}", "order", "id") WHERE NOT "trashed"' + ) + cursor.execute( + f'CREATE INDEX "{legacy_index_name_2}" ON "{table_name}" ' + f'("{field_column}", "order", "id") WHERE NOT "trashed"' + ) + + grid_view.db_index_name = legacy_index_name + grid_view.save(update_fields=["db_index_name"]) + + grid_view_2.db_index_name = legacy_index_name_2 + grid_view_2.save(update_fields=["db_index_name"]) + + large_text = base64.b64encode(os.urandom(8000)).decode("ascii") + + url = reverse("api:database:rows:list", kwargs={"table_id": table.id}) + + response = api_client.post( + url, + {f"field_{long_text_field.id}": large_text}, + format="json", + HTTP_AUTHORIZATION=f"JWT {jwt_token}", + ) + + assert response.status_code == HTTP_200_OK + assert response.json()[f"field_{long_text_field.id}"] == large_text + assert table_model.objects.count() == 1 + + # The legacy indexes should have been dropped + grid_view.refresh_from_db() + assert grid_view.db_index_name is None + + grid_view_2.refresh_from_db() + assert grid_view_2.db_index_name is None + + +@pytest.mark.django_db(transaction=True) +def test_update_row_succeeds_when_legacy_view_index_exceeds_max_size( + api_client, data_fixture +): + """ + When a view has legacy indexes and a row update would exceed the btree + maximum entry size, the indexes should be dropped, the update should + succeed, and the index recreation should be scheduled. + """ + + user, jwt_token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + long_text_field = data_fixture.create_long_text_field(user=user, table=table) + + view_handler = ViewHandler() + grid_view = view_handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + grid_view_2 = view_handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + + table_model = table.get_model() + view_handler.create_sort( + user=user, view=grid_view, field=long_text_field, order="ASC" + ) + view_handler.create_sort( + user=user, view=grid_view_2, field=long_text_field, order="ASC" + ) + + # Create a row with a small value first + row = table_model.objects.create(**{f"field_{long_text_field.id}": "small"}) + + table_name = table_model._meta.db_table + field_column = f"field_{long_text_field.id}" + legacy_index_name = f"legacy_idx_update_{table.id}" + legacy_index_name_2 = f"legacy_idx_update_{table.id}_2" + + # Create "legacy" indexes + with connection.cursor() as cursor: + cursor.execute( + f'CREATE INDEX "{legacy_index_name}" ON "{table_name}" ' + f'("{field_column}", "order", "id") WHERE NOT "trashed"' + ) + cursor.execute( + f'CREATE INDEX "{legacy_index_name_2}" ON "{table_name}" ' + f'("{field_column}", "order", "id") WHERE NOT "trashed"' + ) + + grid_view.db_index_name = legacy_index_name + grid_view.save(update_fields=["db_index_name"]) + + grid_view_2.db_index_name = legacy_index_name_2 + grid_view_2.save(update_fields=["db_index_name"]) + + large_text = base64.b64encode(os.urandom(8000)).decode("ascii") + + url = reverse( + "api:database:rows:item", + kwargs={"table_id": table.id, "row_id": row.id}, + ) + + response = api_client.patch( + url, + {f"field_{long_text_field.id}": large_text}, + format="json", + HTTP_AUTHORIZATION=f"JWT {jwt_token}", + ) + + assert response.status_code == HTTP_200_OK + assert response.json()[f"field_{long_text_field.id}"] == large_text + row.refresh_from_db() + assert getattr(row, f"field_{long_text_field.id}") == large_text + + # The legacy indexes should have been dropped + grid_view.refresh_from_db() + assert grid_view.db_index_name is None + + grid_view_2.refresh_from_db() + assert grid_view_2.db_index_name is None diff --git a/backend/tests/baserow/contrib/database/view/test_view_indexing_handler.py b/backend/tests/baserow/contrib/database/view/test_view_indexing_handler.py new file mode 100644 index 0000000000..e1abc961c0 --- /dev/null +++ b/backend/tests/baserow/contrib/database/view/test_view_indexing_handler.py @@ -0,0 +1,214 @@ +from unittest.mock import patch + +from django.db import OperationalError + +import pytest + +from baserow.contrib.database.views.handler import ViewHandler, ViewIndexingHandler +from baserow.contrib.database.views.models import ( + OWNERSHIP_TYPE_COLLABORATIVE, +) + + +@pytest.mark.django_db(transaction=True) +def test_update_index_long_text_over_max_size_doesnt_fail(data_fixture): + """ + Sort expressions use Left() to truncate text columns, so the index should + be successfully created even when row values exceed PostgreSQL's btree + index maximum entry size (~2712 bytes). + """ + + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + long_text_field = data_fixture.create_long_text_field(user=user, table=table) + handler = ViewHandler() + grid_view = handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + + table_model = table.get_model() + + import base64 + import os + + large_text_value = base64.b64encode(os.urandom(8000)).decode("ascii") + + # Insert a row with a text value large enough that it would exceed + # PostgreSQL's btree index maximum entry size without Left() truncation. + table_model.objects.create(**{f"field_{long_text_field.id}": large_text_value}) + + handler.create_sort(user=user, view=grid_view, field=long_text_field, order="ASC") + + ViewIndexingHandler.update_index(grid_view, table_model) + + index = ViewIndexingHandler.get_index(grid_view, table_model) + # The index should be created successfully because Left() truncation keeps + # the indexed values within btree's size limit. + assert ViewIndexingHandler.does_index_exist(index.name) is True + + +@pytest.mark.django_db(transaction=True) +def test_update_index_multiple_fields_over_max_size_doesnt_fail( + data_fixture, +): + """ + Same as above but with two long text fields combined. Left() truncation + should keep the total index entry size within limits. + """ + + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + long_text_field = data_fixture.create_long_text_field(user=user, table=table) + long_text_field_2 = data_fixture.create_long_text_field(user=user, table=table) + handler = ViewHandler() + grid_view = handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + + table_model = table.get_model() + + import base64 + import os + + large_text_value = base64.b64encode(os.urandom(4000)).decode("ascii") + + # Insert a row with text values that combined would exceed PostgreSQL's btree + # index maximum entry size without Left() truncation. + table_model.objects.create( + **{ + f"field_{long_text_field.id}": large_text_value, + f"field_{long_text_field_2.id}": large_text_value, + } + ) + + handler.create_sort(user=user, view=grid_view, field=long_text_field, order="ASC") + handler.create_sort(user=user, view=grid_view, field=long_text_field_2, order="ASC") + + ViewIndexingHandler.update_index(grid_view, table_model) + + index = ViewIndexingHandler.get_index(grid_view, table_model) + # The index should be created successfully because Left() truncation keeps + # the indexed values within btree's size limit. + assert ViewIndexingHandler.does_index_exist(index.name) is True + + +@pytest.mark.django_db(transaction=True) +def test_update_index_catches_operational_error_gracefully(data_fixture): + """ + If PostgreSQL raises an OperationalError during index creation (e.g., due + to an unforeseen size issue), the error is caught and the view's + db_index_name is set to None instead of crashing. + """ + + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + text_field = data_fixture.create_text_field(user=user, table=table) + handler = ViewHandler() + grid_view = handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + + table_model = table.get_model() + handler.create_sort(user=user, view=grid_view, field=text_field, order="ASC") + + # Mock the schema editor's add_index to simulate a btree size error. + with patch( + "baserow.contrib.database.views.handler.safe_django_schema_editor" + ) as mock_editor_ctx: + mock_schema_editor = mock_editor_ctx.return_value.__enter__.return_value + mock_schema_editor.add_index.side_effect = OperationalError( + "index row size 6568 exceeds btree version 4 maximum 2704 for index" + ) + + ViewIndexingHandler.update_index(grid_view, table_model) + + grid_view.refresh_from_db() + # The index creation failed, so db_index_name should be None. + assert grid_view.db_index_name is None + + +@pytest.mark.django_db(transaction=True) +def test_drop_all_indexes_for_table(data_fixture): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + text_field = data_fixture.create_text_field(user=user, table=table) + handler = ViewHandler() + grid_view = handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + grid_view_2 = handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + + table_model = table.get_model() + handler.create_sort(user=user, view=grid_view, field=text_field, order="ASC") + handler.create_sort(user=user, view=grid_view_2, field=text_field, order="ASC") + + ViewIndexingHandler.update_index(grid_view, table_model) + ViewIndexingHandler.update_index(grid_view_2, table_model) + grid_view.refresh_from_db() + grid_view_2.refresh_from_db() + assert grid_view.db_index_name is not None + assert grid_view_2.db_index_name is not None + + index = ViewIndexingHandler.get_index(grid_view, table_model) + assert ViewIndexingHandler.does_index_exist(index.name) is True + + index2 = ViewIndexingHandler.get_index(grid_view_2, table_model) + assert ViewIndexingHandler.does_index_exist(index2.name) is True + + ViewIndexingHandler.drop_all_indexes_for_table(table.id) + + grid_view.refresh_from_db() + assert grid_view.db_index_name is None + assert ViewIndexingHandler.does_index_exist(index.name) is False + grid_view_2.refresh_from_db() + assert grid_view_2.db_index_name is None + assert ViewIndexingHandler.does_index_exist(index2.name) is False + + +@pytest.mark.django_db(transaction=True) +def test_handle_index_row_size_error_drops_indexes(data_fixture): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + text_field = data_fixture.create_text_field(user=user, table=table) + handler = ViewHandler() + grid_view = handler.create_view( + user=user, + table=table, + type_name="grid", + name="Test grid", + ownership_type=OWNERSHIP_TYPE_COLLABORATIVE, + ) + + table_model = table.get_model() + handler.create_sort(user=user, view=grid_view, field=text_field, order="ASC") + + ViewIndexingHandler.update_index(grid_view, table_model) + grid_view.refresh_from_db() + assert grid_view.db_index_name is not None + + ViewIndexingHandler.handle_index_row_size_error(table.id) + + grid_view.refresh_from_db() + assert grid_view.db_index_name is None diff --git a/backend/uv.lock b/backend/uv.lock index f4e9c2c3c3..70fdac2ad3 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -375,7 +375,7 @@ requires-dist = [ { name = "opentelemetry-semantic-conventions", specifier = "==0.60b1" }, { name = "opentelemetry-util-http", specifier = "==0.60b1" }, { name = "pgvector", specifier = "==0.4.2" }, - { name = "pillow", specifier = "==12.1.1" }, + { name = "pillow", specifier = "==12.2.0" }, { name = "posthog", specifier = "==7.4.2" }, { name = "prosemirror", specifier = "==0.5.2" }, { name = "psutil", specifier = "==7.2.2" }, @@ -2532,29 +2532,29 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, - { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, - { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, - { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, - { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, - { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, - { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, - { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, - { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, ] [[package]] @@ -2869,11 +2869,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] diff --git a/changelog/entries/unreleased/bug/2040_fix_index_size_error.json b/changelog/entries/unreleased/bug/2040_fix_index_size_error.json new file mode 100644 index 0000000000..69c6236f65 --- /dev/null +++ b/changelog/entries/unreleased/bug/2040_fix_index_size_error.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fix index max size error for view sort indexes", + "issue_origin": "github", + "issue_number": 2040, + "domain": "database", + "bullet_points": [], + "created_at": "2026-03-26" +} diff --git a/changelog/entries/unreleased/bug/fix_survey_form_next.json b/changelog/entries/unreleased/bug/fix_survey_form_next.json new file mode 100644 index 0000000000..974d94d3fe --- /dev/null +++ b/changelog/entries/unreleased/bug/fix_survey_form_next.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fixed survey form navigation with required fields", + "issue_origin": "github", + "issue_number": 3188, + "domain": "database", + "bullet_points": [], + "created_at": "2026-04-06" +} diff --git a/enterprise/backend/src/baserow_enterprise/role/default_roles.py b/enterprise/backend/src/baserow_enterprise/role/default_roles.py index c654b09443..5530cc153d 100755 --- a/enterprise/backend/src/baserow_enterprise/role/default_roles.py +++ b/enterprise/backend/src/baserow_enterprise/role/default_roles.py @@ -153,6 +153,7 @@ CreateViewFilterOperationType, CreateViewGroupByOperationType, CreateViewOperationType, + CreateViewRowCommentOperationType, CreateViewRowOperationType, CreateViewSortOperationType, DeleteViewDecorationOperationType, @@ -160,6 +161,7 @@ DeleteViewFilterOperationType, DeleteViewGroupByOperationType, DeleteViewOperationType, + DeleteViewRowCommentOperationType, DeleteViewRowOperationType, DeleteViewSortOperationType, DuplicateViewOperationType, @@ -181,10 +183,12 @@ ReadViewFilterOperationType, ReadViewGroupByOperationType, ReadViewOperationType, + ReadViewRowCommentsOperationType, ReadViewRowOperationType, ReadViewsOrderOperationType, ReadViewSortOperationType, RestoreViewOperationType, + RestoreViewRowCommentOperationType, UpdateViewDecorationOperationType, UpdateViewDefaultValuesOperationType, UpdateViewFieldOptionsOperationType, @@ -193,6 +197,7 @@ UpdateViewGroupByOperationType, UpdateViewOperationType, UpdateViewPublicOperationType, + UpdateViewRowCommentOperationType, UpdateViewRowOperationType, UpdateViewSlugOperationType, UpdateViewSortOperationType, @@ -414,6 +419,11 @@ ReadRowCommentsOperationType, RestoreRowCommentOperationType, UpdateRowCommentsOperationType, + ReadViewRowCommentsOperationType, + CreateViewRowCommentOperationType, + UpdateViewRowCommentOperationType, + DeleteViewRowCommentOperationType, + RestoreViewRowCommentOperationType, ReadDatabaseRowHistoryOperationType, ] ) diff --git a/enterprise/backend/src/baserow_enterprise/view_ownership_types.py b/enterprise/backend/src/baserow_enterprise/view_ownership_types.py index 98866d6f06..cc1d370455 100644 --- a/enterprise/backend/src/baserow_enterprise/view_ownership_types.py +++ b/enterprise/backend/src/baserow_enterprise/view_ownership_types.py @@ -3,17 +3,21 @@ from django.contrib.auth.models import AbstractUser from django.db.models import QuerySet +from baserow.contrib.database.table.models import Table from baserow.contrib.database.views.handler import ViewHandler from baserow.contrib.database.views.models import View from baserow.contrib.database.views.operations import ( CreateViewFilterOperationType, ReadViewDefaultValuesOperationType, + ReadViewRowCommentsOperationType, UpdateViewFieldOptionsOperationType, ) from baserow.contrib.database.views.registries import ( ViewOwnershipType, view_type_registry, ) +from baserow.contrib.database.views.row_checker import FilteredViewRowChecker +from baserow.contrib.database.views.view_types import FormViewType from baserow.core.exceptions import PermissionDenied from baserow.core.handler import CoreHandler from baserow.core.models import Workspace @@ -32,6 +36,11 @@ class RestrictedViewOwnershipType(ViewOwnershipType): type = "restricted" + def is_compatible_with_view_type(self, view_type) -> bool: + # The form view is not related to showing data, so it does not make any sense to + # have a restricted view ownership type. + return view_type.type != FormViewType.type + def change_ownership_type(self, user: AbstractUser, view: View) -> View: # It's not possible to change to and from restricted view type because that # could accidentally expose or restrict data. @@ -216,6 +225,70 @@ def can_modify_rows(self, view, row_ids=None): ) return not rows_outside_view.exists() + def get_users_to_notify_for_row_comment( + self, + table: Table, + row_id: int, + users: List[AbstractUser], + ) -> List[AbstractUser]: + if not users: + return [] + + # Fetch all restricted views for this table in one query, with filters + # prefetched so FilteredViewRowChecker can work efficiently. + restricted_views_qs = ( + View.objects.filter(table=table, ownership_type=self.type) + .select_related("table") + .prefetch_related("viewfilter_set", "filter_groups", "table__field_set") + ) + + # Use FilteredViewRowChecker to determine in a single bulk query which + # restricted views the commented row is visible in. When there are no restricted + # views the checker will simply produce no results. + model = table.get_model() + checker = FilteredViewRowChecker( + model, + restricted_views_qs, + only_include_views_which_want_realtime_events=False, + ) + + try: + row = model.objects.get(id=row_id) + except model.DoesNotExist: + return [] + + visible_views = checker.get_filtered_views_where_row_is_visible(row) + if not visible_views: + return [] + + # Check ReadViewRowCommentsOperationType for each remaining user on each view + # where the row is visible, in a single bulk call. + workspace = table.database.workspace + checks = [] + check_to_user = {} + for user in users: + for view in visible_views: + check = PermissionCheck( + user, ReadViewRowCommentsOperationType.type, view + ) + checks.append(check) + check_to_user[check] = user + + check_results = CoreHandler().check_multiple_permissions( + checks, workspace=workspace + ) + + allowed_user_ids = set() + additional_users = [] + for check, result in check_results.items(): + if result is True: + user = check_to_user[check] + if user.id not in allowed_user_ids: + allowed_user_ids.add(user.id) + additional_users.append(user) + + return additional_users + def enhance_list_fields_queryset( self, user: AbstractUser, view: View, queryset: QuerySet ) -> QuerySet: 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 index 025913c3bf..a4adc84b9f 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/views/test_restricted_view.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/views/test_restricted_view.py @@ -6,7 +6,12 @@ from django.urls import reverse import pytest -from starlette.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND +from starlette.status import ( + HTTP_200_OK, + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_404_NOT_FOUND, +) from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID from baserow.contrib.database.fields.models import DateField @@ -34,6 +39,10 @@ from baserow_enterprise.ws.restricted_view.fields.signals import ( _broadcast_payload_to_all_restricted_views, ) +from baserow_premium.row_comments.handler import ( + RowCommentHandler, + RowCommentsNotificationModes, +) from baserow_premium.views.models import ( CalendarViewFieldOptions, KanbanViewFieldOptions, @@ -1868,6 +1877,210 @@ def test_broadcast_payload_to_all_restricted_views_no_n_plus_one_queries( assert mock_broadcast_to_channel_group.delay.call_count == 0 +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_commenter_with_view_access_included_in_users_to_notify_for_comment( + enterprise_data_fixture, + premium_data_fixture, + synced_roles, +): + """ + Tests that get_users_to_notify_for_comment includes users who only have + view-level COMMENTER access (NO_ACCESS at workspace level, COMMENTER on a + restricted view) when they are subscribed to row comment notifications. + """ + + enterprise_data_fixture.enable_enterprise() + + owner = enterprise_data_fixture.create_user() + view_commenter = premium_data_fixture.create_user( + has_active_premium_license=True, + ) + workspace = enterprise_data_fixture.create_workspace( + user=owner, members=[view_commenter] + ) + 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) + + no_access_role = Role.objects.get(uid="NO_ACCESS") + commenter_role = Role.objects.get(uid="COMMENTER") + RoleAssignmentHandler().assign_role( + view_commenter, workspace, role=no_access_role, scope=workspace + ) + + row = RowHandler().create_row( + owner, table, values={f"field_{text_field.id}": "visible"} + ) + + view = premium_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_view_filter( + view=view, field=text_field, type="equal", value="visible" + ) + + RoleAssignmentHandler().assign_role( + view_commenter, + workspace, + role=commenter_role, + scope=View.objects.get(id=view.id), + ) + + # Subscribe the view_commenter to notifications on this row. + RowCommentHandler.update_row_comments_notification_mode( + view_commenter, + table.id, + row.id, + RowCommentsNotificationModes.MODE_ALL_COMMENTS.value, + skip_permission_check=True, + ) + + row_outside = RowHandler().create_row( + owner, table, values={f"field_{text_field.id}": "hidden"} + ) + + # Also subscribe to notifications on the row outside the view's filters. + RowCommentHandler.update_row_comments_notification_mode( + view_commenter, + table.id, + row_outside.id, + RowCommentsNotificationModes.MODE_ALL_COMMENTS.value, + skip_permission_check=True, + ) + + message = { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "owner comment"}], + } + ], + } + + # The row is within the view's filters, so the view-level commenter should + # be included in users to notify. + row_comment = RowCommentHandler.create_comment(owner, table.id, row.id, message) + users_to_notify = RowCommentHandler.get_users_to_notify_for_comment(row_comment) + assert view_commenter in users_to_notify, ( + "A user with view-level COMMENTER access who is subscribed to row comment " + "notifications should be included when the row is within the view's filters" + ) + + # The row is outside the view's filters, so the view-level commenter should + # NOT be included because the row is not visible in any restricted view + # they have access to. + row_comment_outside = RowCommentHandler.create_comment( + owner, table.id, row_outside.id, message + ) + users_to_notify = RowCommentHandler.get_users_to_notify_for_comment( + row_comment_outside + ) + assert view_commenter not in users_to_notify, ( + "A user with view-level COMMENTER access should NOT be notified for " + "comments on rows outside the view's filters" + ) + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_get_users_to_notify_for_comment_no_n_plus_one_queries( + enterprise_data_fixture, + premium_data_fixture, + synced_roles, +): + """ + Verifies that increasing the number of view-level commenters does not + increase the number of database queries executed by + get_users_to_notify_for_comment (no N+1 problem). + """ + + enterprise_data_fixture.enable_enterprise() + + owner = enterprise_data_fixture.create_user() + workspace = enterprise_data_fixture.create_workspace(user=owner) + 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) + + no_access_role = Role.objects.get(uid="NO_ACCESS") + commenter_role = Role.objects.get(uid="COMMENTER") + + row = RowHandler().create_row( + owner, table, values={f"field_{text_field.id}": "visible"} + ) + + view = premium_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_view_filter( + view=view, field=text_field, type="equal", value="visible" + ) + + message = { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "test"}], + } + ], + } + row_comment = RowCommentHandler.create_comment(owner, table.id, row.id, message) + + def _create_commenter(): + user = premium_data_fixture.create_user(has_active_premium_license=True) + enterprise_data_fixture.create_user_workspace( + user=user, workspace=workspace, order=0 + ) + RoleAssignmentHandler().assign_role( + user, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user, + workspace, + role=commenter_role, + scope=View.objects.get(id=view.id), + ) + RowCommentHandler.update_row_comments_notification_mode( + user, + table.id, + row.id, + RowCommentsNotificationModes.MODE_ALL_COMMENTS.value, + skip_permission_check=True, + ) + return user + + # Create 2 commenters and measure query count. + _create_commenter() + _create_commenter() + + # Warm up caches with a first call. + RowCommentHandler.get_users_to_notify_for_comment(row_comment) + + with CaptureQueriesContext(connection) as ctx_two: + result_two = RowCommentHandler.get_users_to_notify_for_comment(row_comment) + assert len(result_two) == 2 + + # Create 3 more commenters (5 total) and measure again. + _create_commenter() + _create_commenter() + _create_commenter() + + # Warm up caches again. + RowCommentHandler.get_users_to_notify_for_comment(row_comment) + + with CaptureQueriesContext(connection) as ctx_five: + result_five = RowCommentHandler.get_users_to_notify_for_comment(row_comment) + assert len(result_five) == 5 + + assert len(ctx_five) == len(ctx_two), ( + f"Query count should not grow with the number of users. " + f"2 users: {len(ctx_two)} queries, 5 users: {len(ctx_five)} queries" + ) + + @pytest.mark.django_db @override_settings(DEBUG=True) def test_editor_row_endpoints_exclude_hidden_fields_in_response( @@ -2466,3 +2679,200 @@ def test_editor_with_view_access_can_list_rows_for_all_view_types( response_json = response.json() rows = get_value_at_path(response_json, response_path) assert len(rows) == 1, f"Editor should see the row in {view_type.type}" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_editor_with_view_access_can_comment_on_rows( + enterprise_data_fixture, + premium_data_fixture, + api_client, + synced_roles, +): + """ + Tests that a user who has NO_ACCESS at workspace level but COMMENTER on a + specific restricted view can create, read, update, and delete comments on + rows that match the view's filters. + """ + + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = premium_data_fixture.create_user_and_token( + has_active_premium_license=True, + ) + 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) + + commenter_role = Role.objects.get(uid="COMMENTER") + no_access_role = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + + row = RowHandler().create_row( + user, table, values={f"field_{text_field.id}": "visible"} + ) + row_outside = RowHandler().create_row( + user, table, values={f"field_{text_field.id}": "hidden"} + ) + + view = premium_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_view_filter( + view=view, field=text_field, type="equal", value="visible" + ) + + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=commenter_role, + scope=View.objects.get(id=view.id), + ) + + # User2 can create a comment on a row that matches the view's filters. + message = { + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "test comment"}]} + ], + } + response = api_client.post( + reverse( + "api:premium:row_comments:list", + kwargs={"table_id": table.id, "row_id": row.id}, + ) + + f"?view={view.id}", + {"message": message}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == 200, ( + f"Commenter with view-level access should be able to create a comment: " + f"{response.json()}" + ) + comment_id = response.json()["id"] + + # User2 can read comments on the row. + response = api_client.get( + reverse( + "api:premium:row_comments:list", + kwargs={"table_id": table.id, "row_id": row.id}, + ) + + f"?view={view.id}", + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == 200 + assert response.json()["count"] == 1 + + # User2 can update their own comment. + updated_message = { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "updated comment"}], + } + ], + } + response = api_client.patch( + reverse( + "api:premium:row_comments:item", + kwargs={"table_id": table.id, "comment_id": comment_id}, + ) + + f"?view={view.id}", + {"message": updated_message}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == 200 + + # User2 can delete their own comment. + response = api_client.delete( + reverse( + "api:premium:row_comments:item", + kwargs={"table_id": table.id, "comment_id": comment_id}, + ) + + f"?view={view.id}", + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == 200 + + # User2 can update notification mode on a row within the view's filters. + response = api_client.put( + reverse( + "api:premium:row_comments:notification_mode", + kwargs={"table_id": table.id, "row_id": row.id}, + ) + + f"?view={view.id}", + {"mode": "all"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == 204, ( + f"Commenter with view-level access should be able to update notification " + f"mode: {response.json() if response.status_code != 204 else ''}" + ) + + # User2 cannot update notification mode on a row outside the view's filters. + response = api_client.put( + reverse( + "api:premium:row_comments:notification_mode", + kwargs={"table_id": table.id, "row_id": row_outside.id}, + ) + + f"?view={view.id}", + {"mode": "all"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code != 204, ( + "Commenter should not be able to update notification mode on a row " + "outside the view's filters" + ) + + # User2 cannot create a comment on a row outside the view's filters. + response = api_client.post( + reverse( + "api:premium:row_comments:list", + kwargs={"table_id": table.id, "row_id": row_outside.id}, + ) + + f"?view={view.id}", + {"message": message}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code != 200, ( + "Commenter should not be able to comment on a row outside the view's filters" + ) + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_cannot_create_form_view_with_restricted_ownership_type( + 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) + + response = api_client.post( + reverse("api:database:views:list", kwargs={"table_id": table.id}), + { + "name": "Test Form", + "type": "form", + "ownership_type": "restricted", + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert ( + response.json()["error"] + == "ERROR_VIEW_OWNERSHIP_TYPE_INCOMPATIBLE_WITH_VIEW_TYPE" + ) diff --git a/enterprise/web-frontend/modules/baserow_enterprise/viewOwnershipTypes.js b/enterprise/web-frontend/modules/baserow_enterprise/viewOwnershipTypes.js index 25e1e5a60c..3a3a67bfe1 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/viewOwnershipTypes.js +++ b/enterprise/web-frontend/modules/baserow_enterprise/viewOwnershipTypes.js @@ -2,6 +2,7 @@ import { ViewOwnershipType } from '@baserow/modules/database/viewOwnershipTypes' import EnterpriseFeatures from '@baserow_enterprise/features' import PaidFeaturesModal from '@baserow_premium/components/PaidFeaturesModal' import { RBACPaidFeature } from '@baserow_enterprise/paidFeatures' +import { FormViewType } from '@baserow/modules/database/viewTypes.js' export class RestrictedViewOwnershipType extends ViewOwnershipType { static getType() { @@ -27,6 +28,10 @@ export class RestrictedViewOwnershipType extends ViewOwnershipType { return 'iconoir-shield-check' } + isCompatibleWithViewType(viewType) { + return viewType.type !== FormViewType.getType() + } + isDeactivated(workspaceId) { return !this.app.$hasFeature(EnterpriseFeatures.RBAC, workspaceId) } diff --git a/premium/backend/src/baserow_premium/api/row_comments/serializers.py b/premium/backend/src/baserow_premium/api/row_comments/serializers.py index 5f94ab6d71..200931761f 100644 --- a/premium/backend/src/baserow_premium/api/row_comments/serializers.py +++ b/premium/backend/src/baserow_premium/api/row_comments/serializers.py @@ -58,6 +58,10 @@ def validate_message(self, value): return value +class RowCommentViewQueryParamsSerializer(serializers.Serializer): + view = serializers.IntegerField(required=False) + + class RowCommentsNotificationModeSerializer(serializers.Serializer): mode = serializers.ChoiceField( choices=ALL_ROW_COMMENT_NOTIFICATION_MODES, diff --git a/premium/backend/src/baserow_premium/api/row_comments/views.py b/premium/backend/src/baserow_premium/api/row_comments/views.py index f74feb2794..01da3c0ef8 100755 --- a/premium/backend/src/baserow_premium/api/row_comments/views.py +++ b/premium/backend/src/baserow_premium/api/row_comments/views.py @@ -7,7 +7,11 @@ from rest_framework.response import Response from rest_framework.views import APIView -from baserow.api.decorators import map_exceptions, validate_body +from baserow.api.decorators import ( + map_exceptions, + validate_body, + validate_query_parameters, +) from baserow.api.errors import ERROR_USER_NOT_IN_GROUP from baserow.api.pagination import PageNumberPagination from baserow.api.schemas import get_error_schema @@ -39,6 +43,19 @@ RowCommentCreateSerializer, RowCommentSerializer, RowCommentsNotificationModeSerializer, + RowCommentViewQueryParamsSerializer, +) + +VIEW_ID_API_PARAM = OpenApiParameter( + name="view", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + required=False, + description=( + "Optionally provide a view id. If the user doesn't have table-level " + "permissions, the system will check if the user has view-level " + "permissions as a fallback." + ), ) @@ -85,6 +102,7 @@ class RowCommentsView(APIView): description="Can only be used in combination with the `page` parameter " "and defines how many rows should be returned.", ), + VIEW_ID_API_PARAM, ], tags=["Database table rows"], operation_id="get_row_comments", @@ -110,9 +128,12 @@ class RowCommentsView(APIView): UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP, } ) - def get(self, request, table_id, row_id): + @validate_query_parameters(RowCommentViewQueryParamsSerializer) + def get(self, request, table_id, row_id, query_params): + view_id = query_params.get("view") + comments = RowCommentHandler.get_comments( - request.user, table_id, row_id, include_trash=True + request.user, table_id, row_id, include_trash=True, view_id=view_id ) if LimitOffsetPagination.limit_query_param in request.GET: @@ -143,6 +164,7 @@ def get(self, request, table_id, row_id): type=OpenApiTypes.INT, description="The row to create a comment for.", ), + VIEW_ID_API_PARAM, ], tags=["Database table rows"], operation_id="create_row_comment", @@ -167,10 +189,13 @@ def get(self, request, table_id, row_id): } ) @validate_body(RowCommentCreateSerializer) + @validate_query_parameters(RowCommentViewQueryParamsSerializer) @transaction.atomic - def post(self, request, table_id, row_id, data): + def post(self, request, table_id, row_id, data, query_params): + view_id = query_params.get("view") + new_row_comment = action_type_registry.get(CreateRowCommentActionType.type).do( - request.user, table_id, row_id, data["message"] + request.user, table_id, row_id, data["message"], view_id=view_id ) context = {"user": request.user} return Response(RowCommentSerializer(new_row_comment, context=context).data) @@ -191,6 +216,7 @@ class RowCommentView(APIView): type=OpenApiTypes.INT, description="The row comment to update.", ), + VIEW_ID_API_PARAM, ], tags=["Database table rows"], operation_id="update_row_comment", @@ -221,12 +247,15 @@ class RowCommentView(APIView): } ) @validate_body(RowCommentCreateSerializer) + @validate_query_parameters(RowCommentViewQueryParamsSerializer) @transaction.atomic - def patch(self, request, table_id, comment_id, data): + def patch(self, request, table_id, comment_id, data, query_params): + view_id = query_params.get("view") + comment = data.get("message", data.get("comment", None)) updated_row_comment = action_type_registry.get( UpdateRowCommentActionType.type - ).do(request.user, table_id, comment_id, comment) + ).do(request.user, table_id, comment_id, comment, view_id=view_id) context = {"user": request.user} return Response(RowCommentSerializer(updated_row_comment, context=context).data) @@ -244,6 +273,7 @@ def patch(self, request, table_id, comment_id, data): type=OpenApiTypes.INT, description="The row comment to delete.", ), + VIEW_ID_API_PARAM, ], tags=["Database table rows"], operation_id="delete_row_comment", @@ -267,10 +297,13 @@ def patch(self, request, table_id, comment_id, data): UserNotRowCommentAuthorException: ERROR_USER_NOT_COMMENT_AUTHOR, } ) + @validate_query_parameters(RowCommentViewQueryParamsSerializer) @transaction.atomic - def delete(self, request, table_id, comment_id): + def delete(self, request, table_id, comment_id, query_params): + view_id = query_params.get("view") + trashed_comment = action_type_registry.get(DeleteRowCommentActionType.type).do( - request.user, table_id, comment_id + request.user, table_id, comment_id, view_id=view_id ) context = {"user": request.user} return Response(RowCommentSerializer(trashed_comment, context=context).data) @@ -291,6 +324,7 @@ class RowCommentsNotificationModeView(APIView): type=OpenApiTypes.INT, description="The row on which to manage the comment subscription.", ), + VIEW_ID_API_PARAM, ], tags=["Database table rows"], operation_id="update_row_comment_notification_mode", @@ -318,10 +352,13 @@ class RowCommentsNotificationModeView(APIView): } ) @validate_body(RowCommentsNotificationModeSerializer, return_validated=True) + @validate_query_parameters(RowCommentViewQueryParamsSerializer) @transaction.atomic - def put(self, request, table_id, row_id, data): + def put(self, request, table_id, row_id, data, query_params): + view_id = query_params.get("view") + notification_mode = data["mode"] RowCommentHandler.update_row_comments_notification_mode( - request.user, table_id, row_id, notification_mode + request.user, table_id, row_id, notification_mode, view_id=view_id ) return Response(status=204) diff --git a/premium/backend/src/baserow_premium/permission_manager.py b/premium/backend/src/baserow_premium/permission_manager.py index 05c0fcff97..a21775429d 100644 --- a/premium/backend/src/baserow_premium/permission_manager.py +++ b/premium/backend/src/baserow_premium/permission_manager.py @@ -12,6 +12,7 @@ CreateViewFilterGroupOperationType, CreateViewFilterOperationType, CreateViewGroupByOperationType, + CreateViewRowCommentOperationType, CreateViewRowOperationType, CreateViewSortOperationType, DeleteViewDecorationOperationType, @@ -19,6 +20,7 @@ DeleteViewFilterOperationType, DeleteViewGroupByOperationType, DeleteViewOperationType, + DeleteViewRowCommentOperationType, DeleteViewRowOperationType, DeleteViewSortOperationType, DuplicateViewOperationType, @@ -39,9 +41,11 @@ ReadViewFilterOperationType, ReadViewGroupByOperationType, ReadViewOperationType, + ReadViewRowCommentsOperationType, ReadViewRowOperationType, ReadViewSortOperationType, RestoreViewOperationType, + RestoreViewRowCommentOperationType, UpdateViewDecorationOperationType, UpdateViewDefaultValuesOperationType, UpdateViewFieldOptionsOperationType, @@ -50,6 +54,7 @@ UpdateViewGroupByOperationType, UpdateViewOperationType, UpdateViewPublicOperationType, + UpdateViewRowCommentOperationType, UpdateViewRowOperationType, UpdateViewSlugOperationType, UpdateViewSortOperationType, @@ -132,6 +137,11 @@ def __init__(self): ReadViewRowOperationType.type, ReadAdjacentViewRowOperationType.type, ListViewRowsOperationType.type, + ReadViewRowCommentsOperationType.type, + CreateViewRowCommentOperationType.type, + UpdateViewRowCommentOperationType.type, + DeleteViewRowCommentOperationType.type, + RestoreViewRowCommentOperationType.type, CreateViewRowOperationType.type, UpdateViewRowOperationType.type, DeleteViewRowOperationType.type, diff --git a/premium/backend/src/baserow_premium/row_comments/actions.py b/premium/backend/src/baserow_premium/row_comments/actions.py index 25de47b08d..62f6465655 100644 --- a/premium/backend/src/baserow_premium/row_comments/actions.py +++ b/premium/backend/src/baserow_premium/row_comments/actions.py @@ -1,5 +1,5 @@ import dataclasses -from typing import Any, Dict +from typing import Any, Dict, Optional from django.contrib.auth.models import AbstractUser from django.utils.translation import gettext_lazy as _ @@ -49,6 +49,7 @@ def do( table_id: int, row_id: int, message: Dict[str, Any], + view_id: Optional[int] = None, ) -> RowComment: """ Creates a new comment for the given row. @@ -58,7 +59,9 @@ def do( Redo this action will restore the comment. """ - row_comment = RowCommentHandler.create_comment(user, table_id, row_id, message) + row_comment = RowCommentHandler.create_comment( + user, table_id, row_id, message, view_id=view_id + ) table = TableHandler().get_table(table_id) database = table.database @@ -122,6 +125,7 @@ def do( table_id: int, row_comment_id: int, comment_content: str, + view_id: Optional[int] = None, ) -> RowComment: """ Updates a comment with the given comment_content. @@ -132,7 +136,7 @@ def do( """ row_comment = RowCommentHandler.get_comment_by_id( - user, table_id, row_comment_id + user, table_id, row_comment_id, view_id=view_id ) table = row_comment.table @@ -141,7 +145,7 @@ def do( original_message = row_comment.message updated_comment = RowCommentHandler.update_comment( - user, row_comment, comment_content + user, row_comment, comment_content, view_id=view_id ) cls.register_action( @@ -196,7 +200,13 @@ class Params: workspace_name: str @classmethod - def do(cls, user: AbstractUser, table_id: int, row_comment_id: int) -> RowComment: + def do( + cls, + user: AbstractUser, + table_id: int, + row_comment_id: int, + view_id: Optional[int] = None, + ) -> RowComment: """ Deletes a comment from the given row. Look at .handler.RowCommentHandler.delete_comment for @@ -206,9 +216,9 @@ def do(cls, user: AbstractUser, table_id: int, row_comment_id: int) -> RowCommen """ row_comment = RowCommentHandler.get_comment_by_id( - user, table_id, row_comment_id + user, table_id, row_comment_id, view_id=view_id ) - RowCommentHandler.delete_comment(user, row_comment) + RowCommentHandler.delete_comment(user, row_comment, view_id=view_id) table = row_comment.table database = table.database diff --git a/premium/backend/src/baserow_premium/row_comments/handler.py b/premium/backend/src/baserow_premium/row_comments/handler.py index 905e3fff18..b38a89afcb 100644 --- a/premium/backend/src/baserow_premium/row_comments/handler.py +++ b/premium/backend/src/baserow_premium/row_comments/handler.py @@ -1,11 +1,21 @@ -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from django.contrib.auth.models import AbstractUser from django.db.models import QuerySet -from baserow.contrib.database.rows.exceptions import RowDoesNotExist from baserow.contrib.database.rows.handler import RowHandler from baserow.contrib.database.table.handler import TableHandler +from baserow.contrib.database.views.handler import ViewHandler +from baserow.contrib.database.views.operations import ( + CreateViewRowCommentOperationType, + DeleteViewRowCommentOperationType, + ReadViewRowCommentsOperationType, + UpdateViewRowCommentOperationType, +) +from baserow.contrib.database.views.registries import ( + view_ownership_type_registry, +) +from baserow.contrib.database.views.utils import check_permissions_with_view_fallback from baserow.core.handler import CoreHandler from baserow.core.prosemirror.utils import ( extract_mentioned_users_in_workspace, @@ -41,6 +51,17 @@ ) +def _get_view_if_provided(user, view_id): + """ + Resolves an optional view_id to a View instance. + """ + + if view_id is None: + return None + + return ViewHandler().get_view_as_user(user, view_id) + + class RowCommentHandler: @classmethod def get_comments( @@ -49,6 +70,7 @@ def get_comments( table_id: int, row_id: int, include_trash: bool = False, + view_id: Optional[int] = None, ) -> QuerySet: """ Returns all the row comments for a given row in a table. @@ -58,6 +80,9 @@ def get_comments( :param row_id: The id of the row to get comments for. :param include_trash: If True, trashed comments will be included in the queryset. + :param view_id: Optionally provide a view id. If the user doesn't have + table-level permissions, view-level permissions will be checked as + a fallback. :return: A queryset of all row comments for that particular row. :raises TableDoesNotExist: If the table does not exist. :raises RowDoesNotExist: If the row does not exist. @@ -69,16 +94,20 @@ def get_comments( LicenseHandler.raise_if_user_doesnt_have_feature( PREMIUM, requesting_user, table.database.workspace ) - # TODO: RBAC -> When row level permissions are introduced we also need to check - # that the user can see the row - CoreHandler().check_permissions( - requesting_user, + + view = _get_view_if_provided(requesting_user, view_id) + check_permissions_with_view_fallback( ReadRowCommentsOperationType.type, - workspace=table.database.workspace, - context=table, + ReadViewRowCommentsOperationType.type, + requesting_user, + table, + view, + row_ids=[row_id], ) - RowHandler().has_row(requesting_user, table, row_id, raise_error=True) + RowHandler().has_row( + requesting_user, table, row_id, raise_error=True, view=view + ) row_comment_manager = RowComment.objects if include_trash: @@ -92,7 +121,11 @@ def get_comments( @classmethod def get_comment_by_id( - cls, requesting_user: AbstractUser, table_id: int, comment_id: int + cls, + requesting_user: AbstractUser, + table_id: int, + comment_id: int, + view_id: Optional[int] = None, ) -> RowComment: """ Returns the row comment for a given comment id. @@ -101,6 +134,7 @@ def get_comment_by_id( comments. :param table_id: The table to find the row in. :param comment_id: The id of the comment to get. + :param view_id: Optionally provide a view id for permission fallback. :return: the comment for that particular row. :raises PermissionException: If the user does not have permission to read comment on the table. @@ -112,25 +146,28 @@ def get_comment_by_id( PREMIUM, requesting_user, table.database.workspace ) - # TODO: RBAC -> When row level permissions are introduced we also need - # to check that the user can see the row - CoreHandler().check_permissions( - requesting_user, - ReadRowCommentsOperationType.type, - workspace=table.database.workspace, - context=table, - ) - queryset = RowComment.objects.select_related( "table__database__workspace" ).prefetch_related("mentions") try: row_comment = queryset.get(pk=comment_id) - RowHandler().has_row( - requesting_user, table, row_comment.row_id, raise_error=True - ) - except (RowComment.DoesNotExist, RowDoesNotExist): + except RowComment.DoesNotExist: + raise RowCommentDoesNotExist() + + view = _get_view_if_provided(requesting_user, view_id) + check_permissions_with_view_fallback( + ReadRowCommentsOperationType.type, + ReadViewRowCommentsOperationType.type, + requesting_user, + table, + view, + row_ids=[row_comment.row_id], + ) + + if not RowHandler().has_row( + requesting_user, table, row_comment.row_id, view=view + ): raise RowCommentDoesNotExist() return row_comment @@ -142,6 +179,7 @@ def create_comment( table_id: int, row_id: int, message: Dict[str, Any], + view_id: Optional[int] = None, ) -> RowComment: """ Creates a new row comment on the specified row. @@ -151,6 +189,7 @@ def create_comment( :param row_id: The id of the row to post the comment on. :param message: The comment content to post. It must be a dict containing a valid prosemirror document. + :param view_id: Optionally provide a view id for permission fallback. :return: The newly created RowComment instance. :raises TableDoesNotExist: If the table does not exist. :raises RowDoesNotExist: If the row does not exist. @@ -166,16 +205,16 @@ def create_comment( PREMIUM, requesting_user, workspace ) - # TODO: RBAC -> When row level permissions are introduced we also need to check - # that the user can see the row - CoreHandler().check_permissions( - requesting_user, + view = _get_view_if_provided(requesting_user, view_id) + check_permissions_with_view_fallback( CreateRowCommentsOperationType.type, - workspace=workspace, - context=table, + CreateViewRowCommentOperationType.type, + requesting_user, + table, + view, + row_ids=[row_id], ) - - row = RowHandler().get_row(requesting_user, table, row_id) + row = RowHandler().get_row(requesting_user, table, row_id, view=view) if not is_valid_prosemirror_document(message): raise InvalidRowCommentException() @@ -211,6 +250,7 @@ def update_comment( requesting_user: AbstractUser, row_comment: RowComment, message: Dict[str, Any], + view_id: Optional[int] = None, ) -> RowComment: """ Updates a new row comment on the specified row. @@ -218,6 +258,7 @@ def update_comment( :param requesting_user: The user who is making the comment. :param row_comment: The comment content for the update. :param message: The new content of the comment. + :param view_id: Optionally provide a view id for permission fallback. :return: The updated RowComment instance. :raises PermissionException: If the user does not have permission to delete the comment. @@ -228,11 +269,15 @@ def update_comment( table = row_comment.table workspace = table.database.workspace - CoreHandler().check_permissions( - requesting_user, + + view = _get_view_if_provided(requesting_user, view_id) + check_permissions_with_view_fallback( UpdateRowCommentsOperationType.type, - workspace=workspace, - context=table, + UpdateViewRowCommentOperationType.type, + requesting_user, + table, + view, + row_ids=[row_comment.row_id], ) # only the owner of the comment can update it @@ -254,7 +299,9 @@ def update_comment( if new_mentions: row_comment.mentions.set(new_mentions) - row = RowHandler().get_row(requesting_user, table, row_comment.row_id) + row = RowHandler().get_row( + requesting_user, table, row_comment.row_id, view=view + ) row_comment_updated.send( cls, @@ -266,13 +313,19 @@ def update_comment( return row_comment @classmethod - def delete_comment(cls, requesting_user: AbstractUser, row_comment: RowComment): + def delete_comment( + cls, + requesting_user: AbstractUser, + row_comment: RowComment, + view_id: Optional[int] = None, + ): """ Set a row comment marked as trashed and so it will not be visible to users anymore. :param requesting_user: The user who is making the comment. :param row_comment: The comment to delete. + :param view_id: Optionally provide a view id for permission fallback. :raises PermissionException: If the user does not have permission to delete the comment. :raises UserNotRowCommentAuthorException: If the user is not the author of the @@ -281,11 +334,15 @@ def delete_comment(cls, requesting_user: AbstractUser, row_comment: RowComment): table = row_comment.table database = table.database - CoreHandler().check_permissions( - requesting_user, + + view = _get_view_if_provided(requesting_user, view_id) + check_permissions_with_view_fallback( DeleteRowCommentsOperationType.type, - workspace=database.workspace, - context=table, + DeleteViewRowCommentOperationType.type, + requesting_user, + table, + view, + row_ids=[row_comment.row_id], ) # only the owner of the comment can trash it @@ -304,7 +361,9 @@ def delete_comment(cls, requesting_user: AbstractUser, row_comment: RowComment): @classmethod def get_users_to_notify_for_comment( - cls, row_comment: RowComment, user_ids_to_exclude=None + cls, + row_comment: RowComment, + user_ids_to_exclude=None, ) -> List[AbstractUser]: """ Returns a list of users who should be notified about a new comment on a @@ -313,7 +372,7 @@ def get_users_to_notify_for_comment( :param row_comment: The comment to notify users about. :param user_ids_to_exclude: A list of user ids to exclude from the returned list (i.e. users mentioned in the comment). - :return: A queryset of users who should be notified about the comment. + :return: A list of users who should be notified about the comment. """ user_ids_to_exclude = user_ids_to_exclude or [] @@ -337,13 +396,35 @@ def get_users_to_notify_for_comment( ) if len(user_subscriptions): - # Ensure users can see comments for this table - users_to_notify = CoreHandler().check_permission_for_multiple_actors( - [u.user for u in user_subscriptions], + table = row_comment.table + workspace = table.database.workspace + users = [u.user for u in user_subscriptions] + + # Step 1: check table-level permission for all subscribed users. + # This covers the common case where users have workspace or table + # level access. + table_allowed_users = CoreHandler().check_permission_for_multiple_actors( + users, ReadRowCommentsOperationType.type, - workspace=row_comment.table.database.workspace, - context=row_comment.table, + workspace=workspace, + context=table, ) + table_allowed = {u.id for u in table_allowed_users} + users_to_notify.extend(table_allowed_users) + + # Step 2: for users who didn't pass the table-level check, ask each view + # ownership type if there are additional users that should be notified. + # This allows ownership types like "restricted" to check view-level + # permissions and row visibility within their views. + remaining_users = [u for u in users if u.id not in table_allowed] + if remaining_users: + for ownership_type in view_ownership_type_registry.get_all(): + additional = ownership_type.get_users_to_notify_for_row_comment( + table, + row_comment.row_id, + remaining_users, + ) + users_to_notify.extend(additional) return users_to_notify @@ -355,6 +436,8 @@ def update_row_comments_notification_mode( row_id: int, mode: RowCommentsNotificationModes, include_user_in_signal=False, + view_id: Optional[int] = None, + skip_permission_check: bool = False, ): """ Updates the user's notification settings for comments on a specific @@ -364,13 +447,16 @@ def update_row_comments_notification_mode( notification_type will take care to send the notification to the mentioned user. - :param requesting_user: The user who is subscribing to the row's - comments. + :param user: The user who is subscribing to the row's comments. :param table_id: The table to find the row in. :param row_id: The id of the row to subscribe to. :param mode: The subscription mode. :param include_user_in_signal: If True, the user sessions will be included in the signal that is sent. + :param view_id: Optionally provide a view id for permission fallback. + :param skip_permission_check: If True, skip the permission check. + This is used when the method is called internally from a signal + handler where the caller has already verified access. :raises TableDoesNotExist: If the table does not exist. :raises RowDoesNotExist: If the row does not exist. :raises UserNotInWorkspace: If the user is not a member of the workspace @@ -386,18 +472,23 @@ def update_row_comments_notification_mode( # ensure the table and row exist and user has access to them table = TableHandler().get_table(table_id) - row = RowHandler().get_row(user, table, row_id) - LicenseHandler.raise_if_user_doesnt_have_feature( - PREMIUM, user, table.database.workspace - ) + if not skip_permission_check: + LicenseHandler.raise_if_user_doesnt_have_feature( + PREMIUM, user, table.database.workspace + ) - CoreHandler().check_permissions( - user, - ReadRowCommentsOperationType.type, - workspace=table.database.workspace, - context=table, - ) + view = _get_view_if_provided(user, view_id) + check_permissions_with_view_fallback( + ReadRowCommentsOperationType.type, + ReadViewRowCommentsOperationType.type, + user, + table, + view, + row_ids=[row_id], + ) + + RowHandler().has_row(user, table, row_id, raise_error=True, view=view) RowCommentsNotificationMode.objects.update_or_create( table=table, row_id=row_id, user=user, defaults={"mode": mode} @@ -407,7 +498,7 @@ def update_row_comments_notification_mode( cls, user=user, table=table, - row_id=row.id, + row_id=int(row_id), mode=mode, include_user_in_signal=include_user_in_signal, ) diff --git a/premium/backend/src/baserow_premium/row_comments/notification_types.py b/premium/backend/src/baserow_premium/row_comments/notification_types.py index bacf44a97a..b796e9850c 100644 --- a/premium/backend/src/baserow_premium/row_comments/notification_types.py +++ b/premium/backend/src/baserow_premium/row_comments/notification_types.py @@ -167,6 +167,7 @@ def on_row_comment_created(sender, row_comment, row, user, mentions, **kwargs): row_id, RowCommentsNotificationModes.MODE_ALL_COMMENTS.value, include_user_in_signal=True, + skip_permission_check=True, ) diff --git a/premium/backend/tests/baserow_premium_tests/api/row_comments/test_row_comments_views.py b/premium/backend/tests/baserow_premium_tests/api/row_comments/test_row_comments_views.py index 4c7030ad5c..00f97cccdc 100644 --- a/premium/backend/tests/baserow_premium_tests/api/row_comments/test_row_comments_views.py +++ b/premium/backend/tests/baserow_premium_tests/api/row_comments/test_row_comments_views.py @@ -621,11 +621,21 @@ def test_perm_deleting_a_trashed_table_with_comments_cleans_up_the_rows( assert RowComment.objects.first().row_id == other_rows[0].id +def _mock_check_multiple_permissions(checks, workspace=None, **kwargs): + return {check: True for check in checks} + + @pytest.mark.django_db @override_settings(DEBUG=True) -@patch("baserow.core.handler.CoreHandler.check_permissions") +@patch( + "baserow.core.handler.CoreHandler.check_multiple_permissions", + side_effect=_mock_check_multiple_permissions, +) def test_getting_row_comments_executes_fixed_number_of_queries( - mock_check_permissions, premium_data_fixture, api_client, django_assert_num_queries + mock_check_multiple_permissions, + premium_data_fixture, + api_client, + django_assert_num_queries, ): user, token = premium_data_fixture.create_user_and_token( first_name="Test User", has_active_premium_license=True diff --git a/premium/web-frontend/modules/baserow_premium/components/row_comments/RowComment.vue b/premium/web-frontend/modules/baserow_premium/components/row_comments/RowComment.vue index 48f56f1252..44cc71a924 100644 --- a/premium/web-frontend/modules/baserow_premium/components/row_comments/RowComment.vue +++ b/premium/web-frontend/modules/baserow_premium/components/row_comments/RowComment.vue @@ -109,6 +109,11 @@ export default { type: Boolean, required: true, }, + viewId: { + type: Number, + required: false, + default: null, + }, }, emits: ['stop-edit'], data() { @@ -245,6 +250,7 @@ export default { tableId: this.comment.table_id, commentId: this.comment.id, message: this.message, + viewId: this.viewId, }) } catch (error) { notifyIf(error, 'application') @@ -261,6 +267,7 @@ export default { tableId: this.comment.table_id, rowId: this.comment.row_id, commentId: this.comment.id, + viewId: this.viewId, }) } catch (error) { notifyIf(error, 'application') diff --git a/premium/web-frontend/modules/baserow_premium/components/row_comments/RowCommentsSidebar.vue b/premium/web-frontend/modules/baserow_premium/components/row_comments/RowCommentsSidebar.vue index af0761c183..9701b437c3 100644 --- a/premium/web-frontend/modules/baserow_premium/components/row_comments/RowCommentsSidebar.vue +++ b/premium/web-frontend/modules/baserow_premium/components/row_comments/RowCommentsSidebar.vue @@ -29,16 +29,9 @@