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

Merge branch '137-file-and-image-field' into 'develop'

Resolve "File and image field"

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2020-11-30 18:52:40 +00:00
commit 1a61cb12ed
92 changed files with 4378 additions and 155 deletions
backend
changelog.md
docs/guides/installation
web-frontend

View file

@ -1,7 +1,7 @@
FROM python:3.6
ADD . /backend
RUN mkdir -p /media
WORKDIR /backend
ENV PYTHONPATH $PYTHONPATH:/backend/src

View file

@ -11,3 +11,4 @@ django-mjml==0.9.0
requests==2.25.0
itsdangerous==1.1.0
drf-spectacular==0.9.12
Pillow==8.0.1

View file

@ -2,3 +2,4 @@ flake8==3.7.9
pytest-django>=3.5.0
pytest-env==0.6.2
freezegun==0.3.15
responses==0.12.0

View file

@ -29,7 +29,7 @@ setup(
author='Bram Wiepjes (Baserow)',
author_email='bram@baserow.io',
license='MIT',
description='Baserow: open source online database web frontend.',
description='Baserow: open source online database backend.',
long_description='',
platforms=['linux'],
package_dir={'': 'src'},

View file

@ -5,6 +5,7 @@ from drf_spectacular.views import SpectacularJSONAPIView, SpectacularRedocView
from baserow.core.registries import plugin_registry, application_type_registry
from .user import urls as user_urls
from .user_files import urls as user_files_urls
from .groups import urls as group_urls
from .applications import urls as application_urls
@ -19,6 +20,7 @@ urlpatterns = [
name='redoc'
),
path('user/', include(user_urls, namespace='user')),
path('user-files/', include(user_files_urls, namespace='user_files')),
path('groups/', include(group_urls, namespace='groups')),
path('applications/', include(application_urls, namespace='applications'))
] + application_type_registry.api_urls + plugin_registry.api_urls

View file

@ -0,0 +1,30 @@
from rest_framework.status import (
HTTP_400_BAD_REQUEST, HTTP_413_REQUEST_ENTITY_TOO_LARGE
)
ERROR_INVALID_FILE = (
'ERROR_INVALID_FILE',
HTTP_400_BAD_REQUEST,
'No file has been provided or the file is invalid.'
)
ERROR_FILE_SIZE_TOO_LARGE = (
'ERROR_FILE_SIZE_TOO_LARGE',
HTTP_413_REQUEST_ENTITY_TOO_LARGE,
'The provided file is too large. Max {e.max_size_mb}MB is allowed.'
)
ERROR_FILE_URL_COULD_NOT_BE_REACHED = (
'ERROR_FILE_URL_COULD_NOT_BE_REACHED',
HTTP_400_BAD_REQUEST,
'The provided URL could not be reached.'
)
ERROR_INVALID_USER_FILE_NAME_ERROR = (
'ERROR_INVALID_USER_FILE_NAME_ERROR',
HTTP_400_BAD_REQUEST,
'The user file name {e.name} is invalid.'
)
ERROR_USER_FILE_DOES_NOT_EXIST = (
'ERROR_USER_FILE_DOES_NOT_EXIST',
HTTP_400_BAD_REQUEST,
'The user file {e.name_or_id} does not exist.'
)

View file

@ -0,0 +1,63 @@
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
from django.conf import settings
from django.core.files.storage import default_storage
from baserow.core.models import UserFile
from baserow.core.user_files.handler import UserFileHandler
class UserFileUploadViaURLRequestSerializer(serializers.Serializer):
url = serializers.URLField()
class UserFileURLAndThumbnailsSerializerMixin(serializers.Serializer):
url = serializers.SerializerMethodField()
thumbnails = serializers.SerializerMethodField()
def get_instance_attr(self, instance, name):
return getattr(instance, name)
@extend_schema_field(OpenApiTypes.URI)
def get_url(self, instance):
name = self.get_instance_attr(instance, 'name')
path = UserFileHandler().user_file_path(name)
url = default_storage.url(path)
return url
def get_thumbnails(self, instance):
if not self.get_instance_attr(instance, 'is_image'):
return None
name = self.get_instance_attr(instance, 'name')
return {
thumbnail_name: {
'url': default_storage.url(
UserFileHandler().user_file_thumbnail_path(
name,
thumbnail_name
)
),
'width': size[0],
'height': size[1]
}
for thumbnail_name, size in settings.USER_THUMBNAILS.items()
}
class UserFileSerializer(UserFileURLAndThumbnailsSerializerMixin,
serializers.ModelSerializer):
name = serializers.SerializerMethodField()
class Meta:
model = UserFile
fields = ('size', 'mime_type', 'is_image', 'image_width', 'image_height',
'uploaded_at', 'url', 'thumbnails', 'name', 'original_name')
@extend_schema_field(OpenApiTypes.STR)
def get_name(self, instance):
return instance.name

View file

@ -0,0 +1,13 @@
from django.conf.urls import url
from .views import (
UploadFileView, UploadViaURLView
)
app_name = 'baserow.api.user'
urlpatterns = [
url(r'^upload-file/$', UploadFileView.as_view(), name='upload_file'),
url(r'^upload-via-url/$', UploadViaURLView.as_view(), name='upload_via_url'),
]

View file

@ -0,0 +1,11 @@
from rest_framework.exceptions import ValidationError
from baserow.core.user_files.models import UserFile
from baserow.core.user_files.exceptions import InvalidUserFileNameError
def user_file_name_validator(value):
try:
UserFile.deconstruct_name(value)
except InvalidUserFileNameError:
raise ValidationError('The user file name is invalid.', code='invalid')

View file

@ -0,0 +1,91 @@
from django.db import transaction
from rest_framework.parsers import MultiPartParser
from drf_spectacular.utils import extend_schema
from drf_spectacular.plumbing import build_object_type
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from baserow.api.decorators import map_exceptions, validate_body
from baserow.api.schemas import get_error_schema
from baserow.core.user_files.exceptions import (
InvalidFileStreamError, FileSizeTooLargeError, FileURLCouldNotBeReached
)
from baserow.core.user_files.handler import UserFileHandler
from .serializers import UserFileSerializer, UserFileUploadViaURLRequestSerializer
from .errors import (
ERROR_INVALID_FILE, ERROR_FILE_SIZE_TOO_LARGE, ERROR_FILE_URL_COULD_NOT_BE_REACHED
)
class UploadFileView(APIView):
permission_classes = (IsAuthenticated,)
parser_classes = (MultiPartParser,)
@extend_schema(
tags=['User files'],
operation_id='upload_file',
description=(
'Uploads a file to Baserow by uploading the file contents directly. A '
'`file` multipart is expected containing the file contents.'
),
request=build_object_type(),
responses={
200: UserFileSerializer,
400: get_error_schema(['ERROR_INVALID_FILE', 'ERROR_FILE_SIZE_TOO_LARGE'])
}
)
@transaction.atomic
@map_exceptions({
InvalidFileStreamError: ERROR_INVALID_FILE,
FileSizeTooLargeError: ERROR_FILE_SIZE_TOO_LARGE
})
def post(self, request):
"""Uploads a file by uploading the contents directly."""
if 'file' not in request.FILES:
raise InvalidFileStreamError('No file was provided.')
file = request.FILES.get('file')
user_file = UserFileHandler().upload_user_file(request.user, file.name, file)
serializer = UserFileSerializer(user_file)
return Response(serializer.data)
class UploadViaURLView(APIView):
permission_classes = (IsAuthenticated,)
@extend_schema(
tags=['User files'],
operation_id='upload_via_url',
description=(
'Uploads a file to Baserow by downloading it from the provided URL.'
),
request=UserFileUploadViaURLRequestSerializer,
responses={
200: UserFileSerializer,
400: get_error_schema([
'ERROR_INVALID_FILE',
'ERROR_FILE_SIZE_TOO_LARGE',
'ERROR_FILE_URL_COULD_NOT_BE_REACHED'
])
}
)
@transaction.atomic
@map_exceptions({
InvalidFileStreamError: ERROR_INVALID_FILE,
FileSizeTooLargeError: ERROR_FILE_SIZE_TOO_LARGE,
FileURLCouldNotBeReached: ERROR_FILE_URL_COULD_NOT_BE_REACHED
})
@validate_body(UserFileUploadViaURLRequestSerializer)
def post(self, request, data):
"""Uploads a user file by downloading it from the provided URL."""
url = data['url']
user_file = UserFileHandler().upload_user_file_by_url(request.user, url)
serializer = UserFileSerializer(user_file)
return Response(serializer.data)

View file

@ -78,26 +78,26 @@ def validate_data(serializer_class, data):
:rtype: dict
"""
def add(details, key, error_list):
if 'key' not in details:
details[key] = []
for error in error_list:
details[key].append({
def serialize_errors_recursive(error):
if isinstance(error, dict):
return {
key: serialize_errors_recursive(errors)
for key, errors in error.items()
}
elif isinstance(error, list):
return [
serialize_errors_recursive(errors)
for errors in error
]
else:
return {
'error': force_text(error),
'code': error.code
})
}
serializer = serializer_class(data=data)
if not serializer.is_valid():
detail = {}
for key, errors in serializer.errors.items():
if isinstance(errors, dict):
detail[key] = {}
for group_key, group_errors in errors.items():
add(detail[key], group_key, group_errors)
else:
add(detail, key, errors)
detail = serialize_errors_recursive(serializer.errors)
raise RequestBodyValidationException(detail)
return serializer.data

View file

@ -1,6 +1,6 @@
import os
import datetime
from urllib.parse import urlparse
from urllib.parse import urlparse, urljoin
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -157,6 +157,7 @@ SPECTACULAR_SETTINGS = {
'SERVE_INCLUDE_SCHEMA': False,
'TAGS': [
{'name': 'User'},
{'name': 'User files'},
{'name': 'Groups'},
{'name': 'Applications'},
{'name': 'Database tables'},
@ -172,6 +173,9 @@ SPECTACULAR_SETTINGS = {
DATABASE_ROUTERS = ('baserow.contrib.database.database_routers.TablesDatabaseRouter',)
# The storage must always overwrite existing files.
DEFAULT_FILE_STORAGE = 'baserow.core.storage.OverwriteFileSystemStorage'
MJML_BACKEND_MODE = 'tcpserver'
MJML_TCPSERVERS = [
(os.getenv('MJML_SERVER_HOST', 'mjml'), int(os.getenv('MJML_SERVER_PORT', 28101))),
@ -198,3 +202,19 @@ ROW_PAGE_SIZE_LIMIT = 200 # Indicates how many rows can be requested at once.
INITIAL_TABLE_DATA_LIMIT = None
if 'INITIAL_TABLE_DATA_LIMIT' in os.environ:
INITIAL_TABLE_DATA_LIMIT = int(os.getenv('INITIAL_TABLE_DATA_LIMIT'))
MEDIA_URL_PATH = '/media/'
MEDIA_URL = os.getenv('MEDIA_URL', urljoin(PUBLIC_BACKEND_URL, MEDIA_URL_PATH))
MEDIA_ROOT = os.getenv('MEDIA_ROOT', '/media')
# Indicates the directory where the user files and user thumbnails are stored.
USER_FILES_DIRECTORY = 'user_files'
USER_THUMBNAILS_DIRECTORY = 'thumbnails'
USER_FILE_SIZE_LIMIT = 1024 * 1024 * 20 # 20MB
# Configurable thumbnails that are going to be generated when a user uploads an image
# file.
USER_THUMBNAILS = {
'tiny': [None, 21],
'small': [48, 48]
}

View file

@ -1,4 +1,5 @@
from .base import * # noqa: F403, F401
DEBUG = True
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

View file

@ -1 +1,6 @@
from .base import * # noqa: F403, F401
USER_FILES_DIRECTORY = 'user_files'
USER_THUMBNAILS_DIRECTORY = 'thumbnails'
USER_THUMBNAILS = {'tiny': [21, 21]}

View file

@ -1,6 +1,8 @@
from django.urls import include
from django.conf.urls import url
from django.http import HttpResponse
from django.conf import settings
from django.conf.urls.static import static
from baserow.core.registries import plugin_registry
@ -12,4 +14,7 @@ def health(request):
urlpatterns = [
url(r'^api/', include('baserow.api.urls', namespace='api')),
url(r'^_health$', health, name='health_check')
] + plugin_registry.urls
] + plugin_registry.urls + static(
settings.MEDIA_URL_PATH,
document_root=settings.MEDIA_ROOT
)

View file

@ -5,6 +5,8 @@ from drf_spectacular.types import OpenApiTypes
from rest_framework import serializers
from baserow.api.user_files.validators import user_file_name_validator
from baserow.api.user_files.serializers import UserFileURLAndThumbnailsSerializerMixin
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.fields.models import Field
@ -71,3 +73,30 @@ class LinkRowValueSerializer(serializers.Serializer):
source=value_field_name,
required=False
)
class FileFieldRequestSerializer(serializers.Serializer):
visible_name = serializers.CharField(
required=False,
help_text='A visually editable name for the field.'
)
name = serializers.CharField(
required=True,
validators=[user_file_name_validator],
help_text='Accepts the name of the already uploaded user file.'
)
class FileFieldResponseSerializer(UserFileURLAndThumbnailsSerializerMixin,
serializers.Serializer):
visible_name = serializers.CharField()
name = serializers.CharField()
size = serializers.IntegerField()
mime_type = serializers.CharField()
is_image = serializers.BooleanField()
image_width = serializers.IntegerField()
image_height = serializers.IntegerField()
uploaded_at = serializers.DateTimeField()
def get_instance_attr(self, instance, name):
return instance[name]

View file

@ -13,7 +13,9 @@ from baserow.api.decorators import map_exceptions
from baserow.api.pagination import PageNumberPagination
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP
from baserow.api.schemas import get_error_schema
from baserow.api.user_files.errors import ERROR_USER_FILE_DOES_NOT_EXIST
from baserow.core.exceptions import UserNotInGroupError
from baserow.core.user_files.exceptions import UserFileDoesNotExist
from baserow.contrib.database.api.tokens.authentications import TokenAuthentication
from baserow.contrib.database.api.tables.errors import ERROR_TABLE_DOES_NOT_EXIST
from baserow.contrib.database.api.rows.errors import ERROR_ROW_DOES_NOT_EXIST
@ -185,7 +187,8 @@ class RowsView(APIView):
@map_exceptions({
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
NoPermissionToTable: ERROR_NO_PERMISSION_TO_TABLE
NoPermissionToTable: ERROR_NO_PERMISSION_TO_TABLE,
UserFileDoesNotExist: ERROR_USER_FILE_DOES_NOT_EXIST
})
def post(self, request, table_id):
"""
@ -316,7 +319,8 @@ class RowView(APIView):
UserNotInGroupError: ERROR_USER_NOT_IN_GROUP,
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
RowDoesNotExist: ERROR_ROW_DOES_NOT_EXIST,
NoPermissionToTable: ERROR_NO_PERMISSION_TO_TABLE
NoPermissionToTable: ERROR_NO_PERMISSION_TO_TABLE,
UserFileDoesNotExist: ERROR_USER_FILE_DOES_NOT_EXIST
})
def patch(self, request, table_id, row_id):
"""

View file

@ -45,7 +45,8 @@ class DatabaseConfig(AppConfig):
from .fields.field_types import (
TextFieldType, LongTextFieldType, URLFieldType, NumberFieldType,
BooleanFieldType, DateFieldType, LinkRowFieldType, EmailFieldType
BooleanFieldType, DateFieldType, LinkRowFieldType, EmailFieldType,
FileFieldType
)
field_type_registry.register(TextFieldType())
field_type_registry.register(LongTextFieldType())
@ -55,9 +56,11 @@ class DatabaseConfig(AppConfig):
field_type_registry.register(BooleanFieldType())
field_type_registry.register(DateFieldType())
field_type_registry.register(LinkRowFieldType())
field_type_registry.register(FileFieldType())
from .fields.field_converters import LinkRowFieldConverter
from .fields.field_converters import LinkRowFieldConverter, FileFieldConverter
field_converter_registry.register(LinkRowFieldConverter())
field_converter_registry.register(FileFieldConverter())
from .views.view_types import GridViewType
view_type_registry.register(GridViewType())

View file

@ -1,5 +1,5 @@
from .registries import FieldConverter
from .models import LinkRowField
from .models import LinkRowField, FileField
class RecreateFieldConverter(FieldConverter):
@ -35,3 +35,18 @@ class LinkRowFieldConverter(RecreateFieldConverter):
from_field.link_row_table_id != to_field.link_row_table_id
)
)
class FileFieldConverter(RecreateFieldConverter):
type = 'file'
def is_applicable(self, from_model, from_field, to_field):
return (
(
isinstance(from_field, FileField) and
not isinstance(to_field, FileField)
) or (
not isinstance(from_field, FileField) and
isinstance(to_field, FileField)
)
)

View file

@ -6,13 +6,18 @@ from dateutil.parser import ParserError
from datetime import datetime, date
from django.db import models
from django.contrib.postgres.fields import JSONField
from django.core.validators import URLValidator, EmailValidator
from django.core.exceptions import ValidationError
from django.utils.timezone import make_aware
from rest_framework import serializers
from baserow.contrib.database.api.fields.serializers import LinkRowValueSerializer
from baserow.core.models import UserFile
from baserow.core.user_files.exceptions import UserFileDoesNotExist
from baserow.contrib.database.api.fields.serializers import (
LinkRowValueSerializer, FileFieldRequestSerializer, FileFieldResponseSerializer
)
from baserow.contrib.database.api.fields.errors import (
ERROR_LINK_ROW_TABLE_NOT_IN_SAME_DATABASE, ERROR_LINK_ROW_TABLE_NOT_PROVIDED
)
@ -21,7 +26,7 @@ from .handler import FieldHandler
from .registries import FieldType
from .models import (
NUMBER_TYPE_INTEGER, NUMBER_TYPE_DECIMAL, TextField, LongTextField, URLField,
NumberField, BooleanField, DateField, LinkRowField, EmailField
NumberField, BooleanField, DateField, LinkRowField, EmailField, FileField
)
from .exceptions import LinkRowTableNotInSameDatabase, LinkRowTableNotProvided
@ -587,3 +592,101 @@ class EmailFieldType(FieldType):
)"""
return super().get_alter_column_type_function(connection, instance)
class FileFieldType(FieldType):
type = 'file'
model_class = FileField
def prepare_value_for_db(self, instance, value):
if value is None:
return []
if not isinstance(value, list):
raise ValidationError('The provided value must be a list.')
if len(value) == 0:
return []
# Validates the provided object and extract the names from it. We need the name
# to validate if the file actually exists and to get the 'real' properties
# from it.
provided_files = []
for o in value:
if not isinstance(o, object) or not isinstance(o.get('name'), str):
raise ValidationError('Every provided value must at least contain '
'the file name as `name`.')
if 'visible_name' in o and not isinstance(o['visible_name'], str):
raise ValidationError('The provided `visible_name` must be a string.')
provided_files.append(o)
# Create a list of the serialized UserFiles in the originally provided order
# because that is also the order we need to store the serialized versions in.
user_files = []
queryset = UserFile.objects.all().name(*[f['name'] for f in provided_files])
for file in provided_files:
try:
user_file = next(
user_file
for user_file in queryset
if user_file.name == file['name']
)
serialized = user_file.serialize()
serialized['visible_name'] = (
file.get('visible_name') or user_file.original_name
)
except StopIteration:
raise UserFileDoesNotExist(
file['name'],
f"The provided file {file['name']} does not exist."
)
user_files.append(serialized)
return user_files
def get_serializer_field(self, instance, **kwargs):
return serializers.ListSerializer(
child=FileFieldRequestSerializer(),
required=False,
allow_null=True,
**kwargs
)
def get_response_serializer_field(self, instance, **kwargs):
return FileFieldResponseSerializer(many=True, required=False, **kwargs)
def get_serializer_help_text(self, instance):
return 'This field accepts an `array` containing objects with the name of ' \
'the file. The response contains an `array` of more detailed objects ' \
'related to the files.'
def get_model_field(self, instance, **kwargs):
return JSONField(default=[], **kwargs)
def random_value(self, instance, fake, cache):
"""
Selects between 0 and 3 random user files and returns those serialized in a
list.
"""
count_name = f'field_{instance.id}_count'
if count_name not in cache:
cache[count_name] = UserFile.objects.all().count()
values = []
count = cache[count_name]
if count == 0:
return values
for i in range(0, randrange(0, 3)):
instance = UserFile.objects.all()[randint(0, count - 1)]
serialized = instance.serialize()
serialized['visible_name'] = serialized['name']
values.append(serialized)
return values

