From dece765c2959e736f2c465a38c34ea75caf2743d Mon Sep 17 00:00:00 2001
From: Nigel Gott <nigel@baserow.io>
Date: Mon, 27 Dec 2021 10:45:32 +0000
Subject: [PATCH] =?UTF-8?q?Resolve=20"=F0=9F=93=A8=20ShareGridView:=20Migr?=
 =?UTF-8?q?ate=20slug+public=20from=20form=20view=20to=20all=20views"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../contrib/database/api/views/errors.py      |   5 +
 .../contrib/database/api/views/form/urls.py   |   7 -
 .../contrib/database/api/views/form/views.py  |  62 +------
 .../contrib/database/api/views/serializers.py |   5 +-
 .../contrib/database/api/views/urls.py        |   6 +
 .../contrib/database/api/views/views.py       |  54 ++++++
 .../0053_add_and_move_public_flags.py         | 141 ++++++++++++++
 .../contrib/database/views/exceptions.py      |   4 +
 .../baserow/contrib/database/views/handler.py | 105 ++++++-----
 .../baserow/contrib/database/views/models.py  |  27 ++-
 .../contrib/database/views/registries.py      |  26 +++
 .../contrib/database/views/view_types.py      |  30 +--
 .../api/views/form/test_form_view_views.py    |  29 ---
 .../api/views/grid/test_grid_view_views.py    |  24 +++
 .../database/api/views/test_view_views.py     |  35 ++++
 .../test_add_and_move_public_flags.py         | 173 ++++++++++++++++++
 .../database/view/test_view_handler.py        |  30 +--
 .../contrib/database/view/test_view_models.py |   2 +-
 changelog.md                                  |   2 +
 .../view/form/FormViewRotateSlugModal.vue     |   4 +-
 .../modules/database/services/view.js         |   3 +
 .../modules/database/services/view/form.js    |   3 -
 22 files changed, 585 insertions(+), 192 deletions(-)
 create mode 100644 backend/src/baserow/contrib/database/migrations/0053_add_and_move_public_flags.py
 create mode 100644 backend/tests/baserow/contrib/database/migrations/test_add_and_move_public_flags.py

diff --git a/backend/src/baserow/contrib/database/api/views/errors.py b/backend/src/baserow/contrib/database/api/views/errors.py
index 23bf44941..93cad87b9 100644
--- a/backend/src/baserow/contrib/database/api/views/errors.py
+++ b/backend/src/baserow/contrib/database/api/views/errors.py
@@ -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.",
+)
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 c3b209375..bde58c2c2 100644
--- a/backend/src/baserow/contrib/database/api/views/form/urls.py
+++ b/backend/src/baserow/contrib/database/api/views/form/urls.py
@@ -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(),
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 b85caae01..59a3541d4 100644
--- a/backend/src/baserow/contrib/database/api/views/form/views.py
+++ b/backend/src/baserow/contrib/database/api/views/form/views.py
@@ -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:
diff --git a/backend/src/baserow/contrib/database/api/views/serializers.py b/backend/src/baserow/contrib/database/api/views/serializers.py
index 9b4e864b9..e30d71f12 100644
--- a/backend/src/baserow/contrib/database/api/views/serializers.py
+++ b/backend/src/baserow/contrib/database/api/views/serializers.py
@@ -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", {})
diff --git a/backend/src/baserow/contrib/database/api/views/urls.py b/backend/src/baserow/contrib/database/api/views/urls.py
index 9a195c11d..d8cf1f36c 100644
--- a/backend/src/baserow/contrib/database/api/views/urls.py
+++ b/backend/src/baserow/contrib/database/api/views/urls.py
@@ -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",
+    ),
 ]
diff --git a/backend/src/baserow/contrib/database/api/views/views.py b/backend/src/baserow/contrib/database/api/views/views.py
index ebd74e8ea..784274464 100644
--- a/backend/src/baserow/contrib/database/api/views/views.py
+++ b/backend/src/baserow/contrib/database/api/views/views.py
@@ -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)
diff --git a/backend/src/baserow/contrib/database/migrations/0053_add_and_move_public_flags.py b/backend/src/baserow/contrib/database/migrations/0053_add_and_move_public_flags.py
new file mode 100644
index 000000000..620a4f04a
--- /dev/null
+++ b/backend/src/baserow/contrib/database/migrations/0053_add_and_move_public_flags.py
@@ -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",
+        ),
+    ]
diff --git a/backend/src/baserow/contrib/database/views/exceptions.py b/backend/src/baserow/contrib/database/views/exceptions.py
index 815c13b9d..db8d0fbf5 100644
--- a/backend/src/baserow/contrib/database/views/exceptions.py
+++ b/backend/src/baserow/contrib/database/views/exceptions.py
@@ -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."""
 
diff --git a/backend/src/baserow/contrib/database/views/handler.py b/backend/src/baserow/contrib/database/views/handler.py
index 49f64a9ef..06b4138a7 100644
--- a/backend/src/baserow/contrib/database/views/handler.py
+++ b/backend/src/baserow/contrib/database/views/handler.py
@@ -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):
         """
