Merge branch '1040-signup-screening' into 'develop'

Resolve "Screening for signups"

Closes #1040

See merge request funkwhale/funkwhale!1056
This commit is contained in:
Eliot Berriot 2020-03-18 11:57:34 +01:00
commit f8baae53fd
49 changed files with 1759 additions and 49 deletions

View File

@ -135,7 +135,7 @@ test_api:
only:
- branches
before_script:
- apk add make git
- apk add make git gcc python3-dev
- cd api
- pip3 install -r requirements/base.txt
- pip3 install -r requirements/local.txt

View File

@ -659,6 +659,8 @@ AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
if env.bool("DISABLE_PASSWORD_VALIDATORS", default=False):
AUTH_PASSWORD_VALIDATORS = []
ACCOUNT_ADAPTER = "funkwhale_api.users.adapters.FunkwhaleAccountAdapter"
CORS_ORIGIN_ALLOW_ALL = True
# CORS_ORIGIN_WHITELIST = (

View File

@ -1,4 +1,7 @@
import json
from django import forms
from django.contrib.postgres.forms import JSONField
from django.conf import settings
from dynamic_preferences import serializers, types
from dynamic_preferences.registries import global_preferences_registry
@ -57,3 +60,48 @@ class StringListPreference(types.BasePreferenceType):
d = super(StringListPreference, self).get_api_additional_data()
d["choices"] = self.get("choices")
return d
class JSONSerializer(serializers.BaseSerializer):
required = True
@classmethod
def to_db(cls, value, **kwargs):
if not cls.required and value is None:
return json.dumps(value)
data_serializer = cls.data_serializer_class(data=value)
if not data_serializer.is_valid():
raise cls.exception(
"{} is not a valid value: {}".format(value, data_serializer.errors)
)
value = data_serializer.validated_data
try:
return json.dumps(value, sort_keys=True)
except TypeError:
raise cls.exception(
"Cannot serialize, value {} is not JSON serializable".format(value)
)
@classmethod
def to_python(cls, value, **kwargs):
return json.loads(value)
class SerializedPreference(types.BasePreferenceType):
"""
A preference that store arbitrary JSON and validate it using a rest_framework
serializer
"""
serializer = JSONSerializer
data_serializer_class = None
field_class = JSONField
widget = forms.Textarea
@property
def serializer(self):
class _internal(JSONSerializer):
data_serializer_class = self.data_serializer_class
required = self.get("required")
return _internal

View File

@ -0,0 +1,45 @@
# Generated by Django 3.0.4 on 2020-03-17 08:20
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('federation', '0024_actor_attachment_icon'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='object_content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objecting_activities', to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='activity',
name='object_id',
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='activity',
name='related_object_content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_objecting_activities', to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='activity',
name='related_object_id',
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='activity',
name='target_content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='targeting_activities', to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='activity',
name='target_id',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -292,6 +292,9 @@ class Actor(models.Model):
from_activity__actor=self.pk
).count()
data["reports"] = moderation_models.Report.objects.get_for_target(self).count()
data["requests"] = moderation_models.UserRequest.objects.filter(
submitter=self
).count()
data["albums"] = music_models.Album.objects.filter(
from_activity__actor=self.pk
).count()

View File

@ -394,3 +394,26 @@ class ManageNoteFilterSet(filters.FilterSet):
class Meta:
model = moderation_models.Note
fields = ["q"]
class ManageUserRequestFilterSet(filters.FilterSet):
q = fields.SmartSearchFilter(
config=search.SearchConfig(
search_fields={
"username": {"to": "submitter__preferred_username"},
"uuid": {"to": "uuid"},
},
filter_fields={
"uuid": {"to": "uuid"},
"id": {"to": "id"},
"status": {"to": "status"},
"category": {"to": "type"},
"submitter": get_actor_filter("submitter"),
"assigned_to": get_actor_filter("assigned_to"),
},
)
)
class Meta:
model = moderation_models.UserRequest
fields = ["q", "status", "type"]

View File

@ -710,3 +710,36 @@ class ManageReportSerializer(serializers.ModelSerializer):
def get_notes(self, o):
notes = getattr(o, "_prefetched_notes", [])
return ManageBaseNoteSerializer(notes, many=True).data
class ManageUserRequestSerializer(serializers.ModelSerializer):
assigned_to = ManageBaseActorSerializer()
submitter = ManageBaseActorSerializer()
notes = serializers.SerializerMethodField()
class Meta:
model = moderation_models.UserRequest
fields = [
"id",
"uuid",
"creation_date",
"handled_date",
"type",
"status",
"assigned_to",
"submitter",
"notes",
"metadata",
]
read_only_fields = [
"id",
"uuid",
"submitter",
"creation_date",
"handled_date",
"metadata",
]
def get_notes(self, o):
notes = getattr(o, "_prefetched_notes", [])
return ManageBaseNoteSerializer(notes, many=True).data

View File

@ -18,6 +18,7 @@ moderation_router.register(
r"instance-policies", views.ManageInstancePolicyViewSet, "instance-policies"
)
moderation_router.register(r"reports", views.ManageReportViewSet, "reports")
moderation_router.register(r"requests", views.ManageUserRequestViewSet, "requests")
moderation_router.register(r"notes", views.ManageNoteViewSet, "notes")
users_router = routers.OptionalSlashRouter()

View File

@ -1,12 +1,14 @@
from rest_framework import mixins, response, viewsets
from rest_framework import decorators as rest_decorators
from django.db import transaction
from django.db.models import Count, Prefetch, Q, Sum, OuterRef, Subquery
from django.db.models.functions import Coalesce, Length
from django.shortcuts import get_object_or_404
from funkwhale_api.common import models as common_models
from funkwhale_api.common import preferences, decorators
from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import tasks as federation_tasks
@ -14,6 +16,7 @@ from funkwhale_api.history import models as history_models
from funkwhale_api.music import models as music_models
from funkwhale_api.music import views as music_views
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import tasks as moderation_tasks
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.tags import models as tags_models
from funkwhale_api.users import models as users_models
@ -469,8 +472,8 @@ class ManageActorViewSet(
@rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs):
domain = self.get_object()
return response.Response(domain.get_stats(), status=200)
obj = self.get_object()
return response.Response(obj.get_stats(), status=200)
action = decorators.action_route(serializers.ManageActorActionSerializer)
@ -607,3 +610,54 @@ class ManageTagViewSet(
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)
class ManageUserRequestViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
moderation_models.UserRequest.objects.all()
.order_by("-creation_date")
.select_related("submitter", "assigned_to")
.prefetch_related(
Prefetch(
"notes",
queryset=moderation_models.Note.objects.order_by(
"creation_date"
).select_related("author"),
to_attr="_prefetched_notes",
)
)
)
serializer_class = serializers.ManageUserRequestSerializer
filterset_class = filters.ManageUserRequestFilterSet
required_scope = "instance:requests"
ordering_fields = ["id", "creation_date", "handled_date"]
def get_queryset(self):
queryset = super().get_queryset()
if self.action in ["update", "partial_update"]:
# approved requests cannot be edited
queryset = queryset.exclude(status="approved")
return queryset
@transaction.atomic
def perform_update(self, serializer):
old_status = serializer.instance.status
new_status = serializer.validated_data.get("status")
if old_status != new_status and new_status != "pending":
# report was resolved, we assign to the mod making the request
serializer.save(assigned_to=self.request.user.actor)
common_utils.on_commit(
moderation_tasks.user_request_handle.delay,
user_request_id=serializer.instance.pk,
new_status=new_status,
old_status=old_status,
)
else:
serializer.save()

View File

@ -1,7 +1,11 @@
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
from rest_framework import serializers
from funkwhale_api.common import preferences as common_preferences
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
from . import models
@ -40,3 +44,52 @@ class UnauthenticatedReportTypes(common_preferences.StringListPreference):
help_text = "A list of categories for which external users (without an account) can submit a report"
choices = models.REPORT_TYPES
field_kwargs = {"choices": choices, "required": False}
@global_preferences_registry.register
class SignupApprovalEnabled(types.BooleanPreference):
show_in_api = True
section = moderation
name = "signup_approval_enabled"
verbose_name = "Enable manual sign-up validation"
help_text = "If enabled, new registrations will go to a moderation queue and need to be reviewed by moderators."
default = False
CUSTOM_FIELDS_TYPES = [
"short_text",
"long_text",
]
class CustomFieldSerializer(serializers.Serializer):
label = serializers.CharField()
required = serializers.BooleanField(default=True)
input_type = serializers.ChoiceField(choices=CUSTOM_FIELDS_TYPES)
class CustomFormSerializer(serializers.Serializer):
help_text = common_serializers.ContentSerializer(required=False, allow_null=True)
fields = serializers.ListField(
child=CustomFieldSerializer(), min_length=0, max_length=10, required=False
)
def validate_help_text(self, v):
if not v:
return
v["html"] = common_utils.render_html(
v["text"], content_type=v["content_type"], permissive=True
)
return v
@global_preferences_registry.register
class SignupFormCustomization(common_preferences.SerializedPreference):
show_in_api = True
section = moderation
name = "signup_form_customization"
verbose_name = "Sign-up form customization"
help_text = "Configure custom fields and help text for your sign-up form"
required = False
default = {}
data_serializer_class = CustomFormSerializer

