diff --git a/backend/src/baserow/contrib/database/api/views/form/errors.py b/backend/src/baserow/contrib/database/api/views/form/errors.py index 57b0cb3f9b..204766642e 100644 --- a/backend/src/baserow/contrib/database/api/views/form/errors.py +++ b/backend/src/baserow/contrib/database/api/views/form/errors.py @@ -35,3 +35,8 @@ HTTP_400_BAD_REQUEST, "The provided form view field options condition group does not exists.", ) +ERROR_INVALID_EDIT_ROW_TOKEN = ( + "ERROR_INVALID_EDIT_ROW_TOKEN", + HTTP_404_NOT_FOUND, + "The provided edit token is invalid or does not match the requested row.", +) diff --git a/backend/src/baserow/contrib/database/api/views/form/exceptions.py b/backend/src/baserow/contrib/database/api/views/form/exceptions.py index c28e5cb820..92e94938c2 100644 --- a/backend/src/baserow/contrib/database/api/views/form/exceptions.py +++ b/backend/src/baserow/contrib/database/api/views/form/exceptions.py @@ -7,3 +7,7 @@ class FormViewFieldOptionsConditionGroupDoesNotExist(Exception): Raised when the provided form view field options condition group does not exists. """ + + +class InvalidEditRowTokenError(Exception): + """Raised when the provided edit row token is invalid or does not match.""" diff --git a/backend/src/baserow/contrib/database/api/views/form/urls.py b/backend/src/baserow/contrib/database/api/views/form/urls.py index 74a8346fcd..c79c7696fc 100644 --- a/backend/src/baserow/contrib/database/api/views/form/urls.py +++ b/backend/src/baserow/contrib/database/api/views/form/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from .views import FormUploadFileView, SubmitFormViewView +from .views import EditRowFormViewView, FormUploadFileView, SubmitFormViewView app_name = "baserow.contrib.database.api.views.form" @@ -15,4 +15,9 @@ FormUploadFileView.as_view(), name="upload_file", ), + re_path( + r"(?P[-\w]+)/edit-row/(?P[^/]+)/$", + EditRowFormViewView.as_view(), + name="edit_row", + ), ] diff --git a/backend/src/baserow/contrib/database/api/views/form/utils.py b/backend/src/baserow/contrib/database/api/views/form/utils.py new file mode 100644 index 0000000000..398ad50959 --- /dev/null +++ b/backend/src/baserow/contrib/database/api/views/form/utils.py @@ -0,0 +1,80 @@ +from typing import Tuple + +from rest_framework.fields import empty + +from baserow.contrib.database.fields.models import FormViewEditRowField +from baserow.contrib.database.fields.utils.row_edit import verify_and_decode_edit_token +from baserow.contrib.database.views.models import FormView +from baserow.contrib.database.views.validators import ( + allow_only_specific_select_options_factory, + no_empty_form_values_when_required_validator, +) + +from .exceptions import InvalidEditRowTokenError + + +def decode_and_validate_edit_token(form: FormView, token: str) -> Tuple[str, int]: + """ + Decode the edit token and validate it against the given form view. + + The token payload must contain `view_slug`, `field_id`, and + `cell_uuid`. Validation checks: + + 1. The token signature is valid. + 2. The `view_slug` matches the form view's current slug (rotating the + slug invalidates all existing tokens). + 3. The `field_id` references a `FormViewEditRowField` linked to this + form view. + + :param form: The form view the token must belong to. + :param token: The signed edit token string. + :raises InvalidEditRowTokenError: If the token is missing, invalid, or + does not match the form view. + :return: A (cell_uuid, field_id) tuple extracted from the valid token. + """ + + if not token: + raise InvalidEditRowTokenError() + + data = verify_and_decode_edit_token(token) + if data is None or data.get("view_slug") != form.slug: + raise InvalidEditRowTokenError() + + field_id = data.get("field_id") + if not FormViewEditRowField.objects.filter(id=field_id, form_view=form).exists(): + raise InvalidEditRowTokenError() + + return data["cell_uuid"], field_id + + +def build_field_kwargs_for_options(model, options, enforce_required=False): + """ + Builds `field_kwargs` for the row serializer based on the form view's + active field options. + + When *enforce_required* is ``True`` (used by the submit endpoint), fields + marked as required will get ``required=True`` and the "not empty" validator. + """ + + field_kwargs = {} + for option in options: + validators = [] + o = {} + if enforce_required and option.is_required(): + o["required"] = True + o["default"] = empty + validators.append(no_empty_form_values_when_required_validator) + if not option.include_all_select_options: + validators.append( + allow_only_specific_select_options_factory( + [ + allowed_select_option.id + for allowed_select_option in option.allowed_select_options.all() + ] + ) + ) + if len(validators) > 0 and len(o) > 0: + name = model._field_objects[option.field_id]["name"] + o["validators"] = validators + field_kwargs[name] = o + return field_kwargs diff --git a/backend/src/baserow/contrib/database/api/views/form/views.py b/backend/src/baserow/contrib/database/api/views/form/views.py index 9b626e8fd5..cda0ebb894 100644 --- a/backend/src/baserow/contrib/database/api/views/form/views.py +++ b/backend/src/baserow/contrib/database/api/views/form/views.py @@ -1,8 +1,8 @@ +from django.core.exceptions import ValidationError from django.db import transaction from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes from drf_spectacular.utils import extend_schema -from rest_framework.fields import empty from rest_framework.parsers import MultiPartParser from rest_framework.permissions import AllowAny from rest_framework.request import Request @@ -15,7 +15,10 @@ from baserow.api.user_files.serializers import UserFileSerializer from baserow.api.utils import validate_data from baserow.contrib.database.api.fields.errors import ERROR_FIELD_DATA_CONSTRAINT -from baserow.contrib.database.api.rows.errors import ERROR_CANNOT_CREATE_ROWS_IN_TABLE +from baserow.contrib.database.api.rows.errors import ( + ERROR_CANNOT_CREATE_ROWS_IN_TABLE, + ERROR_ROW_DOES_NOT_EXIST, +) from baserow.contrib.database.api.rows.serializers import ( get_example_row_serializer_class, get_row_serializer_class, @@ -26,9 +29,18 @@ ) from baserow.contrib.database.api.views.utils import get_public_view_authorization_token from baserow.contrib.database.fields.exceptions import FieldDataConstraintException -from baserow.contrib.database.fields.models import FileField, LongTextField -from baserow.contrib.database.rows.exceptions import CannotCreateRowsInTable -from baserow.contrib.database.views.actions import SubmitFormActionType +from baserow.contrib.database.fields.models import ( + FileField, + LongTextField, +) +from baserow.contrib.database.rows.exceptions import ( + CannotCreateRowsInTable, + RowDoesNotExist, +) +from baserow.contrib.database.views.actions import ( + EditFormRowActionType, + SubmitFormActionType, +) from baserow.contrib.database.views.exceptions import ( NoAuthorizationToPubliclySharedView, ViewDoesNotExist, @@ -36,10 +48,6 @@ from baserow.contrib.database.views.handler import ViewHandler from baserow.contrib.database.views.models import FormView from baserow.contrib.database.views.registries import view_ownership_type_registry -from baserow.contrib.database.views.validators import ( - allow_only_specific_select_options_factory, - no_empty_form_values_when_required_validator, -) from baserow.core.action.registries import action_type_registry from baserow.core.user_files.exceptions import ( FileSizeTooLargeError, @@ -49,11 +57,16 @@ from .errors import ( ERROR_FORM_DOES_NOT_EXIST, + ERROR_INVALID_EDIT_ROW_TOKEN, ERROR_NO_PERMISSION_TO_PUBLICLY_SHARED_FORM, ERROR_VIEW_HAS_NO_PUBLIC_FILE_FIELD, ) -from .exceptions import ViewHasNoPublicFileFieldError +from .exceptions import ( + InvalidEditRowTokenError, + ViewHasNoPublicFileFieldError, +) from .serializers import FormViewSubmittedSerializer, PublicFormViewSerializer +from .utils import build_field_kwargs_for_options, decode_and_validate_edit_token class SubmitFormViewView(APIView): @@ -146,27 +159,9 @@ def post(self, request: Request, slug: str) -> Response: model = form.table.get_model() options = form.active_field_options - field_kwargs = {} - for option in options: - validators = [] - o = {} - if option.is_required(): - o["required"] = True - o["default"] = empty - validators.append(no_empty_form_values_when_required_validator) - if not option.include_all_select_options: - validators.append( - allow_only_specific_select_options_factory( - [ - allowed_select_option.id - for allowed_select_option in option.allowed_select_options.all() - ] - ) - ) - if len(validators) > 0 and len(o) > 0: - name = model._field_objects[option.field_id]["name"] - o["validators"] = validators - field_kwargs[name] = o + field_kwargs = build_field_kwargs_for_options( + model, options, enforce_required=True + ) field_ids = [option.field_id for option in options] validation_serializer = get_row_serializer_class( @@ -183,6 +178,173 @@ def post(self, request: Request, slug: str) -> Response: return Response(FormViewSubmittedSerializer(form).data) +class EditRowFormViewView(APIView): + permission_classes = (AllowAny,) + + @extend_schema( + parameters=[ + OpenApiParameter( + name="slug", + location=OpenApiParameter.PATH, + type=OpenApiTypes.STR, + required=True, + description="The slug of the form view.", + ), + OpenApiParameter( + name="row_token", + location=OpenApiParameter.PATH, + type=OpenApiTypes.STR, + required=True, + description="The signed edit token that identifies the row to edit.", + ), + ], + tags=["Database table form view"], + operation_id="get_edit_row_database_table_form_view", + description=( + "Returns the current field values of the row identified by the edit token. " + "Only fields visible in the form view are returned. The token must be a " + "valid signed token generated by a form_view_edit_row field." + ), + responses={ + 200: get_example_row_serializer_class(example_type="get"), + 401: get_error_schema( + [ + "ERROR_NO_PERMISSION_TO_PUBLICLY_SHARED_FORM", + ] + ), + 404: get_error_schema( + [ + "ERROR_FORM_DOES_NOT_EXIST", + "ERROR_ROW_DOES_NOT_EXIST", + "ERROR_INVALID_EDIT_ROW_TOKEN", + ] + ), + }, + ) + @map_exceptions( + { + ViewDoesNotExist: ERROR_FORM_DOES_NOT_EXIST, + NoAuthorizationToPubliclySharedView: ERROR_NO_PERMISSION_TO_PUBLICLY_SHARED_FORM, + InvalidEditRowTokenError: ERROR_INVALID_EDIT_ROW_TOKEN, + RowDoesNotExist: ERROR_ROW_DOES_NOT_EXIST, + } + ) + def get(self, request: Request, slug: str, row_token: str) -> Response: + handler = ViewHandler() + form = handler.get_public_view_by_slug( + request.user, + slug, + view_model=FormView, + authorization_token=get_public_view_authorization_token(request), + ) + cell_uuid, field_id = decode_and_validate_edit_token(form, row_token) + + model = form.table.get_model() + field_column = f"field_{field_id}" + + try: + row = model.objects.get(**{field_column: cell_uuid}) + except (model.DoesNotExist, ValidationError): + raise RowDoesNotExist(cell_uuid) + + options = form.active_field_options + field_ids = [option.field_id for option in options] + + serializer_class = get_row_serializer_class( + model, is_response=True, field_ids=field_ids + ) + return Response(serializer_class(row).data) + + @extend_schema( + parameters=[ + OpenApiParameter( + name="slug", + location=OpenApiParameter.PATH, + type=OpenApiTypes.STR, + required=True, + description="The slug of the form view.", + ), + OpenApiParameter( + name="row_token", + location=OpenApiParameter.PATH, + type=OpenApiTypes.STR, + required=True, + description="The signed edit token that identifies the row to edit.", + ), + ], + tags=["Database table form view"], + operation_id="update_edit_row_database_table_form_view", + description=( + "Updates the row identified by the edit token using the submitted field " + "values. Only fields that are visible in the form view can be changed. " + "The `row_token` must be a valid signed token generated by a " + "form_view_edit_row field." + ), + request=get_example_row_serializer_class(example_type="patch"), + responses={ + 200: FormViewSubmittedSerializer, + 401: get_error_schema( + [ + "ERROR_NO_PERMISSION_TO_PUBLICLY_SHARED_FORM", + ] + ), + 400: get_error_schema(["ERROR_FIELD_DATA_CONSTRAINT"]), + 404: get_error_schema( + [ + "ERROR_FORM_DOES_NOT_EXIST", + "ERROR_ROW_DOES_NOT_EXIST", + "ERROR_INVALID_EDIT_ROW_TOKEN", + ] + ), + }, + ) + @map_exceptions( + { + ViewDoesNotExist: ERROR_FORM_DOES_NOT_EXIST, + NoAuthorizationToPubliclySharedView: ERROR_NO_PERMISSION_TO_PUBLICLY_SHARED_FORM, + InvalidEditRowTokenError: ERROR_INVALID_EDIT_ROW_TOKEN, + RowDoesNotExist: ERROR_ROW_DOES_NOT_EXIST, + FieldDataConstraintException: ERROR_FIELD_DATA_CONSTRAINT, + } + ) + @transaction.atomic + def patch(self, request: Request, slug: str, row_token: str) -> Response: + handler = ViewHandler() + form = handler.get_public_view_by_slug( + request.user, + slug, + view_model=FormView, + authorization_token=get_public_view_authorization_token(request), + ) + + cell_uuid, field_id = decode_and_validate_edit_token(form, row_token) + data = request.data + + model = form.table.get_model() + field_column = f"field_{field_id}" + + try: + row = model.objects.get(**{field_column: cell_uuid}) + except (model.DoesNotExist, ValidationError): + raise RowDoesNotExist(cell_uuid) + + options = form.active_field_options + field_kwargs = build_field_kwargs_for_options(model, options) + + field_ids = [option.field_id for option in options] + validation_serializer = get_row_serializer_class( + model, field_ids=field_ids, field_kwargs=field_kwargs + ) + values = validate_data(validation_serializer, data, return_validated=True) + + updated_row = action_type_registry.get_by_type(EditFormRowActionType).do( + request.user, form, row.id, values, model, options + ) + + form.row_id = updated_row.id + return Response(FormViewSubmittedSerializer(form).data) + + class FormUploadFileView(APIView): permission_classes = (AllowAny,) parser_classes = (MultiPartParser,) diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index c4a28c5dae..ecfaf0c746 100755 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -113,6 +113,7 @@ def ready(self): DeleteViewGroupByActionType, DeleteViewSortActionType, DuplicateViewActionType, + EditFormRowActionType, OrderViewsActionType, RotateViewSlugActionType, SubmitFormActionType, @@ -140,6 +141,7 @@ def ready(self): action_type_registry.register(UpdateViewGroupByActionType()) action_type_registry.register(DeleteViewGroupByActionType()) action_type_registry.register(SubmitFormActionType()) + action_type_registry.register(EditFormRowActionType()) action_type_registry.register(RotateViewSlugActionType()) action_type_registry.register(UpdateViewFieldOptionsActionType()) action_type_registry.register(CreateDecorationActionType()) @@ -196,6 +198,7 @@ def ready(self): EmailFieldType, FileFieldType, FormulaFieldType, + FormViewEditRowFieldType, LastModifiedByFieldType, LastModifiedFieldType, LinkRowFieldType, @@ -240,6 +243,7 @@ def ready(self): field_type_registry.register(UUIDFieldType()) field_type_registry.register(AutonumberFieldType()) field_type_registry.register(PasswordFieldType()) + field_type_registry.register(FormViewEditRowFieldType()) from .fields.field_aggregations import ( AverageFieldAggregationType, @@ -287,6 +291,7 @@ def ready(self): AutonumberFieldConverter, FileFieldConverter, FormulaFieldConverter, + FormViewEditRowFieldConverter, LinkRowFieldConverter, MultipleCollaboratorsFieldConverter, MultipleSelectFieldToSingleSelectFieldConverter, @@ -308,6 +313,7 @@ def ready(self): SingleSelectFieldToMultipleSelectFieldConverter() ) field_converter_registry.register(FormulaFieldConverter()) + field_converter_registry.register(FormViewEditRowFieldConverter()) field_converter_registry.register(AutonumberFieldConverter()) field_converter_registry.register(PasswordFieldConverter()) diff --git a/backend/src/baserow/contrib/database/fields/field_converters.py b/backend/src/baserow/contrib/database/fields/field_converters.py index 11946bac3d..c5bb968a35 100644 --- a/backend/src/baserow/contrib/database/fields/field_converters.py +++ b/backend/src/baserow/contrib/database/fields/field_converters.py @@ -12,6 +12,7 @@ AutonumberField, FileField, FormulaField, + FormViewEditRowField, LinkRowField, MultipleCollaboratorsField, MultipleSelectField, @@ -122,6 +123,15 @@ def is_applicable(self, from_model, from_field, to_field): return isinstance(to_field, FormulaField) +class FormViewEditRowFieldConverter(RecreateFieldConverter): + type = "form_view_edit_row" + + def is_applicable(self, from_model, from_field, to_field): + from_edit_row = isinstance(from_field, FormViewEditRowField) + to_edit_row = isinstance(to_field, FormViewEditRowField) + return from_edit_row != to_edit_row + + class AutonumberFieldConverter(RecreateFieldConverter): type = "autonumber" diff --git a/backend/src/baserow/contrib/database/fields/field_helpers.py b/backend/src/baserow/contrib/database/fields/field_helpers.py index 78d4913545..9240d6e3e3 100644 --- a/backend/src/baserow/contrib/database/fields/field_helpers.py +++ b/backend/src/baserow/contrib/database/fields/field_helpers.py @@ -14,6 +14,7 @@ def construct_all_possible_field_kwargs( decimal_link_table, file_link_table, multiple_collaborator_link_table, + form_view, ) -> Dict[str, List[Dict[str, Any]]]: """ Some baserow field types have multiple different 'modes' which result in @@ -299,6 +300,9 @@ def construct_all_possible_field_kwargs( "uuid": [{"name": "uuid"}], "autonumber": [{"name": "autonumber"}], "password": [{"name": "password"}], + "form_view_edit_row": [ + {"name": "form_view_edit_row", "form_view_id": form_view.id} + ], "ai": [ { "name": "ai", diff --git a/backend/src/baserow/contrib/database/fields/field_types.py b/backend/src/baserow/contrib/database/fields/field_types.py index 161b4a74ba..82831793db 100755 --- a/backend/src/baserow/contrib/database/fields/field_types.py +++ b/backend/src/baserow/contrib/database/fields/field_types.py @@ -142,6 +142,7 @@ from baserow.contrib.database.views.models import ( DEFAULT_SORT_TYPE_KEY, OWNERSHIP_TYPE_COLLABORATIVE, + FormView, View, ) from baserow.core.db import ( @@ -206,6 +207,7 @@ from .fields import ( BaserowExpressionField, BaserowLastModifiedField, + FormViewEditRowURLSerializerField, IntegerFieldWithSequence, MultipleSelectManyToManyField, SingleSelectForeignKey, @@ -226,6 +228,7 @@ Field, FileField, FormulaField, + FormViewEditRowField, LastModifiedByField, LastModifiedField, LinkRowField, @@ -263,6 +266,7 @@ prepare_duration_value_for_db, text_value_sql_to_duration, ) +from .utils.row_edit import build_row_edit_url User = get_user_model() @@ -7503,3 +7507,137 @@ def prepare_value_for_row_history(self, value): def is_searchable(self, field: Field) -> bool: # passwords shouldn't be searchable! return False + + +class FormViewEditRowFieldType(ReadOnlyFieldType): + """ + A field type that generates a unique, secure URL per row. When visited, the URL + opens the linked form view pre-filled with that row's current values, allowing the + visitor to edit and save them. + """ + + type = "form_view_edit_row" + model_class = FormViewEditRowField + allowed_fields = ["form_view_id"] + serializer_field_names = ["form_view_id"] + serializer_field_overrides = { + "form_view_id": serializers.IntegerField( + required=False, + allow_null=True, + help_text="The id of the form view used to edit rows via this field.", + ) + } + can_be_in_form_view = False + field_data_is_derived_from_attrs = False + _can_order_by_types = [] + _can_be_primary_field = False + can_get_unique_values = False + _can_have_db_index = True + keep_data_on_duplication = False + api_exceptions_map = { + ViewNotInTable: ERROR_VIEW_NOT_IN_TABLE, + } + + def get_serializer_field(self, instance, **kwargs): + return serializers.UUIDField(required=False, **kwargs) + + def get_response_serializer_field(self, instance, **kwargs): + return FormViewEditRowURLSerializerField(field_instance=instance, **kwargs) + + def get_model_field(self, instance, **kwargs): + return models.UUIDField( + default=uuid.uuid4, + db_default=RandomUUID(), + null=True, + db_index=instance.db_index, + **kwargs, + ) + + def enhance_field_queryset( + self, queryset: QuerySet[Field], field: Field + ) -> QuerySet[Field]: + return queryset.select_related("form_view") + + def before_create( + self, table, primary, allowed_field_values, order, user, field_kwargs + ): + form_view_id = field_kwargs.get("form_view_id") + if form_view_id: + if not FormView.objects.filter(id=form_view_id, table=table).exists(): + raise ViewNotInTable(form_view_id) + + def before_update(self, from_field, to_field_values, user, field_kwargs): + form_view_id = field_kwargs.get("form_view_id") + if form_view_id is not None: + if not FormView.objects.filter( + id=form_view_id, table_id=from_field.table_id + ).exists(): + raise ViewNotInTable(form_view_id) + + def is_searchable(self, field: Field) -> bool: + return False + + def get_export_value(self, value, field_object, rich_value=False): + if not value: + return "" + field_instance = field_object["field"] + form_view = field_instance.form_view + if form_view is None: + return "" + return build_row_edit_url(str(value), form_view, field_instance.id) + + def import_serialized( + self, + table: "Table", + serialized_values: Dict[str, Any], + import_export_config: ImportExportConfig, + id_mapping: Dict[str, Any], + deferred_fk_update_collector: DeferredForeignKeyUpdater, + ) -> Optional[Field]: + """ + Import the field and remap the ``form_view_id`` to the newly created + view via the deferred FK collector. + + :param table: The table the field is being imported into. + :param serialized_values: The exported field attributes. + :param import_export_config: Import/export configuration. + :param id_mapping: Mapping from old IDs to new IDs. + :param deferred_fk_update_collector: Collector for deferred FK updates. + :return: The newly created field instance. + """ + + serialized_copy = serialized_values.copy() + old_form_view_id = serialized_copy.pop("form_view_id", None) + + field = super().import_serialized( + table, + serialized_copy, + import_export_config, + id_mapping, + deferred_fk_update_collector, + ) + + if old_form_view_id: + deferred_fk_update_collector.add_deferred_fk_to_update( + field, + "form_view_id", + old_form_view_id, + "database_views", + ) + + return field + + def get_export_serialized_value( + self, + row: "GeneratedTableModel", + field_name: str, + cache: Dict[str, Any], + files_zip=None, + storage=None, + ): + return None + + def set_import_serialized_value( + self, row, field_name, value, id_mapping, cache, files_zip, storage + ): + pass diff --git a/backend/src/baserow/contrib/database/fields/fields.py b/backend/src/baserow/contrib/database/fields/fields.py index d380acb85d..f2049c2d6a 100644 --- a/backend/src/baserow/contrib/database/fields/fields.py +++ b/backend/src/baserow/contrib/database/fields/fields.py @@ -1,4 +1,5 @@ from typing import Any, Optional +from uuid import UUID from django.db import models from django.db.models import Field @@ -9,7 +10,10 @@ ) from django.utils.functional import cached_property +from rest_framework import serializers + from baserow.contrib.database.fields.utils.duration import duration_value_to_timedelta +from baserow.contrib.database.fields.utils.row_edit import build_row_edit_url from baserow.contrib.database.formula import BaserowExpression from baserow.core.fields import SyncedDateTimeField @@ -374,3 +378,42 @@ def deconstruct(self): kwargs.pop("editable", None) kwargs.pop("blank", None) return name, path, args, kwargs + + +class FormViewEditRowURLSerializerField(serializers.Field): + """ + A custom serializer field that computes a unique, secure edit URL for a + row. It reads the per-cell UUID stored in the field column and uses it + together with the form view slug to generate a signed token URL. + """ + + def __init__(self, field_instance, *args, **kwargs): + self.field_instance = field_instance + kwargs.setdefault("read_only", True) + super().__init__(*args, **kwargs) + + def to_representation(self, cell_uuid: UUID) -> Optional[str]: + """ + Convert the cell UUID into a full edit URL. + + :param cell_uuid: The unique UUID stored in the row's edit-link cell. + :return: The edit URL, or ``None`` if no form view is configured. + """ + + if not cell_uuid: + return None + if not self.field_instance.form_view_id: + return None + try: + form_view = self.field_instance.form_view + except Exception: + # The form view may have been deleted while the model cache still + # holds the old field instance (form_view_id is non-null but FK + # target is gone). + return None + if form_view is None: + return None + return build_row_edit_url(str(cell_uuid), form_view, self.field_instance.id) + + def to_internal_value(self, data): + raise serializers.ValidationError("This field is read-only.") diff --git a/backend/src/baserow/contrib/database/fields/models.py b/backend/src/baserow/contrib/database/fields/models.py index a5b36981e2..e6dda57587 100644 --- a/backend/src/baserow/contrib/database/fields/models.py +++ b/backend/src/baserow/contrib/database/fields/models.py @@ -961,6 +961,17 @@ class PasswordField(Field): ) +class FormViewEditRowField(Field): + form_view = models.ForeignKey( + "database.FormView", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="edit_row_fields", + help_text="The form view that will be used to edit rows via this field.", + ) + + class DuplicateFieldJob( JobWithUserIpAddress, JobWithWebsocketId, JobWithUndoRedoIds, Job ): diff --git a/backend/src/baserow/contrib/database/fields/utils/row_edit.py b/backend/src/baserow/contrib/database/fields/utils/row_edit.py new file mode 100644 index 0000000000..2253241112 --- /dev/null +++ b/backend/src/baserow/contrib/database/fields/utils/row_edit.py @@ -0,0 +1,58 @@ +from typing import TYPE_CHECKING, Dict, Optional + +from django.conf import settings + +from itsdangerous import BadSignature, URLSafeSerializer + +if TYPE_CHECKING: + from baserow.contrib.database.views.models import FormView + + +def _get_row_edit_signer(): + return URLSafeSerializer(settings.SECRET_KEY, salt="form-view-edit-row") + + +def generate_row_edit_token(view_slug: str, field_id: int, cell_uuid: str) -> str: + """ + Generate a signed, URL-safe token encoding the view slug, field ID, and + per-cell UUID. + + :param view_slug: The slug of the form view. + :param field_id: The primary key of the form_view_edit_row field. + :param cell_uuid: The unique UUID stored in the row's edit-link cell. + :return: A URL-safe signed token string. + """ + + return _get_row_edit_signer().dumps( + {"view_slug": view_slug, "field_id": field_id, "cell_uuid": cell_uuid} + ) + + +def build_row_edit_url(cell_uuid: str, form_view: "FormView", field_id: int) -> str: + """ + Build the full public URL that lets a visitor edit a row via a form view. + + :param cell_uuid: The unique UUID stored in the row's edit-link cell. + :param form_view: The form view instance. + :param field_id: The primary key of the form_view_edit_row field. + :return: The absolute edit URL. + """ + + token = generate_row_edit_token(form_view.slug, field_id, cell_uuid) + base = getattr(settings, "PUBLIC_WEB_FRONTEND_URL", "").rstrip("/") + return f"{base}/form/{form_view.slug}/?edit_token={token}" + + +def verify_and_decode_edit_token(token: str) -> Optional[Dict[str, str]]: + """ + Decode and verify a row edit token. + + :param token: The signed token string to verify. + :return: The payload dict containing `view_slug`, `field_id`, and + `cell_uuid`, or `None` if the token is invalid. + """ + + try: + return _get_row_edit_signer().loads(token) + except BadSignature: + return None diff --git a/backend/src/baserow/contrib/database/locale/en/LC_MESSAGES/django.po b/backend/src/baserow/contrib/database/locale/en/LC_MESSAGES/django.po index c1da067247..6ee1964a6f 100644 --- a/backend/src/baserow/contrib/database/locale/en/LC_MESSAGES/django.po +++ b/backend/src/baserow/contrib/database/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-13 19:58+0000\n" +"POT-Creation-Date: 2026-03-16 14:50+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -49,7 +49,7 @@ msgid "" "\"%(airtable_share_id)s\"" msgstr "" -#: src/baserow/contrib/database/application_types.py:305 +#: src/baserow/contrib/database/application_types.py:311 msgid "Table" msgstr "" @@ -62,25 +62,25 @@ msgstr "" msgid "Data sync table \"%(table_name)s\" (%(table_id)s) created" msgstr "" -#: src/baserow/contrib/database/data_sync/actions.py:104 +#: src/baserow/contrib/database/data_sync/actions.py:105 msgid "Update data sync table" msgstr "" -#: src/baserow/contrib/database/data_sync/actions.py:105 +#: src/baserow/contrib/database/data_sync/actions.py:106 #, python-format msgid "Data sync table \"%(table_name)s\" (%(table_id)s) updated" msgstr "" -#: src/baserow/contrib/database/data_sync/actions.py:166 +#: src/baserow/contrib/database/data_sync/actions.py:167 msgid "Sync data sync table" msgstr "" -#: src/baserow/contrib/database/data_sync/actions.py:167 +#: src/baserow/contrib/database/data_sync/actions.py:168 msgid "The data sync synchronized" msgstr "" #: src/baserow/contrib/database/data_sync/handler.py:209 -#: src/baserow/contrib/database/table/handler.py:476 +#: src/baserow/contrib/database/table/handler.py:478 msgid "Grid" msgstr "" @@ -210,8 +210,8 @@ msgstr "" #: src/baserow/contrib/database/plugins.py:55 #: src/baserow/contrib/database/plugins.py:77 -#: src/baserow/contrib/database/table/handler.py:572 -#: src/baserow/contrib/database/table/handler.py:585 +#: src/baserow/contrib/database/table/handler.py:574 +#: src/baserow/contrib/database/table/handler.py:587 msgid "Name" msgstr "" @@ -220,13 +220,13 @@ msgid "Last name" msgstr "" #: src/baserow/contrib/database/plugins.py:57 -#: src/baserow/contrib/database/table/handler.py:573 +#: src/baserow/contrib/database/table/handler.py:575 msgid "Notes" msgstr "" #: src/baserow/contrib/database/plugins.py:58 #: src/baserow/contrib/database/plugins.py:79 -#: src/baserow/contrib/database/table/handler.py:574 +#: src/baserow/contrib/database/table/handler.py:576 msgid "Active" msgstr "" @@ -254,74 +254,74 @@ msgstr "" msgid "Cellular Automata" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:82 +#: src/baserow/contrib/database/rows/actions.py:83 msgid "Create row" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:82 +#: src/baserow/contrib/database/rows/actions.py:83 #, python-format msgid "Row (%(row_id)s) created" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:195 +#: src/baserow/contrib/database/rows/actions.py:204 msgid "Create rows" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:195 +#: src/baserow/contrib/database/rows/actions.py:204 #, python-format msgid "Rows (%(row_ids)s) created" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:312 +#: src/baserow/contrib/database/rows/actions.py:329 msgid "Import rows" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:312 +#: src/baserow/contrib/database/rows/actions.py:329 #, python-format msgid "Rows (%(row_ids)s) imported" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:403 +#: src/baserow/contrib/database/rows/actions.py:420 msgid "Delete row" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:403 +#: src/baserow/contrib/database/rows/actions.py:420 #, python-format msgid "Row (%(row_id)s) deleted" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:497 +#: src/baserow/contrib/database/rows/actions.py:526 msgid "Delete rows" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:497 +#: src/baserow/contrib/database/rows/actions.py:526 #, python-format msgid "Rows (%(row_ids)s) deleted" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:667 +#: src/baserow/contrib/database/rows/actions.py:708 msgid "Move row" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:667 +#: src/baserow/contrib/database/rows/actions.py:708 #, python-format msgid "Row (%(row_id)s) moved" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:785 +#: src/baserow/contrib/database/rows/actions.py:826 msgid "Update row" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:785 +#: src/baserow/contrib/database/rows/actions.py:826 #, python-format msgid "Row (%(row_id)s) updated" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:885 +#: src/baserow/contrib/database/rows/actions.py:940 msgid "Update rows" msgstr "" -#: src/baserow/contrib/database/rows/actions.py:885 +#: src/baserow/contrib/database/rows/actions.py:940 #, python-format msgid "Rows (%(row_ids)s) updated" msgstr "" @@ -374,7 +374,7 @@ msgid "" "\"%(original_table_name)s\" (%(original_table_id)s) " msgstr "" -#: src/baserow/contrib/database/table/handler.py:534 +#: src/baserow/contrib/database/table/handler.py:536 #, python-format msgid "Field %d" msgstr "" @@ -633,6 +633,15 @@ msgstr "" msgid "Row (%(row_id)s) created via form submission" msgstr "" +#: src/baserow/contrib/database/views/actions.py:2484 +msgid "Edit form row" +msgstr "" + +#: src/baserow/contrib/database/views/actions.py:2485 +#, python-format +msgid "Row (%(row_id)s) updated via form edit" +msgstr "" + #: src/baserow/contrib/database/views/notification_types.py:86 #, python-format msgid "%(form_name)s has been submitted in %(table_name)s" diff --git a/backend/src/baserow/contrib/database/management/commands/fill_table_fields.py b/backend/src/baserow/contrib/database/management/commands/fill_table_fields.py index 88176f0370..a60f274135 100644 --- a/backend/src/baserow/contrib/database/management/commands/fill_table_fields.py +++ b/backend/src/baserow/contrib/database/management/commands/fill_table_fields.py @@ -8,6 +8,7 @@ ) from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.table.models import Table +from baserow.contrib.database.views.models import FormView class Command(BaseCommand): @@ -60,10 +61,18 @@ def handle(self, *args, **options): self.stdout.write(self.style.SUCCESS(f"{limit} fields have been created.")) +def _get_or_create_form_view(table: Table) -> FormView: + return FormView.objects.get_or_create( + table=table, + defaults={"name": "Form", "order": 0}, + )[0] + + def fill_table_fields(limit, table, shuffle_fields=False): field_handler = FieldHandler() + form_view = _get_or_create_form_view(table) all_kwargs_per_type = construct_all_possible_field_kwargs( - None, None, None, None, None + None, None, None, None, None, form_view ) first_user = table.database.workspace.users.first() # Keep all fields but link_row, count, rollup and lookup @@ -95,8 +104,9 @@ def fill_table_fields(limit, table, shuffle_fields=False): def create_field_for_every_type(table): field_handler = FieldHandler() + form_view = _get_or_create_form_view(table) all_kwargs_per_type = construct_all_possible_field_kwargs( - None, None, None, None, None + None, None, None, None, None, form_view ) first_user = table.database.workspace.users.first() i = 0 diff --git a/backend/src/baserow/contrib/database/migrations/0205_formvieweditrowfield.py b/backend/src/baserow/contrib/database/migrations/0205_formvieweditrowfield.py new file mode 100644 index 0000000000..8fe00ca14e --- /dev/null +++ b/backend/src/baserow/contrib/database/migrations/0205_formvieweditrowfield.py @@ -0,0 +1,39 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0204_add_row_exists_not_trashed_function"), + ] + + operations = [ + migrations.CreateModel( + name="FormViewEditRowField", + fields=[ + ( + "field_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="database.field", + ), + ), + ( + "form_view", + models.ForeignKey( + blank=True, + help_text="The form view that will be used to edit rows via this field.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="edit_row_fields", + to="database.formview", + ), + ), + ], + bases=("database.field",), + ), + ] diff --git a/backend/src/baserow/contrib/database/views/actions.py b/backend/src/baserow/contrib/database/views/actions.py index 235a571e8d..7d856e155c 100755 --- a/backend/src/baserow/contrib/database/views/actions.py +++ b/backend/src/baserow/contrib/database/views/actions.py @@ -16,7 +16,6 @@ from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.fields.models import Field from baserow.contrib.database.rows.exceptions import CannotCreateRowsInTable -from baserow.contrib.database.rows.handler import RowHandler from baserow.contrib.database.rows.helpers import ( construct_entry_from_action_and_diff, construct_related_rows_entries, @@ -42,6 +41,7 @@ ViewGroupBy, ViewSort, ) +from baserow.contrib.database.views.utils import serialize_row_for_action from baserow.core.action.models import Action from baserow.core.action.registries import ( ActionScopeStr, @@ -2378,20 +2378,8 @@ def do( model = form.table.get_model() row = ViewHandler().submit_form_view(user, form, values, model, field_options) - rh = RowHandler() table = model.baserow_table - tmodel = table.get_model() - fields_metadata = rh.get_fields_metadata_for_rows([row], tmodel.get_fields())[ - row.id - ] - cache = {} - serialized_values = { - f["name"]: f["type"].get_export_serialized_value( - row, f["name"], cache=cache, files_zip=None, storage=None - ) - for f in tmodel.get_field_objects() - if not f["type"].read_only - } + serialized_values, fields_metadata = serialize_row_for_action(row, model) workspace = table.database.workspace params = cls.Params( @@ -2488,3 +2476,83 @@ def are_equal_on_create(field_identifier, before_value, after_value) -> bool: ) row_history_entries.extend(related_entries) return row_history_entries + + +class EditFormRowActionType(ActionType): + type = "edit_form_row" + description = ActionTypeDescription( + _("Edit form row"), + _("Row (%(row_id)s) updated via form edit"), + VIEW_ACTION_CONTEXT, + ) + analytics_params = [ + "view_id", + "table_id", + "database_id", + "row_id", + ] + + @dataclasses.dataclass + class Params: + view_id: int + view_name: str + table_id: int + table_name: str + database_id: int + database_name: str + row_id: int + values: Dict[str, Any] + fields_metadata: dict[str, Any] + + @classmethod + def do( + cls, + user: AbstractUser, + form: FormView, + row_id: int, + values: Dict[str, Any], + model: Optional[Type[GeneratedTableModel]] = None, + field_options: Dict[str, Any] | None = None, + ) -> GeneratedTableModel: + """ + Updates a row via a form view edit link that's generated by the form view + edit link field type. + + :param user: The user on whose behalf the row is updated. + :param form: The form view used to edit the row. + :param row_id: The primary key of the row to update. + :param values: The field values to update. + :param model: The table model to use. + :param field_options: The form view field options. If not provided, the + field options will be fetched from the form view. + :return: The updated row instance. + """ + + if model is None: + model = form.table.get_model() + + row = ViewHandler().edit_form_view_row( + user, form, row_id, values, model, field_options + ) + table = model.baserow_table + serialized_values, fields_metadata = serialize_row_for_action(row, model) + + workspace = table.database.workspace + params = cls.Params( + form.id, + form.name, + table.id, + table.name, + table.database.id, + table.database.name, + row.id, + serialized_values, + fields_metadata=fields_metadata, + ) + cls.register_action(user, params, scope=cls.scope(form.id), workspace=workspace) + + return row + + @classmethod + def scope(cls, view_id: int) -> ActionScopeStr: + return ViewActionScopeType.value(view_id) diff --git a/backend/src/baserow/contrib/database/views/handler.py b/backend/src/baserow/contrib/database/views/handler.py index cd0655a354..82f314fe58 100644 --- a/backend/src/baserow/contrib/database/views/handler.py +++ b/backend/src/baserow/contrib/database/views/handler.py @@ -36,6 +36,7 @@ from baserow.contrib.database.fields.registries import field_type_registry from baserow.contrib.database.rows.handler import RowHandler from baserow.contrib.database.search.handler import SearchMode +from baserow.contrib.database.table.cache import invalidate_table_in_model_cache from baserow.contrib.database.table.models import GeneratedTableModel, Table from baserow.contrib.database.views.exceptions import ViewOwnershipTypeDoesNotExist from baserow.contrib.database.views.filters import AdHocFilters @@ -129,6 +130,8 @@ from .models import ( DEFAULT_SORT_TYPE_KEY, OWNERSHIP_TYPE_COLLABORATIVE, + FormView, + FormViewFieldOptions, View, ViewDecoration, ViewFilter, @@ -3273,6 +3276,11 @@ def update_view_slug( setattr(view, slug_field, slug) view.save() + table_id = view.table_id + # Invalidate the model cache because fields could be depending on that specific + # model slug, like the edit row link field. + invalidate_table_in_model_cache(table_id) + view_updated.send(self, view=view, user=user, old_view=old_view) return view @@ -3350,14 +3358,30 @@ def get_public_view_by_slug( return view + @staticmethod + def _get_allowed_form_field_names(model, enabled_field_options): + """ + Return the list of internal field names that are enabled in the given + form view field options. + + :param model: The generated table model. + :param enabled_field_options: The enabled form view field options. + :return: A list of allowed field names. + """ + + return [ + model._field_objects[field.field_id]["name"] + for field in enabled_field_options + ] + def submit_form_view( self, - user, - form, - values, - model: GeneratedTableModel | None = None, - enabled_field_options=None, - ): + user: AbstractUser, + form: FormView, + values: Dict[str, Any], + model: Optional[Type[GeneratedTableModel]] = None, + enabled_field_options: Optional[QuerySet[FormViewFieldOptions]] = None, + ) -> GeneratedTableModel: """ Handles when a form is submitted. It will validate the data by checking if the required fields are provided and not empty and it will create a new row @@ -3384,15 +3408,13 @@ def submit_form_view( if not enabled_field_options: enabled_field_options = form.active_field_options - allowed_field_names = [] - field_errors = {} + allowed_field_names = self._get_allowed_form_field_names( + model, enabled_field_options + ) - # Loop over all field options, find the name in the model and check if the - # required values are provided. If not, a validation error is raised. + field_errors = {} for field in enabled_field_options: field_name = model._field_objects[field.field_id]["name"] - allowed_field_names.append(field_name) - if field.is_required() and ( field_name not in values or value_is_empty_for_required_form_field(values[field_name]) @@ -3409,6 +3431,50 @@ def submit_form_view( ) return created_row + def edit_form_view_row( + self, + user: AbstractUser, + form: FormView, + row_id: int, + values: Dict[str, Any], + model: Optional[Type[GeneratedTableModel]] = None, + enabled_field_options: Optional[QuerySet[FormViewFieldOptions]] = None, + ) -> GeneratedTableModel: + """ + Handles when a row is edited via a form view. Only fields that are enabled + in the form view can be updated. + + :param user: The user on whose behalf the row is updated. + :param form: The form view used to edit the row. + :param row_id: The primary key of the row to update. + :param values: The submitted values to update. + :param model: If the model is already generated, it can be provided here. + :param enabled_field_options: If the enabled field options have already been + fetched, they can be provided here. + :return: The updated row instance. + """ + + table = form.table + + if model is None: + model = table.get_model() + + if not enabled_field_options: + enabled_field_options = form.active_field_options + + allowed_field_names = self._get_allowed_form_field_names( + model, enabled_field_options + ) + allowed_values = extract_allowed(values, allowed_field_names) + + updated_rows_data = RowHandler().force_update_rows( + user, + table, + [{"id": row_id, **allowed_values}], + model=model, + ) + return updated_rows_data.updated_rows[0] + def restrict_row_for_view( self, view: View, serialized_row: Dict[str, Any] ) -> Dict[Any, Any]: diff --git a/backend/src/baserow/contrib/database/views/utils.py b/backend/src/baserow/contrib/database/views/utils.py index 2fbc26ce00..2ef76458bb 100644 --- a/backend/src/baserow/contrib/database/views/utils.py +++ b/backend/src/baserow/contrib/database/views/utils.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any, Dict, Tuple from django.db.models.aggregates import Aggregate, Count @@ -50,3 +50,30 @@ def calculate(self, queryset, limit=10): .order_by("-count", self.group_by) .values_list(self.group_by, "count")[:limit] ) + + +def serialize_row_for_action(row, model) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """ + Build the serialized values dict and fields metadata for a row, to be used + when registering form-related actions for the audit log. + + :param row: The row instance. + :param model: The generated table model. + :return: A tuple of (serialized_values, fields_metadata). + """ + + from baserow.contrib.database.rows.handler import RowHandler + + row_handler = RowHandler() + fields_metadata = row_handler.get_fields_metadata_for_rows( + [row], model.get_fields() + )[row.id] + cache = {} + serialized_values = { + f["name"]: f["type"].get_export_serialized_value( + row, f["name"], cache=cache, files_zip=None, storage=None + ) + for f in model.get_field_objects() + if not f["type"].read_only + } + return serialized_values, fields_metadata diff --git a/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po b/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po index 54a6c36099..1e92428abd 100644 --- a/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po +++ b/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-25 13:02+0000\n" +"POT-Creation-Date: 2026-03-16 14:50+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -108,8 +108,8 @@ msgstr "" #, python-format msgid "" "Application \"%(application_name)s\" (%(application_id)s) of type " -"%(application_type)s duplicated from " -"\"%(original_application_name)s\" (%(original_application_id)s)" +"%(application_type)s duplicated from \"%(original_application_name)s\" " +"(%(original_application_id)s)" msgstr "" #: src/baserow/core/actions.py:709 @@ -130,8 +130,8 @@ msgstr "" #: src/baserow/core/actions.py:796 #, python-format msgid "" -"Group invitation created for \"%(email)s\" to join " -"\"%(group_name)s\" (%(group_id)s) as %(permissions)s." +"Group invitation created for \"%(email)s\" to join \"%(group_name)s\" " +"(%(group_id)s) as %(permissions)s." msgstr "" #: src/baserow/core/actions.py:851 @@ -215,7 +215,7 @@ msgid "Applications \"%(application_names)s\" (%(application_ids)s) imported" msgstr "" #: src/baserow/core/emails.py:97 -#: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:176 +#: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:173 msgid "Please confirm email" msgstr "" @@ -242,7 +242,7 @@ msgstr "" msgid "Decimal number" msgstr "" -#: src/baserow/core/handler.py:2187 src/baserow/core/user/handler.py:269 +#: src/baserow/core/handler.py:2187 src/baserow/core/user/handler.py:271 #, python-format msgid "%(name)s's workspace" msgstr "" @@ -323,49 +323,49 @@ msgid "" "\"%(application_name)s\" (%(application_id)s)." msgstr "" -#: src/baserow/core/templates/baserow/core/notifications_summary.html:176 +#: src/baserow/core/templates/baserow/core/notifications_summary.html:173 #, python-format msgid "You have %(counter)s new notification" msgid_plural "You have %(counter)s new notifications" msgstr[0] "" msgstr[1] "" -#: src/baserow/core/templates/baserow/core/notifications_summary.html:228 +#: src/baserow/core/templates/baserow/core/notifications_summary.html:214 #, python-format msgid "Plus %(counter)s more notification." msgid_plural "Plus %(counter)s more notifications." msgstr[0] "" msgstr[1] "" -#: src/baserow/core/templates/baserow/core/notifications_summary.html:237 -#: src/baserow/core/templates/baserow/core/user/account_deleted.html:188 -#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:188 -#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:193 -#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:211 -#: src/baserow/core/templates/baserow/core/user/reset_password.html:211 -#: src/baserow/core/templates/baserow/core/workspace_invitation.html:215 +#: src/baserow/core/templates/baserow/core/notifications_summary.html:220 +#: src/baserow/core/templates/baserow/core/user/account_deleted.html:184 +#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:184 +#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:189 +#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:207 +#: src/baserow/core/templates/baserow/core/user/reset_password.html:207 +#: src/baserow/core/templates/baserow/core/workspace_invitation.html:209 msgid "" "Baserow is an open source no-code database tool which allows you to " "collaborate on projects, customers and more. It gives you the powers of a " "developer without leaving your browser." msgstr "" -#: src/baserow/core/templates/baserow/core/user/account_deleted.html:176 +#: src/baserow/core/templates/baserow/core/user/account_deleted.html:173 msgid "Account permanently deleted" msgstr "" -#: src/baserow/core/templates/baserow/core/user/account_deleted.html:181 +#: src/baserow/core/templates/baserow/core/user/account_deleted.html:178 #, python-format msgid "" "Your account (%(username)s) on Baserow (%(baserow_embedded_share_hostname)s) " "has been permanently deleted." msgstr "" -#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:176 +#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:173 msgid "Account deletion cancelled" msgstr "" -#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:181 +#: src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html:178 #, python-format msgid "" "Your account (%(username)s) on Baserow (%(baserow_embedded_share_hostname)s) " @@ -373,28 +373,28 @@ msgid "" "cancelled." msgstr "" -#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:176 +#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:173 msgid "Account pending deletion" msgstr "" -#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:181 +#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:178 #, python-format msgid "" "Your account (%(username)s) on Baserow (%(baserow_embedded_share_hostname)s) " "will be permanently deleted in %(days_left)s days." msgstr "" -#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:186 +#: src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html:183 msgid "" "If you've changed your mind and want to cancel your account deletion, you " "just have to login again." msgstr "" -#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:176 +#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:173 msgid "Confirm email address change" msgstr "" -#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:181 +#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:178 #, python-format msgid "" "A request was made to change the email address for your Baserow account from " @@ -403,38 +403,38 @@ msgid "" "may simply ignore this email." msgstr "" -#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:186 +#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:183 #, python-format msgid "" "To confirm your email address change, simply click the button below. This " "link will expire in %(hours)s hours." msgstr "" -#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:195 +#: src/baserow/core/templates/baserow/core/user/change_email_confirmation.html:192 msgid "Confirm email change" msgstr "" -#: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:181 +#: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:178 msgid "Thank you for using Baserow" msgstr "" -#: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:186 +#: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:183 msgid "" "To keep your account secure, please take a moment to verify your email by " "clicking the button below. Your email address will be used to assist you in " "changing your Baserow password should you ever need to in the future." msgstr "" -#: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:195 +#: src/baserow/core/templates/baserow/core/user/email_pending_verification.html:192 msgid "Confirm" msgstr "" -#: src/baserow/core/templates/baserow/core/user/reset_password.html:176 -#: src/baserow/core/templates/baserow/core/user/reset_password.html:195 +#: src/baserow/core/templates/baserow/core/user/reset_password.html:173 +#: src/baserow/core/templates/baserow/core/user/reset_password.html:192 msgid "Reset password" msgstr "" -#: src/baserow/core/templates/baserow/core/user/reset_password.html:181 +#: src/baserow/core/templates/baserow/core/user/reset_password.html:178 #, python-format msgid "" "A password reset was requested for your account (%(username)s) on Baserow " @@ -442,7 +442,7 @@ msgid "" "may simply ignore this email." msgstr "" -#: src/baserow/core/templates/baserow/core/user/reset_password.html:186 +#: src/baserow/core/templates/baserow/core/user/reset_password.html:183 #, python-format msgid "" "To continue with your password reset, simply click the button below, and you " @@ -450,18 +450,18 @@ msgid "" "hours." msgstr "" -#: src/baserow/core/templates/baserow/core/workspace_invitation.html:176 +#: src/baserow/core/templates/baserow/core/workspace_invitation.html:173 msgid "Invitation" msgstr "" -#: src/baserow/core/templates/baserow/core/workspace_invitation.html:181 +#: src/baserow/core/templates/baserow/core/workspace_invitation.html:178 #, python-format msgid "" "%(first_name)s has invited you to collaborate on " "%(workspace_name)s." msgstr "" -#: src/baserow/core/templates/baserow/core/workspace_invitation.html:199 +#: src/baserow/core/templates/baserow/core/workspace_invitation.html:194 msgid "Accept invitation" msgstr "" @@ -521,9 +521,8 @@ msgstr "" #: src/baserow/core/user/actions.py:27 #, python-format msgid "" -"User \"%(user_email)s\" (%(user_id)s) created via " -"\"%(auth_provider_type)s\" (%(auth_provider_id)s) auth provider (invitation: " -"%(with_invitation_token)s)" +"User \"%(user_email)s\" (%(user_id)s) created via \"%(auth_provider_type)s\" " +"(%(auth_provider_id)s) auth provider (invitation: %(with_invitation_token)s)" msgstr "" #: src/baserow/core/user/actions.py:119 diff --git a/backend/src/baserow/locale/en/LC_MESSAGES/django.po b/backend/src/baserow/locale/en/LC_MESSAGES/django.po index e16f37e48a..f185ecf311 100755 --- a/backend/src/baserow/locale/en/LC_MESSAGES/django.po +++ b/backend/src/baserow/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-01-09 16:13+0000\n" +"POT-Creation-Date: 2026-03-16 14:50+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -35,15 +35,15 @@ msgstr "" #: src/baserow/contrib/automation/action_scopes.py:14 #, python-format msgid "" -"of type (%(node_type)s) in automation " -"\"%(automation_name)s\" (%(automation_id)s)." +"of type (%(node_type)s) in automation \"%(automation_name)s\" " +"(%(automation_id)s)." msgstr "" #: src/baserow/contrib/automation/actions.py:8 #, python-format msgid "" -"in workflow (%(workflow_id)s) in automation " -"\"%(automation_name)s\" (%(automation_id)s)." +"in workflow (%(workflow_id)s) in automation \"%(automation_name)s\" " +"(%(automation_id)s)." msgstr "" #: src/baserow/contrib/automation/automation_init_application.py:29 @@ -221,14 +221,10 @@ msgstr "" msgid "Triggered at" msgstr "" -#: src/baserow/contrib/integrations/local_baserow/service_types.py:1676 +#: src/baserow/contrib/integrations/local_baserow/service_types.py:1688 msgid "No rows found" msgstr "" -#: src/baserow/contrib/integrations/local_baserow/service_types.py:1692 -msgid "Row {resolved_values['row_id']} does not exist." -msgstr "" - #: src/baserow/contrib/integrations/slack/service_types.py:166 msgid "OK" msgstr "" diff --git a/backend/src/baserow/test_utils/helpers.py b/backend/src/baserow/test_utils/helpers.py index 21b57231d9..25e6c5ff8b 100644 --- a/backend/src/baserow/test_utils/helpers.py +++ b/backend/src/baserow/test_utils/helpers.py @@ -7,6 +7,7 @@ from typing import Any, Dict, Generator, List, Optional, Type, Union from unittest.mock import patch +from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser from django.db import connection @@ -20,6 +21,7 @@ ) from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.fields.models import SelectOption +from baserow.contrib.database.fields.utils.row_edit import generate_row_edit_token from baserow.contrib.database.models import Database from baserow.contrib.database.rows.handler import RowHandler from baserow.core.action.models import Action @@ -142,12 +144,14 @@ def setup_interesting_test_table( ) = data_fixture.create_database_table( database=database, user=user, name="multiple_collaborators_link_table" ) + form_view = data_fixture.create_form_view(user=user, table=table) all_possible_kwargs_per_type = construct_all_possible_field_kwargs( table, link_table, decimal_link_table, file_link_table, multiple_collaborators_link_table, + form_view, ) name_to_field_id = {} i = 0 @@ -304,6 +308,7 @@ def setup_interesting_test_table( "duration_rollup_sum", "multiple_collaborators_lookup", "multiple_select_with_default", + "form_view_edit_row", } ) assert missing_fields == set(), ( @@ -442,6 +447,7 @@ def setup_interesting_test_table( "fields": fields, "tables": linked_tables, "name_to_field_id": name_to_field_id, + "form_view": form_view, } return table, user, row, blank_row, context @@ -561,6 +567,25 @@ def independent_test_db_connection(): conn.close() +def get_form_view_edit_row_url(context: Dict[str, Any], row) -> str: + """ + Compute the expected form_view_edit_row URL for a given row in an + interesting test table. + + :param context: The context dict returned by ``setup_interesting_test_table``. + :param row: The row instance (must have the edit-link field column). + :return: The full edit URL. + """ + + form_view = context["form_view"] + name_to_field_id = context["name_to_field_id"] + field_id = name_to_field_id["form_view_edit_row"] + cell_uuid = str(getattr(row, f"field_{field_id}")) + base = getattr(settings, "PUBLIC_WEB_FRONTEND_URL", "").rstrip("/") + token = generate_row_edit_token(form_view.slug, field_id, cell_uuid) + return f"{base}/form/{form_view.slug}/?edit_token={token}" + + def assert_serialized_field_values_are_the_same( value_1, value_2, ordered=False, field_name=None ): @@ -591,6 +616,10 @@ def extract_value(value): def assert_serialized_rows_contain_same_values(row_1, row_2): for field_name, row_field_value in row_1.items(): + # The form_view_edit_row field embeds the field_id in its token, + # so duplicated fields/tables will always produce different URLs. + if field_name == "form_view_edit_row": + continue row_1_value = extract_serialized_field_value(row_field_value) row_2_value = extract_serialized_field_value(row_2[field_name]) assert_serialized_field_values_are_the_same( diff --git a/backend/tests/baserow/contrib/database/api/rows/test_row_serializers.py b/backend/tests/baserow/contrib/database/api/rows/test_row_serializers.py index 6cb83a73dd..76a0aa85fd 100644 --- a/backend/tests/baserow/contrib/database/api/rows/test_row_serializers.py +++ b/backend/tests/baserow/contrib/database/api/rows/test_row_serializers.py @@ -13,7 +13,11 @@ from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.fields.models import SelectOption from baserow.contrib.database.fields.registries import field_type_registry -from baserow.test_utils.helpers import AnyStr, setup_interesting_test_table +from baserow.test_utils.helpers import ( + AnyStr, + get_form_view_edit_row_url, + setup_interesting_test_table, +) @pytest.mark.django_db @@ -318,6 +322,8 @@ def test_get_row_serializer_with_user_field_names( table, user, row, _, context = setup_interesting_test_table(data_fixture) model = table.get_model() + form_view_edit_row_url = get_form_view_edit_row_url(context, row) + # get_row_serializer_class should nevere make any queries to the database with django_assert_num_queries(0): serializer_class = get_row_serializer_class( @@ -562,6 +568,7 @@ def test_get_row_serializer_with_user_field_names( "id": SelectOption.objects.get(value="Object").id, "value": "Object", }, + "form_view_edit_row": form_view_edit_row_url, } ) ) 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 ed35fc4365..6780ff9999 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 @@ -20,6 +20,8 @@ ) from baserow.contrib.database.data_sync.handler import DataSyncHandler +from baserow.contrib.database.fields.handler import FieldHandler +from baserow.contrib.database.fields.utils.row_edit import generate_row_edit_token from baserow.contrib.database.rows.handler import RowHandler from baserow.contrib.database.views.models import ( FormView, @@ -3826,3 +3828,421 @@ def test_can_use_link_row_field_to_table_with_formula_as_primary_key_in_form_vie assert response.json()["row_id"] == 1 assert response.json()["submit_action"] == "MESSAGE" assert response.json()["submit_action_message"] == "" + + +def _get_cell_uuid(table, edit_field_id, row_id): + """Helper to fetch the cell UUID for an edit-row field from the database.""" + model = table.get_model() + row_obj = model.objects.get(id=row_id) + return str(getattr(row_obj, f"field_{edit_field_id}")) + + +@pytest.mark.django_db +def test_edit_row_get_valid_token(data_fixture, api_client): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + text_field = data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + data_fixture.create_form_view_field_option( + form_view, text_field, enabled=True, required=False + ) + + edit_field_obj = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + row = RowHandler().create_row( + user=user, + table=table, + values={f"field_{text_field.id}": "Test value"}, + ) + + cell_uuid = _get_cell_uuid(table, edit_field_obj.id, row.id) + token = generate_row_edit_token(form_view.slug, edit_field_obj.id, cell_uuid) + url = reverse( + "api:database:views:form:edit_row", + kwargs={"slug": form_view.slug, "row_token": token}, + ) + response = api_client.get(url) + + assert response.status_code == HTTP_200_OK + data = response.json() + assert data[f"field_{text_field.id}"] == "Test value" + + +@pytest.mark.django_db +def test_edit_row_get_invalid_token(data_fixture, api_client): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + RowHandler().create_row(user=user, table=table, values={}) + + url = reverse( + "api:database:views:form:edit_row", + kwargs={"slug": form_view.slug, "row_token": "invalid-token"}, + ) + response = api_client.get(url) + assert response.status_code == HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_edit_row_get_missing_row(data_fixture, api_client): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + edit_field_obj = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + # Use a UUID that doesn't exist in any row. + token = generate_row_edit_token( + form_view.slug, edit_field_obj.id, "00000000-0000-0000-0000-000000000000" + ) + url = reverse( + "api:database:views:form:edit_row", + kwargs={"slug": form_view.slug, "row_token": token}, + ) + response = api_client.get(url) + assert response.status_code == HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_edit_row_patch_updates_row(data_fixture, api_client): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + text_field = data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + data_fixture.create_form_view_field_option( + form_view, text_field, enabled=True, required=False + ) + + edit_field_obj = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + row = RowHandler().create_row( + user=user, + table=table, + values={f"field_{text_field.id}": "Original"}, + ) + + cell_uuid = _get_cell_uuid(table, edit_field_obj.id, row.id) + token = generate_row_edit_token(form_view.slug, edit_field_obj.id, cell_uuid) + url = reverse( + "api:database:views:form:edit_row", + kwargs={"slug": form_view.slug, "row_token": token}, + ) + response = api_client.patch( + url, + {f"field_{text_field.id}": "Updated"}, + format="json", + ) + + assert response.status_code == HTTP_200_OK + data = response.json() + assert "row_id" in data + assert data["row_id"] == row.id + + model = table.get_model() + updated_row = model.objects.get(id=row.id) + assert getattr(updated_row, f"field_{text_field.id}") == "Updated" + + +@pytest.mark.django_db +def test_edit_row_patch_invalid_token(data_fixture, api_client): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + RowHandler().create_row(user=user, table=table, values={}) + + url = reverse( + "api:database:views:form:edit_row", + kwargs={"slug": form_view.slug, "row_token": "bad-token"}, + ) + response = api_client.patch( + url, + {}, + format="json", + ) + assert response.status_code == HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_edit_row_patch_missing_row(data_fixture, api_client): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + edit_field_obj = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + token = generate_row_edit_token( + form_view.slug, edit_field_obj.id, "00000000-0000-0000-0000-000000000000" + ) + url = reverse( + "api:database:views:form:edit_row", + kwargs={"slug": form_view.slug, "row_token": token}, + ) + response = api_client.patch( + url, + {}, + format="json", + ) + assert response.status_code == HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_edit_row_patch_only_visible_form_fields_are_writable(data_fixture, api_client): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + visible_field = data_fixture.create_text_field( + table=table, name="Visible", primary=True + ) + hidden_field = data_fixture.create_text_field(table=table, name="Hidden") + form_view = data_fixture.create_form_view(table=table, public=True) + data_fixture.create_form_view_field_option( + form_view, visible_field, enabled=True, required=False + ) + + edit_field_obj = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + row = RowHandler().create_row( + user=user, + table=table, + values={ + f"field_{visible_field.id}": "Original visible", + f"field_{hidden_field.id}": "Original hidden", + }, + ) + + cell_uuid = _get_cell_uuid(table, edit_field_obj.id, row.id) + token = generate_row_edit_token(form_view.slug, edit_field_obj.id, cell_uuid) + url = reverse( + "api:database:views:form:edit_row", + kwargs={"slug": form_view.slug, "row_token": token}, + ) + response = api_client.patch( + url, + { + f"field_{visible_field.id}": "Updated visible", + f"field_{hidden_field.id}": "Attempt to change hidden", + }, + format="json", + ) + assert response.status_code == HTTP_200_OK + + model = table.get_model() + updated = model.objects.get(id=row.id) + assert getattr(updated, f"field_{visible_field.id}") == "Updated visible" + assert getattr(updated, f"field_{hidden_field.id}") == "Original hidden" + + +@pytest.mark.django_db +def test_edit_row_get_token_for_wrong_view(data_fixture, api_client): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + other_form_view = data_fixture.create_form_view(table=table, public=True) + + edit_field_obj = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + row = RowHandler().create_row(user=user, table=table, values={}) + + # Token is signed for form_view's slug, but request targets other_form_view. + cell_uuid = _get_cell_uuid(table, edit_field_obj.id, row.id) + token = generate_row_edit_token(form_view.slug, edit_field_obj.id, cell_uuid) + url = reverse( + "api:database:views:form:edit_row", + kwargs={"slug": other_form_view.slug, "row_token": token}, + ) + response = api_client.get(url) + assert response.status_code == HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_edit_row_patch_token_for_wrong_view(data_fixture, api_client): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + other_form_view = data_fixture.create_form_view(table=table, public=True) + + edit_field_obj = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + row = RowHandler().create_row(user=user, table=table, values={}) + + cell_uuid = _get_cell_uuid(table, edit_field_obj.id, row.id) + token = generate_row_edit_token(form_view.slug, edit_field_obj.id, cell_uuid) + url = reverse( + "api:database:views:form:edit_row", + kwargs={"slug": other_form_view.slug, "row_token": token}, + ) + response = api_client.patch(url, {}, format="json") + assert response.status_code == HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_edit_row_get_deleted_edit_field(data_fixture, api_client): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + edit_field_obj = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + row = RowHandler().create_row(user=user, table=table, values={}) + + cell_uuid = _get_cell_uuid(table, edit_field_obj.id, row.id) + token = generate_row_edit_token(form_view.slug, edit_field_obj.id, cell_uuid) + + # Delete the field after generating the token. + FieldHandler().delete_field(user=user, field=edit_field_obj) + + url = reverse( + "api:database:views:form:edit_row", + kwargs={"slug": form_view.slug, "row_token": token}, + ) + response = api_client.get(url) + assert response.status_code == HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_edit_row_patch_deleted_edit_field(data_fixture, api_client): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + edit_field_obj = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + row = RowHandler().create_row(user=user, table=table, values={}) + + cell_uuid = _get_cell_uuid(table, edit_field_obj.id, row.id) + token = generate_row_edit_token(form_view.slug, edit_field_obj.id, cell_uuid) + + FieldHandler().delete_field(user=user, field=edit_field_obj) + + url = reverse( + "api:database:views:form:edit_row", + kwargs={"slug": form_view.slug, "row_token": token}, + ) + response = api_client.patch(url, {}, format="json") + assert response.status_code == HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_edit_row_get_deleted_form_view(data_fixture, api_client): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + edit_field_obj = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + row = RowHandler().create_row(user=user, table=table, values={}) + + cell_uuid = _get_cell_uuid(table, edit_field_obj.id, row.id) + token = generate_row_edit_token(form_view.slug, edit_field_obj.id, cell_uuid) + slug = form_view.slug + + # Delete the form view after generating the token. + form_view.delete() + + url = reverse( + "api:database:views:form:edit_row", + kwargs={"slug": slug, "row_token": token}, + ) + response = api_client.get(url) + assert response.status_code == HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_edit_row_patch_deleted_form_view(data_fixture, api_client): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + edit_field_obj = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + row = RowHandler().create_row(user=user, table=table, values={}) + + cell_uuid = _get_cell_uuid(table, edit_field_obj.id, row.id) + token = generate_row_edit_token(form_view.slug, edit_field_obj.id, cell_uuid) + slug = form_view.slug + + form_view.delete() + + url = reverse( + "api:database:views:form:edit_row", + kwargs={"slug": slug, "row_token": token}, + ) + response = api_client.patch(url, {}, format="json") + assert response.status_code == HTTP_404_NOT_FOUND diff --git a/backend/tests/baserow/contrib/database/field/test_field_actions.py b/backend/tests/baserow/contrib/database/field/test_field_actions.py index c364979225..5dedece3cf 100644 --- a/backend/tests/baserow/contrib/database/field/test_field_actions.py +++ b/backend/tests/baserow/contrib/database/field/test_field_actions.py @@ -1403,6 +1403,10 @@ def test_can_undo_redo_duplicate_fields_of_interesting_table(api_client, data_fi assert table.field_set.count() == len(original_field_set) * 2 for row in response_json["results"]: for field in original_field_set: + # The form_view_edit_row field embeds the field_id in its token, + # so the duplicated field will always produce a different URL. + if field.get_type().type == "form_view_edit_row": + continue row_1_value = extract_serialized_field_value(row[field.db_column]) duplicated_field = duplicated_fields[field.id] row_2_value = extract_serialized_field_value( diff --git a/backend/tests/baserow/contrib/database/field/test_field_handler.py b/backend/tests/baserow/contrib/database/field/test_field_handler.py index 86f39481b0..103dde5b4c 100644 --- a/backend/tests/baserow/contrib/database/field/test_field_handler.py +++ b/backend/tests/baserow/contrib/database/field/test_field_handler.py @@ -83,7 +83,7 @@ from baserow.contrib.database.fields.utils import DeferredForeignKeyUpdater from baserow.contrib.database.rows.handler import RowHandler from baserow.contrib.database.table.models import Table -from baserow.contrib.database.views.models import ViewFilter +from baserow.contrib.database.views.models import FormView, ViewFilter from baserow.core.exceptions import UserNotInWorkspace from baserow.core.handler import CoreHandler from baserow.core.psycopg import is_unique_violation_error @@ -110,7 +110,7 @@ def _test_can_convert_between_fields(data_fixture, field_type_to_test): does not raise any exceptions. """ - table, user, row, _, _ = setup_interesting_test_table(data_fixture) + table, user, row, _, context = setup_interesting_test_table(data_fixture) handler = FieldHandler() row_handler = RowHandler() @@ -128,6 +128,7 @@ def _test_can_convert_between_fields(data_fixture, field_type_to_test): Table.objects.get(name="decimal_link_table"), Table.objects.get(name="file_link_table"), Table.objects.get(name="multiple_collaborators_link_table"), + FormView.objects.get(table=table), ) i = 1 diff --git a/backend/tests/baserow/contrib/database/field/test_form_view_edit_row_field_type.py b/backend/tests/baserow/contrib/database/field/test_form_view_edit_row_field_type.py new file mode 100644 index 0000000000..dfddfabfd2 --- /dev/null +++ b/backend/tests/baserow/contrib/database/field/test_form_view_edit_row_field_type.py @@ -0,0 +1,541 @@ +from urllib.parse import parse_qs, urlparse + +from django.shortcuts import reverse + +import pytest +from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST + +from baserow.contrib.database.fields.handler import FieldHandler +from baserow.contrib.database.fields.models import FormViewEditRowField +from baserow.contrib.database.fields.utils.row_edit import ( + generate_row_edit_token, + verify_and_decode_edit_token, +) +from baserow.contrib.database.rows.handler import RowHandler +from baserow.contrib.database.table.handler import TableHandler +from baserow.contrib.database.views.exceptions import ViewNotInTable +from baserow.contrib.database.views.handler import ViewHandler + + +@pytest.mark.django_db +def test_generate_row_edit_token_is_deterministic(): + """The same inputs must always produce the same token.""" + token1 = generate_row_edit_token(view_slug="slug1", field_id=2, cell_uuid="abc-123") + token2 = generate_row_edit_token(view_slug="slug1", field_id=2, cell_uuid="abc-123") + assert token1 == token2 + + +@pytest.mark.django_db +def test_generate_row_edit_token_differs_by_input(): + """Different inputs must produce different tokens.""" + t1 = generate_row_edit_token(view_slug="slug1", field_id=2, cell_uuid="uuid-a") + t2 = generate_row_edit_token(view_slug="slug2", field_id=2, cell_uuid="uuid-a") + t3 = generate_row_edit_token(view_slug="slug1", field_id=99, cell_uuid="uuid-a") + t4 = generate_row_edit_token(view_slug="slug1", field_id=2, cell_uuid="uuid-b") + assert len({t1, t2, t3, t4}) == 4 + + +@pytest.mark.django_db +def test_verify_and_decode_edit_token_valid(): + """A freshly generated token must decode back to the original values.""" + token = generate_row_edit_token( + view_slug="test-slug", field_id=15, cell_uuid="my-uuid" + ) + data = verify_and_decode_edit_token(token) + assert data == {"view_slug": "test-slug", "field_id": 15, "cell_uuid": "my-uuid"} + + +@pytest.mark.django_db +def test_verify_and_decode_edit_token_tampered(): + """A tampered token must return None.""" + token = generate_row_edit_token(view_slug="slug", field_id=3, cell_uuid="uuid") + assert verify_and_decode_edit_token(token + "X") is None + assert verify_and_decode_edit_token("totallyinvalid") is None + assert verify_and_decode_edit_token("") is None + + +@pytest.mark.django_db +def test_create_form_view_edit_row_field(data_fixture): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + form_view = data_fixture.create_form_view(table=table) + + handler = FieldHandler() + field = handler.create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + assert isinstance(field.specific, FormViewEditRowField) + assert field.specific.form_view_id == form_view.id + assert FormViewEditRowField.objects.filter(id=field.id).exists() + + +@pytest.mark.django_db +def test_create_form_view_edit_row_field_wrong_table(data_fixture): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + other_table = data_fixture.create_database_table(user=user) + form_view = data_fixture.create_form_view(table=other_table) + + handler = FieldHandler() + with pytest.raises(ViewNotInTable): + handler.create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + +@pytest.mark.django_db +def test_create_form_view_edit_row_field_wrong_table_via_api(data_fixture, api_client): + user, token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + other_table = data_fixture.create_database_table(user=user) + form_view = data_fixture.create_form_view(table=other_table) + + response = api_client.post( + reverse("api:database:fields:list", kwargs={"table_id": table.id}), + { + "name": "Edit link", + "type": "form_view_edit_row", + "form_view_id": form_view.id, + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_VIEW_NOT_IN_TABLE" + + +@pytest.mark.django_db +def test_field_excluded_from_form_view_active_options(data_fixture): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + form_view = data_fixture.create_form_view(table=table) + + handler = FieldHandler() + handler.create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + active_field_ids = [opt.field_id for opt in form_view.active_field_options] + edit_row_field_ids = list( + FormViewEditRowField.objects.filter(table=table).values_list("id", flat=True) + ) + for fid in edit_row_field_ids: + assert fid not in active_field_ids + + +@pytest.mark.django_db +def test_create_field_on_table_with_existing_rows(data_fixture, api_client): + 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, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + # Create multiple rows before adding the edit-row field. + for i in range(5): + RowHandler().create_row( + user=user, + table=table, + values={f"field_{text_field.id}": f"Row {i}"}, + ) + + response = api_client.post( + reverse("api:database:fields:list", kwargs={"table_id": table.id}), + { + "name": "Edit link", + "type": "form_view_edit_row", + "form_view_id": form_view.id, + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK, response.json() + + field_id = response.json()["id"] + model = table.get_model() + uuids = list( + model.objects.values_list(f"field_{field_id}", flat=True).order_by("id") + ) + + # All UUIDs must be non-empty and unique. + assert all(u is not None for u in uuids), f"Bad UUIDs: {uuids}" + assert len(set(uuids)) == 5, f"Duplicate UUIDs found: {uuids}" + + +@pytest.mark.django_db +def test_update_field_preserves_unique_uuids(data_fixture, api_client): + 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, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + for i in range(3): + RowHandler().create_row( + user=user, + table=table, + values={f"field_{text_field.id}": f"Row {i}"}, + ) + + response = api_client.post( + reverse("api:database:fields:list", kwargs={"table_id": table.id}), + { + "name": "Edit link", + "type": "form_view_edit_row", + "form_view_id": form_view.id, + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + field_id = response.json()["id"] + + # Update the field without changing the type (simulates a rename or + # no-op update from the frontend). + response = api_client.patch( + reverse("api:database:fields:item", kwargs={"field_id": field_id}), + { + "name": "Edit link renamed", + "type": "form_view_edit_row", + "form_view_id": form_view.id, + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + + model = table.get_model() + uuids = list( + model.objects.values_list(f"field_{field_id}", flat=True).order_by("id") + ) + + assert all(u is not None for u in uuids), f"Bad UUIDs: {uuids}" + assert len(set(uuids)) == 3, f"Duplicate UUIDs after update: {uuids}" + + +@pytest.mark.django_db +def test_row_serializer_includes_edit_url(data_fixture, api_client): + 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, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + handler = FieldHandler() + edit_field = handler.create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + row = RowHandler().create_row( + user=user, + table=table, + values={f"field_{text_field.id}": "Hello"}, + ) + + response = api_client.get( + reverse("api:database:rows:list", kwargs={"table_id": table.id}), + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + edit_url = results[0][f"field_{edit_field.id}"] + assert edit_url is not None + assert form_view.slug in edit_url + assert "edit_token=" in edit_url + + # Verify the token embedded in the URL is valid + parsed = urlparse(edit_url) + qs = parse_qs(parsed.query) + token_val = qs["edit_token"][0] + decoded = verify_and_decode_edit_token(token_val) + assert decoded is not None + assert decoded["view_slug"] == form_view.slug + assert decoded["field_id"] == edit_field.id + + # The cell_uuid in the token must match the value stored in the row. + model = table.get_model() + row_instance = model.objects.get(id=row.id) + cell_uuid = str(getattr(row_instance, f"field_{edit_field.id}")) + assert decoded["cell_uuid"] == cell_uuid + + +@pytest.mark.django_db +def test_create_field_via_api_includes_form_view_id(data_fixture, api_client): + user, token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + response = api_client.post( + reverse("api:database:fields:list", kwargs={"table_id": table.id}), + { + "name": "Edit link", + "type": "form_view_edit_row", + "form_view_id": form_view.id, + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK, response.json() + field_data = response.json() + field_id = field_data["id"] + assert field_data["type"] == "form_view_edit_row" + db_field = FormViewEditRowField.objects.get(id=field_id) + assert db_field.form_view_id == form_view.id, ( + f"form_view_id not saved in DB: got {db_field.form_view_id}" + ) + assert field_data.get("form_view_id") == form_view.id, ( + f"Expected form_view_id={form_view.id} in response but got: {field_data}" + ) + + RowHandler().create_row(user=user, table=table, values={}) + + # List rows and confirm the edit URL is present + response = api_client.get( + reverse("api:database:rows:list", kwargs={"table_id": table.id}), + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + edit_url = results[0][f"field_{field_id}"] + assert edit_url is not None, ( + f"Expected a URL for field_{field_id} but got None. Full row: {results[0]}" + ) + assert form_view.slug in edit_url + assert "edit_token=" in edit_url + + +@pytest.mark.django_db +def test_grid_view_includes_edit_url(data_fixture, api_client): + 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, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + grid_view = data_fixture.create_grid_view(table=table) + + handler = FieldHandler() + edit_field = handler.create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + row = RowHandler().create_row( + user=user, + table=table, + values={f"field_{text_field.id}": "Hello"}, + ) + + response = api_client.get( + reverse("api:database:views:grid:list", kwargs={"view_id": grid_view.id}), + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + edit_url = results[0][f"field_{edit_field.id}"] + assert edit_url is not None, ( + f"Expected a URL for field_{edit_field.id} but got None. " + f"Full row response: {results[0]}" + ) + assert form_view.slug in edit_url + assert "edit_token=" in edit_url + + parsed = urlparse(edit_url) + qs = parse_qs(parsed.query) + token_val = qs["edit_token"][0] + decoded = verify_and_decode_edit_token(token_val) + assert decoded is not None + assert decoded["view_slug"] == form_view.slug + assert decoded["field_id"] == edit_field.id + + # The cell_uuid in the token must match the stored value. + model = table.get_model() + row_instance = model.objects.get(id=row.id) + cell_uuid = str(getattr(row_instance, f"field_{edit_field.id}")) + assert decoded["cell_uuid"] == cell_uuid + + +@pytest.mark.django_db +def test_export_includes_edit_url(data_fixture): + """CSV export must include the computed edit URL for each row.""" + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + edit_field = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + RowHandler().create_row(user=user, table=table, values={}) + + from baserow.contrib.database.fields.registries import field_type_registry + + field_type = field_type_registry.get("form_view_edit_row") + model = table.get_model() + field_object = next( + fo for fo in model.get_field_objects() if fo["field"].id == edit_field.id + ) + + row_instance = model.objects.first() + value = getattr(row_instance, field_object["name"]) + export_value = field_type.get_export_value(value, field_object) + + assert export_value, "Expected a non-empty export URL" + assert form_view.slug in export_value + assert "edit_token=" in export_value + + +@pytest.mark.django_db +def test_convert_form_view_edit_row_to_text(data_fixture): + """Converting a form_view_edit_row field to a text field must not crash.""" + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + handler = FieldHandler() + edit_field = handler.create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + RowHandler().create_row(user=user, table=table, values={}) + + handler.update_field(user=user, field=edit_field, new_type_name="text") + + from baserow.contrib.database.fields.models import TextField + + assert TextField.objects.filter(id=edit_field.id).exists() + assert not FormViewEditRowField.objects.filter(id=edit_field.id).exists() + + +@pytest.mark.django_db +def test_convert_text_to_form_view_edit_row(data_fixture): + """Converting a text field to a form_view_edit_row field must not crash.""" + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + handler = FieldHandler() + text_field = handler.create_field( + user=user, + table=table, + type_name="text", + name="My field", + ) + + RowHandler().create_row( + user=user, table=table, values={f"field_{text_field.id}": "hello"} + ) + + handler.update_field( + user=user, + field=text_field, + new_type_name="form_view_edit_row", + form_view_id=form_view.id, + ) + + assert FormViewEditRowField.objects.filter(id=text_field.id).exists() + + # Verify the converted field has a UUID backfilled. + model = table.get_model() + row = model.objects.first() + cell_uuid = str(getattr(row, f"field_{text_field.id}")) + assert cell_uuid is not None and len(cell_uuid) == 36 + + +@pytest.mark.django_db +def test_duplicate_table_remaps_form_view_id(data_fixture): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + edit_field = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + assert edit_field.form_view_id == form_view.id + + new_table = TableHandler().duplicate_table(user, table) + + new_edit_field = FormViewEditRowField.objects.get(table=new_table) + # The duplicated field must NOT point to the original form view. + assert new_edit_field.form_view_id != form_view.id + # It must point to a form view that belongs to the new table. + assert new_edit_field.form_view_id is not None + assert new_edit_field.form_view.table_id == new_table.id + + +@pytest.mark.django_db +def test_rotating_slug_invalidates_edit_urls(data_fixture, api_client): + user, token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + data_fixture.create_text_field(table=table, name="Name", primary=True) + form_view = data_fixture.create_form_view(table=table, public=True) + + edit_field = FieldHandler().create_field( + user=user, + table=table, + type_name="form_view_edit_row", + name="Edit link", + form_view_id=form_view.id, + ) + + RowHandler().create_row(user=user, table=table, values={}) + + response = api_client.get( + reverse("api:database:rows:list", kwargs={"table_id": table.id}), + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + old_url = response.json()["results"][0][f"field_{edit_field.id}"] + old_slug = form_view.slug + assert old_slug in old_url + + ViewHandler().rotate_view_slug(user, form_view) + form_view.refresh_from_db() + new_slug = form_view.slug + assert new_slug != old_slug + + # Fetch the edit URL again — it must contain the new slug. + response = api_client.get( + reverse("api:database:rows:list", kwargs={"table_id": table.id}), + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + new_url = response.json()["results"][0][f"field_{edit_field.id}"] + assert new_slug in new_url + assert old_slug not in new_url diff --git a/backend/tests/baserow/contrib/database/import_export/test_export_handler.py b/backend/tests/baserow/contrib/database/import_export/test_export_handler.py index 99501d93b3..558abdc1a2 100755 --- a/backend/tests/baserow/contrib/database/import_export/test_export_handler.py +++ b/backend/tests/baserow/contrib/database/import_export/test_export_handler.py @@ -40,7 +40,10 @@ from baserow.contrib.database.views.exceptions import ViewNotInTable from baserow.contrib.database.views.models import GridView, GridViewFieldOptions from baserow.core.exceptions import PermissionDenied -from baserow.test_utils.helpers import setup_interesting_test_table +from baserow.test_utils.helpers import ( + get_form_view_edit_row_url, + setup_interesting_test_table, +) def _parse_datetime(datetime): @@ -290,9 +293,11 @@ def test_can_export_every_interesting_different_field_to_csv( ): storage_mock = MagicMock() get_storage_mock.return_value = storage_mock - contents = run_export_job_over_interesting_table( + contents, row, blank_row, context = run_export_job_over_interesting_table( data_fixture, storage_mock, {"exporter_type": "csv"} ) + form_view_edit_row_url_row1 = get_form_view_edit_row_url(context, blank_row) + form_view_edit_row_url_row2 = get_form_view_edit_row_url(context, row) # noinspection HttpUrlsUsage fields = { "id": ["1", "2"], @@ -392,6 +397,10 @@ def test_can_export_every_interesting_different_field_to_csv( ], "autonumber": ["1", "2"], "password": ["", "True"], + "form_view_edit_row": [ + form_view_edit_row_url_row1, + form_view_edit_row_url_row2, + ], "ai": ["", "I'm an AI."], "ai_choice": ["", "Object"], } @@ -430,14 +439,14 @@ def show_diff(actual, expected): def run_export_job_over_interesting_table(data_fixture, storage_mock, options): - table, user, _, _, context = setup_interesting_test_table( + table, user, row, blank_row, context = setup_interesting_test_table( data_fixture, user_kwargs={"email": "user@example.com"} ) grid_view = data_fixture.create_grid_view(table=table) job, contents = run_export_job_with_mock_storage( table, grid_view, storage_mock, user, options ) - return contents + return contents, row, blank_row, context @pytest.mark.django_db diff --git a/backend/tests/baserow/contrib/integrations/local_baserow/test_service_types.py b/backend/tests/baserow/contrib/integrations/local_baserow/test_service_types.py index 5680831cd5..e9432c9000 100644 --- a/backend/tests/baserow/contrib/integrations/local_baserow/test_service_types.py +++ b/backend/tests/baserow/contrib/integrations/local_baserow/test_service_types.py @@ -1045,6 +1045,16 @@ def reset_metadata(schema, field_name): "metadata": {}, "type": "boolean", }, + field_db_column_by_name["form_view_edit_row"]: { + "title": "form_view_edit_row", + "default": None, + "searchable": False, + "sortable": False, + "filterable": False, + "original_type": "form_view_edit_row", + "metadata": {}, + "type": None, + }, field_db_column_by_name["ai"]: { "title": "ai", "default": None, diff --git a/changelog/entries/unreleased/bug/fix_date_localization_not_working_with_non_english_languages.json b/changelog/entries/unreleased/bug/fix_date_localization_not_working_with_non_english_languages.json new file mode 100644 index 0000000000..0a85ba3da6 --- /dev/null +++ b/changelog/entries/unreleased/bug/fix_date_localization_not_working_with_non_english_languages.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fix date localization not working with non english languages", + "issue_origin": "github", + "issue_number": null, + "domain": "core", + "bullet_points": [], + "created_at": "2026-03-17" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/feature/editable_form_view_via_field_type.json b/changelog/entries/unreleased/feature/editable_form_view_via_field_type.json new file mode 100644 index 0000000000..096b322365 --- /dev/null +++ b/changelog/entries/unreleased/feature/editable_form_view_via_field_type.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Introduced field type that can edit a row via a form view.", + "issue_origin": "github", + "issue_number": 2287, + "domain": "database", + "bullet_points": [], + "created_at": "2026-02-26" +} diff --git a/changelog/entries/unreleased/refactor/remove_jira_data_sync_advocate.json b/changelog/entries/unreleased/refactor/remove_jira_data_sync_advocate.json new file mode 100644 index 0000000000..221eb1de5b --- /dev/null +++ b/changelog/entries/unreleased/refactor/remove_jira_data_sync_advocate.json @@ -0,0 +1,9 @@ +{ + "type": "refactor", + "message": "Removed the use of `advocate` for the Jira data sync so that a connection can be made to the local network.", + "issue_origin": "github", + "issue_number": null, + "domain": "database", + "bullet_points": [], + "created_at": "2026-03-16" +} diff --git a/enterprise/backend/src/baserow_enterprise/data_sync/jira_issues_data_sync.py b/enterprise/backend/src/baserow_enterprise/data_sync/jira_issues_data_sync.py index 4ceb445839..0e16b72b95 100644 --- a/enterprise/backend/src/baserow_enterprise/data_sync/jira_issues_data_sync.py +++ b/enterprise/backend/src/baserow_enterprise/data_sync/jira_issues_data_sync.py @@ -2,11 +2,10 @@ from datetime import datetime from typing import Any, Dict, List, Optional +import requests from requests.auth import HTTPBasicAuth from requests.exceptions import JSONDecodeError, RequestException -import advocate -from advocate import UnacceptableAddressException from baserow.contrib.database.data_sync.exceptions import SyncError from baserow.contrib.database.data_sync.registries import DataSyncProperty, DataSyncType from baserow.contrib.database.data_sync.utils import compare_date @@ -226,7 +225,7 @@ def _get_issue_count(self, instance, jql, headers, kwargs): url = f"{instance.jira_url}/rest/api/2/search/approximate-count" try: - response = advocate.post( + response = requests.post( url, headers={**headers, "Content-Type": "application/json"}, json={"jql": jql}, @@ -235,7 +234,7 @@ def _get_issue_count(self, instance, jql, headers, kwargs): ) if response.ok: return response.json().get("count", 0) - except (RequestException, UnacceptableAddressException, ConnectionError): + except (RequestException, ConnectionError): pass return 0 @@ -278,7 +277,7 @@ def _fetch_issues(self, instance, progress_builder: ChildProgressBuilder): if next_page_token: params["nextPageToken"] = next_page_token - response = advocate.get( + response = requests.get( url, headers=headers, params=params, timeout=10, **kwargs ) if not response.ok: @@ -307,7 +306,7 @@ def _fetch_issues(self, instance, progress_builder: ChildProgressBuilder): next_page_token = data.get("nextPageToken") if not next_page_token: break - except (RequestException, UnacceptableAddressException, ConnectionError) as e: + except (RequestException, ConnectionError) as e: raise SyncError(f"Error connecting to Jira: {str(e)}") return issues diff --git a/enterprise/backend/src/baserow_enterprise/locale/en/LC_MESSAGES/django.po b/enterprise/backend/src/baserow_enterprise/locale/en/LC_MESSAGES/django.po index 627e5114c3..2d9b9d358c 100644 --- a/enterprise/backend/src/baserow_enterprise/locale/en/LC_MESSAGES/django.po +++ b/enterprise/backend/src/baserow_enterprise/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-17 15:17+0000\n" +"POT-Creation-Date: 2026-03-16 14:50+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,21 +18,6 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: src/baserow_enterprise/assistant/assistant.py:461 -msgid "Thinking..." -msgstr "" - -#: src/baserow_enterprise/assistant/assistant.py:469 -msgid "" -"I wanted to search the documentation for you, but the search tool isn't " -"currently available.\n" -"\n" -"To enable documentation search, you'll need to set up the local knowledge " -"base. \n" -"\n" -"You can find setup instructions at: https://baserow.io/user-docs" -msgstr "" - #: src/baserow_enterprise/assistant/tools/automation/tools.py:38 msgid "Listing workflows..." msgstr "" @@ -115,17 +100,17 @@ msgstr "" msgid "Creating field %(field_name)s..." msgstr "" -#: src/baserow_enterprise/assistant/tools/database/utils.py:417 +#: src/baserow_enterprise/assistant/tools/database/utils.py:422 #, python-format msgid "Creating rows in %(table_name)s " msgstr "" -#: src/baserow_enterprise/assistant/tools/database/utils.py:458 +#: src/baserow_enterprise/assistant/tools/database/utils.py:463 #, python-format msgid "Updating rows in %(table_name)s " msgstr "" -#: src/baserow_enterprise/assistant/tools/database/utils.py:497 +#: src/baserow_enterprise/assistant/tools/database/utils.py:502 #, python-format msgid "Deleting rows in %(table_name)s " msgstr "" @@ -135,25 +120,25 @@ msgstr "" msgid "Navigating to %(location)s..." msgstr "" -#: src/baserow_enterprise/assistant/tools/search_user_docs/tools.py:102 +#: src/baserow_enterprise/assistant/tools/search_user_docs/tools.py:135 msgid "Exploring the knowledge base..." msgstr "" -#: src/baserow_enterprise/assistant/types.py:220 +#: src/baserow_enterprise/assistant/types.py:207 #, python-format msgid "table %(table_name)s" msgstr "" -#: src/baserow_enterprise/assistant/types.py:232 +#: src/baserow_enterprise/assistant/types.py:219 #, python-format msgid "view %(view_name)s" msgstr "" -#: src/baserow_enterprise/assistant/types.py:239 +#: src/baserow_enterprise/assistant/types.py:226 msgid "home" msgstr "" -#: src/baserow_enterprise/assistant/types.py:249 +#: src/baserow_enterprise/assistant/types.py:236 #, python-format msgid "workflow %(workflow_name)s" msgstr "" diff --git a/premium/backend/src/baserow_premium/locale/en/LC_MESSAGES/django.po b/premium/backend/src/baserow_premium/locale/en/LC_MESSAGES/django.po index 8cd9f628c8..0a79fbb0e6 100644 --- a/premium/backend/src/baserow_premium/locale/en/LC_MESSAGES/django.po +++ b/premium/backend/src/baserow_premium/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-13 19:58+0000\n" +"POT-Creation-Date: 2026-03-16 14:50+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,11 +18,11 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: src/baserow_premium/fields/actions.py:20 +#: src/baserow_premium/fields/actions.py:19 msgid "Generate Formula With AI" msgstr "" -#: src/baserow_premium/fields/actions.py:21 +#: src/baserow_premium/fields/actions.py:20 #, python-format msgid "Generate formula with AI using \"%(ai_type)s\" and \"%(ai_model)s\"" msgstr "" @@ -54,20 +54,20 @@ msgstr "" msgid "Comment (%(comment_id)s) has been deleted from row (%(row_id)s)" msgstr "" -#: src/baserow_premium/row_comments/notification_types.py:76 +#: src/baserow_premium/row_comments/notification_types.py:75 #, python-format msgid "%(user)s mentioned you in row %(row_name)s in %(table_name)s." msgstr "" -#: src/baserow_premium/row_comments/notification_types.py:129 +#: src/baserow_premium/row_comments/notification_types.py:128 #, python-format msgid "%(user)s posted a comment in row %(row_name)s in %(table_name)s." msgstr "" -#: src/baserow_premium/views/actions.py:27 +#: src/baserow_premium/views/actions.py:26 msgid "Calendar View ICal feed slug URL updated" msgstr "" -#: src/baserow_premium/views/actions.py:28 +#: src/baserow_premium/views/actions.py:27 msgid "View changed public ICal feed slug URL" msgstr "" diff --git a/premium/backend/tests/baserow_premium_tests/export/test_premium_export_types.py b/premium/backend/tests/baserow_premium_tests/export/test_premium_export_types.py index 26cfe096a6..ef68c0e7a5 100644 --- a/premium/backend/tests/baserow_premium_tests/export/test_premium_export_types.py +++ b/premium/backend/tests/baserow_premium_tests/export/test_premium_export_types.py @@ -11,7 +11,10 @@ from baserow.contrib.database.export.models import EXPORT_JOB_FINISHED_STATUS from baserow.contrib.database.rows.handler import RowHandler from baserow.core.storage import get_default_storage -from baserow.test_utils.helpers import setup_interesting_test_table +from baserow.test_utils.helpers import ( + get_form_view_edit_row_url, + setup_interesting_test_table, +) from baserow_premium.license.exceptions import FeaturesNotAvailableError @@ -24,12 +27,14 @@ def test_can_export_every_interesting_different_field_to_json( storage_mock = MagicMock() get_storage_mock.return_value = storage_mock - contents = run_export_over_interesting_test_table( + contents, row, blank_row, context = run_export_over_interesting_test_table( premium_data_fixture, storage_mock, {"exporter_type": "json"}, user_kwargs={"has_active_premium_license": True, "email": "user@example.com"}, ) + form_view_edit_row_url_row1 = get_form_view_edit_row_url(context, blank_row) + form_view_edit_row_url_row2 = get_form_view_edit_row_url(context, row) assert ( contents == """[ @@ -115,6 +120,7 @@ def test_can_export_every_interesting_different_field_to_json( "uuid": "00000000-0000-4000-8000-000000000001", "autonumber": 1, "password": "", + "form_view_edit_row": "FORM_VIEW_EDIT_ROW_URL_ROW1", "ai": "", "ai_choice": "" }, @@ -264,11 +270,14 @@ def test_can_export_every_interesting_different_field_to_json( "uuid": "00000000-0000-4000-8000-000000000002", "autonumber": 2, "password": true, + "form_view_edit_row": "FORM_VIEW_EDIT_ROW_URL_ROW2", "ai": "I'm an AI.", "ai_choice": "Object" } ] -""" +""".replace("FORM_VIEW_EDIT_ROW_URL_ROW1", form_view_edit_row_url_row1).replace( + "FORM_VIEW_EDIT_ROW_URL_ROW2", form_view_edit_row_url_row2 + ) ) @@ -347,12 +356,14 @@ def test_can_export_every_interesting_different_field_to_xml( ): storage_mock = MagicMock() get_storage_mock.return_value = storage_mock - xml = run_export_over_interesting_test_table( + xml, row, blank_row, context = run_export_over_interesting_test_table( premium_data_fixture, storage_mock, {"exporter_type": "xml"}, user_kwargs={"has_active_premium_license": True, "email": "user@example.com"}, ) + form_view_edit_row_url_row1 = get_form_view_edit_row_url(context, blank_row) + form_view_edit_row_url_row2 = get_form_view_edit_row_url(context, row) expected_xml = """ @@ -437,6 +448,7 @@ def test_can_export_every_interesting_different_field_to_xml( 00000000-0000-4000-8000-000000000001 1 + FORM_VIEW_EDIT_ROW_URL_ROW1 @@ -586,11 +598,15 @@ def test_can_export_every_interesting_different_field_to_xml( 00000000-0000-4000-8000-000000000002 2 true + FORM_VIEW_EDIT_ROW_URL_ROW2 I'm an AI. Object """ + expected_xml = expected_xml.replace( + "FORM_VIEW_EDIT_ROW_URL_ROW1", form_view_edit_row_url_row1 + ).replace("FORM_VIEW_EDIT_ROW_URL_ROW2", form_view_edit_row_url_row2) assert strip_indents_and_newlines(xml) == strip_indents_and_newlines(expected_xml) @@ -679,14 +695,14 @@ def strip_indents_and_newlines(xml): def run_export_over_interesting_test_table( premium_data_fixture, storage_mock, options, user_kwargs=None, user=None ): - table, user, _, _, context = setup_interesting_test_table( + table, user, row, blank_row, context = setup_interesting_test_table( premium_data_fixture, user_kwargs=user_kwargs, user=user ) grid_view = premium_data_fixture.create_grid_view(table=table) job, contents = run_export_job_with_mock_storage( table, grid_view, storage_mock, user, options ) - return contents + return contents, row, blank_row, context def run_export_job_with_mock_storage( @@ -739,7 +755,7 @@ def test_can_export_every_interesting_different_field_to_excel( storage_mock = MagicMock() get_storage_mock.return_value = storage_mock - contents = run_export_over_interesting_test_table( + contents, row, blank_row, context = run_export_over_interesting_test_table( premium_data_fixture, storage_mock, { @@ -749,6 +765,8 @@ def test_can_export_every_interesting_different_field_to_excel( }, user_kwargs={"has_active_premium_license": True, "email": "user@example.com"}, ) + form_view_edit_row_url_row1 = get_form_view_edit_row_url(context, blank_row) + form_view_edit_row_url_row2 = get_form_view_edit_row_url(context, row) excel_file = BytesIO(contents) workbook = load_workbook(excel_file) @@ -829,6 +847,7 @@ def test_can_export_every_interesting_different_field_to_excel( "uuid", "autonumber", "password", + "form_view_edit_row", "ai", "ai_choice", ] @@ -907,6 +926,7 @@ def test_can_export_every_interesting_different_field_to_excel( "00000000-0000-4000-8000-000000000001", "1", None, + form_view_edit_row_url_row1, None, None, ] @@ -985,6 +1005,7 @@ def test_can_export_every_interesting_different_field_to_excel( "00000000-0000-4000-8000-000000000002", "2", "True", + form_view_edit_row_url_row2, "I'm an AI.", "Object", ] @@ -1011,7 +1032,7 @@ def test_can_export_every_interesting_different_field_to_excel_without_header( storage_mock = MagicMock() get_storage_mock.return_value = storage_mock - contents = run_export_over_interesting_test_table( + contents, _, _, _ = run_export_over_interesting_test_table( premium_data_fixture, storage_mock, {"exporter_type": "excel", "export_charset": None}, diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index 85d838cff9..19dd1c7455 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -164,6 +164,8 @@ "singleSelectRadios": "Radios", "autonumber": "Autonumber", "password": "Password", + "formViewEditRow": "Edit row link", + "formViewEditRowShareWarning": "Anyone with the shared link can edit rows via the {formNames} form through the publicly visible edit row link fields.|Anyone with the shared link can edit rows via the {formNames} forms through the publicly visible edit row link field.", "ai": "AI prompt", "multipleCollaboratorsDropdown": "Dropdown", "multipleCollaboratorsCheckboxes": "Checkboxes" @@ -213,7 +215,8 @@ "multipleCollaborators": "Accepts an array of objects where each object contains a user's id.", "uuid": "A read-only unique and persistent uuid.", "autonumber": "A read-only field that automatically increments a number for each new row.", - "password": "A write-only field that holds a hashed password. The value will be `null` if not set, or `true` if it has been set. It accepts a string to set it." + "password": "A write-only field that holds a hashed password. The value will be `null` if not set, or `true` if it has been set. It accepts a string to set it.", + "formViewEditRow": "A read-only field that generates a unique, signed URL allowing the row to be edited through the linked form view." }, "fieldConstraint": { "uniqueWithEmpty": "Unique with empty" diff --git a/web-frontend/modules/core/locales/en.json b/web-frontend/modules/core/locales/en.json index 647bd575db..6b7ac378a8 100644 --- a/web-frontend/modules/core/locales/en.json +++ b/web-frontend/modules/core/locales/en.json @@ -635,6 +635,9 @@ "settingsVerifyImportSignatureDescription": "When enabled, the signature of the imported data is verified to ensure the data has not been tampered with." }, "formSidebar": { + "fields": "Fields", + "rowEditableBy": "Update rows via fields", + "rowEditableByDescription": "The rows in this table can be updated using this form via the following row edit fields:", "actions": { "addAll": "Add all", "removeAll": "Remove all", diff --git a/web-frontend/modules/core/moment.js b/web-frontend/modules/core/moment.js index 081c9835e6..981e20fd36 100644 --- a/web-frontend/modules/core/moment.js +++ b/web-frontend/modules/core/moment.js @@ -2,5 +2,13 @@ // is always included. There were some problems when Baserow is installed as a // dependency and then moment-timezone does not work. Still will resolve that issue. import moment from 'moment-timezone' +import 'moment/dist/locale/fr' +import 'moment/dist/locale/nl' +import 'moment/dist/locale/de' +import 'moment/dist/locale/es' +import 'moment/dist/locale/it' +import 'moment/dist/locale/pl' +import 'moment/dist/locale/ko' +import 'moment/dist/locale/uk' export default moment diff --git a/web-frontend/modules/database/components/card/RowCardFieldFormViewEditRow.vue b/web-frontend/modules/database/components/card/RowCardFieldFormViewEditRow.vue new file mode 100644 index 0000000000..ebc1def74e --- /dev/null +++ b/web-frontend/modules/database/components/card/RowCardFieldFormViewEditRow.vue @@ -0,0 +1,33 @@ + + + diff --git a/web-frontend/modules/database/components/field/FieldFormViewEditRowSubForm.vue b/web-frontend/modules/database/components/field/FieldFormViewEditRowSubForm.vue new file mode 100644 index 0000000000..7c16ee3b9f --- /dev/null +++ b/web-frontend/modules/database/components/field/FieldFormViewEditRowSubForm.vue @@ -0,0 +1,140 @@ + + + diff --git a/web-frontend/modules/database/components/row/RowEditFieldFormViewEditRow.vue b/web-frontend/modules/database/components/row/RowEditFieldFormViewEditRow.vue new file mode 100644 index 0000000000..e6771ddcac --- /dev/null +++ b/web-frontend/modules/database/components/row/RowEditFieldFormViewEditRow.vue @@ -0,0 +1,42 @@ + + + diff --git a/web-frontend/modules/database/components/table/Table.vue b/web-frontend/modules/database/components/table/Table.vue index cd66da77a8..57850ee549 100644 --- a/web-frontend/modules/database/components/table/Table.vue +++ b/web-frontend/modules/database/components/table/Table.vue @@ -167,7 +167,13 @@ " class="header__filter-item" > - +
  • + + + +

    + {{ warning }} +

    +
    [], + }, + views: { + type: Array, + required: false, + default: () => [], + }, + storePrefix: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -257,6 +286,33 @@ export default { additionalSharingSections() { return this.viewType.getAdditionalSharingSections() }, + visibleFields() { + return this.viewType.getVisibleFieldsInOrder( + this, + this.fields, + this.view, + this.storePrefix + ) + }, + shareViewWarnings() { + const context = { view: this.view, allViews: this.views } + const fieldsByType = {} + for (const field of this.visibleFields) { + if (!fieldsByType[field.type]) { + fieldsByType[field.type] = [] + } + fieldsByType[field.type].push(field) + } + const warnings = [] + for (const [type, fields] of Object.entries(fieldsByType)) { + const fieldType = this.$registry.get('field', type) + const warning = fieldType.getShareViewWarning(fields, context) + if (warning) { + warnings.push(warning) + } + } + return warnings + }, }, methods: { copyShareUrlToClipboard() { diff --git a/web-frontend/modules/database/components/view/form/FormViewSidebar.vue b/web-frontend/modules/database/components/view/form/FormViewSidebar.vue index 59463602ec..c45475ae81 100644 --- a/web-frontend/modules/database/components/view/form/FormViewSidebar.vue +++ b/web-frontend/modules/database/components/view/form/FormViewSidebar.vue @@ -2,7 +2,9 @@
    -
    Fields
    +
    + {{ $t('formSidebar.fields') }} +
    +
    +

    + + {{ $t('formSidebar.rowEditableByDescription') }} +

    +
    +
    + + +
    + {{ field.name }} +
    +
    +
    +
    +