mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-27 06:00:37 +00:00
Merge branch '320-attachment-filename-filter' into 'develop'
Showing how a filename contains filter could work without larger changes Closes #320 See merge request bramw/baserow!180
This commit is contained in:
commit
1d926d73b8
10 changed files with 200 additions and 2 deletions
backend
src/baserow/contrib/database
tests/baserow/contrib/database/view
web-frontend/modules/database
|
@ -71,11 +71,13 @@ class DatabaseConfig(AppConfig):
|
||||||
EqualViewFilterType, NotEqualViewFilterType, EmptyViewFilterType,
|
EqualViewFilterType, NotEqualViewFilterType, EmptyViewFilterType,
|
||||||
NotEmptyViewFilterType, DateEqualViewFilterType, DateNotEqualViewFilterType,
|
NotEmptyViewFilterType, DateEqualViewFilterType, DateNotEqualViewFilterType,
|
||||||
HigherThanViewFilterType, LowerThanViewFilterType, ContainsViewFilterType,
|
HigherThanViewFilterType, LowerThanViewFilterType, ContainsViewFilterType,
|
||||||
ContainsNotViewFilterType, BooleanViewFilterType,
|
FilenameContainsViewFilterType, ContainsNotViewFilterType,
|
||||||
SingleSelectEqualViewFilterType, SingleSelectNotEqualViewFilterType
|
BooleanViewFilterType, SingleSelectEqualViewFilterType,
|
||||||
|
SingleSelectNotEqualViewFilterType
|
||||||
)
|
)
|
||||||
view_filter_type_registry.register(EqualViewFilterType())
|
view_filter_type_registry.register(EqualViewFilterType())
|
||||||
view_filter_type_registry.register(NotEqualViewFilterType())
|
view_filter_type_registry.register(NotEqualViewFilterType())
|
||||||
|
view_filter_type_registry.register(FilenameContainsViewFilterType())
|
||||||
view_filter_type_registry.register(ContainsViewFilterType())
|
view_filter_type_registry.register(ContainsViewFilterType())
|
||||||
view_filter_type_registry.register(ContainsNotViewFilterType())
|
view_filter_type_registry.register(ContainsNotViewFilterType())
|
||||||
view_filter_type_registry.register(HigherThanViewFilterType())
|
view_filter_type_registry.register(HigherThanViewFilterType())
|
||||||
|
|
|
@ -201,6 +201,13 @@ class TableModelQuerySet(models.QuerySet):
|
||||||
model_field
|
model_field
|
||||||
)
|
)
|
||||||
|
|
||||||
|
view_filter_annotation = view_filter_type.get_annotation(
|
||||||
|
field_name,
|
||||||
|
value
|
||||||
|
)
|
||||||
|
if view_filter_annotation:
|
||||||
|
self = self.annotate(**view_filter_annotation)
|
||||||
|
|
||||||
# Depending on filter type we are going to combine the Q either as
|
# Depending on filter type we are going to combine the Q either as
|
||||||
# AND or as OR.
|
# AND or as OR.
|
||||||
if filter_type == FILTER_TYPE_AND:
|
if filter_type == FILTER_TYPE_AND:
|
||||||
|
|
|
@ -254,6 +254,13 @@ class ViewHandler:
|
||||||
model_field
|
model_field
|
||||||
)
|
)
|
||||||
|
|
||||||
|
view_filter_annotation = view_filter_type.get_annotation(
|
||||||
|
field_name,
|
||||||
|
view_filter.value
|
||||||
|
)
|
||||||
|
if view_filter_annotation:
|
||||||
|
queryset = queryset.annotate(**view_filter_annotation)
|
||||||
|
|
||||||
# Depending on filter type we are going to combine the Q either as AND or
|
# Depending on filter type we are going to combine the Q either as AND or
|
||||||
# as OR.
|
# as OR.
|
||||||
if view.filter_type == FILTER_TYPE_AND:
|
if view.filter_type == FILTER_TYPE_AND:
|
||||||
|
|
|
@ -120,6 +120,25 @@ class ViewFilterType(Instance):
|
||||||
|
|
||||||
raise NotImplementedError('Each must have his own get_filter method.')
|
raise NotImplementedError('Each must have his own get_filter method.')
|
||||||
|
|
||||||
|
def get_annotation(self, field_name, value):
|
||||||
|
"""
|
||||||
|
Optional method allowing this ViewFilterType to annotate the queryset prior to
|
||||||
|
the application of any Q filters returned by ViewFilterType.get_filter.
|
||||||
|
|
||||||
|
Should return a dictionary which can be unpacked into an annotate call or None
|
||||||
|
if you do not wish any annotation to be applied by your filter.
|
||||||
|
|
||||||
|
:param field_name: The name of the field that needs to be filtered.
|
||||||
|
:type field_name: str
|
||||||
|
:param value: The value that the field must be compared to.
|
||||||
|
:type value: str
|
||||||
|
:return: The dict object that will be unpacked into an annotate call or None if
|
||||||
|
no annotation needs to be done.
|
||||||
|
:rtype: None or dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ViewFilterTypeRegistry(Registry):
|
class ViewFilterTypeRegistry(Registry):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -6,6 +6,7 @@ from dateutil import parser
|
||||||
from dateutil.parser import ParserError
|
from dateutil.parser import ParserError
|
||||||
|
|
||||||
from django.db.models import Q, IntegerField, BooleanField
|
from django.db.models import Q, IntegerField, BooleanField
|
||||||
|
from django.db.models.expressions import RawSQL
|
||||||
from django.db.models.fields.related import ManyToManyField, ForeignKey
|
from django.db.models.fields.related import ManyToManyField, ForeignKey
|
||||||
from django.contrib.postgres.fields import JSONField
|
from django.contrib.postgres.fields import JSONField
|
||||||
|
|
||||||
|
@ -62,6 +63,58 @@ class NotEqualViewFilterType(NotViewFilterTypeMixin, EqualViewFilterType):
|
||||||
type = 'not_equal'
|
type = 'not_equal'
|
||||||
|
|
||||||
|
|
||||||
|
class FilenameContainsViewFilterType(ViewFilterType):
|
||||||
|
"""
|
||||||
|
The filename contains filter checks if the filename's visible name contains the
|
||||||
|
provided filter value. It is only compatible with fields.JSONField which contain
|
||||||
|
a list of File JSON Objects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
type = 'filename_contains'
|
||||||
|
compatible_field_types = [
|
||||||
|
FileFieldType.type
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_annotation(self, field_name, value):
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
|
# If an empty value has been provided we do not want to filter at all.
|
||||||
|
if value == '':
|
||||||
|
return None
|
||||||
|
|
||||||
|
# It is not possible to use Django's ORM to query for if one item in a JSONB
|
||||||
|
# list has has a key which contains a specified value.
|
||||||
|
#
|
||||||
|
# The closest thing the Django ORM provides is:
|
||||||
|
# queryset.filter(your_json_field__contains=[{"key":"value"}])
|
||||||
|
# However this is an exact match, so in the above example [{"key":"value_etc"}]
|
||||||
|
# would not match the filter.
|
||||||
|
#
|
||||||
|
# Instead we have to resort to RawSQL to use various built in PostgreSQL JSON
|
||||||
|
# Array manipulation functions to be able to 'iterate' over a JSONB list
|
||||||
|
# performing `like` on individual keys in said list.
|
||||||
|
num_files_with_name_like_value = f"""
|
||||||
|
EXISTS(
|
||||||
|
SELECT attached_files ->> 'visible_name'
|
||||||
|
FROM JSONB_ARRAY_ELEMENTS("{field_name}") as attached_files
|
||||||
|
WHERE UPPER(attached_files ->> 'visible_name') LIKE UPPER(%s)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
query = RawSQL(num_files_with_name_like_value, params=[f"%{value}%"],
|
||||||
|
output_field=BooleanField())
|
||||||
|
return {f"{field_name}_matches_visible_names": query}
|
||||||
|
|
||||||
|
def get_filter(self, field_name, value, model_field):
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
|
# If an empty value has been provided we do not want to filter at all.
|
||||||
|
if value == '':
|
||||||
|
return Q()
|
||||||
|
|
||||||
|
# Check if the model_field has a file which matches the provided filter value.
|
||||||
|
return Q(**{f'{field_name}_matches_visible_names': True})
|
||||||
|
|
||||||
|
|
||||||
class ContainsViewFilterType(ViewFilterType):
|
class ContainsViewFilterType(ViewFilterType):
|
||||||
"""
|
"""
|
||||||
The contains filter checks if the field value contains the provided filter value.
|
The contains filter checks if the field value contains the provided filter value.
|
||||||
|
|
|
@ -1329,3 +1329,67 @@ def test_not_empty_filter_type(data_fixture):
|
||||||
filter.field = single_select_field
|
filter.field = single_select_field
|
||||||
filter.save()
|
filter.save()
|
||||||
assert handler.apply_filters(grid_view, model.objects.all()).get().id == row_2.id
|
assert handler.apply_filters(grid_view, model.objects.all()).get().id == row_2.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_filename_contains_filter_type(data_fixture):
|
||||||
|
user = data_fixture.create_user()
|
||||||
|
table = data_fixture.create_database_table(user=user)
|
||||||
|
grid_view = data_fixture.create_grid_view(table=table)
|
||||||
|
file_field = data_fixture.create_file_field(table=table)
|
||||||
|
|
||||||
|
handler = ViewHandler()
|
||||||
|
model = table.get_model()
|
||||||
|
|
||||||
|
row = model.objects.create(**{
|
||||||
|
f'field_{file_field.id}': [{'visible_name': 'test_file.png'}],
|
||||||
|
})
|
||||||
|
row_with_multiple_files = model.objects.create(**{
|
||||||
|
f'field_{file_field.id}': [
|
||||||
|
{'visible_name': 'test.doc'},
|
||||||
|
{'visible_name': 'test.txt'}
|
||||||
|
],
|
||||||
|
})
|
||||||
|
row_with_no_files = model.objects.create(**{
|
||||||
|
f'field_{file_field.id}': [],
|
||||||
|
})
|
||||||
|
|
||||||
|
filter = data_fixture.create_view_filter(
|
||||||
|
view=grid_view,
|
||||||
|
field=file_field,
|
||||||
|
type='filename_contains',
|
||||||
|
value='test_file.png'
|
||||||
|
)
|
||||||
|
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
|
||||||
|
assert len(ids) == 1
|
||||||
|
assert row.id in ids
|
||||||
|
|
||||||
|
filter.value = '.jpg'
|
||||||
|
filter.save()
|
||||||
|
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
|
||||||
|
assert len(ids) == 0
|
||||||
|
|
||||||
|
filter.value = '.png'
|
||||||
|
filter.save()
|
||||||
|
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
|
||||||
|
assert len(ids) == 1
|
||||||
|
assert row.id in ids
|
||||||
|
|
||||||
|
filter.value = 'test.'
|
||||||
|
filter.save()
|
||||||
|
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
|
||||||
|
assert len(ids) == 1
|
||||||
|
assert row_with_multiple_files.id in ids
|
||||||
|
|
||||||
|
filter.value = ''
|
||||||
|
filter.save()
|
||||||
|
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
|
||||||
|
assert len(ids) == 3
|
||||||
|
assert row.id in ids
|
||||||
|
assert row_with_multiple_files.id in ids
|
||||||
|
assert row_with_no_files.id in ids
|
||||||
|
|
||||||
|
results = model.objects.all().filter_by_fields_object(filter_object={
|
||||||
|
f'filter__field_{file_field.id}__filename_contains': ['.png'],
|
||||||
|
}, filter_type='AND')
|
||||||
|
assert len(results) == 1
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
* Made it possible for the admin to disable new signups.
|
* Made it possible for the admin to disable new signups.
|
||||||
* Reduced the amount of queries when using the link row field.
|
* Reduced the amount of queries when using the link row field.
|
||||||
* Respect the date format when converting to a date field.
|
* Respect the date format when converting to a date field.
|
||||||
|
* Added a field type filename contains filter.
|
||||||
|
|
||||||
## Released (2021-02-04)
|
## Released (2021-02-04)
|
||||||
|
|
||||||
|
|
|
@ -327,6 +327,10 @@ export class LongTextFieldType extends FieldType {
|
||||||
return RowEditFieldLongText
|
return RowEditFieldLongText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEmptyValue(field) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
getSort(name, order) {
|
getSort(name, order) {
|
||||||
return (a, b) => {
|
return (a, b) => {
|
||||||
const stringA = a[name] === null ? '' : '' + a[name]
|
const stringA = a[name] === null ? '' : '' + a[name]
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
DateEqualViewFilterType,
|
DateEqualViewFilterType,
|
||||||
DateNotEqualViewFilterType,
|
DateNotEqualViewFilterType,
|
||||||
ContainsViewFilterType,
|
ContainsViewFilterType,
|
||||||
|
FilenameContainsViewFilterType,
|
||||||
ContainsNotViewFilterType,
|
ContainsNotViewFilterType,
|
||||||
HigherThanViewFilterType,
|
HigherThanViewFilterType,
|
||||||
LowerThanViewFilterType,
|
LowerThanViewFilterType,
|
||||||
|
@ -53,6 +54,7 @@ export default ({ store, app }) => {
|
||||||
app.$registry.register('viewFilter', new DateEqualViewFilterType())
|
app.$registry.register('viewFilter', new DateEqualViewFilterType())
|
||||||
app.$registry.register('viewFilter', new DateNotEqualViewFilterType())
|
app.$registry.register('viewFilter', new DateNotEqualViewFilterType())
|
||||||
app.$registry.register('viewFilter', new ContainsViewFilterType())
|
app.$registry.register('viewFilter', new ContainsViewFilterType())
|
||||||
|
app.$registry.register('viewFilter', new FilenameContainsViewFilterType())
|
||||||
app.$registry.register('viewFilter', new ContainsNotViewFilterType())
|
app.$registry.register('viewFilter', new ContainsNotViewFilterType())
|
||||||
app.$registry.register('viewFilter', new HigherThanViewFilterType())
|
app.$registry.register('viewFilter', new HigherThanViewFilterType())
|
||||||
app.$registry.register('viewFilter', new LowerThanViewFilterType())
|
app.$registry.register('viewFilter', new LowerThanViewFilterType())
|
||||||
|
|
|
@ -140,6 +140,45 @@ export class NotEqualViewFilterType extends ViewFilterType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class FilenameContainsViewFilterType extends ViewFilterType {
|
||||||
|
static getType() {
|
||||||
|
return 'filename_contains'
|
||||||
|
}
|
||||||
|
|
||||||
|
getName() {
|
||||||
|
return 'filename contains'
|
||||||
|
}
|
||||||
|
|
||||||
|
getInputComponent() {
|
||||||
|
return ViewFilterTypeText
|
||||||
|
}
|
||||||
|
|
||||||
|
getCompatibleFieldTypes() {
|
||||||
|
return ['file']
|
||||||
|
}
|
||||||
|
|
||||||
|
matches(rowValue, filterValue) {
|
||||||
|
filterValue = filterValue.toString().toLowerCase().trim()
|
||||||
|
|
||||||
|
if (filterValue === '') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < rowValue.length; i++) {
|
||||||
|
const visibleName = rowValue[i].visible_name
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
if (visibleName.includes(filterValue)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ContainsViewFilterType extends ViewFilterType {
|
export class ContainsViewFilterType extends ViewFilterType {
|
||||||
static getType() {
|
static getType() {
|
||||||
return 'contains'
|
return 'contains'
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue