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
Conversation
There was a problem hiding this 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!
Oh, the only other thing, to make this more usable, it'd be good if the list 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])) |
edf844a
to
8fd2c14
Compare
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 😄 |
There was a problem hiding this 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.
There was a problem hiding this 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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
for user in User.objects.all(): | ||
if not hasattr(user, "userpreferences"): |
There was a problem hiding this comment.
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 😰
There was a problem hiding this comment.
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!
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 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. |
Based on the medium urgency of this PR, the following reviewers are being gently reminded to review this PR: @dhruvkb 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
|
There was a problem hiding this 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.
Co-authored-by: sarayourfriend <24264157+sarayourfriend@users.noreply.github.com>
562650f
to
28f8b6b
Compare
I believe I've addressed all the issues! Will wait for CI to pass, then I'll merge this 😄 |
There was a problem hiding this 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.
Co-authored-by: Dhruv Bhanushali <hi@dhruvkb.dev>
After the recent changes, the |
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
This PR has migrations. Please rebase it before merging to ensure that conflicting migrations are not introduced. |
There was a problem hiding this 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!
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 thejsonb
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
Moderator preferences edit form
Testing Instructions
I added some tests specifically for the admin UI form - those should pass (and hopefully make sense).
just api/dbshell
, then executeselect 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.deploy
/deploy
. Observe the new "USER PREFERENCES" section with the "My Preferences" entry.deploy's preferences
shows up in the list.deploy's preferences
. Observe that the form only includes a check box for "Blur images".moderator
/deploy
. Observe that only the report/sensitivity tables and user preferences sections show up.preferences
value for themoderator
user should be{"moderator": {"blur_images": false}}
{"moderator": {"blur_images": true}}
Checklist
Update index.md
).main
) or a parent feature branch.Developer Certificate of Origin
Developer Certificate of Origin