kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
				
				
				
			Merge branch '248-invite' into 'develop'
Resolve "Invite system" Closes #248 See merge request funkwhale/funkwhale!263merge-requests/267/head
						commit
						afe9ad2c91
					
				| 
						 | 
				
			
			@ -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
 | 
			
		||||
)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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")),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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"]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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'),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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"]()
 | 
			
		||||
    data = {"objects": "all", "action": "test"}
 | 
			
		||||
    serializer = TestDangerousSerializer(data, queryset=models.User.objects.all())
 | 
			
		||||
def test_action_serializers_can_require_filter(factories):
 | 
			
		||||
    user1 = factories["users.User"](is_active=False)
 | 
			
		||||
    factories["users.User"](is_active=True)
 | 
			
		||||
 | 
			
		||||
    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]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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',
 | 
			
		||||
  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')
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
          })
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
		Ładowanie…
	
		Reference in New Issue