Merge branch '248-invite' into 'develop'

Resolve "Invite system"

Closes #248

See merge request funkwhale/funkwhale!263
This commit is contained in:
Eliot Berriot 2018-06-21 17:57:54 +00:00
commit afe9ad2c91
27 changed files with 730 additions and 67 deletions

View File

@ -461,3 +461,7 @@ MUSIC_DIRECTORY_PATH = env("MUSIC_DIRECTORY_PATH", default=None)
MUSIC_DIRECTORY_SERVE_PATH = env( MUSIC_DIRECTORY_SERVE_PATH = env(
"MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH "MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH
) )
USERS_INVITATION_EXPIRATION_DAYS = env.int(
"USERS_INVITATION_EXPIRATION_DAYS", default=14
)

View File

@ -1,6 +1,16 @@
from rest_framework import serializers 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): class ActionSerializer(serializers.Serializer):
""" """
A special serializer that can operate on a list of objects A special serializer that can operate on a list of objects
@ -11,19 +21,16 @@ class ActionSerializer(serializers.Serializer):
objects = serializers.JSONField(required=True) objects = serializers.JSONField(required=True)
filters = serializers.DictField(required=False) filters = serializers.DictField(required=False)
actions = None 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): def __init__(self, *args, **kwargs):
self.actions_by_name = {a.name: a for a in self.actions}
self.queryset = kwargs.pop("queryset") self.queryset = kwargs.pop("queryset")
if self.actions is None: if self.actions is None:
raise ValueError( raise ValueError(
"You must declare a list of actions on " "the serializer class" "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) handler_name = "handle_{}".format(action)
assert hasattr(self, handler_name), "{} miss a {} method".format( assert hasattr(self, handler_name), "{} miss a {} method".format(
self.__class__.__name__, handler_name self.__class__.__name__, handler_name
@ -31,13 +38,14 @@ class ActionSerializer(serializers.Serializer):
super().__init__(self, *args, **kwargs) super().__init__(self, *args, **kwargs)
def validate_action(self, value): def validate_action(self, value):
if value not in self.actions: try:
return self.actions_by_name[value]
except KeyError:
raise serializers.ValidationError( raise serializers.ValidationError(
"{} is not a valid action. Pick one of {}.".format( "{} 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): def validate_objects(self, value):
if value == "all": if value == "all":
@ -51,15 +59,15 @@ class ActionSerializer(serializers.Serializer):
) )
def validate(self, data): def validate(self, data):
dangerous = data["action"] in self.dangerous_actions allow_all = data["action"].allow_all
if dangerous and self.initial_data["objects"] == "all": if not allow_all and self.initial_data["objects"] == "all":
raise serializers.ValidationError( raise serializers.ValidationError(
"This action is to dangerous to be applied to all objects" "You cannot apply this action on all objects"
)
if self.filterset_class and "filters" in data:
qs_filterset = self.filterset_class(
data["filters"], queryset=data["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: try:
assert qs_filterset.form.is_valid() assert qs_filterset.form.is_valid()
except (AssertionError, TypeError): except (AssertionError, TypeError):
@ -72,12 +80,12 @@ class ActionSerializer(serializers.Serializer):
return data return data
def save(self): 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) handler = getattr(self, handler_name)
result = handler(self.validated_data["objects"]) result = handler(self.validated_data["objects"])
payload = { payload = {
"updated": self.validated_data["count"], "updated": self.validated_data["count"],
"action": self.validated_data["action"], "action": self.validated_data["action"].name,
"result": result, "result": result,
} }
return payload return payload

View File

@ -769,7 +769,7 @@ class CollectionSerializer(serializers.Serializer):
class LibraryTrackActionSerializer(common_serializers.ActionSerializer): class LibraryTrackActionSerializer(common_serializers.ActionSerializer):
actions = ["import"] actions = [common_serializers.Action("import", allow_all=True)]
filterset_class = filters.LibraryTrackFilter filterset_class = filters.LibraryTrackFilter
@transaction.atomic @transaction.atomic

View File

@ -1,4 +1,3 @@
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
from funkwhale_api.common import fields from funkwhale_api.common import fields
@ -37,3 +36,17 @@ class ManageUserFilterSet(filters.FilterSet):
"permission_settings", "permission_settings",
"permission_federation", "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)

View File

@ -61,8 +61,7 @@ class ManageTrackFileSerializer(serializers.ModelSerializer):
class ManageTrackFileActionSerializer(common_serializers.ActionSerializer): class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
actions = ["delete"] actions = [common_serializers.Action("delete", allow_all=False)]
dangerous_actions = ["delete"]
filterset_class = filters.ManageTrackFileFilterSet filterset_class = filters.ManageTrackFileFilterSet
@transaction.atomic @transaction.atomic
@ -78,6 +77,23 @@ class PermissionsSerializer(serializers.Serializer):
return {"permissions": o} 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): class ManageUserSerializer(serializers.ModelSerializer):
permissions = PermissionsSerializer(source="*") permissions = PermissionsSerializer(source="*")
@ -115,3 +131,32 @@ class ManageUserSerializer(serializers.ModelSerializer):
update_fields=["permission_{}".format(p) for p in permissions.keys()] update_fields=["permission_{}".format(p) for p in permissions.keys()]
) )
return instance 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()

View File

@ -7,6 +7,7 @@ library_router = routers.SimpleRouter()
library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files") library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files")
users_router = routers.SimpleRouter() users_router = routers.SimpleRouter()
users_router.register(r"users", views.ManageUserViewSet, "users") users_router.register(r"users", views.ManageUserViewSet, "users")
users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations")
urlpatterns = [ urlpatterns = [
url(r"^library/", include((library_router.urls, "instance"), namespace="library")), url(r"^library/", include((library_router.urls, "instance"), namespace="library")),

View File

@ -62,3 +62,37 @@ class ManageUserViewSet(
context = super().get_serializer_context() context = super().get_serializer_context()
context["default_permissions"] = preferences.get("users__default_permissions") context["default_permissions"] = preferences.get("users__default_permissions")
return context 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)

View File

@ -7,12 +7,12 @@ from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from .models import User from . import models
class MyUserChangeForm(UserChangeForm): class MyUserChangeForm(UserChangeForm):
class Meta(UserChangeForm.Meta): class Meta(UserChangeForm.Meta):
model = User model = models.User
class MyUserCreationForm(UserCreationForm): class MyUserCreationForm(UserCreationForm):
@ -22,18 +22,18 @@ class MyUserCreationForm(UserCreationForm):
) )
class Meta(UserCreationForm.Meta): class Meta(UserCreationForm.Meta):
model = User model = models.User
def clean_username(self): def clean_username(self):
username = self.cleaned_data["username"] username = self.cleaned_data["username"]
try: try:
User.objects.get(username=username) models.User.objects.get(username=username)
except User.DoesNotExist: except models.User.DoesNotExist:
return username return username
raise forms.ValidationError(self.error_messages["duplicate_username"]) raise forms.ValidationError(self.error_messages["duplicate_username"])
@admin.register(User) @admin.register(models.User)
class UserAdmin(AuthUserAdmin): class UserAdmin(AuthUserAdmin):
form = MyUserChangeForm form = MyUserChangeForm
add_form = MyUserCreationForm add_form = MyUserCreationForm
@ -74,3 +74,11 @@ class UserAdmin(AuthUserAdmin):
(_("Important dates"), {"fields": ("last_login", "date_joined")}), (_("Important dates"), {"fields": ("last_login", "date_joined")}),
(_("Useless fields"), {"fields": ("user_permissions", "groups")}), (_("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"]

View File

@ -1,5 +1,6 @@
import factory import factory
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.utils import timezone
from funkwhale_api.factories import ManyToManyFromList, registry from funkwhale_api.factories import ManyToManyFromList, registry
@ -28,6 +29,17 @@ class GroupFactory(factory.django.DjangoModelFactory):
self.permissions.add(*perms) 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 @registry.register
class UserFactory(factory.django.DjangoModelFactory): class UserFactory(factory.django.DjangoModelFactory):
username = factory.Sequence(lambda n: "user-{0}".format(n)) username = factory.Sequence(lambda n: "user-{0}".format(n))
@ -40,6 +52,9 @@ class UserFactory(factory.django.DjangoModelFactory):
model = "users.User" model = "users.User"
django_get_or_create = ("username",) django_get_or_create = ("username",)
class Params:
invited = factory.Trait(invitation=factory.SubFactory(InvitationFactory))
@factory.post_generation @factory.post_generation
def perms(self, create, extracted, **kwargs): def perms(self, create, extracted, **kwargs):
if not create: if not create:

View File

@ -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'),
),
]

View File

@ -4,6 +4,8 @@ from __future__ import absolute_import, unicode_literals
import binascii import binascii
import datetime import datetime
import os import os
import random
import string
import uuid import uuid
from django.conf import settings from django.conf import settings
@ -79,6 +81,14 @@ class User(AbstractUser):
last_activity = models.DateTimeField(default=None, null=True, blank=True) 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): def __str__(self):
return self.username return self.username
@ -138,3 +148,40 @@ class User(AbstractUser):
if current is None or current < now - datetime.timedelta(seconds=delay): if current is None or current < now - datetime.timedelta(seconds=delay):
self.last_activity = now self.last_activity = now
self.save(update_fields=["last_activity"]) 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)

