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 #137 See merge request bramw/baserow!117
This commit is contained in:
commit
1a61cb12ed
92 changed files with 4378 additions and 155 deletions
backend
Dockerfile.demo
changelog.mdrequirements
setup.pysrc/baserow
api
config
contrib/database
core
tests
baserow
api
contrib/database
core
fixtures
docs/guides/installation
web-frontend
intellij-idea.webpack.config.js
modules
core
assets/scss
components
variables.scsscomponents
directives
filters
mixins
pages
plugin.jsplugins
services
settingsTypes.jsuserFileUploadTypes.jsutils
database
|
@ -1,7 +1,7 @@
|
|||
FROM python:3.6
|
||||
|
||||
ADD . /backend
|
||||
|
||||
RUN mkdir -p /media
|
||||
WORKDIR /backend
|
||||
|
||||
ENV PYTHONPATH $PYTHONPATH:/backend/src
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'},
|
||||
|
|
|
@ -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
|
||||
|
|
0
backend/src/baserow/api/user_files/__init__.py
Normal file
0
backend/src/baserow/api/user_files/__init__.py
Normal file
30
backend/src/baserow/api/user_files/errors.py
Normal file
30
backend/src/baserow/api/user_files/errors.py
Normal 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.'
|
||||
)
|
63
backend/src/baserow/api/user_files/serializers.py
Normal file
63
backend/src/baserow/api/user_files/serializers.py
Normal 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
|
13
backend/src/baserow/api/user_files/urls.py
Normal file
13
backend/src/baserow/api/user_files/urls.py
Normal 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'),
|
||||
]
|
11
backend/src/baserow/api/user_files/validators.py
Normal file
11
backend/src/baserow/api/user_files/validators.py
Normal 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')
|
91
backend/src/baserow/api/user_files/views.py
Normal file
91
backend/src/baserow/api/user_files/views.py
Normal 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)
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from .base import * # noqa: F403, F401
|
||||
|
||||
|
||||
DEBUG = True
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
|
|
@ -1 +1,6 @@
|
|||
from .base import * # noqa: F403, F401
|
||||
|
||||
|
||||
USER_FILES_DIRECTORY = 'user_files'
|
||||
USER_THUMBNAILS_DIRECTORY = 'thumbnails'
|
||||
USER_THUMBNAILS = {'tiny': [21, 21]}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -239,3 +239,7 @@ class LinkRowField(Field):
|
|||
|
||||
class EmailField(Field):
|
||||
pass
|
||||
|
||||
|
||||
class FileField(Field):
|
||||
pass
|
||||
|
|
|
@ -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.'
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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',),
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
|
|
|
@ -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.'
|
||||
)
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
0
backend/src/baserow/core/management/__init__.py
Normal file
0
backend/src/baserow/core/management/__init__.py
Normal 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."))
|
49
backend/src/baserow/core/migrations/0002_userfile.py
Normal file
49
backend/src/baserow/core/migrations/0002_userfile.py
Normal 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',)}
|
||||
),
|
||||
]
|
|
@ -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()
|
||||
|
||||
|
|
11
backend/src/baserow/core/storage.py
Normal file
11
backend/src/baserow/core/storage.py
Normal 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
|
0
backend/src/baserow/core/user_files/__init__.py
Normal file
0
backend/src/baserow/core/user_files/__init__.py
Normal file
40
backend/src/baserow/core/user_files/exceptions.py
Normal file
40
backend/src/baserow/core/user_files/exceptions.py
Normal 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.
|
||||
"""
|
265
backend/src/baserow/core/user_files/handler.py
Normal file
265
backend/src/baserow/core/user_files/handler.py
Normal 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)
|
15
backend/src/baserow/core/user_files/managers.py
Normal file
15
backend/src/baserow/core/user_files/managers.py
Normal 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)
|
80
backend/src/baserow/core/user_files/models.py
Normal file
80
backend/src/baserow/core/user_files/models.py
Normal 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]
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
217
backend/tests/baserow/api/user_files/test_user_files_views.py
Normal file
217
backend/tests/baserow/api/user_files/test_user_files_views.py
Normal 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()
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
306
backend/tests/baserow/core/user_file/test_user_file_handler.py
Normal file
306
backend/tests/baserow/core/user_file/test_user_file_handler.py
Normal 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'
|
|
@ -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
|
|
@ -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'
|
||||
}
|
5
backend/tests/fixtures/__init__.py
vendored
5
backend/tests/fixtures/__init__.py
vendored
|
@ -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()
|
||||
|
|
20
backend/tests/fixtures/field.py
vendored
20
backend/tests/fixtures/field.py
vendored
|
@ -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
35
backend/tests/fixtures/user_file.py
vendored
Normal 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)
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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%;
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 }}
|
||||
|
|
86
web-frontend/modules/core/directives/tooltip.js
Normal file
86
web-frontend/modules/core/directives/tooltip.js
Normal 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)
|
||||
},
|
||||
}
|
5
web-frontend/modules/core/filters/formatBytes.js
Normal file
5
web-frontend/modules/core/filters/formatBytes.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { formatBytes } from '@baserow/modules/core/utils/file'
|
||||
|
||||
export default function (bytes) {
|
||||
return formatBytes(bytes)
|
||||
}
|
73
web-frontend/modules/core/mixins/baseModal.js
Normal file
73
web-frontend/modules/core/mixins/baseModal.js
Normal 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()
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
20
web-frontend/modules/core/services/userFile.js
Normal file
20
web-frontend/modules/core/services/userFile.js
Normal 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 })
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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.')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
99
web-frontend/modules/core/userFileUploadTypes.js
Normal file
99
web-frontend/modules/core/userFileUploadTypes.js
Normal 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
|
||||
}
|
||||
}
|
18
web-frontend/modules/core/utils/file.js
Normal file
18
web-frontend/modules/core/utils/file.js
Normal 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]
|
||||
}
|
72
web-frontend/modules/core/utils/fontawesome.js
Normal file
72
web-frontend/modules/core/utils/fontawesome.js
Normal 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]
|
||||
}
|
||||
}
|
||||
}
|
47
web-frontend/modules/core/utils/image.js
Normal file
47
web-frontend/modules/core/utils/image.js
Normal 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
|
||||
})
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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()"
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
34
web-frontend/modules/database/mixins/fileField.js
Normal file
34
web-frontend/modules/database/mixins/fileField.js
Normal 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)
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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())
|
||||
|
|
|
@ -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',
|
||||
]
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue