diff --git a/api/config/settings/common.py b/api/config/settings/common.py index a836dfdfddb096384bfff92855a34cf478f0aaa6..b74c2bdfe499af75053748ddb8d90b96b8b42760 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -461,3 +461,7 @@ MUSIC_DIRECTORY_PATH = env("MUSIC_DIRECTORY_PATH", default=None) MUSIC_DIRECTORY_SERVE_PATH = env( "MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH ) + +USERS_INVITATION_EXPIRATION_DAYS = env.int( + "USERS_INVITATION_EXPIRATION_DAYS", default=14 +) diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py index 029338ef992c6a57e3d88f096d4c7619df39d63b..b995afcaa0e28cf32f1746a198a46e05d0e5e466 100644 --- a/api/funkwhale_api/common/serializers.py +++ b/api/funkwhale_api/common/serializers.py @@ -1,6 +1,16 @@ from rest_framework import serializers +class Action(object): + def __init__(self, name, allow_all=False, filters=None): + self.name = name + self.allow_all = allow_all + self.filters = filters or {} + + def __repr__(self): + return "<Action {}>".format(self.name) + + class ActionSerializer(serializers.Serializer): """ A special serializer that can operate on a list of objects @@ -11,19 +21,16 @@ class ActionSerializer(serializers.Serializer): objects = serializers.JSONField(required=True) filters = serializers.DictField(required=False) actions = None - filterset_class = None - # those are actions identifier where we don't want to allow the "all" - # selector because it's to dangerous. Like object deletion. - dangerous_actions = [] def __init__(self, *args, **kwargs): + self.actions_by_name = {a.name: a for a in self.actions} self.queryset = kwargs.pop("queryset") if self.actions is None: raise ValueError( "You must declare a list of actions on " "the serializer class" ) - for action in self.actions: + for action in self.actions_by_name.keys(): handler_name = "handle_{}".format(action) assert hasattr(self, handler_name), "{} miss a {} method".format( self.__class__.__name__, handler_name @@ -31,13 +38,14 @@ class ActionSerializer(serializers.Serializer): super().__init__(self, *args, **kwargs) def validate_action(self, value): - if value not in self.actions: + try: + return self.actions_by_name[value] + except KeyError: raise serializers.ValidationError( "{} is not a valid action. Pick one of {}.".format( - value, ", ".join(self.actions) + value, ", ".join(self.actions_by_name.keys()) ) ) - return value def validate_objects(self, value): if value == "all": @@ -51,15 +59,15 @@ class ActionSerializer(serializers.Serializer): ) def validate(self, data): - dangerous = data["action"] in self.dangerous_actions - if dangerous and self.initial_data["objects"] == "all": + allow_all = data["action"].allow_all + if not allow_all and self.initial_data["objects"] == "all": raise serializers.ValidationError( - "This action is to dangerous to be applied to all objects" - ) - if self.filterset_class and "filters" in data: - qs_filterset = self.filterset_class( - data["filters"], queryset=data["objects"] + "You cannot apply this action on all objects" ) + final_filters = data.get("filters", {}) or {} + final_filters.update(data["action"].filters) + if self.filterset_class and final_filters: + qs_filterset = self.filterset_class(final_filters, queryset=data["objects"]) try: assert qs_filterset.form.is_valid() except (AssertionError, TypeError): @@ -72,12 +80,12 @@ class ActionSerializer(serializers.Serializer): return data def save(self): - handler_name = "handle_{}".format(self.validated_data["action"]) + handler_name = "handle_{}".format(self.validated_data["action"].name) handler = getattr(self, handler_name) result = handler(self.validated_data["objects"]) payload = { "updated": self.validated_data["count"], - "action": self.validated_data["action"], + "action": self.validated_data["action"].name, "result": result, } return payload diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 062f74f476da3b818477306683a9f284a4e126bc..44de5d3129536e0c18a5617007a99694b1aa2864 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -769,7 +769,7 @@ class CollectionSerializer(serializers.Serializer): class LibraryTrackActionSerializer(common_serializers.ActionSerializer): - actions = ["import"] + actions = [common_serializers.Action("import", allow_all=True)] filterset_class = filters.LibraryTrackFilter @transaction.atomic diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index e4cda18c5b9de693a83adf12c4335d45249c0052..5f83ebf1a3143e201891448e2f43206c14015d7c 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -1,4 +1,3 @@ - from django_filters import rest_framework as filters from funkwhale_api.common import fields @@ -37,3 +36,17 @@ class ManageUserFilterSet(filters.FilterSet): "permission_settings", "permission_federation", ] + + +class ManageInvitationFilterSet(filters.FilterSet): + q = fields.SearchFilter(search_fields=["owner__username", "code", "owner__email"]) + is_open = filters.BooleanFilter(method="filter_is_open") + + class Meta: + model = users_models.Invitation + fields = ["q", "is_open"] + + def filter_is_open(self, queryset, field_name, value): + if value is None: + return queryset + return queryset.open(value) diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 6e57db81f91a431baab736f24dda6b1a4c2e9f2e..c639d3a3c2ad26301c203df9c2f393df56f8e926 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -61,8 +61,7 @@ class ManageTrackFileSerializer(serializers.ModelSerializer): class ManageTrackFileActionSerializer(common_serializers.ActionSerializer): - actions = ["delete"] - dangerous_actions = ["delete"] + actions = [common_serializers.Action("delete", allow_all=False)] filterset_class = filters.ManageTrackFileFilterSet @transaction.atomic @@ -78,6 +77,23 @@ class PermissionsSerializer(serializers.Serializer): return {"permissions": o} +class ManageUserSimpleSerializer(serializers.ModelSerializer): + class Meta: + model = users_models.User + fields = ( + "id", + "username", + "email", + "name", + "is_active", + "is_staff", + "is_superuser", + "date_joined", + "last_activity", + "privacy_level", + ) + + class ManageUserSerializer(serializers.ModelSerializer): permissions = PermissionsSerializer(source="*") @@ -115,3 +131,32 @@ class ManageUserSerializer(serializers.ModelSerializer): update_fields=["permission_{}".format(p) for p in permissions.keys()] ) return instance + + +class ManageInvitationSerializer(serializers.ModelSerializer): + users = ManageUserSimpleSerializer(many=True, required=False) + owner = ManageUserSimpleSerializer(required=False) + code = serializers.CharField(required=False, allow_null=True) + + class Meta: + model = users_models.Invitation + fields = ("id", "owner", "code", "expiration_date", "creation_date", "users") + read_only_fields = ["id", "expiration_date", "owner", "creation_date", "users"] + + def validate_code(self, value): + if not value: + return value + if users_models.Invitation.objects.filter(code__iexact=value).exists(): + raise serializers.ValidationError( + "An invitation with this code already exists" + ) + return value + + +class ManageInvitationActionSerializer(common_serializers.ActionSerializer): + actions = [common_serializers.Action("delete", allow_all=False)] + filterset_class = filters.ManageInvitationFilterSet + + @transaction.atomic + def handle_delete(self, objects): + return objects.delete() diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index f208fb857079db390c6d4d4bb63e14107b2d6c11..3d4e15db9327855ff4df5f983fe8a70dba26d452 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -7,6 +7,7 @@ library_router = routers.SimpleRouter() library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files") users_router = routers.SimpleRouter() users_router.register(r"users", views.ManageUserViewSet, "users") +users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations") urlpatterns = [ url(r"^library/", include((library_router.urls, "instance"), namespace="library")), diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index f9b78ef87a076a95afa43dc8c57fe9d6ededffc1..ae3c08a57c829dbc8330c37568a3043fa5f8484e 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -62,3 +62,37 @@ class ManageUserViewSet( context = super().get_serializer_context() context["default_permissions"] = preferences.get("users__default_permissions") return context + + +class ManageInvitationViewSet( + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + queryset = ( + users_models.Invitation.objects.all() + .order_by("-id") + .prefetch_related("users") + .select_related("owner") + ) + serializer_class = serializers.ManageInvitationSerializer + filter_class = filters.ManageInvitationFilterSet + permission_classes = (HasUserPermission,) + required_permissions = ["settings"] + ordering_fields = ["creation_date", "expiration_date"] + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + @list_route(methods=["post"]) + def action(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = serializers.ManageInvitationActionSerializer( + request.data, queryset=queryset + ) + serializer.is_valid(raise_exception=True) + result = serializer.save() + return response.Response(result, status=200) diff --git a/api/funkwhale_api/users/admin.py b/api/funkwhale_api/users/admin.py index 5c694ab0ee962c2977dd227c709861dfef93098f..205c7c36703ebfa153dab7b2a393f4432152e162 100644 --- a/api/funkwhale_api/users/admin.py +++ b/api/funkwhale_api/users/admin.py @@ -7,12 +7,12 @@ from django.contrib.auth.admin import UserAdmin as AuthUserAdmin from django.contrib.auth.forms import UserChangeForm, UserCreationForm from django.utils.translation import ugettext_lazy as _ -from .models import User +from . import models class MyUserChangeForm(UserChangeForm): class Meta(UserChangeForm.Meta): - model = User + model = models.User class MyUserCreationForm(UserCreationForm): @@ -22,18 +22,18 @@ class MyUserCreationForm(UserCreationForm): ) class Meta(UserCreationForm.Meta): - model = User + model = models.User def clean_username(self): username = self.cleaned_data["username"] try: - User.objects.get(username=username) - except User.DoesNotExist: + models.User.objects.get(username=username) + except models.User.DoesNotExist: return username raise forms.ValidationError(self.error_messages["duplicate_username"]) -@admin.register(User) +@admin.register(models.User) class UserAdmin(AuthUserAdmin): form = MyUserChangeForm add_form = MyUserCreationForm @@ -74,3 +74,11 @@ class UserAdmin(AuthUserAdmin): (_("Important dates"), {"fields": ("last_login", "date_joined")}), (_("Useless fields"), {"fields": ("user_permissions", "groups")}), ) + + +@admin.register(models.Invitation) +class InvitationAdmin(admin.ModelAdmin): + list_select_related = True + list_display = ["owner", "code", "creation_date", "expiration_date"] + search_fields = ["owner__username", "code"] + readonly_fields = ["expiration_date", "code"] diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py index eed8c7175a2dacdb65404aaf827c2bee04582dbb..5fceb57bbc17bdd8ac70e95bc81e672869b4abe8 100644 --- a/api/funkwhale_api/users/factories.py +++ b/api/funkwhale_api/users/factories.py @@ -1,5 +1,6 @@ import factory from django.contrib.auth.models import Permission +from django.utils import timezone from funkwhale_api.factories import ManyToManyFromList, registry @@ -28,6 +29,17 @@ class GroupFactory(factory.django.DjangoModelFactory): self.permissions.add(*perms) +@registry.register +class InvitationFactory(factory.django.DjangoModelFactory): + owner = factory.LazyFunction(lambda: UserFactory()) + + class Meta: + model = "users.Invitation" + + class Params: + expired = factory.Trait(expiration_date=factory.LazyFunction(timezone.now)) + + @registry.register class UserFactory(factory.django.DjangoModelFactory): username = factory.Sequence(lambda n: "user-{0}".format(n)) @@ -40,6 +52,9 @@ class UserFactory(factory.django.DjangoModelFactory): model = "users.User" django_get_or_create = ("username",) + class Params: + invited = factory.Trait(invitation=factory.SubFactory(InvitationFactory)) + @factory.post_generation def perms(self, create, extracted, **kwargs): if not create: diff --git a/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py new file mode 100644 index 0000000000000000000000000000000000000000..e8204c4e4748371b7906d9115b0d529f30a35db7 --- /dev/null +++ b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py @@ -0,0 +1,31 @@ +# Generated by Django 2.0.6 on 2018-06-19 20:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0008_auto_20180617_1531'), + ] + + operations = [ + migrations.CreateModel( + name='Invitation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('expiration_date', models.DateTimeField()), + ('code', models.CharField(max_length=50, unique=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='user', + name='invitation', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='users.Invitation'), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 15d16db2369967cddf38310d871c6bae3733dab0..ec9c39fd69a47d08f08f2a59ba20f480e8cfdb9c 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -4,6 +4,8 @@ from __future__ import absolute_import, unicode_literals import binascii import datetime import os +import random +import string import uuid from django.conf import settings @@ -79,6 +81,14 @@ class User(AbstractUser): last_activity = models.DateTimeField(default=None, null=True, blank=True) + invitation = models.ForeignKey( + "Invitation", + related_name="users", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + def __str__(self): return self.username @@ -138,3 +148,40 @@ class User(AbstractUser): if current is None or current < now - datetime.timedelta(seconds=delay): self.last_activity = now self.save(update_fields=["last_activity"]) + + +def generate_code(length=10): + return "".join( + random.SystemRandom().choice(string.ascii_uppercase) for _ in range(length) + ) + + +class InvitationQuerySet(models.QuerySet): + def open(self, include=True): + now = timezone.now() + qs = self.annotate(_users=models.Count("users")) + query = models.Q(_users=0, expiration_date__gt=now) + if include: + return qs.filter(query) + return qs.exclude(query) + + +class Invitation(models.Model): + creation_date = models.DateTimeField(default=timezone.now) + expiration_date = models.DateTimeField() + owner = models.ForeignKey( + User, related_name="invitations", on_delete=models.CASCADE + ) + code = models.CharField(max_length=50, unique=True) + + objects = InvitationQuerySet.as_manager() + + def save(self, **kwargs): + if not self.code: + self.code = generate_code() + if not self.expiration_date: + self.expiration_date = self.creation_date + datetime.timedelta( + days=settings.USERS_INVITATION_EXPIRATION_DAYS + ) + + return super().save(**kwargs) diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index b3bd431c722fc5f8e4751270a9b1690973119de2..4389512650327a2da66fcd35fee362106814d43a 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -1,5 +1,6 @@ from django.conf import settings from rest_auth.serializers import PasswordResetSerializer as PRS +from rest_auth.registration.serializers import RegisterSerializer as RS from rest_framework import serializers from funkwhale_api.activity import serializers as activity_serializers @@ -7,6 +8,28 @@ from funkwhale_api.activity import serializers as activity_serializers from . import models +class RegisterSerializer(RS): + invitation = serializers.CharField( + required=False, allow_null=True, allow_blank=True + ) + + def validate_invitation(self, value): + if not value: + return + + try: + return models.Invitation.objects.open().get(code__iexact=value) + except models.Invitation.DoesNotExist: + raise serializers.ValidationError("Invalid invitation code") + + def save(self, request): + user = super().save(request) + if self.validated_data.get("invitation"): + user.invitation = self.validated_data.get("invitation") + user.save(update_fields=["invitation"]) + return user + + class UserActivitySerializer(activity_serializers.ModelSerializer): type = serializers.SerializerMethodField() name = serializers.CharField(source="username") diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py index 69e69d26e6b987426ef59a450d07e3d6458f6ab2..20d63d788f349643b9065038102cabfd60a49ce0 100644 --- a/api/funkwhale_api/users/views.py +++ b/api/funkwhale_api/users/views.py @@ -10,8 +10,11 @@ from . import models, serializers class RegisterView(BaseRegisterView): + serializer_class = serializers.RegisterSerializer + def create(self, request, *args, **kwargs): - if not self.is_open_for_signup(request): + invitation_code = request.data.get("invitation") + if not invitation_code and not self.is_open_for_signup(request): r = {"detail": "Registration has been disabled"} return Response(r, status=403) return super().create(request, *args, **kwargs) diff --git a/api/tests/common/test_serializers.py b/api/tests/common/test_serializers.py index ca5e5ad8f6fd2317af880eef0aec3f28b1184b04..dbbd38a0dc442bc04358eef8e14ff4e2a89310a9 100644 --- a/api/tests/common/test_serializers.py +++ b/api/tests/common/test_serializers.py @@ -11,7 +11,7 @@ class TestActionFilterSet(django_filters.FilterSet): class TestSerializer(serializers.ActionSerializer): - actions = ["test"] + actions = [serializers.Action("test", allow_all=True)] filterset_class = TestActionFilterSet def handle_test(self, objects): @@ -19,8 +19,10 @@ class TestSerializer(serializers.ActionSerializer): class TestDangerousSerializer(serializers.ActionSerializer): - actions = ["test", "test_dangerous"] - dangerous_actions = ["test_dangerous"] + actions = [ + serializers.Action("test", allow_all=True), + serializers.Action("test_dangerous"), + ] def handle_test(self, objects): pass @@ -29,6 +31,14 @@ class TestDangerousSerializer(serializers.ActionSerializer): pass +class TestDeleteOnlyInactiveSerializer(serializers.ActionSerializer): + actions = [serializers.Action("test", allow_all=True, filters={"is_active": False})] + filterset_class = TestActionFilterSet + + def handle_test(self, objects): + pass + + def test_action_serializer_validates_action(): data = {"objects": "all", "action": "nope"} serializer = TestSerializer(data, queryset=models.User.objects.none()) @@ -52,7 +62,7 @@ def test_action_serializers_objects_clean_ids(factories): data = {"objects": [user1.pk], "action": "test"} serializer = TestSerializer(data, queryset=models.User.objects.all()) - assert serializer.is_valid() is True + assert serializer.is_valid(raise_exception=True) is True assert list(serializer.validated_data["objects"]) == [user1] @@ -63,7 +73,7 @@ def test_action_serializers_objects_clean_all(factories): data = {"objects": "all", "action": "test"} serializer = TestSerializer(data, queryset=models.User.objects.all()) - assert serializer.is_valid() is True + assert serializer.is_valid(raise_exception=True) is True assert list(serializer.validated_data["objects"]) == [user1, user2] @@ -75,7 +85,7 @@ def test_action_serializers_save(factories, mocker): data = {"objects": "all", "action": "test"} serializer = TestSerializer(data, queryset=models.User.objects.all()) - assert serializer.is_valid() is True + assert serializer.is_valid(raise_exception=True) is True result = serializer.save() assert result == {"updated": 2, "action": "test", "result": {"hello": "world"}} handler.assert_called_once() @@ -88,7 +98,7 @@ def test_action_serializers_filterset(factories): data = {"objects": "all", "action": "test", "filters": {"is_active": True}} serializer = TestSerializer(data, queryset=models.User.objects.all()) - assert serializer.is_valid() is True + assert serializer.is_valid(raise_exception=True) is True assert list(serializer.validated_data["objects"]) == [user2] @@ -109,9 +119,14 @@ def test_dangerous_actions_refuses_all(factories): assert "non_field_errors" in serializer.errors -def test_dangerous_actions_refuses_not_listed(factories): - factories["users.User"]() +def test_action_serializers_can_require_filter(factories): + user1 = factories["users.User"](is_active=False) + factories["users.User"](is_active=True) + data = {"objects": "all", "action": "test"} - serializer = TestDangerousSerializer(data, queryset=models.User.objects.all()) + serializer = TestDeleteOnlyInactiveSerializer( + data, queryset=models.User.objects.all() + ) - assert serializer.is_valid() is True + assert serializer.is_valid(raise_exception=True) is True + assert list(serializer.validated_data["objects"]) == [user1] diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index a72bcf5af4fa77b92a25ab5dd099684bb196f13c..d54fca5ddafe3b570bf0d671846587b8132c4f77 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -9,6 +9,7 @@ from funkwhale_api.manage import serializers, views [ (views.ManageTrackFileViewSet, ["library"], "and"), (views.ManageUserViewSet, ["settings"], "and"), + (views.ManageInvitationViewSet, ["settings"], "and"), ], ) def test_permissions(assert_user_permission, view, permissions, operator): @@ -42,3 +43,23 @@ def test_user_view(factories, superuser_api_client, mocker): assert response.data["count"] == len(users) assert response.data["results"] == expected + + +def test_invitation_view(factories, superuser_api_client, mocker): + invitations = factories["users.Invitation"].create_batch(size=5) + qs = invitations[0].__class__.objects.order_by("-id") + url = reverse("api:v1:manage:users:invitations-list") + + response = superuser_api_client.get(url, {"sort": "-id"}) + expected = serializers.ManageInvitationSerializer(qs, many=True).data + + assert response.data["count"] == len(invitations) + assert response.data["results"] == expected + + +def test_invitation_view_create(factories, superuser_api_client, mocker): + url = reverse("api:v1:manage:users:invitations-list") + response = superuser_api_client.post(url) + + assert response.status_code == 201 + assert superuser_api_client.user.invitations.latest("id") is not None diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index 74bb091e54270c4034050b7810e2b6ba3cb4bde3..ea760cc6c6b5a49f39903f1d641bd3d664819348 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -1,3 +1,4 @@ +import datetime import pytest from funkwhale_api.users import models @@ -95,3 +96,34 @@ def test_record_activity_does_nothing_if_already(factories, now, mocker): user.record_activity() save.assert_not_called() + + +def test_invitation_generates_random_code_on_save(factories): + invitation = factories["users.Invitation"]() + assert len(invitation.code) >= 6 + + +def test_invitation_expires_after_delay(factories, settings): + delay = settings.USERS_INVITATION_EXPIRATION_DAYS + invitation = factories["users.Invitation"]() + assert invitation.expiration_date == ( + invitation.creation_date + datetime.timedelta(days=delay) + ) + + +def test_can_filter_open_invitations(factories): + okay = factories["users.Invitation"]() + factories["users.Invitation"](expired=True) + factories["users.User"](invited=True) + + assert models.Invitation.objects.count() == 3 + assert list(models.Invitation.objects.open()) == [okay] + + +def test_can_filter_closed_invitations(factories): + factories["users.Invitation"]() + expired = factories["users.Invitation"](expired=True) + used = factories["users.User"](invited=True).invitation + + assert models.Invitation.objects.count() == 3 + assert list(models.Invitation.objects.open(False)) == [expired, used] diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index 00272c2aea76026c3df2b125c39fdee852acf1ec..fca66d302efc499cb7ef7cf0c2d8124ec7b5668b 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -50,6 +50,39 @@ def test_can_disable_registration_view(preferences, api_client, db): assert response.status_code == 403 +def test_can_signup_with_invitation(preferences, factories, api_client): + url = reverse("rest_register") + invitation = factories["users.Invitation"](code="Hello") + data = { + "username": "test1", + "email": "test1@test.com", + "password1": "testtest", + "password2": "testtest", + "invitation": "hello", + } + preferences["users__registration_enabled"] = False + response = api_client.post(url, data) + assert response.status_code == 201 + u = User.objects.get(email="test1@test.com") + assert u.username == "test1" + assert u.invitation == invitation + + +def test_can_signup_with_invitation_invalid(preferences, factories, api_client): + url = reverse("rest_register") + factories["users.Invitation"](code="hello") + data = { + "username": "test1", + "email": "test1@test.com", + "password1": "testtest", + "password2": "testtest", + "invitation": "nope", + } + response = api_client.post(url, data) + assert response.status_code == 400 + assert "invitation" in response.data + + def test_can_fetch_data_from_api(api_client, factories): url = reverse("api:v1:users:users-me") response = api_client.get(url) diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 03ea4ee0773a022acd0ef5d9cbd36c554330f6af..87c374a336780d1e2623f85685e581abbabab347 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -99,7 +99,7 @@ <router-link class="item" v-if="$store.state.auth.availablePermissions['settings']" - :to="{path: '/manage/users'}"> + :to="{name: 'manage.users.users.list'}"> <i class="users icon"></i>{{ $t('Users') }} </router-link> </div> diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue index 89f4cb1f1266c956283142cfc4f470e3b0c5d031..e4e5cebbce950b7470204f710e06ffe01e7632e7 100644 --- a/front/src/components/auth/Signup.vue +++ b/front/src/components/auth/Signup.vue @@ -2,19 +2,22 @@ <div class="main pusher" v-title="'Sign Up'"> <div class="ui vertical stripe segment"> <div class="ui small text container"> - <h2><i18next path="Create a funkwhale account"/></h2> + <h2>{{ $t("Create a funkwhale account") }}</h2> <form - v-if="$store.state.instance.settings.users.registration_enabled.value" :class="['ui', {'loading': isLoadingInstanceSetting}, 'form']" @submit.prevent="submit()"> + <p class="ui message" v-if="!$store.state.instance.settings.users.registration_enabled.value"> + {{ $t('Registration are closed on this instance, you will need an invitation code to signup.') }} + </p> + <div v-if="errors.length > 0" class="ui negative message"> - <div class="header"><i18next path="We cannot create your account"/></div> + <div class="header">{{ $t("We cannot create your account") }}</div> <ul class="list"> <li v-for="error in errors">{{ error }}</li> </ul> </div> <div class="field"> - <i18next tag="label" path="Username"/> + <label>{{ $t("Username") }}</label> <input ref="username" required @@ -24,7 +27,7 @@ v-model="username"> </div> <div class="field"> - <i18next tag="label" path="Email"/> + <label>{{ $t("Email") }}</label> <input ref="email" required @@ -33,12 +36,22 @@ v-model="email"> </div> <div class="field"> - <i18next tag="label" path="Password"/> + <label>{{ $t("Password") }}</label> <password-input v-model="password" /> </div> - <button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit"><i18next path="Create my account"/></button> + <div class="field"> + <label v-if="!$store.state.instance.settings.users.registration_enabled.value">{{ $t("Invitation code") }}</label> + <label v-else>{{ $t("Invitation code (optional)") }}</label> + <input + :required="!$store.state.instance.settings.users.registration_enabled.value" + type="text" + :placeholder="$t('Enter your invitation code (case insensitive)')" + v-model="invitation"> + </div> + <button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit"> + {{ $t("Create my account") }} + </button> </form> - <i18next v-else tag="p" path="Registration is currently disabled on this instance, please try again later."/> </div> </div> </div> @@ -51,13 +64,13 @@ import logger from '@/logging' import PasswordInput from '@/components/forms/PasswordInput' export default { - name: 'login', - components: { - PasswordInput - }, props: { + invitation: {type: String, required: false, default: null}, next: {type: String, default: '/'} }, + components: { + PasswordInput + }, data () { return { username: '', @@ -85,7 +98,8 @@ export default { username: this.username, password1: this.password, password2: this.password, - email: this.email + email: this.email, + invitation: this.invitation } return axios.post('auth/registration/', payload).then(response => { logger.default.info('Successfully created account') diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index f23479066e37667d3ea7dba63d504f1778a93d07..097fb29385eb495d00bc349d88f733c5d9ac5813 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -36,7 +36,7 @@ <div class="count field"> <span v-if="selectAll">{{ $t('{% count %} on {% total %} selected', {count: objectsData.count, total: objectsData.count}) }}</span> <span v-else>{{ $t('{% count %} on {% total %} selected', {count: checked.length, total: objectsData.count}) }}</span> - <template v-if="!currentAction.isDangerous && checkable.length === checked.length"> + <template v-if="!currentAction.isDangerous && checkable.length > 0 && checkable.length === checked.length"> <a @click="selectAll = true" v-if="!selectAll"> {{ $t('Select all {% total %} elements', {total: objectsData.count}) }} </a> @@ -157,6 +157,7 @@ export default { let self = this self.actionLoading = true self.result = null + self.actionErrors = [] let payload = { action: this.currentActionName, filters: this.filters diff --git a/front/src/components/manage/users/InvitationForm.vue b/front/src/components/manage/users/InvitationForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..9429c1ae16294c49b91aa5a6ee43780a7b596c90 --- /dev/null +++ b/front/src/components/manage/users/InvitationForm.vue @@ -0,0 +1,82 @@ +<template> + <div> + <form class="ui form" @submit.prevent="submit"> + <div v-if="errors.length > 0" class="ui negative message"> + <div class="header">{{ $t('Error while creating invitation') }}</div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <div class="inline fields"> + <div class="ui field"> + <label>{{ $t('Invitation code')}}</label> + <input type="text" v-model="code" :placeholder="$t('Leave empty for a random code')" /> + </div> + <div class="ui field"> + <button :class="['ui', {loading: isLoading}, 'button']" :disabled="isLoading" type="submit"> + {{ $t('Get a new invitation') }} + </button> + </div> + </div> + </form> + <div v-if="invitations.length > 0"> + <div class="ui hidden divider"></div> + <table class="ui ui basic table"> + <thead> + <tr> + <th>{{ $t('Code') }}</th> + <th>{{ $t('Share link') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="invitation in invitations" :key="invitation.code"> + <td>{{ invitation.code.toUpperCase() }}</td> + <td><a :href="getUrl(invitation.code)" target="_blank">{{ getUrl(invitation.code) }}</a></td> + </tr> + </tbody> + </table> + <button class="ui basic button" @click="invitations = []">{{ $t('Clear') }}</button> + </div> + </div> +</template> + +<script> +import axios from 'axios' + +import backend from '@/audio/backend' + +export default { + data () { + return { + isLoading: false, + code: null, + invitations: [], + errors: [] + } + }, + methods: { + submit () { + let self = this + this.isLoading = true + this.errors = [] + let url = 'manage/users/invitations/' + let payload = { + code: this.code + } + axios.post(url, payload).then((response) => { + self.isLoading = false + self.invitations.unshift(response.data) + }, (error) => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + getUrl (code) { + return backend.absoluteUrl(this.$router.resolve({name: 'signup', query: {invitation: code.toUpperCase()}}).href) + } + } +} +</script> + +<style scoped> +</style> diff --git a/front/src/components/manage/users/InvitationsTable.vue b/front/src/components/manage/users/InvitationsTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..e8d0a2406aaf465d7e30803b05ace126b621606d --- /dev/null +++ b/front/src/components/manage/users/InvitationsTable.vue @@ -0,0 +1,191 @@ +<template> + <div> + <div class="ui inline form"> + <div class="fields"> + <div class="ui field"> + <label>{{ $t('Search') }}</label> + <input type="text" v-model="search" placeholder="Search by username, email, code..." /> + </div> + <div class="field"> + <label>{{ $t("Ordering") }}</label> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ option[1] }} + </option> + </select> + </div> + <div class="field"> + <label>{{ $t("Status") }}</label> + <select class="ui dropdown" v-model="isOpen"> + <option :value="null">{{ $t('All') }}</option> + <option :value="true">{{ $t('Open') }}</option> + <option :value="false">{{ $t('Expired/used') }}</option> + </select> + </div> + </div> + </div> + <div class="dimmable"> + <div v-if="isLoading" class="ui active inverted dimmer"> + <div class="ui loader"></div> + </div> + <action-table + v-if="result" + @action-launched="fetchData" + :objects-data="result" + :actions="actions" + :action-url="'manage/users/invitations/action/'" + :filters="actionFilters"> + <template slot="header-cells"> + <th>{{ $t('Owner') }}</th> + <th>{{ $t('Status') }}</th> + <th>{{ $t('Creation date') }}</th> + <th>{{ $t('Expiration date') }}</th> + <th>{{ $t('Code') }}</th> + </template> + <template slot="row-cells" slot-scope="scope"> + <td> + <router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{Â scope.obj.owner.username }}</router-link> + </td> + <td> + <span v-if="scope.obj.users.length > 0" class="ui green basic label">{{ $t('Used') }}</span> + <span v-else-if="moment().isAfter(scope.obj.expiration_date)" class="ui red basic label">{{ $t('Expired') }}</span> + <span v-else class="ui basic label">{{ $t('Not used') }}</span> + </td> + <td> + <human-date :date="scope.obj.creation_date"></human-date> + </td> + <td> + <human-date :date="scope.obj.expiration_date"></human-date> + </td> + <td> + {{ scope.obj.code.toUpperCase() }} + </td> + </template> + </action-table> + </div> + <div> + <pagination + v-if="result && result.results.length > 0" + @page-changed="selectPage" + :compact="true" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + + <span v-if="result && result.results.length > 0"> + {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}} + </span> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import moment from 'moment' +import _ from 'lodash' +import Pagination from '@/components/Pagination' +import ActionTable from '@/components/common/ActionTable' +import OrderingMixin from '@/components/mixins/Ordering' + +export default { + mixins: [OrderingMixin], + props: { + filters: {type: Object, required: false} + }, + components: { + Pagination, + ActionTable + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') + return { + moment, + isLoading: false, + result: null, + page: 1, + paginateBy: 50, + search: '', + isOpen: null, + orderingDirection: defaultOrdering.direction || '+', + ordering: defaultOrdering.field, + orderingOptions: [ + ['expiration_date', 'Expiration date'], + ['creation_date', 'Creation date'] + ] + + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + let params = _.merge({ + 'page': this.page, + 'page_size': this.paginateBy, + 'q': this.search, + 'is_open': this.isOpen, + 'ordering': this.getOrderingAsString() + }, this.filters) + let self = this + self.isLoading = true + self.checked = [] + axios.get('/manage/users/invitations/', {params: params}).then((response) => { + self.result = response.data + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + selectPage: function (page) { + this.page = page + } + }, + computed: { + actionFilters () { + var currentFilters = { + q: this.search + } + if (this.filters) { + return _.merge(currentFilters, this.filters) + } else { + return currentFilters + } + }, + actions () { + return [ + { + name: 'delete', + label: this.$t('Delete'), + filterCheckable: (obj) => { + return obj.users.length === 0 && moment().isBefore(obj.expiration_date) + } + } + ] + } + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.page = 1 + this.fetchData() + }, + isOpen () { + this.page = 1 + this.fetchData() + }, + orderingDirection () { + this.page = 1 + this.fetchData() + } + } +} +</script> diff --git a/front/src/components/manage/users/UsersTable.vue b/front/src/components/manage/users/UsersTable.vue index 5658583c3df1d59b51d186123beb6fa15aa6dd29..855fbe2b5da53abf717dc819a5f2560c5e35bd54 100644 --- a/front/src/components/manage/users/UsersTable.vue +++ b/front/src/components/manage/users/UsersTable.vue @@ -45,7 +45,7 @@ </template> <template slot="row-cells" slot-scope="scope"> <td> - <router-link :to="{name: 'manage.users.detail', params: {id: scope.obj.id }}">{{Â scope.obj.username }}</router-link> + <router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{Â scope.obj.username }}</router-link> </td> <td> <span>{{Â scope.obj.email }}</span> diff --git a/front/src/main.js b/front/src/main.js index eb2e3a23d6ceed3bdb88ed59d49df5a9744e4828..7973e4bb7e87222097c649f44fbba13c318a7256 100644 --- a/front/src/main.js +++ b/front/src/main.js @@ -86,11 +86,15 @@ axios.interceptors.response.use(function (response) { } else if (error.response.status === 500) { error.backendErrors.push('A server error occured') } else if (error.response.data) { - for (var field in error.response.data) { - if (error.response.data.hasOwnProperty(field)) { - error.response.data[field].forEach(e => { - error.backendErrors.push(e) - }) + if (error.response.data.detail) { + error.backendErrors.push(error.response.data.detail) + } else { + for (var field in error.response.data) { + if (error.response.data.hasOwnProperty(field)) { + error.response.data[field].forEach(e => { + error.backendErrors.push(e) + }) + } } } } diff --git a/front/src/router/index.js b/front/src/router/index.js index 0d2ad34f98aceff40b212625bac52b9ab9be9bf7..19474376874435c36e735920290061b7111b3fc4 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -34,6 +34,7 @@ import AdminLibraryFilesList from '@/views/admin/library/FilesList' import AdminUsersBase from '@/views/admin/users/Base' import AdminUsersDetail from '@/views/admin/users/UsersDetail' import AdminUsersList from '@/views/admin/users/UsersList' +import AdminInvitationsList from '@/views/admin/users/InvitationsList' import FederationBase from '@/views/federation/Base' import FederationScan from '@/views/federation/Scan' import FederationLibraryDetail from '@/views/federation/LibraryDetail' @@ -96,7 +97,10 @@ export default new Router({ { path: '/signup', name: 'signup', - component: Signup + component: Signup, + props: (route) => ({ + invitation: route.query.invitation + }) }, { path: '/logout', @@ -188,15 +192,20 @@ export default new Router({ component: AdminUsersBase, children: [ { - path: '', - name: 'manage.users.list', + path: 'users', + name: 'manage.users.users.list', component: AdminUsersList }, { - path: ':id', - name: 'manage.users.detail', + path: 'users/:id', + name: 'manage.users.users.detail', component: AdminUsersDetail, props: true + }, + { + path: 'invitations', + name: 'manage.users.invitations.list', + component: AdminInvitationsList } ] }, diff --git a/front/src/views/admin/users/Base.vue b/front/src/views/admin/users/Base.vue index e545b7f709fd63cad5154c4c0375b067f689e72f..505ca587fe2bf2726b2a3c30bfbf4b6c540a858f 100644 --- a/front/src/views/admin/users/Base.vue +++ b/front/src/views/admin/users/Base.vue @@ -3,7 +3,10 @@ <div class="ui secondary pointing menu"> <router-link class="ui item" - :to="{name: 'manage.users.list'}">{{ $t('Users') }}</router-link> + :to="{name: 'manage.users.users.list'}">{{ $t('Users') }}</router-link> + <router-link + class="ui item" + :to="{name: 'manage.users.invitations.list'}">{{ $t('Invitations') }}</router-link> </div> <router-view :key="$route.fullPath"></router-view> </div> diff --git a/front/src/views/admin/users/InvitationsList.vue b/front/src/views/admin/users/InvitationsList.vue new file mode 100644 index 0000000000000000000000000000000000000000..230dad6c1971a9c86c08570c404530ecf405c7d4 --- /dev/null +++ b/front/src/views/admin/users/InvitationsList.vue @@ -0,0 +1,26 @@ +<template> + <div v-title="$t('Invitations')"> + <div class="ui vertical stripe segment"> + <h2 class="ui header">{{ $t('Invitations') }}</h2> + <invitation-form></invitation-form> + <div class="ui hidden divider"></div> + <invitations-table></invitations-table> + </div> + </div> +</template> + +<script> +import InvitationForm from '@/components/manage/users/InvitationForm' +import InvitationsTable from '@/components/manage/users/InvitationsTable' + +export default { + components: { + InvitationForm, + InvitationsTable + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style>