1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-04 13:15:24 +00:00

Merge branch '710-sharegridview-migrate-slug-public-from-form-view-to-all-views' into 'develop'

Resolve "📨 ShareGridView: Migrate slug+public from form view to all views"

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2021-12-27 10:45:33 +00:00
commit 3e9fb65840
22 changed files with 585 additions and 192 deletions

View file

@ -61,3 +61,8 @@ ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS = (
HTTP_400_BAD_REQUEST,
"This view model does not support field options.",
)
ERROR_CANNOT_SHARE_VIEW_TYPE = (
"ERROR_CANNOT_SHARE_VIEW_TYPE",
HTTP_400_BAD_REQUEST,
"This view type does not support sharing.",
)

View file

@ -1,20 +1,13 @@
from django.urls import re_path
from .views import (
RotateFormViewSlugView,
SubmitFormViewView,
FormViewLinkRowFieldLookupView,
)
app_name = "baserow.contrib.database.api.views.form"
urlpatterns = [
re_path(
r"(?P<view_id>[0-9]+)/rotate-slug/$",
RotateFormViewSlugView.as_view(),
name="rotate_slug",
),
re_path(
r"(?P<slug>[-\w]+)/submit/$",
SubmitFormViewView.as_view(),

View file

@ -1,6 +1,6 @@
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.fields import empty
@ -9,12 +9,10 @@ from django.contrib.contenttypes.models import ContentType
from django.conf import settings
from baserow.api.decorators import map_exceptions
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP
from baserow.api.schemas import get_error_schema
from baserow.api.utils import validate_data
from baserow.api.pagination import PageNumberPagination
from baserow.api.serializers import get_example_pagination_serializer_class
from baserow.contrib.database.api.views.errors import ERROR_VIEW_DOES_NOT_EXIST
from baserow.contrib.database.api.rows.serializers import (
get_row_serializer_class,
get_example_row_serializer_class,
@ -25,60 +23,12 @@ from baserow.contrib.database.fields.models import LinkRowField
from baserow.contrib.database.fields.exceptions import FieldDoesNotExist
from baserow.contrib.database.views.exceptions import ViewDoesNotExist
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.models import FormView, FormViewFieldOptions
from baserow.contrib.database.views.registries import view_type_registry
from baserow.contrib.database.views.models import FormViewFieldOptions, FormView
from baserow.contrib.database.views.validators import required_validator
from baserow.core.exceptions import UserNotInGroup
from .errors import ERROR_FORM_DOES_NOT_EXIST
from .serializers import PublicFormViewSerializer, FormViewSubmittedSerializer
form_view_serializer_class = view_type_registry.get("form").get_serializer_class()
class RotateFormViewSlugView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name="view_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
required=True,
description="Rotates the slug of the form view related to the provided "
"value.",
)
],
tags=["Database table form view"],
operation_id="rotate_database_table_form_view_slug",
description=(
"Rotates the unique slug of the form view by replacing it with a new "
"value. This would mean that the publicly shared URL of the form will "
"change. Everyone that knew the URL won't have access to the form anymore."
),
request=None,
responses={
200: form_view_serializer_class(many=True),
400: get_error_schema(["ERROR_USER_NOT_IN_GROUP"]),
404: get_error_schema(["ERROR_VIEW_DOES_NOT_EXIST"]),
},
)
@map_exceptions(
{
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
}
)
@transaction.atomic
def post(self, request, view_id):
"""Rotates the slug of a form view."""
handler = ViewHandler()
form = ViewHandler().get_view(view_id, FormView)
form = handler.rotate_form_view_slug(request.user, form)
return Response(form_view_serializer_class(form).data)
class SubmitFormViewView(APIView):
permission_classes = (AllowAny,)
@ -111,7 +61,9 @@ class SubmitFormViewView(APIView):
}
)
def get(self, request, slug):
form = ViewHandler().get_public_form_view_by_slug(request.user, slug)
form = ViewHandler().get_public_view_by_slug(
request.user, slug, view_model=FormView
)
serializer = PublicFormViewSerializer(form)
return Response(serializer.data)
@ -147,7 +99,7 @@ class SubmitFormViewView(APIView):
@transaction.atomic
def post(self, request, slug):
handler = ViewHandler()
form = handler.get_public_form_view_by_slug(request.user, slug)
form = handler.get_public_view_by_slug(request.user, slug, view_model=FormView)
model = form.table.get_model()
options = form.active_field_options
@ -215,7 +167,7 @@ class FormViewLinkRowFieldLookupView(APIView):
)
def get(self, request, slug, field_id):
handler = ViewHandler()
form = handler.get_public_form_view_by_slug(request.user, slug)
form = handler.get_public_view_by_slug(request.user, slug, view_model=FormView)
link_row_field_content_type = ContentType.objects.get_for_model(LinkRowField)
try:

