From 851826141ebc8a9c8647732e7b8f7e264ae93ec1 Mon Sep 17 00:00:00 2001 From: Przemyslaw Kukulski <przemyslaw@baserow.io> Date: Tue, 19 Nov 2024 19:33:32 +0000 Subject: [PATCH] Prevent triggering webhook if query param is provided on row create, update, or delete endpoints --- .../contrib/database/api/rows/views.py | 110 +++++++++++++- .../src/baserow/contrib/database/api/utils.py | 14 ++ .../baserow/contrib/database/rows/actions.py | 36 ++++- .../baserow/contrib/database/rows/handler.py | 13 +- .../api/rows/test_batch_rows_views.py | 139 ++++++++++++++++++ .../database/api/rows/test_row_views.py | 131 +++++++++++++++++ .../api/views/form/test_form_view_views.py | 1 + ...ook_if_query_param_is_provided_on_row.json | 7 + web-frontend/locales/en.json | 1 + .../docs/sections/APIDocsTableCreateRow.vue | 20 +++ .../docs/sections/APIDocsTableDeleteRow.vue | 15 +- .../docs/sections/APIDocsTableMoveRow.vue | 10 ++ .../docs/sections/APIDocsTableUpdateRow.vue | 20 +++ 13 files changed, 507 insertions(+), 10 deletions(-) create mode 100644 changelog/entries/unreleased/feature/3085_prevent_triggering_webhook_if_query_param_is_provided_on_row.json diff --git a/backend/src/baserow/contrib/database/api/rows/views.py b/backend/src/baserow/contrib/database/api/rows/views.py index 7c48c21c1..dd5e237f7 100644 --- a/backend/src/baserow/contrib/database/api/rows/views.py +++ b/backend/src/baserow/contrib/database/api/rows/views.py @@ -54,6 +54,7 @@ from baserow.contrib.database.api.tokens.authentications import TokenAuthenticat from baserow.contrib.database.api.tokens.errors import ERROR_NO_PERMISSION_TO_TABLE from baserow.contrib.database.api.utils import ( extract_link_row_joins_from_request, + extract_send_webhook_events_from_params, extract_user_field_names_from_params, get_include_exclude_fields, ) @@ -445,6 +446,16 @@ class RowsView(APIView): "field names (e.g., field_123)." ), ), + OpenApiParameter( + name="send_webhook_events", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.BOOL, + description=( + "A flag query parameter that triggers webhooks after the operation," + " if set to `y`, `yes`, `true`, `t`, `on`, `1`, `or` left empty. " + "Defaults to `true`" + ), + ), CLIENT_SESSION_ID_SCHEMA_PARAMETER, CLIENT_UNDO_REDO_ACTION_GROUP_ID_SCHEMA_PARAMETER, ], @@ -514,6 +525,7 @@ class RowsView(APIView): ) user_field_names = extract_user_field_names_from_params(request.GET) + send_webhook_events = extract_send_webhook_events_from_params(request.GET) model = table.get_model() @@ -537,6 +549,7 @@ class RowsView(APIView): model=model, before_row=before_row, user_field_names=user_field_names, + send_webhook_events=send_webhook_events, ) except ValidationError as e: raise RequestBodyValidationException(detail=e.message) @@ -771,6 +784,16 @@ class RowView(APIView): "field names (e.g., field_123)." ), ), + OpenApiParameter( + name="send_webhook_events", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.BOOL, + description=( + "A flag query parameter that triggers webhooks after the operation," + " if set to `y`, `yes`, `true`, `t`, `on`, `1`, `or` left empty. " + "Defaults to `true`" + ), + ), CLIENT_SESSION_ID_SCHEMA_PARAMETER, CLIENT_UNDO_REDO_ACTION_GROUP_ID_SCHEMA_PARAMETER, ], @@ -833,6 +856,7 @@ class RowView(APIView): TokenHandler().check_table_permissions(request, "update", table, False) user_field_names = extract_user_field_names_from_params(request.GET) + send_webhook_events = extract_send_webhook_events_from_params(request.GET) field_ids, field_names = None, None if user_field_names: @@ -852,7 +876,11 @@ class RowView(APIView): try: data["id"] = int(row_id) row = action_type_registry.get_by_type(UpdateRowsActionType).do( - request.user, table, [data], model + request.user, + table, + [data], + model=model, + send_webhook_events=send_webhook_events, )[0] except ValidationError as exc: raise RequestBodyValidationException(detail=exc.message) from exc @@ -877,6 +905,16 @@ class RowView(APIView): type=OpenApiTypes.INT, description="Deletes the row related to the value.", ), + OpenApiParameter( + name="send_webhook_events", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.BOOL, + description=( + "A flag query parameter that triggers webhooks after the operation," + " if set to `y`, `yes`, `true`, `t`, `on`, `1`, `or` left empty. " + "Defaults to `true`" + ), + ), CLIENT_SESSION_ID_SCHEMA_PARAMETER, CLIENT_UNDO_REDO_ACTION_GROUP_ID_SCHEMA_PARAMETER, ], @@ -913,11 +951,13 @@ class RowView(APIView): table_id. """ + send_webhook_events = extract_send_webhook_events_from_params(request.GET) + table = TableHandler().get_table(table_id) TokenHandler().check_table_permissions(request, "delete", table, False) action_type_registry.get_by_type(DeleteRowActionType).do( - request.user, table, row_id + request.user, table, row_id, send_webhook_events=send_webhook_events ) return Response(status=204) @@ -961,6 +1001,16 @@ class RowMoveView(APIView): "field names (e.g., field_123)." ), ), + OpenApiParameter( + name="send_webhook_events", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.BOOL, + description=( + "A flag query parameter that triggers webhooks after the operation," + " if set to `y`, `yes`, `true`, `t`, `on`, `1`, `or` left empty. " + "Defaults to `true`" + ), + ), CLIENT_SESSION_ID_SCHEMA_PARAMETER, CLIENT_UNDO_REDO_ACTION_GROUP_ID_SCHEMA_PARAMETER, ], @@ -1001,6 +1051,7 @@ class RowMoveView(APIView): TokenHandler().check_table_permissions(request, "update", table, False) user_field_names = extract_user_field_names_from_params(request.GET) + send_webhook_events = extract_send_webhook_events_from_params(request.GET) model = table.get_model() @@ -1014,7 +1065,12 @@ class RowMoveView(APIView): ) row = action_type_registry.get_by_type(MoveRowActionType).do( - request.user, table, row_id, before_row=before_row, model=model + request.user, + table, + row_id, + before_row=before_row, + model=model, + send_webhook_events=send_webhook_events, ) serializer_class = get_row_serializer_class( @@ -1055,6 +1111,16 @@ class BatchRowsView(APIView): "field names (e.g., field_123)." ), ), + OpenApiParameter( + name="send_webhook_events", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.BOOL, + description=( + "A flag query parameter that triggers webhooks after the operation," + " if set to `y`, `yes`, `true`, `t`, `on`, `1`, `or` left empty. " + "Defaults to `true`" + ), + ), CLIENT_SESSION_ID_SCHEMA_PARAMETER, CLIENT_UNDO_REDO_ACTION_GROUP_ID_SCHEMA_PARAMETER, ], @@ -1120,6 +1186,7 @@ class BatchRowsView(APIView): model = table.get_model() user_field_names = extract_user_field_names_from_params(request.GET) + send_webhook_events = extract_send_webhook_events_from_params(request.GET) before_id = query_params.get("before") before_row = ( RowHandler().get_row(request.user, table, before_id, model) @@ -1139,7 +1206,12 @@ class BatchRowsView(APIView): try: rows = action_type_registry.get_by_type(CreateRowsActionType).do( - request.user, table, data["items"], before_row, model + request.user, + table, + data["items"], + before_row, + model=model, + send_webhook_events=send_webhook_events, ) except ValidationError as exc: raise RequestBodyValidationException(detail=exc.message) @@ -1173,6 +1245,16 @@ class BatchRowsView(APIView): "field names (e.g., field_123)." ), ), + OpenApiParameter( + name="send_webhook_events", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.BOOL, + description=( + "A flag query parameter that triggers webhooks after the operation," + " if set to `y`, `yes`, `true`, `t`, `on`, `1`, `or` left empty. " + "Defaults to `true`" + ), + ), CLIENT_SESSION_ID_SCHEMA_PARAMETER, CLIENT_UNDO_REDO_ACTION_GROUP_ID_SCHEMA_PARAMETER, ], @@ -1237,6 +1319,7 @@ class BatchRowsView(APIView): model = table.get_model() user_field_names = extract_user_field_names_from_params(request.GET) + send_webhook_events = extract_send_webhook_events_from_params(request.GET) row_validation_serializer = get_row_serializer_class( model, @@ -1253,7 +1336,11 @@ class BatchRowsView(APIView): try: rows = action_type_registry.get_by_type(UpdateRowsActionType).do( - request.user, table, data["items"], model + request.user, + table, + data["items"], + model=model, + send_webhook_events=send_webhook_events, ) except ValidationError as e: raise RequestBodyValidationException(detail=e.message) @@ -1280,6 +1367,16 @@ class BatchDeleteRowsView(APIView): type=OpenApiTypes.INT, description="Deletes the rows in the table related to the value.", ), + OpenApiParameter( + name="send_webhook_events", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.BOOL, + description=( + "A flag query parameter that triggers webhooks after the operation," + " if set to `y`, `yes`, `true`, `t`, `on`, `1`, `or` left empty. " + "Defaults to `true`" + ), + ), CLIENT_SESSION_ID_SCHEMA_PARAMETER, CLIENT_UNDO_REDO_ACTION_GROUP_ID_SCHEMA_PARAMETER, ], @@ -1327,10 +1424,13 @@ class BatchDeleteRowsView(APIView): table = TableHandler().get_table(table_id) TokenHandler().check_table_permissions(request, "delete", table, False) + send_webhook_events = extract_send_webhook_events_from_params(request.GET) + action_type_registry.get_by_type(DeleteRowsActionType).do( request.user, table, row_ids=data["items"], + send_webhook_events=send_webhook_events, ) return Response(status=204) diff --git a/backend/src/baserow/contrib/database/api/utils.py b/backend/src/baserow/contrib/database/api/utils.py index bd90b898f..e0005ad0a 100644 --- a/backend/src/baserow/contrib/database/api/utils.py +++ b/backend/src/baserow/contrib/database/api/utils.py @@ -174,6 +174,20 @@ def extract_user_field_names_from_params(query_params): return str_to_bool(value) +def extract_send_webhook_events_from_params(query_params) -> bool: + """ + Extracts the send_webhook_events parameter from the query_params and returns + boolean value. Defaults to true if not provided or empty. + """ + + value = query_params.get("send_webhook_events") + + if value is None or value == "": + return True + + return str_to_bool(value) + + @dataclass class LinkedTargetField: field_id: int diff --git a/backend/src/baserow/contrib/database/rows/actions.py b/backend/src/baserow/contrib/database/rows/actions.py index bfac48e42..e52088efa 100755 --- a/backend/src/baserow/contrib/database/rows/actions.py +++ b/backend/src/baserow/contrib/database/rows/actions.py @@ -58,6 +58,7 @@ class CreateRowActionType(UndoableActionType): model: Optional[Type[GeneratedTableModel]] = None, before_row: Optional[GeneratedTableModel] = None, user_field_names: bool = False, + send_webhook_events: bool = True, ) -> GeneratedTableModel: """ Creates a new row for a given table with the provided values if the user @@ -76,6 +77,8 @@ class CreateRowActionType(UndoableActionType): instance. :param user_field_names: Whether or not the values are keyed by the internal Baserow field name (field_1,field_2 etc) or by the user field names. + :param send_webhook_events: If set the false then the webhooks will not be + triggered. Defaults to true. :return: The created row instance. """ @@ -91,6 +94,7 @@ class CreateRowActionType(UndoableActionType): model=model, before_row=before_row, user_field_names=user_field_names, + send_webhook_events=send_webhook_events, ) workspace = table.database.workspace @@ -148,6 +152,7 @@ class CreateRowsActionType(UndoableActionType): rows_values: List[Dict[str, Any]], before_row: Optional[GeneratedTableModel] = None, model: Optional[Type[GeneratedTableModel]] = None, + send_webhook_events: bool = True, ) -> List[GeneratedTableModel]: """ Creates rows for a given table with the provided values if the user @@ -163,6 +168,8 @@ class CreateRowsActionType(UndoableActionType): the row with this id. :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. + :param send_webhook_events: If set the false then the webhooks will not be + triggered. Defaults to true. :return: The created list of rows instances. """ @@ -177,6 +184,7 @@ class CreateRowsActionType(UndoableActionType): rows_values, before_row=before_row, model=model, + send_webhook_events=send_webhook_events, ) workspace = table.database.workspace @@ -327,6 +335,7 @@ class DeleteRowActionType(UndoableActionType): table: Table, row_id: int, model: Optional[Type[GeneratedTableModel]] = None, + send_webhook_events: bool = True, ): """ Deletes an existing row of the given table and with row_id. @@ -339,6 +348,8 @@ class DeleteRowActionType(UndoableActionType): :param row_id: The id of the row that must be deleted. :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. + :param send_webhook_events: If set the false then the webhooks will not be + triggered. Defaults to true. :raises RowDoesNotExist: When the row with the provided id does not exist. """ @@ -347,7 +358,9 @@ class DeleteRowActionType(UndoableActionType): "Can't delete rows because it has a data sync." ) - RowHandler().delete_row_by_id(user, table, row_id, model=model) + RowHandler().delete_row_by_id( + user, table, row_id, model=model, send_webhook_events=send_webhook_events + ) database = table.database params = cls.Params(table.id, table.name, database.id, database.name, row_id) @@ -399,6 +412,7 @@ class DeleteRowsActionType(UndoableActionType): table: Table, row_ids: List[int], model: Optional[Type[GeneratedTableModel]] = None, + send_webhook_events: bool = True, ): """ Deletes rows of the given table with the given row_ids. @@ -411,6 +425,8 @@ class DeleteRowsActionType(UndoableActionType): :param row_ids: The id of the row that must be deleted. :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. + :param send_webhook_events: If set the false then the webhooks will not be + triggered. Defaults to true. :raises RowDoesNotExist: When the row with the provided id does not exist. """ @@ -419,7 +435,9 @@ class DeleteRowsActionType(UndoableActionType): "Can't delete rows because it has a data sync." ) - trashed_rows_entry = RowHandler().delete_rows(user, table, row_ids, model=model) + trashed_rows_entry = RowHandler().delete_rows( + user, table, row_ids, model=model, send_webhook_events=send_webhook_events + ) workspace = table.database.workspace params = cls.Params( @@ -548,6 +566,7 @@ class MoveRowActionType(UndoableActionType): row_id: int, before_row: Optional[GeneratedTableModel] = None, model: Optional[Type[GeneratedTableModel]] = None, + send_webhook_events: bool = True, ) -> GeneratedTableModelForUpdate: """ Moves the row before another row or to the end if no before row is provided. @@ -566,6 +585,8 @@ class MoveRowActionType(UndoableActionType): instance. Otherwise the row will be moved to the end. :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. + :param send_webhook_events: If set the false then the webhooks will not be + triggered. Defaults to true. """ if model is None: @@ -577,7 +598,12 @@ class MoveRowActionType(UndoableActionType): original_row_order = row.order updated_row = row_handler.move_row( - user, table, row, before_row=before_row, model=model + user, + table, + row, + before_row=before_row, + model=model, + send_webhook_events=send_webhook_events, ) rows_displacement = get_rows_displacement( @@ -762,6 +788,7 @@ class UpdateRowsActionType(UndoableActionType): table: Table, rows_values: List[Dict[str, Any]], model: Optional[Type[GeneratedTableModel]] = None, + send_webhook_events: bool = True, ) -> List[GeneratedTableModelForUpdate]: """ Updates field values in batch based on provided rows with the new values. @@ -776,6 +803,8 @@ class UpdateRowsActionType(UndoableActionType): field ids plus the id of the row. :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. + :param send_webhook_events: If set the false then the webhooks will not be + triggered. Defaults to true. :return: The updated rows. """ @@ -786,6 +815,7 @@ class UpdateRowsActionType(UndoableActionType): table, rows_values, model=model, + send_webhook_events=send_webhook_events, ) updated_rows = result.updated_rows diff --git a/backend/src/baserow/contrib/database/rows/handler.py b/backend/src/baserow/contrib/database/rows/handler.py index 4363167ec..2c5de2b93 100644 --- a/backend/src/baserow/contrib/database/rows/handler.py +++ b/backend/src/baserow/contrib/database/rows/handler.py @@ -667,6 +667,7 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)): before_row: Optional[GeneratedTableModel] = None, user_field_names: bool = False, values_already_prepared: bool = False, + send_webhook_events: bool = True, ) -> GeneratedTableModel: """ Creates a new row for a given table with the provided values if the user @@ -685,6 +686,8 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)): :param values_already_prepared: Whether or not the values are already sanitized and validated for every field and can be used directly by the handler without any further check. + :param send_webhook_events: If set the false then the webhooks will not be + triggered. Defaults to true. :return: The created row instance. """ @@ -706,6 +709,7 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)): before_row, user_field_names, values_already_prepared=values_already_prepared, + send_webhook_events=send_webhook_events, ) def force_create_row( @@ -717,6 +721,7 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)): before: Optional[GeneratedTableModel] = None, user_field_names: bool = False, values_already_prepared: bool = False, + send_webhook_events: bool = True, ): """ Creates a new row for a given table with the provided values. @@ -735,6 +740,8 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)): :param values_already_prepared: Whether or not the values are already sanitized and validated for every field and can be used directly by the handler without any further check. + :param send_webhook_events: If set the false then the webhooks will not be + triggered. Defaults to true. :return: The created row instance. :rtype: Model """ @@ -806,7 +813,7 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)): table=table, model=model, send_realtime_update=True, - send_webhook_events=True, + send_webhook_events=send_webhook_events, rows_values_refreshed_from_db=False, m2m_change_tracker=m2m_change_tracker, ) @@ -2028,6 +2035,7 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)): row: GeneratedTableModelForUpdate, before_row: Optional[GeneratedTableModel] = None, model: Optional[Type[GeneratedTableModel]] = None, + send_webhook_events: bool = True, ) -> GeneratedTableModelForUpdate: """ Updates the row order value. @@ -2039,6 +2047,8 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)): instance. Otherwise the row will be moved to the end. :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. + :param send_webhook_events: If set the false then the webhooks will not be + triggered. Defaults to true. """ workspace = table.database.workspace @@ -2090,6 +2100,7 @@ class RowHandler(metaclass=baserow_trace_methods(tracer)): before_return=before_return, updated_field_ids=[], prepared_rows_values=None, + send_webhook_events=send_webhook_events, ) return row 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 0fd4e6008..f01a8f3da 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,4 +1,5 @@ from decimal import Decimal +from unittest.mock import patch from django.conf import settings from django.db import connection @@ -289,6 +290,57 @@ def test_batch_create_rows(api_client, data_fixture): assert row_2.needs_background_update +@pytest.mark.django_db(transaction=True) +@pytest.mark.api_rows +def test_batch_create_rows_with_disabled_webhook_events(api_client, data_fixture): + user, jwt_token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + text_field = data_fixture.create_text_field( + table=table, order=0, name="Color", text_default="white" + ) + number_field = data_fixture.create_number_field( + table=table, order=1, name="Horsepower" + ) + boolean_field = data_fixture.create_boolean_field( + table=table, order=2, name="For sale" + ) + + data_fixture.create_table_webhook( + table=table, + user=user, + request_method="POST", + url="http://localhost", + events=[], + ) + + url = reverse("api:database:rows:batch", kwargs={"table_id": table.id}) + request_body = { + "items": [ + { + f"field_{text_field.id}": "green", + f"field_{number_field.id}": 120, + f"field_{boolean_field.id}": True, + }, + { + f"field_{text_field.id}": "yellow", + f"field_{number_field.id}": 240, + f"field_{boolean_field.id}": False, + }, + ] + } + + with patch("baserow.contrib.database.webhooks.registries.call_webhook.delay") as m: + response = api_client.post( + f"{url}?send_webhook_events=false", + request_body, + format="json", + HTTP_AUTHORIZATION=f"JWT {jwt_token}", + ) + + assert response.status_code == HTTP_200_OK + m.assert_not_called() + + @pytest.mark.django_db @pytest.mark.api_rows def test_batch_create_rows_id_field_ignored(api_client, data_fixture): @@ -1184,6 +1236,62 @@ def test_batch_update_rows(api_client, data_fixture): assert row_2.needs_background_update +@pytest.mark.django_db(transaction=True) +@pytest.mark.api_rows +def test_batch_update_rows_with_disabled_webhook_events(api_client, data_fixture): + user, jwt_token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + text_field = data_fixture.create_text_field( + table=table, order=0, name="Color", text_default="white" + ) + number_field = data_fixture.create_number_field( + table=table, order=1, name="Horsepower" + ) + boolean_field = data_fixture.create_boolean_field( + table=table, order=2, name="For sale" + ) + model = table.get_model() + row_1 = model.objects.create() + row_2 = model.objects.create() + model.objects.update(needs_background_update=False) + + data_fixture.create_table_webhook( + table=table, + user=user, + request_method="POST", + url="http://localhost", + events=[], + ) + + url = reverse("api:database:rows:batch", kwargs={"table_id": table.id}) + request_body = { + "items": [ + { + f"id": row_1.id, + f"field_{text_field.id}": "green", + f"field_{number_field.id}": 120, + f"field_{boolean_field.id}": True, + }, + { + f"id": row_2.id, + f"field_{text_field.id}": "yellow", + f"field_{number_field.id}": 240, + f"field_{boolean_field.id}": False, + }, + ] + } + + with patch("baserow.contrib.database.webhooks.registries.call_webhook.delay") as m: + response = api_client.patch( + f"{url}?send_webhook_events=false", + request_body, + format="json", + HTTP_AUTHORIZATION=f"JWT {jwt_token}", + ) + assert response.status_code == HTTP_200_OK + m.assert_not_called() + + @pytest.mark.django_db @pytest.mark.api_rows def test_batch_update_rows_last_modified_field(api_client, data_fixture): @@ -2275,3 +2383,34 @@ def test_batch_delete_rows_num_of_queries(api_client, data_fixture): assert len(delete_one_row_ctx.captured_queries) == len( delete_multiple_rows_ctx.captured_queries ) + + +@pytest.mark.django_db(transaction=True) +@pytest.mark.api_rows +def test_batch_delete_rows_disabled_webhook_events(api_client, data_fixture): + user, jwt_token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + model = table.get_model() + row_1 = model.objects.create() + row_2 = model.objects.create() + model.objects.create() + url = reverse("api:database:rows:batch-delete", kwargs={"table_id": table.id}) + request_body = {"items": [row_1.id, row_2.id]} + + data_fixture.create_table_webhook( + table=table, + user=user, + request_method="POST", + url="http://localhost", + events=[], + ) + + with patch("baserow.contrib.database.webhooks.registries.call_webhook.delay") as m: + response = api_client.post( + f"{url}?send_webhook_events=false", + request_body, + format="json", + HTTP_AUTHORIZATION=f"JWT {jwt_token}", + ) + assert response.status_code == HTTP_204_NO_CONTENT + m.assert_not_called() 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 22675c10d..c8b4a8789 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,6 +1,7 @@ import json from datetime import datetime, timedelta, timezone from decimal import Decimal +from unittest.mock import patch from urllib.parse import quote from django.db import connection @@ -1840,6 +1841,35 @@ def test_create_row(api_client, data_fixture): } +@pytest.mark.django_db(transaction=True) +def test_create_row_with_disabled_webhook_events(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + text_field = data_fixture.create_text_field( + table=table, order=0, name="Color", text_default="white" + ) + + data_fixture.create_table_webhook( + table=table, + user=user, + request_method="POST", + url="http://localhost", + events=[], + ) + + url = reverse("api:database:rows:list", kwargs={"table_id": table.id}) + + with patch("baserow.contrib.database.webhooks.registries.call_webhook.delay") as m: + response = api_client.post( + f"{url}?send_webhook_events=false", + {f"field_{text_field.id}": "Test 1"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + m.assert_not_called() + + @pytest.mark.django_db def test_create_row_with_read_only_field(api_client, data_fixture): user, jwt_token = data_fixture.create_user_and_token() @@ -2290,6 +2320,40 @@ def test_update_row(api_client, data_fixture): assert getattr(row_2, f"field_{boolean_field.id}") is False +@pytest.mark.django_db(transaction=True) +def test_update_row_with_disabled_webhook_events(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + text_field = data_fixture.create_text_field( + table=table, order=0, name="Color", text_default="white" + ) + + model = table.get_model() + row_1 = model.objects.create() + + data_fixture.create_table_webhook( + table=table, + user=user, + request_method="POST", + url="http://localhost", + events=[], + ) + + url = reverse( + "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row_1.id} + ) + + with patch("baserow.contrib.database.webhooks.registries.call_webhook.delay") as m: + response = api_client.patch( + f"{url}?send_webhook_events=false", + {f"field_{text_field.id}": "Test 1"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + m.assert_not_called() + + @pytest.mark.django_db def test_update_row_with_read_only_field(api_client, data_fixture): user, jwt_token = data_fixture.create_user_and_token() @@ -2472,6 +2536,40 @@ def test_move_row(api_client, data_fixture): ) +@pytest.mark.django_db(transaction=True) +def test_move_row_with_disabled_webhook_events(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field( + table=table, order=0, name="Color", text_default="white" + ) + + model = table.get_model() + row_1 = model.objects.create() + row_2 = model.objects.create() + + data_fixture.create_table_webhook( + table=table, + user=user, + request_method="POST", + url="http://localhost", + events=[], + ) + + url = reverse( + "api:database:rows:move", kwargs={"table_id": table.id, "row_id": row_2.id} + ) + + with patch("baserow.contrib.database.webhooks.registries.call_webhook.delay") as m: + response = api_client.patch( + f"{url}?before_id={row_1.id}&send_webhook_events=false", + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + m.assert_not_called() + + @pytest.mark.django_db def test_cannot_delete_row_by_id_with_data_sync(api_client, data_fixture): user, jwt_token = data_fixture.create_user_and_token() @@ -2566,6 +2664,39 @@ def test_delete_row_by_id(api_client, data_fixture): assert model.objects.count() == 0 +@pytest.mark.django_db(transaction=True) +def test_delete_row_by_id_with_disabled_webhook_events(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field( + table=table, order=0, name="Color", text_default="white" + ) + + model = table.get_model() + row_1 = model.objects.create() + + data_fixture.create_table_webhook( + table=table, + user=user, + request_method="POST", + url="http://localhost", + events=[], + ) + + url = reverse( + "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row_1.id} + ) + + with patch("baserow.contrib.database.webhooks.registries.call_webhook.delay") as m: + response = api_client.delete( + f"{url}?send_webhook_events=false", + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_204_NO_CONTENT + m.assert_not_called() + + @pytest.mark.django_db def test_list_rows_with_attribute_names(api_client, data_fixture): user, jwt_token = data_fixture.create_user_and_token( diff --git a/backend/tests/baserow/contrib/database/api/views/form/test_form_view_views.py b/backend/tests/baserow/contrib/database/api/views/form/test_form_view_views.py index bf38bdff0..43bd78d6e 100644 --- a/backend/tests/baserow/contrib/database/api/views/form/test_form_view_views.py +++ b/backend/tests/baserow/contrib/database/api/views/form/test_form_view_views.py @@ -229,6 +229,7 @@ def test_create_form_view_with_webhooks(api_client, data_fixture): HTTP_AUTHORIZATION=f"JWT {token}", ) assert m.called + print("CALL ARGS", m.call_args) response_json = response.json() assert response.status_code == HTTP_200_OK diff --git a/changelog/entries/unreleased/feature/3085_prevent_triggering_webhook_if_query_param_is_provided_on_row.json b/changelog/entries/unreleased/feature/3085_prevent_triggering_webhook_if_query_param_is_provided_on_row.json new file mode 100644 index 000000000..d7b27a4d4 --- /dev/null +++ b/changelog/entries/unreleased/feature/3085_prevent_triggering_webhook_if_query_param_is_provided_on_row.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "Prevent triggering webhook if query param is provided on row create update or delete endpoints", + "issue_number": 3085, + "bullet_points": [], + "created_at": "2024-11-19" +} diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index 7733dc82f..6f1cb11d2 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -432,6 +432,7 @@ "pathParameters": "Path parameters", "requestBodySchema": "Request body schema", "userFieldNamesDescription": "When the `user_field_names` GET parameter is provided and its value is one of the following: `y`, `yes`, `true`, `t`, `on`, `1`, or empty string, the field names returned by this endpoint will be the actual names of the fields.\n\nIf the `user_field_names` GET parameter is not provided, or if it does not match any of the above values, then all returned field names will be `field_` followed by the id of the field. For example `field_1` refers to the field with an id of `1`.", + "sendWebhookEventsDescription": "A flag query parameter that triggers webhooks after the operation, if set to `y`, `yes`, `true`, `t`, `on`, `1`, `or` left empty. Defaults to `true`", "singleRow": "Single", "batchRows": "Batch", "fileUploads": "File uploads" diff --git a/web-frontend/modules/database/components/docs/sections/APIDocsTableCreateRow.vue b/web-frontend/modules/database/components/docs/sections/APIDocsTableCreateRow.vue index 7e503ef93..cc61fe38c 100644 --- a/web-frontend/modules/database/components/docs/sections/APIDocsTableCreateRow.vue +++ b/web-frontend/modules/database/components/docs/sections/APIDocsTableCreateRow.vue @@ -39,6 +39,16 @@ <APIDocsParameter :optional="true" name="before" type="integer"> {{ $t('apiDocsTableCreateRow.before') }} </APIDocsParameter> + <APIDocsParameter + name="send_webhook_events" + :optional="true" + type="any" + > + <MarkdownIt + class="api-docs__content" + :content="$t('apiDocs.sendWebhookEventsDescription')" + /> + </APIDocsParameter> </ul> <h4 class="api-docs__heading-4"> {{ $t('apiDocs.requestBodySchema') }} @@ -72,6 +82,16 @@ <APIDocsParameter :optional="true" name="before" type="integer"> {{ $t('apiDocsTableCreateRows.before') }} </APIDocsParameter> + <APIDocsParameter + name="send_webhook_events" + :optional="true" + type="any" + > + <MarkdownIt + class="api-docs__content" + :content="$t('apiDocs.sendWebhookEventsDescription')" + /> + </APIDocsParameter> </ul> <h4 class="api-docs__heading-4"> {{ $t('apiDocs.requestBodySchema') }} diff --git a/web-frontend/modules/database/components/docs/sections/APIDocsTableDeleteRow.vue b/web-frontend/modules/database/components/docs/sections/APIDocsTableDeleteRow.vue index 3865eee65..17a004850 100644 --- a/web-frontend/modules/database/components/docs/sections/APIDocsTableDeleteRow.vue +++ b/web-frontend/modules/database/components/docs/sections/APIDocsTableDeleteRow.vue @@ -33,7 +33,20 @@ </APIDocsParameter> </ul> </div> - <div v-else> + <h4 class="api-docs__heading-4">{{ $t('apiDocs.queryParameters') }}</h4> + <ul class="api-docs__parameters"> + <APIDocsParameter + name="send_webhook_events" + :optional="true" + type="any" + > + <MarkdownIt + class="api-docs__content" + :content="$t('apiDocs.sendWebhookEventsDescription')" + /> + </APIDocsParameter> + </ul> + <div v-if="batchMode === true"> <h4 class="api-docs__heading-4"> {{ $t('apiDocs.requestBodySchema') }} </h4> diff --git a/web-frontend/modules/database/components/docs/sections/APIDocsTableMoveRow.vue b/web-frontend/modules/database/components/docs/sections/APIDocsTableMoveRow.vue index cae086d5a..958ee15d6 100644 --- a/web-frontend/modules/database/components/docs/sections/APIDocsTableMoveRow.vue +++ b/web-frontend/modules/database/components/docs/sections/APIDocsTableMoveRow.vue @@ -28,6 +28,16 @@ <APIDocsParameter name="before_id" type="integer" :optional="true"> {{ $t('apiDocsTableMoveRow.before') }} </APIDocsParameter> + <APIDocsParameter + name="send_webhook_events" + :optional="true" + type="any" + > + <MarkdownIt + class="api-docs__content" + :content="$t('apiDocs.sendWebhookEventsDescription')" + /> + </APIDocsParameter> </ul> </div> <div class="api-docs__right"> diff --git a/web-frontend/modules/database/components/docs/sections/APIDocsTableUpdateRow.vue b/web-frontend/modules/database/components/docs/sections/APIDocsTableUpdateRow.vue index 015ff136c..cd4d81d76 100644 --- a/web-frontend/modules/database/components/docs/sections/APIDocsTableUpdateRow.vue +++ b/web-frontend/modules/database/components/docs/sections/APIDocsTableUpdateRow.vue @@ -40,6 +40,16 @@ :content="$t('apiDocs.userFieldNamesDescription')" /> </APIDocsParameter> + <APIDocsParameter + name="send_webhook_events" + :optional="true" + type="any" + > + <MarkdownIt + class="api-docs__content" + :content="$t('apiDocs.sendWebhookEventsDescription')" + /> + </APIDocsParameter> </ul> <h4 class="api-docs__heading-4"> {{ $t('apiDocs.requestBodySchema') }} @@ -68,6 +78,16 @@ :content="$t('apiDocs.userFieldNamesDescription')" /> </APIDocsParameter> + <APIDocsParameter + name="send_webhook_events" + :optional="true" + type="any" + > + <MarkdownIt + class="api-docs__content" + :content="$t('apiDocs.sendWebhookEventsDescription')" + /> + </APIDocsParameter> </ul> <h4 class="api-docs__heading-4"> {{ $t('apiDocs.requestBodySchema') }}