1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-17 02:17:49 +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, HTTP_400_BAD_REQUEST,
"This view model does not support field options.", "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 django.urls import re_path
from .views import ( from .views import (
RotateFormViewSlugView,
SubmitFormViewView, SubmitFormViewView,
FormViewLinkRowFieldLookupView, FormViewLinkRowFieldLookupView,
) )
app_name = "baserow.contrib.database.api.views.form" app_name = "baserow.contrib.database.api.views.form"
urlpatterns = [ urlpatterns = [
re_path(
r"(?P<view_id>[0-9]+)/rotate-slug/$",
RotateFormViewSlugView.as_view(),
name="rotate_slug",
),
re_path( re_path(
r"(?P<slug>[-\w]+)/submit/$", r"(?P<slug>[-\w]+)/submit/$",
SubmitFormViewView.as_view(), SubmitFormViewView.as_view(),

View file

@ -1,6 +1,6 @@
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
from drf_spectacular.utils import extend_schema 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.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.fields import empty from rest_framework.fields import empty
@ -9,12 +9,10 @@ from django.contrib.contenttypes.models import ContentType
from django.conf import settings from django.conf import settings
from baserow.api.decorators import map_exceptions 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.schemas import get_error_schema
from baserow.api.utils import validate_data from baserow.api.utils import validate_data
from baserow.api.pagination import PageNumberPagination from baserow.api.pagination import PageNumberPagination
from baserow.api.serializers import get_example_pagination_serializer_class 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 ( from baserow.contrib.database.api.rows.serializers import (
get_row_serializer_class, get_row_serializer_class,
get_example_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.fields.exceptions import FieldDoesNotExist
from baserow.contrib.database.views.exceptions import ViewDoesNotExist from baserow.contrib.database.views.exceptions import ViewDoesNotExist
from baserow.contrib.database.views.handler import ViewHandler from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.models import FormView, FormViewFieldOptions from baserow.contrib.database.views.models import FormViewFieldOptions, FormView
from baserow.contrib.database.views.registries import view_type_registry
from baserow.contrib.database.views.validators import required_validator from baserow.contrib.database.views.validators import required_validator
from baserow.core.exceptions import UserNotInGroup
from .errors import ERROR_FORM_DOES_NOT_EXIST from .errors import ERROR_FORM_DOES_NOT_EXIST
from .serializers import PublicFormViewSerializer, FormViewSubmittedSerializer 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): class SubmitFormViewView(APIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
@ -111,7 +61,9 @@ class SubmitFormViewView(APIView):
} }
) )
def get(self, request, slug): 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) serializer = PublicFormViewSerializer(form)
return Response(serializer.data) return Response(serializer.data)
@ -147,7 +99,7 @@ class SubmitFormViewView(APIView):
@transaction.atomic @transaction.atomic
def post(self, request, slug): def post(self, request, slug):
handler = ViewHandler() 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() model = form.table.get_model()
options = form.active_field_options options = form.active_field_options
@ -215,7 +167,7 @@ class FormViewLinkRowFieldLookupView(APIView):
) )
def get(self, request, slug, field_id): def get(self, request, slug, field_id):
handler = ViewHandler() 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) link_row_field_content_type = ContentType.objects.get_for_model(LinkRowField)
try: try:

View file

@ -171,7 +171,10 @@ class ViewSerializer(serializers.ModelSerializer):
"sortings", "sortings",
"filters_disabled", "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): def __init__(self, *args, **kwargs):
context = kwargs.setdefault("context", {}) context = kwargs.setdefault("context", {})

View file