View File

@ -74,3 +74,20 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
return
self.target_owner = serializers.get_target_owner(self.target)
@registry.register
class UserRequestFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
submitter = factory.SubFactory(federation_factories.ActorFactory, local=True)
class Meta:
model = "moderation.UserRequest"
class Params:
signup = factory.Trait(
submitter=factory.SubFactory(federation_factories.ActorFactory, local=True),
type="signup",
)
assigned = factory.Trait(
assigned_to=factory.SubFactory(federation_factories.ActorFactory)
)

View File

@ -0,0 +1,41 @@
# Generated by Django 3.0.4 on 2020-03-17 08:20
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
('federation', '0025_auto_20200317_0820'),
('moderation', '0004_note'),
]
operations = [
migrations.AlterField(
model_name='report',
name='summary',
field=models.TextField(blank=True, max_length=50000, null=True),
),
migrations.CreateModel(
name='UserRequest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(blank=True, max_length=500, null=True)),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('handled_date', models.DateTimeField(null=True)),
('type', models.CharField(choices=[('signup', 'Sign-up')], max_length=40)),
('status', models.CharField(choices=[('pending', 'Pending'), ('refused', 'Refused'), ('approved', 'approved')], default='pending', max_length=40)),
('metadata', django.contrib.postgres.fields.jsonb.JSONField(null=True)),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_requests', to='federation.Actor')),
('submitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requests', to='federation.Actor')),
],
options={
'abstract': False,
},
),
]

View File

