Skip to content

Commit 019d02e

Browse files
authored
feat: edit row via form view field (baserow#4886)
1 parent 297e2fc commit 019d02e

49 files changed

Lines changed: 2551 additions & 196 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/src/baserow/contrib/database/api/views/form/errors.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,8 @@
3535
HTTP_400_BAD_REQUEST,
3636
"The provided form view field options condition group does not exists.",
3737
)
38+
ERROR_INVALID_EDIT_ROW_TOKEN = (
39+
"ERROR_INVALID_EDIT_ROW_TOKEN",
40+
HTTP_404_NOT_FOUND,
41+
"The provided edit token is invalid or does not match the requested row.",
42+
)

backend/src/baserow/contrib/database/api/views/form/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ class FormViewFieldOptionsConditionGroupDoesNotExist(Exception):
77
Raised when the provided form view field options condition group does not
88
exists.
99
"""
10+
11+
12+
class InvalidEditRowTokenError(Exception):
13+
"""Raised when the provided edit row token is invalid or does not match."""

backend/src/baserow/contrib/database/api/views/form/urls.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from django.urls import re_path
22

3-
from .views import FormUploadFileView, SubmitFormViewView
3+
from .views import EditRowFormViewView, FormUploadFileView, SubmitFormViewView
44

55
app_name = "baserow.contrib.database.api.views.form"
66

@@ -15,4 +15,9 @@
1515
FormUploadFileView.as_view(),
1616
name="upload_file",
1717
),
18+
re_path(
19+
r"(?P<slug>[-\w]+)/edit-row/(?P<row_token>[^/]+)/$",
20+
EditRowFormViewView.as_view(),
21+
name="edit_row",
22+
),
1823
]
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from typing import Tuple
2+
3+
from rest_framework.fields import empty
4+
5+
from baserow.contrib.database.fields.models import FormViewEditRowField
6+
from baserow.contrib.database.fields.utils.row_edit import verify_and_decode_edit_token
7+
from baserow.contrib.database.views.models import FormView
8+
from baserow.contrib.database.views.validators import (
9+
allow_only_specific_select_options_factory,
10+
no_empty_form_values_when_required_validator,
11+
)
12+
13+
from .exceptions import InvalidEditRowTokenError
14+
15+
16+
def decode_and_validate_edit_token(form: FormView, token: str) -> Tuple[str, int]:
17+
"""
18+
Decode the edit token and validate it against the given form view.
19+
20+
The token payload must contain `view_slug`, `field_id`, and
21+
`cell_uuid`. Validation checks:
22+
23+
1. The token signature is valid.
24+
2. The `view_slug` matches the form view's current slug (rotating the
25+
slug invalidates all existing tokens).
26+
3. The `field_id` references a `FormViewEditRowField` linked to this
27+
form view.
28+
29+
:param form: The form view the token must belong to.
30+
:param token: The signed edit token string.
31+
:raises InvalidEditRowTokenError: If the token is missing, invalid, or
32+
does not match the form view.
33+
:return: A (cell_uuid, field_id) tuple extracted from the valid token.
34+
"""
35+
36+
if not token:
37+
raise InvalidEditRowTokenError()
38+
39+
data = verify_and_decode_edit_token(token)
40+
if data is None or data.get("view_slug") != form.slug:
41+
raise InvalidEditRowTokenError()
42+
43+
field_id = data.get("field_id")
44+
if not FormViewEditRowField.objects.filter(id=field_id, form_view=form).exists():
45+
raise InvalidEditRowTokenError()
46+
47+
return data["cell_uuid"], field_id
48+
49+
50+
def build_field_kwargs_for_options(model, options, enforce_required=False):
51+
"""
52+
Builds `field_kwargs` for the row serializer based on the form view's
53+
active field options.
54+
55+
When *enforce_required* is ``True`` (used by the submit endpoint), fields
56+
marked as required will get ``required=True`` and the "not empty" validator.
57+
"""
58+
59+
field_kwargs = {}
60+
for option in options:
61+
validators = []
62+
o = {}
63+
if enforce_required and option.is_required():
64+
o["required"] = True
65+
o["default"] = empty
66+
validators.append(no_empty_form_values_when_required_validator)
67+
if not option.include_all_select_options:
68+
validators.append(
69+
allow_only_specific_select_options_factory(
70+
[
71+
allowed_select_option.id
72+
for allowed_select_option in option.allowed_select_options.all()
73+
]
74+
)
75+
)
76+
if len(validators) > 0 and len(o) > 0:
77+
name = model._field_objects[option.field_id]["name"]
78+
o["validators"] = validators
79+
field_kwargs[name] = o
80+
return field_kwargs

backend/src/baserow/contrib/database/api/views/form/views.py

Lines changed: 193 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
from django.core.exceptions import ValidationError
12
from django.db import transaction
23

34
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
45
from drf_spectacular.utils import extend_schema
5-
from rest_framework.fields import empty
66
from rest_framework.parsers import MultiPartParser
77
from rest_framework.permissions import AllowAny
88
from rest_framework.request import Request
@@ -15,7 +15,10 @@
1515
from baserow.api.user_files.serializers import UserFileSerializer
1616
from baserow.api.utils import validate_data
1717
from baserow.contrib.database.api.fields.errors import ERROR_FIELD_DATA_CONSTRAINT
18-
from baserow.contrib.database.api.rows.errors import ERROR_CANNOT_CREATE_ROWS_IN_TABLE
18+
from baserow.contrib.database.api.rows.errors import (
19+
ERROR_CANNOT_CREATE_ROWS_IN_TABLE,
20+
ERROR_ROW_DOES_NOT_EXIST,
21+
)
1922
from baserow.contrib.database.api.rows.serializers import (
2023
get_example_row_serializer_class,
2124
get_row_serializer_class,
@@ -26,20 +29,25 @@
2629
)
2730
from baserow.contrib.database.api.views.utils import get_public_view_authorization_token
2831
from baserow.contrib.database.fields.exceptions import FieldDataConstraintException
29-
from baserow.contrib.database.fields.models import FileField, LongTextField
30-
from baserow.contrib.database.rows.exceptions import CannotCreateRowsInTable
31-
from baserow.contrib.database.views.actions import SubmitFormActionType
32+
from baserow.contrib.database.fields.models import (
33+
FileField,
34+
LongTextField,
35+
)
36+
from baserow.contrib.database.rows.exceptions import (
37+
CannotCreateRowsInTable,
38+
RowDoesNotExist,
39+
)
40+
from baserow.contrib.database.views.actions import (
41+
EditFormRowActionType,
42+
SubmitFormActionType,
43+
)
3244
from baserow.contrib.database.views.exceptions import (
3345
NoAuthorizationToPubliclySharedView,
3446
ViewDoesNotExist,
3547
)
3648
from baserow.contrib.database.views.handler import ViewHandler
3749
from baserow.contrib.database.views.models import FormView
3850
from baserow.contrib.database.views.registries import view_ownership_type_registry
39-
from baserow.contrib.database.views.validators import (
40-
allow_only_specific_select_options_factory,
41-
no_empty_form_values_when_required_validator,
42-
)
4351
from baserow.core.action.registries import action_type_registry
4452
from baserow.core.user_files.exceptions import (
4553
FileSizeTooLargeError,
@@ -49,11 +57,16 @@
4957

5058
from .errors import (
5159
ERROR_FORM_DOES_NOT_EXIST,
60+
ERROR_INVALID_EDIT_ROW_TOKEN,
5261
ERROR_NO_PERMISSION_TO_PUBLICLY_SHARED_FORM,
5362
ERROR_VIEW_HAS_NO_PUBLIC_FILE_FIELD,
5463
)
55-
from .exceptions import ViewHasNoPublicFileFieldError
64+
from .exceptions import (
65+
InvalidEditRowTokenError,
66+
ViewHasNoPublicFileFieldError,
67+
)
5668
from .serializers import FormViewSubmittedSerializer, PublicFormViewSerializer
69+
from .utils import build_field_kwargs_for_options, decode_and_validate_edit_token
5770

5871

5972
class SubmitFormViewView(APIView):
@@ -146,27 +159,9 @@ def post(self, request: Request, slug: str) -> Response:
146159
model = form.table.get_model()
147160

148161
options = form.active_field_options
149-
field_kwargs = {}
150-
for option in options:
151-
validators = []
152-
o = {}
153-
if option.is_required():
154-
o["required"] = True
155-
o["default"] = empty
156-
validators.append(no_empty_form_values_when_required_validator)
157-
if not option.include_all_select_options:
158-
validators.append(
159-
allow_only_specific_select_options_factory(
160-
[
161-
allowed_select_option.id
162-
for allowed_select_option in option.allowed_select_options.all()
163-
]
164-
)
165-
)
166-
if len(validators) > 0 and len(o) > 0:
167-
name = model._field_objects[option.field_id]["name"]
168-
o["validators"] = validators
169-
field_kwargs[name] = o
162+
field_kwargs = build_field_kwargs_for_options(
163+
model, options, enforce_required=True
164+
)
170165

171166
field_ids = [option.field_id for option in options]
172167
validation_serializer = get_row_serializer_class(
@@ -183,6 +178,173 @@ def post(self, request: Request, slug: str) -> Response:
183178
return Response(FormViewSubmittedSerializer(form).data)
184179

185180

181+
class EditRowFormViewView(APIView):
182+
permission_classes = (AllowAny,)
183+
184+
@extend_schema(
185+
parameters=[
186+
OpenApiParameter(
187+
name="slug",
188+
location=OpenApiParameter.PATH,
189+
type=OpenApiTypes.STR,
190+
required=True,
191+
description="The slug of the form view.",
192+
),
193+
OpenApiParameter(
194+
name="row_token",
195+
location=OpenApiParameter.PATH,
196+
type=OpenApiTypes.STR,
197+
required=True,
198+
description="The signed edit token that identifies the row to edit.",
199+
),
200+
],
201+
tags=["Database table form view"],
202+
operation_id="get_edit_row_database_table_form_view",
203+
description=(
204+
"Returns the current field values of the row identified by the edit token. "
205+
"Only fields visible in the form view are returned. The token must be a "
206+
"valid signed token generated by a form_view_edit_row field."
207+
),
208+
responses={
209+
200: get_example_row_serializer_class(example_type="get"),
210+
401: get_error_schema(
211+
[
212+
"ERROR_NO_PERMISSION_TO_PUBLICLY_SHARED_FORM",
213+
]
214+
),
215+
404: get_error_schema(
216+
[
217+
"ERROR_FORM_DOES_NOT_EXIST",
218+
"ERROR_ROW_DOES_NOT_EXIST",
219+
"ERROR_INVALID_EDIT_ROW_TOKEN",
220+
]
221+
),
222+
},
223+
)
224+
@map_exceptions(
225+
{
226+
ViewDoesNotExist: ERROR_FORM_DOES_NOT_EXIST,
227+
NoAuthorizationToPubliclySharedView: ERROR_NO_PERMISSION_TO_PUBLICLY_SHARED_FORM,
228+
InvalidEditRowTokenError: ERROR_INVALID_EDIT_ROW_TOKEN,
229+
RowDoesNotExist: ERROR_ROW_DOES_NOT_EXIST,
230+
}
231+
)
232+
def get(self, request: Request, slug: str, row_token: str) -> Response:
233+
handler = ViewHandler()
234+
form = handler.get_public_view_by_slug(
235+
request.user,
236+
slug,
237+
view_model=FormView,
238+
authorization_token=get_public_view_authorization_token(request),
239+
)
240+
cell_uuid, field_id = decode_and_validate_edit_token(form, row_token)
241+
242+
model = form.table.get_model()
243+
field_column = f"field_{field_id}"
244+
245+
try:
246+
row = model.objects.get(**{field_column: cell_uuid})
247+
except (model.DoesNotExist, ValidationError):
248+
raise RowDoesNotExist(cell_uuid)
249+
250+
options = form.active_field_options
251+
field_ids = [option.field_id for option in options]
252+
253+
serializer_class = get_row_serializer_class(
254+
model, is_response=True, field_ids=field_ids
255+
)
256+
return Response(serializer_class(row).data)
257+
258+
@extend_schema(
259+
parameters=[
260+
OpenApiParameter(
261+
name="slug",
262+
location=OpenApiParameter.PATH,
263+
type=OpenApiTypes.STR,
264+
required=True,
265+
description="The slug of the form view.",
266+
),
267+
OpenApiParameter(
268+
name="row_token",
269+
location=OpenApiParameter.PATH,
270+
type=OpenApiTypes.STR,
271+
required=True,
272+
description="The signed edit token that identifies the row to edit.",
273+
),
274+
],
275+
tags=["Database table form view"],
276+
operation_id="update_edit_row_database_table_form_view",
277+
description=(
278+
"Updates the row identified by the edit token using the submitted field "
279+
"values. Only fields that are visible in the form view can be changed. "
280+
"The `row_token` must be a valid signed token generated by a "
281+
"form_view_edit_row field."
282+
),
283+
request=get_example_row_serializer_class(example_type="patch"),
284+
responses={
285+
200: FormViewSubmittedSerializer,
286+
401: get_error_schema(
287+
[
288+
"ERROR_NO_PERMISSION_TO_PUBLICLY_SHARED_FORM",
289+
]
290+
),
291+
400: get_error_schema(["ERROR_FIELD_DATA_CONSTRAINT"]),
292+
404: get_error_schema(
293+
[
294+
"ERROR_FORM_DOES_NOT_EXIST",
295+
"ERROR_ROW_DOES_NOT_EXIST",
296+
"ERROR_INVALID_EDIT_ROW_TOKEN",
297+
]
298+
),
299+
},
300+
)
301+
@map_exceptions(
302+
{
303+
ViewDoesNotExist: ERROR_FORM_DOES_NOT_EXIST,
304+
NoAuthorizationToPubliclySharedView: ERROR_NO_PERMISSION_TO_PUBLICLY_SHARED_FORM,
305+
InvalidEditRowTokenError: ERROR_INVALID_EDIT_ROW_TOKEN,
306+
RowDoesNotExist: ERROR_ROW_DOES_NOT_EXIST,
307+
FieldDataConstraintException: ERROR_FIELD_DATA_CONSTRAINT,
308+
}
309+
)
310+
@transaction.atomic
311+
def patch(self, request: Request, slug: str, row_token: str) -> Response:
312+
handler = ViewHandler()
313+
form = handler.get_public_view_by_slug(
314+
request.user,
315+
slug,
316+
view_model=FormView,
317+
authorization_token=get_public_view_authorization_token(request),
318+
)
319+
320+
cell_uuid, field_id = decode_and_validate_edit_token(form, row_token)
321+
data = request.data
322+
323+
model = form.table.get_model()
324+
field_column = f"field_{field_id}"
325+
326+
try:
327+
row = model.objects.get(**{field_column: cell_uuid})
328+
except (model.DoesNotExist, ValidationError):
329+
raise RowDoesNotExist(cell_uuid)
330+
331+
options = form.active_field_options
332+
field_kwargs = build_field_kwargs_for_options(model, options)
333+
334+
field_ids = [option.field_id for option in options]
335+
validation_serializer = get_row_serializer_class(
336+
model, field_ids=field_ids, field_kwargs=field_kwargs
337+
)
338+
values = validate_data(validation_serializer, data, return_validated=True)
339+
340+
updated_row = action_type_registry.get_by_type(EditFormRowActionType).do(
341+
request.user, form, row.id, values, model, options
342+
)
343+
344+
form.row_id = updated_row.id
345+
return Response(FormViewSubmittedSerializer(form).data)
346+
347+
186348
class FormUploadFileView(APIView):
187349
permission_classes = (AllowAny,)
188350
parser_classes = (MultiPartParser,)

0 commit comments

Comments
 (0)