Skip to content

Commit 77d5488

Browse files
Merge pull request #2560 from IFRCGo/feature/admin-page-to-query-users
Admin page to query users by permissions
2 parents b367bde + 14b9568 commit 77d5488

File tree

3 files changed

+268
-1
lines changed

3 files changed

+268
-1
lines changed

api/admin_reports.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import io
2+
from typing import Any, Iterable
3+
4+
from django.contrib import admin
5+
from django.contrib.admin.views.decorators import staff_member_required
6+
from django.contrib.auth.models import Group, Permission
7+
from django.db.models import Q
8+
from django.http import HttpRequest, HttpResponse
9+
from django.shortcuts import render
10+
from django.utils.translation import gettext_lazy as _
11+
from openpyxl import Workbook
12+
from rest_framework import permissions, renderers, viewsets
13+
from rest_framework.response import Response
14+
15+
16+
def _user_has_access(user: Any) -> bool:
17+
"""Allow superusers, regional admins, or users with Pending view permission."""
18+
if not user.is_authenticated:
19+
return False
20+
if user.is_superuser:
21+
return True
22+
# Pending users permission (model view perm)
23+
if user.has_perm("registrations.view_pending"):
24+
return True
25+
# Regional admin via explicit relation or permission codename
26+
try:
27+
from api.models import UserRegion # inline import to avoid cycles
28+
29+
if UserRegion.objects.filter(user=user).exists():
30+
return True
31+
except Exception:
32+
pass
33+
# Fallback check: any perm codename starting with region_admin_
34+
region_admin_perms = Permission.objects.filter(codename__startswith="region_admin_")
35+
if user.user_permissions.filter(id__in=region_admin_perms).exists():
36+
return True
37+
if Group.objects.filter(id__in=user.groups.all()).filter(permissions__in=region_admin_perms).exists():
38+
return True
39+
return False
40+
41+
42+
def _build_queryset(selected_group_ids: Iterable[int], include_superusers: bool):
43+
from django.contrib.auth import get_user_model
44+
45+
User = get_user_model()
46+
47+
qs = User.objects.all()
48+
49+
# Build a single OR filter instead of union of QuerySets
50+
q = Q()
51+
if selected_group_ids:
52+
q |= Q(groups__in=selected_group_ids)
53+
if include_superusers:
54+
q |= Q(is_superuser=True)
55+
56+
if q:
57+
qs = qs.filter(q)
58+
59+
return qs.distinct().order_by("id")
60+
61+
62+
def _render_csv(users_qs) -> HttpResponse:
63+
import csv
64+
65+
response = HttpResponse(content_type="text/csv")
66+
response["Content-Disposition"] = 'attachment; filename="users_per_permission.csv"'
67+
writer = csv.writer(response)
68+
writer.writerow(["ID", "Username", "Email", "Name", "Organization", "Active", "Staff"]) # headers
69+
for u in users_qs:
70+
name = f"{u.first_name} {u.last_name}".strip()
71+
org = getattr(getattr(u, "profile", None), "org", "") or ""
72+
writer.writerow([u.id, u.username, u.email, name, org, u.is_active, u.is_staff])
73+
return response
74+
75+
76+
def _render_xlsx(users_qs) -> HttpResponse:
77+
from openpyxl.worksheet.worksheet import Worksheet
78+
79+
wb = Workbook()
80+
ws: Worksheet = wb.active # type: ignore[assignment]
81+
# Avoid setting title if type checker is unsure
82+
try:
83+
ws.title = "Users"
84+
except Exception:
85+
pass
86+
headers = ["ID", "Username", "Email", "Name", "Organization", "Active", "Staff"]
87+
ws.append(headers) # type: ignore[attr-defined]
88+
for u in users_qs:
89+
name = f"{u.first_name} {u.last_name}".strip()
90+
org = getattr(getattr(u, "profile", None), "org", "") or ""
91+
row = [u.id, u.username, u.email, name, org, u.is_active, u.is_staff]
92+
ws.append(row) # type: ignore[attr-defined]
93+
94+
buf = io.BytesIO()
95+
wb.save(buf)
96+
buf.seek(0)
97+
response = HttpResponse(
98+
buf.getvalue(),
99+
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
100+
)
101+
response["Content-Disposition"] = 'attachment; filename="users_per_permission.xlsx"'
102+
return response
103+
104+
105+
REPORT_TITLE = _("Users per permission")
106+
107+
108+
@staff_member_required
109+
def users_per_permission_view(request: HttpRequest):
110+
# Gate access for staff who also meet custom criteria
111+
if not _user_has_access(request.user):
112+
return HttpResponse(status=403)
113+
114+
context = admin.site.each_context(request)
115+
context.update(
116+
{
117+
"title": REPORT_TITLE,
118+
}
119+
)
120+
121+
all_groups = Group.objects.order_by("name")
122+
123+
selected_group_ids = [int(gid) for gid in request.GET.getlist("groups") if gid.isdigit()]
124+
include_superusers = request.GET.get("include_superusers") == "1"
125+
export = request.GET.get("export") # "csv" | "xlsx" | None
126+
127+
users_qs = _build_queryset(selected_group_ids, include_superusers)
128+
129+
if export == "csv":
130+
return _render_csv(users_qs)
131+
if export == "xlsx":
132+
return _render_xlsx(users_qs)
133+
134+
# Regular HTML render
135+
context.update(
136+
{
137+
"groups": all_groups,
138+
"selected_group_ids": selected_group_ids,
139+
"include_superusers": include_superusers,
140+
"users": users_qs[:500], # safety cap for UI
141+
"result_count": users_qs.count() if selected_group_ids or include_superusers else 0,
142+
}
143+
)
144+
return render(request, "admin/users_per_permission.html", context)
145+
146+
147+
class UsersPerPermissionAccess(permissions.BasePermission):
148+
"""DRF permission: allow only authenticated users that pass our custom access gate."""
149+
150+
def has_permission(self, request, view): # type: ignore[override]
151+
allowed = bool(request.user and request.user.is_authenticated and _user_has_access(request.user))
152+
return True if allowed else False
153+
154+
155+
class UsersPerPermissionViewSet(viewsets.ViewSet):
156+
"""Read-only endpoint that renders an admin-like page or exports CSV/XLSX."""
157+
158+
renderer_classes = [renderers.TemplateHTMLRenderer, renderers.JSONRenderer]
159+
permission_classes = [UsersPerPermissionAccess]
160+
161+
def list(self, request: HttpRequest):
162+
all_groups = Group.objects.order_by("name")
163+
raw_groups = request.query_params.getlist("groups")
164+
try:
165+
selected_group_ids = [int(g) for g in raw_groups if g]
166+
except ValueError:
167+
selected_group_ids = []
168+
169+
include_superusers = str(request.query_params.get("include_superusers", "")).lower() in ("1", "true", "on", "yes")
170+
export = str(request.query_params.get("export", "")).lower() # <-- define export
171+
172+
users_qs = _build_queryset(selected_group_ids, include_superusers)
173+
174+
if export == "csv":
175+
return _render_csv(users_qs)
176+
if export == "xlsx":
177+
return _render_xlsx(users_qs)
178+
179+
context = admin.site.each_context(request)
180+
context.update(
181+
{
182+
"title": REPORT_TITLE,
183+
"groups": all_groups,
184+
"selected_group_ids": selected_group_ids,
185+
"include_superusers": include_superusers,
186+
"users": users_qs[:500],
187+
"result_count": users_qs.count() if selected_group_ids or include_superusers else 0,
188+
}
189+
)
190+
return Response(context, template_name="admin/users_per_permission.html")
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{% extends "admin/base_site.html" %}
2+
{% load i18n %}
3+
{% block title %}{% trans "Users per permission" %}{% endblock %}
4+
5+
{% block content %}
6+
<div class="app-admin-report">
7+
<form method="get" class="form-inline" style="margin-bottom: 1rem;">
8+
<div>
9+
<label for="id_groups">{% trans "Groups" %}</label><br/>
10+
<select id="id_groups" name="groups" multiple size="12" style="min-width: 360px;">
11+
{% for g in groups %}
12+
<option value="{{ g.id }}" {% if g.id in selected_group_ids %}selected{% endif %}>{{ g.name }}</option>
13+
{% endfor %}
14+
</select>
15+
</div>
16+
<div style="margin-left: 1rem;">
17+
<label>&nbsp;</label><br/>
18+
<label>
19+
<input type="checkbox" name="include_superusers" value="1" {% if include_superusers %}checked{% endif %}>
20+
{% trans "Include superusers" %}
21+
</label>
22+
<div style="margin-top: .5rem;">
23+
<button type="submit" class="button">{% trans "Apply filters" %}</button>
24+
</div>
25+
{% if selected_group_ids or include_superusers %}
26+
<div style="margin-top: .5rem;">
27+
<a class="button" href="?{% for gid in selected_group_ids %}groups={{ gid }}&{% endfor %}{% if include_superusers %}include_superusers=1&{% endif %}export=csv">CSV</a>
28+
<a class="button" href="?{% for gid in selected_group_ids %}groups={{ gid }}&{% endfor %}{% if include_superusers %}include_superusers=1&{% endif %}export=xlsx">XLSX</a>
29+
</div>
30+
{% endif %}
31+
</div>
32+
</form>
33+
34+
{% if selected_group_ids or include_superusers %}
35+
<p>
36+
{% blocktrans %}Showing {{ result_count }} user(s). First 500 listed below.{% endblocktrans %}
37+
</p>
38+
<div class="results">
39+
<table class="listing">
40+
<thead>
41+
<tr>
42+
<th>ID</th>
43+
<th>{% trans "Username" %}</th>
44+
<th>{% trans "Email address" %}</th>
45+
<th>{% trans "Name" %}</th>
46+
<th>{% trans "Organization" %}</th>
47+
<th>{% trans "Active" %}</th>
48+
<th>{% trans "Staff status" %}</th>
49+
</tr>
50+
</thead>
51+
<tbody>
52+
{% for u in users %}
53+
<tr>
54+
<td>{{ u.id }}</td>
55+
<td>{{ u.username }}</td>
56+
<td>{{ u.email }}</td>
57+
<td>{{ u.get_full_name }}</td>
58+
<td>{{ u.profile.org|default_if_none:"" }}</td>
59+
<!-- yesno:"+,," -> '+' for True, '' for False/None -->
60+
<td class="bool-col">{{ u.is_active|yesno:"+,," }}</td>
61+
<td class="bool-col">{{ u.is_staff|yesno:"+,," }}</td>
62+
</tr>
63+
{% empty %}
64+
<tr><td colspan="7">{% trans "No users match the current filters." %}</td></tr>
65+
{% endfor %}
66+
</tbody>
67+
</table>
68+
</div>
69+
{% else %}
70+
<p>{% trans "Select one or more Groups to view users. Optionally include superusers." %}</p>
71+
{% endif %}
72+
</div>
73+
{% endblock %}