View file

@ -239,3 +239,7 @@ class LinkRowField(Field):
class EmailField(Field):
pass
class FileField(Field):
pass

View file

@ -0,0 +1,27 @@
# Generated by Django 2.2.11 on 2020-11-10 12:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('database', '0017_view_filters_disabled'),
]
operations = [
migrations.AlterField(
model_name='token',
name='created',
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name='view',
name='filters_disabled',
field=models.BooleanField(
default=False,
help_text='Allows users to see results unfiltered while still keeping '
'the filters saved for the view.'
),
),
]

View file

@ -0,0 +1,31 @@
# Generated by Django 2.2.11 on 2020-11-16 08:53
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('database', '0018_auto_20201110_1251'),
]
operations = [
migrations.CreateModel(
name='FileField',
fields=[
(
'field_ptr',
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to='database.Field'
)
),
],
bases=('database.field',),
),
]

View file

@ -24,7 +24,7 @@ class Token(models.Model):
help_text='The unique token key that can be used to authorize for the table '
'row endpoints.'
)
created = models.DateTimeField(auto_now=True)
created = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,

View file

@ -43,7 +43,7 @@ class View(OrderableMixin, PolymorphicContentTypeMixin, models.Model):
)
filters_disabled = models.BooleanField(
default=False,
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.'
)

View file

@ -7,10 +7,11 @@ from dateutil.parser import ParserError
from django.db.models import Q, IntegerField, BooleanField
from django.db.models.fields.related import ManyToManyField
from django.contrib.postgres.fields import JSONField
from baserow.contrib.database.fields.field_types import (
TextFieldType, LongTextFieldType, URLFieldType, NumberFieldType, DateFieldType,
LinkRowFieldType, BooleanFieldType, EmailFieldType
LinkRowFieldType, BooleanFieldType, EmailFieldType, FileFieldType
)
from .registries import ViewFilterType
@ -251,7 +252,8 @@ class EmptyViewFilterType(ViewFilterType):
BooleanFieldType.type,
DateFieldType.type,
LinkRowFieldType.type,
EmailFieldType.type
EmailFieldType.type,
FileFieldType.type
]
def get_filter(self, field_name, value, model_field):
@ -265,6 +267,10 @@ class EmptyViewFilterType(ViewFilterType):
q = Q(**{f'{field_name}__isnull': True})
q.add(Q(**{f'{field_name}': None}), Q.OR)
if isinstance(model_field, JSONField):
q.add(Q(**{f'{field_name}': []}), Q.OR)
q.add(Q(**{f'{field_name}': {}}), Q.OR)
# If the model field accepts an empty string as value we are going to add
# that to the or statement.
try:

View file

@ -0,0 +1,45 @@
from PIL import Image
from django.core.management.base import BaseCommand
from django.core.files.storage import default_storage
from baserow.core.user_files.models import UserFile
from baserow.core.user_files.handler import UserFileHandler
class Command(BaseCommand):
help = 'Regenerates all the user file thumbnails based on the current settings. ' \
'Existing files will be overwritten.'
def handle(self, *args, **options):
"""
Regenerates the thumbnails of all image user files. If the USER_THUMBNAILS
setting ever changes then this file can be used to fix all the thumbnails.
"""
i = 0
handler = UserFileHandler()
buffer_size = 100
queryset = UserFile.objects.filter(is_image=True)
count = queryset.count()
while i < count:
user_files = queryset[i:min(count, i + buffer_size)]
for user_file in user_files:
i += 1
full_path = handler.user_file_path(user_file)
stream = default_storage.open(full_path)
try:
image = Image.open(stream)
handler.generate_and_save_image_thumbnails(
image, user_file, storage=default_storage
)
image.close()
except IOError:
pass
stream.close()
self.stdout.write(self.style.SUCCESS(f"{i} thumbnails have been regenerated."))

View file

@ -0,0 +1,49 @@
# Generated by Django 2.2.11 on 2020-11-10 13:09
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='UserFile',
fields=[
(
'id',
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID'
)
),
('original_name', models.CharField(max_length=255)),
('original_extension', models.CharField(max_length=64)),
('unique', models.CharField(max_length=32)),
('size', models.PositiveIntegerField()),
('mime_type', models.CharField(max_length=127, blank=True)),
('is_image', models.BooleanField(default=False)),
('image_width', models.PositiveSmallIntegerField(null=True)),
('image_height', models.PositiveSmallIntegerField(null=True)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('sha256_hash', models.CharField(db_index=True, max_length=64)),
(
'uploaded_by',
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL
)
),
],
options={'ordering': ('id',)}
),
]

View file

@ -2,9 +2,13 @@ from django.db import models
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from baserow.core.user_files.models import UserFile
from .managers import GroupQuerySet
from .mixins import OrderableMixin, PolymorphicContentTypeMixin
__all__ = ['UserFile']
User = get_user_model()

View file

@ -0,0 +1,11 @@
from django.core.files.storage import FileSystemStorage
class OverwriteFileSystemStorage(FileSystemStorage):
def _save(self, name, content):
if self.exists(name):
self.delete(name)
return super()._save(name, content)
def get_available_name(self, name, *args, **kwargs):
return name

View file

@ -0,0 +1,40 @@
import math
class InvalidFileStreamError(Exception):
"""Raised when the provided file stream is invalid."""
class FileSizeTooLargeError(Exception):
"""Raised when the provided file is too large."""
def __init__(self, max_size_bytes, *args, **kwargs):
self.max_size_mb = math.floor(max_size_bytes / 1024 / 1024)
super().__init__(*args, **kwargs)
class FileURLCouldNotBeReached(Exception):
"""Raised when the provided URL could not be reached."""
class InvalidUserFileNameError(Exception):
"""Raised when the provided user file name is invalid."""
def __init__(self, name, *args, **kwargs):
self.name = name
super().__init__(*args, **kwargs)
class UserFileDoesNotExist(Exception):
"""Raised when a user file with the provided name or id does not exist."""
def __init__(self, name_or_id, *args, **kwargs):
self.name_or_id = name_or_id
super().__init__(*args, **kwargs)
class MaximumUniqueTriesError(Exception):
"""
Raised when the maximum tries has been exceeded while generating a unique user file
string.
"""

View file

@ -0,0 +1,265 @@
import pathlib
import mimetypes
from os.path import join
from io import BytesIO
import requests
from requests.exceptions import RequestException
from PIL import Image, ImageOps
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
from baserow.core.utils import sha256_hash, stream_size, random_string
from .exceptions import (
InvalidFileStreamError, FileSizeTooLargeError, FileURLCouldNotBeReached,
MaximumUniqueTriesError
)
from .models import UserFile
class UserFileHandler:
def user_file_path(self, user_file_name):
"""
Generates the full user file path based on the provided file name. This path
can be used with the storage.
:param user_file_name: The user file name.
:type user_file_name: str
:return: The generated path.
:rtype: str
"""
if isinstance(user_file_name, UserFile):
user_file_name = user_file_name.name
return join(settings.USER_FILES_DIRECTORY, user_file_name)
def user_file_thumbnail_path(self, user_file_name, thumbnail_name):
"""
Generates the full user file thumbnail path based on the provided filename.
This path can be used with the storage.
:param user_file_name: The user file name.
:type user_file_name: str
:param thumbnail_name: The thumbnail type name.
:type thumbnail_name: str
:return: The generated path.
:rtype: str
"""
if isinstance(user_file_name, UserFile):
user_file_name = user_file_name.name
return join(settings.USER_THUMBNAILS_DIRECTORY, thumbnail_name, user_file_name)
def generate_unique(self, sha256_hash, extension, length=32, max_tries=1000):
"""
Generates a unique non existing string for a new user file.
:param sha256_hash: The hash of the file name. Needed because they are
required to be unique together.
:type sha256_hash: str
:param extension: The extension of the file name. Needed because they are
required to be unique together.
:type extension: str
:param length: Indicates the amount of characters that the unique must contain.
:type length: int
:param max_tries: The maximum amount of tries to check if a unique already
exists.
:type max_tries: int
:raises MaximumUniqueTriesError: When the maximum amount of tries has
been exceeded.
:return: The generated unique string
:rtype: str
"""
i = 0
while True:
if i > max_tries:
raise MaximumUniqueTriesError(
f'Tried {max_tries} tokens, but none of them are unique.'
)
i += 1
unique = random_string(length)
if not UserFile.objects.filter(
sha256_hash=sha256_hash,
original_extension=extension,
unique=unique
).exists():
return unique
def generate_and_save_image_thumbnails(self, image, user_file, storage=None):
"""
Generates the thumbnails based on the current settings and saves them to the
provided storage. Note that existing files with the same name will be
overwritten.
:param image: The original Pillow image that serves as base when generating the
the image.
:type image: Image
:param user_file: The user file for which the thumbnails must be generated
and saved.
:type user_file: UserFile
:param storage: The storage where the thumbnails must be saved to.
:type storage: Storage or None
:raises ValueError: If the provided user file is not a valid image.
"""
if not user_file.is_image:
raise ValueError('The provided user file is not an image.')
storage = storage or default_storage
image_width = user_file.image_width
image_height = user_file.image_height
for name, size in settings.USER_THUMBNAILS.items():
size_copy = size.copy()
# If the width or height is None we want to keep the aspect ratio.
if size_copy[0] is None and size_copy[1] is not None:
size_copy[0] = round(image_width / image_height * size_copy[1])
elif size_copy[1] is None and size_copy[0] is not None:
size_copy[1] = round(image_height / image_width * size_copy[0])
thumbnail = ImageOps.fit(image.copy(), size_copy, Image.ANTIALIAS)
thumbnail_stream = BytesIO()
thumbnail.save(thumbnail_stream, image.format)
thumbnail_stream.seek(0)
thumbnail_path = self.user_file_thumbnail_path(user_file, name)
storage.save(thumbnail_path, thumbnail_stream)
del thumbnail
del thumbnail_stream
def upload_user_file(self, user, file_name, stream, storage=None):
"""
Saves the provided uploaded file in the provided storage. If no storage is
provided the default_storage will be used. An entry into the user file table
is also created.
:param user: The user on whose behalf the file is uploaded.
:type user: User
:param file_name: The provided file name when the file was uploaded.
:type file_name: str
:param stream: An IO stream containing the uploaded file.
:type stream: IOBase
:param storage: The storage where the file must be saved to.
:type storage: Storage
:raises InvalidFileStreamError: If the provided stream is invalid.
:raises FileSizeToLargeError: If the provided content is too large.
:return: The newly created user file.
:rtype: UserFile
"""
if not hasattr(stream, 'read'):
raise InvalidFileStreamError('The provided stream is not readable.')
size = stream_size(stream)
if size > settings.USER_FILE_SIZE_LIMIT:
raise FileSizeTooLargeError(
settings.USER_FILE_SIZE_LIMIT,
'The provided file is too large.'
)
storage = storage or default_storage
hash = sha256_hash(stream)
try:
return UserFile.objects.get(original_name=file_name, sha256_hash=hash)
except UserFile.DoesNotExist:
pass
extension = pathlib.Path(file_name).suffix[1:].lower()
mime_type = mimetypes.guess_type(file_name)[0] or ''
unique = self.generate_unique(hash, extension)
# By default the provided file is not an image.
image = None
is_image = False
image_width = None
image_height = None
# Try to open the image with Pillow. If that succeeds we know the file is an
# image.
try:
image = Image.open(stream)
is_image = True
image_width = image.width
image_height = image.height
except IOError:
pass
user_file = UserFile.objects.create(
original_name=file_name,
original_extension=extension,
size=size,
mime_type=mime_type,
unique=unique,
uploaded_by=user,
sha256_hash=hash,
is_image=is_image,
image_width=image_width,
image_height=image_height
)
# If the uploaded file is an image we need to generate the configurable
# thumbnails for it. We want to generate them before the file is saved to the
# storage because some storages close the stream after saving.
if image:
self.generate_and_save_image_thumbnails(image, user_file, storage=storage)
# When all the thumbnails have been generated, the image can be deleted
# from memory.
del image
# Save the file to the storage.
full_path = self.user_file_path(user_file)
storage.save(full_path, stream)
# Close the stream because we don't need it anymore.
stream.close()
return user_file
def upload_user_file_by_url(self, user, url, storage=None):
"""
Uploads a user file by downloading it from the provided URL.
:param user: The user on whose behalf the file is uploaded.
:type user: User
:param url: The URL where the file must be downloaded from.
:type url: str
:param storage: The storage where the file must be saved to.
:type storage: Storage
:raises FileURLCouldNotBeReached: If the file could not be downloaded from
the URL.
:return: The newly created user file.
:rtype: UserFile
"""
file_name = url.split('/')[-1]
try:
response = requests.get(url, stream=True, timeout=10)
if not response.ok:
raise FileURLCouldNotBeReached('The response did not respond with an '
'OK status code.')
content = response.raw.read(
settings.USER_FILE_SIZE_LIMIT + 1,
decode_content=True
)
except RequestException:
raise FileURLCouldNotBeReached('The provided URL could not be reached.')
file = SimpleUploadedFile(file_name, content)
return UserFileHandler().upload_user_file(user, file_name, file, storage)

View file

@ -0,0 +1,15 @@
from django.db import models
from django.db.models import Q
class UserFileQuerySet(models.QuerySet):
def name(self, *names):
if len(names) == 0:
raise ValueError('At least one name must be provided.')
q_or = Q()
for name in names:
q_or |= Q(**self.model.deconstruct_name(name))
return self.filter(q_or)

View file

