Merge branch '1040-signup-screening' into 'develop'
Resolve "Screening for signups" Closes #1040 See merge request funkwhale/funkwhale!1056
This commit is contained in:
commit
f8baae53fd
|
@ -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
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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()
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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://
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
|
|
|
@ -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
|
|
@ -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]
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
),
|
||||
(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Screening for sign-ups (#1040)
|
|
@ -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]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<router-link :to="url" :title="actor.full_username">
|
||||
<template v-if="avatar"><actor-avatar :actor="actor" /> </template><slot>{{ repr | truncate(truncateLength) }}</slot>
|
||||
<template v-if="avatar"><actor-avatar :actor="actor" /><span> </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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = ''
|
||||
|
|
|
@ -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>
|
||||
<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>
|
||||
<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>
|
|
@ -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'),
|
||||
}
|
||||
|
|
|
@ -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
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -50,6 +50,12 @@ export default {
|
|||
value: 0
|
||||
}
|
||||
},
|
||||
moderation: {
|
||||
signup_approval_enabled: {
|
||||
value: false,
|
||||
},
|
||||
signup_form_customization: {value: null}
|
||||
},
|
||||
subsonic: {
|
||||
enabled: {
|
||||
value: true
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in New Issue