@ -11,6 +11,7 @@ from .views import (
ViewSortingsView, ViewSortingsView,
ViewSortView, ViewSortView,
ViewFieldOptionsView, ViewFieldOptionsView,
RotateViewSlugView,
) )
@ -43,4 +44,9 @@ urlpatterns = view_type_registry.api_urls + [
ViewFieldOptionsView.as_view(), ViewFieldOptionsView.as_view(),
name="field_options", 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, ViewSortFieldNotSupported,
UnrelatedFieldError, UnrelatedFieldError,
ViewDoesNotSupportFieldOptions, ViewDoesNotSupportFieldOptions,
CannotShareViewTypeError,
) )
from .serializers import ( from .serializers import (
@ -72,6 +73,7 @@ from .errors import (
ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED, ERROR_VIEW_SORT_FIELD_NOT_SUPPORTED,
ERROR_UNRELATED_FIELD, ERROR_UNRELATED_FIELD,
ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS, ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS,
ERROR_CANNOT_SHARE_VIEW_TYPE,
) )
@ -1040,3 +1042,55 @@ class ViewFieldOptionsView(APIView):
serializer = serializer_class(view) serializer = serializer_class(view)
return Response(serializer.data) 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.""" """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): class ViewNotInTable(Exception):
"""Raised when a provided view does not belong to a table.""" """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.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.trash.handler import TrashHandler
from baserow.core.utils import ( from baserow.core.utils import (
extract_allowed, extract_allowed,
set_allowed_attrs, set_allowed_attrs,
get_model_reference_field_name, 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 ( from .exceptions import (
ViewDoesNotExist, ViewDoesNotExist,
ViewNotInTable, ViewNotInTable,
@ -26,9 +26,9 @@ from .exceptions import (
ViewSortFieldAlreadyExist, ViewSortFieldAlreadyExist,
ViewSortFieldNotSupported, ViewSortFieldNotSupported,
ViewDoesNotSupportFieldOptions, ViewDoesNotSupportFieldOptions,
CannotShareViewTypeError,
) )
from .validators import EMPTY_VALUES from .models import View, ViewFilter, ViewSort
from .models import View, ViewFilter, ViewSort, FormView
from .registries import view_type_registry, view_filter_type_registry from .registries import view_type_registry, view_filter_type_registry
from .signals import ( from .signals import (
view_created, view_created,
@ -43,6 +43,7 @@ from .signals import (
view_sort_deleted, view_sort_deleted,
view_field_options_updated, view_field_options_updated,
) )
from .validators import EMPTY_VALUES
class ViewHandler: class ViewHandler:
@ -782,55 +783,65 @@ class ViewHandler:
queryset = queryset.search_all_fields(search) queryset = queryset.search_all_fields(search)
return queryset 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 :type user: User
:param form: The form view instance that needs to be updated. :param view: The form view instance that needs to be updated.
:type form: View :type view: View
:return: The updated view instance. :return: The updated view instance.
:rtype: View :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): if not view_model:
raise ValueError("The provided form is not an instance of FormView.") view_model = View
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
"""
try: try:
form = FormView.objects.get(slug=slug) view = view_model.objects.get(slug=slug)
except (FormView.DoesNotExist, ValidationError): except (view_model.DoesNotExist, ValidationError):
raise ViewDoesNotExist("The form does not exist.") raise ViewDoesNotExist("The view does not exist.")
if not form.public and ( if not view.public and (
not user or not form.table.database.group.has_user(user) 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): 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 " help_text="Allows users to see results unfiltered while still keeping "
"the filters saved for the view.", "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: class Meta:
ordering = ("order",) ordering = ("order",)
@ -247,17 +260,6 @@ class GalleryViewFieldOptions(ParentFieldTrashableModelMixin, models.Model):
class FormView(View): class FormView(View):
field_options = models.ManyToManyField(Field, through="FormViewFieldOptions") 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( title = models.TextField(
blank=True, blank=True,
help_text="The title that is displayed at the beginning of the form.", help_text="The title that is displayed at the beginning of the form.",
@ -301,9 +303,6 @@ class FormView(View):
f"form.", f"form.",
) )
def rotate_slug(self):
self.slug = secrets.token_urlsafe()
@property @property
def active_field_options(self): def active_field_options(self):
return ( return (

View file

@ -1,6 +1,7 @@
from typing import Callable, Union, List from typing import Callable, Union, List
from django.contrib.auth.models import User as DjangoUser from django.contrib.auth.models import User as DjangoUser
from rest_framework.fields import CharField
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
@ -81,6 +82,11 @@ class ViewType(
sort to the view. sort to the view.
""" """
can_share = False
"""
Indicates if the view supports being shared via a public link.
"""
field_options_model_class = None field_options_model_class = None
""" """
The model class of the through table that contains the field options. The model The model class of the through table that contains the field options. The model
@ -94,6 +100,23 @@ class ViewType(
option changes. 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): def export_serialized(self, view, files_zip, storage):
""" """
Exports the view to a serialized dict that can be imported by the 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() for sort in view.viewsort_set.all()
] ]
if self.can_share:
serialized["public"] = view.public
return serialized return serialized
def import_serialized( def import_serialized(

View file

@ -1,23 +1,21 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.serializers import CharField
from baserow.api.user_files.serializers import UserFileField 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 ( from baserow.contrib.database.api.views.form.errors import (
ERROR_FORM_VIEW_FIELD_TYPE_IS_NOT_SUPPORTED, 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 ( from baserow.contrib.database.api.views.form.serializers import (
FormViewFieldOptionsSerializer, 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 .handler import ViewHandler
from .models import ( from .models import (
GridView, GridView,
@ -28,7 +26,6 @@ from .models import (
FormViewFieldOptions, FormViewFieldOptions,
) )
from .registries import ViewType from .registries import ViewType
from .exceptions import FormViewFieldTypeIsNotSupported
class GridViewType(ViewType): class GridViewType(ViewType):
@ -207,10 +204,10 @@ class FormViewType(ViewType):
model_class = FormView model_class = FormView
can_filter = False can_filter = False
can_sort = False can_sort = False
can_share = True
field_options_model_class = FormViewFieldOptions field_options_model_class = FormViewFieldOptions
field_options_serializer_class = FormViewFieldOptionsSerializer field_options_serializer_class = FormViewFieldOptionsSerializer
allowed_fields = [ allowed_fields = [
"public",
"title", "title",
"description", "description",
"cover_image", "cover_image",
@ -220,8 +217,6 @@ class FormViewType(ViewType):
"submit_action_redirect_url", "submit_action_redirect_url",
] ]
serializer_field_names = [ serializer_field_names = [
"slug",
"public",
"title", "title",
"description", "description",
"cover_image", "cover_image",
@ -231,10 +226,6 @@ class FormViewType(ViewType):
"submit_action_redirect_url", "submit_action_redirect_url",
] ]
serializer_field_overrides = { 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( "cover_image": UserFileField(
required=False, required=False,
help_text="The cover image that must be displayed at the top of the form.", 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} return {"name": name, "original_name": user_file.original_name}
serialized["public"] = form.public
serialized["title"] = form.title serialized["title"] = form.title
serialized["description"] = form.description serialized["description"] = form.description
serialized["cover_image"] = add_user_file(form.cover_image) 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 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 @pytest.mark.django_db
def test_meta_submit_form_view(api_client, data_fixture): def test_meta_submit_form_view(api_client, data_fixture):
user, token = data_fixture.create_user_and_token() 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 "filters" not in response_json
assert "sortings" 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 @pytest.mark.django_db
def test_update_grid_view(api_client, data_fixture): 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_disabled"] is True
assert response_json["filters"][0]["id"] == filter_1.id assert response_json["filters"][0]["id"] == filter_1.id
assert response_json["sortings"] == [] 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() response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_VIEW_DOES_NOT_SUPPORT_FIELD_OPTIONS" 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, ViewSortFieldNotSupported,
ViewDoesNotSupportFieldOptions, ViewDoesNotSupportFieldOptions,
FormViewFieldTypeIsNotSupported, FormViewFieldTypeIsNotSupported,
CannotShareViewTypeError,
) )
from baserow.contrib.database.fields.models import Field from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.fields.handler import FieldHandler
@ -1263,22 +1264,23 @@ def test_delete_sort(send_mock, data_fixture):
@pytest.mark.django_db @pytest.mark.django_db
@patch("baserow.contrib.database.views.signals.view_updated.send") @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 = data_fixture.create_user()
user_2 = data_fixture.create_user() user_2 = data_fixture.create_user()
table = data_fixture.create_database_table(user=user) table = data_fixture.create_database_table(user=user)
form = data_fixture.create_form_view(table=table) form = data_fixture.create_form_view(table=table)
grid = data_fixture.create_grid_view(table=table)
old_slug = str(form.slug) old_slug = str(form.slug)
handler = ViewHandler() handler = ViewHandler()
with pytest.raises(UserNotInGroup): 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): with pytest.raises(CannotShareViewTypeError):
handler.rotate_form_view_slug(user=user, form=object()) 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() send_mock.assert_called_once()
assert send_mock.call_args[1]["view"].id == form.id 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 @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 = data_fixture.create_user()
user_2 = data_fixture.create_user() user_2 = data_fixture.create_user()
form = data_fixture.create_form_view(user=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() handler = ViewHandler()
with pytest.raises(ViewDoesNotExist): 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): with pytest.raises(ViewDoesNotExist):
handler.get_public_form_view_by_slug( handler.get_public_view_by_slug(user_2, "a3f1493a-9229-4889-8531-6a65e745602e")
user_2, "a3f1493a-9229-4889-8531-6a65e745602e"
)
with pytest.raises(ViewDoesNotExist): 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 assert form.id == form2.id
form.public = True form.public = True
form.save() 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 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 @pytest.mark.django_db
@patch("baserow.contrib.database.rows.signals.row_created.send") @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 @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() form_view = data_fixture.create_form_view()
old_slug = str(form_view.slug) old_slug = str(form_view.slug)
form_view.rotate_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, * **dev.sh users** Fixed bug in dev.sh where UID/GID were not being set correctly,
please rebuild any dev images you are using. please rebuild any dev images you are using.
* Replaced the table `order` index with an `order, id` index to improve performance. * 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) ## Released (2021-11-25)

View file

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

View file

@ -58,5 +58,8 @@ export default (client) => {
updateFieldOptions({ viewId, values }) { updateFieldOptions({ viewId, values }) {
return client.patch(`/database/views/${viewId}/field-options/`, 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) => { export default (client) => {
return { return {
rotateSlug(formId) {
return client.post(`/database/views/form/${formId}/rotate-slug/`)
},
getMetaInformation(slug) { getMetaInformation(slug) {
return client.get(`/database/views/form/${slug}/submit/`) return client.get(`/database/views/form/${slug}/submit/`)
}, },