main/urls.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from rest_framework import routers
2323

2424
from api import drf_views as api_views
25+
from api.admin_reports import UsersPerPermissionViewSet
2526
from api.views import (
2627
AddCronJobLog,
2728
AddSubscription,
@@ -123,7 +124,7 @@
123124
router.register(r"per-formcomponent", per_views.FormComponentViewset, basename="per-formcomponent")
124125
router.register(r"per-formquestion", per_views.FormQuestionViewset, basename="per-formquestion")
125126
router.register(r"per-formquestion-group", per_views.FormQuestionGroupViewset, basename="per-formquestion-group")
126-
router.register(r"aggregated-per-process-status", per_views.PerAggregatedViewSet, basename="aggregated-per-process-status"),
127+
router.register(r"aggregated-per-process-status", per_views.PerAggregatedViewSet, basename="aggregated-per-process-status")
127128
router.register(r"per-file", per_views.PerFileViewSet, basename="per-file")
128129
router.register(r"per-process-status", per_views.PerProcessStatusViewSet, basename="per-process-status")
129130
router.register(r"public-per-process-status", per_views.PublicPerProcessStatusViewSet, basename="public-per-process-status")
@@ -171,6 +172,9 @@
171172
router.register(r"pdf-export", api_views.ExportViewSet, basename="export")
172173
router.register(r"dref3", dref_views.Dref3ViewSet, basename="dref3")
173174

175+
# Query user lists per permission
176+
router.register(r"users-per-permission", UsersPerPermissionViewSet, basename="users_per_permission")
177+
174178
# Country Plan apis
175179
router.register(r"country-plan", country_plan_views.CountryPlanViewset, basename="country_plan")
176180

0 commit comments

Comments
 (0)