mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-15 01:28:30 +00:00
Resolve "Make Views Trashable"
This commit is contained in:
parent
9c5775656a
commit
a88b57238c
26 changed files with 635 additions and 98 deletions
backend
src/baserow/contrib/database
tests/baserow/contrib/database
premium/backend
web-frontend
locales
modules/database/components/view
|
@ -475,6 +475,7 @@ class ViewView(APIView):
|
|||
"""Deletes an existing view if the user belongs to the group."""
|
||||
|
||||
view = ViewHandler().get_view(view_id)
|
||||
|
||||
ViewHandler().delete_view(request.user, view)
|
||||
|
||||
return Response(status=204)
|
||||
|
|
|
@ -270,11 +270,13 @@ class DatabaseConfig(AppConfig):
|
|||
TableTrashableItemType,
|
||||
RowTrashableItemType,
|
||||
FieldTrashableItemType,
|
||||
ViewTrashableItemType,
|
||||
)
|
||||
|
||||
trash_item_type_registry.register(TableTrashableItemType())
|
||||
trash_item_type_registry.register(FieldTrashableItemType())
|
||||
trash_item_type_registry.register(RowTrashableItemType())
|
||||
trash_item_type_registry.register(ViewTrashableItemType())
|
||||
|
||||
from .formula.ast.function_defs import register_formula_functions
|
||||
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.12 on 2022-04-29 10:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("database", "0068_view_public_view_password"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="view",
|
||||
name="trashed",
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
]
|
|
@ -1,4 +1,3 @@
|
|||
from baserow.core.mixins import make_trashable_mixin
|
||||
|
||||
ParentFieldTrashableModelMixin = make_trashable_mixin("field")
|
||||
ParentTableTrashableModelMixin = make_trashable_mixin("table")
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from typing import Optional, Any
|
||||
from typing import Optional, Any, Dict
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
|
@ -14,6 +14,9 @@ from baserow.contrib.database.rows.signals import row_created
|
|||
from baserow.contrib.database.table.models import Table, GeneratedTableModel
|
||||
from baserow.contrib.database.table.signals import table_created
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
from baserow.contrib.database.views.models import View
|
||||
from baserow.contrib.database.views.registries import view_type_registry
|
||||
from baserow.contrib.database.views.signals import view_created
|
||||
from baserow.core.exceptions import TrashItemDoesNotExist
|
||||
from baserow.core.models import TrashEntry
|
||||
from baserow.core.trash.exceptions import RelatedTableTrashedException
|
||||
|
@ -307,3 +310,34 @@ class RowTrashableItemType(TrashableItemType):
|
|||
return primary_value
|
||||
|
||||
return "unknown row"
|
||||
|
||||
|
||||
class ViewTrashableItemType(TrashableItemType):
|
||||
type = "view"
|
||||
model_class = View
|
||||
|
||||
@property
|
||||
def requires_parent_id(self) -> bool:
|
||||
return False
|
||||
|
||||
def permanently_delete_item(
|
||||
self, trashed_item: View, trash_item_lookup_cache: Dict[str, View] = None
|
||||
):
|
||||
trashed_item.delete()
|
||||
|
||||
def get_parent(self, trashed_item: View, parent_id: int) -> Optional[View]:
|
||||
return trashed_item.table
|
||||
|
||||
def restore(self, trashed_item: View, trash_entry):
|
||||
super().restore(trashed_item, trash_entry)
|
||||
|
||||
type_name = view_type_registry.get_by_model(trashed_item.specific_class).type
|
||||
view_created.send(
|
||||
self,
|
||||
user=trash_entry.user_who_trashed,
|
||||
view=trashed_item,
|
||||
type_name=type_name,
|
||||
)
|
||||
|
||||
def get_name(self, trashed_item: View) -> str:
|
||||
return trashed_item.name
|
||||
|
|
|
@ -254,7 +254,8 @@ class ViewHandler:
|
|||
group.has_user(user, raise_error=True)
|
||||
|
||||
view_id = view.id
|
||||
view.delete()
|
||||
|
||||
TrashHandler().trash(user, group, view.table.database, view)
|
||||
|
||||
view_deleted.send(self, view_id=view_id, view=view, user=user)
|
||||
|
||||
|
@ -263,6 +264,14 @@ class ViewHandler:
|
|||
Updates the field options with the provided values if the field id exists in
|
||||
the table related to the view.
|
||||
|
||||
This will also update views which are trashed. It is up to the caller to
|
||||
ensure that the view is not trashed if they would like to exclude it from
|
||||
the update.
|
||||
|
||||
It is necesarry to do so, because aggregations have to be removed
|
||||
from trashed views as well if the field options change. Otherwise,
|
||||
you might restore a view and the aggregation is invalid on that view.
|
||||
|
||||
:param view: The view for which the field options need to be updated.
|
||||
:type view: View
|
||||
:param field_options: A dict with the field ids as the key and a dict
|
||||
|
@ -314,7 +323,7 @@ class ViewHandler:
|
|||
raise UnrelatedFieldError(
|
||||
f"The field id {field_id} is not related to the view."
|
||||
)
|
||||
model.objects.update_or_create(
|
||||
model.objects_and_trash.update_or_create(
|
||||
field_id=field_id, defaults=options, **{field_name: view}
|
||||
)
|
||||
|
||||
|
@ -470,7 +479,7 @@ class ViewHandler:
|
|||
)
|
||||
|
||||
if TrashHandler.item_has_a_trashed_parent(
|
||||
view_filter.view.table, check_item_also=True
|
||||
view_filter.view, check_item_also=True
|
||||
):
|
||||
raise ViewFilterDoesNotExist(
|
||||
f"The view filter with id {view_filter_id} does not exist."
|
||||
|
@ -650,6 +659,7 @@ class ViewHandler:
|
|||
:type queryset: QuerySet
|
||||
:raises ValueError: When the queryset's model is not a table model or if the
|
||||
table model does not contain the one of the fields.
|
||||
:raises ViewSortDoesNotExist: When the view is trashed
|
||||
:param restrict_to_field_ids: Only field ids in this iterable will have their
|
||||
view sorts applied in the resulting queryset.
|
||||
:type restrict_to_field_ids: Optional[Iterable[int]]
|
||||
|
@ -664,6 +674,9 @@ class ViewHandler:
|
|||
if not hasattr(model, "_field_objects"):
|
||||
raise ValueError("A queryset of the table model is required.")
|
||||
|
||||
if view.trashed:
|
||||
raise ViewSortDoesNotExist(f"The view {view.id} is trashed.")
|
||||
|
||||
order_by = []
|
||||
|
||||
qs = view.viewsort_set
|
||||
|
@ -737,9 +750,7 @@ class ViewHandler:
|
|||
f"The view sort with id {view_sort_id} does not exist."
|
||||
)
|
||||
|
||||
if TrashHandler.item_has_a_trashed_parent(
|
||||
view_sort.view.table, check_item_also=True
|
||||
):
|
||||
if TrashHandler.item_has_a_trashed_parent(view_sort.view, check_item_also=True):
|
||||
raise ViewSortDoesNotExist(
|
||||
f"The view sort with id {view_sort_id} does not exist."
|
||||
)
|
||||
|
@ -823,12 +834,16 @@ class ViewHandler:
|
|||
:param view_sort: The view sort that needs to be updated.
|
||||
:param field: The field that must be sorted on.
|
||||
:param order: Indicates the sort order direction.
|
||||
:raises ViewSortDoesNotExist: When the view used by the filter is trashed.
|
||||
:raises ViewSortFieldNotSupported: When the field does not support sorting.
|
||||
:raises FieldNotInTable: When the provided field does not belong to the
|
||||
provided view's table.
|
||||
:return: The updated view sort instance.
|
||||
"""
|
||||
|
||||
if view_sort.view.trashed:
|
||||
raise ViewSortDoesNotExist(f"The view {view_sort.view.id} is trashed.")
|
||||
|
||||
group = view_sort.view.table.database.group
|
||||
group.has_user(user, raise_error=True)
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import secrets
|
|||
from django.contrib.auth.hashers import check_password, make_password
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
|
||||
from baserow.core.utils import get_model_reference_field_name
|
||||
from baserow.core.models import UserFile
|
||||
|
@ -10,6 +11,7 @@ from baserow.core.mixins import (
|
|||
OrderableMixin,
|
||||
PolymorphicContentTypeMixin,
|
||||
CreatedAndUpdatedOnMixin,
|
||||
TrashableModelMixin,
|
||||
)
|
||||
from baserow.contrib.database.fields.field_filters import (
|
||||
FILTER_TYPE_AND,
|
||||
|
@ -20,10 +22,6 @@ from baserow.contrib.database.views.registries import (
|
|||
view_type_registry,
|
||||
view_filter_type_registry,
|
||||
)
|
||||
from baserow.contrib.database.mixins import (
|
||||
ParentTableTrashableModelMixin,
|
||||
ParentFieldTrashableModelMixin,
|
||||
)
|
||||
|
||||
FILTER_TYPES = ((FILTER_TYPE_AND, "And"), (FILTER_TYPE_OR, "Or"))
|
||||
|
||||
|
@ -45,7 +43,7 @@ def get_default_view_content_type():
|
|||
|
||||
|
||||
class View(
|
||||
ParentTableTrashableModelMixin,
|
||||
TrashableModelMixin,
|
||||
CreatedAndUpdatedOnMixin,
|
||||
OrderableMixin,
|
||||
PolymorphicContentTypeMixin,
|
||||
|
@ -228,7 +226,21 @@ class View(
|
|||
return field_options
|
||||
|
||||
|
||||
class ViewFilter(ParentFieldTrashableModelMixin, models.Model):
|
||||
class ViewFilterManager(models.Manager):
|
||||
"""
|
||||
Manager for the ViewFilter model.
|
||||
The View can be trashed and the filters are not deleted, therefore
|
||||
we need to filter out the trashed views.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
trashed_Q = Q(view__trashed=True) | Q(field__trashed=True)
|
||||
return super().get_queryset().filter(~trashed_Q)
|
||||
|
||||
|
||||
class ViewFilter(models.Model):
|
||||
objects = ViewFilterManager()
|
||||
|
||||
view = models.ForeignKey(
|
||||
View,
|
||||
on_delete=models.CASCADE,
|
||||
|
@ -302,7 +314,21 @@ class ViewDecoration(OrderableMixin, models.Model):
|
|||
ordering = ("order", "id")
|
||||
|
||||
|
||||
class ViewSort(ParentFieldTrashableModelMixin, models.Model):
|
||||
class ViewSortManager(models.Manager):
|
||||
"""
|
||||
Manager for the ViewSort model.
|
||||
The View can be trashed and the sorts are not deleted, therefore
|
||||
we need to filter out the trashed views.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
trashed_Q = Q(view__trashed=True) | Q(field__trashed=True)
|
||||
return super().get_queryset().filter(~trashed_Q)
|
||||
|
||||
|
||||
class ViewSort(models.Model):
|
||||
objects = ViewSortManager()
|
||||
|
||||
view = models.ForeignKey(
|
||||
View,
|
||||
on_delete=models.CASCADE,
|
||||
|
@ -330,7 +356,22 @@ class GridView(View):
|
|||
field_options = models.ManyToManyField(Field, through="GridViewFieldOptions")
|
||||
|
||||
|
||||
class GridViewFieldOptions(ParentFieldTrashableModelMixin, models.Model):
|
||||
class GridViewFieldOptionsManager(models.Manager):
|
||||
"""
|
||||
Manager for the GridViewFieldOptions model.
|
||||
The View can be trashed and the field options are not deleted, therefore
|
||||
we need to filter out the trashed views.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
trashed_Q = Q(grid_view__trashed=True) | Q(field__trashed=True)
|
||||
return super().get_queryset().filter(~trashed_Q)
|
||||
|
||||
|
||||
class GridViewFieldOptions(models.Model):
|
||||
objects = GridViewFieldOptionsManager()
|
||||
objects_and_trash = models.Manager()
|
||||
|
||||
grid_view = models.ForeignKey(GridView, on_delete=models.CASCADE)
|
||||
field = models.ForeignKey(Field, on_delete=models.CASCADE)
|
||||
# The defaults should match the ones in `afterFieldCreated` of the `GridViewType`
|
||||
|
@ -398,7 +439,21 @@ class GalleryView(View):
|
|||
)
|
||||
|
||||
|
||||
class GalleryViewFieldOptions(ParentFieldTrashableModelMixin, models.Model):
|
||||
class GalleryViewFieldOptionsManager(models.Manager):
|
||||
"""
|
||||
The View can be trashed and the field options are not deleted, therefore
|
||||
we need to filter out the trashed views.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
trashed_Q = Q(gallery_view__trashed=True) | Q(field__trashed=True)
|
||||
return super().get_queryset().filter(~trashed_Q)
|
||||
|
||||
|
||||
class GalleryViewFieldOptions(models.Model):
|
||||
objects = GalleryViewFieldOptionsManager()
|
||||
objects_and_trash = models.Manager()
|
||||
|
||||
gallery_view = models.ForeignKey(GalleryView, on_delete=models.CASCADE)
|
||||
field = models.ForeignKey(Field, on_delete=models.CASCADE)
|
||||
hidden = models.BooleanField(
|
||||
|
@ -477,7 +532,21 @@ class FormView(View):
|
|||
)
|
||||
|
||||
|
||||
class FormViewFieldOptions(ParentFieldTrashableModelMixin, models.Model):
|
||||
class FormViewFieldOptionsManager(models.Manager):
|
||||
"""
|
||||
The View can be trashed and the field options are not deleted, therefore
|
||||
we need to filter out the trashed views.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
trashed_Q = Q(form_view__trashed=True) | Q(field__trashed=True)
|
||||
return super().get_queryset().filter(~trashed_Q)
|
||||
|
||||
|
||||
class FormViewFieldOptions(models.Model):
|
||||
objects = FormViewFieldOptionsManager()
|
||||
objects_and_trash = models.Manager()
|
||||
|
||||
form_view = models.ForeignKey(FormView, on_delete=models.CASCADE)
|
||||
field = models.ForeignKey(Field, on_delete=models.CASCADE)
|
||||
name = models.CharField(
|
||||
|
|
|
@ -171,7 +171,7 @@ class GridViewType(ViewType):
|
|||
"""
|
||||
|
||||
field_options = (
|
||||
GridViewFieldOptions.objects.filter(field=field)
|
||||
GridViewFieldOptions.objects_and_trash.filter(field=field)
|
||||
.exclude(aggregation_raw_type="")
|
||||
.select_related("grid_view")
|
||||
)
|
||||
|
|
|
@ -9,6 +9,7 @@ from rest_framework.status import (
|
|||
|
||||
from django.shortcuts import reverse
|
||||
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
from baserow.contrib.database.views.models import ViewFilter
|
||||
from baserow.contrib.database.views.registries import (
|
||||
view_type_registry,
|
||||
|
@ -522,3 +523,40 @@ def test_list_views_including_filters(api_client, data_fixture):
|
|||
assert response_json[0]["filters"][1]["id"] == filter_2.id
|
||||
assert len(response_json[1]["filters"]) == 1
|
||||
assert response_json[1]["filters"][0]["id"] == filter_3.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cant_update_view_filter_when_view_trashed(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
grid_view = data_fixture.create_grid_view(user=user)
|
||||
view_filter = data_fixture.create_view_filter(user=user, view=grid_view)
|
||||
|
||||
ViewHandler().delete_view(user, grid_view)
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
"api:database:views:filter_item", kwargs={"view_filter_id": view_filter.id}
|
||||
),
|
||||
data={"value": "new value"},
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cant_delete_view_filter_when_view_trashed(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
grid_view = data_fixture.create_grid_view(user=user)
|
||||
view_filter = data_fixture.create_view_filter(user=user, view=grid_view)
|
||||
|
||||
ViewHandler().delete_view(user, grid_view)
|
||||
|
||||
response = api_client.delete(
|
||||
reverse(
|
||||
"api:database:views:filter_item", kwargs={"view_filter_id": view_filter.id}
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
|
|
|
@ -9,6 +9,7 @@ from rest_framework.status import (
|
|||
|
||||
from django.shortcuts import reverse
|
||||
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
from baserow.contrib.database.views.models import ViewSort
|
||||
from baserow.contrib.database.views.registries import (
|
||||
view_type_registry,
|
||||
|
@ -454,3 +455,61 @@ def test_list_views_including_sortings(api_client, data_fixture):
|
|||
assert response_json[0]["sortings"][1]["id"] == sort_2.id
|
||||
assert len(response_json[1]["sortings"]) == 1
|
||||
assert response_json[1]["sortings"][0]["id"] == sort_3.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cant_get_view_sort_when_view_trashed(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)
|
||||
|
||||
ViewHandler().delete_view(user, view)
|
||||
|
||||
url = reverse("api:database:views:sort_item", kwargs={"view_sort_id": view.id})
|
||||
response = api_client.get(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cant_update_view_sort_when_view_trashed(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
view = data_fixture.create_grid_view(table=table)
|
||||
field = data_fixture.create_number_field(user, table=table)
|
||||
|
||||
view_sort = ViewHandler().create_sort(user, view, field, "asc")
|
||||
ViewHandler().delete_view(user, view)
|
||||
|
||||
url = reverse(
|
||||
"api:database:views:sort_item",
|
||||
kwargs={"view_sort_id": view_sort.id},
|
||||
)
|
||||
|
||||
response = api_client.patch(
|
||||
url,
|
||||
{"order": "ASC"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cant_delete_view_sort_when_view_trashed(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
view = data_fixture.create_grid_view(table=table)
|
||||
field = data_fixture.create_number_field(user, table=table)
|
||||
|
||||
view_sort = ViewHandler().create_sort(user, view, field, "asc")
|
||||
ViewHandler().delete_view(user, view)
|
||||
|
||||
url = reverse(
|
||||
"api:database:views:sort_item",
|
||||
kwargs={"view_sort_id": view_sort.id},
|
||||
)
|
||||
|
||||
response = api_client.delete(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}")
|
||||
|
||||
assert response.status_code == HTTP_404_NOT_FOUND
|
||||
|
|
|
@ -1409,3 +1409,23 @@ def test_trashing_two_linked_tables_after_one_perm_deleted_can_restore(
|
|||
TrashHandler.permanently_delete_marked_trash()
|
||||
|
||||
assert not TrashEntry.objects.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_trash_restore_view(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
database = data_fixture.create_database_application(user=user, name="Placeholder")
|
||||
table = data_fixture.create_database_table(name="Table 1", database=database)
|
||||
view = data_fixture.create_grid_view(name="View 1", table=table)
|
||||
|
||||
assert view.trashed is False
|
||||
|
||||
TrashHandler.trash(user, database.group, database, view)
|
||||
view.refresh_from_db()
|
||||
|
||||
assert view.trashed is True
|
||||
|
||||
TrashHandler.restore_item(user, "view", view.id)
|
||||
view.refresh_from_db()
|
||||
|
||||
assert view.trashed is False
|
||||
|
|
|
@ -2,10 +2,12 @@ import pytest
|
|||
import random
|
||||
from decimal import Decimal
|
||||
|
||||
from baserow.contrib.database.fields.handler import FieldHandler
|
||||
from baserow.contrib.database.views.registries import view_aggregation_type_registry
|
||||
from baserow.contrib.database.views.exceptions import FieldAggregationNotSupported
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
from baserow.contrib.database.fields.exceptions import FieldNotInTable
|
||||
from baserow.core.trash.handler import TrashHandler
|
||||
|
||||
from baserow.test_utils.helpers import setup_interesting_test_table
|
||||
|
||||
|
@ -353,3 +355,79 @@ def test_view_aggregation_errors(data_fixture):
|
|||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_aggregation_is_updated_when_view_is_trashed(data_fixture):
|
||||
"""
|
||||
Test that aggregation is updated when view is trashed
|
||||
|
||||
The following scenario is tested:
|
||||
- Create two views
|
||||
- Creat a field in both views
|
||||
- Create and aggregation in both views
|
||||
- Trash that view
|
||||
- Change the field type to a different type that is
|
||||
incompatible with the aggregation
|
||||
- Restore the view
|
||||
- Check that the aggregation is updated
|
||||
"""
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
grid_view_one = data_fixture.create_grid_view(table=table)
|
||||
grid_view_two = data_fixture.create_grid_view(table=table)
|
||||
field = data_fixture.create_number_field(user=user, table=table)
|
||||
application = table.database
|
||||
|
||||
view_handler = ViewHandler()
|
||||
|
||||
model = table.get_model()
|
||||
|
||||
model.objects.create(**{field.db_column: 1})
|
||||
model.objects.create(**{field.db_column: 2})
|
||||
|
||||
view_handler.update_field_options(
|
||||
view=grid_view_one,
|
||||
field_options={
|
||||
field.id: {
|
||||
"aggregation_type": "sum",
|
||||
"aggregation_raw_type": "sum",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
view_handler.update_field_options(
|
||||
view=grid_view_two,
|
||||
field_options={
|
||||
field.id: {
|
||||
"aggregation_type": "sum",
|
||||
"aggregation_raw_type": "sum",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Verify both views have an aggregation
|
||||
aggregations_view_one = view_handler.get_view_field_aggregations(grid_view_one)
|
||||
aggregations_view_two = view_handler.get_view_field_aggregations(grid_view_two)
|
||||
|
||||
assert field.db_column in aggregations_view_one
|
||||
assert field.db_column in aggregations_view_two
|
||||
|
||||
# Trash the view and verify that the aggregation is not retreivable anymore
|
||||
TrashHandler().trash(user, application.group, application, trash_item=grid_view_one)
|
||||
aggregations = view_handler.get_view_field_aggregations(grid_view_one)
|
||||
assert field.db_column not in aggregations
|
||||
|
||||
# Update the field and verify that the aggregation is removed from the
|
||||
# not trashed view
|
||||
FieldHandler().update_field(user, field, new_type_name="text")
|
||||
aggregations_not_trashed_view = view_handler.get_view_field_aggregations(
|
||||
grid_view_two
|
||||
)
|
||||
assert field.db_column not in aggregations_not_trashed_view
|
||||
|
||||
# Restore the view and verify that the aggregation
|
||||
# is also removed from the restored view
|
||||
TrashHandler().restore_item(user, "view", grid_view_one.id)
|
||||
aggregations_restored_view = view_handler.get_view_field_aggregations(grid_view_one)
|
||||
assert field.db_column not in aggregations_restored_view
|
||||
|
|
|
@ -1776,3 +1776,55 @@ def test_public_view_row_checker_runs_expected_queries_when_checking_rows(
|
|||
with django_assert_num_queries(2):
|
||||
# Now should run two queries, one per public view
|
||||
assert row_checker.get_public_views_where_row_is_visible(invisible_row) == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cant_get_view_filter_when_view_trashed(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
grid_view = data_fixture.create_grid_view(user=user)
|
||||
view_filter = data_fixture.create_view_filter(user=user, view=grid_view)
|
||||
|
||||
ViewHandler().delete_view(user, grid_view)
|
||||
|
||||
with pytest.raises(ViewFilterDoesNotExist):
|
||||
ViewHandler().get_filter(user, view_filter.id)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cant_apply_sorting_when_view_trashed(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
grid_view = data_fixture.create_grid_view(user=user)
|
||||
|
||||
ViewHandler().delete_view(user, grid_view)
|
||||
|
||||
with pytest.raises(ViewSortDoesNotExist):
|
||||
ViewHandler().apply_sorting(
|
||||
grid_view,
|
||||
grid_view.table.get_model().objects.all(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cant_get_sort_when_view_trashed(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
grid_view = data_fixture.create_grid_view(user=user)
|
||||
field = data_fixture.create_number_field(user, table=grid_view.table)
|
||||
|
||||
view_sort = ViewHandler().create_sort(user, grid_view, field, "asc")
|
||||
ViewHandler().delete_view(user, grid_view)
|
||||
|
||||
with pytest.raises(ViewSortDoesNotExist):
|
||||
ViewHandler().get_sort(user, view_sort.id)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cant_update_sort_when_view_trashed(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
grid_view = data_fixture.create_grid_view(user=user)
|
||||
field = data_fixture.create_number_field(user, table=grid_view.table)
|
||||
|
||||
view_sort = ViewHandler().create_sort(user, grid_view, field, "asc")
|
||||
ViewHandler().delete_view(user, grid_view)
|
||||
|
||||
with pytest.raises(ViewSortDoesNotExist):
|
||||
ViewHandler().update_sort(user, view_sort, field)
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import pytest
|
||||
|
||||
from baserow.contrib.database.views.models import (
|
||||
ViewFilter,
|
||||
ViewSort,
|
||||
GridViewFieldOptions,
|
||||
GalleryViewFieldOptions,
|
||||
FormViewFieldOptions,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_view_get_field_options(data_fixture):
|
||||
|
@ -59,6 +67,144 @@ def test_rotate_view_slug(data_fixture):
|
|||
assert str(form_view.slug) != old_slug
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_view_filter_manager_view_trashed(data_fixture):
|
||||
grid_view = data_fixture.create_grid_view()
|
||||
data_fixture.create_view_filter(view=grid_view)
|
||||
|
||||
assert ViewFilter.objects.count() == 1
|
||||
|
||||
grid_view.trashed = True
|
||||
grid_view.save()
|
||||
|
||||
assert ViewFilter.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_view_filter_manager_field_trashed(data_fixture):
|
||||
grid_view = data_fixture.create_grid_view()
|
||||
field = data_fixture.create_text_field(table=grid_view.table)
|
||||
data_fixture.create_view_filter(view=grid_view, field=field)
|
||||
|
||||
assert ViewFilter.objects.count() == 1
|
||||
|
||||
field.trashed = True
|
||||
field.save()
|
||||
|
||||
assert ViewFilter.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_view_sort_manager_view_trashed(data_fixture):
|
||||
grid_view = data_fixture.create_grid_view()
|
||||
data_fixture.create_view_sort(view=grid_view)
|
||||
|
||||
assert ViewSort.objects.count() == 1
|
||||
|
||||
grid_view.trashed = True
|
||||
grid_view.save()
|
||||
|
||||
assert ViewSort.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_view_sort_manager_field_trashed(data_fixture):
|
||||
grid_view = data_fixture.create_grid_view()
|
||||
field = data_fixture.create_text_field(table=grid_view.table)
|
||||
data_fixture.create_view_sort(view=grid_view, field=field)
|
||||
|
||||
assert ViewSort.objects.count() == 1
|
||||
|
||||
field.trashed = True
|
||||
field.save()
|
||||
|
||||
assert ViewSort.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grid_view_field_options_manager_view_trashed(data_fixture):
|
||||
grid_view = data_fixture.create_grid_view()
|
||||
field = data_fixture.create_text_field(table=grid_view.table)
|
||||
data_fixture.create_grid_view_field_option(grid_view, field)
|
||||
|
||||
assert GridViewFieldOptions.objects.count() == 1
|
||||
|
||||
grid_view.trashed = True
|
||||
grid_view.save()
|
||||
|
||||
assert GridViewFieldOptions.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_grid_view_field_options_manager_field_trashed(data_fixture):
|
||||
grid_view = data_fixture.create_grid_view()
|
||||
field = data_fixture.create_text_field(table=grid_view.table)
|
||||
data_fixture.create_grid_view_field_option(grid_view, field)
|
||||
|
||||
assert GridViewFieldOptions.objects.count() == 1
|
||||
|
||||
field.trashed = True
|
||||
field.save()
|
||||
|
||||
assert GridViewFieldOptions.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_gallery_view_field_options_manager_view_trashed(data_fixture):
|
||||
gallery_view = data_fixture.create_gallery_view()
|
||||
field = data_fixture.create_text_field(table=gallery_view.table)
|
||||
data_fixture.create_gallery_view_field_option(gallery_view, field)
|
||||
|
||||
assert GalleryViewFieldOptions.objects.count() == 1
|
||||
|
||||
gallery_view.trashed = True
|
||||
gallery_view.save()
|
||||
|
||||
assert GalleryViewFieldOptions.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_gallery_view_field_options_manager_field_trashed(data_fixture):
|
||||
gallery_view = data_fixture.create_gallery_view()
|
||||
field = data_fixture.create_text_field(table=gallery_view.table)
|
||||
data_fixture.create_gallery_view_field_option(gallery_view, field)
|
||||
|
||||
assert GalleryViewFieldOptions.objects.count() == 1
|
||||
|
||||
field.trashed = True
|
||||
field.save()
|
||||
|
||||
assert GalleryViewFieldOptions.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_form_view_field_options_manager_view_trashed(data_fixture):
|
||||
form_view = data_fixture.create_form_view()
|
||||
field = data_fixture.create_text_field(table=form_view.table)
|
||||
data_fixture.create_form_view_field_option(form_view, field)
|
||||
|
||||
assert FormViewFieldOptions.objects.count() == 1
|
||||
|
||||
form_view.trashed = True
|
||||
form_view.save()
|
||||
|
||||
assert FormViewFieldOptions.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_form_view_field_options_manager_field_trashed(data_fixture):
|
||||
form_view = data_fixture.create_form_view()
|
||||
field = data_fixture.create_text_field(table=form_view.table)
|
||||
data_fixture.create_form_view_field_option(form_view, field)
|
||||
|
||||
assert FormViewFieldOptions.objects.count() == 1
|
||||
|
||||
field.trashed = True
|
||||
field.save()
|
||||
|
||||
assert FormViewFieldOptions.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_public_view_password(data_fixture):
|
||||
form_view = data_fixture.create_form_view()
|
||||
|
|
|
@ -41,6 +41,7 @@
|
|||
* Added Spanish and Italian languages.
|
||||
* Added undo/redo.
|
||||
* Added password protection for publicly shared grids and forms.
|
||||
* Made views trashable.
|
||||
|
||||
## Released (2022-03-03 1.9.1)
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
from baserow.contrib.database.fields.models import Field, FileField, SingleSelectField
|
||||
from baserow.contrib.database.views.models import View
|
||||
from baserow.contrib.database.mixins import ParentFieldTrashableModelMixin
|
||||
|
||||
|
||||
class KanbanView(View):
|
||||
|
@ -30,7 +30,21 @@ class KanbanView(View):
|
|||
db_table = "database_kanbanview"
|
||||
|
||||
|
||||
class KanbanViewFieldOptions(ParentFieldTrashableModelMixin, models.Model):
|
||||
class KanbanViewFieldOptionsManager(models.Manager):
|
||||
"""
|
||||
The View can be trashed and the field options are not deleted, therefore
|
||||
we need to filter out the trashed views.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
trashed_Q = Q(kanban_view__trashed=True) | Q(field__trashed=True)
|
||||
return super().get_queryset().filter(~trashed_Q)
|
||||
|
||||
|
||||
class KanbanViewFieldOptions(models.Model):
|
||||
objects = KanbanViewFieldOptionsManager()
|
||||
objects_and_trash = models.Manager()
|
||||
|
||||
kanban_view = models.ForeignKey(KanbanView, on_delete=models.CASCADE)
|
||||
field = models.ForeignKey(Field, on_delete=models.CASCADE)
|
||||
hidden = models.BooleanField(
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
import pytest
|
||||
|
||||
from baserow_premium.views.models import KanbanViewFieldOptions
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_kanban_view_field_options_manager_view_trashed(premium_data_fixture):
|
||||
kanban_view = premium_data_fixture.create_kanban_view()
|
||||
|
||||
# create_kanban_view already creates a view field option
|
||||
# which we don't want to test here
|
||||
KanbanViewFieldOptions.objects.all().delete()
|
||||
|
||||
field = premium_data_fixture.create_text_field(table=kanban_view.table)
|
||||
premium_data_fixture.create_kanban_view_field_option(kanban_view, field)
|
||||
|
||||
assert KanbanViewFieldOptions.objects.count() == 1
|
||||
|
||||
kanban_view.trashed = True
|
||||
kanban_view.save()
|
||||
|
||||
assert KanbanViewFieldOptions.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_kanban_view_field_options_manager_field_trashed(premium_data_fixture):
|
||||
kanban_view = premium_data_fixture.create_kanban_view()
|
||||
|
||||
# create_kanban_view already creates a view field option
|
||||
# which we don't want to test here
|
||||
KanbanViewFieldOptions.objects.all().delete()
|
||||
|
||||
field = premium_data_fixture.create_text_field(table=kanban_view.table)
|
||||
premium_data_fixture.create_kanban_view_field_option(kanban_view, field)
|
||||
|
||||
assert KanbanViewFieldOptions.objects.count() == 1
|
||||
|
||||
field.trashed = True
|
||||
field.save()
|
||||
|
||||
assert KanbanViewFieldOptions.objects.count() == 0
|
|
@ -154,7 +154,8 @@
|
|||
"application": "",
|
||||
"table": "",
|
||||
"field": "",
|
||||
"row": ""
|
||||
"row": "",
|
||||
"view": ""
|
||||
},
|
||||
"webhook": {
|
||||
"request": "",
|
||||
|
|
|
@ -154,7 +154,8 @@
|
|||
"application": "Applikation",
|
||||
"table": "Tabelle",
|
||||
"field": "Feld",
|
||||
"row": "Zeile"
|
||||
"row": "Zeile",
|
||||
"view": "Ansicht"
|
||||
},
|
||||
"webhook": {
|
||||
"request": "Anfrage",
|
||||
|
|
|
@ -155,7 +155,8 @@
|
|||
"application": "application",
|
||||
"table": "table",
|
||||
"field": "field",
|
||||
"row": "row"
|
||||
"row": "row",
|
||||
"view": "view"
|
||||
},
|
||||
"webhook": {
|
||||
"request": "Request",
|
||||
|
|
|
@ -154,7 +154,8 @@
|
|||
"application": "aplicación",
|
||||
"table": "tabla",
|
||||
"field": "campo",
|
||||
"row": "fila"
|
||||
"row": "fila",
|
||||
"view": "vista"
|
||||
},
|
||||
"webhook": {
|
||||
"request": "Requerimiento",
|
||||
|
|
|
@ -154,7 +154,8 @@
|
|||
"application": "application",
|
||||
"table": "table",
|
||||
"field": "champ",
|
||||
"row": "ligne"
|
||||
"row": "ligne",
|
||||
"view": "vue"
|
||||
},
|
||||
"webhook": {
|
||||
"request": "Requête",
|
||||
|
|
|
@ -154,7 +154,8 @@
|
|||
"application": "applicazione",
|
||||
"table": "tabella",
|
||||
"field": "campo",
|
||||
"row": "riga"
|
||||
"row": "riga",
|
||||
"view": "vista"
|
||||
},
|
||||
"webhook": {
|
||||
"request": "Richiesta",
|
||||
|
|
|
@ -154,7 +154,8 @@
|
|||
"application": "applicatie",
|
||||
"table": "tabel",
|
||||
"field": "veld",
|
||||
"row": "rij"
|
||||
"row": "rij",
|
||||
"view": "weergave"
|
||||
},
|
||||
"webhook": {
|
||||
"request": "Verzoek",
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
<template>
|
||||
<Modal>
|
||||
<h2 class="box__title">
|
||||
{{ $t('deleteViewModal.title', { name: view.name }) }}
|
||||
</h2>
|
||||
<Error :error="error"></Error>
|
||||
<div>
|
||||
<i18n path="deleteViewModal.description" tag="p">
|
||||
<template #name>
|
||||
<strong>{{ view.name }}</strong>
|
||||
</template>
|
||||
</i18n>
|
||||
<div class="actions">
|
||||
<div class="align-right">
|
||||
<button
|
||||
class="button button--large button--error"
|
||||
:class="{ 'button--loading': loading }"
|
||||
:disabled="loading"
|
||||
@click="deleteView()"
|
||||
>
|
||||
{{ $t('deleteViewModal.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import modal from '@baserow/modules/core/mixins/modal'
|
||||
import error from '@baserow/modules/core/mixins/error'
|
||||
|
||||
export default {
|
||||
name: 'DeleteViewModal',
|
||||
mixins: [modal, error],
|
||||
props: {
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async deleteView() {
|
||||
this.hideError()
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('view/delete', this.view)
|
||||
this.hide()
|
||||
} catch (error) {
|
||||
this.handleError(error, 'view')
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -26,7 +26,6 @@
|
|||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<DeleteViewModal ref="deleteViewModal" :view="view" />
|
||||
<ExportTableModal ref="exportViewModal" :table="table" :view="view" />
|
||||
<WebhookModal ref="webhookModal" :table="table" />
|
||||
</Context>
|
||||
|
@ -34,16 +33,16 @@
|
|||
|
||||
<script>
|
||||
import context from '@baserow/modules/core/mixins/context'
|
||||
import error from '@baserow/modules/core/mixins/error'
|
||||
import viewTypeHasExporterTypes from '@baserow/modules/database/utils/viewTypeHasExporterTypes'
|
||||
|
||||
import ExportTableModal from '@baserow/modules/database/components/export/ExportTableModal'
|
||||
import DeleteViewModal from './DeleteViewModal'
|
||||
import WebhookModal from '@baserow/modules/database/components/webhook/WebhookModal.vue'
|
||||
|
||||
export default {
|
||||
name: 'ViewContext',
|
||||
components: { DeleteViewModal, ExportTableModal, WebhookModal },
|
||||
mixins: [context],
|
||||
components: { ExportTableModal, WebhookModal },
|
||||
mixins: [context, error],
|
||||
props: {
|
||||
view: {
|
||||
type: Object,
|
||||
|
@ -67,9 +66,16 @@ export default {
|
|||
this.$refs.context.hide()
|
||||
this.$emit('enable-rename')
|
||||
},
|
||||
deleteView() {
|
||||
this.$refs.context.hide()
|
||||
this.$refs.deleteViewModal.show()
|
||||
async deleteView() {
|
||||
this.setLoading(this.view, true)
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('view/delete', this.view)
|
||||
} catch (error) {
|
||||
this.handleError(error, 'view')
|
||||
}
|
||||
|
||||
this.setLoading(this.view, false)
|
||||
},
|
||||
exportView() {
|
||||
this.$refs.context.hide()
|
||||
|
|
Loading…
Add table
Reference in a new issue