@ -0,0 +1,80 @@
import re
from django.db import models
from django.contrib.auth import get_user_model
from .exceptions import InvalidUserFileNameError
from .managers import UserFileQuerySet
User = get_user_model()
deconstruct_user_file_regex = re.compile(
r'([a-zA-Z0-9]*)_([a-zA-Z0-9]*)\.([a-zA-Z0-9]*)$'
)
class UserFile(models.Model):
original_name = models.CharField(max_length=255)
original_extension = models.CharField(max_length=64)
unique = models.CharField(max_length=32)
size = models.PositiveIntegerField()
mime_type = models.CharField(max_length=127, blank=True)
is_image = models.BooleanField(default=False)
image_width = models.PositiveSmallIntegerField(null=True)
image_height = models.PositiveSmallIntegerField(null=True)
uploaded_at = models.DateTimeField(auto_now_add=True)
uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
sha256_hash = models.CharField(max_length=64, db_index=True)
objects = UserFileQuerySet.as_manager()
class Meta:
ordering = ('id',)
def serialize(self):
"""
Generates a serialized version that can be stored in other data sources. This
is possible because the state of the UserFile never changes.
:return: The serialized version.
:rtype: dict
"""
return {
'name': self.name,
'size': self.size,
'mime_type': self.mime_type,
'is_image': self.is_image,
'image_width': self.image_width,
'image_height': self.image_height,
'uploaded_at': self.uploaded_at.isoformat()
}
@property
def name(self):
return f'{self.unique}_{self.sha256_hash}.{self.original_extension}'
@staticmethod
def deconstruct_name(name):
"""
Extracts the model field name values from the provided file name and returns it
as a mapping.
:param name: The model generated file name.
:type name: str
:return: The field name and extracted value mapping.
:rtype: dict
"""
matches = deconstruct_user_file_regex.match(name)
if not matches:
raise InvalidUserFileNameError(
name,
'The provided name is not in the correct format.'
)
return {
'unique': matches[1],
'sha256_hash': matches[2],
'original_extension': matches[3]
}

View file

@ -1,6 +1,8 @@
import os
import re
import random
import string
import hashlib
from collections import namedtuple
@ -175,3 +177,39 @@ def random_string(length):
string.ascii_letters + string.digits
) for _ in range(length)
)
def sha256_hash(stream, block_size=65536):
"""
Calculates a sha256 hash for the contents of the provided stream.
:param stream: The stream of the content where to calculate the hash for.
:type stream: IOStream
:param block_size: The amount of bytes that are read each time.
:type block_size: int
:return: The calculated hash.
:rtype: str
"""
stream.seek(0)
hasher = hashlib.sha256()
for stream_chunk in iter(lambda: stream.read(block_size), b''):
hasher.update(stream_chunk)
stream.seek(0)
return hasher.hexdigest()
def stream_size(stream):
"""
Calculates the total amount of bytes of the stream's content.
:param stream: The stream of the content where to calculate the size for.
:type stream: IOStream
:return: The total size of the stream.
:rtype: int
"""
stream.seek(0, os.SEEK_END)
size = stream.tell()
stream.seek(0)
return size

View file

