Merge branch '248-invite' into 'develop'
Resolve "Invite system" Closes #248 See merge request funkwhale/funkwhale!263
This commit is contained in:
commit
afe9ad2c91
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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")),
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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 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)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
<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>
|
||||||
|
|
|
@ -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'))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue