Commit f8baae53 authored by Agate's avatar Agate 💬

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

Resolve "Screening for signups"

Closes #1040

See merge request !1056
parents e6df21b9 e313fcd0
Pipeline #9980 passed with stages
in 8 minutes and 48 seconds
......@@ -135,7 +135,7 @@ test_api:
only:
- branches
before_script:
- apk add make git
- apk add make git gcc python3-dev
- cd api
- pip3 install -r requirements/base.txt
- pip3 install -r requirements/local.txt
......
......@@ -659,6 +659,8 @@ AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
if env.bool("DISABLE_PASSWORD_VALIDATORS", default=False):
AUTH_PASSWORD_VALIDATORS = []
ACCOUNT_ADAPTER = "funkwhale_api.users.adapters.FunkwhaleAccountAdapter"
CORS_ORIGIN_ALLOW_ALL = True
# CORS_ORIGIN_WHITELIST = (
......
import json
from django import forms
from django.contrib.postgres.forms import JSONField
from django.conf import settings
from dynamic_preferences import serializers, types
from dynamic_preferences.registries import global_preferences_registry
......@@ -57,3 +60,48 @@ class StringListPreference(types.BasePreferenceType):
d = super(StringListPreference, self).get_api_additional_data()
d["choices"] = self.get("choices")
return d
class JSONSerializer(serializers.BaseSerializer):
required = True
@classmethod
def to_db(cls, value, **kwargs):
if not cls.required and value is None:
return json.dumps(value)
data_serializer = cls.data_serializer_class(data=value)
if not data_serializer.is_valid():
raise cls.exception(
"{} is not a valid value: {}".format(value, data_serializer.errors)
)
value = data_serializer.validated_data
try:
return json.dumps(value, sort_keys=True)
except TypeError:
raise cls.exception(
"Cannot serialize, value {} is not JSON serializable".format(value)
)
@classmethod
def to_python(cls, value, **kwargs):
return json.loads(value)
class SerializedPreference(types.BasePreferenceType):
"""
A preference that store arbitrary JSON and validate it using a rest_framework
serializer
"""
serializer = JSONSerializer
data_serializer_class = None
field_class = JSONField
widget = forms.Textarea
@property
def serializer(self):
class _internal(JSONSerializer):
data_serializer_class = self.data_serializer_class
required = self.get("required")
return _internal
# Generated by Django 3.0.4 on 2020-03-17 08:20
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('federation', '0024_actor_attachment_icon'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='object_content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objecting_activities', to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='activity',
name='object_id',
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='activity',
name='related_object_content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_objecting_activities', to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='activity',
name='related_object_id',
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='activity',
name='target_content_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='targeting_activities', to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='activity',
name='target_id',
field=models.IntegerField(blank=True, null=True),
),
]
......@@ -292,6 +292,9 @@ class Actor(models.Model):
from_activity__actor=self.pk
).count()
data["reports"] = moderation_models.Report.objects.get_for_target(self).count()
data["requests"] = moderation_models.UserRequest.objects.filter(
submitter=self
).count()
data["albums"] = music_models.Album.objects.filter(
from_activity__actor=self.pk
).count()
......
......@@ -394,3 +394,26 @@ class ManageNoteFilterSet(filters.FilterSet):
class Meta:
model = moderation_models.Note
fields = ["q"]
class ManageUserRequestFilterSet(filters.FilterSet):
q = fields.SmartSearchFilter(
config=search.SearchConfig(
search_fields={
"username": {"to": "submitter__preferred_username"},
"uuid": {"to": "uuid"},
},
filter_fields={
"uuid": {"to": "uuid"},
"id": {"to": "id"},
"status": {"to": "status"},
"category": {"to": "type"},
"submitter": get_actor_filter("submitter"),
"assigned_to": get_actor_filter("assigned_to"),
},
)
)
class Meta:
model = moderation_models.UserRequest
fields = ["q", "status", "type"]
......@@ -710,3 +710,36 @@ class ManageReportSerializer(serializers.ModelSerializer):
def get_notes(self, o):
notes = getattr(o, "_prefetched_notes", [])
return ManageBaseNoteSerializer(notes, many=True).data
class ManageUserRequestSerializer(serializers.ModelSerializer):
assigned_to = ManageBaseActorSerializer()
submitter = ManageBaseActorSerializer()
notes = serializers.SerializerMethodField()
class Meta:
model = moderation_models.UserRequest
fields = [
"id",
"uuid",
"creation_date",
"handled_date",
"type",
"status",
"assigned_to",
"submitter",
"notes",
"metadata",
]
read_only_fields = [
"id",
"uuid",
"submitter",
"creation_date",
"handled_date",
"metadata",
]
def get_notes(self, o):
notes = getattr(o, "_prefetched_notes", [])
return ManageBaseNoteSerializer(notes, many=True).data
......@@ -18,6 +18,7 @@ moderation_router.register(
r"instance-policies", views.ManageInstancePolicyViewSet, "instance-policies"
)
moderation_router.register(r"reports", views.ManageReportViewSet, "reports")
moderation_router.register(r"requests", views.ManageUserRequestViewSet, "requests")
moderation_router.register(r"notes", views.ManageNoteViewSet, "notes")
users_router = routers.OptionalSlashRouter()
......
from rest_framework import mixins, response, viewsets
from rest_framework import decorators as rest_decorators
from django.db import transaction
from django.db.models import Count, Prefetch, Q, Sum, OuterRef, Subquery
from django.db.models.functions import Coalesce, Length
from django.shortcuts import get_object_or_404
from funkwhale_api.common import models as common_models
from funkwhale_api.common import preferences, decorators
from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import tasks as federation_tasks
......@@ -14,6 +16,7 @@ from funkwhale_api.history import models as history_models
from funkwhale_api.music import models as music_models
from funkwhale_api.music import views as music_views
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import tasks as moderation_tasks
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.tags import models as tags_models
from funkwhale_api.users import models as users_models
......@@ -469,8 +472,8 @@ class ManageActorViewSet(
@rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs):
domain = self.get_object()
return response.Response(domain.get_stats(), status=200)
obj = self.get_object()
return response.Response(obj.get_stats(), status=200)
action = decorators.action_route(serializers.ManageActorActionSerializer)
......@@ -607,3 +610,54 @@ class ManageTagViewSet(
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)
class ManageUserRequestViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
moderation_models.UserRequest.objects.all()
.order_by("-creation_date")
.select_related("submitter", "assigned_to")
.prefetch_related(
Prefetch(
"notes",
queryset=moderation_models.Note.objects.order_by(
"creation_date"
).select_related("author"),
to_attr="_prefetched_notes",
)
)
)
serializer_class = serializers.ManageUserRequestSerializer
filterset_class = filters.ManageUserRequestFilterSet
required_scope = "instance:requests"
ordering_fields = ["id", "creation_date", "handled_date"]
def get_queryset(self):
queryset = super().get_queryset()
if self.action in ["update", "partial_update"]:
# approved requests cannot be edited
queryset = queryset.exclude(status="approved")
return queryset
@transaction.atomic
def perform_update(self, serializer):
old_status = serializer.instance.status
new_status = serializer.validated_data.get("status")
if old_status != new_status and new_status != "pending":
# report was resolved, we assign to the mod making the request
serializer.save(assigned_to=self.request.user.actor)
common_utils.on_commit(
moderation_tasks.user_request_handle.delay,
user_request_id=serializer.instance.pk,
new_status=new_status,
old_status=old_status,
)
else:
serializer.save()
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
from rest_framework import serializers
from funkwhale_api.common import preferences as common_preferences
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
from . import models
......@@ -40,3 +44,52 @@ class UnauthenticatedReportTypes(common_preferences.StringListPreference):
help_text = "A list of categories for which external users (without an account) can submit a report"
choices = models.REPORT_TYPES
field_kwargs = {"choices": choices, "required": False}
@global_preferences_registry.register
class SignupApprovalEnabled(types.BooleanPreference):
show_in_api = True
section = moderation
name = "signup_approval_enabled"
verbose_name = "Enable manual sign-up validation"
help_text = "If enabled, new registrations will go to a moderation queue and need to be reviewed by moderators."
default = False
CUSTOM_FIELDS_TYPES = [
"short_text",
"long_text",
]
class CustomFieldSerializer(serializers.Serializer):
label = serializers.CharField()
required = serializers.BooleanField(default=True)
input_type = serializers.ChoiceField(choices=CUSTOM_FIELDS_TYPES)
class CustomFormSerializer(serializers.Serializer):
help_text = common_serializers.ContentSerializer(required=False, allow_null=True)
fields = serializers.ListField(
child=CustomFieldSerializer(), min_length=0, max_length=10, required=False
)
def validate_help_text(self, v):
if not v:
return
v["html"] = common_utils.render_html(
v["text"], content_type=v["content_type"], permissive=True
)
return v
@global_preferences_registry.register
class SignupFormCustomization(common_preferences.SerializedPreference):
show_in_api = True
section = moderation
name = "signup_form_customization"
verbose_name = "Sign-up form customization"
help_text = "Configure custom fields and help text for your sign-up form"
required = False
default = {}
data_serializer_class = CustomFormSerializer
......@@ -74,3 +74,20 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
return
self.target_owner = serializers.get_target_owner(self.target)
@registry.register
class UserRequestFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
submitter = factory.SubFactory(federation_factories.ActorFactory, local=True)
class Meta:
model = "moderation.UserRequest"
class Params:
signup = factory.Trait(
submitter=factory.SubFactory(federation_factories.ActorFactory, local=True),
type="signup",
)
assigned = factory.Trait(
assigned_to=factory.SubFactory(federation_factories.ActorFactory)
)
# Generated by Django 3.0.4 on 2020-03-17 08:20
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
('federation', '0025_auto_20200317_0820'),
('moderation', '0004_note'),
]
operations = [
migrations.AlterField(
model_name='report',
name='summary',
field=models.TextField(blank=True, max_length=50000, null=True),
),
migrations.CreateModel(
name='UserRequest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(blank=True, max_length=500, null=True)),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('handled_date', models.DateTimeField(null=True)),
('type', models.CharField(choices=[('signup', 'Sign-up')], max_length=40)),
('status', models.CharField(choices=[('pending', 'Pending'), ('refused', 'Refused'), ('approved', 'approved')], default='pending', max_length=40)),
('metadata', django.contrib.postgres.fields.jsonb.JSONField(null=True)),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_requests', to='federation.Actor')),
('submitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requests', to='federation.Actor')),
],
options={
'abstract': False,
},
),
]
......@@ -185,6 +185,43 @@ class Note(models.Model):
target = GenericForeignKey("target_content_type", "target_id")
USER_REQUEST_TYPES = [
("signup", "Sign-up"),
]
USER_REQUEST_STATUSES = [
("pending", "Pending"),
("refused", "Refused"),
("approved", "Approved"),
]
class UserRequest(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
handled_date = models.DateTimeField(null=True)
type = models.CharField(max_length=40, choices=USER_REQUEST_TYPES)
status = models.CharField(
max_length=40, choices=USER_REQUEST_STATUSES, default="pending"
)
submitter = models.ForeignKey(
"federation.Actor", related_name="requests", on_delete=models.CASCADE,
)
assigned_to = models.ForeignKey(
"federation.Actor",
related_name="assigned_requests",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
metadata = JSONField(null=True)
notes = GenericRelation(
"Note", content_type_field="target_content_type", object_id_field="target_id"
)
@receiver(pre_save, sender=Report)
def set_handled_date(sender, instance, **kwargs):
if instance.is_handled is True and not instance.handled_date:
......
import logging
from django.core import mail
from django.dispatch import receiver
from django.conf import settings
from django.db import transaction
from django.dispatch import receiver
from funkwhale_api.common import channels
from funkwhale_api.common import preferences
from funkwhale_api.common import utils
from funkwhale_api.taskapp import celery
from funkwhale_api.federation import utils as federation_utils
......@@ -41,11 +43,7 @@ def trigger_moderator_email(report, **kwargs):
utils.on_commit(send_new_report_email_to_moderators.delay, report_id=report.pk)
@celery.app.task(name="moderation.send_new_report_email_to_moderators")
@celery.require_instance(
models.Report.objects.select_related("submitter").filter(is_handled=False), "report"
)
def send_new_report_email_to_moderators(report):
def get_moderators():
moderators = users_models.User.objects.filter(
is_active=True, permission_moderation=True
)
......@@ -53,6 +51,15 @@ def send_new_report_email_to_moderators(report):
# we fallback on superusers
moderators = users_models.User.objects.filter(is_superuser=True)
moderators = sorted(moderators, key=lambda m: m.pk)
return moderators
@celery.app.task(name="moderation.send_new_report_email_to_moderators")
@celery.require_instance(
models.Report.objects.select_related("submitter").filter(is_handled=False), "report"
)
def send_new_report_email_to_moderators(report):
moderators = get_moderators()
submitter_repr = (
report.submitter.full_username if report.submitter else report.submitter_email
)
......@@ -114,3 +121,148 @@ def send_new_report_email_to_moderators(report):
recipient_list=[moderator.email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
@celery.app.task(name="moderation.user_request_handle")
@celery.require_instance(
models.UserRequest.objects.select_related("submitter"), "user_request"
)
@transaction.atomic
def user_request_handle(user_request, new_status, old_status=None):
if user_request.status != new_status:
logger.warn(
"User request %s was handled before asynchronous tasks run", user_request.pk
)
return
if user_request.type == "signup" and new_status == "pending" and old_status is None:
notify_mods_signup_request_pending(user_request)
broadcast_user_request_created(user_request)
elif user_request.type == "signup" and new_status == "approved":
user_request.submitter.user.is_active = True
user_request.submitter.user.save(update_fields=["is_active"])
notify_submitter_signup_request_approved(user_request)
elif user_request.type == "signup" and new_status == "refused":
notify_submitter_signup_request_refused(user_request)
def broadcast_user_request_created(user_request):
from funkwhale_api.manage import serializers as manage_serializers
channels.group_send(
"admin.moderation",
{
"type": "event.send",
"text": "",
"data": {
"type": "user_request.created",
"user_request": manage_serializers.ManageUserRequestSerializer(
user_request
).data,
"pending_count": models.UserRequest.objects.filter(
status="pending"
).count(),
},
},
)
def notify_mods_signup_request_pending(obj):
moderators = get_moderators()
submitter_repr = obj.submitter.preferred_username
subject = "[{} moderation] New sign-up request from {}".format(
settings.FUNKWHALE_HOSTNAME, submitter_repr
)
detail_url = federation_utils.full_url(
"/manage/moderation/requests/{}".format(obj.uuid)
)
unresolved_requests_url = federation_utils.full_url(
"/manage/moderation/requests?q=status:pending"
)
unresolved_requests = models.UserRequest.objects.filter(status="pending").count()
body = [
"{} wants to register on your pod. You need to review their request before they can use the service.".format(
submitter_repr
),
"",
"- To handle this request, please visit {}".format(detail_url),
"- To view all unresolved requests (currently {}), please visit {}".format(
unresolved_requests, unresolved_requests_url
),
"",
"—",
"",
"You are receiving this email because you are a moderator for {}.".format(
settings.FUNKWHALE_HOSTNAME
),
]
for moderator in moderators:
if not moderator.email:
logger.warning("Moderator %s has no email configured", moderator.username)
continue
mail.send_mail(
subject,
message="\n".join(body),
recipient_list=[moderator.email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
def notify_submitter_signup_request_approved(user_request):
submitter_repr = user_request.submitter.preferred_username
submitter_email = user_request.submitter.user.email
if not submitter_email:
logger.warning("User %s has no email configured", submitter_repr)
return
subject = "Welcome to {}, {}!".format(settings.FUNKWHALE_HOSTNAME, submitter_repr)
login_url = federation_utils.full_url("/login")
body = [
"Hi {} and welcome,".format(submitter_repr),
"",
"Our moderation team has approved your account request and you can now start "
"using the service. Please visit {} to get started.".format(login_url),
"",
"Before your first login, you may need to verify your email address if you didn't already.",
]
mail.send_mail(
subject,
message="\n".join(body),
recipient_list=[submitter_email],
from_email=settings.DEFAULT_FROM_EMAIL,
)
def notify_submitter_signup_request_refused(user_request):
submitter_repr = user_request.submitter.preferred_username
submitter_email = user_request.submitter.user.email
if not submitter_email:
logger.warning("User %s has no email configured", submitter_repr)
return
subject = "Your account request at {} was refused".format(
settings.FUNKWHALE_HOSTNAME
)
body = [
"Hi {},".format(submitter_repr),
"",
"You recently submitted an account request on our service. However, our "
"moderation team has refused it, and as a result, you won't be able to use "