View File

@ -1,5 +1,6 @@
from django.conf import settings from django.conf import settings
from rest_auth.serializers import PasswordResetSerializer as PRS from rest_auth.serializers import PasswordResetSerializer as PRS
from rest_auth.registration.serializers import RegisterSerializer as RS
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_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 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): class UserActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField() type = serializers.SerializerMethodField()
name = serializers.CharField(source="username") name = serializers.CharField(source="username")

View File

@ -10,8 +10,11 @@ from . import models, serializers
class RegisterView(BaseRegisterView): class RegisterView(BaseRegisterView):
serializer_class = serializers.RegisterSerializer
def create(self, request, *args, **kwargs): 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"} r = {"detail": "Registration has been disabled"}
return Response(r, status=403) return Response(r, status=403)
return super().create(request, *args, **kwargs) return super().create(request, *args, **kwargs)

View File

@ -11,7 +11,7 @@ class TestActionFilterSet(django_filters.FilterSet):
class TestSerializer(serializers.ActionSerializer): class TestSerializer(serializers.ActionSerializer):
actions = ["test"] actions = [serializers.Action("test", allow_all=True)]
filterset_class = TestActionFilterSet filterset_class = TestActionFilterSet
def handle_test(self, objects): def handle_test(self, objects):
@ -19,8 +19,10 @@ class TestSerializer(serializers.ActionSerializer):
class TestDangerousSerializer(serializers.ActionSerializer): class TestDangerousSerializer(serializers.ActionSerializer):
actions = ["test", "test_dangerous"] actions = [
dangerous_actions = ["test_dangerous"] serializers.Action("test", allow_all=True),
serializers.Action("test_dangerous"),
]
def handle_test(self, objects): def handle_test(self, objects):
pass pass
@ -29,6 +31,14 @@ class TestDangerousSerializer(serializers.ActionSerializer):
pass 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(): def test_action_serializer_validates_action():
data = {"objects": "all", "action": "nope"} data = {"objects": "all", "action": "nope"}
serializer = TestSerializer(data, queryset=models.User.objects.none()) 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"} data = {"objects": [user1.pk], "action": "test"}
serializer = TestSerializer(data, queryset=models.User.objects.all()) 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] assert list(serializer.validated_data["objects"]) == [user1]
@ -63,7 +73,7 @@ def test_action_serializers_objects_clean_all(factories):
data = {"objects": "all", "action": "test"} data = {"objects": "all", "action": "test"}
serializer = TestSerializer(data, queryset=models.User.objects.all()) 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] assert list(serializer.validated_data["objects"]) == [user1, user2]
@ -75,7 +85,7 @@ def test_action_serializers_save(factories, mocker):
data = {"objects": "all", "action": "test"} data = {"objects": "all", "action": "test"}
serializer = TestSerializer(data, queryset=models.User.objects.all()) 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() result = serializer.save()
assert result == {"updated": 2, "action": "test", "result": {"hello": "world"}} assert result == {"updated": 2, "action": "test", "result": {"hello": "world"}}
handler.assert_called_once() handler.assert_called_once()
@ -88,7 +98,7 @@ def test_action_serializers_filterset(factories):
data = {"objects": "all", "action": "test", "filters": {"is_active": True}} data = {"objects": "all", "action": "test", "filters": {"is_active": True}}
serializer = TestSerializer(data, queryset=models.User.objects.all()) 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] 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 assert "non_field_errors" in serializer.errors
def test_dangerous_actions_refuses_not_listed(factories): def test_action_serializers_can_require_filter(factories):
factories["users.User"]() user1 = factories["users.User"](is_active=False)
data = {"objects": "all", "action": "test"} factories["users.User"](is_active=True)
serializer = TestDangerousSerializer(data, queryset=models.User.objects.all())
assert serializer.is_valid() is True data = {"objects": "all", "action": "test"}
serializer = TestDeleteOnlyInactiveSerializer(
data, queryset=models.User.objects.all()
)
assert serializer.is_valid(raise_exception=True) is True
assert list(serializer.validated_data["objects"]) == [user1]