@ -185,6 +185,43 @@ class Note(models.Model):
target = GenericForeignKey("target_content_type", "target_id")
USER_REQUEST_TYPES = [
("signup", "Sign-up"),
]
USER_REQUEST_STATUSES = [
("pending", "Pending"),
("refused", "Refused"),
("approved", "Approved"),
]
class UserRequest(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
handled_date = models.DateTimeField(null=True)
type = models.CharField(max_length=40, choices=USER_REQUEST_TYPES)
status = models.CharField(
max_length=40, choices=USER_REQUEST_STATUSES, default="pending"
)
submitter = models.ForeignKey(
"federation.Actor", related_name="requests", on_delete=models.CASCADE,
)
assigned_to = models.ForeignKey(
"federation.Actor",
related_name="assigned_requests",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
metadata = JSONField(null=True)
notes = GenericRelation(
"Note", content_type_field="target_content_type", object_id_field="target_id"
)
@receiver(pre_save, sender=Report)
def set_handled_date(sender, instance, **kwargs):
if instance.is_handled is True and not instance.handled_date:

View File

@ -1,9 +1,11 @@
import logging
from django.core import mail
from django.dispatch import receiver
from django.conf import settings
from django.db import transaction
from django.dispatch import receiver
from funkwhale_api.common import channels
from funkwhale_api.common import preferences
from funkwhale_api.common import utils
from funkwhale_api.taskapp import celery
from funkwhale_api.federation import utils as federation_utils
@ -41,11 +43,7 @@ def trigger_moderator_email(report, **kwargs):
utils.on_commit(send_new_report_email_to_moderators.delay, report_id=report.pk)
@celery.app.task(name="moderation.send_new_report_email_to_moderators")
@celery.require_instance(
models.Report.objects.select_related("submitter").filter(is_handled=False), "report"
)
def send_new_report_email_to_moderators(report):
def get_moderators():
moderators = users_models.User.objects.filter(
is_active=True, permission_moderation=True
)
@ -53,6 +51,15 @@ def send_new_report_email_to_moderators(report):
# we fallback on superusers
moderators = users_models.User.objects.filter(is_superuser=True)
moderators = sorted(moderators, key=lambda m: m.pk)
return moderators
@celery.app.task(name="moderation.send_new_report_email_to_moderators")
@celery.require_instance(
models.Report.objects.select_related("submitter").filter(is_handled=False), "report"
)
def send_new_report_email_to_moderators(report):
moderators = get_moderators()
submitter_repr = (
report.submitter.full_username if report.submitter else report.submitter_email
)
@ -114,3 +121,148 @@ def send_new_report_email_to_moderators(report):
recipient_list=[moderator.email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
@celery.app.task(name="moderation.user_request_handle")
@celery.require_instance(
models.UserRequest.objects.select_related("submitter"), "user_request"
)
@transaction.atomic
def user_request_handle(user_request, new_status, old_status=None):
if user_request.status != new_status:
logger.warn(
"User request %s was handled before asynchronous tasks run", user_request.pk
)
return
if user_request.type == "signup" and new_status == "pending" and old_status is None:
notify_mods_signup_request_pending(user_request)
broadcast_user_request_created(user_request)
elif user_request.type == "signup" and new_status == "approved":
user_request.submitter.user.is_active = True
user_request.submitter.user.save(update_fields=["is_active"])
notify_submitter_signup_request_approved(user_request)
elif user_request.type == "signup" and new_status == "refused":
notify_submitter_signup_request_refused(user_request)
def broadcast_user_request_created(user_request):
from funkwhale_api.manage import serializers as manage_serializers
channels.group_send(
"admin.moderation",
{
"type": "event.send",
"text": "",
"data": {
"type": "user_request.created",
"user_request": manage_serializers.ManageUserRequestSerializer(
user_request
).data,
"pending_count": models.UserRequest.objects.filter(
status="pending"
).count(),
},
},
)
def notify_mods_signup_request_pending(obj):
moderators = get_moderators()
submitter_repr = obj.submitter.preferred_username
subject = "[{} moderation] New sign-up request from {}".format(
settings.FUNKWHALE_HOSTNAME, submitter_repr
)
detail_url = federation_utils.full_url(
"/manage/moderation/requests/{}".format(obj.uuid)
)
unresolved_requests_url = federation_utils.full_url(
"/manage/moderation/requests?q=status:pending"
)
unresolved_requests = models.UserRequest.objects.filter(status="pending").count()
body = [
"{} wants to register on your pod. You need to review their request before they can use the service.".format(
submitter_repr
),
"",
"- To handle this request, please visit {}".format(detail_url),
"- To view all unresolved requests (currently {}), please visit {}".format(
unresolved_requests, unresolved_requests_url
),
"",
"",
"",
"You are receiving this email because you are a moderator for {}.".format(
settings.FUNKWHALE_HOSTNAME
),
]
for moderator in moderators:
if not moderator.email:
logger.warning("Moderator %s has no email configured", moderator.username)
continue
mail.send_mail(
subject,
message="\n".join(body),
recipient_list=[moderator.email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
def notify_submitter_signup_request_approved(user_request):
submitter_repr = user_request.submitter.preferred_username
submitter_email = user_request.submitter.user.email
if not submitter_email:
logger.warning("User %s has no email configured", submitter_repr)
return
subject = "Welcome to {}, {}!".format(settings.FUNKWHALE_HOSTNAME, submitter_repr)
login_url = federation_utils.full_url("/login")
body = [
"Hi {} and welcome,".format(submitter_repr),
"",
"Our moderation team has approved your account request and you can now start "
"using the service. Please visit {} to get started.".format(login_url),
"",
"Before your first login, you may need to verify your email address if you didn't already.",
]
mail.send_mail(
subject,
message="\n".join(body),
recipient_list=[submitter_email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
def notify_submitter_signup_request_refused(user_request):
submitter_repr = user_request.submitter.preferred_username
submitter_email = user_request.submitter.user.email
if not submitter_email:
logger.warning("User %s has no email configured", submitter_repr)
return
subject = "Your account request at {} was refused".format(
settings.FUNKWHALE_HOSTNAME
)
body = [
"Hi {},".format(submitter_repr),
"",
"You recently submitted an account request on our service. However, our "
"moderation team has refused it, and as a result, you won't be able to use "
"the service.",
]
instance_contact_email = preferences.get("instance__contact_email")
if instance_contact_email:
body += [
"",
"If you think this is a mistake, please contact our team at {}.".format(
instance_contact_email
),
]
mail.send_mail(
subject,
message="\n".join(body),
recipient_list=[submitter_email],
from_email=settings.DEFAULT_FROM_EMAIL,
)

View File

@ -12,6 +12,11 @@ NOTE_TARGET_FIELDS = {
"id_attr": "uuid",
"id_field": serializers.UUIDField(),
},
"request": {
"queryset": models.UserRequest.objects.all(),
"id_attr": "uuid",
"id_field": serializers.UUIDField(),
},
"account": {
"queryset": federation_models.Actor.objects.all(),
"id_attr": "full_username",
@ -19,3 +24,21 @@ NOTE_TARGET_FIELDS = {
"get_query": moderation_serializers.get_actor_query,
},
}
def get_signup_form_additional_fields_serializer(customization):
fields = (customization or {}).get("fields", []) or []
class AdditionalFieldsSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in fields:
required = bool(field.get("required", True))
self.fields[field["label"]] = serializers.CharField(
max_length=5000,
required=required,
allow_null=not required,
allow_blank=not required,
)
return AdditionalFieldsSerializer(required=fields, allow_null=not fields)

View File

@ -46,6 +46,8 @@ PERMISSIONS_CONFIGURATION = {
"write:instance:domains",
"read:instance:reports",
"write:instance:reports",
"read:instance:requests",
"write:instance:requests",
"read:instance:notes",
"write:instance:notes",
},

View File

@ -35,6 +35,7 @@ BASE_SCOPES = [
Scope("instance:domains", "Access instance domains"),
Scope("instance:policies", "Access instance moderation policies"),
Scope("instance:reports", "Access instance moderation reports"),
Scope("instance:requests", "Access instance moderation requests"),
Scope("instance:notes", "Access instance moderation notes"),
]
SCOPES = [

View File

@ -10,9 +10,14 @@ from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.common import models as common_models
from funkwhale_api.common import preferences
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import models as federation_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import tasks as moderation_tasks
from funkwhale_api.moderation import utils as moderation_utils
from . import adapters
from . import models
@ -36,6 +41,17 @@ class RegisterSerializer(RS):
required=False, allow_null=True, allow_blank=True
)
def __init__(self, *args, **kwargs):
self.approval_enabled = preferences.get("moderation__signup_approval_enabled")
super().__init__(*args, **kwargs)
if self.approval_enabled:
customization = preferences.get("moderation__signup_form_customization")
self.fields[
"request_fields"
] = moderation_utils.get_signup_form_additional_fields_serializer(
customization
)
def validate_invitation(self, value):
if not value:
return
@ -67,11 +83,28 @@ class RegisterSerializer(RS):
def save(self, request):
user = super().save(request)
update_fields = ["actor"]
user.actor = models.create_actor(user)
user_request = None
if self.approval_enabled:
# manually approve users
user.is_active = False
user_request = moderation_models.UserRequest.objects.create(
submitter=user.actor,
type="signup",
metadata=self.validated_data.get("request_fields", None) or None,
)
update_fields.append("is_active")
if self.validated_data.get("invitation"):
user.invitation = self.validated_data.get("invitation")
user.save(update_fields=["invitation"])
user.actor = models.create_actor(user)
user.save(update_fields=["actor"])
update_fields.append("invitation")
user.save(update_fields=update_fields)
if user_request:
common_utils.on_commit(
moderation_tasks.user_request_handle.delay,
user_request_id=user_request.pk,
new_status=user_request.status,
)
return user

View File

@ -13,6 +13,7 @@ exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
[tool:pytest]
python_files = tests.py test_*.py *_tests.py
testpaths = tests
addopts = -p no:warnings
env =
SECRET_KEY=test
EMAIL_CONFIG=consolemail://

View File

@ -1,6 +1,10 @@
import pytest
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
from rest_framework import serializers
from funkwhale_api.common import preferences as common_preferences
@ -43,3 +47,44 @@ def test_string_list_pref_default(string_list_pref, preferences):
def test_string_list_pref_set(string_list_pref, preferences):
preferences["test__string_list"] = ["world", "hello"]
assert preferences["test__string_list"] == ["hello", "world"]
class PreferenceDataSerializer(serializers.Serializer):
name = serializers.CharField()
optional = serializers.BooleanField(required=False)
@pytest.fixture
def serialized_preference(db):
@global_preferences_registry.register
class TestSerialized(common_preferences.SerializedPreference):
section = types.Section("test")
name = "serialized"
data_serializer_class = PreferenceDataSerializer
default = None
required = False
yield
del global_preferences_registry["test"]["serialized"]
@pytest.mark.parametrize(
"value", [{"name": "hello"}, {"name": "hello", "optional": True}]
)
def test_get_serialized_preference(value, preferences, serialized_preference):
pref_id = "test__serialized"
# default value
assert preferences[pref_id] is None
preferences[pref_id] = value
assert preferences[pref_id] == value
@pytest.mark.parametrize(
"value", [{"noop": "hello"}, {"name": "hello", "optional": None}, "noop"]
)
def test_get_serialized_preference_error(value, preferences, serialized_preference):
pref_id = "test__serialized"
with pytest.raises(common_preferences.JSONSerializer.exception):
preferences[pref_id] = value

View File

@ -148,6 +148,7 @@ def test_actor_stats(factories):
"uploads": 0,
"artists": 0,
"reports": 0,
"requests": 0,
"outbox_activities": 0,
"received_library_follows": 0,
"emitted_library_follows": 0,

View File

@ -562,3 +562,26 @@ def test_manage_note_serializer(factories, to_api_date):
s = serializers.ManageNoteSerializer(note)
assert s.data == expected
def test_manage_user_request_serializer(factories, to_api_date):
user_request = factories["moderation.UserRequest"](
signup=True, metadata={"foo": "bar"}, assigned=True
)
expected = {
"id": user_request.id,
"uuid": str(user_request.uuid),
"creation_date": to_api_date(user_request.creation_date),
"handled_date": None,
"status": user_request.status,
"type": user_request.type,
"submitter": serializers.ManageBaseActorSerializer(user_request.submitter).data,
"assigned_to": serializers.ManageBaseActorSerializer(
user_request.assigned_to
).data,
"metadata": {"foo": "bar"},
"notes": [],
}
s = serializers.ManageUserRequestSerializer(user_request)
assert s.data == expected

View File

@ -3,6 +3,7 @@ from django.urls import reverse
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.manage import serializers
from funkwhale_api.moderation import tasks as moderation_tasks
def test_user_view(factories, superuser_api_client, mocker):
@ -409,7 +410,7 @@ def test_upload_delete(factories, superuser_api_client):
assert response.status_code == 204
def test_note_create(factories, superuser_api_client):
def test_note_create_actor(factories, superuser_api_client):
actor = superuser_api_client.user.create_actor()
target = factories["federation.Actor"]()
data = {
@ -425,6 +426,22 @@ def test_note_create(factories, superuser_api_client):
assert response.data == serializers.ManageNoteSerializer(note).data
def test_note_create_user_request(factories, superuser_api_client):
actor = superuser_api_client.user.create_actor()
target = factories["moderation.UserRequest"]()
data = {
"summary": "Hello",
"target": {"type": "request", "uuid": target.uuid},
}
url = reverse("api:v1:manage:moderation:notes-list")
response = superuser_api_client.post(url, data, format="json")
assert response.status_code == 201
note = actor.moderation_notes.latest("id")
assert note.target == target
assert response.data == serializers.ManageNoteSerializer(note).data
def test_note_list(factories, superuser_api_client, settings):
note = factories["moderation.Note"]()
url = reverse("api:v1:manage:moderation:notes-list")
@ -527,3 +544,58 @@ def test_report_update_is_handled_true_assigns(factories, superuser_api_client):
report.refresh_from_db()
assert report.is_handled is True
assert report.assigned_to == actor
def test_request_detail(factories, superuser_api_client):
request = factories["moderation.UserRequest"]()
url = reverse(
"api:v1:manage:moderation:requests-detail", kwargs={"uuid": request.uuid}
)
response = superuser_api_client.get(url)
assert response.status_code == 200
assert response.data["uuid"] == str(request.uuid)
def test_request_list(factories, superuser_api_client, settings):
request = factories["moderation.UserRequest"]()
url = reverse("api:v1:manage:moderation:requests-list")
response = superuser_api_client.get(url)
assert response.status_code == 200
assert response.data["count"] == 1
assert response.data["results"][0]["uuid"] == str(request.uuid)
def test_user_request_update(factories, superuser_api_client):
user_request = factories["moderation.UserRequest"](signup=True)
url = reverse(
"api:v1:manage:moderation:requests-detail", kwargs={"uuid": user_request.uuid}
)
response = superuser_api_client.patch(url, {"status": "approved"})
assert response.status_code == 200
user_request.refresh_from_db()
assert user_request.status == "approved"
def test_user_request_update_status_assigns(factories, superuser_api_client, mocker):
actor = superuser_api_client.user.create_actor()
user_request = factories["moderation.UserRequest"](signup=True)
url = reverse(
"api:v1:manage:moderation:requests-detail", kwargs={"uuid": user_request.uuid}
)
on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
response = superuser_api_client.patch(url, {"status": "refused"})
assert response.status_code == 200
user_request.refresh_from_db()
assert user_request.status == "refused"
assert user_request.assigned_to == actor
on_commit.assert_called_once_with(
moderation_tasks.user_request_handle.delay,
user_request_id=user_request.pk,
new_status="refused",
old_status="pending",
)

View File

@ -0,0 +1,52 @@
import pytest
from django.urls import reverse
from funkwhale_api.common import preferences as common_preferences
from funkwhale_api.common import utils as common_utils
@pytest.mark.parametrize("value", [{"fields": {}}, {"fields": list(range(15))}])
def test_get_serialized_preference_error(value, preferences):
pref_id = "moderation__signup_form_customization"
with pytest.raises(common_preferences.JSONSerializer.exception):
preferences[pref_id] = value
@pytest.mark.parametrize(
"value",
[
{"fields": []},
{"help_text": {"text": "hello", "content_type": "text/markdown"}},
{"fields": [{"label": "Message", "required": True, "input_type": "long_text"}]},
],
)
def test_get_serialized_preference(value, preferences):
pref_id = "moderation__signup_form_customization"
preferences[pref_id] = value
if "help_text" in value:
value["help_text"]["html"] = common_utils.render_html(
value["help_text"]["text"],
content_type=value["help_text"]["content_type"],
permissive=True,
)
assert preferences[pref_id] == value
def test_update_via_api(superuser_api_client, preferences):
pref_id = "moderation__signup_form_customization"
url = reverse("api:v1:instance:admin-settings-bulk")
new_value = {
"help_text": {"text": "hello", "content_type": "text/markdown"},
"fields": [{"required": True, "label": "hello", "input_type": "short_text"}],
}
response = superuser_api_client.post(url, {pref_id: new_value}, format="json")
assert response.status_code == 200
new_value["help_text"]["html"] = common_utils.render_html(
new_value["help_text"]["text"],
content_type=new_value["help_text"]["content_type"],
permissive=True,
)
assert preferences[pref_id] == new_value

View File

@ -46,3 +46,77 @@ def test_report_created_signal_sends_email_to_mods(factories, mailoutbox, settin
assert detail_url in m.body
assert unresolved_reports_url in m.body
assert list(m.to) == [mod.email]
def test_signup_request_pending_sends_email_to_mods(factories, mailoutbox, settings):
mod1 = factories["users.User"](permission_moderation=True)
mod2 = factories["users.User"](permission_moderation=True)
signup_request = factories["moderation.UserRequest"](signup=True)
tasks.user_request_handle(user_request_id=signup_request.pk, new_status="pending")
detail_url = federation_utils.full_url(
"/manage/moderation/requests/{}".format(signup_request.uuid)
)
unresolved_requests_url = federation_utils.full_url(
"/manage/moderation/requests?q=status:pending"
)
assert len(mailoutbox) == 2
for i, mod in enumerate([mod1, mod2]):
m = mailoutbox[i]
assert m.subject == "[{} moderation] New sign-up request from {}".format(
settings.FUNKWHALE_HOSTNAME, signup_request.submitter.preferred_username,
)
assert detail_url in m.body
assert unresolved_requests_url in m.body
assert list(m.to) == [mod.email]
def test_approved_request_sends_email_to_submitter_and_set_active(
factories, mailoutbox, settings
):
user = factories["users.User"](is_active=False)
actor = user.create_actor()
signup_request = factories["moderation.UserRequest"](
signup=True, submitter=actor, status="approved"
)
tasks.user_request_handle(user_request_id=signup_request.pk, new_status="approved")
user.refresh_from_db()
assert user.is_active is True
assert len(mailoutbox) == 1
m = mailoutbox[-1]
login_url = federation_utils.full_url("/login")
assert m.subject == "Welcome to {}, {}!".format(
settings.FUNKWHALE_HOSTNAME, signup_request.submitter.preferred_username,
)
assert login_url in m.body
assert list(m.to) == [user.email]
def test_refused_request_sends_email_to_submitter(
factories, mailoutbox, settings, preferences
):
preferences["instance__contact_email"] = "test@pod.example"
user = factories["users.User"](is_active=False)
actor = user.create_actor()
signup_request = factories["moderation.UserRequest"](
signup=True, submitter=actor, status="refused"
)
tasks.user_request_handle(user_request_id=signup_request.pk, new_status="refused")
user.refresh_from_db()
assert user.is_active is False
assert len(mailoutbox) == 1
m = mailoutbox[-1]
assert m.subject == "Your account request at {} was refused".format(
settings.FUNKWHALE_HOSTNAME,
)
assert "test@pod.example" in m.body
assert list(m.to) == [user.email]

View File

@ -54,6 +54,8 @@ from funkwhale_api.users.oauth import scopes
"write:instance:notes",
"read:instance:reports",
"write:instance:reports",
"read:instance:requests",
"write:instance:requests",
},
),
(
@ -99,6 +101,8 @@ from funkwhale_api.users.oauth import scopes
"write:instance:notes",
"read:instance:reports",
"write:instance:reports",
"read:instance:requests",
"write:instance:requests",
},
),
(
@ -138,6 +142,8 @@ from funkwhale_api.users.oauth import scopes
"write:instance:notes",
"read:instance:reports",
"write:instance:reports",
"read:instance:requests",
"write:instance:requests",
},
),
(

View File

@ -3,6 +3,7 @@ from django.urls import reverse
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
from funkwhale_api.moderation import tasks as moderation_tasks
from funkwhale_api.users.models import User
@ -415,3 +416,64 @@ def test_username_with_existing_local_account_are_invalid(
assert response.status_code == 400
assert "username" in response.data
def test_signup_with_approval_enabled(preferences, factories, api_client, mocker):
url = reverse("rest_register")
data = {
"username": "test1",
"email": "test1@test.com",
"password1": "thisismypassword",
"password2": "thisismypassword",
"request_fields": {"field1": "Value 1", "field2": "Value 2", "noop": "Noop"},
}
preferences["users__registration_enabled"] = True
preferences["moderation__signup_approval_enabled"] = True
preferences["moderation__signup_form_customization"] = {
"fields": [
{"label": "field1", "input_type": "short_text"},
{"label": "field2", "input_type": "short_text"},
]
}
on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
response = api_client.post(url, data, format="json")
assert response.status_code == 201
u = User.objects.get(email="test1@test.com")
assert u.username == "test1"
assert u.is_active is False
user_request = u.actor.requests.latest("id")
assert user_request.type == "signup"
assert user_request.status == "pending"
assert user_request.metadata == {
"field1": "Value 1",
"field2": "Value 2",
}
on_commit.assert_any_call(
moderation_tasks.user_request_handle.delay,
user_request_id=user_request.pk,
new_status="pending",
)
def test_signup_with_approval_enabled_validation_error(
preferences, factories, api_client
):
url = reverse("rest_register")
data = {
"username": "test1",
"email": "test1@test.com",
"password1": "thisismypassword",
"password2": "thisismypassword",
"request_fields": {"field1": "Value 1"},
}
preferences["users__registration_enabled"] = True
preferences["moderation__signup_approval_enabled"] = True
preferences["moderation__signup_form_customization"] = {
"fields": [
{"label": "field1", "input_type": "short_text"},
{"label": "field2", "input_type": "short_text"},
]
}
response = api_client.post(url, data, format="json")
assert response.status_code == 400

View File

@ -0,0 +1 @@
Screening for sign-ups (#1040)

View File

@ -13,19 +13,23 @@ This release includes a full redesign of our navigation, player and queue. Overa
a better, less confusing experience, especially on mobile devices. This redesign was suggested
14 months ago, and took a while, but thanks to the involvement and feedback of many people, we got it done!
Progressive web app [Manual change suggested, non-docker only]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Screening for sign-ups and custom sign-up form
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Instance admins can now configure their pod so that registrations required manual approval from a moderator. This
is especially useful on private or semi-private pods where you don't want to close registrations completely,
but don't want spam or unwanted users to join your pod.
When this is enabled and a new user register, their request is put in a moderation queue, and moderators
are notified by email. When the request is approved or refused, the user is also notified by email.
In addition, it's also possible to customize the sign-up form by:
- Providing a custom help text, in markdown format
- Including additional fields in the form, for instance to ask the user why they want to join. Data collected through these fields is included in the sign-up request and viewable by the mods
We've made Funkwhale's Web UI a Progressive Web Application (PWA), in order to improve the user experience
during offline use, and on mobile devices.
In order to fully benefit from this change, if your pod isn't deployed using Docker, ensure
the following instruction is present in your nginx configuration::
location /front/ {
# Add the following line in the /front/ location
add_header Service-Worker-Allowed "/";
}
Federated reports
^^^^^^^^^^^^^^^^^
@ -63,6 +67,20 @@ All user-related commands are available under the ``python manage.py fw users``
Please refer to the `Admin documentation <https://docs.funkwhale.audio/admin/commands.html#user-management>`_ for
more information and instructions.
Progressive web app [Manual change suggested, non-docker only]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
We've made Funkwhale's Web UI a Progressive Web Application (PWA), in order to improve the user experience
during offline use, and on mobile devices.
In order to fully benefit from this change, if your pod isn't deployed using Docker, ensure
the following instruction is present in your nginx configuration::
location /front/ {
# Add the following line in the /front/ location
add_header Service-Worker-Allowed "/";
}
Postgres docker changed environment variable [manual action required, docker multi-container only]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -133,6 +133,11 @@ export default {
id: 'sidebarPendingReviewReportCount',
handler: this.incrementPendingReviewReportsCountInSidebar
})
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'user_request.created',
id: 'sidebarPendingReviewRequestCount',
handler: this.incrementPendingReviewRequestsCountInSidebar
})
},
mounted () {
let self = this
@ -166,6 +171,10 @@ export default {
eventName: 'mutation.updated',
id: 'sidebarPendingReviewReportCount',
})
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'user_request.created',
id: 'sidebarPendingReviewRequestCount',
})
this.disconnect()
},
methods: {
@ -178,6 +187,9 @@ export default {
incrementPendingReviewReportsCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewReports', value: event.unresolved_count})
},
incrementPendingReviewRequestsCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewRequests', value: event.pending_count})
},
async fetchNodeInfo () {
let response = await axios.get('instance/nodeinfo/2.0/')
this.$store.commit('instance/nodeinfo', response.data)

View File

@ -17,8 +17,8 @@
<div class="item ui inline admin-dropdown dropdown">
<i class="wrench icon"></i>
<div
v-if="$store.state.ui.notifications.pendingReviewEdits + $store.state.ui.notifications.pendingReviewReports > 0"
:class="['ui', 'teal', 'mini', 'bottom floating', 'circular', 'label']">{{ $store.state.ui.notifications.pendingReviewEdits + $store.state.ui.notifications.pendingReviewReports }}</div>
v-if="moderationNotifications > 0"
:class="['ui', 'teal', 'mini', 'bottom floating', 'circular', 'label']">{{ moderationNotifications }}</div>
<div class="menu">
<div class="header">
<translate translate-context="Sidebar/Admin/Title/Noun">Administration</translate>
@ -40,9 +40,9 @@
class="item"
:to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}">
<div
v-if="$store.state.ui.notifications.pendingReviewReports > 0"
v-if="$store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests> 0"
:title="labels.pendingReviewReports"
:class="['ui', 'circular', 'mini', 'right floated', 'teal', 'label']">{{ $store.state.ui.notifications.pendingReviewReports }}</div>
:class="['ui', 'circular', 'mini', 'right floated', 'teal', 'label']">{{ $store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests }}</div>
<translate translate-context="*/Moderation/*">Moderation</translate>
</router-link>
<router-link
@ -242,6 +242,13 @@ export default {
} else {
return 'exploreExpanded'
}
},
moderationNotifications () {
return (
this.$store.state.ui.notifications.pendingReviewEdits +
this.$store.state.ui.notifications.pendingReviewReports +
this.$store.state.ui.notifications.pendingReviewRequests
)
}
},
methods: {

View File

@ -18,6 +18,11 @@
<p v-if="setting.help_text">{{ setting.help_text }}</p>
</template>
<content-form v-if="setting.fieldType === 'markdown'" v-model="values[setting.identifier]" v-bind="setting.fieldParams" />
<signup-form-builder
v-else-if="setting.fieldType === 'formBuilder'"
:value="values[setting.identifier]"
:signup-approval-enabled="values.moderation__signup_approval_enabled"
@input="set(setting.identifier, $event)" />
<input
:id="setting.identifier"
:name="setting.identifier"
@ -82,11 +87,16 @@
<script>
import axios from 'axios'
import lodash from '@/lodash'
export default {
props: {
group: {type: Object, required: true},
settingsData: {type: Array, required: true}
},
components: {
SignupFormBuilder: () => import(/* webpackChunkName: "signup-form-builder" */ "@/components/admin/SignupFormBuilder"),
},
data () {
return {
values: {},
@ -141,6 +151,11 @@ export default {
self.isLoading = false
self.errors = error.backendErrors
})
},
set (key, value) {
// otherwise reactivity doesn't trigger :/
this.values = lodash.cloneDeep(this.values)
this.$set(this.values, key, value)
}
},
computed: {

View File

@ -0,0 +1,191 @@
<template>
<div>
<div class="ui top attached tabular menu">
<button :class="[{active: !isPreviewing}, 'item']" @click.stop.prevent="isPreviewing = false">
<translate translate-context="Content/*/Button.Label/Verb">Edit form</translate>
</button>
<button :class="[{active: isPreviewing}, 'item']" @click.stop.prevent="isPreviewing = true">
<translate translate-context="*/Form/Menu.item">Preview form</translate>
</button>
</div>
<div v-if="isPreviewing" class="ui bottom attached segment">
<signup-form
:customization="local"
:signup-approval-enabled="signupApprovalEnabled"
:fetch-description-html="true"></signup-form>
<div class="ui clearing hidden divider"></div>
</div>
<div v-else class="ui bottom attached segment">
<div class="field">
<label for="help-text">
<translate translate-context="*/*/Label">Help text</translate>
</label>
<p>
<translate translate-context="*/*/Help">An optional text to be displayed at the start of the sign-up form.</translate>
</p>
<content-form
field-id="help-text"
:permissive="true"
:value="(local.help_text || {}).text"
@input="update('help_text.text', $event)"></content-form>
</div>
<div class="field">
<label>
<translate translate-context="*/*/Label">Additional fields</translate>
</label>
<p>
<translate translate-context="*/*/Help">Additional form fields to be displayed in the form. Only shown if manual sign-up validation is enabled.</translate>
</p>
<table v-if="local.fields.length > 0">
<thead>
<tr>
<th>
<translate translate-context="*/*/Form-builder,Help">Field label</translate>
</th>
<th>
<translate translate-context="*/*/Form-builder,Help">Field type</translate>
</th>
<th>
<translate translate-context="*/*/Form-builder,Help">Required</translate>
</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(field, idx) in local.fields">
<td>
<input type="text" v-model="field.label" required>
</td>
<td>
<select v-model="field.input_type">
<option value="short_text">
<translate translate-context="*/*/Form-builder">Short text</translate>
</option>
<option value="long_text">
<translate translate-context="*/*/Form-builder">Long text</translate>
</option>
</select>
</td>
<td>
<select v-model="field.required">
<option :value="true">
<translate translate-context="*/*/*">Yes</translate>
</option>
<option :value="false">
<translate translate-context="*/*/*">No</translate>
</option>
</select>
</td>
<td>
<i
:disabled="idx === 0"
@click="move(idx, -1)" rel="button"
:title="labels.up"
:class="['up', 'arrow', {disabled: idx === 0}, 'icon']"></i>
<i
:disabled="idx >= local.fields.length - 1"
@click="move(idx, 1)" rel="button"
:title="labels.up"
:class="['down', 'arrow', {disabled: idx >= local.fields.length - 1}, 'icon']"></i>
<i @click="remove(idx)" rel="button" :title="labels.delete" class="x icon"></i>
</td>
</tr>
</tbody>
</table>
<div class="ui hidden divider"></div>
<button v-if="local.fields.length < maxFields" class="ui basic button" @click.stop.prevent="addField">
<translate translate-context="*/*/Form-builder">Add a new field</translate>
</button>
</div>
</div>
<div class="ui hidden divider"></div>
</div>
</template>
<script>
import lodash from '@/lodash'
import SignupForm from "@/components/auth/SignupForm"
function arrayMove(arr, oldIndex, newIndex) {
if (newIndex >= arr.length) {
var k = newIndex - arr.length + 1
while (k--) {
arr.push(undefined)
}
}
arr.splice(newIndex, 0, arr.splice(oldIndex, 1)[0])
return arr
};
// v-model with objects is complex, cf
// https://simonkollross.de/posts/vuejs-using-v-model-with-objects-for-custom-components
export default {
props: {
value: {type: Object},
signupApprovalEnabled: {type: Boolean},
},
components: {
SignupForm
},
data () {
return {
maxFields: 10,
isPreviewing: false
}
},
created () {
this.$emit('input', this.local)
},
computed: {
labels () {
return {
delete: this.$pgettext('*/*/*', 'Delete'),
up: this.$pgettext('*/*/*', 'Move up'),
down: this.$pgettext('*/*/*', 'Move down'),
}
},
local() {
return (this.value && this.value.fields) ? this.value : { help_text: {text: null, content_type: "text/markdown"}, fields: [] }
},
},
methods: {
addField () {
let newValue = lodash.tap(lodash.cloneDeep(this.local), v => v.fields.push({
label: this.$pgettext('*/*/Form-builder', 'Additional field') + ' ' + (this.local.fields.length + 1),
required: true,
input_type: 'short_text',
}))
this.$emit('input', newValue)
},
remove (idx) {
this.$emit('input', lodash.tap(lodash.cloneDeep(this.local), v => v.fields.splice(idx, 1)))
},
move (idx, incr) {
if (idx === 0 && incr < 0) {
return
}
if (idx + incr >= this.local.fields.length) {
return
}
let newFields = arrayMove(lodash.cloneDeep(this.local).fields, idx, idx + incr)
this.update('fields', newFields)
},
update(key, value) {
if (key === 'help_text.text') {
key = 'help_text'
if (!value || value.length === 0) {
value = null
} else {
value = {
text: value,
content_type: "text/markdown"
}
}
}
this.$emit('input', lodash.tap(lodash.cloneDeep(this.local), v => lodash.set(v, key, value)))
},
},
}
</script>

View File

@ -3,7 +3,12 @@
<div v-if="error" class="ui negative message">
<div class="header"><translate translate-context="Content/Login/Error message.Title">We cannot log you in</translate></div>
<ul class="list">
<li v-if="error == 'invalid_credentials'"><translate translate-context="Content/Login/Error message.List item/Call to action">Please double-check your username/password couple is correct</translate></li>
<li v-if="error == 'invalid_credentials'">
<translate translate-context="Content/Login/Error message.List item/Call to action">Please double-check your username/password couple is correct</translate>
</li>
<li v-if="error == 'invalid_credentials' && $store.state.instance.settings.moderation.signup_approval_enabled.value">
<translate translate-context="Content/Login/Error message.List item/Call to action">If you signed-up recently, you may need to wait before our moderation team review your account</translate>
</li>
<li v-else>{{ error }}</li>
</ul>
</div>

View File

@ -1,18 +1,37 @@
<template>
<div v-if="submitted">
<div class="ui success message">
<p v-if="signupRequiresApproval">
<translate translate-context="Content/Signup/Form/Paragraph">Your account request was successfully submitted. You will be notified by email when our moderation team has reviewed your request.</translate>
</p>
<p v-else>
<translate translate-context="Content/Signup/Form/Paragraph">Your account was successfully created. Please verify your email before trying to login.</translate>
</p>
</div>
<h2><translate translate-context="Content/Login/Title/Verb">Log in to your Funkwhale account</translate></h2>
<login-form button-classes="basic green" :show-signup="false"></login-form>
</div>
<form
v-else
:class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
@submit.prevent="submit()">
<p class="ui message" v-if="!$store.state.instance.settings.users.registration_enabled.value">
<translate translate-context="Content/Signup/Form/Paragraph">Public registrations are not possible on this instance. You will need an invitation code to sign up.</translate>
</p>
<p class="ui message" v-else-if="signupRequiresApproval">
<translate translate-context="Content/Signup/Form/Paragraph">Registrations on this pod are open, but reviewed by moderators before approval.</translate>
</p>
<template v-if="formCustomization && formCustomization.help_text">
<rendered-description :content="formCustomization.help_text" :fetch-html="fetchDescriptionHtml" :permissive="true"></rendered-description>
<div class="ui hidden divider"></div>
</template>
<div v-if="errors.length > 0" class="ui negative message">
<div class="header"><translate translate-context="Content/Signup/Form/Paragraph">Your account cannot be created.</translate></div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
<div class="field">
<div class="required field">
<label><translate translate-context="Content/*/*">Username</translate></label>
<input
ref="username"
@ -23,7 +42,7 @@
:placeholder="labels.usernamePlaceholder"
v-model="username">
</div>
<div class="field">
<div class="required field">
<label><translate translate-context="Content/*/*/Noun">Email</translate></label>
<input
ref="email"
@ -33,11 +52,11 @@
:placeholder="labels.emailPlaceholder"
v-model="email">
</div>
<div class="field">
<div class="required field">
<label><translate translate-context="*/*/*">Password</translate></label>
<password-input v-model="password" />
</div>
<div class="field" v-if="!$store.state.instance.settings.users.registration_enabled.value">
<div class="required field" v-if="!$store.state.instance.settings.users.registration_enabled.value">
<label><translate translate-context="Content/*/Input.Label">Invitation code</translate></label>
<input
required
@ -46,6 +65,17 @@
:placeholder="labels.placeholder"
v-model="invitation">
</div>
<template v-if="signupRequiresApproval && formCustomization && formCustomization.fields && formCustomization.fields.length > 0">
<div :class="[{required: field.required}, 'field']" v-for="(field, idx) in formCustomization.fields" :key="idx">
<label :for="`custom-field-${idx}`">{{ field.label }}</label>
<textarea
v-if="field.input_type === 'long_text'"
:value="customFields[field.label]"
:required="field.required"
@input="$set(customFields, field.label, $event.target.value)" rows="5"></textarea>
<input v-else type="text" :value="customFields[field.label]" :required="field.required" @input="$set(customFields, field.label, $event.target.value)">
</div>
</template>
<button :class="['ui', buttonClasses, {'loading': isLoading}, ' right floated button']" type="submit">
<translate translate-context="Content/Signup/Button.Label">Create my account</translate>
</button>
@ -56,6 +86,7 @@
import axios from "axios"
import logger from "@/logging"
import LoginForm from "@/components/auth/LoginForm"
import PasswordInput from "@/components/forms/PasswordInput"
export default {
@ -63,9 +94,14 @@ export default {
defaultInvitation: { type: String, required: false, default: null },
next: { type: String, default: "/" },
buttonClasses: { type: String, default: "green" },
customization: { type: Object, default: null},
fetchDescriptionHtml: { type: Boolean, default: false},
fetchDescriptionHtml: { type: Boolean, default: false},
signupApprovalEnabled: {type: Boolean, default: null, required: false},
},
components: {
PasswordInput
LoginForm,
PasswordInput,
},
data() {
return {
@ -75,7 +111,9 @@ export default {
isLoadingInstanceSetting: true,
errors: [],
isLoading: false,
invitation: this.defaultInvitation
invitation: this.defaultInvitation,
customFields: {},
submitted: false,
}
},
created() {
@ -99,6 +137,15 @@ export default {
emailPlaceholder,
placeholder
}
},
formCustomization () {
return this.customization || this.$store.state.instance.settings.moderation.signup_form_customization.value
},
signupRequiresApproval () {
if (this.signupApprovalEnabled === null) {
return this.$store.state.instance.settings.moderation.signup_approval_enabled.value
}
return this.signupApprovalEnabled
}
},
methods: {
@ -111,17 +158,14 @@ export default {
password1: this.password,
password2: this.password,
email: this.email,
invitation: this.invitation
invitation: this.invitation,
request_fields: this.customFields,
}
return axios.post("auth/registration/", payload).then(
response => {
logger.default.info("Successfully created account")
self.$router.push({
name: "profile.overview",
params: {
username: this.username
}
})
self.submitted = true
self.isLoading = false
},
error => {
self.errors = error.backendErrors

View File

@ -1,6 +1,6 @@
<template>
<router-link :to="url" :title="actor.full_username">
<template v-if="avatar"><actor-avatar :actor="actor" />&nbsp;</template><slot>{{ repr | truncate(truncateLength) }}</slot>
<template v-if="avatar"><actor-avatar :actor="actor" /><span>&nbsp;</span></template><slot>{{ repr | truncate(truncateLength) }}</slot>
</router-link>
</template>
@ -17,6 +17,9 @@ export default {
},
computed: {
url () {
if (this.admin) {
return {name: 'manage.moderation.accounts.detail', params: {id: this.actor.full_username}}
}
if (this.actor.is_local) {
return {name: 'profile.overview', params: {username: this.actor.preferred_username}}
} else {
@ -24,7 +27,7 @@ export default {
}
},
repr () {
if (this.displayName) {
if (this.displayName || this.actor.is_local) {
return this.actor.preferred_username
} else {
return this.actor.full_username

View File

@ -1,6 +1,6 @@
<template>
<div>
<div v-html="content.html" v-if="content && !isUpdating"></div>
<div v-html="html" v-if="content && !isUpdating"></div>
<p v-else-if="!isUpdating">
<translate translate-context="*/*/Placeholder">No description available</translate>
</p>
@ -40,6 +40,9 @@ export default {
fieldName: {required: false, default: 'description'},
updateUrl: {required: false, type: String},
canUpdate: {required: false, default: true, type: Boolean},
fetchHtml: {required: false, default: false, type: Boolean},
permissive: {required: false, default: false, type: Boolean},
},
data () {
return {
@ -48,9 +51,27 @@ export default {
errors: null,
isLoading: false,
errors: [],
preview: null
}
},
async created () {
if (this.fetchHtml) {
await this.fetchPreview()
}
},
computed: {
html () {
if (this.fetchHtml) {
return this.preview
}
return this.content.html
}
},
methods: {
async fetchPreview () {
let response = await axios.post('text-preview/', {text: this.content.text, permissive: this.permissive})
this.preview = response.data.rendered
},
submit () {
let self = this
this.isLoading = true

View File

@ -46,6 +46,7 @@ export default {
target: this.target,
summary: this.summary
}
this.errors = []
axios.post(`manage/moderation/notes/`, payload).then((response) => {
self.$emit('created', response.data)
self.summary = ''

View File

@ -0,0 +1,192 @@
<template>
<div class="ui fluid user-request card">
<div class="content">
<div class="header">
<router-link :to="{name: 'manage.moderation.requests.detail', params: {id: obj.uuid}}">
<translate translate-context="Content/Moderation/Card/Short" :translate-params="{id: obj.uuid.substring(0, 8)}">Request %{ id }</translate>
</router-link>
<collapse-link class="right floated" v-model="isCollapsed"></collapse-link>
</div>
<div class="content">
<div class="ui hidden divider"></div>
<div class="ui stackable two column grid">
<div class="column">
<table class="ui very basic unstackable table">
<tbody>
<tr>
<td>
<translate translate-context="Content/Moderation/*">Submitted by</translate>
</td>
<td>
<actor-link :admin="true" :actor="obj.submitter" />
</td>
</tr>
<tr>
<td>
<translate translate-context="Content/*/*/Noun">Creation date</translate>
</td>
<td>
<human-date :date="obj.creation_date" :icon="true"></human-date>
</td>
</tr>
</tbody>
</table>
</div>
<div class="column">
<table class="ui very basic unstackable table">
<tbody>
<tr>
<td>
<translate translate-context="*/*/*">Status</translate>
</td>
<td>
<template v-if="obj.status === 'pending'">
<i class="yellow hourglass icon"></i>
<translate translate-context="Content/Library/*/Short">Pending</translate>
</template>
<template v-else-if="obj.status === 'refused'">
<i class="red x icon"></i>
<translate translate-context="Content/*/*/Short">Refused</translate>
</template>
<template v-else-if="obj.status === 'approved'">
<i class="green check icon"></i>
<translate translate-context="Content/*/*/Short">Approved</translate>
</template>
</td>
</tr>
<tr>
<td>
<translate translate-context="Content/Moderation/*">Assigned to</translate>
</td>
<td>
<div v-if="obj.assigned_to">
<actor-link :admin="true" :actor="obj.assigned_to" />
</div>
<translate v-else translate-context="*/*/*">N/A</translate>
</td>
</tr>
<tr>
<td>
<translate translate-context="Content/*/*/Noun">Resolution date</translate>
</td>
<td>
<human-date v-if="obj.handled_date" :date="obj.handled_date" :icon="true"></human-date>
<translate v-else translate-context="*/*/*">N/A</translate>
</td>
</tr>
<tr>
<td>
<translate translate-context="Content/*/*/Noun">Internal notes</translate>
</td>
<td>
<i class="comment icon"></i>
{{ obj.notes.length }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="main content" v-if="!isCollapsed">
<div class="ui stackable two column grid">
<div class="column">
<h3>
<translate translate-context="*/*/Field.Label/Noun">Message</translate>
</h3>
<p>
<translate translate-context="Content/Moderation/Paragraph">This user wants to sign-up on your pod.</translate>
</p>
<template v-if="obj.metadata">
<div class="ui hidden divider"></div>
<div v-for="k in Object.keys(obj.metadata)" :key="k">
<h4>{{ k }}</h4>
<p v-if="obj.metadata[k] && obj.metadata[k].length">{{ obj.metadata[k] }}</p>
<translate v-else translate-context="*/*/*">N/A</translate>
<div class="ui hidden divider"></div>
</div>
</template>
</div>
<aside class="column">
<div v-if="obj.status != 'approved'">
<h3>
<translate translate-context="Content/*/*/Noun">Actions</translate>
</h3>
<div class="ui labelled icon basic buttons">
<button
v-if="obj.status === 'pending' || obj.status === 'refused'"
@click="approve(true)"
:class="['ui', {loading: isLoading}, 'button']">
<i class="green check icon"></i>&nbsp;
<translate translate-context="Content/*/Button.Label/Verb">Approve</translate>
</button>
<button
v-if="obj.status === 'pending'"
@click="approve(false)"
:class="['ui', {loading: isLoading}, 'button']">
<i class="red x icon"></i>&nbsp;
<translate translate-context="Content/*/Button.Label">Refuse</translate>
</button>
</div>
</div>
<h3>
<translate translate-context="Content/*/*/Noun">Internal notes</translate>
</h3>
<notes-thread @deleted="handleRemovedNote($event)" :notes="obj.notes" />
<note-form @created="obj.notes.push($event)" :target="{type: 'request', uuid: obj.uuid}" />
</aside>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
import NoteForm from '@/components/manage/moderation/NoteForm'
import NotesThread from '@/components/manage/moderation/NotesThread'
import {setUpdate} from '@/utils'
import showdown from 'showdown'
export default {
props: {
obj: {required: true},
},
components: {
NoteForm,
NotesThread,
},
data () {
return {
markdown: new showdown.Converter(),
isLoading: false,
isCollapsed: false,
}
},
methods: {
approve (v) {
let url = `manage/moderation/requests/${this.obj.uuid}/`
let self = this
let newStatus = v ? 'approved' : 'refused'
this.isLoading = true
axios.patch(url, {status: newStatus}).then((response) => {
self.$emit('handled', newStatus)
self.isLoading = false
self.obj.status = newStatus
if (v) {
self.isCollapsed = true
}
self.$store.commit('ui/incrementNotifications', {count: -1, type: 'pendingReviewRequests'})
}, error => {
self.isLoading = false
})
},
handleRemovedNote (uuid) {
this.obj.notes = this.obj.notes.filter((note) => {
return note.uuid != uuid
})
},
}
}
</script>

View File

@ -2,9 +2,11 @@
export default {
clone: require('lodash/clone'),
cloneDeep: require('lodash/cloneDeep'),
keys: require('lodash/keys'),
debounce: require('lodash/debounce'),
get: require('lodash/get'),
set: require('lodash/set'),
merge: require('lodash/merge'),
range: require('lodash/range'),
shuffle: require('lodash/shuffle'),
@ -16,5 +18,6 @@ export default {
isEqual: require('lodash/isEqual'),
sum: require('lodash/sum'),
startCase: require('lodash/startCase'),
tap: require('lodash/tap'),
trim: require('lodash/trim'),
}

View File

@ -498,6 +498,29 @@ export default new Router({
),
props: true
},
{
path: "requests",
name: "manage.moderation.requests.list",
component: () =>
import(
/* webpackChunkName: "admin" */ "@/views/admin/moderation/RequestsList"
),
props: route => {
return {
defaultQuery: route.query.q,
updateUrl: true
}
}
},
{
path: "requests/:id",
name: "manage.moderation.requests.detail",
component: () =>
import(
/* webpackChunkName: "admin" */ "@/views/admin/moderation/RequestDetail"
),
props: true
},
]
},
{

View File

@ -140,6 +140,7 @@ export default {
}
if (response.data.permissions.moderation) {
dispatch('ui/fetchPendingReviewReports', null, { root: true })
dispatch('ui/fetchPendingReviewRequests', null, { root: true })
}
dispatch('favorites/fetch', null, { root: true })
dispatch('channels/fetchSubscriptions', null, { root: true })

View File

@ -50,6 +50,12 @@ export default {
value: 0
}
},
moderation: {
signup_approval_enabled: {
value: false,
},
signup_form_customization: {value: null}
},
subsonic: {
enabled: {
value: true

View File

@ -22,6 +22,7 @@ export default {
inbox: 0,
pendingReviewEdits: 0,
pendingReviewReports: 0,
pendingReviewRequests: 0,
},
websocketEventsHandlers: {
'inbox.item_added': {},
@ -29,6 +30,7 @@ export default {
'mutation.created': {},
'mutation.updated': {},
'report.created': {},
'user_request.created': {},
},
pageTitle: null,
routePreferences: {
@ -97,6 +99,16 @@ export default {
orderingDirection: "-",
ordering: "creation_date",
},
"manage.moderation.requests.list": {
paginateBy: 25,
orderingDirection: "-",
ordering: "creation_date",
},
"manage.moderation.reports.list": {
paginateBy: 25,
orderingDirection: "-",
ordering: "creation_date",
},
},
serviceWorker: {
refreshing: false,
@ -260,6 +272,11 @@ export default {
commit('notifications', {type: 'pendingReviewReports', count: response.data.count})
})
},
fetchPendingReviewRequests ({commit, rootState}, payload) {
axios.get('manage/moderation/requests/', {params: {status: 'pending', page_size: 1}}).then((response) => {
commit('notifications', {type: 'pendingReviewRequests', count: response.data.count})
})
},
websocketEvent ({state}, event) {
let handlers = state.websocketEventsHandlers[event.type]
console.log('Dispatching websocket event', event, handlers)

View File

@ -78,7 +78,8 @@ export default {
groups() {
// somehow, extraction fails if in the return block directly
let instanceLabel = this.$pgettext('Content/Admin/Menu','Instance information')
let usersLabel = this.$pgettext('*/*/*/Noun', 'Users')
let signupsLabel = this.$pgettext('*/*/*/Noun', 'Sign-ups')
let securityLabel = this.$pgettext('*/*/*/Noun', 'Security')
let musicLabel = this.$pgettext('*/*/*/Noun', 'Music')
let channelsLabel = this.$pgettext('*/*/*', 'Channels')
let playlistsLabel = this.$pgettext('*/*/*', 'Playlists')
@ -104,10 +105,18 @@ export default {
]
},
{
label: usersLabel,
id: "users",
label: signupsLabel,
id: "signup",
settings: [
{name: "users__registration_enabled"},
{name: "moderation__signup_approval_enabled"},
{name: "moderation__signup_form_customization", fieldType: 'formBuilder'},
]
},
{
label: securityLabel,
id: "security",
settings: [
{name: "common__api_authentication_required"},
{name: "users__default_permissions"},
{name: "users__upload_quota"},

View File

@ -274,6 +274,16 @@
{{ stats.reports }}
</td>
</tr>
<tr>
<td>
<router-link :to="{name: 'manage.moderation.requests.list', query: {q: getQuery('submitter', `${object.full_username}`) }}">
<translate translate-context="Content/Moderation/Table.Label/Noun">Requests</translate>
</router-link>
</td>
<td>
{{ stats.requests }}
</td>
</tr>
</tbody>
</table>
</section>

View File

@ -3,7 +3,20 @@
<nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu">
<router-link
class="ui item"
:to="{name: 'manage.moderation.reports.list'}"><translate translate-context="*/Moderation/*/Noun">Reports</translate></router-link>
:to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}">
<translate translate-context="*/Moderation/*/Noun">Reports</translate>
<div
v-if="$store.state.ui.notifications.pendingReviewReports > 0"
:class="['ui', 'circular', 'mini', 'right floated', 'teal', 'label']">{{ $store.state.ui.notifications.pendingReviewReports }}</div>
</router-link>
<router-link
class="ui item"
:to="{name: 'manage.moderation.requests.list', query: {q: 'status:pending'}}">
<translate translate-context="*/Moderation/*/Noun">User Requests</translate>
<div
v-if="$store.state.ui.notifications.pendingReviewRequests > 0"
:class="['ui', 'circular', 'mini', 'right floated', 'teal', 'label']">{{ $store.state.ui.notifications.pendingReviewRequests }}</div>
</router-link>
<router-link
class="ui item"
:to="{name: 'manage.moderation.domains.list'}"><translate translate-context="*/Moderation/*/Noun">Domains</translate></router-link>

View File

@ -0,0 +1,46 @@
<template>
<main>
<div v-if="isLoading" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<template v-if="object">
<div class="ui vertical stripe segment">
<user-request-card :obj="object"></user-request-card>
</div>
</template>
</main>
</template>
<script>
import axios from "axios"
import UserRequestCard from "@/components/manage/moderation/UserRequestCard"
export default {
props: ["id"],
components: {
UserRequestCard,
},
data() {
return {
isLoading: true,
object: null,
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
var self = this
this.isLoading = true
let url = `manage/moderation/requests/${this.id}/`
axios.get(url).then(response => {
self.object = response.data
self.isLoading = false
})
},
},
}
</script>

View File

@ -0,0 +1,168 @@
<template>
<main v-title="labels.accounts">
<section class="ui vertical stripe segment">
<h2 class="ui header"><translate translate-context="*/Moderation/*/Noun">User Requests</translate></h2>
<div class="ui hidden divider"></div>
<div class="ui inline form">
<div class="fields">
<div class="ui field">
<label><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label>
<form @submit.prevent="search.query = $refs.search.value">
<input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" />
</form>
</div>
<div class="field">
<label><translate translate-context="*/*/*">Status</translate></label>
<select class="ui dropdown" @change="addSearchToken('status', $event.target.value)" :value="getTokenValue('status', '')">
<option value="">
<translate translate-context="Content/*/Dropdown">All</translate>
</option>
<option value="pending">
<translate translate-context="Content/Library/*/Short">Pending</translate>
</option>
<option value="approved">
<translate translate-context="Content/*/*/Short">Approved</translate>
</option>
<option value="refused">
<translate translate-context="Content/*/*/Short">Refused</translate>
</option>
</select>
</div>
<div class="field">
<label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label>
<select class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]">
{{ sharedLabels.filters[option[1]] }}
</option>
</select>
</div>
<div class="field">
<label><translate translate-context="Content/Search/Dropdown.Label/Noun">Order</translate></label>
<select class="ui dropdown" v-model="orderingDirection">
<option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option>
<option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option>
</select>
</div>
</div>
</div>
<div v-if="isLoading" class="ui active inverted dimmer">
<div class="ui loader"></div>
</div>
<div v-else-if="!result || result.count === 0">
<empty-state @refresh="fetchData()" :refresh="true"></empty-state>
</div>
<template v-else>
<user-request-card @handled="fetchData" :obj="obj" v-for="obj in result.results" :key="obj.uuid" />
<div class="ui center aligned basic segment">
<pagination
v-if="result.count > paginateBy"
@page-changed="selectPage"
:current="page"
:paginate-by="paginateBy"
:total="result.count"
></pagination>
</div>
</template>
</section>
</main>
</template>
<script>
import axios from 'axios'
import _ from '@/lodash'
import time from '@/utils/time'
import Pagination from '@/components/Pagination'
import OrderingMixin from '@/components/mixins/Ordering'
import TranslationsMixin from '@/components/mixins/Translations'
import UserRequestCard from '@/components/manage/moderation/UserRequestCard'
import {normalizeQuery, parseTokens} from '@/search'
import SmartSearchMixin from '@/components/mixins/SmartSearch'
export default {
mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin],
components: {
Pagination,
UserRequestCard,
},
data () {
return {
time,
isLoading: false,
result: null,
page: 1,
search: {
query: this.defaultQuery,
tokens: parseTokens(normalizeQuery(this.defaultQuery))
},
orderingOptions: [
['creation_date', 'creation_date'],
['handled_date', 'handled_date'],
],
targets: {
track: {}
}
}
},
created () {
this.fetchData()
},
methods: {
fetchData () {
let params = _.merge({
'page': this.page,
'page_size': this.paginateBy,
'q': this.search.query,
'ordering': this.getOrderingAsString()
}, this.filters)
let self = this
self.isLoading = true
this.result = null
axios.get('manage/moderation/requests/', {params: params}).then((response) => {
self.result = response.data
self.isLoading = false
if (self.search.query === 'status:pending') {
self.$store.commit('ui/incrementNotifications', {type: 'pendingReviewRequests', value: response.data.count})
}
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
selectPage: function (page) {
this.page = page
},
},
computed: {
labels () {
return {
searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by username…'),
reports: this.$pgettext('*/Moderation/*/Noun', "User Requests"),
}
},
},
watch: {
search (newValue) {
this.page = 1
this.fetchData()
},
page () {
this.fetchData()
},
ordering () {
this.fetchData()
},
orderingDirection () {
this.fetchData()
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>