diff --git a/backend/src/baserow/contrib/database/views/models.py b/backend/src/baserow/contrib/database/views/models.py
index 7a6023f3f..b5b0156e3 100644
--- a/backend/src/baserow/contrib/database/views/models.py
+++ b/backend/src/baserow/contrib/database/views/models.py
@@ -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 (
diff --git a/backend/src/baserow/contrib/database/views/registries.py b/backend/src/baserow/contrib/database/views/registries.py
index 2b8d3fb08..ef41a41c8 100644
--- a/backend/src/baserow/contrib/database/views/registries.py
+++ b/backend/src/baserow/contrib/database/views/registries.py
@@ -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(
diff --git a/backend/src/baserow/contrib/database/views/view_types.py b/backend/src/baserow/contrib/database/views/view_types.py
index a76890a62..b96913763 100644
--- a/backend/src/baserow/contrib/database/views/view_types.py
+++ b/backend/src/baserow/contrib/database/views/view_types.py
@@ -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)
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 583aa2ea4..552e051c1 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
@@ -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()
diff --git a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py
index 536f8e1a7..7c1a104ab 100644
--- a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py
+++ b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py
@@ -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
diff --git a/backend/tests/baserow/contrib/database/api/views/test_view_views.py b/backend/tests/baserow/contrib/database/api/views/test_view_views.py
index 2e6e5b0db..a4536f1f4 100644
--- a/backend/tests/baserow/contrib/database/api/views/test_view_views.py
+++ b/backend/tests/baserow/contrib/database/api/views/test_view_views.py
@@ -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
diff --git a/backend/tests/baserow/contrib/database/migrations/test_add_and_move_public_flags.py b/backend/tests/baserow/contrib/database/migrations/test_add_and_move_public_flags.py
new file mode 100644
index 000000000..950a90c7a
--- /dev/null
+++ b/backend/tests/baserow/contrib/database/migrations/test_add_and_move_public_flags.py
@@ -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
diff --git a/backend/tests/baserow/contrib/database/view/test_view_handler.py b/backend/tests/baserow/contrib/database/view/test_view_handler.py
index c8887fa4f..90c64d423 100644
--- a/backend/tests/baserow/contrib/database/view/test_view_handler.py
+++ b/backend/tests/baserow/contrib/database/view/test_view_handler.py
@@ -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")
diff --git a/backend/tests/baserow/contrib/database/view/test_view_models.py b/backend/tests/baserow/contrib/database/view/test_view_models.py
index a39cf0efc..e87c6ab48 100644
--- a/backend/tests/baserow/contrib/database/view/test_view_models.py
+++ b/backend/tests/baserow/contrib/database/view/test_view_models.py
@@ -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()
diff --git a/changelog.md b/changelog.md
index 3d800c1ee..20cf77d7e 100644
--- a/changelog.md
+++ b/changelog.md
@@ -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)
 
diff --git a/web-frontend/modules/database/components/view/form/FormViewRotateSlugModal.vue b/web-frontend/modules/database/components/view/form/FormViewRotateSlugModal.vue
index bbd236525..394d2d0da 100644
--- a/web-frontend/modules/database/components/view/form/FormViewRotateSlugModal.vue
+++ b/web-frontend/modules/database/components/view/form/FormViewRotateSlugModal.vue
@@ -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', {
diff --git a/web-frontend/modules/database/services/view.js b/web-frontend/modules/database/services/view.js
index 2522e2d0a..1e9a8fc15 100644
--- a/web-frontend/modules/database/services/view.js
+++ b/web-frontend/modules/database/services/view.js
@@ -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/`)
+    },
   }
 }
diff --git a/web-frontend/modules/database/services/view/form.js b/web-frontend/modules/database/services/view/form.js
index c3f5ba890..03864da5c 100644
--- a/web-frontend/modules/database/services/view/form.js
+++ b/web-frontend/modules/database/services/view/form.js
@@ -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/`)
     },