View File

@ -9,6 +9,7 @@ from funkwhale_api.manage import serializers, views
[ [
(views.ManageTrackFileViewSet, ["library"], "and"), (views.ManageTrackFileViewSet, ["library"], "and"),
(views.ManageUserViewSet, ["settings"], "and"), (views.ManageUserViewSet, ["settings"], "and"),
(views.ManageInvitationViewSet, ["settings"], "and"),
], ],
) )
def test_permissions(assert_user_permission, view, permissions, operator): 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["count"] == len(users)
assert response.data["results"] == expected 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

View File

@ -1,3 +1,4 @@
import datetime
import pytest import pytest
from funkwhale_api.users import models from funkwhale_api.users import models
@ -95,3 +96,34 @@ def test_record_activity_does_nothing_if_already(factories, now, mocker):
user.record_activity() user.record_activity()
save.assert_not_called() 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]

View File

@ -50,6 +50,39 @@ def test_can_disable_registration_view(preferences, api_client, db):
assert response.status_code == 403 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): def test_can_fetch_data_from_api(api_client, factories):
url = reverse("api:v1:users:users-me") url = reverse("api:v1:users:users-me")
response = api_client.get(url) response = api_client.get(url)

View File

@ -99,7 +99,7 @@
<router-link <router-link
class="item" class="item"
v-if="$store.state.auth.availablePermissions['settings']" v-if="$store.state.auth.availablePermissions['settings']"
:to="{path: '/manage/users'}"> :to="{name: 'manage.users.users.list'}">
<i class="users icon"></i>{{ $t('Users') }} <i class="users icon"></i>{{ $t('Users') }}
</router-link> </router-link>
</div> </div>

