Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/src/baserow/contrib/database/api/views/form/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
7 changes: 6 additions & 1 deletion backend/src/baserow/contrib/database/api/views/form/urls.py
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -15,4 +15,9 @@
FormUploadFileView.as_view(),
name="upload_file",
),
re_path(
r"(?P<slug>[-\w]+)/edit-row/(?P<row_token>[^/]+)/$",
EditRowFormViewView.as_view(),
name="edit_row",
),
]
80 changes: 80 additions & 0 deletions backend/src/baserow/contrib/database/api/views/form/utils.py
Original file line number Diff line number Diff line change
@@ -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
224 changes: 193 additions & 31 deletions backend/src/baserow/contrib/database/api/views/form/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -26,20 +29,25 @@
)
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,
)
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,
Expand All @@ -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):
Expand Down Expand Up @@ -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(
Expand All @@ -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,)
Expand Down
Loading
Loading