From effd06ca8b8f628937ec3c4a0dc002e64399479c Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden Date: Mon, 15 Apr 2024 08:20:02 -0700 Subject: [PATCH] Content moderator user preferences admin view (#3914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add UserPreferences model * Add string name for model, and create prefs on user creation * Add user preferences to separate section in admin UI * Use a custom form for only changing blur images in Admin UI * Add tests for the admin form * Add moderator user and content moderator group creation to init * Use more idiomatic Django MVC logic * Jump directly into the single user preferences entry from list view * Always return a bound object Co-authored-by: sarayourfriend <24264157+sarayourfriend@users.noreply.github.com> * Linting * Add UserPreferences insertion data migration * Use settings.AUTH_USER_MODEL instead of model directly * Update migration * Update permissions for UserPreferences admin models Co-authored-by: Dhruv Bhanushali * Re-add change permissions This is required for has_view_permissions: The default implementation returns True if the user has either the “change” or “view” permission. https://docs.djangoproject.com/en/5.0/ref/contrib/admin/#django.contrib.admin.ModelAdmin.has_view_permission --------- Co-authored-by: sarayourfriend <24264157+sarayourfriend@users.noreply.github.com> Co-authored-by: Dhruv Bhanushali --- api/api/admin/__init__.py | 44 ++++++++++++++- api/api/admin/forms.py | 19 +++++++ api/api/admin/site.py | 16 ++++++ api/api/migrations/0059_userpreferences.py | 34 ++++++++++++ api/api/models/__init__.py | 1 + api/api/models/moderation.py | 38 +++++++++++++ api/test/unit/admin/test_forms.py | 62 ++++++++++++++++++++++ load_sample_data.sh | 39 ++++++++++---- 8 files changed, 243 insertions(+), 10 deletions(-) create mode 100644 api/api/admin/forms.py create mode 100644 api/api/migrations/0059_userpreferences.py create mode 100644 api/api/models/moderation.py create mode 100644 api/test/unit/admin/test_forms.py diff --git a/api/api/admin/__init__.py b/api/api/admin/__init__.py index e2a4a1e2f23..3390383aef6 100644 --- a/api/api/admin/__init__.py +++ b/api/api/admin/__init__.py @@ -1,11 +1,22 @@ from django.contrib import admin from django.contrib.auth.admin import GroupAdmin, UserAdmin from django.contrib.auth.models import Group, User +from django.http.response import HttpResponseRedirect +from django.urls import reverse from oauth2_provider.models import AccessToken +from api.admin.forms import UserPreferencesAdminForm from api.admin.site import openverse_admin -from api.models import PENDING, Audio, AudioReport, ContentProvider, Image, ImageReport +from api.models import ( + PENDING, + Audio, + AudioReport, + ContentProvider, + Image, + ImageReport, + UserPreferences, +) from api.models.media import AbstractDeletedMedia, AbstractSensitiveMedia from api.models.oauth import ThrottledApplication @@ -127,3 +138,34 @@ class AccessTokenAdmin(admin.ModelAdmin): "created", "updated", ) + + +@admin.register(UserPreferences) +class IndividualUserPreferencesAdmin(admin.ModelAdmin): + """ + Model admin for showing user preferences. This should only ever show the + currently logged-in user's preferences + """ + + verbose_name_plural = "My Preferences" + verbose_name = "My Preferences" + form = UserPreferencesAdminForm + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.filter(user=request.user) + + def has_change_permission(self, request, obj=None): + return True + + def has_add_permission(*args, **kwargs): + return False + + def has_delete_permission(*args, **kwargs): + return False + + def changelist_view(self, request, extra_context=None): + obj = self.get_queryset(request).first() + return HttpResponseRedirect( + reverse("admin:api_userpreferences_change", args=[obj.id]) + ) diff --git a/api/api/admin/forms.py b/api/api/admin/forms.py new file mode 100644 index 00000000000..2ac56677391 --- /dev/null +++ b/api/api/admin/forms.py @@ -0,0 +1,19 @@ +from django import forms + +from api.models import UserPreferences + + +class UserPreferencesAdminForm(forms.ModelForm): + blur_images = forms.BooleanField(initial=True, required=False) + + class Meta: + model = UserPreferences + fields = ["blur_images"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.initial["blur_images"] = self.instance.blur_images + + def save(self, commit=True): + self.instance.blur_images = self.cleaned_data.get("blur_images") + return super().save(commit=commit) diff --git a/api/api/admin/site.py b/api/api/admin/site.py index e7d478e8e46..2e95432cecd 100644 --- a/api/api/admin/site.py +++ b/api/api/admin/site.py @@ -46,6 +46,22 @@ def key(entry): } app_list.insert(0, media_app) + # Move user preferences to its own section + for model in api_app["models"][:]: + if model["object_name"] == "UserPreferences": + model["name"] = "My Preferences" + api_app["models"].remove(model) + app_list.insert( + -1, + { + "name": "User Preferences", + "app_label": "preferences", + "app_url": "/admin/api/userpreferences/", + "has_module_perms": True, + "models": [model], + }, + ) + return app_list diff --git a/api/api/migrations/0059_userpreferences.py b/api/api/migrations/0059_userpreferences.py new file mode 100644 index 00000000000..a5bad02e2b5 --- /dev/null +++ b/api/api/migrations/0059_userpreferences.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.11 on 2024-04-10 01:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def create_user_preferences(apps, schema_editor): + UserPreferences = apps.get_model("api", "UserPreferences") + User = apps.get_model(*settings.AUTH_USER_MODEL.split(".")) + for user in User.objects.all(): + if not hasattr(user, "userpreferences"): + UserPreferences.objects.create(user=user) + user.userpreferences.save() + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0058_moderation_decision'), + ] + + operations = [ + migrations.CreateModel( + name='UserPreferences', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('preferences', models.JSONField(default=dict)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.RunPython(create_user_preferences) + ] diff --git a/api/api/models/__init__.py b/api/api/models/__init__.py index e91d289a99d..fb1adcfaa16 100644 --- a/api/api/models/__init__.py +++ b/api/api/models/__init__.py @@ -19,6 +19,7 @@ PENDING, ) from api.models.models import ContentProvider, Tag +from api.models.moderation import UserPreferences from api.models.oauth import ( OAuth2Registration, OAuth2Verification, diff --git a/api/api/models/moderation.py b/api/api/models/moderation.py new file mode 100644 index 00000000000..26bd67c18ac --- /dev/null +++ b/api/api/models/moderation.py @@ -0,0 +1,38 @@ +from django.conf import settings +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver + + +class UserPreferences(models.Model): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + preferences = models.JSONField(default=dict) + + def __str__(self): + return f"{self.user.username}'s preferences" + + @property + def moderator(self): + if "moderator" not in self.preferences: + self.preferences["moderator"] = {} + + return self.preferences["moderator"] + + @moderator.setter + def moderator(self, value): + self.preferences["moderator"] = value + + @property + def blur_images(self): + return self.moderator.get("blur_images", True) + + @blur_images.setter + def blur_images(self, value): + self.moderator |= {"blur_images": value} + + +@receiver(post_save, sender=settings.AUTH_USER_MODEL) +def create_or_update_user_profile(sender, instance, created, **kwargs): + if created: + UserPreferences.objects.create(user=instance) + instance.userpreferences.save() diff --git a/api/test/unit/admin/test_forms.py b/api/test/unit/admin/test_forms.py new file mode 100644 index 00000000000..cfeced7a50e --- /dev/null +++ b/api/test/unit/admin/test_forms.py @@ -0,0 +1,62 @@ +import pytest + +from api.admin.forms import UserPreferencesAdminForm +from api.models import UserPreferences + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "initial_preferences, blur_images_value, expected_preferences", + [ + ({}, True, {"moderator": {"blur_images": True}}), + ({}, False, {"moderator": {"blur_images": False}}), + ( + {"moderator": {"blur_images": False}}, + True, + {"moderator": {"blur_images": True}}, + ), + ( + {"moderator": {"blur_images": False}}, + False, + {"moderator": {"blur_images": False}}, + ), + ( + {"moderator": {"blur_images": True}}, + True, + {"moderator": {"blur_images": True}}, + ), + ( + {"moderator": {"blur_images": False}}, + False, + {"moderator": {"blur_images": False}}, + ), + ( + {"some_other_setting": 123}, + False, + {"some_other_setting": 123, "moderator": {"blur_images": False}}, + ), + ( + {"moderator": {"some_other_setting": 123}}, + False, + {"moderator": {"some_other_setting": 123, "blur_images": False}}, + ), + ], +) +def test_user_preferences_form( + initial_preferences, blur_images_value, expected_preferences, django_user_model +): + user = django_user_model.objects.create(username="foobar", password="fake") + preferences = user.userpreferences + # Check that new user has no preferences + assert preferences.preferences == {} + # Set the initial user preferences + preferences.preferences = initial_preferences + preferences.save() + + form_data = {"blur_images": blur_images_value} + form = UserPreferencesAdminForm(data=form_data, instance=preferences) + assert form.is_valid(), "Form should be valid" + instance = form.save() + # Retrieve the instance from the database to ensure it's saved correctly + saved_instance = UserPreferences.objects.get(id=instance.id) + assert saved_instance.preferences == expected_preferences diff --git a/load_sample_data.sh b/load_sample_data.sh index 1ead589998a..0143fd778c2 100755 --- a/load_sample_data.sh +++ b/load_sample_data.sh @@ -64,16 +64,37 @@ just dc exec -T "$WEB_SERVICE_NAME" python3 manage.py migrate --noinput # Create a superuser and a user for integration testing echo " from django.contrib.auth.models import User -usernames = ['continuous_integration', 'deploy'] +usernames = ['continuous_integration', 'deploy', 'moderator'] for username in usernames: - if User.objects.filter(username=username).exists(): - print(f'User {username} already exists') - continue - if username == 'deploy': - user = User.objects.create_superuser(username, f'{username}@example.com', 'deploy') - else: - user = User.objects.create_user(username, f'{username}@example.com', 'deploy') - user.save() + if User.objects.filter(username=username).exists(): + print(f'User {username} already exists') + continue + if username == 'deploy': + user = User.objects.create_superuser(username, f'{username}@example.com', 'deploy') + else: + is_staff = username == 'moderator' + user = User.objects.create_user(username, f'{username}@example.com', 'deploy', is_staff=is_staff) + user.save() +" | just dc exec -T "$WEB_SERVICE_NAME" python3 manage.py shell + +# Create the Content Moderator group and add the moderator to it +# Credit: https://stackoverflow.com/a/53733693 +echo " +from django.contrib.auth.models import User, Group, Permission +perms_to_add = ['view', 'add', 'change'] +models_to_affect = ['audio report', 'image report', 'sensitive audio', 'sensitive image'] + +mod_group, created = Group.objects.get_or_create(name='Content Moderators') +if created: + print('Setting up Content Moderators group') + for model in models_to_affect: + for perm in perms_to_add: + name = f'Can {perm} {model}' + print(f'Adding permission to moderators group: {name}') + model_add_perm = Permission.objects.get(name=name) + mod_group.permissions.add(model_add_perm) + mod_group.save() + mod_group.user_set.add(User.objects.get(username='moderator')) " | just dc exec -T "$WEB_SERVICE_NAME" python3 manage.py shell # Load content providers