From 789bef38cb2f24f2f0bd13c891fb238460cfb07f Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 19 Jun 2018 21:47:43 +0200 Subject: [PATCH 1/9] See #248: model / migration --- api/config/settings/common.py | 4 ++ api/funkwhale_api/users/factories.py | 15 ++++++ .../migrations/0009_auto_20180619_2024.py | 31 +++++++++++++ api/funkwhale_api/users/models.py | 46 +++++++++++++++++++ api/tests/users/test_models.py | 23 ++++++++++ 5 files changed, 119 insertions(+) create mode 100644 api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py diff --git a/api/config/settings/common.py b/api/config/settings/common.py index a836dfdfd..b74c2bdfe 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/users/factories.py b/api/funkwhale_api/users/factories.py index eed8c7175..5fceb57bb 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 000000000..e8204c4e4 --- /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 15d16db23..61f57a3c5 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,39 @@ 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_lowercase) for _ in range(length) + ) + + +class InvitationQuerySet(models.QuerySet): + def open(self): + now = timezone.now() + qs = self.annotate(_users=models.Count("users")) + qs = qs.filter(_users=0) + qs = qs.exclude(expiration_date__lte=now) + return qs + + +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/tests/users/test_models.py b/api/tests/users/test_models.py index 74bb091e5..475691293 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,25 @@ 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] From d18f98e0f8da5362f3409f9a00f9cc6b2f2d2361 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 19 Jun 2018 22:23:22 +0200 Subject: [PATCH 2/9] See #248: can now sign up using invitation code --- api/funkwhale_api/users/admin.py | 16 ++++++++--- api/funkwhale_api/users/serializers.py | 23 +++++++++++++++ api/funkwhale_api/users/views.py | 5 +++- api/tests/users/test_views.py | 33 +++++++++++++++++++++ front/src/components/auth/Signup.vue | 40 +++++++++++++++++--------- front/src/router/index.js | 5 +++- 6 files changed, 103 insertions(+), 19 deletions(-) diff --git a/api/funkwhale_api/users/admin.py b/api/funkwhale_api/users/admin.py index 5c694ab0e..2affd0836 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,7 +22,7 @@ class MyUserCreationForm(UserCreationForm): ) class Meta(UserCreationForm.Meta): - model = User + model = models.User def clean_username(self): username = self.cleaned_data["username"] @@ -33,7 +33,7 @@ class MyUserCreationForm(UserCreationForm): 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/serializers.py b/api/funkwhale_api/users/serializers.py index b3bd431c7..f857e8da6 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=value.lower()) + 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 69e69d26e..20d63d788 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/users/test_views.py b/api/tests/users/test_views.py index 00272c2ae..0ad67fb86 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") + invitation = 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/auth/Signup.vue b/front/src/components/auth/Signup.vue index 89f4cb1f1..e4e5cebbc 100644 --- a/front/src/components/auth/Signup.vue +++ b/front/src/components/auth/Signup.vue @@ -2,19 +2,22 @@
-

+

{{ $t("Create a funkwhale account") }}

+

+ {{ $t('Registration are closed on this instance, you will need an invitation code to signup.') }} +

+
-
+
{{ $t("We cannot create your account") }}
  • {{ error }}
- +
- +
- +
- +
+ + + +
+
-
@@ -51,13 +64,13 @@ import logger from '@/logging' import PasswordInput from '@/components/forms/PasswordInput' export default { - name: 'login', + props: { + invitation: {type: String, required: false, default: null}, + next: {type: String, default: '/'} + }, components: { PasswordInput }, - props: { - next: {type: String, default: '/'} - }, 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/router/index.js b/front/src/router/index.js index 0d2ad34f9..5528addd4 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -96,7 +96,10 @@ export default new Router({ { path: '/signup', name: 'signup', - component: Signup + component: Signup, + props: (route) => ({ + invitation: route.query.invitation + }) }, { path: '/logout', From 107b1ea7dc98ea35e38c9555bbe2457c688fa000 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Tue, 19 Jun 2018 23:27:21 +0200 Subject: [PATCH 3/9] See #248: can now generate and list invitations in the front-end --- api/funkwhale_api/manage/filters.py | 9 +- api/funkwhale_api/manage/serializers.py | 37 ++++ api/funkwhale_api/manage/urls.py | 1 + api/funkwhale_api/manage/views.py | 24 +++ api/tests/manage/test_views.py | 21 ++ front/src/components/Sidebar.vue | 2 +- .../manage/users/InvitationForm.vue | 82 ++++++++ .../manage/users/InvitationsTable.vue | 180 ++++++++++++++++++ .../components/manage/users/UsersTable.vue | 2 +- front/src/router/index.js | 14 +- front/src/views/admin/users/Base.vue | 5 +- .../src/views/admin/users/InvitationsList.vue | 26 +++ 12 files changed, 395 insertions(+), 8 deletions(-) create mode 100644 front/src/components/manage/users/InvitationForm.vue create mode 100644 front/src/components/manage/users/InvitationsTable.vue create mode 100644 front/src/views/admin/users/InvitationsList.vue diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index e4cda18c5..16ee5c162 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,11 @@ class ManageUserFilterSet(filters.FilterSet): "permission_settings", "permission_federation", ] + + +class ManageInvitationFilterSet(filters.FilterSet): + q = fields.SearchFilter(search_fields=["owner__username", "code", "owner__email"]) + + class Meta: + model = users_models.Invitation + fields = ["q"] diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 6e57db81f..e8f1e328e 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -78,6 +78,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 +132,23 @@ 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=value.lower()).exists(): + raise serializers.ValidationError( + "An invitation with this code already exists" + ) + return value diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index f208fb857..3d4e15db9 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 f9b78ef87..803f8db7c 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -62,3 +62,27 @@ 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) diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index a72bcf5af..d54fca5dd 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/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 03ea4ee07..87c374a33 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -99,7 +99,7 @@ + :to="{name: 'manage.users.users.list'}"> {{ $t('Users') }} diff --git a/front/src/components/manage/users/InvitationForm.vue b/front/src/components/manage/users/InvitationForm.vue new file mode 100644 index 000000000..ffd5a7d12 --- /dev/null +++ b/front/src/components/manage/users/InvitationForm.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/front/src/components/manage/users/InvitationsTable.vue b/front/src/components/manage/users/InvitationsTable.vue new file mode 100644 index 000000000..e9b46cc2c --- /dev/null +++ b/front/src/components/manage/users/InvitationsTable.vue @@ -0,0 +1,180 @@ + + + diff --git a/front/src/components/manage/users/UsersTable.vue b/front/src/components/manage/users/UsersTable.vue index 5658583c3..855fbe2b5 100644 --- a/front/src/components/manage/users/UsersTable.vue +++ b/front/src/components/manage/users/UsersTable.vue @@ -45,7 +45,7 @@