@ -27,6 +27,12 @@ class TemporarySerializer(serializers.Serializer):
field_2 = serializers.ChoiceField(choices=('choice_1', 'choice_2'))
class TemporaryListSerializer(serializers.ListSerializer):
def __init__(self, *args, **kwargs):
kwargs['child'] = TemporarySerializer()
super().__init__(*args, **kwargs)
class TemporarySerializerWithList(serializers.Serializer):
field_3 = serializers.IntegerField()
field_4 = serializers.ListField(child=serializers.IntegerField())
@ -152,6 +158,18 @@ def test_validate_data():
'invalid'
)
with pytest.raises(APIException) as api_exception_3:
validate_data(
TemporaryListSerializer,
[{'something': 'nothing'}]
)
assert api_exception_3.value.status_code == status.HTTP_400_BAD_REQUEST
assert api_exception_3.value.detail['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
assert len(api_exception_3.value.detail['detail']) == 1
assert api_exception_3.value.detail['detail'][0]['field_1'][0]['code'] == 'required'
assert api_exception_3.value.detail['detail'][0]['field_2'][0]['code'] == 'required'
def test_validate_data_custom_fields():
registry = TemporaryTypeRegistry()

View file

@ -0,0 +1,217 @@
import pytest
import responses
from unittest.mock import patch
from freezegun import freeze_time
from PIL import Image
from django.shortcuts import reverse
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.files.storage import FileSystemStorage
from rest_framework.status import (
HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_413_REQUEST_ENTITY_TOO_LARGE
)
from baserow.core.models import UserFile
@pytest.mark.django_db
def test_upload_file(api_client, data_fixture, tmpdir):
user, token = data_fixture.create_user_and_token(
email='test@test.nl', password='password', first_name='Test1')
response = api_client.post(
reverse('api:user_files:upload_file'),
format='multipart',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_INVALID_FILE'
response = api_client.post(
reverse('api:user_files:upload_file'),
data={'file': ''},
format='multipart',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_INVALID_FILE'
old_limit = settings.USER_FILE_SIZE_LIMIT
settings.USER_FILE_SIZE_LIMIT = 6
response = api_client.post(
reverse('api:user_files:upload_file'),
data={'file': SimpleUploadedFile('test.txt', b'Hello World')},
format='multipart',
HTTP_AUTHORIZATION=f'JWT {token}'
)
settings.USER_FILE_SIZE_LIMIT = old_limit
assert response.status_code == HTTP_413_REQUEST_ENTITY_TOO_LARGE
assert response.json()['error'] == 'ERROR_FILE_SIZE_TOO_LARGE'
assert response.json()['detail'] == (
'The provided file is too large. Max 0MB is allowed.'
)
response = api_client.post(
reverse('api:user_files:upload_file'),
data={'file': 'not a file'},
format='multipart',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_INVALID_FILE'
storage = FileSystemStorage(location=str(tmpdir), base_url='http://localhost')
with patch('baserow.core.user_files.handler.default_storage', new=storage):
with freeze_time('2020-01-01 12:00'):
file = SimpleUploadedFile('test.txt', b'Hello World')
response = api_client.post(
reverse('api:user_files:upload_file'),
data={'file': file},
format='multipart',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['size'] == 11
assert response_json['mime_type'] == 'text/plain'
assert response_json['is_image'] is False
assert response_json['image_width'] is None
assert response_json['image_height'] is None
assert response_json['uploaded_at'] == '2020-01-01T12:00:00Z'
assert response_json['thumbnails'] is None
assert response_json['original_name'] == 'test.txt'
assert 'localhost:8000' in response_json['url']
user_file = UserFile.objects.all().last()
assert user_file.name == response_json['name']
assert response_json['url'].endswith(response_json['name'])
file_path = tmpdir.join('user_files', user_file.name)
assert file_path.isfile()
with patch('baserow.core.user_files.handler.default_storage', new=storage):
file = SimpleUploadedFile('test.txt', b'Hello World')
response_2 = api_client.post(
reverse('api:user_files:upload_file'),
data={'file': file},
format='multipart',
HTTP_AUTHORIZATION=f'JWT {token}'
)
# The old file should be provided.
assert response_2.json()['name'] == response_json['name']
assert response_json['original_name'] == 'test.txt'
image = Image.new('RGB', (100, 140), color='red')
file = SimpleUploadedFile('test.png', b'')
image.save(file, format='PNG')
file.seek(0)
with patch('baserow.core.user_files.handler.default_storage', new=storage):
response = api_client.post(
reverse('api:user_files:upload_file'),
data={'file': file},
format='multipart',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['mime_type'] == 'image/png'
assert response_json['is_image'] is True
assert response_json['image_width'] == 100
assert response_json['image_height'] == 140
assert len(response_json['thumbnails']) == 1
assert 'localhost:8000' in response_json['thumbnails']['tiny']['url']
assert 'tiny' in response_json['thumbnails']['tiny']['url']
assert response_json['thumbnails']['tiny']['width'] == 21
assert response_json['thumbnails']['tiny']['height'] == 21
assert response_json['original_name'] == 'test.png'
user_file = UserFile.objects.all().last()
file_path = tmpdir.join('user_files', user_file.name)
assert file_path.isfile()
file_path = tmpdir.join('thumbnails', 'tiny', user_file.name)
assert file_path.isfile()
thumbnail = Image.open(file_path.open('rb'))
assert thumbnail.height == 21
assert thumbnail.width == 21
@pytest.mark.django_db
@responses.activate
def test_upload_file_via_url(api_client, data_fixture, tmpdir):
user, token = data_fixture.create_user_and_token(
email='test@test.nl', password='password', first_name='Test1'
)
response = api_client.post(
reverse('api:user_files:upload_via_url'),
data={},
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
response = api_client.post(
reverse('api:user_files:upload_via_url'),
data={'url': 'NOT_A_URL'},
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
response = api_client.post(
reverse('api:user_files:upload_via_url'),
data={'url': 'http://localhost/test2.txt'},
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()['error'] == 'ERROR_FILE_URL_COULD_NOT_BE_REACHED'
responses.add(
responses.GET,
'http://localhost/test.txt',
body=b'Hello World',
status=200,
content_type="text/plain",
stream=True,
)
old_limit = settings.USER_FILE_SIZE_LIMIT
settings.USER_FILE_SIZE_LIMIT = 6
response = api_client.post(
reverse('api:user_files:upload_via_url'),
data={'url': 'http://localhost/test.txt'},
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_413_REQUEST_ENTITY_TOO_LARGE
assert response.json()['error'] == 'ERROR_FILE_SIZE_TOO_LARGE'
settings.USER_FILE_SIZE_LIMIT = old_limit
storage = FileSystemStorage(location=str(tmpdir), base_url='http://localhost')
with patch('baserow.core.user_files.handler.default_storage', new=storage):
response = api_client.post(
reverse('api:user_files:upload_via_url'),
data={'url': 'http://localhost/test.txt'},
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['size'] == 11
assert response_json['mime_type'] == 'text/plain'
assert response_json['is_image'] is False
assert response_json['image_width'] is None
assert response_json['image_height'] is None
assert response_json['thumbnails'] is None
assert response_json['original_name'] == 'test.txt'
assert 'localhost:8000' in response_json['url']
user_file = UserFile.objects.all().last()
file_path = tmpdir.join('user_files', user_file.name)
assert file_path.isfile()

View file

@ -2,13 +2,14 @@ import pytest
from faker import Faker
from pytz import timezone
from datetime import date, datetime
from freezegun import freeze_time
from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST
from django.shortcuts import reverse
from baserow.contrib.database.fields.models import (
LongTextField, URLField, DateField, EmailField
LongTextField, URLField, DateField, EmailField, FileField
)
@ -382,3 +383,263 @@ def test_email_field_type(api_client, data_fixture):
response = api_client.delete(email, HTTP_AUTHORIZATION=f'JWT {token}')
assert response.status_code == HTTP_204_NO_CONTENT
assert EmailField.objects.all().count() == 0
@pytest.mark.django_db
def test_file_field_type(api_client, data_fixture):
user, token = data_fixture.create_user_and_token(
email='test@test.nl', password='password', first_name='Test1'
)
table = data_fixture.create_database_table(user=user)
grid = data_fixture.create_grid_view(table=table)
with freeze_time('2020-01-01 12:00'):
user_file_1 = data_fixture.create_user_file(
original_name='test.txt',
original_extension='txt',
unique='sdafi6WtHfnDrU6S1lQKh9PdC7PeafCA',
size=10,
mime_type='text/plain',
is_image=True,
image_width=1920,
image_height=1080,
sha256_hash=(
'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'
),
)
user_file_2 = data_fixture.create_user_file()
user_file_3 = data_fixture.create_user_file()
response = api_client.post(
reverse('api:database:fields:list', kwargs={'table_id': table.id}),
{'name': 'File', 'type': 'file'},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['type'] == 'file'
assert FileField.objects.all().count() == 1
field_id = response_json['id']
response = api_client.patch(
reverse('api:database:fields:item', kwargs={'field_id': field_id}),
{'name': 'File2'},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_200_OK
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json[f'field_{field_id}'] == []
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{
f'field_{field_id}': []
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json[f'field_{field_id}'] == []
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{
f'field_{field_id}': [{'without_name': 'test'}]
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{
f'field_{field_id}': [{'name': 'an__invalid__name.jpg'}]
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
assert (
response_json['detail'][f'field_{field_id}'][0]['name'][0]['code'] == 'invalid'
)
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{
f'field_{field_id}': [{'name': 'not_existing.jpg'}]
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json['error'] == 'ERROR_USER_FILE_DOES_NOT_EXIST'
assert response_json['detail'] == 'The user file not_existing.jpg does not exist.'
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{
f'field_{field_id}': [
{
'name': user_file_1.name,
'is_image': True
}
]
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert (
response_json[f'field_{field_id}'][0]['visible_name'] ==
user_file_1.original_name
)
assert response_json[f'field_{field_id}'][0]['name'] == (
'sdafi6WtHfnDrU6S1lQKh9PdC7PeafCA_'
'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e.txt'
)
assert response_json[f'field_{field_id}'][0]['size'] == 10
assert response_json[f'field_{field_id}'][0]['mime_type'] == 'text/plain'
assert response_json[f'field_{field_id}'][0]['is_image'] is True
assert response_json[f'field_{field_id}'][0]['image_width'] == 1920
assert response_json[f'field_{field_id}'][0]['image_height'] == 1080
assert response_json[f'field_{field_id}'][0]['uploaded_at'] == (
'2020-01-01T12:00:00+00:00'
)
assert 'localhost:8000' in response_json[f'field_{field_id}'][0]['url']
assert len(response_json[f'field_{field_id}'][0]['thumbnails']) == 1
assert (
'localhost:8000' in
response_json[f'field_{field_id}'][0]['thumbnails']['tiny']['url']
)
assert (
'sdafi6WtHfnDrU6S1lQKh9PdC7PeafCA_'
'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e.txt' in
response_json[f'field_{field_id}'][0]['thumbnails']['tiny']['url']
)
assert (
'tiny' in response_json[f'field_{field_id}'][0]['thumbnails']['tiny']['url']
)
assert response_json[f'field_{field_id}'][0]['thumbnails']['tiny']['width'] == 21
assert response_json[f'field_{field_id}'][0]['thumbnails']['tiny']['height'] == 21
assert 'original_name' not in response_json
assert 'original_extension' not in response_json
assert 'sha256_hash' not in response_json
response = api_client.patch(
reverse('api:database:rows:item', kwargs={
'table_id': table.id,
'row_id': response_json['id']
}),
{
f'field_{field_id}': [
{'name': user_file_3.name},
{'name': user_file_2.name, 'visible_name': 'new_name_1.txt'}
]
},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert response_json[f'field_{field_id}'][0]['name'] == user_file_3.name
assert (
response_json[f'field_{field_id}'][0]['visible_name'] ==
user_file_3.original_name
)
assert 'localhost:8000' in response_json[f'field_{field_id}'][0]['url']
assert response_json[f'field_{field_id}'][0]['is_image'] is False
assert response_json[f'field_{field_id}'][0]['image_width'] is None
assert response_json[f'field_{field_id}'][0]['image_height'] is None
assert response_json[f'field_{field_id}'][0]['thumbnails'] is None
assert response_json[f'field_{field_id}'][1]['name'] == user_file_2.name
assert response_json[f'field_{field_id}'][1]['visible_name'] == 'new_name_1.txt'
response = api_client.patch(
reverse('api:database:rows:item', kwargs={
'table_id': table.id,
'row_id': response_json['id']
}),
{},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_200_OK
response = api_client.get(
reverse('api:database:rows:item', kwargs={
'table_id': table.id,
'row_id': response_json['id']
}),
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert response_json[f'field_{field_id}'][0]['name'] == user_file_3.name
assert (
response_json[f'field_{field_id}'][0]['visible_name'] ==
user_file_3.original_name
)
assert 'localhost:8000' in response_json[f'field_{field_id}'][0]['url']
assert response_json[f'field_{field_id}'][1]['name'] == user_file_2.name
assert response_json[f'field_{field_id}'][1]['visible_name'] == 'new_name_1.txt'
response = api_client.get(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert len(response_json['results']) == 3
assert response_json['results'][0][f'field_{field_id}'] == []
assert response_json['results'][1][f'field_{field_id}'] == []
assert (
response_json['results'][2][f'field_{field_id}'][0]['name'] == user_file_3.name
)
assert (
'localhost:8000' in response_json['results'][2][f'field_{field_id}'][0]['url']
)
assert (
response_json['results'][2][f'field_{field_id}'][1]['name'] == user_file_2.name
)
# We also need to check if the grid view returns the correct url because the
# request context must be provided there in order to work.
url = reverse('api:database:views:grid:list', kwargs={'view_id': grid.id})
response = api_client.get(
url,
**{'HTTP_AUTHORIZATION': f'JWT {token}'}
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert len(response_json['results']) == 3
assert response_json['results'][0][f'field_{field_id}'] == []
assert response_json['results'][1][f'field_{field_id}'] == []
assert (
response_json['results'][2][f'field_{field_id}'][0]['name'] == user_file_3.name
)
assert (
'localhost:8000' in response_json['results'][2][f'field_{field_id}'][0]['url']
)
assert (
response_json['results'][2][f'field_{field_id}'][1]['name'] == user_file_2.name
)

View file

@ -1,4 +1,5 @@
import pytest
import json
from pytz import timezone
from datetime import date
from faker import Faker
@ -7,9 +8,12 @@ from decimal import Decimal
from django.core.exceptions import ValidationError
from django.utils.timezone import make_aware, datetime
from baserow.core.user_files.exceptions import (
InvalidUserFileNameError, UserFileDoesNotExist
)
from baserow.contrib.database.fields.field_types import DateFieldType
from baserow.contrib.database.fields.models import (
LongTextField, URLField, DateField, EmailField
LongTextField, URLField, DateField, EmailField, FileField
)
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.rows.handler import RowHandler
@ -481,3 +485,137 @@ def test_email_field_type(data_fixture):
field_handler.delete_field(user=user, field=field_2)
assert len(EmailField.objects.all()) == 2
@pytest.mark.django_db
def test_file_field_type(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
user_file_1 = data_fixture.create_user_file()
user_file_2 = data_fixture.create_user_file()
user_file_3 = data_fixture.create_user_file()
field_handler = FieldHandler()
row_handler = RowHandler()
file = field_handler.create_field(user=user, table=table, type_name='file',
name='File')
assert FileField.objects.all().count() == 1
model = table.get_model(attribute_names=True)
with pytest.raises(ValidationError):
row_handler.create_row(user=user, table=table, values={
'file': 'not_a_json'
}, model=model)
with pytest.raises(ValidationError):
row_handler.create_row(user=user, table=table, values={
'file': {}
}, model=model)
with pytest.raises(ValidationError):
row_handler.create_row(user=user, table=table, values={
'file': [{'no_name': 'test'}]
}, model=model)
with pytest.raises(InvalidUserFileNameError):
row_handler.create_row(user=user, table=table, values={
'file': [{'name': 'wrongfilename.jpg'}]
}, model=model)
with pytest.raises(UserFileDoesNotExist):
row_handler.create_row(user=user, table=table, values={
'file': [{'name': 'file_name.jpg'}]
}, model=model)
row = row_handler.create_row(user=user, table=table, values={
'file': [{'name': user_file_1.name}]
}, model=model)
assert row.file[0]['visible_name'] == user_file_1.original_name
del row.file[0]['visible_name']
assert row.file[0] == user_file_1.serialize()
row = row_handler.create_row(user=user, table=table, values={
'file': [
{'name': user_file_2.name},
{'name': user_file_1.name},
{'name': user_file_1.name}
]
}, model=model)
assert row.file[0]['visible_name'] == user_file_2.original_name
assert row.file[1]['visible_name'] == user_file_1.original_name
assert row.file[2]['visible_name'] == user_file_1.original_name
del row.file[0]['visible_name']
del row.file[1]['visible_name']
del row.file[2]['visible_name']
assert row.file[0] == user_file_2.serialize()
assert row.file[1] == user_file_1.serialize()
assert row.file[2] == user_file_1.serialize()
row = row_handler.create_row(user=user, table=table, values={
'file': [
{'name': user_file_1.name},
{'name': user_file_3.name},
{'name': user_file_2.name}
]
}, model=model)
assert row.file[0]['visible_name'] == user_file_1.original_name
assert row.file[1]['visible_name'] == user_file_3.original_name
assert row.file[2]['visible_name'] == user_file_2.original_name
del row.file[0]['visible_name']
del row.file[1]['visible_name']
del row.file[2]['visible_name']
assert row.file[0] == user_file_1.serialize()
assert row.file[1] == user_file_3.serialize()
assert row.file[2] == user_file_2.serialize()
row = row_handler.update_row(user=user, table=table, row_id=row.id, values={
'file': [
{'name': user_file_1.name, 'visible_name': 'not_original.jpg'},
]
}, model=model)
assert row.file[0]['visible_name'] == 'not_original.jpg'
del row.file[0]['visible_name']
assert row.file[0] == user_file_1.serialize()
assert model.objects.all().count() == 3
field_handler.delete_field(user=user, field=file)
assert FileField.objects.all().count() == 0
model.objects.all().delete()
text = field_handler.create_field(user=user, table=table, type_name='text',
name='Text')
model = table.get_model(attribute_names=True)
row = row_handler.create_row(user=user, table=table, values={
'text': 'Some random text'
}, model=model)
row_handler.create_row(user=user, table=table, values={
'text': '["Not compatible"]'
}, model=model)
row_handler.create_row(user=user, table=table, values={
'text': json.dumps(user_file_1.serialize())
}, model=model)
file = field_handler.update_field(user=user, table=table, field=text,
new_type_name='file', name='File')
model = table.get_model(attribute_names=True)
results = model.objects.all()
assert results[0].file == []
assert results[1].file == []
assert results[2].file == []
row_handler.update_row(user=user, table=table, row_id=row.id, values={
'file': [
{'name': user_file_1.name, 'visible_name': 'not_original.jpg'},
]
}, model=model)
field_handler.update_field(user=user, table=table, field=file,
new_type_name='text', name='text')
model = table.get_model(attribute_names=True)
results = model.objects.all()
assert results[0].text is None
assert results[1].text is None
assert results[2].text is None

View file

@ -1016,6 +1016,7 @@ def test_empty_filter_type(data_fixture):
date_include_time=True
)
boolean_field = data_fixture.create_boolean_field(table=table)
file_field = data_fixture.create_file_field(table=table)
tmp_table = data_fixture.create_database_table(database=table.database)
tmp_field = data_fixture.create_text_field(table=tmp_table, primary=True)
@ -1038,6 +1039,7 @@ def test_empty_filter_type(data_fixture):
f'field_{date_field.id}': None,
f'field_{date_time_field.id}': None,
f'field_{boolean_field.id}': False,
f'field_{file_field.id}': []
})
row_2 = model.objects.create(**{
f'field_{text_field.id}': 'Value',
@ -1047,6 +1049,7 @@ def test_empty_filter_type(data_fixture):
f'field_{date_field.id}': date(2020, 6, 17),
f'field_{date_time_field.id}': make_aware(datetime(2020, 6, 17, 1, 30, 0), utc),
f'field_{boolean_field.id}': True,
f'field_{file_field.id}': [{'name': 'test_file.png'}]
})
getattr(row_2, f'field_{link_row_field.id}').add(tmp_row.id)
row_3 = model.objects.create(**{
@ -1057,6 +1060,9 @@ def test_empty_filter_type(data_fixture):
f'field_{date_field.id}': date(1970, 1, 1),
f'field_{date_time_field.id}': make_aware(datetime(1970, 1, 1, 0, 0, 0), utc),
f'field_{boolean_field.id}': True,
f'field_{file_field.id}': [
{'name': 'test_file.png'}, {'name': 'another_file.jpg'}
]
})
getattr(row_3, f'field_{link_row_field.id}').add(tmp_row.id)
@ -1096,6 +1102,10 @@ def test_empty_filter_type(data_fixture):
filter.save()
assert handler.apply_filters(grid_view, model.objects.all()).get().id == row.id
filter.field = file_field
filter.save()
assert handler.apply_filters(grid_view, model.objects.all()).get().id == row.id
@pytest.mark.django_db
def test_not_empty_filter_type(data_fixture):
@ -1116,6 +1126,7 @@ def test_not_empty_filter_type(data_fixture):
date_include_time=True
)
boolean_field = data_fixture.create_boolean_field(table=table)
file_field = data_fixture.create_file_field(table=table)
tmp_table = data_fixture.create_database_table(database=table.database)
tmp_field = data_fixture.create_text_field(table=tmp_table, primary=True)
@ -1138,6 +1149,7 @@ def test_not_empty_filter_type(data_fixture):
f'field_{date_field.id}': None,
f'field_{date_time_field.id}': None,
f'field_{boolean_field.id}': False,
f'field_{file_field.id}': []
})
row_2 = model.objects.create(**{
f'field_{text_field.id}': 'Value',
@ -1147,6 +1159,7 @@ def test_not_empty_filter_type(data_fixture):
f'field_{date_field.id}': date(2020, 6, 17),
f'field_{date_time_field.id}': make_aware(datetime(2020, 6, 17, 1, 30, 0), utc),
f'field_{boolean_field.id}': True,
f'field_{file_field.id}': [{'name': 'test_file.png'}]
})
getattr(row_2, f'field_{link_row_field.id}').add(tmp_row.id)
@ -1185,3 +1198,7 @@ def test_not_empty_filter_type(data_fixture):
filter.field = boolean_field
filter.save()
assert handler.apply_filters(grid_view, model.objects.all()).get().id == row_2.id
filter.field = file_field
filter.save()
assert handler.apply_filters(grid_view, model.objects.all()).get().id == row_2.id

View file

@ -1,6 +1,9 @@
from io import BytesIO
from baserow.core.utils import (
extract_allowed, set_allowed_attrs, to_pascal_case, to_snake_case,
remove_special_characters, dict_to_object, random_string
remove_special_characters, dict_to_object, random_string, sha256_hash,
stream_size
)
@ -61,3 +64,16 @@ def test_dict_to_object():
def test_random_string():
assert len(random_string(32)) == 32
assert random_string(32) != random_string(32)
def test_sha256_hash():
assert sha256_hash(BytesIO(b'test')) == (
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'
)
assert sha256_hash(BytesIO(b'Hello World')) == (
'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'
)
def test_stream_size():
assert stream_size(BytesIO(b'test')) == 4

View file

@ -0,0 +1,306 @@
import pytest
import responses
import string
from freezegun import freeze_time
from PIL import Image
from io import BytesIO
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.storage import FileSystemStorage
from baserow.core.models import UserFile
from baserow.core.user_files.exceptions import (
InvalidFileStreamError, FileSizeTooLargeError, FileURLCouldNotBeReached,
MaximumUniqueTriesError
)
from baserow.core.user_files.handler import UserFileHandler
@pytest.mark.django_db
def test_user_file_path(data_fixture):
handler = UserFileHandler()
assert handler.user_file_path('test.jpg') == 'user_files/test.jpg'
assert handler.user_file_path('another_file.png') == 'user_files/another_file.png'
user_file = data_fixture.create_user_file()
assert handler.user_file_path(user_file) == f'user_files/{user_file.name}'
@pytest.mark.django_db
def test_user_file_thumbnail_path(data_fixture):
handler = UserFileHandler()
assert handler.user_file_thumbnail_path(
'test.jpg',
'tiny'
) == 'thumbnails/tiny/test.jpg'
assert handler.user_file_thumbnail_path(
'another_file.png',
'small'
) == 'thumbnails/small/another_file.png'
user_file = data_fixture.create_user_file()
assert handler.user_file_thumbnail_path(
user_file,
'tiny'
) == f'thumbnails/tiny/{user_file.name}'
@pytest.mark.django_db
def test_generate_unique(data_fixture):
user = data_fixture.create_user()
handler = UserFileHandler()
assert len(handler.generate_unique('test', 'txt', 32)) == 32
assert len(handler.generate_unique('test', 'txt', 10)) == 10
assert (
handler.generate_unique('test', 'txt', 32) !=
handler.generate_unique('test', 'txt', 32)
)
unique = handler.generate_unique('test', 'txt', 32)
assert not UserFile.objects.filter(unique=unique).exists()
for char in string.ascii_letters + string.digits:
data_fixture.create_user_file(uploaded_by=user, unique=char,
original_extension='txt', sha256_hash='test')
with pytest.raises(MaximumUniqueTriesError):
handler.generate_unique('test', 'txt', 1, 3)
handler.generate_unique('test2', 'txt', 1, 3)
handler.generate_unique('test', 'txt2', 1, 3)
@pytest.mark.django_db
def test_upload_user_file(data_fixture, tmpdir):
user = data_fixture.create_user()
storage = FileSystemStorage(location=str(tmpdir), base_url='http://localhost')
handler = UserFileHandler()
with pytest.raises(InvalidFileStreamError):
handler.upload_user_file(
user,
'test.txt',
'NOT A STREAM!',
storage=storage
)
with pytest.raises(InvalidFileStreamError):
handler.upload_user_file(
user,
'test.txt',
None,
storage=storage
)
old_limit = settings.USER_FILE_SIZE_LIMIT
settings.USER_FILE_SIZE_LIMIT = 6
with pytest.raises(FileSizeTooLargeError):
handler.upload_user_file(
user,
'test.txt',
ContentFile(b'Hello World')
)
settings.USER_FILE_SIZE_LIMIT = old_limit
with freeze_time('2020-01-01 12:00'):
user_file = handler.upload_user_file(
user,
'test.txt',
ContentFile(b'Hello World'),
storage=storage
)
assert user_file.original_name == 'test.txt'
assert user_file.original_extension == 'txt'
assert len(user_file.unique) == 32
assert user_file.size == 11
assert user_file.mime_type == 'text/plain'
assert user_file.uploaded_by_id == user.id
assert user_file.uploaded_at.year == 2020
assert user_file.uploaded_at.month == 1
assert user_file.uploaded_at.day == 1
assert user_file.is_image is False
assert user_file.image_width is None
assert user_file.image_height is None
assert user_file.sha256_hash == (
'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'
)
file_path = tmpdir.join('user_files', user_file.name)
assert file_path.isfile()
assert file_path.open().read() == 'Hello World'
user_file = handler.upload_user_file(
user,
'another.txt',
BytesIO(b'Hello'),
storage=storage
)
assert user_file.original_name == 'another.txt'
assert user_file.original_extension == 'txt'
assert user_file.mime_type == 'text/plain'
assert user_file.size == 5
assert user_file.sha256_hash == (
'185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969'
)
file_path = tmpdir.join('user_files', user_file.name)
assert file_path.isfile()
assert file_path.open().read() == 'Hello'
assert (
handler.upload_user_file(
user,
'another.txt',
ContentFile(b'Hello'),
storage=storage
).id == user_file.id
)
assert handler.upload_user_file(
user,
'another_name.txt',
ContentFile(b'Hello'),
storage=storage
).id != user_file.id
image = Image.new('RGB', (100, 140), color='red')
image_bytes = BytesIO()
image.save(image_bytes, format='PNG')
user_file = handler.upload_user_file(
user,
'some image.png',
image_bytes,
storage=storage
)
assert user_file.mime_type == 'image/png'
assert user_file.is_image is True
assert user_file.image_width == 100
assert user_file.image_height == 140
file_path = tmpdir.join('user_files', user_file.name)
assert file_path.isfile()
file_path = tmpdir.join('thumbnails', 'tiny', user_file.name)
assert file_path.isfile()
thumbnail = Image.open(file_path.open('rb'))
assert thumbnail.height == 21
assert thumbnail.width == 21
old_thumbnail_settings = settings.USER_THUMBNAILS
settings.USER_THUMBNAILS = {'tiny': [None, 100]}
image = Image.new('RGB', (1920, 1080), color='red')
image_bytes = BytesIO()
image.save(image_bytes, format='PNG')
user_file = handler.upload_user_file(
user,
'red.png',
image_bytes,
storage=storage
)
file_path = tmpdir.join('thumbnails', 'tiny', user_file.name)
assert file_path.isfile()
thumbnail = Image.open(file_path.open('rb'))
assert thumbnail.width == 178
assert thumbnail.height == 100
image = Image.new('RGB', (400, 400), color='red')
image_bytes = BytesIO()
image.save(image_bytes, format='PNG')
user_file = handler.upload_user_file(
user,
'red2.png',
image_bytes,
storage=storage
)
file_path = tmpdir.join('thumbnails', 'tiny', user_file.name)
assert file_path.isfile()
thumbnail = Image.open(file_path.open('rb'))
assert thumbnail.width == 100
assert thumbnail.height == 100
settings.USER_THUMBNAILS = {'tiny': [21, None]}
image = Image.new('RGB', (1400, 1000), color='red')
image_bytes = BytesIO()
image.save(image_bytes, format='PNG')
user_file = handler.upload_user_file(
user,
'red3.png',
image_bytes,
storage=storage
)
file_path = tmpdir.join('thumbnails', 'tiny', user_file.name)
assert file_path.isfile()
thumbnail = Image.open(file_path.open('rb'))
assert thumbnail.width == 21
assert thumbnail.height == 15
settings.USER_THUMBNAILS = old_thumbnail_settings
assert UserFile.objects.all().count() == 7
@pytest.mark.django_db
@responses.activate
def test_upload_user_file_by_url(data_fixture, tmpdir):
user = data_fixture.create_user()
storage = FileSystemStorage(location=str(tmpdir), base_url='http://localhost')
handler = UserFileHandler()
responses.add(
responses.GET,
'http://localhost/test.txt',
body=b'Hello World',
status=200,
content_type="text/plain",
stream=True,
)
responses.add(
responses.GET,
'http://localhost/not-found.pdf',
body=b'Hello World',
status=404,
content_type="application/pdf",
stream=True,
)
with pytest.raises(FileURLCouldNotBeReached):
handler.upload_user_file_by_url(
user,
'http://localhost/test2.txt',
storage=storage
)
with freeze_time('2020-01-01 12:00'):
user_file = handler.upload_user_file_by_url(
user,
'http://localhost/test.txt',
storage=storage
)
with pytest.raises(FileURLCouldNotBeReached):
handler.upload_user_file_by_url(
user,
'http://localhost/not-found.pdf',
storage=storage
)
assert user_file.original_name == 'test.txt'
assert user_file.original_extension == 'txt'
assert len(user_file.unique) == 32
assert user_file.size == 11
assert user_file.mime_type == 'text/plain'
assert user_file.uploaded_by_id == user.id
assert user_file.uploaded_at.year == 2020
assert user_file.uploaded_at.month == 1
assert user_file.uploaded_at.day == 1
assert user_file.is_image is False
assert user_file.image_width is None
assert user_file.image_height is None
assert user_file.sha256_hash == (
'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'
)
file_path = tmpdir.join('user_files', user_file.name)
assert file_path.isfile()
assert file_path.open().read() == 'Hello World'

View file

@ -0,0 +1,32 @@
import pytest
from baserow.core.models import UserFile
from baserow.core.user_files.exceptions import InvalidUserFileNameError
@pytest.mark.django_db
def test_user_file_name(data_fixture):
user_file = data_fixture.create_user_file()
user_file_2 = data_fixture.create_user_file()
user_file_3 = data_fixture.create_user_file()
with pytest.raises(InvalidUserFileNameError):
UserFile.objects.all().name('wrong.jpg')
queryset = UserFile.objects.all().name(user_file.name)
assert len(queryset) == 1
assert queryset[0].id == user_file.id
queryset = UserFile.objects.all().name(user_file_2.name)
assert len(queryset) == 1
assert queryset[0].id == user_file_2.id
queryset = UserFile.objects.all().name(user_file.name, user_file_2.name)
assert len(queryset) == 2
assert queryset[0].id == user_file.id
assert queryset[1].id == user_file_2.id
queryset = UserFile.objects.all().name(user_file_3.name, user_file.name)
assert len(queryset) == 2
assert queryset[0].id == user_file.id
assert queryset[1].id == user_file_3.id

View file

@ -0,0 +1,79 @@
import pytest
from baserow.core.user_files.exceptions import InvalidUserFileNameError
from baserow.core.models import UserFile
@pytest.mark.django_db
def test_serialize_user_file():
user_file = UserFile.objects.create(
original_name='test.txt',
original_extension='txt',
unique='sdafi6WtHfnDrU6S1lQKh9PdC7PeafCA',
size=10,
mime_type='plain/text',
is_image=True,
image_width=100,
image_height=100,
sha256_hash='a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'
)
assert user_file.serialize() == {
'name': 'sdafi6WtHfnDrU6S1lQKh9PdC7PeafCA_'
'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e.txt',
'size': 10,
'mime_type': 'plain/text',
'is_image': True,
'image_width': 100,
'image_height': 100,
'uploaded_at': user_file.uploaded_at.isoformat()
}
@pytest.mark.django_db
def test_user_file_name():
user_file = UserFile.objects.create(
original_name='test.txt',
original_extension='txt',
unique='sdafi6WtHfnDrU6S1lQKh9PdC7PeafCA',
size=0,
mime_type='plain/text',
is_image=True,
image_width=0,
image_height=0,
sha256_hash='a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'
)
assert user_file.name == (
'sdafi6WtHfnDrU6S1lQKh9PdC7PeafCA_'
'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e.txt'
)
@pytest.mark.django_db
def test_user_file_deconstruct_name():
with pytest.raises(InvalidUserFileNameError):
UserFile.deconstruct_name('something.jpg')
with pytest.raises(InvalidUserFileNameError):
UserFile.deconstruct_name('something__test.jpg')
with pytest.raises(InvalidUserFileNameError):
UserFile.deconstruct_name('something_testjpg')
with pytest.raises(InvalidUserFileNameError):
UserFile.deconstruct_name('nothing_test.-')
assert UserFile.deconstruct_name('random_hash.jpg') == {
'unique': 'random',
'sha256_hash': 'hash',
'original_extension': 'jpg'
}
assert UserFile.deconstruct_name(
'sdafi6WtHfnDrU6S1lQKh9PdC7PeafCA_'
'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e.txt'
) == {
'unique': 'sdafi6WtHfnDrU6S1lQKh9PdC7PeafCA',
'sha256_hash': (
'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'
),
'original_extension': 'txt'
}

View file

@ -1,6 +1,7 @@
from faker import Faker
from .user import UserFixtures
from .user_file import UserFileFixtures
from .group import GroupFixtures
from .application import ApplicationFixtures
from .table import TableFixtures
@ -9,6 +10,6 @@ from .field import FieldFixtures
from .token import TokenFixtures
class Fixtures(UserFixtures, GroupFixtures, ApplicationFixtures, TableFixtures,
ViewFixtures, FieldFixtures, TokenFixtures):
class Fixtures(UserFixtures, UserFileFixtures, GroupFixtures, ApplicationFixtures,
TableFixtures, ViewFixtures, FieldFixtures, TokenFixtures):
fake = Faker()

View file

@ -1,7 +1,8 @@
from django.db import connection
from baserow.contrib.database.fields.models import (
TextField, LongTextField, NumberField, BooleanField, DateField, LinkRowField
TextField, LongTextField, NumberField, BooleanField, DateField, LinkRowField,
FileField
)
@ -116,3 +117,20 @@ class FieldFixtures:
self.create_model_field(kwargs['table'], field)
return field
def create_file_field(self, user=None, create_field=True, **kwargs):
if 'table' not in kwargs:
kwargs['table'] = self.create_database_table(user=user)
if 'name' not in kwargs:
kwargs['name'] = self.fake.name()
if 'order' not in kwargs:
kwargs['order'] = 0
field = FileField.objects.create(**kwargs)
if create_field:
self.create_model_field(kwargs['table'], field)
return field

35
backend/tests/fixtures/user_file.py vendored Normal file
View file

@ -0,0 +1,35 @@
import pathlib
import mimetypes
from baserow.core.models import UserFile
from baserow.core.utils import random_string
class UserFileFixtures:
def create_user_file(self, **kwargs):
if 'original_name' not in kwargs:
kwargs['original_name'] = self.fake.file_name()
if 'original_extension' not in kwargs:
kwargs['original_extension'] = pathlib.Path(
kwargs['original_name']
).suffix[1:].lower()
if 'unique' not in kwargs:
kwargs['unique'] = random_string(32)
if 'size' not in kwargs:
kwargs['size'] = 100
if 'mime_type' not in kwargs:
kwargs['mime_type'] = (
mimetypes.guess_type(kwargs['original_name'])[0] or ''
)
if 'uploaded_by' not in kwargs:
kwargs['uploaded_by'] = self.create_user()
if 'sha256_hash' not in kwargs:
kwargs['sha256_hash'] = random_string(64)
return UserFile.objects.create(**kwargs)

View file

@ -10,6 +10,7 @@
* Removed the redundant _DOMAIN variables.
* Set un-secure lax cookie when public web frontend url isn't over a secure connection.
* Fixed bug where the sort choose field item didn't have a hover effect.
* Implemented a file field and user files upload.
## Released (2020-11-02)

View file

@ -0,0 +1,17 @@
server {
listen 80;
server_name "*YOUR_DOMAIN*";
autoindex off;
gzip on;
gzip_disable "msie6";
location / {
root /baserow/media;
}
location /user_files {
root /baserow/media;
add_header Content-disposition "attachment; filename=$1";
}
}

View file

@ -9,7 +9,9 @@ environment =
SECRET_KEY="SOMETHING_SECRET",
PRIVATE_BACKEND_URL='http://localhost:8000',
PUBLIC_WEB_FRONTEND_URL='https://FRONTEND_DOMAIN',
PUBLIC_BACKEND_URL='https://BACKEND_DOMAIN'
PUBLIC_BACKEND_URL='https://BACKEND_DOMAIN',
MEDIA_ROOT='/baserow/media',
MEDIA_URL='https://MEDIA_DOMAIN'
command = /baserow/backend/env/bin/gunicorn -w 5 -b 127.0.0.1:8000 baserow.config.wsgi:application --log-level=debug --chdir=/baserow
stdout_logfile=/var/log/baserow/backend.log
stderr_logfile=/var/log/baserow/backend.error

View file

@ -74,10 +74,15 @@ it for when you need it later.
## Install dependencies for & setup Baserow
In order to use the Baserow application, we will need to create a virtual environment
and install some more dependencies like: NodeJS, Yarn, Python 3.
In order to use the Baserow application, we will need to create a media directory for
the uploaded user files, a virtual environment and install some more dependencies
like: NodeJS, Yarn, Python 3.
```bash
# Create uploaded user files and media directory
$ mkdir media
$ chmod 0755 media
# Install python3, pip & virtualenv
$ apt install python3 python3-pip virtualenv libpq-dev libmysqlclient-dev -y
@ -137,7 +142,8 @@ the `server_name` value in both of the files. The server name is the domain unde
which you want Baserow to be reachable.
Make sure that in the following commands you replace `api.domain.com` with your own
backend domain and that you replace `baserow.domain.com` with your frontend domain.
backend domain, that you replace `baserow.domain.com` with your frontend domain and
replace `media.baserow.com` with your domain to serve the user files.
```bash
# Move virtualhost files to /etc/nginx/sites-enabled/
@ -148,6 +154,7 @@ $ rm /etc/nginx/sites-enabled/default
# Change the server_name values
$ sed -i 's/\*YOUR_DOMAIN\*/api.domain.com/g' /etc/nginx/sites-enabled/baserow-backend.conf
$ sed -i 's/\*YOUR_DOMAIN\*/baserow.domain.com/g' /etc/nginx/sites-enabled/baserow-frontend.conf
$ sed -i 's/\*YOUR_DOMAIN\*/media.domain.com/g' /etc/nginx/sites-enabled/baserow-media.conf
# Then restart nginx so that it processes the configuration files
$ service nginx restart
@ -166,7 +173,7 @@ commands:
$ source backend/env/bin/activate
$ export DJANGO_SETTINGS_MODULE='baserow.config.settings.base'
$ export DATABASE_PASSWORD="yourpassword"
$ export DATABASE_HOST="localhost"
$ export DATABASE_HOST="localhost"
# Create database schema
$ baserow migrate

View file

@ -1,6 +1,9 @@
/**
* This file can be used in combination with intellij idea so the @baserow path
* resolves.
*
* Intellij IDEA: Preferences -> Languages & Frameworks -> JavaScript -> Webpack ->
* webpack configuration file
*/
const path = require('path')

View file

@ -12,11 +12,13 @@
@import 'context';
@import 'select';
@import 'dropdown';
@import 'tooltip';
@import 'fields/boolean';
@import 'fields/number';
@import 'fields/long_text';
@import 'fields/date';
@import 'fields/link_row';
@import 'fields/file';
@import 'views/grid';
@import 'views/grid/text';
@import 'views/grid/long_text';
@ -24,6 +26,7 @@
@import 'views/grid/number';
@import 'views/grid/date';
@import 'views/grid/link_row';
@import 'views/grid/file';
@import 'box_page';
@import 'loading';
@import 'notifications';
@ -32,7 +35,7 @@
@import 'style_guide';
@import 'datepicker';
@import 'time_select';
@import 'settings';
@import 'modal_sidebar';
@import 'select_row_modal';
@import 'filters';
@import 'sortings';
@ -45,3 +48,5 @@
@import 'delete_section';
@import 'copied';
@import 'select_application';
@import 'upload_files';
@import 'file_field_modal';

View file

@ -0,0 +1,106 @@
.field-file__list {
list-style: none;
margin: 0 0 20px 0;
padding: 0;
}
.field-file__item {
display: flex;
&:not(:last-child) {
margin-bottom: 10px;
}
}
.field-file__preview {
flex: 0 0 48px;
margin-right: 16px;
}
.field-file__icon {
display: block;
border: solid 1px $color-neutral-300;
border-radius: 3px;
color: $color-neutral-600;
overflow: hidden;
@include center-text(48px, 22px);
&:hover {
text-decoration: none;
}
img {
display: block;
max-width: 100%;
max-height: 100%;
}
}
.field-file__description {
min-width: 0;
width: 100%;
padding-top: 6px;
}
.field-file__name {
@extend %ellipsis;
position: relative;
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.field-file__info {
@extend %ellipsis;
font-size: 12px;
color: $color-neutral-600;
}
.field-file__actions {
flex: 0 0;
display: flex;
margin-left: 16px;
text-align: right;
padding-top: 6px;
font-size: 13px;
color: $color-success-500;
}
.field-file__action {
position: relative;
color: $color-neutral-400;
border-radius: 3px;
@include center-text(24px, 13px);
&:not(:last-child) {
margin-right: 8px;
}
&:hover {
color: $color-primary-900;
background-color: $color-neutral-100;
}
}
.field-file__add {
display: inline-block;
padding: 0 5px;
background-color: $color-primary-100;
border-radius: 3px;
color: $color-primary-900;
@include fixed-height(22px, 13px);
&:hover {
background-color: $color-primary-200;
text-decoration: none;
}
}
.field-file__add-icon {
font-size: 12px;
}

View file

@ -0,0 +1,182 @@
.file-field-modal__wrapper {
background-color: rgba($black, 0.78);
}
.file-field-modal {
overflow: hidden;
@include absolute(0, 0, 0, 0);
}
.file-field-modal__head {
@include absolute(0, 0, auto, 0);
}
.file-field-modal__name {
@extend %ellipsis;
padding: 0 60px;
text-align: center;
font-weight: 600;
color: $white;
@include fixed-height($file-field-modal-head-height, 14px);
}
.file-field-modal__close {
border-radius: 3px;
color: $white;
@include center-text(32px, 20px);
@include absolute(17px, 17px, auto, auto);
&:hover {
background-color: $color-neutral-700;
}
}
.file-field-modal__body {
@include absolute($file-field-modal-head-height, 0, $file-field-modal-foot-height, 0);
}
.file-field-modal__body-nav {
width: $file-field-modal-body-nav-width;
display: flex;
align-items: center;
justify-content: center;
color: $color-neutral-500;
font-size: 60px;
position: absolute;
top: 0;
bottom: 0;
&:hover {
color: $white;
text-decoration: none;
}
&.file-field-modal__body-nav--previous {
left: 0;
}
&.file-field-modal__body-nav--next {
right: 0;
}
}
.file-field-modal__preview {
display: flex;
align-items: center;
justify-content: center;
@include absolute(0, $file-field-modal-body-nav-width, 0, $file-field-modal-body-nav-width);
}
.file-field-modal__preview-image-wrapper {
display: contents;
}
.file-field-modal__preview-image-loading {
margin-top: -7px;
margin-left: -7px;
@include loading(14px);
@include absolute(50%, auto, auto, 50%);
}
.file-field-modal__preview-image {
display: block;
max-width: 100%;
max-height: 100%;
&.file-field-modal__preview-image--hidden {
visibility: hidden;
@include absolute(-99999px, -99999px);
}
}
.file-field-modal__preview-icon {
font-size: 40vmin;
color: $white;
}
.file-field-modal__foot {
display: flex;
align-items: center;
justify-content: center;
padding: 0 100px;
height: $file-field-modal-foot-height;
@include absolute(auto, 0, 0, 0);
}
.file-field-modal__nav {
display: flex;
list-style: none;
margin: 0;
padding: 10px 0;
overflow-x: auto;
}
.file-field-modal__nav-item:not(:last-child) {
margin-right: 20px;
}
.file-field-modal__nav-link {
display: block;
border-radius: 3px;
border: solid 3px transparent;
overflow: hidden;
&.active {
border-color: $white;
}
}
.file-field-modal__nav-image {
display: block;
height: 48px;
border-radius: 3px;
.file-field-modal__nav-link.active & {
border-radius: 0;
}
}
.file-field-modal__nav-icon {
background-color: $color-neutral-600;
border-radius: 3px;
color: $white;
@include center-text(48px, 22px);
.file-field-modal__nav-link.active & {
border-radius: 0;
}
}
.file-field-modal__actions {
display: flex;
list-style: none;
margin: 0;
padding: 0;
@include absolute(42px, 25px, auto, auto);
}
.file-field-modal__action {
position: relative;
color: $white;
border-radius: 3px;
@include center-text(32px, 14px);
&:not(:last-child) {
margin-right: 8px;
}
&:hover {
background-color: $color-neutral-700;
}
}

View file

@ -1,10 +1,10 @@
.settings__head {
.modal-sidebar__head {
display: flex;
align-items: center;
padding: 16px;
}
.settings__head-icon {
.modal-sidebar__head-icon {
flex: 0 0 32px;
border-radius: 100%;
margin-right: 16px;
@ -15,17 +15,17 @@
@include center-text(32px, 18px);
}
.settings__head-name {
.modal-sidebar__head-name {
font-weight: 600;
}
.settings__nav {
.modal-sidebar__nav {
list-style: none;
margin: 0;
padding: 0 0 16px;
}
.settings__nav-link {
.modal-sidebar__nav-link {
@extend %ellipsis;
position: relative;
@ -48,7 +48,7 @@
}
}
.settings__nav-icon {
.modal-sidebar__nav-icon {
position: absolute;
left: 19px;
top: 50%;

View file

@ -0,0 +1,47 @@
.tooltip {
position: relative;
padding-top: 8px;
&::after {
content: '';
margin-left: -6px;
width: 0;
height: 0;
border-style: solid;
border-width: 0 6px 8px 6px;
border-color: transparent transparent $color-neutral-800 transparent;
@include absolute(0, auto, auto, 50%);
}
&.tooltip--top {
padding-top: 0;
padding-bottom: 8px;
&::after {
top: auto;
bottom: 0;
border-width: 8px 6px 0 6px;
border-color: $color-neutral-800 transparent transparent transparent;
}
}
&.tooltip--body {
position: absolute;
z-index: $z-index-tooltip;
}
&.tooltip--center {
transform: translateX(-50%);
}
}
.tooltip__content {
background-color: $color-neutral-800;
border-radius: 3px;
color: $white;
padding: 0 8px;
text-align: center;
@include fixed-height(26px, 12px);
}

View file

@ -0,0 +1,147 @@
.upload-files__dropzone {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 108px;
border: dashed 2px $color-neutral-300;
border-radius: 6px;
user-select: none;
color: $color-neutral-500;
margin-bottom: 30px;
cursor: pointer;
&:hover,
&.upload-files__dropzone--dragging {
color: $color-primary-500;
border-color: $color-primary-500;
}
* {
pointer-events: none;
}
}
.upload-files__dropzone-content {
text-align: center;
}
.upload-files__dropzone-icon {
font-size: 21px;
}
.upload-files__dropzone-text {
margin-top: 8px;
font-size: 13px;
font-weight: 600;
}
.upload-files__list {
list-style: none;
margin: 0 0 30px 0;
padding: 0;
}
.upload-files__item {
display: flex;
&:not(:last-child) {
margin-bottom: 10px;
}
}
.upload-files__preview {
flex: 0 0 48px;
margin-right: 16px;
}
.upload-files__icon {
border: solid 1px $color-neutral-300;
border-radius: 3px;
color: $color-neutral-600;
overflow: hidden;
@include center-text(48px, 22px);
img {
display: block;
max-width: 100%;
max-height: 100%;
}
}
.upload-files__description {
min-width: 0;
width: 100%;
padding-top: 6px;
}
.upload-files__name {
@extend %ellipsis;
position: relative;
font-size: 13px;
font-weight: 600;
padding-right: 40px;
margin-bottom: 8px;
}
.upload-files__percentage {
font-size: 13px;
font-weight: 600;
color: $color-neutral-400;
@include absolute(0, 0, auto, auto);
}
.upload-files__progress {
position: relative;
overflow: hidden;
width: 100%;
height: 10px;
border-radius: 3px;
background-color: $color-neutral-100;
}
.upload-files__progress-bar {
background-color: $color-primary-500;
@include absolute(0, auto, 0, 0);
&.upload-files__progress-bar--finished {
background-color: $color-success-500;
}
}
.upload-files__error {
color: $color-error-500;
}
.upload-files__state {
flex: 0 0 32px;
margin-left: 16px;
text-align: right;
padding-top: 6px;
font-size: 13px;
color: $color-success-500;
}
.upload-files__state-waiting {
color: $color-success-500;
}
.upload-files__state-failed {
color: $color-error-500;
}
.upload-files__state-loading {
display: inline-block;
}
.upload-files__state-link {
color: $color-neutral-400;
&:hover {
color: $color-neutral-900;
}
}

View file

@ -0,0 +1,123 @@
.grid-field-file__cell {
&.active {
bottom: auto;
right: auto;
height: auto;
min-width: calc(100% + 4px);
}
}
.grid-field-file__dragging {
display: flex;
align-items: center;
justify-content: center;
border: dashed 2px $color-primary-500;
background-color: $white;
text-align: center;
color: $color-neutral-500;
font-size: 12px;
z-index: 1;
@include absolute(0, 0, 0, 0);
}
.grid-field-file__list {
overflow: hidden;
display: flex;
flex-flow: row;
flex-wrap: nowrap;
list-style: none;
margin: 0;
padding: 0 5px;
.grid-field-file__cell.active & {
height: auto;
flex-wrap: wrap;
}
}
%grid-field-file__border {
border-radius: 3px;
border: solid 1px $color-neutral-300;
.grid-field-file__cell.active & {
border-color: $color-neutral-500;
}
}
.grid-field-file__item {
white-space: nowrap;
margin: 5px 3px;
display: flex;
height: 22px;
}
.grid-field-file__link {
cursor: initial;
// The link is not clickable when the cell is not active.
.grid-field-file__cell.active & {
cursor: pointer;
}
}
.grid-field-file__image {
@extend %grid-field-file__border;
display: block;
width: auto;
height: 22px;
}
.grid-field-file__icon {
@extend %grid-field-file__border;
display: flex;
align-items: center;
justify-content: center;
color: $color-neutral-600;
width: 22px;
height: 22px;
}
.grid-field-file__loading {
@extend %grid-field-file__border;
position: relative;
width: 22px;
height: 22px;
&::before {
content: '';
margin: -6px 0 0 -6px;
@include loading(12px);
@include absolute(50%, auto, auto, 50%);
}
}
.grid-field-file__item-add {
border-radius: 3px;
color: $color-primary-900;
background-color: $color-primary-100;
@include center-text(22px, 11px);
&:hover {
background-color: $color-primary-200;
}
}
.grid-field-file__drop {
display: none;
line-height: 22px;
color: $color-neutral-500;
margin-left: 8px;
font-size: 12px;
pointer-events: none;
}
.grid-field-file__drop-icon {
margin-right: 4px;
font-size: 10px;
}

View file

@ -72,9 +72,13 @@ $z-index-layout-col-3-2: 4 !default;
$z-index-modal: 7 !default;
$z-index-context: 7 !default;
// The tooltip is only visible on hover and because of that it can be over the modals
// and contexts.
$z-index-tooltip: 8 !default;
// The notifications will be on top of anything else, because the message is temporary
// and must always be visible.
$z-index-notifications: 8 !default;
$z-index-notifications: 9 !default;
// normalize overrides
$base-font-family: $text-font-stack !default;
@ -82,10 +86,15 @@ $font-awesome-font-family: 'Font Awesome 5 Free', sans-serif !default;
$font-awesome-font-weight: 900 !default;
// API docs variables
$api-docs-nav-width: 240px;
$api-docs-header-height: 52px;
$api-docs-header-z-index: 4;
$api-docs-databases-z-index: 5;
$api-docs-nav-z-index: 3;
$api-docs-body-z-index: 2;
$api-docs-background-z-index: 1;
$api-docs-nav-width: 240px !default;
$api-docs-header-height: 52px !default;
$api-docs-header-z-index: 4 !default;
$api-docs-databases-z-index: 5 !default;
$api-docs-nav-z-index: 3 !default;
$api-docs-body-z-index: 2 !default;
$api-docs-background-z-index: 1 !default;
// file field modal variables
$file-field-modal-head-height: 62px !default;
$file-field-modal-body-nav-width: 120px !default;
$file-field-modal-foot-height: 108px !default;

View file

@ -25,11 +25,11 @@
</template>
<script>
import MoveToBody from '@baserow/modules/core/mixins/moveToBody'
import baseModal from '@baserow/modules/core/mixins/baseModal'
export default {
name: 'Modal',
mixins: [MoveToBody],
mixins: [baseModal],
props: {
sidebar: {
type: Boolean,
@ -37,73 +37,5 @@ export default {
required: false,
},
},
data() {
return {
open: false,
}
},
destroyed() {
window.removeEventListener('keyup', this.keyup)
},
methods: {
/**
* Toggle the open state of the modal.
*/
toggle(value) {
if (value === undefined) {
value = !this.open
}
if (value) {
this.show()
} else {
this.hide()
}
},
/**
* Show the modal.
*/
show() {
this.open = true
window.addEventListener('keyup', this.keyup)
},
/**
* Hide the modal.
*/
hide(emit = true) {
// This is a temporary fix. What happens is the model is opened by a context menu
// item and the user closes the modal, the element is first deleted and then the
// click outside event of the context is fired. It then checks if the click was
// inside one of his children, but because the modal element doesn't exists
// anymore it thinks it was outside, so is closes the context menu which we don't
// want automatically.
setTimeout(() => {
this.open = false
})
if (emit) {
this.$emit('hidden')
}
window.removeEventListener('keyup', this.keyup)
},
/**
* If someone actually clicked on the modal wrapper and not one of his children the
* modal should be closed.
*/
outside(event) {
if (event.target === this.$refs.modalWrapper) {
this.hide()
}
},
/**
* When the escape key is pressed the modal needs to be hidden.
*/
keyup(event) {
if (event.keyCode === 27) {
this.hide()
}
},
},
}
</script>

View file

@ -0,0 +1,255 @@
<template>
<div>
<h2 class="box__title">Upload from my device</h2>
<input
v-show="false"
ref="file"
type="file"
multiple
@change="addFile($event)"
/>
<div
class="upload-files__dropzone"
:class="{ 'upload-files__dropzone--dragging': dragging }"
@click.prevent="$refs.file.click($event)"
@drop.prevent="addFile($event)"
@dragover.prevent
@dragenter.prevent="dragging = true"
@dragleave.prevent="dragging = false"
>
<div class="upload-files__dropzone-content">
<i class="upload-files__dropzone-icon fas fa-cloud-upload-alt"></i>
<div class="upload-files__dropzone-text">
<template v-if="dragging">
Drop here
</template>
<template v-else>
Click or drop your files here
</template>
</div>
</div>
</div>
<ul v-show="files.length > 0" class="upload-files__list">
<li v-for="file in files" :key="file.id" class="upload-files__item">
<div class="upload-files__preview">
<div class="upload-files__icon">
<i
v-if="!file.isImage"
class="fas"
:class="'fa-' + file.iconClass"
></i>
<img v-if="file.isImage" :ref="'file-image-' + file.id" />
</div>
</div>
<div class="upload-files__description">
<div class="upload-files__name">
{{ file.file.name }}
<div class="upload-files__percentage">{{ file.percentage }}%</div>
</div>
<div v-if="file.state === 'failed'" class="upload-files__error">
{{ file.error }}
</div>
<div v-else class="upload-files__progress">
<div
class="upload-files__progress-bar"
:class="{
'upload-files__progress-bar--finished':
file.state === 'finished',
}"
:style="{ width: file.percentage + '%' }"
></div>
</div>
</div>
<div class="upload-files__state">
<i
v-show="file.state === 'finished'"
class="upload-files__state-waiting fas fa-check"
></i>
<i
v-show="file.state === 'failed'"
class="upload-files__state-failed fas fa-times"
></i>
<div
v-show="file.state === 'uploading'"
class="upload-files__state-loading loading"
></div>
<a
v-show="file.state === 'waiting'"
class="upload-files__state-link"
@click.prevent="removeFile(file.id)"
>
<i class="fas fa-trash"></i>
</a>
</div>
</li>
</ul>
<div v-show="files.length > 0" class="align-right">
<a
class="button button--large"
:class="{ 'button--loading': uploading }"
:disabled="uploading"
@click="upload()"
>
<template v-if="!uploading && hasFailed">Retry</template>
<template v-else>Upload</template>
</a>
</div>
</div>
</template>
<script>
import { uuid } from '@baserow/modules/core/utils/string'
import { mimetype2fa } from '@baserow/modules/core/utils/fontawesome'
import { generateThumbnail } from '@baserow/modules/core/utils/image'
import UserFileService from '@baserow/modules/core/services/userFile'
export default {
name: 'UploadFileUserFileUpload',
data() {
return {
uploading: false,
dragging: false,
files: [],
responses: [],
}
},
computed: {
hasFailed() {
for (let i = 0; i < this.files.length; i++) {
if (this.files[i].state === 'failed') {
return true
}
}
return false
},
},
methods: {
/**
* Called when new files must be added to the overview. It can handle files via a
* drop event, but also via a file upload input event.
*/
addFile(event) {
this.dragging = false
let files = null
if (event.target.files) {
// Files via the file upload input.
files = event.target.files
} else if (event.dataTransfer) {
// Files via drag and drop.
files = event.dataTransfer.files
}
if (files === null) {
return
}
const imageTypes = ['image/jpeg', 'image/jpg', 'image/png']
Array.from(files).forEach((file) => {
const isImage = imageTypes.includes(file.type)
const item = {
id: uuid(),
percentage: 0,
error: null,
state: 'waiting',
iconClass: mimetype2fa(file.type),
isImage,
file,
}
this.files.push(item)
// If the file is an image we can generate a preview thumbnail after the img
// element has been rendered.
if (isImage) {
this.$nextTick(() => {
this.generateThumbnail(item)
})
}
})
},
/**
* Generates and sets a thumbnail of the just chosen file.
*/
generateThumbnail(item) {
const reader = new FileReader()
reader.onload = async () => {
const dataUrl = await generateThumbnail(reader.result, 48, 48)
this.$refs['file-image-' + item.id][0].src = dataUrl
}
reader.readAsDataURL(item.file)
},
removeFile(id) {
const index = this.files.findIndex((file) => file.id === id)
if (index === -1) {
return
}
this.files.splice(index, 1)
},
upload() {
// The upload button, which calls this method, could also be the retry button. In
// that case the failed files must be converted to waiting.
this.files.forEach((file) => {
if (file.state === 'failed') {
file.state = 'waiting'
file.error = null
file.percentage = 0
}
})
this.uploadNext()
},
/**
* Is called when the next file must be uploaded. If there aren't any waiting ones
* and at least one file has been uploaded successfully the uploaded event is
* emitted.
*/
async uploadNext() {
this.uploading = true
const file = this.files.find((file) => file.state === 'waiting')
// If no waiting files have been found we can assume the upload progress is
// completed.
if (file === undefined) {
this.uploading = false
// If at least one of the files has been uploaded successfully we can
// communicate the responses to the parent component.
if (this.responses.length > 0) {
this.$emit('uploaded', this.responses)
}
return
}
const progress = (event) => {
const percentage = Math.round((event.loaded * 100) / event.total)
file.percentage = percentage
}
file.state = 'uploading'
try {
const { data } = await UserFileService(this.$client).uploadFile(
file.file,
progress
)
this.responses.push(data)
file.state = 'finished'
} catch (error) {
const message = error.handler.getMessage('userFile')
error.handler.handled()
file.state = 'failed'
file.error = message.message
}
this.uploadNext()
},
},
}
</script>

View file

@ -0,0 +1,77 @@
<template>
<div>
<h2 class="box__title">Upload from a URL</h2>
<Error :error="error"></Error>
<form @submit.prevent="upload(values.url)">
<div class="control">
<label class="control__label">URL</label>
<div class="control__elements">
<input
v-model="values.url"
:class="{ 'input--error': $v.values.url.$error }"
type="text"
class="input input--large"
@blur="$v.values.url.$touch()"
/>
<div v-if="$v.values.url.$error" class="error">
A valid URL is required.
</div>
</div>
</div>
<div class="actions actions--right">
<button
:class="{ 'button--loading': loading }"
class="button button--large"
:disabled="loading"
>
Upload
</button>
</div>
</form>
</div>
</template>
<script>
import { required, url } from 'vuelidate/lib/validators'
import error from '@baserow/modules/core/mixins/error'
import UserFileService from '@baserow/modules/core/services/userFile'
export default {
name: 'UploadViaURLUserFileUpload',
mixins: [error],
data() {
return {
loading: false,
values: {
url: '',
},
}
},
methods: {
async upload(url) {
this.$v.$touch()
if (this.$v.$invalid) {
return
}
this.loading = true
this.hideError()
try {
const { data } = await UserFileService(this.$client).uploadViaURL(url)
this.$emit('uploaded', [data])
} catch (error) {
this.handleError(error, 'userFile')
}
this.loading = false
},
},
validations: {
values: {
url: { required, url },
},
},
}
</script>

View file

@ -0,0 +1,67 @@
<template>
<Modal :sidebar="true" @hidden="$emit('hidden')">
<template v-slot:sidebar>
<div class="modal-sidebar__head">
<div class="modal-sidebar__head-name">Upload from</div>
</div>
<ul class="modal-sidebar__nav">
<li v-for="upload in registeredUserFileUploads" :key="upload.type">
<a
class="modal-sidebar__nav-link"
:class="{ active: page === upload.type }"
@click="setPage(upload.type)"
>
<i
class="fas modal-sidebar__nav-icon"
:class="'fa-' + upload.iconClass"
></i>
{{ upload.name }}
</a>
</li>
</ul>
</template>
<template v-slot:content>
<component
:is="userFileUploadComponent"
@uploaded="$emit('uploaded', $event)"
></component>
</template>
</Modal>
</template>
<script>
import modal from '@baserow/modules/core/mixins/modal'
export default {
name: 'UserFilesModal',
mixins: [modal],
data() {
return {
page: null,
}
},
computed: {
registeredUserFileUploads() {
return this.$registry.getAll('userFileUpload')
},
userFileUploadComponent() {
const active = Object.values(
this.$registry.getAll('userFileUpload')
).find((upload) => upload.type === this.page)
return active ? active.getComponent() : null
},
},
methods: {
setPage(page) {
this.page = page
},
isPage(page) {
return this.page === page
},
show(page, ...args) {
this.page = page
return modal.methods.show.call(this, ...args)
},
},
}
</script>

View file

@ -1,19 +1,19 @@
<template>
<Modal :sidebar="true">
<template v-slot:sidebar>
<div class="settings__head">
<div class="settings__head-icon">{{ nameAbbreviation }}</div>
<div class="settings__head-name">Settings</div>
<div class="modal-sidebar__head">
<div class="modal-sidebar__head-icon">{{ nameAbbreviation }}</div>
<div class="modal-sidebar__head-name">Settings</div>
</div>
<ul class="settings__nav">
<ul class="modal-sidebar__nav">
<li v-for="setting in registeredSettings" :key="setting.type">
<a
class="settings__nav-link"
class="modal-sidebar__nav-link"
:class="{ active: page === setting.type }"
@click="setPage(setting.type)"
>
<i
class="fas settings__nav-icon"
class="fas modal-sidebar__nav-icon"
:class="'fa-' + setting.iconClass"
></i>
{{ setting.name }}

View file

@ -0,0 +1,86 @@
/**
* This is a very simple and fast tooltip directive. It will add the binding value as
* tooltip content. The tooltip only shows if there is a value.
*/
export default {
/**
* If there is a value and the tooltip has not yet been initialized we can add the
* mouse events to show and hide the tooltip.
*/
initialize(el, value) {
el.updatePositionEvent = () => {
const rect = el.getBoundingClientRect()
const width = rect.right - rect.left
el.tooltipElement.style.top = rect.bottom + 4 + 'px'
el.tooltipElement.style.left = rect.left + width / 2 + 'px'
}
el.tooltipMouseEnterEvent = () => {
if (el.tooltipElement) {
this.terminate(el)
}
el.tooltipElement = document.createElement('div')
el.tooltipElement.className = 'tooltip tooltip--body tooltip--center'
document.body.insertBefore(el.tooltipElement, document.body.firstChild)
el.tooltipContentElement = document.createElement('div')
el.tooltipContentElement.className = 'tooltip__content'
el.tooltipContentElement.textContent = value
el.tooltipElement.appendChild(el.tooltipContentElement)
el.updatePositionEvent()
// When the user scrolls or resizes the window it could be possible that the
// element where the tooltip is anchored to has moved, so then the position
// needs to be updated. We only want to do this when the tooltip is visible.
window.addEventListener('scroll', el.updatePositionEvent, true)
window.addEventListener('resize', el.updatePositionEvent)
}
el.tooltipMoveLeaveEvent = () => {
if (el.tooltipElement) {
el.tooltipElement.parentNode.removeChild(el.tooltipElement)
el.tooltipElement = null
el.tooltipContentElement = null
}
window.removeEventListener('scroll', el.updatePositionEvent, true)
window.removeEventListener('resize', el.updatePositionEvent)
}
el.addEventListener('mouseenter', el.tooltipMouseEnterEvent)
el.addEventListener('mouseleave', el.tooltipMoveLeaveEvent)
},
/**
* If there isn't a value or if the directive is unbinded the tooltipElement can
* be destroyed if it wasn't already and all the events can be removed.
*/
terminate(el) {
if (el.tooltipElement && el.tooltipElement.parentNode) {
el.tooltipElement.parentNode.removeChild(el.tooltipElement)
}
el.tooltipElement = null
el.tooltipContentElement = null
el.removeEventListener('mouseenter', el.tooltipMouseEnterEvent)
el.removeEventListener('mouseleave', el.tooltipMoveLeaveEvent)
window.removeEventListener('scroll', el.updatePositionEvent, true)
window.removeEventListener('resize', el.updatePositionEvent)
},
bind(el, binding) {
el.tooltipElement = null
el.tooltipContentElement = null
binding.def.update(el, binding)
},
update(el, binding) {
const { value } = binding
if (!!value && el.tooltipElement) {
el.tooltipContentElement.textContent = value
} else if (!!value && el.tooltipElement === null) {
binding.def.initialize(el, value)
} else if (!value) {
binding.def.terminate(el)
}
},
unbind(el, binding) {
binding.def.terminate(el)
},
}

View file

@ -0,0 +1,5 @@
import { formatBytes } from '@baserow/modules/core/utils/file'
export default function (bytes) {
return formatBytes(bytes)
}

View file

@ -0,0 +1,73 @@
import MoveToBody from '@baserow/modules/core/mixins/moveToBody'
export default {
mixins: [MoveToBody],
data() {
return {
open: false,
}
},
destroyed() {
window.removeEventListener('keyup', this.keyup)
},
methods: {
/**
* Toggle the open state of the modal.
*/
toggle(value) {
if (value === undefined) {
value = !this.open
}
if (value) {
this.show()
} else {
this.hide()
}
},
/**
* Show the modal.
*/
show() {
this.open = true
window.addEventListener('keyup', this.keyup)
},
/**
* Hide the modal.
*/
hide(emit = true) {
// This is a temporary fix. What happens is the model is opened by a context menu
// item and the user closes the modal, the element is first deleted and then the
// click outside event of the context is fired. It then checks if the click was
// inside one of his children, but because the modal element doesn't exists
// anymore it thinks it was outside, so is closes the context menu which we don't
// want automatically.
setTimeout(() => {
this.open = false
})
if (emit) {
this.$emit('hidden')
}
window.removeEventListener('keyup', this.keyup)
},
/**
* If someone actually clicked on the modal wrapper and not one of his children the
* modal should be closed.
*/
outside(event) {
if (event.target === this.$refs.modalWrapper) {
this.hide()
}
},
/**
* When the escape key is pressed the modal needs to be hidden.
*/
keyup(event) {
if (event.keyCode === 27) {
this.hide()
}
},
},
}

View file

@ -450,6 +450,14 @@
<a class="button button--large button--loading">Loading</a>
<a class="button button--loading">Loading</a>
</div>
<div class="margin-bottom-3">
<div class="tooltip margin-bottom-2">
<div class="tooltip__content">Example tooltip</div>
</div>
<div class="tooltip tooltip--top">
<div class="tooltip__content">Tooltip top</div>
</div>
</div>
<div class="margin-bottom-3 style-guide__contexts">
<div class="context">
<div class="context__menu-title">Vehicles</div>
@ -798,32 +806,32 @@
<i class="fas fa-times"></i>
</a>
<div class="modal__box-sidebar">
<div class="settings__head">
<div class="settings__head-icon">B</div>
<div class="settings__head-name">Settings</div>
<div class="modal-sidebar__head">
<div class="modal-sidebar__head-icon">B</div>
<div class="modal-sidebar__head-name">Settings</div>
</div>
<ul class="settings__nav">
<ul class="modal-sidebar__nav">
<li>
<a href="#" class="settings__nav-link">
<i class="fas fa-user-circle settings__nav-icon"></i>
<a href="#" class="modal-sidebar__nav-link">
<i class="fas fa-user-circle modal-sidebar__nav-icon"></i>
Profile
</a>
</li>
<li>
<a href="#" class="settings__nav-link">
<i class="fas fa-user settings__nav-icon"></i>
<a href="#" class="modal-sidebar__nav-link">
<i class="fas fa-user modal-sidebar__nav-icon"></i>
Account
</a>
</li>
<li>
<a href="#" class="settings__nav-link active">
<i class="fas fa-lock settings__nav-icon"></i>
<a href="#" class="modal-sidebar__nav-link active">
<i class="fas fa-lock modal-sidebar__nav-icon"></i>
Password
</a>
</li>
<li>
<a href="#" class="settings__nav-link">
<i class="fas fa-envelope settings__nav-icon"></i>
<a href="#" class="modal-sidebar__nav-link">
<i class="fas fa-envelope modal-sidebar__nav-icon"></i>
Email
</a>
</li>

View file

@ -3,6 +3,10 @@ import Vue from 'vue'
import { Registry } from '@baserow/modules/core/registry'
import { PasswordSettingsType } from '@baserow/modules/core/settingsTypes'
import {
UploadFileUserFileUploadType,
UploadViaURLUserFileUploadType,
} from '@baserow/modules/core/userFileUploadTypes'
import applicationStore from '@baserow/modules/core/store/application'
import authStore from '@baserow/modules/core/store/auth'
@ -19,7 +23,10 @@ export default ({ store, app }, inject) => {
registry.registerNamespace('view')
registry.registerNamespace('field')
registry.registerNamespace('settings')
registry.registerNamespace('userFileUpload')
registry.register('settings', new PasswordSettingsType())
registry.register('userFileUpload', new UploadFileUserFileUploadType())
registry.register('userFileUpload', new UploadViaURLUserFileUploadType())
inject('registry', registry)
store.registerModule('application', applicationStore)

View file

@ -35,6 +35,18 @@ class ErrorHandler {
"The action couldn't be completed because the related row doesn't exist" +
' anymore.'
),
ERROR_FILE_SIZE_TOO_LARGE: new ResponseErrorMessage(
'File to large',
'The provided file is too large.'
),
ERROR_INVALID_FILE: new ResponseErrorMessage(
'Invalid file',
'The provided file is not a valid file.'
),
ERROR_FILE_URL_COULD_NOT_BE_REACHED: new ResponseErrorMessage(
'Invalid URL',
'The provided file URL could not be reached.'
),
}
// A temporary notFoundMap containing the error messages for when the

View file

@ -13,9 +13,11 @@ import Copied from '@baserow/modules/core/components/Copied'
import lowercase from '@baserow/modules/core/filters/lowercase'
import uppercase from '@baserow/modules/core/filters/uppercase'
import formatBytes from '@baserow/modules/core/filters/formatBytes'
import scroll from '@baserow/modules/core/directives/scroll'
import preventParentScroll from '@baserow/modules/core/directives/preventParentScroll'
import tooltip from '@baserow/modules/core/directives/tooltip'
Vue.component('Context', Context)
Vue.component('Modal', Modal)
@ -30,6 +32,8 @@ Vue.component('Copied', Copied)
Vue.filter('lowercase', lowercase)
Vue.filter('uppercase', uppercase)
Vue.filter('formatBytes', formatBytes)
Vue.directive('scroll', scroll)
Vue.directive('preventParentScroll', preventParentScroll)
Vue.directive('tooltip', tooltip)

View file

@ -0,0 +1,20 @@
export default (client) => {
return {
uploadFile(file, onUploadProgress = function () {}) {
const formData = new FormData()
formData.append('file', file)
const config = {
onUploadProgress,
headers: {
'Content-Type': 'multipart/form-data',
},
}
return client.post('/user-files/upload-file/', formData, config)
},
uploadViaURL(url) {
return client.post('/user-files/upload-via-url/', { url })
},
}
}

View file

@ -38,13 +38,13 @@ export class SettingsType extends Registerable {
this.name = this.getName()
if (this.type === null) {
throw new Error('The type name of an application type must be set.')
throw new Error('The type name of a settings type must be set.')
}
if (this.iconClass === null) {
throw new Error('The icon class of an application type must be set.')
throw new Error('The icon class of a settings type must be set.')
}
if (this.name === null) {
throw new Error('The name of an application type must be set.')
throw new Error('The name of a settings type must be set.')
}
}

View file

@ -0,0 +1,99 @@
import { Registerable } from '@baserow/modules/core/registry'
import UploadFileUserFileUpload from '@baserow/modules/core/components/files/UploadFileUserFileUpload'
import UploadViaURLUserFileUpload from '@baserow/modules/core/components/files/UploadViaURLUserFileUpload'
/**
* Upload file types will be added to the user files modal. Each upload should be able
* to upload files in a specific way to the user files.
*/
export class UserFileUploadType extends Registerable {
/**
* The font awesome 5 icon name that is used as convenience for the user to
* recognize user file upload types. The icon will for example be displayed in the
* user files modal sidebar. If you for example want the database icon, you must
* return 'database' here. This will result in the classname 'fas fa-database'.
*/
getIconClass() {
return null
}
/**
* A human readable name of the user file upload type. This will be shown in the
* user files modal sidebar.
*/
getName() {
return null
}
/**
* The component will be rendered when the user clicks on the item in the user
* file upload model.
*/
getComponent() {
throw new Error('The component of a user file upload type must be set.')
}
constructor() {
super()
this.type = this.getType()
this.iconClass = this.getIconClass()
this.name = this.getName()
if (this.type === null) {
throw new Error('The type name of a user file upload type must be set.')
}
if (this.iconClass === null) {
throw new Error('The icon class of a user file upload type must be set.')
}
if (this.name === null) {
throw new Error('The name of a user file upload type must be set.')
}
}
/**
* @return object
*/
serialize() {
return {
type: this.type,
iconClass: this.iconClass,
name: this.name,
}
}
}
export class UploadFileUserFileUploadType extends UserFileUploadType {
static getType() {
return 'file'
}
getIconClass() {
return 'upload'
}
getName() {
return 'my device'
}
getComponent() {
return UploadFileUserFileUpload
}
}
export class UploadViaURLUserFileUploadType extends UserFileUploadType {
static getType() {
return 'url'
}
getIconClass() {
return 'link'
}
getName() {
return 'a URL'
}
getComponent() {
return UploadViaURLUserFileUpload
}
}

View file

@ -0,0 +1,18 @@
/**
* Originally from
* https://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript
*
* Converts an integer representing the amount of bytes to a human readable format.
* Where for example 1024 will end up in 1KB.
*/
export function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / k ** i).toFixed(dm)) + ' ' + sizes[i]
}

View file

@ -0,0 +1,72 @@
// Original file at
// https://github.com/LoicMahieu/mimetype-to-fontawesome/blob/master/index.js
const mapping = [
['file-image', /^image\//],
['file-audio', /^audio\//],
['file-video', /^video\//],
['file-pdf', 'application/pdf'],
['file-alt', 'text/plain'],
['file-csv', 'text/csv'],
['file-code', ['text/html', 'text/javascript']],
[
'file-archive',
[
/^application\/x-(g?tar|xz|compress|bzip2|g?zip)$/,
/^application\/x-(7z|rar|zip)-compressed$/,
/^application\/(zip|gzip|tar)$/,
],
],
[
'file-word',
[
/ms-?word/,
'application/vnd.oasis.opendocument.text',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
],
[
'file-powerpoint',
[
/ms-?powerpoint/,
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
],
],
[
'file-excel',
[
/ms-?excel/,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
],
['file'],
]
function match(mimetype, cond) {
if (Array.isArray(cond)) {
return cond.reduce(function (v, c) {
return v || match(mimetype, c)
}, false)
} else if (cond instanceof RegExp) {
return cond.test(mimetype)
} else if (cond === undefined) {
return true
} else {
return mimetype === cond
}
}
const cache = {}
export function mimetype2fa(mimetype) {
if (cache[mimetype]) {
return cache[mimetype]
}
for (let i = 0; i < mapping.length; i++) {
if (match(mimetype, mapping[i][1])) {
cache[mimetype] = mapping[i][0]
return mapping[i][0]
}
}
}

View file

@ -0,0 +1,47 @@
/**
* Generates a cropped thumbnail that has the provided dimensions. The source image
* is not stretched into the right format, but is is cropped. So it could be part
* of the image is not visible anymore.
*/
export function generateThumbnail(source, targetWidth, targetHeight) {
return new Promise((resolve) => {
const sourceImage = new Image()
sourceImage.onload = function (f) {
const canvas = document.createElement('canvas')
canvas.width = targetWidth
canvas.height = targetHeight
if (sourceImage.width === sourceImage.height) {
canvas
.getContext('2d')
.drawImage(sourceImage, 0, 0, targetWidth, targetHeight)
} else {
const minVal = Math.min(sourceImage.width, sourceImage.height)
let sourceX = 0
let sourceY = 0
if (sourceImage.width > sourceImage.height) {
sourceX = (sourceImage.width - minVal) / 2
} else {
sourceY = (sourceImage.height - minVal) / 2
}
canvas
.getContext('2d')
.drawImage(
sourceImage,
sourceX,
sourceY,
minVal,
minVal,
0,
0,
targetWidth,
targetHeight
)
}
resolve(canvas.toDataURL())
}
sourceImage.src = source
})
}

View file

@ -0,0 +1,152 @@
<template>
<div
v-if="open"
ref="modalWrapper"
class="modal__wrapper file-field-modal__wrapper"
@click="outside($event)"
>
<div class="file-field-modal">
<div class="file-field-modal__head">
<div class="file-field-modal__name">
<template v-if="preview">{{ preview.visible_name }}</template>
</div>
<a class="file-field-modal__close" @click="hide()">
<i class="fas fa-times"></i>
</a>
</div>
<div class="file-field-modal__body">
<a
class="file-field-modal__body-nav file-field-modal__body-nav--previous"
@click="previous()"
>
<i class="fas fa-chevron-left"></i>
</a>
<a
class="file-field-modal__body-nav file-field-modal__body-nav--next"
@click="next()"
>
<i class="fas fa-chevron-right"></i>
</a>
<div v-if="preview !== null" class="file-field-modal__preview">
<FileFieldModalImage
v-if="preview.is_image"
:key="preview.name + '-' + selected"
:src="preview.url"
></FileFieldModalImage>
<div v-else class="file-field-modal__preview-icon">
<i class="fas" :class="'fa-' + getIconClass(preview.mime_type)"></i>
</div>
</div>
</div>
<div class="file-field-modal__foot">
<ul class="file-field-modal__nav">
<li
v-for="(file, index) in files"
:key="file.name + '-' + index"
class="file-field-modal__nav-item"
>
<a
class="file-field-modal__nav-link"
:class="{ active: index === selected }"
@click="selected = index"
>
<img
v-if="file.is_image"
:src="file.thumbnails.small.url"
class="file-field-modal__nav-image"
/>
<i
v-else
class="fas file-field-modal__nav-icon"
:class="'fa-' + getIconClass(file.mime_type)"
></i>
</a>
</li>
</ul>
<ul v-if="preview" class="file-field-modal__actions">
<a
target="_blank"
:href="preview.url"
class="file-field-modal__action"
>
<i class="fas fa-download"></i>
</a>
<a class="file-field-modal__action" @click="remove(selected)">
<i class="fas fa-trash"></i>
</a>
</ul>
</div>
</div>
</div>
</template>
<script>
import baseModal from '@baserow/modules/core/mixins/baseModal'
import { mimetype2fa } from '@baserow/modules/core/utils/fontawesome'
import FileFieldModalImage from '@baserow/modules/database/components/field/FileFieldModalImage'
export default {
name: 'FileFieldModal',
components: { FileFieldModalImage },
mixins: [baseModal],
props: {
files: {
type: Array,
required: true,
},
},
data() {
return {
selected: 0,
}
},
computed: {
preview() {
if (this.files.length > this.selected) {
return this.files[this.selected]
}
return null
},
},
methods: {
show(index = 0) {
this.selected = index
return baseModal.methods.show.call(this)
},
getIconClass(mimeType) {
return mimetype2fa(mimeType)
},
next() {
this.selected =
this.files.length - 1 > this.selected ? this.selected + 1 : 0
},
previous() {
this.selected =
this.selected === 0 ? this.files.length - 1 : this.selected - 1
},
remove(index) {
if (index === this.files.length - 1) {
this.selected = 0
}
const length = this.files.length
this.$emit('removed', index)
if (length === 1) {
this.hide()
}
},
keyup(event) {
// If left arrow
if (event.keyCode === 37) {
this.previous()
}
// If right arrow
if (event.keyCode === 39) {
this.next()
}
return baseModal.methods.keyup.call(this, event)
},
},
}
</script>

View file

@ -0,0 +1,28 @@
<template>
<div class="file-field-modal__preview-image-wrapper">
<div v-if="!loaded" class="file-field-modal__preview-image-loading"></div>
<img
:src="src"
class="file-field-modal__preview-image"
:class="{ 'file-field-modal__preview-image--hidden': !loaded }"
@load="loaded = true"
/>
</div>
</template>
<script>
export default {
name: 'FileFieldModalImage',
props: {
src: {
type: String,
required: true,
},
},
data() {
return {
loaded: false,
}
},
}
</script>

View file

@ -0,0 +1,83 @@
<template>
<div class="control__elements">
<ul class="field-file__list">
<li
v-for="(file, index) in value"
:key="file.name + '-' + index"
class="field-file__item"
>
<div class="field-file__preview">
<a class="field-file__icon" @click="$refs.fileModal.show(index)">
<img v-if="file.is_image" :src="file.thumbnails.small.url" />
<i
v-else
class="fas"
:class="'fa-' + getIconClass(file.mime_type)"
></i>
</a>
</div>
<div class="field-file__description">
<div class="field-file__name">
{{ file.visible_name }}
</div>
<div class="field-file__info">
{{ getDate(file.uploaded_at) }} - {{ file.size | formatBytes }}
</div>
</div>
<div class="field-file__actions">
<a
v-tooltip="'download'"
target="_blank"
:href="file.url"
class="field-file__action"
>
<i class="fas fa-download"></i>
</a>
<a
v-tooltip="'delete'"
class="field-file__action"
@click="removeFile(value, index)"
>
<i class="fas fa-trash"></i>
</a>
</div>
</li>
</ul>
<a class="field-file__add" @click.prevent="showModal()">
<i class="fas fa-plus field-file__add-icon"></i>
Add a file
</a>
<UserFilesModal
ref="uploadModal"
@uploaded="addFiles(value, $event)"
></UserFilesModal>
<FileFieldModal
ref="fileModal"
:files="value"
@removed="removeFile(value, $event)"
></FileFieldModal>
</div>
</template>
<script>
import moment from 'moment'
import { UploadFileUserFileUploadType } from '@baserow/modules/core/userFileUploadTypes'
import UserFilesModal from '@baserow/modules/core/components/files/UserFilesModal'
import FileFieldModal from '@baserow/modules/database/components/field/FileFieldModal'
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
import fileField from '@baserow/modules/database/mixins/fileField'
export default {
components: { UserFilesModal, FileFieldModal },
mixins: [rowEditField, fileField],
methods: {
showModal() {
this.$refs.uploadModal.show(UploadFileUserFileUploadType.getType())
},
getDate(value) {
return moment.utc(value).format('MMM Do YYYY [at] H:mm')
},
},
}
</script>

View file

@ -5,7 +5,6 @@
v-model="copy"
type="text"
class="input input--large field-long-text"
@keyup.enter="$refs.input.blur()"
@focus="select()"
@blur="unselect()"
/>

View file

@ -1,5 +1,5 @@
<template>
<div class="grid-view__column" @click="select($event)">
<div ref="wrapper" class="grid-view__column" @click="select($event)">
<component
:is="getFieldComponent(field.type)"
ref="field"
@ -8,6 +8,7 @@
:selected="selected"
@update="update"
@edit="edit"
@select="$refs.wrapper.click()"
/>
</div>
</template>

View file

@ -0,0 +1,220 @@
<template>
<div
ref="cell"
class="grid-view__cell grid-field-file__cell"
:class="{ active: selected }"
@drop.prevent="uploadFiles($event)"
@dragover.prevent
@dragenter.prevent="dragEnter($event)"
@dragleave="dragLeave($event)"
>
<div v-show="dragging" class="grid-field-file__dragging">
<div>
<i class="grid-field-file__drop-icon fas fa-cloud-upload-alt"></i>
Drop here
</div>
</div>
<ul v-if="Array.isArray(value)" class="grid-field-file__list">
<li
v-for="(file, index) in value"
:key="file.name + index"
class="grid-field-file__item"
>
<a
v-tooltip="selected ? file.visible_name : null"
class="grid-field-file__link"
@click.prevent="showFileModal(index)"
>
<img
v-if="file.is_image"
class="grid-field-file__image"
:src="file.thumbnails.tiny.url"
/>
<i
v-else
class="fas grid-field-file__icon"
:class="'fa-' + getIconClass(file.mime_type)"
></i>
</a>
</li>
<li
v-for="loading in loadings"
:key="loading.id"
class="grid-field-file__item"
>
<div class="grid-field-file__loading"></div>
</li>
<li v-if="selected" class="grid-field-file__item">
<a class="grid-field-file__item-add" @click.prevent="showUploadModal()">
<i class="fas fa-plus"></i>
</a>
<div v-if="value.length == 0" class="grid-field-file__drop">
<i class="grid-field-file__drop-icon fas fa-cloud-upload-alt"></i>
Drop files here
</div>
</li>
</ul>
<UserFilesModal
v-if="Array.isArray(value)"
ref="uploadModal"
@uploaded="addFiles(value, $event)"
@hidden="hideModal"
></UserFilesModal>
<FileFieldModal
v-if="Array.isArray(value)"
ref="fileModal"
:files="value"
@hidden="hideModal"
@removed="removeFile(value, $event)"
></FileFieldModal>
</div>
</template>
<script>
import { uuid } from '@baserow/modules/core/utils/string'
import { isElement } from '@baserow/modules/core/utils/dom'
import { notifyIf } from '@baserow/modules/core/utils/error'
import UserFilesModal from '@baserow/modules/core/components/files/UserFilesModal'
import { UploadFileUserFileUploadType } from '@baserow/modules/core/userFileUploadTypes'
import UserFileService from '@baserow/modules/core/services/userFile'
import FileFieldModal from '@baserow/modules/database/components/field/FileFieldModal'
import gridField from '@baserow/modules/database/mixins/gridField'
import fileField from '@baserow/modules/database/mixins/fileField'
export default {
name: 'GridViewFieldFile',
components: { UserFilesModal, FileFieldModal },
mixins: [gridField, fileField],
data() {
return {
modalOpen: false,
dragging: false,
loadings: [],
dragTarget: null,
}
},
methods: {
/**
* Method is called when the user drops his files into the field. The files should
* automatically be uploaded to the user files and added to the field after that.
*/
async uploadFiles(event) {
this.dragging = false
const files = Array.from(event.dataTransfer.files).map((file) => {
return {
id: uuid(),
file,
}
})
if (files === null) {
return
}
this.$emit('select')
// First add the file ids to the loading list so the user sees a visual loading
// indication for each file.
files.forEach((file) => {
this.loadings.push({ id: file.id })
})
// Now upload the files one by one to not overload the backend. When finished,
// regardless of is has succeeded, the loading state for that file can be removed
// because it has already been added as a file.
for (let i = 0; i < files.length; i++) {
const id = files[i].id
const file = files[i].file
try {
const { data } = await UserFileService(this.$client).uploadFile(file)
this.addFiles(this.value, [data])
} catch (error) {
notifyIf(error, 'userFile')
}
const index = this.loadings.findIndex((l) => l.id === id)
this.loadings.splice(index, 1)
}
},
select() {
// While the field is selected we want to open the select row popup by pressing
// the enter key.
this.$el.keydownEvent = (event) => {
if (event.keyCode === 13 && !this.modalOpen) {
this.showUploadModal()
}
}
document.body.addEventListener('keydown', this.$el.keydownEvent)
},
beforeUnSelect() {
document.body.removeEventListener('keydown', this.$el.keydownEvent)
},
/**
* If the user clicks inside the select row modal we do not want to unselect the
* field. The modal lives in the root of the body element and not inside the cell,
* so the system naturally wants to unselect when the user clicks inside one of
* these contexts.
*/
canUnselectByClickingOutside(event) {
return (
!isElement(this.$refs.uploadModal.$el, event.target) &&
!isElement(this.$refs.fileModal.$el, event.target)
)
},
/**
* Prevent unselecting the field cell by changing the event. Because the deleted
* item is not going to be part of the dom anymore after deleting it will get
* noticed as if the user clicked outside the cell which wasn't the case.
*/
removeFile(event, index) {
event.preventFieldCellUnselect = true
return fileField.methods.removeFile.call(this, event, index)
},
showUploadModal() {
this.modalOpen = true
this.$refs.uploadModal.show(UploadFileUserFileUploadType.getType())
},
showFileModal(index) {
if (!this.selected) {
return
}
this.modalOpen = true
this.$refs.fileModal.show(index)
},
hideModal() {
this.modalOpen = false
},
/**
* While the modal is open, all key combinations related to the field must be
* ignored.
*/
canKeyDown() {
return !this.modalOpen
},
canPaste() {
return !this.modalOpen
},
canCopy() {
return !this.modalOpen
},
canEmpty() {
return !this.modalOpen
},
dragEnter(event) {
this.dragging = true
this.dragTarget = event.target
},
dragLeave(event) {
if (this.dragTarget === event.target) {
event.stopPropagation()
event.preventDefault()
this.dragging = false
this.dragTarget = null
}
},
},
}
</script>

View file

@ -62,7 +62,7 @@ export default {
// While the field is selected we want to open the select row popup by pressing
// the enter key.
this.$el.keydownEvent = (event) => {
if (event.keyCode === 13) {
if (event.keyCode === 13 && !this.modalOpen) {
this.showModal()
}
}
@ -72,10 +72,10 @@ export default {
document.body.removeEventListener('keydown', this.$el.keydownEvent)
},
/**
* If the user clicks inside the select row modal we do not want to unselect the
* field. The modals lives in the root of the body element and not inside the cell,
* so the system naturally wants to unselect when the user clicks inside one of
* these contexts.
* If the user clicks inside the select row or file modal we do not want to
* unselect the field. The modals lives in the root of the body element and not
* inside the cell, so the system naturally wants to unselect when the user clicks
* inside one of these contexts.
*/
canUnselectByClickingOutside(event) {
return !isElement(this.$refs.selectModal.$el, event.target)
@ -103,6 +103,15 @@ export default {
canKeyDown(event) {
return !this.modalOpen
},
canPaste() {
return !this.modalOpen
},
canCopy() {
return !this.modalOpen
},
canEmpty() {
return !this.modalOpen
},
},
}
</script>

View file

@ -16,6 +16,7 @@ import GridViewFieldLinkRow from '@baserow/modules/database/components/view/grid
import GridViewFieldNumber from '@baserow/modules/database/components/view/grid/GridViewFieldNumber'
import GridViewFieldBoolean from '@baserow/modules/database/components/view/grid/GridViewFieldBoolean'
import GridViewFieldDate from '@baserow/modules/database/components/view/grid/GridViewFieldDate'
import GridViewFieldFile from '@baserow/modules/database/components/view/grid/GridViewFieldFile'
import RowEditFieldText from '@baserow/modules/database/components/row/RowEditFieldText'
import RowEditFieldLongText from '@baserow/modules/database/components/row/RowEditFieldLongText'
@ -25,6 +26,7 @@ import RowEditFieldLinkRow from '@baserow/modules/database/components/row/RowEdi
import RowEditFieldNumber from '@baserow/modules/database/components/row/RowEditFieldNumber'
import RowEditFieldBoolean from '@baserow/modules/database/components/row/RowEditFieldBoolean'
import RowEditFieldDate from '@baserow/modules/database/components/row/RowEditFieldDate'
import RowEditFieldFile from '@baserow/modules/database/components/row/RowEditFieldFile'
import { trueString } from '@baserow/modules/database/utils/constants'
@ -370,7 +372,14 @@ export class LinkRowFieldType extends FieldType {
}
prepareValueForPaste(field, clipboardData) {
const values = JSON.parse(clipboardData.getData('text'))
let values
try {
values = JSON.parse(clipboardData.getData('text'))
} catch (SyntaxError) {
return []
}
if (field.link_row_table === values.tableId) {
return values.value
}
@ -756,3 +765,106 @@ export class EmailFieldType extends FieldType {
return 'example@baserow.io'
}
}
export class FileFieldType extends FieldType {
static getType() {
return 'file'
}
getIconClass() {
return 'file'
}
getName() {
return 'File'
}
getGridViewFieldComponent() {
return GridViewFieldFile
}
getRowEditFieldComponent() {
return RowEditFieldFile
}
prepareValueForCopy(field, value) {
return JSON.stringify(value)
}
prepareValueForPaste(field, clipboardData) {
let value
try {
value = JSON.parse(clipboardData.getData('text'))
} catch (SyntaxError) {
return []
}
if (!Array.isArray(value)) {
return []
}
// Each object should at least have the file name as property.
for (let i = 0; i < value.length; i++) {
if (!Object.prototype.hasOwnProperty.call(value[i], 'name')) {
return []
}
}
return value
}
getEmptyValue(field) {
return []
}
getCanSortInView() {
return false
}
getDocsDataType() {
return 'array'
}
getDocsDescription() {
return 'Accepts an array of objects containing at least the name of the user file.'
}
getDocsRequestExample() {
return [
{
name:
'VXotniBOVm8tbstZkKsMKbj2Qg7KmPvn_39d354a76abe56baaf569ad87d0333f58ee4bf3eed368e3b9dc736fd18b09dfd.png',
},
]
}
getDocsResponseExample() {
return [
{
url:
'https://files.baserow.io/user_files/VXotniBOVm8tbstZkKsMKbj2Qg7KmPvn_39d354a76abe56baaf569ad87d0333f58ee4bf3eed368e3b9dc736fd18b09dfd.png',
thumbnails: {
tiny: {
url:
'https://files.baserow.io/media/thumbnails/tiny/VXotniBOVm8tbstZkKsMKbj2Qg7KmPvn_39d354a76abe56baaf569ad87d0333f58ee4bf3eed368e3b9dc736fd18b09dfd.png',
width: 21,
height: 21,
},
small: {
url:
'https://files.baserow.io/media/thumbnails/small/VXotniBOVm8tbstZkKsMKbj2Qg7KmPvn_39d354a76abe56baaf569ad87d0333f58ee4bf3eed368e3b9dc736fd18b09dfd.png',
width: 48,
height: 48,
},
},
name:
'VXotniBOVm8tbstZkKsMKbj2Qg7KmPvn_39d354a76abe56baaf569ad87d0333f58ee4bf3eed368e3b9dc736fd18b09dfd.png',
size: 229940,
mime_type: 'image/png',
is_image: true,
image_width: 1280,
image_height: 585,
uploaded_at: '2020-11-17T12:16:10.035234+00:00',
},
]
}
}

View file

@ -0,0 +1,34 @@
import { mimetype2fa } from '@baserow/modules/core/utils/fontawesome'
export default {
methods: {
/**
* Removes a file at a given index and then updates the value of the field.
*/
removeFile(value, index) {
const newValue = JSON.parse(JSON.stringify(value))
newValue.splice(index, 1)
this.$emit('update', newValue, value)
},
/**
* Adds multiple files to the field. This happens right after the file has been
* uploaded to the user files.
*/
addFiles(value, files) {
// The file field expects the file name to be a visible name because it is
// editable per file in the field.
files = files.map((file) => {
file.visible_name = file.original_name
delete file.original_name
return file
})
this.$refs.uploadModal.hide()
const newValue = JSON.parse(JSON.stringify(value))
newValue.push(...files)
this.$emit('update', newValue, value)
},
getIconClass(mimeType) {
return mimetype2fa(mimeType)
},
},
}

View file

@ -9,6 +9,7 @@ import {
NumberFieldType,
BooleanFieldType,
DateFieldType,
FileFieldType,
} from '@baserow/modules/database/fieldTypes'
import {
EqualViewFilterType,
@ -61,6 +62,7 @@ export default ({ store, app }) => {
app.$registry.register('field', new DateFieldType())
app.$registry.register('field', new URLFieldType())
app.$registry.register('field', new EmailFieldType())
app.$registry.register('field', new FileFieldType())
app.$registry.register('importer', new CSVImporterType())
app.$registry.register('importer', new PasteImporterType())
app.$registry.register('settings', new APITokenSettingsType())

View file

@ -345,6 +345,7 @@ export class EmptyViewFilterType extends ViewFilterType {
'date',
'boolean',
'link_row',
'file',
]
}
@ -381,6 +382,7 @@ export class NotEmptyViewFilterType extends ViewFilterType {
'date',
'boolean',
'link_row',
'file',
]
}