Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Content moderator user preferences admin view #3914

Merged
merged 15 commits into from Apr 15, 2024

Conversation

AetherUnbound
Copy link
Contributor

@AetherUnbound AetherUnbound commented Mar 14, 2024

Fixes

Fixes #3634 by @AetherUnbound

Description

This PR adds a new UserPreferences "profile" model to the project, along with a new Django Admin view for editing a user's preferences. The admin view only ever displays the currently logged in user's preferences, and it has a custom form set up to set (or unset) a specific value within the jsonb column used for storing preferences.

In a bit of a follow up to #3633, I've also added some startup logic to create both an additional moderator user with staff access to the Django Admin site and a "Content Moderators" group which has the permissions we're expecting for that group. The upshot of this is that all of the testing below shouldn't require any additional user or group creation! Yay!

This was really tricky to figure out how to match the expectations of the IP while doing all this within the Django framework. I haven't done a ton of Django work, so if there are more idiomatic ways to accomplish what I've done, please suggest them!

Screenshots

Moderator user view
image

Moderator preferences edit form
image

Testing Instructions

I added some tests specifically for the admin UI form - those should pass (and hopefully make sense).

  1. Run just api/dbshell, then execute select u.username, p.preferences from auth_user u left join api_userpreferences p on p.user_id = u.id;. There should be 3 users and all preferences should be blank.
  2. Log in to the admin site (http://localhost:50280/admin/) using deploy/deploy. Observe the new "USER PREFERENCES" section with the "My Preferences" entry.
  3. Click on "My Preferences". Observe that only deploy's preferences shows up in the list.
  4. Click on deploy's preferences. Observe that the form only includes a check box for "Blur images".
  5. Log out and log in again using moderator/deploy. Observe that only the report/sensitivity tables and user preferences sections show up.
  6. Follow the steps above to edit the user preferences (verifying only the moderator user shows up), only this time unchecking the "Blur images" box. Click save.
  7. Run the select above once more. The preferences value for the moderator user should be {"moderator": {"blur_images": false}}
  8. Edit the preferences once more time and re-enable blurring images. Run the select above and verify the preferences saved in the database are now {"moderator": {"blur_images": true}}

Checklist

  • My pull request has a descriptive title (not a vague title likeUpdate index.md).
  • My pull request targets the default branch of the repository (main) or a parent feature branch.
  • My commit messages follow best practices.
  • My code follows the established code style of the repository.
  • I added or updated tests for the changes I made (if applicable).
  • I added or updated documentation (if applicable).
  • I tried running the project locally and verified that there are no visible errors.
  • I ran the DAG documentation generator (if applicable).

Developer Certificate of Origin

Developer Certificate of Origin
Developer Certificate of Origin
Version 1.1

Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
1 Letterman Drive
Suite D4700
San Francisco, CA, 94129

Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.


Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.

@AetherUnbound AetherUnbound requested a review from a team as a code owner March 14, 2024 00:40
@github-actions github-actions bot added 🧱 stack: api Related to the Django API 🧱 stack: ingestion server Related to the ingestion/data refresh server labels Mar 14, 2024
@openverse-bot openverse-bot added 🟨 priority: medium Not blocking but should be addressed soon 🌟 goal: addition Addition of new feature 🕹 aspect: interface Concerns end-users' experience with the software labels Mar 14, 2024
@github-actions github-actions bot added the migrations Modifications to Django migrations label Mar 14, 2024
Copy link
Contributor

@sarayourfriend sarayourfriend left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking really good! The approach you took makes sense coming from a low amount of Django background and less context on how code sharing in Django works. I've left a comment with a diff showing the specific reorganisation of the code that would align with Django's mental model. All of which ends up with the exact same behaviour (and almost the same code) that you've implemented, just in different locations to make it align with Django's separation of concerns between models and forms. That separation of concerns somewhat matches the separation between model and view/controller in the MVC pattern (though Django's FAQs point out that Django complicates the VC part of MVC by having the framework do a lot of the typical "controller" stuff).

Hope that helps!

api/api/admin/forms.py Outdated Show resolved Hide resolved
@sarayourfriend
Copy link
Contributor

Oh, the only other thing, to make this more usable, it'd be good if the list view (http://localhost:50280/admin/api/userpreferences/) is skipped when clicking "my preferences" from the model list view. There's only ever one item in the list, the logged-in user's preferences, so it's an unnecessary interstitial page. I tried this out locally, and it looks like this works. The idea is to just redirect requests to the changelist view to the change view.

diff --git a/api/api/admin/__init__.py b/api/api/admin/__init__.py
index 3cb3c31cb..581a6961e 100644
--- a/api/api/admin/__init__.py
+++ b/api/api/admin/__init__.py
@@ -1,6 +1,8 @@
 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 api.admin.forms import UserPreferencesAdminForm
 from api.admin.site import openverse_admin
@@ -122,3 +124,7 @@ class IndividualUserPreferencesAdmin(admin.ModelAdmin):
             # the changelist itself
             return True
         return obj.user == request.user
+
+    def changelist_view(self, request, extra_context=None):
+        obj = self.get_queryset(request).first()
+        return HttpResponseRedirect(reverse("admin:api_userpreferences_change", args=[obj.id]))

@AetherUnbound AetherUnbound marked this pull request as draft March 21, 2024 16:44
@AetherUnbound AetherUnbound force-pushed the feature/moderator-user-preferences branch from edf844a to 8fd2c14 Compare March 21, 2024 21:22
@WordPress WordPress deleted a comment from github-actions bot Mar 21, 2024
@AetherUnbound
Copy link
Contributor Author

Thanks so much for the feedback @sarayourfriend, and the extra bit of code to jump straight into the preferences rather than the list view! I wanted to try and make that the case but didn't even know it was possible. I've made those changes and rebased, this should be good to re-review 😄

@AetherUnbound AetherUnbound marked this pull request as ready for review March 21, 2024 21:24
Copy link
Contributor

@sarayourfriend sarayourfriend left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't tested the new code locally, but I realised a couple very small potential bugs while thinking about and re-reading the code.

api/api/models/moderation.py Outdated Show resolved Hide resolved
api/api/admin/__init__.py Show resolved Hide resolved
@zackkrida zackkrida marked this pull request as draft March 25, 2024 15:24
@WordPress WordPress deleted a comment from github-actions bot Mar 26, 2024
@AetherUnbound AetherUnbound marked this pull request as ready for review March 26, 2024 21:33
@WordPress WordPress deleted a comment from github-actions bot Mar 26, 2024
Copy link
Contributor

@sarayourfriend sarayourfriend left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! I left one big comment but it is not a request for a change, which I've explained there. Just an "FYI" for the future. Apologies if it's all information you're familiar with, but the in-Python filtering on a .all() QuerySet pulled entirely into memory all at once is an easy and common mistake to make for folks new to Django, the ORM needing a "here be dragons" disclaimer and all.

('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.RunPython(create_user_preferences)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines 11 to 12
for user in User.objects.all():
if not hasattr(user, "userpreferences"):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a nit (and mostly a tip for the future, really), but we would ideally do this kind of filtering at the ORM level, so that it's executed in the DB, rather than in Python. Calling .all() on a model manager will load literally every single instance of the model into memory. That might not matter in this case, because I don't think we have many User instances in our live environments, but at some future date it could.

In Django ORM you could do that a couple of ways, in order of best to worst options. You can definitely use the first one in this case, but for more complex cases, the others might be necessary. For all of these we would still want to use QuerySet slices to avoid loading all selected instances into memory: even a subset could be huge. These alternative queries reduce the selected rows, but don't necessarily solve the problem of loading too many instances into memory all at once.

So for this case, you'd just use isnull to do an outer join:

User.objects.filter(userpreferences__isnull=True)

Which turns into this SQL:

SELECT
    "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined"
FROM
    "auth_user"
    LEFT OUTER JOIN
        "api_userpreferences" ON ("auth_user"."id" = "api_userpreferences"."user_id")
WHERE
    "api_userpreferences"."id" IS NULL

I don't think there's any reason not to use that approach for this particular kind of one-to-one relationship.

For other circumstances, you can also do the following, to avoid filtering in Python.

With an inner-select and not-in (probably the simplest, but potentially also problematic):

User.objects.exclude(pk__in=UserPreferences.objects.values_list("user", flat=True))

Which turns into this SQL:

SELECT
    "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined"
FROM
    "auth_user"
WHERE NOT (
    "auth_user"."id" IN (SELECT U0."user_id" FROM "api_userpreferences" U0)
)

With a join:

from django.db.models import Exists, OuterRef

User.objects.filter(Exists(UserPreferences.objects.filter(user=OuterRef("pk"))))

which turns into this SQL:

SELECT
    "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined"
FROM
    "auth_user"
WHERE
    EXISTS (
        SELECT 1 AS "a" FROM "api_userpreferences" U0 WHERE U0."user_id" = ("auth_user"."id") LIMIT 1
    )

Any of these are ideally combined with QuerySet slicing to avoid loading the full list into memory. Any of these query sets can be used like so, to achieve chunking:

qs = User.objects.whatever()
slice_size = 10

while (slice := qs[:slice_size]):
    for user in slice:
        # do whatever

That has the added benefit of being a noop if qs is empty. QueryString slices are already evaluated, so assigning it to slice saves any second query, and bool(empty_qs) evaluates to False, so the while loop combined with walrus operator works great here.

But anyway, I don't think this will be a problem for this PR. Each ThrottledApplication instance is able to have a user (https://github.com/jazzband/django-oauth-toolkit/blob/master/oauth2_provider/models.py#L102-L108) but it looks like that isn't something the library automatically enforces. I went ahead and queried production API database to be sure:

deploy@localhost:openledger> select count(*) from api_throttledapplication where user_id is not null;
+-------+
| count |
|-------|
| 0     |
+-------+
SELECT 1
Time: 0.227s
deploy@localhost:openledger> select count(*) from api_throttledapplication where user_id is null;
+-------+
| count |
|-------|
| 5031  |
+-------+
SELECT 1
Time: 0.233s

And more succinctly:

deploy@localhost:openledger> select count(*) from auth_user;
+-------+
| count |
|-------|
| 2     |
+-------+
SELECT 1
Time: 0.227s

So anyway, definitely not a problem in this particular case, but just wanted to share how to avoid this in the future in case it ever would be a problem, and give examples of how it can be achieved. Django ORM is very powerful but terribly complex 😰

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

THANK YOU for taking the time to write this all out! I'll definitely be referencing it in the future 😄 I took the easiest approach here because I knew we had very few user accounts at this point, so iterating over all of them would not be a problem. I appreciate the reminder about how this "giant hammer" approach could be problematic in the future!

@sarayourfriend
Copy link
Contributor

Oh, one thing we might want in the future, if we do get outside folks working on reports, is the ability to view a specific user's preferences from the User admin. It should be easy to do with InlineModelAdmin and would make it possible for Openverse admin to "debug" preferences for a particular moderator if they ever became more complex, considering we don't have any "become user" functionality.

That's for a separate issue and only if we ever needed it. As far as we know, it isn't going to be a problem for the forseeable future.

@openverse-bot
Copy link
Collaborator

Based on the medium urgency of this PR, the following reviewers are being gently reminded to review this PR:

@dhruvkb
This reminder is being automatically generated due to the urgency configuration.

Excluding weekend1 days, this PR was ready for review 6 day(s) ago. PRs labelled with medium urgency are expected to be reviewed within 4 weekday(s)2.

@AetherUnbound, if this PR is not ready for a review, please draft it to prevent reviewers from getting further unnecessary pings.

Footnotes

  1. Specifically, Saturday and Sunday.

  2. For the purpose of these reminders we treat Monday - Friday as weekdays. Please note that the operation that generates these reminders runs at midnight UTC on Monday - Friday. This means that depending on your timezone, you may be pinged outside of the expected range.

Copy link
Member

@dhruvkb dhruvkb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have one request for a change and also a PR #4034 with some bigger changes towards the admin UI for the model.

api/api/models/moderation.py Outdated Show resolved Hide resolved
api/api/migrations/0058_userpreferences.py Outdated Show resolved Hide resolved
@AetherUnbound AetherUnbound force-pushed the feature/moderator-user-preferences branch from 562650f to 28f8b6b Compare April 10, 2024 02:02
@WordPress WordPress deleted a comment from github-actions bot Apr 10, 2024
@AetherUnbound
Copy link
Contributor Author

I believe I've addressed all the issues! Will wait for CI to pass, then I'll merge this 😄

Copy link
Member

@dhruvkb dhruvkb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommending some changes based on #4034.

api/api/admin/__init__.py Outdated Show resolved Hide resolved
api/api/admin/__init__.py Outdated Show resolved Hide resolved
Co-authored-by: Dhruv Bhanushali <hi@dhruvkb.dev>
@WordPress WordPress deleted a comment from github-actions bot Apr 10, 2024
@AetherUnbound
Copy link
Contributor Author

After the recent changes, the moderator user is not able to see user preferences. I'm going to try and figure out why.

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
@WordPress WordPress deleted a comment from github-actions bot Apr 10, 2024
Copy link

This PR has migrations. Please rebase it before merging to ensure that conflicting migrations are not introduced.

Copy link
Member

@dhruvkb dhruvkb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for addressing my feedback and correcting my mistake as well!

@AetherUnbound AetherUnbound merged commit effd06c into main Apr 15, 2024
44 checks passed
@AetherUnbound AetherUnbound deleted the feature/moderator-user-preferences branch April 15, 2024 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🕹 aspect: interface Concerns end-users' experience with the software 🌟 goal: addition Addition of new feature migrations Modifications to Django migrations 🟨 priority: medium Not blocking but should be addressed soon 🧱 stack: api Related to the Django API 🧱 stack: ingestion server Related to the ingestion/data refresh server
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

Add content moderator user preferences admin view
4 participants