View file

@ -171,7 +171,10 @@ class ViewSerializer(serializers.ModelSerializer):
"sortings",
"filters_disabled",
)
extra_kwargs = {"id": {"read_only": True}, "table_id": {"read_only": True}}
extra_kwargs = {
"id": {"read_only": True},
"table_id": {"read_only": True},
}
def __init__(self, *args, **kwargs):
context = kwargs.setdefault("context", {})

View file

@ -11,6 +11,7 @@ from .views import (
ViewSortingsView,
ViewSortView,
ViewFieldOptionsView,
RotateViewSlugView,
)
@ -43,4 +44,9 @@ urlpatterns = view_type_registry.api_urls + [
ViewFieldOptionsView.as_view(),
name="field_options",
),
re_path(
r"(?P<view_id>[0-9]+)/rotate-slug/$",
RotateViewSlugView.as_view(),
name="rotate_slug",
),
]

View file

@ -46,6 +46,7 @@ from baserow.contrib.database.views.exceptions import (
ViewSortFieldNotSupported,
UnrelatedFieldError,
ViewDoesNotSupportFieldOptions,
CannotShareViewTypeError,
)
from .serializers import (
@ -72,6 +73,7 @@ from .errors import (
ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED,
ERROR_UNRELATED_FIELD,
ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS,
ERROR_CANNOT_SHARE_VIEW_TYPE,
)
@ -1040,3 +1042,55 @@ class ViewFieldOptionsView(APIView):
serializer = serializer_class(view)
return Response(serializer.data)
class RotateViewSlugView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name="view_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
required=True,
description="Rotates the slug of the view related to the provided "
"value.",
)
],
tags=["Database table views"],
operation_id="rotate_database_view_slug",
description=(
"Rotates the unique slug of the view by replacing it with a new "
"value. This would mean that the publicly shared URL of the view will "
"change. Anyone with the old URL won't be able to access the view"
"anymore. Only view types which are sharable can have their slugs rotated."
),
request=None,
responses={
200: DiscriminatorCustomFieldsMappingSerializer(
view_type_registry,
ViewSerializer,
),
400: get_error_schema(
["ERROR_USER_NOT_IN_GROUP", "ERROR_CANNOT_SHARE_VIEW_TYPE"]
),
404: get_error_schema(["ERROR_VIEW_DOES_NOT_EXIST"]),
},
)
@map_exceptions(
{
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST,
CannotShareViewTypeError: ERROR_CANNOT_SHARE_VIEW_TYPE,
}
)
@transaction.atomic
def post(self, request, view_id):
"""Rotates the slug of a view."""
handler = ViewHandler()
view = ViewHandler().get_view(view_id)
view = handler.rotate_view_slug(request.user, view)
serializer = view_type_registry.get_serializer(view, ViewSerializer)
return Response(serializer.data)

View file