View File

@ -2,19 +2,22 @@
<div class="main pusher" v-title="'Sign Up'"> <div class="main pusher" v-title="'Sign Up'">
<div class="ui vertical stripe segment"> <div class="ui vertical stripe segment">
<div class="ui small text container"> <div class="ui small text container">
<h2><i18next path="Create a funkwhale account"/></h2> <h2>{{ $t("Create a funkwhale account") }}</h2>
<form <form
v-if="$store.state.instance.settings.users.registration_enabled.value"
:class="['ui', {'loading': isLoadingInstanceSetting}, 'form']" :class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
@submit.prevent="submit()"> @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 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"> <ul class="list">
<li v-for="error in errors">{{ error }}</li> <li v-for="error in errors">{{ error }}</li>
</ul> </ul>
</div> </div>
<div class="field"> <div class="field">
<i18next tag="label" path="Username"/> <label>{{ $t("Username") }}</label>
<input <input
ref="username" ref="username"
required required
@ -24,7 +27,7 @@
v-model="username"> v-model="username">
</div> </div>
<div class="field"> <div class="field">
<i18next tag="label" path="Email"/> <label>{{ $t("Email") }}</label>
<input <input
ref="email" ref="email"
required required
@ -33,12 +36,22 @@
v-model="email"> v-model="email">
</div> </div>
<div class="field"> <div class="field">
<i18next tag="label" path="Password"/> <label>{{ $t("Password") }}</label>
<password-input v-model="password" /> <password-input v-model="password" />
</div> </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> </form>
<i18next v-else tag="p" path="Registration is currently disabled on this instance, please try again later."/>
</div> </div>
</div> </div>
</div> </div>
@ -51,13 +64,13 @@ import logger from '@/logging'
import PasswordInput from '@/components/forms/PasswordInput' import PasswordInput from '@/components/forms/PasswordInput'
export default { export default {
name: 'login', props: {
invitation: {type: String, required: false, default: null},
next: {type: String, default: '/'}
},
components: { components: {
PasswordInput PasswordInput
}, },
props: {
next: {type: String, default: '/'}
},
data () { data () {
return { return {
username: '', username: '',
@ -85,7 +98,8 @@ export default {
username: this.username, username: this.username,
password1: this.password, password1: this.password,
password2: this.password, password2: this.password,
email: this.email email: this.email,
invitation: this.invitation
} }
return axios.post('auth/registration/', payload).then(response => { return axios.post('auth/registration/', payload).then(response => {
logger.default.info('Successfully created account') logger.default.info('Successfully created account')

View File

@ -36,7 +36,7 @@
<div class="count field"> <div class="count field">
<span v-if="selectAll">{{ $t('{% count %} on {% total %} selected', {count: objectsData.count, total: objectsData.count}) }}</span> <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> <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"> <a @click="selectAll = true" v-if="!selectAll">
{{ $t('Select all {% total %} elements', {total: objectsData.count}) }} {{ $t('Select all {% total %} elements', {total: objectsData.count}) }}
</a> </a>
@ -157,6 +157,7 @@ export default {
let self = this let self = this
self.actionLoading = true self.actionLoading = true
self.result = null self.result = null
self.actionErrors = []
let payload = { let payload = {
action: this.currentActionName, action: this.currentActionName,
filters: this.filters filters: this.filters

View File

@ -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>

View File

@ -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>

View File

@ -45,7 +45,7 @@
</template> </template>
<template slot="row-cells" slot-scope="scope"> <template slot="row-cells" slot-scope="scope">
<td> <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>
<td> <td>
<span>{{ scope.obj.email }}</span> <span>{{ scope.obj.email }}</span>

View File

@ -86,6 +86,9 @@ axios.interceptors.response.use(function (response) {
} else if (error.response.status === 500) { } else if (error.response.status === 500) {
error.backendErrors.push('A server error occured') error.backendErrors.push('A server error occured')
} else if (error.response.data) { } else if (error.response.data) {
if (error.response.data.detail) {
error.backendErrors.push(error.response.data.detail)
} else {
for (var field in error.response.data) { for (var field in error.response.data) {
if (error.response.data.hasOwnProperty(field)) { if (error.response.data.hasOwnProperty(field)) {
error.response.data[field].forEach(e => { error.response.data[field].forEach(e => {
@ -94,6 +97,7 @@ axios.interceptors.response.use(function (response) {
} }
} }
} }
}
if (error.backendErrors.length === 0) { if (error.backendErrors.length === 0) {
error.backendErrors.push(i18next.t('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running')) error.backendErrors.push(i18next.t('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running'))
} }

View File

@ -34,6 +34,7 @@ import AdminLibraryFilesList from '@/views/admin/library/FilesList'
import AdminUsersBase from '@/views/admin/users/Base' import AdminUsersBase from '@/views/admin/users/Base'
import AdminUsersDetail from '@/views/admin/users/UsersDetail' import AdminUsersDetail from '@/views/admin/users/UsersDetail'
import AdminUsersList from '@/views/admin/users/UsersList' import AdminUsersList from '@/views/admin/users/UsersList'
import AdminInvitationsList from '@/views/admin/users/InvitationsList'
import FederationBase from '@/views/federation/Base' import FederationBase from '@/views/federation/Base'
import FederationScan from '@/views/federation/Scan' import FederationScan from '@/views/federation/Scan'
import FederationLibraryDetail from '@/views/federation/LibraryDetail' import FederationLibraryDetail from '@/views/federation/LibraryDetail'
@ -96,7 +97,10 @@ export default new Router({
{ {
path: '/signup', path: '/signup',
name: 'signup', name: 'signup',
component: Signup component: Signup,
props: (route) => ({
invitation: route.query.invitation
})
}, },
{ {
path: '/logout', path: '/logout',
@ -188,15 +192,20 @@ export default new Router({
component: AdminUsersBase, component: AdminUsersBase,
children: [ children: [
{ {
path: '', path: 'users',
name: 'manage.users.list', name: 'manage.users.users.list',
component: AdminUsersList component: AdminUsersList
}, },
{ {
path: ':id', path: 'users/:id',
name: 'manage.users.detail', name: 'manage.users.users.detail',
component: AdminUsersDetail, component: AdminUsersDetail,
props: true props: true
},
{
path: 'invitations',
name: 'manage.users.invitations.list',
component: AdminInvitationsList
} }
] ]
}, },

View File

@ -3,7 +3,10 @@
<div class="ui secondary pointing menu"> <div class="ui secondary pointing menu">
<router-link <router-link
class="ui item" 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> </div>
<router-view :key="$route.fullPath"></router-view> <router-view :key="$route.fullPath"></router-view>
</div> </div>

View File

@ -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>