Resolve "Screening for signups"
This commit is contained in:
parent
e6df21b96c
commit
e313fcd033
|
@ -135,7 +135,7 @@ test_api:
|
||||||
only:
|
only:
|
||||||
- branches
|
- branches
|
||||||
before_script:
|
before_script:
|
||||||
- apk add make git
|
- apk add make git gcc python3-dev
|
||||||
- cd api
|
- cd api
|
||||||
- pip3 install -r requirements/base.txt
|
- pip3 install -r requirements/base.txt
|
||||||
- pip3 install -r requirements/local.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.CommonPasswordValidator"},
|
||||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
{"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"
|
ACCOUNT_ADAPTER = "funkwhale_api.users.adapters.FunkwhaleAccountAdapter"
|
||||||
CORS_ORIGIN_ALLOW_ALL = True
|
CORS_ORIGIN_ALLOW_ALL = True
|
||||||
# CORS_ORIGIN_WHITELIST = (
|
# CORS_ORIGIN_WHITELIST = (
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
|
import json
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.contrib.postgres.forms import JSONField
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from dynamic_preferences import serializers, types
|
from dynamic_preferences import serializers, types
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
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 = super(StringListPreference, self).get_api_additional_data()
|
||||||
d["choices"] = self.get("choices")
|
d["choices"] = self.get("choices")
|
||||||
return d
|
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
|
from_activity__actor=self.pk
|
||||||
).count()
|
).count()
|
||||||
data["reports"] = moderation_models.Report.objects.get_for_target(self).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(
|
data["albums"] = music_models.Album.objects.filter(
|
||||||
from_activity__actor=self.pk
|
from_activity__actor=self.pk
|
||||||
).count()
|
).count()
|
||||||
|
|
|
@ -394,3 +394,26 @@ class ManageNoteFilterSet(filters.FilterSet):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = moderation_models.Note
|
model = moderation_models.Note
|
||||||
fields = ["q"]
|
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):
|
def get_notes(self, o):
|
||||||
notes = getattr(o, "_prefetched_notes", [])
|
notes = getattr(o, "_prefetched_notes", [])
|
||||||
return ManageBaseNoteSerializer(notes, many=True).data
|
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"
|
r"instance-policies", views.ManageInstancePolicyViewSet, "instance-policies"
|
||||||
)
|
)
|
||||||
moderation_router.register(r"reports", views.ManageReportViewSet, "reports")
|
moderation_router.register(r"reports", views.ManageReportViewSet, "reports")
|
||||||
|
moderation_router.register(r"requests", views.ManageUserRequestViewSet, "requests")
|
||||||
moderation_router.register(r"notes", views.ManageNoteViewSet, "notes")
|
moderation_router.register(r"notes", views.ManageNoteViewSet, "notes")
|
||||||
|
|
||||||
users_router = routers.OptionalSlashRouter()
|
users_router = routers.OptionalSlashRouter()
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
from rest_framework import mixins, response, viewsets
|
from rest_framework import mixins, response, viewsets
|
||||||
from rest_framework import decorators as rest_decorators
|
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 import Count, Prefetch, Q, Sum, OuterRef, Subquery
|
||||||
from django.db.models.functions import Coalesce, Length
|
from django.db.models.functions import Coalesce, Length
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
from funkwhale_api.common import models as common_models
|
from funkwhale_api.common import models as common_models
|
||||||
from funkwhale_api.common import preferences, decorators
|
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.favorites import models as favorites_models
|
||||||
from funkwhale_api.federation import models as federation_models
|
from funkwhale_api.federation import models as federation_models
|
||||||
from funkwhale_api.federation import tasks as federation_tasks
|
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 models as music_models
|
||||||
from funkwhale_api.music import views as music_views
|
from funkwhale_api.music import views as music_views
|
||||||
from funkwhale_api.moderation import models as moderation_models
|
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.playlists import models as playlists_models
|
||||||
from funkwhale_api.tags import models as tags_models
|
from funkwhale_api.tags import models as tags_models
|
||||||
from funkwhale_api.users import models as users_models
|
from funkwhale_api.users import models as users_models
|
||||||
|
@ -469,8 +472,8 @@ class ManageActorViewSet(
|
||||||
|
|
||||||
@rest_decorators.action(methods=["get"], detail=True)
|
@rest_decorators.action(methods=["get"], detail=True)
|
||||||
def stats(self, request, *args, **kwargs):
|
def stats(self, request, *args, **kwargs):
|
||||||
domain = self.get_object()
|
obj = self.get_object()
|
||||||
return response.Response(domain.get_stats(), status=200)
|
return response.Response(obj.get_stats(), status=200)
|
||||||
|
|
||||||
action = decorators.action_route(serializers.ManageActorActionSerializer)
|
action = decorators.action_route(serializers.ManageActorActionSerializer)
|
||||||
|
|
||||||
|
@ -607,3 +610,54 @@ class ManageTagViewSet(
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
result = serializer.save()
|
result = serializer.save()
|
||||||
return response.Response(result, status=200)
|
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 import types
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
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 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
|
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"
|
help_text = "A list of categories for which external users (without an account) can submit a report"
|
||||||
choices = models.REPORT_TYPES
|
choices = models.REPORT_TYPES
|
||||||
field_kwargs = {"choices": choices, "required": False}
|
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
|
return
|
||||||
|
|
||||||
self.target_owner = serializers.get_target_owner(self.target)
|
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")
|
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)
|
@receiver(pre_save, sender=Report)
|
||||||
def set_handled_date(sender, instance, **kwargs):
|
def set_handled_date(sender, instance, **kwargs):
|
||||||
if instance.is_handled is True and not instance.handled_date:
|
if instance.is_handled is True and not instance.handled_date:
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import logging
|
import logging
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.dispatch import receiver
|
|
||||||
from django.conf import settings
|
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 channels
|
||||||
|
from funkwhale_api.common import preferences
|
||||||
from funkwhale_api.common import utils
|
from funkwhale_api.common import utils
|
||||||
from funkwhale_api.taskapp import celery
|
from funkwhale_api.taskapp import celery
|
||||||
from funkwhale_api.federation import utils as federation_utils
|
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)
|
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")
|
def get_moderators():
|
||||||
@celery.require_instance(
|
|
||||||
models.Report.objects.select_related("submitter").filter(is_handled=False), "report"
|
|
||||||
)
|
|
||||||
def send_new_report_email_to_moderators(report):
|
|
||||||
moderators = users_models.User.objects.filter(
|
moderators = users_models.User.objects.filter(
|
||||||
is_active=True, permission_moderation=True
|
is_active=True, permission_moderation=True
|
||||||
)
|
)
|
||||||
|
@ -53,6 +51,15 @@ def send_new_report_email_to_moderators(report):
|
||||||
# we fallback on superusers
|
# we fallback on superusers
|
||||||
moderators = users_models.User.objects.filter(is_superuser=True)
|
moderators = users_models.User.objects.filter(is_superuser=True)
|
||||||
moderators = sorted(moderators, key=lambda m: m.pk)
|
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 = (
|
submitter_repr = (
|
||||||
report.submitter.full_username if report.submitter else report.submitter_email
|
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],
|
recipient_list=[moderator.email],
|
||||||
from_email=settings.DEFAULT_FROM_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_attr": "uuid",
|
||||||
"id_field": serializers.UUIDField(),
|
"id_field": serializers.UUIDField(),
|
||||||
},
|
},
|
||||||
|
"request": {
|
||||||
|
"queryset": models.UserRequest.objects.all(),
|
||||||
|
"id_attr": "uuid",
|
||||||
|
"id_field": serializers.UUIDField(),
|
||||||
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"queryset": federation_models.Actor.objects.all(),
|
"queryset": federation_models.Actor.objects.all(),
|
||||||
"id_attr": "full_username",
|
"id_attr": "full_username",
|
||||||
|
@ -19,3 +24,21 @@ NOTE_TARGET_FIELDS = {
|
||||||
"get_query": moderation_serializers.get_actor_query,
|
"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",
|
"write:instance:domains",
|
||||||
"read:instance:reports",
|
"read:instance:reports",
|
||||||
"write:instance:reports",
|
"write:instance:reports",
|
||||||
|
"read:instance:requests",
|
||||||
|
"write:instance:requests",
|
||||||
"read:instance:notes",
|
"read:instance:notes",
|
||||||
"write:instance:notes",
|
"write:instance:notes",
|
||||||
},
|
},
|
||||||
|
|
|
@ -35,6 +35,7 @@ BASE_SCOPES = [
|
||||||
Scope("instance:domains", "Access instance domains"),
|
Scope("instance:domains", "Access instance domains"),
|
||||||
Scope("instance:policies", "Access instance moderation policies"),
|
Scope("instance:policies", "Access instance moderation policies"),
|
||||||
Scope("instance:reports", "Access instance moderation reports"),
|
Scope("instance:reports", "Access instance moderation reports"),
|
||||||
|
Scope("instance:requests", "Access instance moderation requests"),
|
||||||
Scope("instance:notes", "Access instance moderation notes"),
|
Scope("instance:notes", "Access instance moderation notes"),
|
||||||
]
|
]
|
||||||
SCOPES = [
|
SCOPES = [
|
||||||
|
|
|
@ -10,9 +10,14 @@ from rest_framework import serializers
|
||||||
|
|
||||||
from funkwhale_api.activity import serializers as activity_serializers
|
from funkwhale_api.activity import serializers as activity_serializers
|
||||||
from funkwhale_api.common import models as common_models
|
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 serializers as common_serializers
|
||||||
from funkwhale_api.common import utils as common_utils
|
from funkwhale_api.common import utils as common_utils
|
||||||
from funkwhale_api.federation import models as federation_models
|
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 adapters
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
@ -36,6 +41,17 @@ class RegisterSerializer(RS):
|
||||||
required=False, allow_null=True, allow_blank=True
|
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):
|
def validate_invitation(self, value):
|
||||||
if not value:
|
if not value:
|
||||||
return
|
return
|
||||||
|
@ -67,11 +83,28 @@ class RegisterSerializer(RS):
|
||||||
|
|
||||||
def save(self, request):
|
def save(self, request):
|
||||||
user = super().save(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"):
|
if self.validated_data.get("invitation"):
|
||||||
user.invitation = self.validated_data.get("invitation")
|
user.invitation = self.validated_data.get("invitation")
|
||||||
user.save(update_fields=["invitation"])
|
update_fields.append("invitation")
|
||||||
user.actor = models.create_actor(user)
|
user.save(update_fields=update_fields)
|
||||||
user.save(update_fields=["actor"])
|
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
|
return user
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
|
||||||
[tool:pytest]
|
[tool:pytest]
|
||||||
python_files = tests.py test_*.py *_tests.py
|
python_files = tests.py test_*.py *_tests.py
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
|
addopts = -p no:warnings
|
||||||
env =
|
env =
|
||||||
SECRET_KEY=test
|
SECRET_KEY=test
|
||||||
EMAIL_CONFIG=consolemail://
|
EMAIL_CONFIG=consolemail://
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from dynamic_preferences import types
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
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 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):
|
def test_string_list_pref_set(string_list_pref, preferences):
|
||||||
preferences["test__string_list"] = ["world", "hello"]
|
preferences["test__string_list"] = ["world", "hello"]
|
||||||
assert preferences["test__string_list"] == ["hello", "world"]
|
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,
|
"uploads": 0,
|
||||||
"artists": 0,
|
"artists": 0,
|
||||||
"reports": 0,
|
"reports": 0,
|
||||||
|
"requests": 0,
|
||||||
"outbox_activities": 0,
|
"outbox_activities": 0,
|
||||||
"received_library_follows": 0,
|
"received_library_follows": 0,
|
||||||
"emitted_library_follows": 0,
|
"emitted_library_follows": 0,
|
||||||
|
|
|
@ -562,3 +562,26 @@ def test_manage_note_serializer(factories, to_api_date):
|
||||||
s = serializers.ManageNoteSerializer(note)
|
s = serializers.ManageNoteSerializer(note)
|
||||||
|
|
||||||
assert s.data == expected
|
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 models as federation_models
|
||||||
from funkwhale_api.federation import tasks as federation_tasks
|
from funkwhale_api.federation import tasks as federation_tasks
|
||||||
from funkwhale_api.manage import serializers
|
from funkwhale_api.manage import serializers
|
||||||
|
from funkwhale_api.moderation import tasks as moderation_tasks
|
||||||
|
|
||||||
|
|
||||||
def test_user_view(factories, superuser_api_client, mocker):
|
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
|
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()
|
actor = superuser_api_client.user.create_actor()
|
||||||
target = factories["federation.Actor"]()
|
target = factories["federation.Actor"]()
|
||||||
data = {
|
data = {
|
||||||
|
@ -425,6 +426,22 @@ def test_note_create(factories, superuser_api_client):
|
||||||
assert response.data == serializers.ManageNoteSerializer(note).data
|
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):
|
def test_note_list(factories, superuser_api_client, settings):
|
||||||
note = factories["moderation.Note"]()
|
note = factories["moderation.Note"]()
|
||||||
url = reverse("api:v1:manage:moderation:notes-list")
|
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()
|
report.refresh_from_db()
|
||||||
assert report.is_handled is True
|
assert report.is_handled is True
|
||||||
assert report.assigned_to == actor
|
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 detail_url in m.body
|
||||||
assert unresolved_reports_url in m.body
|
assert unresolved_reports_url in m.body
|
||||||
assert list(m.to) == [mod.email]
|
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",
|
"write:instance:notes",
|
||||||
"read:instance:reports",
|
"read:instance:reports",
|
||||||
"write: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",
|
"write:instance:notes",
|
||||||
"read:instance:reports",
|
"read:instance:reports",
|
||||||
"write: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",
|
"write:instance:notes",
|
||||||
"read:instance:reports",
|
"read:instance:reports",
|
||||||
"write: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 serializers as common_serializers
|
||||||
from funkwhale_api.common import utils as common_utils
|
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
|
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 response.status_code == 400
|
||||||
assert "username" in response.data
|
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
|
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!
|
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
|
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
|
Please refer to the `Admin documentation <https://docs.funkwhale.audio/admin/commands.html#user-management>`_ for
|
||||||
more information and instructions.
|
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]
|
Postgres docker changed environment variable [manual action required, docker multi-container only]
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
|
|
@ -133,6 +133,11 @@ export default {
|
||||||
id: 'sidebarPendingReviewReportCount',
|
id: 'sidebarPendingReviewReportCount',
|
||||||
handler: this.incrementPendingReviewReportsCountInSidebar
|
handler: this.incrementPendingReviewReportsCountInSidebar
|
||||||
})
|
})
|
||||||
|
this.$store.commit('ui/addWebsocketEventHandler', {
|
||||||
|
eventName: 'user_request.created',
|
||||||
|
id: 'sidebarPendingReviewRequestCount',
|
||||||
|
handler: this.incrementPendingReviewRequestsCountInSidebar
|
||||||
|
})
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
let self = this
|
let self = this
|
||||||
|
@ -166,6 +171,10 @@ export default {
|
||||||
eventName: 'mutation.updated',
|
eventName: 'mutation.updated',
|
||||||
id: 'sidebarPendingReviewReportCount',
|
id: 'sidebarPendingReviewReportCount',
|
||||||
})
|
})
|
||||||
|
this.$store.commit('ui/removeWebsocketEventHandler', {
|
||||||
|
eventName: 'user_request.created',
|
||||||
|
id: 'sidebarPendingReviewRequestCount',
|
||||||
|
})
|
||||||
this.disconnect()
|
this.disconnect()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -178,6 +187,9 @@ export default {
|
||||||
incrementPendingReviewReportsCountInSidebar (event) {
|
incrementPendingReviewReportsCountInSidebar (event) {
|
||||||
this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewReports', value: event.unresolved_count})
|
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 () {
|
async fetchNodeInfo () {
|
||||||
let response = await axios.get('instance/nodeinfo/2.0/')
|
let response = await axios.get('instance/nodeinfo/2.0/')
|
||||||
this.$store.commit('instance/nodeinfo', response.data)
|
this.$store.commit('instance/nodeinfo', response.data)
|
||||||
|
|
|
@ -17,8 +17,8 @@
|
||||||
<div class="item ui inline admin-dropdown dropdown">
|
<div class="item ui inline admin-dropdown dropdown">
|
||||||
<i class="wrench icon"></i>
|
<i class="wrench icon"></i>
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.ui.notifications.pendingReviewEdits + $store.state.ui.notifications.pendingReviewReports > 0"
|
v-if="moderationNotifications > 0"
|
||||||
:class="['ui', 'teal', 'mini', 'bottom floating', 'circular', 'label']">{{ $store.state.ui.notifications.pendingReviewEdits + $store.state.ui.notifications.pendingReviewReports }}</div>
|
:class="['ui', 'teal', 'mini', 'bottom floating', 'circular', 'label']">{{ moderationNotifications }}</div>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<translate translate-context="Sidebar/Admin/Title/Noun">Administration</translate>
|
<translate translate-context="Sidebar/Admin/Title/Noun">Administration</translate>
|
||||||
|
@ -40,9 +40,9 @@
|
||||||
class="item"
|
class="item"
|
||||||
:to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}">
|
:to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}">
|
||||||
<div
|
<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"
|
: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>
|
<translate translate-context="*/Moderation/*">Moderation</translate>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
|
@ -242,6 +242,13 @@ export default {
|
||||||
} else {
|
} else {
|
||||||
return 'exploreExpanded'
|
return 'exploreExpanded'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
moderationNotifications () {
|
||||||
|
return (
|
||||||
|
this.$store.state.ui.notifications.pendingReviewEdits +
|
||||||
|
this.$store.state.ui.notifications.pendingReviewReports +
|
||||||
|
this.$store.state.ui.notifications.pendingReviewRequests
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -18,6 +18,11 @@
|
||||||
<p v-if="setting.help_text">{{ setting.help_text }}</p>
|
<p v-if="setting.help_text">{{ setting.help_text }}</p>
|
||||||
</template>
|
</template>
|
||||||
<content-form v-if="setting.fieldType === 'markdown'" v-model="values[setting.identifier]" v-bind="setting.fieldParams" />
|
<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
|
<input
|
||||||
:id="setting.identifier"
|
:id="setting.identifier"
|
||||||
:name="setting.identifier"
|
:name="setting.identifier"
|
||||||
|
@ -82,11 +87,16 @@
|
||||||
<script>
|
<script>
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import lodash from '@/lodash'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
group: {type: Object, required: true},
|
group: {type: Object, required: true},
|
||||||
settingsData: {type: Array, required: true}
|
settingsData: {type: Array, required: true}
|
||||||
},
|
},
|
||||||
|
components: {
|
||||||
|
SignupFormBuilder: () => import(/* webpackChunkName: "signup-form-builder" */ "@/components/admin/SignupFormBuilder"),
|
||||||
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
values: {},
|
values: {},
|
||||||
|
@ -141,6 +151,11 @@ export default {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.errors = error.backendErrors
|
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: {
|
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 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>
|
<div class="header"><translate translate-context="Content/Login/Error message.Title">We cannot log you in</translate></div>
|
||||||
<ul class="list">
|
<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>
|
<li v-else>{{ error }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,18 +1,37 @@
|
||||||
<template>
|
<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
|
<form
|
||||||
|
v-else
|
||||||
:class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
|
:class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
|
||||||
@submit.prevent="submit()">
|
@submit.prevent="submit()">
|
||||||
<p class="ui message" v-if="!$store.state.instance.settings.users.registration_enabled.value">
|
<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>
|
<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>
|
||||||
|
<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 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>
|
<div class="header"><translate translate-context="Content/Signup/Form/Paragraph">Your account cannot be created.</translate></div>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li v-for="error in errors">{{ error }}</li>
|
<li v-for="error in errors">{{ error }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="required field">
|
||||||
<label><translate translate-context="Content/*/*">Username</translate></label>
|
<label><translate translate-context="Content/*/*">Username</translate></label>
|
||||||
<input
|
<input
|
||||||
ref="username"
|
ref="username"
|
||||||
|
@ -23,7 +42,7 @@
|
||||||
:placeholder="labels.usernamePlaceholder"
|
:placeholder="labels.usernamePlaceholder"
|
||||||
v-model="username">
|
v-model="username">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="required field">
|
||||||
<label><translate translate-context="Content/*/*/Noun">Email</translate></label>
|
<label><translate translate-context="Content/*/*/Noun">Email</translate></label>
|
||||||
<input
|
<input
|
||||||
ref="email"
|
ref="email"
|
||||||
|
@ -33,11 +52,11 @@
|
||||||
:placeholder="labels.emailPlaceholder"
|
:placeholder="labels.emailPlaceholder"
|
||||||
v-model="email">
|
v-model="email">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="required field">
|
||||||
<label><translate translate-context="*/*/*">Password</translate></label>
|
<label><translate translate-context="*/*/*">Password</translate></label>
|
||||||
<password-input v-model="password" />
|
<password-input v-model="password" />
|
||||||
</div>
|
</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>
|
<label><translate translate-context="Content/*/Input.Label">Invitation code</translate></label>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
|
@ -46,6 +65,17 @@
|
||||||
:placeholder="labels.placeholder"
|
:placeholder="labels.placeholder"
|
||||||
v-model="invitation">
|
v-model="invitation">
|
||||||
</div>
|
</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">
|
<button :class="['ui', buttonClasses, {'loading': isLoading}, ' right floated button']" type="submit">
|
||||||
<translate translate-context="Content/Signup/Button.Label">Create my account</translate>
|
<translate translate-context="Content/Signup/Button.Label">Create my account</translate>
|
||||||
</button>
|
</button>
|
||||||
|
@ -56,6 +86,7 @@
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import logger from "@/logging"
|
import logger from "@/logging"
|
||||||
|
|
||||||
|
import LoginForm from "@/components/auth/LoginForm"
|
||||||
import PasswordInput from "@/components/forms/PasswordInput"
|
import PasswordInput from "@/components/forms/PasswordInput"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -63,9 +94,14 @@ export default {
|
||||||
defaultInvitation: { type: String, required: false, default: null },
|
defaultInvitation: { type: String, required: false, default: null },
|
||||||
next: { type: String, default: "/" },
|
next: { type: String, default: "/" },
|
||||||
buttonClasses: { type: String, default: "green" },
|
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: {
|
components: {
|
||||||
PasswordInput
|
LoginForm,
|
||||||
|
PasswordInput,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -75,7 +111,9 @@ export default {
|
||||||
isLoadingInstanceSetting: true,
|
isLoadingInstanceSetting: true,
|
||||||
errors: [],
|
errors: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
invitation: this.defaultInvitation
|
invitation: this.defaultInvitation,
|
||||||
|
customFields: {},
|
||||||
|
submitted: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
|
@ -99,6 +137,15 @@ export default {
|
||||||
emailPlaceholder,
|
emailPlaceholder,
|
||||||
placeholder
|
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: {
|
methods: {
|
||||||
|
@ -111,17 +158,14 @@ export default {
|
||||||
password1: this.password,
|
password1: this.password,
|
||||||
password2: this.password,
|
password2: this.password,
|
||||||
email: this.email,
|
email: this.email,
|
||||||
invitation: this.invitation
|
invitation: this.invitation,
|
||||||
|
request_fields: this.customFields,
|
||||||
}
|
}
|
||||||
return axios.post("auth/registration/", payload).then(
|
return axios.post("auth/registration/", payload).then(
|
||||||
response => {
|
response => {
|
||||||
logger.default.info("Successfully created account")
|
logger.default.info("Successfully created account")
|
||||||
self.$router.push({
|
self.submitted = true
|
||||||
name: "profile.overview",
|
self.isLoading = false
|
||||||
params: {
|
|
||||||
username: this.username
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
error => {
|
error => {
|
||||||
self.errors = error.backendErrors
|
self.errors = error.backendErrors
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<router-link :to="url" :title="actor.full_username">
|
<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>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -17,6 +17,9 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
url () {
|
url () {
|
||||||
|
if (this.admin) {
|
||||||
|
return {name: 'manage.moderation.accounts.detail', params: {id: this.actor.full_username}}
|
||||||
|
}
|
||||||
if (this.actor.is_local) {
|
if (this.actor.is_local) {
|
||||||
return {name: 'profile.overview', params: {username: this.actor.preferred_username}}
|
return {name: 'profile.overview', params: {username: this.actor.preferred_username}}
|
||||||
} else {
|
} else {
|
||||||
|
@ -24,7 +27,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
repr () {
|
repr () {
|
||||||
if (this.displayName) {
|
if (this.displayName || this.actor.is_local) {
|
||||||
return this.actor.preferred_username
|
return this.actor.preferred_username
|
||||||
} else {
|
} else {
|
||||||
return this.actor.full_username
|
return this.actor.full_username
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<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">
|
<p v-else-if="!isUpdating">
|
||||||
<translate translate-context="*/*/Placeholder">No description available</translate>
|
<translate translate-context="*/*/Placeholder">No description available</translate>
|
||||||
</p>
|
</p>
|
||||||
|
@ -40,6 +40,9 @@ export default {
|
||||||
fieldName: {required: false, default: 'description'},
|
fieldName: {required: false, default: 'description'},
|
||||||
updateUrl: {required: false, type: String},
|
updateUrl: {required: false, type: String},
|
||||||
canUpdate: {required: false, default: true, type: Boolean},
|
canUpdate: {required: false, default: true, type: Boolean},
|
||||||
|
fetchHtml: {required: false, default: false, type: Boolean},
|
||||||
|
permissive: {required: false, default: false, type: Boolean},
|
||||||
|
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
|
@ -48,9 +51,27 @@ export default {
|
||||||
errors: null,
|
errors: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
errors: [],
|
errors: [],
|
||||||
|
preview: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async created () {
|
||||||
|
if (this.fetchHtml) {
|
||||||
|
await this.fetchPreview()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
html () {
|
||||||
|
if (this.fetchHtml) {
|
||||||
|
return this.preview
|
||||||
|
}
|
||||||
|
return this.content.html
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async fetchPreview () {
|
||||||
|
let response = await axios.post('text-preview/', {text: this.content.text, permissive: this.permissive})
|
||||||
|
this.preview = response.data.rendered
|
||||||
|
},
|
||||||
submit () {
|
submit () {
|
||||||
let self = this
|
let self = this
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
|
|
|
@ -46,6 +46,7 @@ export default {
|
||||||
target: this.target,
|
target: this.target,
|
||||||
summary: this.summary
|
summary: this.summary
|
||||||
}
|
}
|
||||||
|
this.errors = []
|
||||||
axios.post(`manage/moderation/notes/`, payload).then((response) => {
|
axios.post(`manage/moderation/notes/`, payload).then((response) => {
|
||||||
self.$emit('created', response.data)
|
self.$emit('created', response.data)
|
||||||
self.summary = ''
|
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 {
|
export default {
|
||||||
clone: require('lodash/clone'),
|
clone: require('lodash/clone'),
|
||||||
|
cloneDeep: require('lodash/cloneDeep'),
|
||||||
keys: require('lodash/keys'),
|
keys: require('lodash/keys'),
|
||||||
debounce: require('lodash/debounce'),
|
debounce: require('lodash/debounce'),
|
||||||
get: require('lodash/get'),
|
get: require('lodash/get'),
|
||||||
|
set: require('lodash/set'),
|
||||||
merge: require('lodash/merge'),
|
merge: require('lodash/merge'),
|
||||||
range: require('lodash/range'),
|
range: require('lodash/range'),
|
||||||
shuffle: require('lodash/shuffle'),
|
shuffle: require('lodash/shuffle'),
|
||||||
|
@ -16,5 +18,6 @@ export default {
|
||||||
isEqual: require('lodash/isEqual'),
|
isEqual: require('lodash/isEqual'),
|
||||||
sum: require('lodash/sum'),
|
sum: require('lodash/sum'),
|
||||||
startCase: require('lodash/startCase'),
|
startCase: require('lodash/startCase'),
|
||||||
|
tap: require('lodash/tap'),
|
||||||
trim: require('lodash/trim'),
|
trim: require('lodash/trim'),
|
||||||
}
|
}
|
||||||
|
|
|
@ -498,6 +498,29 @@ export default new Router({
|
||||||
),
|
),
|
||||||
props: true
|
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) {
|
if (response.data.permissions.moderation) {
|
||||||
dispatch('ui/fetchPendingReviewReports', null, { root: true })
|
dispatch('ui/fetchPendingReviewReports', null, { root: true })
|
||||||
|
dispatch('ui/fetchPendingReviewRequests', null, { root: true })
|
||||||
}
|
}
|
||||||
dispatch('favorites/fetch', null, { root: true })
|
dispatch('favorites/fetch', null, { root: true })
|
||||||
dispatch('channels/fetchSubscriptions', null, { root: true })
|
dispatch('channels/fetchSubscriptions', null, { root: true })
|
||||||
|
|
|
@ -50,6 +50,12 @@ export default {
|
||||||
value: 0
|
value: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
moderation: {
|
||||||
|
signup_approval_enabled: {
|
||||||
|
value: false,
|
||||||
|
},
|
||||||
|
signup_form_customization: {value: null}
|
||||||
|
},
|
||||||
subsonic: {
|
subsonic: {
|
||||||
enabled: {
|
enabled: {
|
||||||
value: true
|
value: true
|
||||||
|
|
|
@ -22,6 +22,7 @@ export default {
|
||||||
inbox: 0,
|
inbox: 0,
|
||||||
pendingReviewEdits: 0,
|
pendingReviewEdits: 0,
|
||||||
pendingReviewReports: 0,
|
pendingReviewReports: 0,
|
||||||
|
pendingReviewRequests: 0,
|
||||||
},
|
},
|
||||||
websocketEventsHandlers: {
|
websocketEventsHandlers: {
|
||||||
'inbox.item_added': {},
|
'inbox.item_added': {},
|
||||||
|
@ -29,6 +30,7 @@ export default {
|
||||||
'mutation.created': {},
|
'mutation.created': {},
|
||||||
'mutation.updated': {},
|
'mutation.updated': {},
|
||||||
'report.created': {},
|
'report.created': {},
|
||||||
|
'user_request.created': {},
|
||||||
},
|
},
|
||||||
pageTitle: null,
|
pageTitle: null,
|
||||||
routePreferences: {
|
routePreferences: {
|
||||||
|
@ -97,6 +99,16 @@ export default {
|
||||||
orderingDirection: "-",
|
orderingDirection: "-",
|
||||||
ordering: "creation_date",
|
ordering: "creation_date",
|
||||||
},
|
},
|
||||||
|
"manage.moderation.requests.list": {
|
||||||
|
paginateBy: 25,
|
||||||
|
orderingDirection: "-",
|
||||||
|
ordering: "creation_date",
|
||||||
|
},
|
||||||
|
"manage.moderation.reports.list": {
|
||||||
|
paginateBy: 25,
|
||||||
|
orderingDirection: "-",
|
||||||
|
ordering: "creation_date",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
serviceWorker: {
|
serviceWorker: {
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
|
@ -260,6 +272,11 @@ export default {
|
||||||
commit('notifications', {type: 'pendingReviewReports', count: response.data.count})
|
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) {
|
websocketEvent ({state}, event) {
|
||||||
let handlers = state.websocketEventsHandlers[event.type]
|
let handlers = state.websocketEventsHandlers[event.type]
|
||||||
console.log('Dispatching websocket event', event, handlers)
|
console.log('Dispatching websocket event', event, handlers)
|
||||||
|
|
|
@ -78,7 +78,8 @@ export default {
|
||||||
groups() {
|
groups() {
|
||||||
// somehow, extraction fails if in the return block directly
|
// somehow, extraction fails if in the return block directly
|
||||||
let instanceLabel = this.$pgettext('Content/Admin/Menu','Instance information')
|
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 musicLabel = this.$pgettext('*/*/*/Noun', 'Music')
|
||||||
let channelsLabel = this.$pgettext('*/*/*', 'Channels')
|
let channelsLabel = this.$pgettext('*/*/*', 'Channels')
|
||||||
let playlistsLabel = this.$pgettext('*/*/*', 'Playlists')
|
let playlistsLabel = this.$pgettext('*/*/*', 'Playlists')
|
||||||
|
@ -104,10 +105,18 @@ export default {
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: usersLabel,
|
label: signupsLabel,
|
||||||
id: "users",
|
id: "signup",
|
||||||
settings: [
|
settings: [
|
||||||
{name: "users__registration_enabled"},
|
{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: "common__api_authentication_required"},
|
||||||
{name: "users__default_permissions"},
|
{name: "users__default_permissions"},
|
||||||
{name: "users__upload_quota"},
|
{name: "users__upload_quota"},
|
||||||
|
|
|
@ -274,6 +274,16 @@
|
||||||
{{ stats.reports }}
|
{{ stats.reports }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -3,7 +3,20 @@
|
||||||
<nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu">
|
<nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu">
|
||||||
<router-link
|
<router-link
|
||||||
class="ui item"
|
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
|
<router-link
|
||||||
class="ui item"
|
class="ui item"
|
||||||
:to="{name: 'manage.moderation.domains.list'}"><translate translate-context="*/Moderation/*/Noun">Domains</translate></router-link>
|
: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