@ -0,0 +1,141 @@
# Generated by Django 3.2.6 on 2021-12-14 09:16
import math
import secrets
from django.db import migrations, models
from tqdm import tqdm
def update_batch_size():
# Exists as a function purely so tests can mock the return_value
return 1000
def reverse(apps, schema_editor):
FormView = apps.get_model("database", "FormView")
print("Migrating view public and slug properties back to form view...")
for f in FormView.objects.all():
view = f.view_ptr
f.public = view.public_temp
f.slug = view.slug_temp
f.save()
# noinspection PyPep8Naming
def forward(apps, schema_editor):
FormView = apps.get_model("database", "FormView")
View = apps.get_model("database", "View")
_copy_slug_and_public_from_form_to_view(FormView, View)
_generate_slugs_for_views(View)
def _copy_slug_and_public_from_form_to_view(FormView, View):
print("Migrating form view public and slug properties to View...")
updated_form_views = []
for f in FormView.objects.all():
view = f.view_ptr
view.public_temp = f.public
view.slug_temp = f.slug
updated_form_views.append(view)
View.objects.bulk_update(updated_form_views, fields=["public_temp", "slug_temp"])
print("Done with form view")
def _generate_slugs_for_views(View):
views_to_generate_slugs_for = View.objects.filter(slug_temp__isnull=True)
view_count = views_to_generate_slugs_for.count()
batch_size = update_batch_size()
updated_views = []
# Use tqdm in manual mode as it doesn't work nicely wrapping generators like
# .iterator()
with tqdm(
total=math.ceil(view_count / batch_size),
desc=f"Generating slugs for {view_count} views in batches of {batch_size}",
) as pbar:
for view in views_to_generate_slugs_for.iterator():
view.slug_temp = secrets.token_urlsafe()
updated_views.append(view)
if len(updated_views) >= batch_size:
View.objects.bulk_update(updated_views, fields=["slug_temp"])
updated_views.clear()
pbar.update(1)
View.objects.bulk_update(updated_views, fields=["slug_temp"])
pbar.update(1)
class Migration(migrations.Migration):
dependencies = [
("database", "0052_table_order_and_id_index"),
]
operations = [
migrations.AddField(
model_name="view",
name="public_temp",
field=models.BooleanField(
default=False,
help_text="Indicates whether the view is publicly accessible to "
"visitors.",
),
),
migrations.AddField(
model_name="view",
name="slug_temp",
field=models.SlugField(
null=True,
blank=True,
help_text="The unique slug where the view can be accessed publicly on.",
unique=True,
db_index=True,
),
),
# Ensure that the slug is nullable on the formview so when migrating backwards
# so that the field is created filled with nulls which we then copy
# view.slug into. The reverse of this AlterField will set null=False,
# blank=False, default=secrets.token_urlsafe.
migrations.AlterField(
model_name="formview",
name="slug",
field=models.SlugField(
help_text="The unique slug where the form can be accessed "
"publicly on.",
null=True,
blank=True,
unique=True,
db_index=True,
),
),
migrations.RunPython(forward, reverse),
migrations.AlterField(
model_name="view",
name="slug_temp",
field=models.SlugField(
help_text="The unique slug where the view can be accessed publicly on.",
default=secrets.token_urlsafe,
unique=True,
db_index=True,
),
),
migrations.RemoveField(
model_name="formview",
name="public",
),
migrations.RemoveField(
model_name="formview",
name="slug",
),
migrations.RenameField(
model_name="view",
old_name="public_temp",
new_name="public",
),
migrations.RenameField(
model_name="view",
old_name="slug_temp",
new_name="slug",
),
]

View file

@ -8,6 +8,10 @@ class ViewDoesNotExist(Exception):
"""Raised when trying to get a view that doesn't exist."""
class CannotShareViewTypeError(Exception):
"""Raised when trying to a share a view that cannot be shared"""
class ViewNotInTable(Exception):
"""Raised when a provided view does not belong to a table."""

View file

