diff --git a/backend/src/baserow/api/v0/__init__.py b/backend/src/baserow/api/v0/__init__.py index 0b51eadc4..50a568ebf 100644 --- a/backend/src/baserow/api/v0/__init__.py +++ b/backend/src/baserow/api/v0/__init__.py @@ -1 +1 @@ -app_name = 'baserow.api.v0' +default_app_config = 'baserow.api.v0.config.ApiConfig' diff --git a/backend/src/baserow/api/v0/applications/__init__.py b/backend/src/baserow/api/v0/applications/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/baserow/api/v0/applications/errors.py b/backend/src/baserow/api/v0/applications/errors.py new file mode 100644 index 000000000..a6d2b0e6d --- /dev/null +++ b/backend/src/baserow/api/v0/applications/errors.py @@ -0,0 +1 @@ +ERROR_USER_NOT_IN_GROUP = 'ERROR_USER_NOT_IN_GROUP' diff --git a/backend/src/baserow/api/v0/applications/serializers.py b/backend/src/baserow/api/v0/applications/serializers.py new file mode 100644 index 000000000..fe73d1b67 --- /dev/null +++ b/backend/src/baserow/api/v0/applications/serializers.py @@ -0,0 +1,35 @@ +from rest_framework import serializers + +from baserow.core.applications import registry +from baserow.core.models import Application + + +class ApplicationSerializer(serializers.ModelSerializer): + type = serializers.SerializerMethodField() + + class Meta: + model = Application + fields = ('id', 'name', 'order', 'type') + extra_kwargs = { + 'id': { + 'read_only': True + } + } + + def get_type(self, instance): + application = registry.get_by_model(instance.specific_class) + return application.type + + +class ApplicationCreateSerializer(serializers.ModelSerializer): + type = serializers.ChoiceField(choices=registry.get_types()) + + class Meta: + model = Application + fields = ('name', 'type') + + +class ApplicationUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = Application + fields = ('name',) diff --git a/backend/src/baserow/api/v0/applications/urls.py b/backend/src/baserow/api/v0/applications/urls.py new file mode 100644 index 000000000..bdb9f24b1 --- /dev/null +++ b/backend/src/baserow/api/v0/applications/urls.py @@ -0,0 +1,11 @@ +from django.conf.urls import url + +from .views import ApplicationsView, ApplicationView + + +app_name = 'baserow.api.v0.group' + +urlpatterns = [ + url(r'group/(?P<group_id>[0-9]+)/$', ApplicationsView.as_view(), name='list'), + url(r'(?P<application_id>[0-9]+)/$', ApplicationView.as_view(), name='item'), +] diff --git a/backend/src/baserow/api/v0/applications/views.py b/backend/src/baserow/api/v0/applications/views.py new file mode 100644 index 000000000..f5fd73311 --- /dev/null +++ b/backend/src/baserow/api/v0/applications/views.py @@ -0,0 +1,89 @@ +from django.shortcuts import get_object_or_404 +from django.db import transaction + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated + +from baserow.api.v0.decorators import validate_body, map_exceptions +from baserow.core.models import GroupUser, Application +from baserow.core.handler import CoreHandler +from baserow.core.exceptions import UserNotIngroupError + +from .serializers import ( + ApplicationSerializer, ApplicationCreateSerializer, ApplicationUpdateSerializer +) +from .errors import ERROR_USER_NOT_IN_GROUP + + +class ApplicationsView(APIView): + permission_classes = (IsAuthenticated,) + core_handler = CoreHandler() + + def load_group(self, request, group_id): + return get_object_or_404( + GroupUser.objects.select_related('group'), + group_id=group_id, + user=request.user + ) + + def get(self, request, group_id): + """ + Responds with a list of applications that belong to the group if the user has + access to that group. + """ + + group_user = self.load_group(request, group_id) + applications = Application.objects.filter( + group=group_user.group + ).select_related('content_type') + serializer = ApplicationSerializer(applications, many=True) + return Response(serializer.data) + + @transaction.atomic + @validate_body(ApplicationCreateSerializer) + def post(self, request, data, group_id): + """Creates a new application for a user.""" + + group_user = self.load_group(request, group_id) + application = self.core_handler.create_application( + request.user, group_user.group, data['type'], name=data['name']) + + return Response(ApplicationSerializer(application).data) + + +class ApplicationView(APIView): + permission_classes = (IsAuthenticated,) + core_handler = CoreHandler() + + @transaction.atomic + @validate_body(ApplicationUpdateSerializer) + @map_exceptions({ + UserNotIngroupError: ERROR_USER_NOT_IN_GROUP + }) + def patch(self, request, data, application_id): + """Updates the application if the user belongs to the group.""" + + application = get_object_or_404( + Application.objects.select_related('group').select_for_update(), + pk=application_id + ) + application = self.core_handler.update_application( + request.user, application, name=data['name']) + + return Response(ApplicationSerializer(application).data) + + @transaction.atomic + @map_exceptions({ + UserNotIngroupError: ERROR_USER_NOT_IN_GROUP + }) + def delete(self, request, application_id): + """Deletes an existing application if the user belongs to the group.""" + + application = get_object_or_404( + Application.objects.select_related('group'), + pk=application_id + ) + self.core_handler.delete_application(request.user, application) + + return Response(status=204) diff --git a/backend/src/baserow/api/v0/apps.py b/backend/src/baserow/api/v0/config.py similarity index 100% rename from backend/src/baserow/api/v0/apps.py rename to backend/src/baserow/api/v0/config.py diff --git a/backend/src/baserow/api/v0/decorators.py b/backend/src/baserow/api/v0/decorators.py index 4e3857594..d909decfd 100644 --- a/backend/src/baserow/api/v0/decorators.py +++ b/backend/src/baserow/api/v0/decorators.py @@ -1,9 +1,15 @@ +from collections import defaultdict + +from django.utils.encoding import force_text + from rest_framework import status from rest_framework.exceptions import APIException +from rest_framework.request import Request def map_exceptions(exceptions): - """This decorator easily maps specific exceptions to a standard api response. + """ + This decorator simplifies mapping specific exceptions to a standard api response. Example: @map_exceptions({ SomeException: 'ERROR_1' }) @@ -54,3 +60,70 @@ def map_exceptions(exceptions): raise exc return func_wrapper return map_exceptions_decorator + + +def validate_body(serializer_class): + """ + This decorator can validate the request body using a serializer. If the body is + valid it will add the data to the kwargs. If not it will raise an APIException with + structured details about what is wrong. + + Example: + class LoginSerializer(serializers.Serializer): + username = serializers.EmailField() + password = serializers.CharField() + + @validate_body(LoginSerializer) + def post(self, request): + raise SomeException('This is a test') + + HTTP/1.1 400 + { + "error": "ERROR_REQUEST_BODY_VALIDATION", + "detail": { + "username": [ + { + "error": "This field is required.", + "code": "required" + } + ] + } + } + + :param serializer_class: The serializer that must be used for validating. + :type serializer_class: Serializer + """ + + def validate_decorator(func): + def func_wrapper(*args, **kwargs): + # Check if the request + if len(args) < 2 or not isinstance(args[1], Request): + raise ValueError('There must be a request in the kwargs.') + + request = args[1] + serializer = serializer_class(data=request.data) + if not serializer.is_valid(): + # Create a serialized detail on why the validation failed. + detail = defaultdict(list) + for key, errors in serializer.errors.items(): + for error in errors: + detail[key].append({ + 'error': force_text(error), + 'code': error.code + }) + + exc = APIException({ + 'error': 'ERROR_REQUEST_BODY_VALIDATION', + 'detail': detail + }) + exc.status_code = 400 + raise exc + + # We do not want to override already existing data value in the kwargs. + if 'data' in kwargs: + raise ValueError('The data attribute is already in the kwargs.') + + kwargs['data'] = serializer.data + return func(*args, **kwargs) + return func_wrapper + return validate_decorator diff --git a/backend/src/baserow/api/v0/groups/views.py b/backend/src/baserow/api/v0/groups/views.py index 3167910ac..da5784191 100644 --- a/backend/src/baserow/api/v0/groups/views.py +++ b/backend/src/baserow/api/v0/groups/views.py @@ -5,6 +5,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated +from baserow.api.v0.decorators import validate_body from baserow.core.models import GroupUser from baserow.core.handler import CoreHandler @@ -17,19 +18,17 @@ class GroupsView(APIView): def get(self, request): """Responds with a list of groups where the users takes part in.""" + groups = GroupUser.objects.filter(user=request.user).select_related('group') serializer = GroupUserSerializer(groups, many=True) return Response(serializer.data) @transaction.atomic - def post(self, request): + @validate_body(GroupSerializer) + def post(self, request, data): """Creates a new group for a user.""" - serializer = GroupSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - data = serializer.data group_user = self.core_handler.create_group(request.user, name=data['name']) - return Response(GroupUserSerializer(group_user).data) @@ -38,14 +37,16 @@ class GroupView(APIView): core_handler = CoreHandler() @transaction.atomic - def patch(self, request, group_id): + @validate_body(GroupSerializer) + def patch(self, request, data, group_id): """Updates the group if it belongs to a user.""" - group_user = get_object_or_404(GroupUser, group_id=group_id, user=request.user) - serializer = GroupSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + group_user = get_object_or_404( + GroupUser.objects.select_for_update(), + group_id=group_id, + user=request.user + ) - data = serializer.data group_user.group = self.core_handler.update_group( request.user, group_user.group, name=data['name']) @@ -54,6 +55,7 @@ class GroupView(APIView): @transaction.atomic def delete(self, request, group_id): """Deletes an existing group if it belongs to a user.""" + group_user = get_object_or_404(GroupUser, group_id=group_id, user=request.user) self.core_handler.delete_group(request.user, group_user.group) return Response(status=204) @@ -63,11 +65,9 @@ class GroupOrderView(APIView): permission_classes = (IsAuthenticated,) core_handler = CoreHandler() - def post(self, request): + @validate_body(OrderGroupsSerializer) + def post(self, request, data): """Updates to order of some groups for a user.""" - serializer = OrderGroupsSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - self.core_handler.order_groups(request.user, serializer.data['groups']) + self.core_handler.order_groups(request.user, data['groups']) return Response(status=204) diff --git a/backend/src/baserow/api/v0/urls.py b/backend/src/baserow/api/v0/urls.py index e434d41b2..7f33d5da6 100644 --- a/backend/src/baserow/api/v0/urls.py +++ b/backend/src/baserow/api/v0/urls.py @@ -1,12 +1,16 @@ from django.urls import path, include +from baserow.core.applications import registry + from .user import urls as user_urls from .groups import urls as group_urls +from .applications import urls as application_urls app_name = 'baserow.api.v0' urlpatterns = [ path('user/', include(user_urls, namespace='user')), - path('groups/', include(group_urls, namespace='groups')) -] + path('groups/', include(group_urls, namespace='groups')), + path('applications/', include(application_urls, namespace='applications')) +] + registry.api_urls diff --git a/backend/src/baserow/api/v0/user/errors.py b/backend/src/baserow/api/v0/user/errors.py new file mode 100644 index 000000000..238292a17 --- /dev/null +++ b/backend/src/baserow/api/v0/user/errors.py @@ -0,0 +1 @@ +ERROR_ALREADY_EXISTS = 'ERROR_ALREADY_EXISTS' diff --git a/backend/src/baserow/api/v0/user/views.py b/backend/src/baserow/api/v0/user/views.py index 15a1d85f3..db72bdc9a 100644 --- a/backend/src/baserow/api/v0/user/views.py +++ b/backend/src/baserow/api/v0/user/views.py @@ -5,12 +5,12 @@ from rest_framework.response import Response from rest_framework.permissions import AllowAny from rest_framework_jwt.settings import api_settings -from baserow.api.v0.decorators import map_exceptions +from baserow.api.v0.decorators import map_exceptions, validate_body from baserow.user.handler import UserHandler from baserow.user.exceptions import UserAlreadyExist - from .serializers import RegisterSerializer, UserSerializer +from .errors import ERROR_ALREADY_EXISTS jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER @@ -23,13 +23,12 @@ class UserView(APIView): @transaction.atomic @map_exceptions({ - UserAlreadyExist: 'ERROR_ALREADY_EXISTS' + UserAlreadyExist: ERROR_ALREADY_EXISTS }) - def post(self, request): - serializer = RegisterSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + @validate_body(RegisterSerializer) + def post(self, request, data): + """Registers a new user.""" - data = serializer.data user = self.user_handler.create_user(name=data['name'], email=data['email'], password=data['password']) diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py index aae81bca5..ba7c57766 100644 --- a/backend/src/baserow/config/settings/base.py +++ b/backend/src/baserow/config/settings/base.py @@ -26,7 +26,8 @@ INSTALLED_APPS = [ 'corsheaders', 'baserow.core', - 'baserow.api.v0' + 'baserow.api.v0', + 'baserow.contrib.database' ] MIDDLEWARE = [ diff --git a/backend/src/baserow/contrib/__init__.py b/backend/src/baserow/contrib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/baserow/contrib/database/__init__.py b/backend/src/baserow/contrib/database/__init__.py new file mode 100644 index 000000000..f0b024840 --- /dev/null +++ b/backend/src/baserow/contrib/database/__init__.py @@ -0,0 +1 @@ +default_app_config = 'baserow.contrib.database.config.DatabaseConfig' diff --git a/backend/src/baserow/contrib/database/api_urls.py b/backend/src/baserow/contrib/database/api_urls.py new file mode 100644 index 000000000..916b115e0 --- /dev/null +++ b/backend/src/baserow/contrib/database/api_urls.py @@ -0,0 +1,5 @@ +app_name = 'baserow.contrib.database' + +urlpatterns = [ + +] diff --git a/backend/src/baserow/contrib/database/applications.py b/backend/src/baserow/contrib/database/applications.py new file mode 100644 index 000000000..327f4deae --- /dev/null +++ b/backend/src/baserow/contrib/database/applications.py @@ -0,0 +1,16 @@ +from django.urls import path, include + +from baserow.core.applications import Application + +from . import api_urls +from .models import Database + + +class DatabaseApplication(Application): + type = 'database' + instance_model = Database + + def get_api_urls(self): + return [ + path('database/', include(api_urls, namespace=self.type)), + ] diff --git a/backend/src/baserow/contrib/database/config.py b/backend/src/baserow/contrib/database/config.py new file mode 100644 index 000000000..4679e3159 --- /dev/null +++ b/backend/src/baserow/contrib/database/config.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + +from baserow.core.applications import registry + + +class DatabaseConfig(AppConfig): + name = 'baserow.contrib.database' + + def ready(self): + from .applications import DatabaseApplication + registry.register(DatabaseApplication()) diff --git a/backend/src/baserow/contrib/database/migrations/0001_initial.py b/backend/src/baserow/contrib/database/migrations/0001_initial.py new file mode 100644 index 000000000..1012e38fd --- /dev/null +++ b/backend/src/baserow/contrib/database/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 2.2.2 on 2019-09-13 12:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('core', '0002_application'), + ] + + operations = [ + migrations.CreateModel( + name='Database', + fields=[ + ('application_ptr', models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to='core.Application') + ), + ], + bases=('core.application',), + ), + migrations.CreateModel( + name='Table', + fields=[ + ('id', models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID') + ), + ('order', models.PositiveIntegerField()), + ('group', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='database.Database') + ), + ], + ), + ] diff --git a/backend/src/baserow/contrib/database/migrations/__init__.py b/backend/src/baserow/contrib/database/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/baserow/contrib/database/models.py b/backend/src/baserow/contrib/database/models.py new file mode 100644 index 000000000..344526c13 --- /dev/null +++ b/backend/src/baserow/contrib/database/models.py @@ -0,0 +1,12 @@ +from django.db import models + +from baserow.core.models import Application + + +class Database(Application): + pass + + +class Table(models.Model): + group = models.ForeignKey(Database, on_delete=models.CASCADE) + order = models.PositiveIntegerField() diff --git a/backend/src/baserow/core/__init__.py b/backend/src/baserow/core/__init__.py index 1a371c919..feef96834 100644 --- a/backend/src/baserow/core/__init__.py +++ b/backend/src/baserow/core/__init__.py @@ -1 +1 @@ -app_name = 'baserow.group' +default_app_config = 'baserow.core.config.CoreConfig' diff --git a/backend/src/baserow/core/applications.py b/backend/src/baserow/core/applications.py new file mode 100644 index 000000000..221689d42 --- /dev/null +++ b/backend/src/baserow/core/applications.py @@ -0,0 +1,175 @@ +from django.core.exceptions import ImproperlyConfigured + +from .exceptions import ApplicationAlreadyRegistered, ApplicationTypeDoesNotExist + + +class Application(object): + """ + This abstract class represents a custom application that can be added to the + application registry. It must be extended so customisation can be done. Each + application will have his own model that must extend the Application model, this is + needed so that the user can set custom settings per application instance he has + created. + + Example: + from baserow.core.models import Application as ApplicationModel + from baserow.core.applications import Application, registry + + class ExampleApplicationModel(ApplicationModel): + pass + + class ExampleApplication(Application): + type = 'a-unique-type-name' + instance_model = ExampleApplicationModel + + registry.register(ExampleApplication()) + + """ + + type = None + instance_model = None + + def __init__(self): + if not self.type: + raise ImproperlyConfigured('The type of an application must be set.') + + if not self.instance_model: + raise ImproperlyConfigured('The instance model of an application must be ' + 'set.') + + def get_api_urls(self): + """ + If needed custom api related urls to the application can be added here. + + Example: + + def get_api_urls(self): + from . import api_urls + + return [ + path('some-application/', include(api_urls, namespace=self.type)), + ] + + # api_urls.py + from django.conf.urls import url + + urlpatterns = [ + url(r'some-view^$', SomeView.as_view(), name='some_view'), + ] + + :return: A list containing the urls. + :rtype: list + """ + + return [] + + +class ApplicationRegistry(object): + """ + With the application registry it is possible to register new applications. An + application is an abstraction made specifically for Baserow. If added to the + registry a user can create new instances of that application via the ap and register + api related urls. + """ + + def __init__(self): + self.registry = {} + + def get(self, type): + """ + Returns a registered application their type name. + + :param type: The type name of the registered application. + :param type: str + :return: The requested application. + :rtype: Application + """ + + if type not in self.registry: + raise ApplicationTypeDoesNotExist(f'The application type {type} does not ' + f'exist.') + + return self.registry[type] + + def get_by_model(self, instance): + """Returns the application instance of a model or model instance. + + :param instance: The modal that must be the applications model_instance. + :type instance: Model or an instance of model. + :return: The registered application instance. + :rtype: Application + """ + + for value in self.registry.values(): + if value.instance_model == instance \ + or isinstance(instance, value.instance_model): + return value + + raise ApplicationTypeDoesNotExist(f'The application with model instance ' + f'{instance} does not exist. ') + + def get_types(self): + """ + Returns a list of available type names. + + :return: A list of available types. + :rtype: List + """ + + return list(self.registry.keys()) + + def register(self, application): + """ + Registers a new application in the registry. + + :param application: The application that needs to be registered. + :type application: + """ + + if not isinstance(application, Application): + raise ValueError('The application must be an instance of Application.') + + if application.type in self.registry: + raise ApplicationAlreadyRegistered( + f'The application with type {application.type} is already registered.') + + self.registry[application.type] = application + + def unregister(self, value): + """ + Removes a registered application from the registry. An application instance or + type name can be provided as value. + + :param value: The application instance or type name. + :type value: Application or str + """ + + if isinstance(value, Application): + for type, application in self.registry.items(): + if application == value: + value = type + + if isinstance(value, str): + del self.registry[value] + else: + raise ValueError('The value must either be an application instance or type ' + 'name') + + @property + def api_urls(self): + """ + Returns a list of all the api urls that are in the registered applications. + + :return: The api urls of the registered applications. + :rtype: list + """ + + api_urls = [] + for application in self.registry.values(): + api_urls += application.get_api_urls() + return api_urls + + +# A default application is created here, this is the one that is used throughout the +# whole Baserow application. To add a new application use this registry. +registry = ApplicationRegistry() diff --git a/backend/src/baserow/core/apps.py b/backend/src/baserow/core/config.py similarity index 68% rename from backend/src/baserow/core/apps.py rename to backend/src/baserow/core/config.py index b2ef0f46f..7e99c3758 100644 --- a/backend/src/baserow/core/apps.py +++ b/backend/src/baserow/core/config.py @@ -1,5 +1,5 @@ from django.apps import AppConfig -class ApiConfig(AppConfig): +class CoreConfig(AppConfig): name = 'baserow.core' diff --git a/backend/src/baserow/core/exceptions.py b/backend/src/baserow/core/exceptions.py index 6a8d1ffa0..1b585adf8 100644 --- a/backend/src/baserow/core/exceptions.py +++ b/backend/src/baserow/core/exceptions.py @@ -1,2 +1,10 @@ class UserNotIngroupError(Exception): pass + + +class ApplicationAlreadyRegistered(Exception): + pass + + +class ApplicationTypeDoesNotExist(Exception): + pass diff --git a/backend/src/baserow/core/handler.py b/backend/src/baserow/core/handler.py index cb8071b98..7e3b853a2 100644 --- a/backend/src/baserow/core/handler.py +++ b/backend/src/baserow/core/handler.py @@ -1,10 +1,13 @@ -from .models import Group, GroupUser +from .models import Group, GroupUser, Application from .exceptions import UserNotIngroupError +from .utils import extract_allowed, set_allowed_attrs +from .applications import registry class CoreHandler: def create_group(self, user, **kwargs): - """Creates a new group for an existing user. + """ + Creates a new group for an existing user. :param user: The user that must be in the group. :type user: User @@ -12,13 +15,7 @@ class CoreHandler: :rtype: GroupUser """ - allowed_fields = ['name'] - - group_values = {} - for field in allowed_fields: - if field in allowed_fields: - group_values[field] = kwargs[field] - + group_values = extract_allowed(kwargs, ['name']) group = Group.objects.create(**group_values) last_order = GroupUser.get_last_order(user) group_user = GroupUser.objects.create(group=group, user=user, order=last_order) @@ -26,36 +23,41 @@ class CoreHandler: return group_user def update_group(self, user, group, **kwargs): - """Updates fields of a group. - - :param user: - :param group: - :return: """ + Updates the values of a group. + + :param user: The user on whose behalf the change is made. + :type user: User + :param group: The group instance that must be updated. + :type group: Group + :return: The updated group + :rtype: Group + """ + + if not isinstance(group, Group): + raise ValueError('The group is not an instance of Group.') if not group.has_user(user): raise UserNotIngroupError(f'The user {user} does not belong to the group ' f'{group}.') - allowed_fields = ['name'] - - for field in allowed_fields: - if field in kwargs: - setattr(group, field, kwargs[field]) - + group = set_allowed_attrs(kwargs, ['name'], group) group.save() return group def delete_group(self, user, group): - """Deletes an existing group. - - :param user: - :type: user: User - :param group: - :type: group: Group - :return: """ + Deletes an existing group. + + :param user: The user on whose behalf the delete is done. + :type: user: User + :param group: The group instance that must be deleted. + :type: group: Group + """ + + if not isinstance(group, Group): + raise ValueError('The group is not an instance of Group.') if not group.has_user(user): raise UserNotIngroupError(f'The user {user} does not belong to the group ' @@ -64,9 +66,10 @@ class CoreHandler: group.delete() def order_groups(self, user, group_ids): - """Changes the order of groups for a user. + """ + Changes the order of groups for a user. - :param user: + :param user: The user on whose behalf the ordering is done. :type: user: User :param group_ids: A list of group ids ordered the way they need to be ordered. :type group_ids: List[int] @@ -77,3 +80,81 @@ class CoreHandler: user=user, group_id=group_id ).update(order=index + 1) + + def create_application(self, user, group, type, **kwargs): + """ + Creates a new application based on the provided type. + + :param user: The user on whose behalf the application is created. + :type user: User + :param group: The group that the application instance belongs to. + :type group: Group + :param type: The type name of the application. Application can be registered via + the ApplicationRegistry. + :type type: str + :param kwargs: The fields that need to be set upon creation. + :type kwargs: object + :return: The created application instance. + :rtype: Application + """ + + if not group.has_user(user): + raise UserNotIngroupError(f'The user {user} does not belong to the group ' + f'{group}.') + + # Figure out which model is used for the given application type. + application = registry.get(type) + model = application.instance_model + application_values = extract_allowed(kwargs, ['name']) + + if 'order' not in application_values: + application_values['order'] = model.get_last_order(group) + + instance = model.objects.create(group=group, **application_values) + + return instance + + def update_application(self, user, application, **kwargs): + """ + Updates an existing application instance. + + :param user: The user on whose behalf the application is updated. + :type user: User + :param application: The application instance that needs to be updated. + :type application: Application + :param kwargs: The fields that need to be updated. + :type kwargs: object + :return: The updated application instance. + :rtype: Application + """ + + if not isinstance(application, Application): + raise ValueError('The application is not an instance of Application') + + if not application.group.has_user(user): + raise UserNotIngroupError(f'The user {user} does not belong to the group ' + f'{application.group}.') + + application = set_allowed_attrs(kwargs, ['name'], application) + application.save() + + return application + + def delete_application(self, user, application): + """ + Deletes an existing application instance. + + :param user: The user on whose behalf the application is deleted. + :type user: User + :param application: The application instance that needs to be deleted. + :type application: Application + """ + + if not isinstance(application, Application): + raise ValueError('The application is not an instance of Application') + + if not application.group.has_user(user): + raise UserNotIngroupError(f'The user {user} does not belong to the group ' + f'{application.group}.') + + application.delete() diff --git a/backend/src/baserow/core/migrations/0002_application.py b/backend/src/baserow/core/migrations/0002_application.py new file mode 100644 index 000000000..5661ba577 --- /dev/null +++ b/backend/src/baserow/core/migrations/0002_application.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.2 on 2019-09-13 12:08 + +import baserow.core.mixins +import baserow.core.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Application', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, + serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('order', models.PositiveIntegerField()), + ('content_type', models.ForeignKey( + on_delete=models.SET( + baserow.core.models.get_default_application_content_type), + related_name='applications', to='contenttypes.ContentType', + verbose_name='content type') + ), + ('group', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='core.Group')), + ], + options={ + 'ordering': ('order',), + }, + bases=(baserow.core.mixins.OrderableMixin, models.Model), + ), + ] diff --git a/backend/src/baserow/core/mixins.py b/backend/src/baserow/core/mixins.py new file mode 100644 index 000000000..26926daf1 --- /dev/null +++ b/backend/src/baserow/core/mixins.py @@ -0,0 +1,20 @@ +from django.db import models + + +class OrderableMixin: + """ + This mixin introduces a set of helpers of the model is orderable by a field. + """ + + @classmethod + def get_highest_order_of_queryset(cls, queryset, field='order'): + """ + + :param queryset: + :param field: + :return: + """ + + return queryset.aggregate( + models.Max(field) + ).get(f'{field}__max', 0) or 0 diff --git a/backend/src/baserow/core/models.py b/backend/src/baserow/core/models.py index b4e833b4c..7d0299c20 100644 --- a/backend/src/baserow/core/models.py +++ b/backend/src/baserow/core/models.py @@ -1,12 +1,19 @@ from django.db import models from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.utils.functional import cached_property from .managers import GroupQuerySet +from .mixins import OrderableMixin User = get_user_model() +def get_default_application_content_type(): + return ContentType.objects.get_for_model(Application) + + class Group(models.Model): name = models.CharField(max_length=100) users = models.ManyToManyField(User, through='GroupUser') @@ -15,13 +22,14 @@ class Group(models.Model): def has_user(self, user): """Returns true is the user belongs to the group.""" + return self.users.filter(id=user.id).exists() def __str__(self): return f'<Group id={self.id}, name={self.name}>' -class GroupUser(models.Model): +class GroupUser(OrderableMixin, models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) group = models.ForeignKey(Group, on_delete=models.CASCADE) order = models.PositiveIntegerField() @@ -31,11 +39,54 @@ class GroupUser(models.Model): @classmethod def get_last_order(cls, user): - """Returns a new position that will be last for a new group.""" - highest_order = cls.objects.filter( - user=user - ).aggregate( - models.Max('order') - ).get('order__max', 0) or 0 + return cls.get_highest_order_of_queryset(cls.objects.filter(user=user)) + 1 - return highest_order + 1 + +class Application(OrderableMixin, models.Model): + group = models.ForeignKey(Group, on_delete=models.CASCADE) + name = models.CharField(max_length=50) + order = models.PositiveIntegerField() + content_type = models.ForeignKey( + ContentType, + verbose_name='content type', + related_name='applications', + on_delete=models.SET(get_default_application_content_type) + ) + + class Meta: + ordering = ('order',) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if not self.id: + if not self.content_type_id: + self.content_type = ContentType.objects.get_for_model(self) + + @cached_property + def specific(self): + """Return this page in its most specific subclassed form.""" + + content_type = ContentType.objects.get_for_id(self.content_type_id) + model_class = self.specific_class + if model_class is None: + return self + elif isinstance(self, model_class): + return self + else: + return content_type.get_object_for_this_type(id=self.id) + + @cached_property + def specific_class(self): + """ + Return the class that this application would be if instantiated in its + most specific form + """ + + content_type = ContentType.objects.get_for_id(self.content_type_id) + return content_type.model_class() + + @classmethod + def get_last_order(cls, group): + return cls.get_highest_order_of_queryset( + Application.objects.filter(group=group)) + 1 diff --git a/backend/src/baserow/core/utils.py b/backend/src/baserow/core/utils.py new file mode 100644 index 000000000..0ad6022cd --- /dev/null +++ b/backend/src/baserow/core/utils.py @@ -0,0 +1,67 @@ +def extract_allowed(values, allowed_fields): + """ + Returns a new dict with the values of the key names that are in the allowed_fields. + The other keys are ignored. + + Example: + object_1 = { + 'value_1': 'value', + 'value_2': 'value' + } + + extract_allowed(object_1, ['value_1']) + >> {'value_1': 'value'} + + :param values: A dict containing the values. + :type dict: + :param allowed_fields: A list containing the keys of the values that need to be + extracted from the values. + :type allowed_fields: list + :return: The extracted values. + :rtype: dict + """ + + allowed_values = {} + for field in allowed_fields: + if field in values: + allowed_values[field] = values[field] + + return allowed_values + + +def set_allowed_attrs(values, allowed_fields, instance): + """ + Sets the attributes of the instance with the values of the key names that are in the + allowed_fields. The other keys are ignored. + + Examples: + class Tmp(object): + value_1 = 'value' + value_2 = 'value' + + object_1 = { + 'value_1': 'value_2', + 'value_2': 'value_2' + } + + tmp = set_allowed_attrs(object_1, ['value_1'], Tmp()) + tmp.value_1 + >> 'value_2' + tmp.value_2 + >> 'value' + + :param values: The dict containing the values. + :type values: dict + :param allowed_fields: A list containing the keys of the value that need to be set + on the instance. + :type allowed_fields: list + :param instance: The instance of which the attributes must be updated. + :type instance: object + :return: The updated instance. + """ + + for field in allowed_fields: + if field in values: + setattr(instance, field, values[field]) + + return instance diff --git a/backend/tests/baserow/api/v0/applications/test_application_views.py b/backend/tests/baserow/api/v0/applications/test_application_views.py new file mode 100644 index 000000000..7f0a8272d --- /dev/null +++ b/backend/tests/baserow/api/v0/applications/test_application_views.py @@ -0,0 +1,165 @@ +import pytest + +from django.shortcuts import reverse + +from baserow.contrib.database.models import Database + + +@pytest.mark.django_db +def test_list_applications(api_client, data_fixture): + user, token = data_fixture.create_user_and_token( + email='test@test.nl', password='password', first_name='Test1') + group_1 = data_fixture.create_group(user=user) + group_2 = data_fixture.create_group() + application_1 = data_fixture.create_database_application(group=group_1, order=1) + application_2 = data_fixture.create_database_application(group=group_1, order=3) + application_3 = data_fixture.create_database_application(group=group_1, order=2) + data_fixture.create_database_application(group=group_2, order=1) + + response = api_client.get( + reverse('api_v0:applications:list', kwargs={'group_id': group_1.id}), **{ + 'HTTP_AUTHORIZATION': f'JWT {token}' + } + ) + assert response.status_code == 200 + response_json = response.json() + + assert len(response_json) == 3 + + assert response_json[0]['id'] == application_1.id + assert response_json[0]['type'] == 'database' + + assert response_json[1]['id'] == application_3.id + assert response_json[1]['type'] == 'database' + + assert response_json[2]['id'] == application_2.id + assert response_json[2]['type'] == 'database' + + response = api_client.get( + reverse('api_v0:applications:list', kwargs={'group_id': group_2.id}), **{ + 'HTTP_AUTHORIZATION': f'JWT {token}' + } + ) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_create_application(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_2, token_2 = data_fixture.create_user_and_token() + group = data_fixture.create_group(user=user) + group_2 = data_fixture.create_group(user=user_2) + + response = api_client.post( + reverse('api_v0:applications:list', kwargs={'group_id': group.id}), + { + 'name': 'Test 1', + 'type': 'NOT_EXISTING' + }, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + response_json = response.json() + assert response.status_code == 400 + assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION' + assert response_json['detail']['type'][0]['code'] == 'invalid_choice' + + response = api_client.post( + reverse('api_v0:applications:list', kwargs={'group_id': group_2.id}), + { + 'name': 'Test 1', + 'type': 'database' + }, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + assert response.status_code == 404 + + response = api_client.post( + reverse('api_v0:applications:list', kwargs={'group_id': group.id}), + { + 'name': 'Test 1', + 'type': 'database' + }, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + response_json = response.json() + assert response.status_code == 200 + assert response_json['type'] == 'database' + + database = Database.objects.filter()[0] + assert response_json['id'] == database.id + assert response_json['name'] == database.name + assert response_json['order'] == database.order + + +@pytest.mark.django_db +def test_update_application(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_2, token_2 = data_fixture.create_user_and_token() + group = data_fixture.create_group(user=user) + group_2 = data_fixture.create_group(user=user_2) + application = data_fixture.create_database_application(group=group) + application_2 = data_fixture.create_database_application(group=group_2) + + url = reverse('api_v0:applications:item', + kwargs={'application_id': application_2.id}) + response = api_client.patch( + url, + {'name': 'Test 1'}, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + response_json = response.json() + assert response.status_code == 400 + assert response_json['error'] == 'ERROR_USER_NOT_IN_GROUP' + + url = reverse('api_v0:applications:item', kwargs={'application_id': application.id}) + response = api_client.patch( + url, + {'UNKNOWN_FIELD': 'Test 1'}, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + response_json = response.json() + assert response.status_code == 400 + assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION' + + url = reverse('api_v0:applications:item', kwargs={'application_id': application.id}) + response = api_client.patch( + url, + {'name': 'Test 1'}, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + response_json = response.json() + assert response.status_code == 200 + assert response_json['id'] == application.id + assert response_json['name'] == 'Test 1' + + application.refresh_from_db() + assert application.name == 'Test 1' + + +@pytest.mark.django_db +def test_delete_application(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user_2, token_2 = data_fixture.create_user_and_token() + group = data_fixture.create_group(user=user) + group_2 = data_fixture.create_group(user=user_2) + application = data_fixture.create_database_application(group=group) + application_2 = data_fixture.create_database_application(group=group_2) + + url = reverse('api_v0:applications:item', + kwargs={'application_id': application_2.id}) + response = api_client.delete(url, HTTP_AUTHORIZATION=f'JWT {token}') + response_json = response.json() + assert response.status_code == 400 + assert response_json['error'] == 'ERROR_USER_NOT_IN_GROUP' + + url = reverse('api_v0:applications:item', kwargs={'application_id': application.id}) + response = api_client.delete(url, HTTP_AUTHORIZATION=f'JWT {token}') + assert response.status_code == 204 + + assert Database.objects.all().count() == 1 diff --git a/backend/tests/baserow/api/v0/group/test_group_views.py b/backend/tests/baserow/api/v0/groups/test_group_views.py similarity index 93% rename from backend/tests/baserow/api/v0/group/test_group_views.py rename to backend/tests/baserow/api/v0/groups/test_group_views.py index a5aeb1af8..e015d1759 100644 --- a/backend/tests/baserow/api/v0/group/test_group_views.py +++ b/backend/tests/baserow/api/v0/groups/test_group_views.py @@ -9,17 +9,17 @@ from baserow.core.models import Group, GroupUser def test_list_groups(api_client, data_fixture): user, token = data_fixture.create_user_and_token( email='test@test.nl', password='password', first_name='Test1') - group_2 = data_fixture.create_user_group(user=user, order=2) - group_1 = data_fixture.create_user_group(user=user, order=1) + user_group_2 = data_fixture.create_user_group(user=user, order=2) + user_group_1 = data_fixture.create_user_group(user=user, order=1) response = api_client.get(reverse('api_v0:groups:list'), **{ 'HTTP_AUTHORIZATION': f'JWT {token}' }) assert response.status_code == 200 response_json = response.json() - assert response_json[0]['id'] == group_1.id + assert response_json[0]['id'] == user_group_1.group.id assert response_json[0]['order'] == 1 - assert response_json[1]['id'] == group_2.id + assert response_json[1]['id'] == user_group_2.group.id assert response_json[1]['order'] == 2 diff --git a/backend/tests/baserow/api/v0/test_api_decorators.py b/backend/tests/baserow/api/v0/test_api_decorators.py new file mode 100644 index 000000000..b83533cf4 --- /dev/null +++ b/backend/tests/baserow/api/v0/test_api_decorators.py @@ -0,0 +1,106 @@ +import pytest +import json + +from unittest.mock import MagicMock + +from django.http.request import HttpRequest + +from rest_framework import status, serializers +from rest_framework.request import Request +from rest_framework.parsers import JSONParser +from rest_framework.exceptions import APIException +from rest_framework.test import APIRequestFactory + +from baserow.api.v0.decorators import map_exceptions, validate_body + + +class TemporaryException(Exception): + pass + + +class TemporarySerializer(serializers.Serializer): + field_1 = serializers.CharField() + field_2 = serializers.ChoiceField(choices=('choice_1', 'choice_2')) + + +def test_map_exceptions(): + @map_exceptions({ + TemporaryException: 'ERROR_TEMPORARY' + }) + def test_1(): + raise TemporaryException + + with pytest.raises(APIException) as api_exception_1: + test_1() + + assert api_exception_1.value.detail['error'] == 'ERROR_TEMPORARY' + assert api_exception_1.value.detail['detail'] == '' + assert api_exception_1.value.status_code == status.HTTP_400_BAD_REQUEST + + @map_exceptions({ + TemporaryException: ('ERROR_TEMPORARY_2', 404, 'Another message') + }) + def test_2(): + raise TemporaryException + + with pytest.raises(APIException) as api_exception_2: + test_2() + + assert api_exception_2.value.detail['error'] == 'ERROR_TEMPORARY_2' + assert api_exception_2.value.detail['detail'] == 'Another message' + assert api_exception_2.value.status_code == status.HTTP_404_NOT_FOUND + + @map_exceptions({ + TemporaryException: 'ERROR_TEMPORARY_3' + }) + def test_3(): + pass + + test_3() + + +def test_validate_body(): + factory = APIRequestFactory() + + request = Request(factory.post( + '/some-page/', + data=json.dumps({'field_1': 'test'}), + content_type='application/json' + ), parsers=[JSONParser()]) + func = MagicMock() + + with pytest.raises(APIException) as api_exception_1: + validate_body(TemporarySerializer)(func)(*[object, request]) + + assert api_exception_1.value.detail['error'] == 'ERROR_REQUEST_BODY_VALIDATION' + assert api_exception_1.value.detail['detail']['field_2'][0]['error'] == \ + 'This field is required.' + assert api_exception_1.value.detail['detail']['field_2'][0]['code'] == 'required' + assert api_exception_1.value.status_code == status.HTTP_400_BAD_REQUEST + + request = Request(factory.post( + '/some-page/', + data=json.dumps({'field_1': 'test', 'field_2': 'wrong'}), + content_type='application/json' + ), parsers=[JSONParser()]) + func = MagicMock() + + with pytest.raises(APIException) as api_exception_1: + validate_body(TemporarySerializer)(func)(*[object, request]) + + assert api_exception_1.value.detail['error'] == 'ERROR_REQUEST_BODY_VALIDATION' + assert api_exception_1.value.detail['detail']['field_2'][0]['error'] == \ + '"wrong" is not a valid choice.' + assert api_exception_1.value.detail['detail']['field_2'][0]['code'] == \ + 'invalid_choice' + assert api_exception_1.value.status_code == status.HTTP_400_BAD_REQUEST + + request = Request(factory.post( + '/some-page/', + data=json.dumps({'field_1': 'test', 'field_2': 'choice_1'}), + content_type='application/json' + ), parsers=[JSONParser()]) + func = MagicMock() + + validate_body(TemporarySerializer)(func)(*[object, request]) + diff --git a/backend/tests/baserow/api/v0/user/test_token_auth.py b/backend/tests/baserow/api/v0/users/test_token_auth.py similarity index 100% rename from backend/tests/baserow/api/v0/user/test_token_auth.py rename to backend/tests/baserow/api/v0/users/test_token_auth.py diff --git a/backend/tests/baserow/api/v0/user/test_user_views.py b/backend/tests/baserow/api/v0/users/test_user_views.py similarity index 100% rename from backend/tests/baserow/api/v0/user/test_user_views.py rename to backend/tests/baserow/api/v0/users/test_user_views.py diff --git a/backend/tests/baserow/core/test_core_applications.py b/backend/tests/baserow/core/test_core_applications.py new file mode 100644 index 000000000..72cdc8073 --- /dev/null +++ b/backend/tests/baserow/core/test_core_applications.py @@ -0,0 +1,88 @@ +import pytest + +from baserow.core.applications import Application, ApplicationRegistry +from baserow.core.exceptions import ( + ApplicationAlreadyRegistered, ApplicationTypeDoesNotExist +) + + +class FakeModel(object): + pass + + +class FakeModel2(object): + pass + + +class TemporaryApplication1(Application): + type = 'temporary_1' + instance_model = FakeModel + + def get_api_urls(self): + return ['url_1', 'url_2'] + + +class TemporaryApplication2(Application): + type = 'temporary_2' + instance_model = FakeModel2 + + def get_api_urls(self): + return ['url_3'] + + +def test_application_registry_register(): + temporary_1 = TemporaryApplication1() + temporary_2 = TemporaryApplication2() + + registry = ApplicationRegistry() + registry.register(temporary_1) + registry.register(temporary_2) + + with pytest.raises(ValueError): + registry.register('NOT AN APPLICATION') + + with pytest.raises(ApplicationAlreadyRegistered): + registry.register(temporary_1) + + assert len(registry.registry.items()) == 2 + assert registry.registry['temporary_1'] == temporary_1 + assert registry.registry['temporary_2'] == temporary_2 + + registry.unregister(temporary_1) + + assert len(registry.registry.items()) == 1 + + registry.unregister('temporary_2') + + assert len(registry.registry.items()) == 0 + + with pytest.raises(ValueError): + registry.unregister(000) + + +def test_application_registry_get(): + temporary_1 = TemporaryApplication1() + registry = ApplicationRegistry() + registry.register(temporary_1) + + assert registry.get('temporary_1') == temporary_1 + with pytest.raises(ApplicationTypeDoesNotExist): + registry.get('something') + + assert registry.get_by_model(FakeModel) == temporary_1 + assert registry.get_by_model(FakeModel()) == temporary_1 + with pytest.raises(ApplicationTypeDoesNotExist): + registry.get_by_model(FakeModel2) + with pytest.raises(ApplicationTypeDoesNotExist): + registry.get_by_model(FakeModel2()) + + +def test_application_get_api_urls(): + temporary_1 = TemporaryApplication1() + temporary_2 = TemporaryApplication2() + + registry = ApplicationRegistry() + registry.register(temporary_1) + registry.register(temporary_2) + + assert registry.api_urls == ['url_1', 'url_2', 'url_3'] diff --git a/backend/tests/baserow/core/test_core_handler.py b/backend/tests/baserow/core/test_core_handler.py index 8d69d57cc..99408d962 100644 --- a/backend/tests/baserow/core/test_core_handler.py +++ b/backend/tests/baserow/core/test_core_handler.py @@ -1,8 +1,9 @@ import pytest from baserow.core.handler import CoreHandler -from baserow.core.models import Group, GroupUser -from baserow.core.exceptions import UserNotIngroupError +from baserow.core.models import Group, GroupUser, Application +from baserow.core.exceptions import UserNotIngroupError, ApplicationTypeDoesNotExist +from baserow.contrib.database.models import Database @pytest.mark.django_db @@ -42,6 +43,9 @@ def test_update_group(data_fixture): with pytest.raises(UserNotIngroupError): handler.update_group(user=user_2, group=group, name='New name') + with pytest.raises(ValueError): + handler.update_group(user=user_2, group=object(), name='New name') + @pytest.mark.django_db def test_delete_group(data_fixture): @@ -66,6 +70,9 @@ def test_delete_group(data_fixture): assert Group.objects.all().count() == 1 assert GroupUser.objects.all().count() == 1 + with pytest.raises(ValueError): + handler.delete_group(user=user_2, group=object()) + @pytest.mark.django_db def test_order_groups(data_fixture): @@ -77,7 +84,7 @@ def test_order_groups(data_fixture): assert [1, 2, 3] == [ug_1.order, ug_2.order, ug_3.order] handler = CoreHandler() - handler.order_groups(user, [ug_3.id, ug_2.id, ug_1.id]) + handler.order_groups(user, [ug_3.group.id, ug_2.group.id, ug_1.group.id]) ug_1.refresh_from_db() ug_2.refresh_from_db() @@ -85,10 +92,76 @@ def test_order_groups(data_fixture): assert [1, 2, 3] == [ug_3.order, ug_2.order, ug_1.order] - handler.order_groups(user, [ug_2.id, ug_1.id, ug_3.id]) + handler.order_groups(user, [ug_2.group.id, ug_1.group.id, ug_3.group.id]) ug_1.refresh_from_db() ug_2.refresh_from_db() ug_3.refresh_from_db() assert [1, 2, 3] == [ug_2.order, ug_1.order, ug_3.order] + + +@pytest.mark.django_db +def test_create_database_application(data_fixture): + user = data_fixture.create_user() + user_2 = data_fixture.create_user() + group = data_fixture.create_group(user=user) + + handler = CoreHandler() + handler.create_application(user=user, group=group, type='database', + name='Test database') + + assert Application.objects.all().count() == 1 + assert Database.objects.all().count() == 1 + + database = Database.objects.all().first() + assert database.name == 'Test database' + assert database.order == 1 + assert database.group == group + + with pytest.raises(UserNotIngroupError): + handler.create_application(user=user_2, group=group, type='database', name='') + + with pytest.raises(ApplicationTypeDoesNotExist): + handler.create_application(user=user, group=group, type='UNKNOWN', name='') + + +@pytest.mark.django_db +def test_update_database_application(data_fixture): + user = data_fixture.create_user() + user_2 = data_fixture.create_user() + group = data_fixture.create_group(user=user) + database = data_fixture.create_database_application(group=group) + + handler = CoreHandler() + + with pytest.raises(UserNotIngroupError): + handler.update_application(user=user_2, application=database, name='Test 1') + + with pytest.raises(ValueError): + handler.update_application(user=user_2, application=object(), name='Test 1') + + handler.update_application(user=user, application=database, name='Test 1') + + database.refresh_from_db() + + assert database.name == 'Test 1' + +@pytest.mark.django_db +def test_delete_database_application(data_fixture): + user = data_fixture.create_user() + user_2 = data_fixture.create_user() + group = data_fixture.create_group(user=user) + database = data_fixture.create_database_application(group=group) + + handler = CoreHandler() + + with pytest.raises(UserNotIngroupError): + handler.delete_application(user=user_2, application=database) + + with pytest.raises(ValueError): + handler.delete_application(user=user_2, application=object()) + + assert Database.objects.all().count() == 1 + handler.delete_application(user=user, application=database) + assert Database.objects.all().count() == 0 diff --git a/backend/tests/baserow/core/test_core_managers.py b/backend/tests/baserow/core/test_core_managers.py index aeea1da2d..a5048297b 100644 --- a/backend/tests/baserow/core/test_core_managers.py +++ b/backend/tests/baserow/core/test_core_managers.py @@ -6,19 +6,20 @@ from baserow.core.models import Group @pytest.mark.django_db def test_groups_of_user(data_fixture): user_1 = data_fixture.create_user() - group_user_1 = data_fixture.create_user_group(user=user_1, order=1) - group_user_2 = data_fixture.create_user_group(user=user_1, order=2) - group_user_3 = data_fixture.create_user_group(user=user_1, order=0) + user_group_1 = data_fixture.create_user_group(user=user_1, order=1) + user_group_2 = data_fixture.create_user_group(user=user_1, order=2) + user_group_3 = data_fixture.create_user_group(user=user_1, order=0) user_2 = data_fixture.create_user() - group_user_4 = data_fixture.create_user_group(user=user_2, order=0) + user_group_4 = data_fixture.create_user_group(user=user_2, order=0) groups_user_1 = Group.objects.of_user(user=user_1) assert len(groups_user_1) == 3 - assert groups_user_1[0].id == group_user_3.id - assert groups_user_1[1].id == group_user_1.id - assert groups_user_1[2].id == group_user_2.id + + assert groups_user_1[0].id == user_group_3.group.id + assert groups_user_1[1].id == user_group_1.group.id + assert groups_user_1[2].id == user_group_2.group.id groups_user_2 = Group.objects.of_user(user=user_2) assert len(groups_user_2) == 1 - assert groups_user_2[0].id == group_user_4.id + assert groups_user_2[0].id == user_group_4.group.id diff --git a/backend/tests/baserow/core/test_core_models.py b/backend/tests/baserow/core/test_core_models.py index 4abcf464d..f40e152db 100644 --- a/backend/tests/baserow/core/test_core_models.py +++ b/backend/tests/baserow/core/test_core_models.py @@ -1,6 +1,7 @@ import pytest from baserow.core.models import GroupUser +from baserow.contrib.database.models import Database @pytest.mark.django_db @@ -24,3 +25,12 @@ def test_group_has_user(data_fixture): assert user_group.group.has_user(user_group.user) assert not user_group.group.has_user(user) + + +@pytest.mark.django_db +def test_application_content_type_init(data_fixture): + group = data_fixture.create_group() + database = Database.objects.create(name='Test 1', order=0, group=group) + + assert database.content_type.app_label == 'database' + assert database.content_type.model == 'database' diff --git a/backend/tests/baserow/core/test_core_utils.py b/backend/tests/baserow/core/test_core_utils.py new file mode 100644 index 000000000..1728b15c5 --- /dev/null +++ b/backend/tests/baserow/core/test_core_utils.py @@ -0,0 +1,30 @@ +from baserow.core.utils import extract_allowed, set_allowed_attrs + + +def test_extract_allowed(): + assert extract_allowed({ + 'test_1': 'test_1', + 'test_2': 'test_2' + }, ['test_1']) == { + 'test_1': 'test_1' + } + + assert extract_allowed({}, ['test_1']) == {} + assert extract_allowed({'test_1': 'test'}, ['test_2']) == {} + assert extract_allowed({'test_1': 'test'}, []) == {} + + +def test_set_allowed_attrs(): + class Tmp(object): + test_1 = None + test_2 = None + + tmp1 = Tmp() + tmp1 = set_allowed_attrs( + {'test_1': 'test', 'test_2': 'test'}, + ['test_1'], + tmp1 + ) + + assert tmp1.test_1 == 'test' + assert tmp1.test_2 is None diff --git a/backend/tests/fixtures/__init__.py b/backend/tests/fixtures/__init__.py index 13a51aaef..a336658ad 100644 --- a/backend/tests/fixtures/__init__.py +++ b/backend/tests/fixtures/__init__.py @@ -2,7 +2,8 @@ from faker import Faker from .user import UserFixtures from .group import GroupFixtures +from .application import ApplicationFixtures -class Fixtures(UserFixtures, GroupFixtures): +class Fixtures(UserFixtures, GroupFixtures, ApplicationFixtures): fake = Faker() diff --git a/backend/tests/fixtures/application.py b/backend/tests/fixtures/application.py new file mode 100644 index 000000000..142382d62 --- /dev/null +++ b/backend/tests/fixtures/application.py @@ -0,0 +1,15 @@ +from baserow.contrib.database.models import Database + + +class ApplicationFixtures: + def create_database_application(self, **kwargs): + if 'group' not in kwargs: + kwargs['group'] = self.create_group() + + if 'name' not in kwargs: + kwargs['name'] = self.fake.name() + + if 'order' not in kwargs: + kwargs['order'] = 0 + + return Database.objects.create(**kwargs)