@ -1,19 +1,19 @@
from django.db.models import F
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.db.models import F
from baserow.contrib.database.fields.exceptions import FieldNotInTable
from baserow.contrib.database.fields.field_filters import FilterBuilder
from baserow.contrib.database.fields.field_sortings import AnnotatedOrder
from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.rows.signals import row_created
from baserow.core.trash.handler import TrashHandler
from baserow.core.utils import (
extract_allowed,
set_allowed_attrs,
get_model_reference_field_name,
)
from baserow.contrib.database.fields.exceptions import FieldNotInTable
from baserow.contrib.database.fields.field_filters import FilterBuilder
from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.fields.field_sortings import AnnotatedOrder
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.rows.signals import row_created
from .exceptions import (
ViewDoesNotExist,
ViewNotInTable,
@ -26,9 +26,9 @@ from .exceptions import (
ViewSortFieldAlreadyExist,
ViewSortFieldNotSupported,
ViewDoesNotSupportFieldOptions,
CannotShareViewTypeError,
)
from .validators import EMPTY_VALUES
from .models import View, ViewFilter, ViewSort, FormView
from .models import View, ViewFilter, ViewSort
from .registries import view_type_registry, view_filter_type_registry
from .signals import (
view_created,
@ -43,6 +43,7 @@ from .signals import (
view_sort_deleted,
view_field_options_updated,
)
from .validators import EMPTY_VALUES
class ViewHandler:
@ -782,55 +783,65 @@ class ViewHandler:
queryset = queryset.search_all_fields(search)
return queryset
def rotate_form_view_slug(self, user, form):
def rotate_view_slug(self, user, view):
"""
Rotates the slug of the provided form view.
Rotates the slug of the provided view.
:param user: The user on whose behalf the form view is updated.
:param user: The user on whose behalf the view is updated.
:type user: User
:param form: The form view instance that needs to be updated.
:type form: View
:param view: The form view instance that needs to be updated.
:type view: View
:return: The updated view instance.
:rtype: View
:raises CannotShareViewTypeError: Raised if called for a view which does not
support sharing.
"""
view_type = view_type_registry.get_by_model(view.specific_class)
if not view_type.can_share:
raise CannotShareViewTypeError()
group = view.table.database.group
group.has_user(user, raise_error=True)
view.rotate_slug()
view.save()
view_updated.send(self, view=view, user=user)
return view
def get_public_view_by_slug(self, user, slug, view_model=None):
"""
Returns the view with the provided slug if it is public or if the user has
access to the views group.
:param user: The user on whose behalf the view is requested.
:type user: User
:param slug: The slug of the view.
:type slug: str
:param view_model: If provided that models objects are used to select the
view. This can for example be useful when you want to select a GridView or
other child of the View model.
:type view_model: Type[View]
:return: The requested view with matching slug.
:rtype: View
"""
if not isinstance(form, FormView):
raise ValueError("The provided form is not an instance of FormView.")
group = form.table.database.group
group.has_user(user, raise_error=True)
form.rotate_slug()
form.save()
view_updated.send(self, view=form, user=user)
return form
def get_public_form_view_by_slug(self, user, slug):
"""
Returns the form view related to the provided slug if the form related to the
slug is public or if the user has access to the related group.
:param user: The user on whose behalf the form is requested.
:type user: User
:param slug: The slug of the form view.
:type slug: str
:return: The requested form view that belongs to the form with the slug.
:rtype: FormView
"""
if not view_model:
view_model = View
try:
form = FormView.objects.get(slug=slug)
except (FormView.DoesNotExist, ValidationError):
raise ViewDoesNotExist("The form does not exist.")
view = view_model.objects.get(slug=slug)
except (view_model.DoesNotExist, ValidationError):
raise ViewDoesNotExist("The view does not exist.")
if not form.public and (
not user or not form.table.database.group.has_user(user)
if not view.public and (
not user or not view.table.database.group.has_user(user)
):
raise ViewDoesNotExist("The form does not exist.")
raise ViewDoesNotExist("The view does not exist.")
return form
return view
def submit_form_view(self, form, values, model=None, enabled_field_options=None):
"""

View file

@ -70,6 +70,19 @@ class View(
help_text="Allows users to see results unfiltered while still keeping "
"the filters saved for the view.",
)
slug = models.SlugField(
default=secrets.token_urlsafe,
help_text="The unique slug where the view can be accessed publicly on.",
unique=True,
db_index=True,
)
public = models.BooleanField(
default=False,
help_text="Indicates whether the view is publicly accessible to visitors.",
)
def rotate_slug(self):
self.slug = secrets.token_urlsafe()
class Meta:
ordering = ("order",)
@ -247,17 +260,6 @@ class GalleryViewFieldOptions(ParentFieldTrashableModelMixin, models.Model):
class FormView(View):
field_options = models.ManyToManyField(Field, through="FormViewFieldOptions")
slug = models.SlugField(
default=secrets.token_urlsafe,
help_text="The unique slug where the form can be accessed publicly on.",
unique=True,
db_index=True,
)
public = models.BooleanField(
default=False,
help_text="Indicates whether the form is publicly accessible to visitors and "
"if they can fill it out.",
)
title = models.TextField(
blank=True,
help_text="The title that is displayed at the beginning of the form.",
@ -301,9 +303,6 @@ class FormView(View):
f"form.",
)
def rotate_slug(self):
self.slug = secrets.token_urlsafe()
@property
def active_field_options(self):
return (

View file

@ -1,6 +1,7 @@
from typing import Callable, Union, List
from django.contrib.auth.models import User as DjangoUser
from rest_framework.fields import CharField
from rest_framework.serializers import Serializer
@ -81,6 +82,11 @@ class ViewType(
sort to the view.
"""
can_share = False
"""
Indicates if the view supports being shared via a public link.
"""
field_options_model_class = None
"""
The model class of the through table that contains the field options. The model
@ -94,6 +100,23 @@ class ViewType(
option changes.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.can_share:
self.allowed_fields = self.allowed_fields + ["public"]
self.serializer_field_names = self.serializer_field_names + [
"public",
"slug",
]
self.serializer_field_overrides = {
**self.serializer_field_overrides,
"slug": CharField(
read_only=True,
help_text="The unique slug that can be used to construct a public "
"URL.",
),
}
def export_serialized(self, view, files_zip, storage):
"""
Exports the view to a serialized dict that can be imported by the
@ -138,6 +161,9 @@ class ViewType(
for sort in view.viewsort_set.all()
]
if self.can_share:
serialized["public"] = view.public
return serialized
def import_serialized(

View file

@ -1,23 +1,21 @@
from django.urls import path, include
from rest_framework.serializers import CharField
from baserow.api.user_files.serializers import UserFileField
from baserow.core.user_files.handler import UserFileHandler
from baserow.contrib.database.api.views.form.errors import (
ERROR_FORM_VIEW_FIELD_TYPE_IS_NOT_SUPPORTED,
)
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.api.views.grid.serializers import (
GridViewFieldOptionsSerializer,
)
from baserow.contrib.database.api.views.gallery.serializers import (
GalleryViewFieldOptionsSerializer,
)
from baserow.contrib.database.api.views.form.serializers import (
FormViewFieldOptionsSerializer,
)
from baserow.contrib.database.api.views.gallery.serializers import (
GalleryViewFieldOptionsSerializer,
)
from baserow.contrib.database.api.views.grid.serializers import (
GridViewFieldOptionsSerializer,
)
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.core.user_files.handler import UserFileHandler
from .exceptions import FormViewFieldTypeIsNotSupported
from .handler import ViewHandler
from .models import (
GridView,
@ -28,7 +26,6 @@ from .models import (
FormViewFieldOptions,
)
from .registries import ViewType
from .exceptions import FormViewFieldTypeIsNotSupported
class GridViewType(ViewType):
@ -207,10 +204,10 @@ class FormViewType(ViewType):
model_class = FormView
can_filter = False
can_sort = False
can_share = True
field_options_model_class = FormViewFieldOptions
field_options_serializer_class = FormViewFieldOptionsSerializer
allowed_fields = [
"public",
"title",
"description",
"cover_image",
@ -220,8 +217,6 @@ class FormViewType(ViewType):
"submit_action_redirect_url",
]
serializer_field_names = [
"slug",
"public",
"title",
"description",
"cover_image",
@ -231,10 +226,6 @@ class FormViewType(ViewType):
"submit_action_redirect_url",
]
serializer_field_overrides = {
"slug": CharField(
read_only=True,
help_text="The unique slug that can be used to construct a public URL.",
),
"cover_image": UserFileField(
required=False,
help_text="The cover image that must be displayed at the top of the form.",
@ -291,7 +282,6 @@ class FormViewType(ViewType):
return {"name": name, "original_name": user_file.original_name}
serialized["public"] = form.public
serialized["title"] = form.title
serialized["description"] = form.description
serialized["cover_image"] = add_user_file(form.cover_image)

View file

@ -192,35 +192,6 @@ def test_update_form_view(api_client, data_fixture):
assert response_json["logo_image"]["name"] == user_file_2.name
@pytest.mark.django_db
def test_rotate_slug(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
view = data_fixture.create_form_view(table=table)
view_2 = data_fixture.create_form_view()
old_slug = str(view.slug)
url = reverse("api:database:views:form:rotate_slug", kwargs={"view_id": view_2.id})
response = api_client.post(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
url = reverse("api:database:views:form:rotate_slug", kwargs={"view_id": 99999})
response = api_client.post(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
assert response.status_code == HTTP_404_NOT_FOUND
url = reverse("api:database:views:form:rotate_slug", kwargs={"view_id": view.id})
response = api_client.post(
url,
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["slug"] != old_slug
assert len(response_json["slug"]) == 43
@pytest.mark.django_db
def test_meta_submit_form_view(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()

View file

@ -646,6 +646,18 @@ def test_create_grid_view(api_client, data_fixture):
assert "filters" not in response_json
assert "sortings" not in response_json
# Can't create a public non sharable view.
response = api_client.post(
reverse("api:database:views:list", kwargs={"table_id": table.id}),
{"name": "Test 1", "type": "grid", "public": True},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert "public" not in response_json
assert "slug" not in response_json
@pytest.mark.django_db
def test_update_grid_view(api_client, data_fixture):
@ -733,3 +745,15 @@ def test_update_grid_view(api_client, data_fixture):
assert response_json["filters_disabled"] is True
assert response_json["filters"][0]["id"] == filter_1.id
assert response_json["sortings"] == []
# Can't make a non sharable view public.
response = api_client.patch(
reverse("api:database:views:item", kwargs={"view_id": view.id}),
{"public": True},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert "public" not in response_json
assert "slug" not in response_json

View file

@ -1313,3 +1313,38 @@ def test_patch_view_field_options(api_client, data_fixture):
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS"
@pytest.mark.django_db
def test_rotate_slug(api_client, data_fixture):
user, token = data_fixture.create_user_and_token()
table = data_fixture.create_database_table(user=user)
view = data_fixture.create_form_view(table=table)
view_2 = data_fixture.create_form_view(public=True)
grid_view = data_fixture.create_grid_view(user=user, table=table)
old_slug = str(view.slug)
url = reverse("api:database:views:rotate_slug", kwargs={"view_id": view_2.id})
response = api_client.post(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
url = reverse("api:database:views:rotate_slug", kwargs={"view_id": grid_view.id})
response = api_client.post(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_CANNOT_SHARE_VIEW_TYPE"
url = reverse("api:database:views:rotate_slug", kwargs={"view_id": 99999})
response = api_client.post(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
assert response.status_code == HTTP_404_NOT_FOUND
url = reverse("api:database:views:rotate_slug", kwargs={"view_id": view.id})
response = api_client.post(
url,
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["slug"] != old_slug
assert len(response_json["slug"]) == 43

View file

@ -0,0 +1,173 @@
import secrets
from unittest.mock import patch
import pytest
from django.core.management import call_command
from django.db import DEFAULT_DB_ALIAS
# noinspection PyPep8Naming
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
# noinspection PyPep8Naming
@pytest.mark.django_db
def test_forwards_migration(data_fixture, transactional_db):
migrate_from = [("database", "0052_table_order_and_id_index")]
migrate_to = [("database", "0053_add_and_move_public_flags")]
old_state = migrate(migrate_from)
# The models used by the data_fixture below are not touched by this migration so
# it is safe to use the latest version in the test.
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
FormView = old_state.apps.get_model("database", "FormView")
GridView = old_state.apps.get_model("database", "GridView")
ContentType = old_state.apps.get_model("contenttypes", "ContentType")
form_content_type_id = ContentType.objects.get_for_model(FormView).id
grid_content_type_id = ContentType.objects.get_for_model(GridView).id
form_view = FormView.objects.create(
table_id=table.id,
name="a",
order=1,
public=True,
slug="slug",
content_type_id=form_content_type_id,
)
form_view2 = FormView.objects.create(
table_id=table.id,
name="b",
order=1,
public=False,
slug="slug2",
content_type_id=form_content_type_id,
)
grid_view = GridView.objects.create(
table_id=table.id,
name="c",
order=1,
content_type_id=grid_content_type_id,
)
grid_view2 = GridView.objects.create(
table_id=table.id,
name="d",
order=2,
content_type_id=grid_content_type_id,
)
new_state = migrate(migrate_to)
NewFormView = new_state.apps.get_model("database", "FormView")
NewGridView = new_state.apps.get_model("database", "GridView")
new_form_view = NewFormView.objects.get(id=form_view.id)
assert new_form_view.view_ptr.public
assert new_form_view.view_ptr.slug == form_view.slug
new_form_view2 = NewFormView.objects.get(id=form_view2.id)
assert not new_form_view2.view_ptr.public
assert new_form_view2.view_ptr.slug == form_view2.slug
new_grid_view = NewGridView.objects.get(id=grid_view.id)
assert not new_grid_view.view_ptr.public
assert new_grid_view.view_ptr.slug is not None
assert len(new_grid_view.view_ptr.slug) == len(secrets.token_urlsafe())
new_grid_view2 = NewGridView.objects.get(id=grid_view2.id)
assert not new_grid_view2.view_ptr.public
assert new_grid_view2.view_ptr.slug is not None
assert len(new_grid_view2.view_ptr.slug) == len(secrets.token_urlsafe())
assert new_grid_view.view_ptr.slug != new_grid_view2.view_ptr.slug
# We need to apply the latest migration otherwise other tests might fail.
call_command("migrate", verbosity=0, database=DEFAULT_DB_ALIAS)
@pytest.mark.django_db
@patch(
"baserow.contrib.database.migrations.0053_add_and_move_public_flags"
".update_batch_size"
)
def test_multi_batch_forwards_migration(
patched_update_size, data_fixture, transactional_db
):
migrate_from = [("database", "0052_table_order_and_id_index")]
migrate_to = [("database", "0053_add_and_move_public_flags")]
old_state = migrate(migrate_from)
# The models used by the data_fixture below are not touched by this migration so
# it is safe to use the latest version in the test.
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
GridView = old_state.apps.get_model("database", "GridView")
ContentType = old_state.apps.get_model("contenttypes", "ContentType")
grid_content_type_id = ContentType.objects.get_for_model(GridView).id
size = 3
patched_update_size.return_value = size
views_to_make = size * 2 + int(size / 2)
for i in range(views_to_make):
GridView.objects.create(
table_id=table.id,
name=str(i),
order=1,
content_type_id=grid_content_type_id,
)
new_state = migrate(migrate_to)
NewGridView = new_state.apps.get_model("database", "GridView")
assert not NewGridView.objects.filter(slug__isnull=True).exists()
assert not NewGridView.objects.filter(public=True).exists()
# We need to apply the latest migration otherwise other tests might fail.
call_command("migrate", verbosity=0, database=DEFAULT_DB_ALIAS)
@pytest.mark.django_db
def test_backwards_migration(data_fixture, transactional_db):
migrate_from = [("database", "0053_add_and_move_public_flags")]
migrate_to = [("database", "0052_table_order_and_id_index")]
old_state = migrate(migrate_from)
# The models used by the data_fixture below are not touched by this migration so
# it is safe to use the latest version in the test.
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
FormView = old_state.apps.get_model("database", "FormView")
ContentType = old_state.apps.get_model("contenttypes", "ContentType")
content_type_id = ContentType.objects.get_for_model(FormView).id
form_view = FormView.objects.create(
table_id=table.id,
name="a",
order=1,
public=True,
slug="slug",
content_type_id=content_type_id,
)
form_view2 = FormView.objects.create(
table_id=table.id,
name="b",
order=2,
public=False,
slug="slug2",
content_type_id=content_type_id,
)
new_state = migrate(migrate_to)
NewFormView = new_state.apps.get_model("database", "FormView")
new_form_view = NewFormView.objects.get(id=form_view.id)
new_form_view2 = NewFormView.objects.get(id=form_view2.id)
assert new_form_view.public
assert new_form_view.slug == form_view.slug
assert not new_form_view2.public
assert new_form_view2.slug == form_view2.slug
# We need to apply the latest migration otherwise other tests might fail.
call_command("migrate", verbosity=0, database=DEFAULT_DB_ALIAS)
def migrate(target):
executor = MigrationExecutor(connection)
executor.loader.build_graph() # reload.
executor.migrate(target)
new_state = executor.loader.project_state(target)
return new_state

View file

@ -32,6 +32,7 @@ from baserow.contrib.database.views.exceptions import (
ViewSortFieldNotSupported,
ViewDoesNotSupportFieldOptions,
FormViewFieldTypeIsNotSupported,
CannotShareViewTypeError,
)
from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.fields.handler import FieldHandler
@ -1263,22 +1264,23 @@ def test_delete_sort(send_mock, data_fixture):
@pytest.mark.django_db
@patch("baserow.contrib.database.views.signals.view_updated.send")
def test_rotate_form_view_slug(send_mock, data_fixture):
def test_rotate_view_slug(send_mock, data_fixture):
user = data_fixture.create_user()
user_2 = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
form = data_fixture.create_form_view(table=table)
grid = data_fixture.create_grid_view(table=table)
old_slug = str(form.slug)
handler = ViewHandler()
with pytest.raises(UserNotInGroup):
handler.rotate_form_view_slug(user=user_2, form=form)
handler.rotate_view_slug(user=user_2, view=form)
with pytest.raises(ValueError):
handler.rotate_form_view_slug(user=user, form=object())
with pytest.raises(CannotShareViewTypeError):
handler.rotate_view_slug(user=user, view=grid)
handler.rotate_form_view_slug(user=user, form=form)
handler.rotate_view_slug(user=user, view=form)
send_mock.assert_called_once()
assert send_mock.call_args[1]["view"].id == form.id
@ -1290,7 +1292,7 @@ def test_rotate_form_view_slug(send_mock, data_fixture):
@pytest.mark.django_db
def test_get_public_form_view_by_slug(data_fixture):
def test_get_public_view_by_slug(data_fixture):
user = data_fixture.create_user()
user_2 = data_fixture.create_user()
form = data_fixture.create_form_view(user=user)
@ -1298,25 +1300,27 @@ def test_get_public_form_view_by_slug(data_fixture):
handler = ViewHandler()
with pytest.raises(ViewDoesNotExist):
handler.get_public_form_view_by_slug(user_2, "not_existing")
handler.get_public_view_by_slug(user_2, "not_existing")
with pytest.raises(ViewDoesNotExist):
handler.get_public_form_view_by_slug(
user_2, "a3f1493a-9229-4889-8531-6a65e745602e"
)
handler.get_public_view_by_slug(user_2, "a3f1493a-9229-4889-8531-6a65e745602e")
with pytest.raises(ViewDoesNotExist):
handler.get_public_form_view_by_slug(user_2, form.slug)
handler.get_public_view_by_slug(user_2, form.slug)
form2 = handler.get_public_form_view_by_slug(user, form.slug)
form2 = handler.get_public_view_by_slug(user, form.slug)
assert form.id == form2.id
form.public = True
form.save()
form2 = handler.get_public_form_view_by_slug(user_2, form.slug)
form2 = handler.get_public_view_by_slug(user_2, form.slug)
assert form.id == form2.id
form3 = handler.get_public_view_by_slug(user_2, form.slug, view_model=FormView)
assert form.id == form3.id
assert isinstance(form3, FormView)
@pytest.mark.django_db
@patch("baserow.contrib.database.rows.signals.row_created.send")

View file

@ -54,7 +54,7 @@ def test_view_get_field_options(data_fixture):
@pytest.mark.django_db
def test_rotate_form_view_slug(data_fixture):
def test_rotate_view_slug(data_fixture):
form_view = data_fixture.create_form_view()
old_slug = str(form_view.slug)
form_view.rotate_slug()

View file

@ -10,6 +10,8 @@
* **dev.sh users** Fixed bug in dev.sh where UID/GID were not being set correctly,
please rebuild any dev images you are using.
* Replaced the table `order` index with an `order, id` index to improve performance.
* **breaking change** The API endpoint to rotate a form views slug has been moved to
`/database/views/${viewId}/rotate-slug/`.
## Released (2021-11-25)

View file

@ -29,7 +29,7 @@
import modal from '@baserow/modules/core/mixins/modal'
import error from '@baserow/modules/core/mixins/error'
import formViewHelpers from '@baserow/modules/database/mixins/formViewHelpers'
import FormService from '@baserow/modules/database/services/view/form'
import ViewService from '@baserow/modules/database/services/view'
export default {
name: 'FormViewRotateSlugModal',
@ -51,7 +51,7 @@ export default {
this.loading = true
try {
const { data } = await FormService(this.$client).rotateSlug(
const { data } = await ViewService(this.$client).rotateSlug(
this.view.id
)
await this.$store.dispatch('view/forceUpdate', {

View file

@ -58,5 +58,8 @@ export default (client) => {
updateFieldOptions({ viewId, values }) {
return client.patch(`/database/views/${viewId}/field-options/`, values)
},
rotateSlug(viewId) {
return client.post(`/database/views/${viewId}/rotate-slug/`)
},
}
}

View file

@ -1,8 +1,5 @@
export default (client) => {
return {
rotateSlug(formId) {
return client.post(`/database/views/form/${formId}/rotate-slug/`)
},
getMetaInformation(slug) {
return client.get(`/database/views/form/${slug}/submit/`)
},