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

Refactor Grant Summary implementation in Admin #3654

Merged
merged 13 commits into from
Dec 30, 2023
Merged
225 changes: 139 additions & 86 deletions backend/grants/admin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from import_export.resources import ModelResource
from collections import Counter
from datetime import timedelta
from itertools import groupby
from typing import Dict, List, Optional
from countries.filters import CountryFilter
from django.urls import path
from django.template.response import TemplateResponse
from django.db.models import Count
from helpers.constants import GENDERS
from django import forms
from django.contrib import admin, messages
from django.db.models.query import QuerySet
Expand All @@ -24,7 +26,7 @@
from schedule.models import ScheduleItem
from submissions.models import Submission

from .models import Grant, GrantRecap
from .models import Grant


EXPORT_GRANTS_FIELDS = (
Expand Down Expand Up @@ -361,6 +363,8 @@ class Meta:

@admin.register(Grant)
class GrantAdmin(ExportMixin, admin.ModelAdmin):
change_list_template = "admin/grants/grant/change_list.html"

speaker_ids = []
resource_class = GrantResource
form = GrantAdminForm
Expand Down Expand Up @@ -499,7 +503,7 @@ def is_speaker(self, obj):
def get_queryset(self, request):
qs = super().get_queryset(request)
if not self.speaker_ids:
conference_id = request.GET.get("conference__id__exact")
conference_id = request.GET.get("conference__id")
self.speaker_ids = ScheduleItem.objects.filter(
conference__id=conference_id,
submission__speaker_id__isnull=False,
Expand Down Expand Up @@ -548,88 +552,137 @@ def save_form(self, request, form, change):

return return_value

class Media:
js = ["admin/js/jquery.init.js"]


@admin.register(GrantRecap)
class GrantsRecap(admin.ModelAdmin):
list_filter = ("conference",)
qs = None

def has_change_permission(self, *args, **kwargs) -> bool:
return False

def get_queryset(self, request):
if self.qs:
return self.qs

self.qs = super().get_queryset(request)
filters = dict(request.GET.items())

if filters:
self.qs = self.qs.filter(**filters)

return self.qs

def changelist_view(self, request, extra_context=None):
qs = self.get_queryset(request).order_by("travelling_from")

results = []
for country_code, group in groupby(list(qs), key=lambda k: k.travelling_from):
country = countries.get(code=country_code)
if not country:
continue
grants = list(group)
counter = Counter([g.status for g in grants])
results.append(
{
"continent": country.continent.name,
"country": country.name,
"total": int(sum([g.total_amount for g in grants])),
"count": len(grants),
"rejected": counter.get(Grant.Status.rejected, 0),
"approved": counter.get(Grant.Status.approved, 0),
"waiting_list": counter.get(Grant.Status.waiting_list, 0)
+ counter.get(Grant.Status.waiting_list_maybe, 0),
"waiting_for_confirmation": counter.get(
Grant.Status.waiting_for_confirmation, 0
),
"refused": counter.get(Grant.Status.refused, 0),
"confirmed": counter.get(Grant.Status.confirmed, 0),
}
)

results = sorted(results, key=lambda k: (k["continent"], k["country"]))

counter = Counter([g.status for g in qs])
footer = {
"title": "Total",
"count": len(qs),
"total": int(
sum(
[
g.total_amount
for g in qs
if g.status
in (
Grant.Status.confirmed,
Grant.Status.approved,
Grant.Status.waiting_for_confirmation,
)
]
)
),
"rejected": counter.get(Grant.Status.rejected, 0),
"approved": counter.get(Grant.Status.approved, 0),
"waiting_list": counter.get(Grant.Status.waiting_list, 0)
+ counter.get(Grant.Status.waiting_list_maybe, 0),
"waiting_for_confirmation": counter.get(
Grant.Status.waiting_for_confirmation, 0
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"summary/",
self.admin_site.admin_view(self.summary_view),
name="grants-summary",
),
"refused": counter.get(Grant.Status.refused, 0),
"confirmed": counter.get(Grant.Status.confirmed, 0),
]
return custom_urls + urls

def summary_view(self, request):
"""
Custom view for summarizing Grant data in the Django admin.
Aggregates data by country and status, and applies request filters.
"""
# Initialize statuses
statuses = Grant.Status.choices

# Apply filters to the Grant queryset
filter_params = request.GET.dict()
Copy link
Member

Choose a reason for hiding this comment

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

uhm this is probably fine because it is in admin, but I am not a big fan of just forwarding whatever is in the GET parameters to filter 🤔

Copy link
Member Author

Choose a reason for hiding this comment

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

I moved this logic in _filter_and_format_grants to filter only by the allowed filters

filtered_grants = Grant.objects.filter(**filter_params)

# Aggregate grant data by 'travelling_from' and 'status'
grants_data = filtered_grants.values("travelling_from", "status").annotate(
total=Count("id")
)

# Process and aggregate data for display
(
summary,
totals_by_status,
totals_per_continent,
) = self._aggregate_data_by_country(grants_data, statuses)
gender_summary = self._aggregate_data_by_gender(filtered_grants, statuses)
human_readable_filters = self._format_filters_for_display(filter_params)
# Sort the summary data
sorted_summary = dict(sorted(summary.items(), key=lambda x: (x[0][0], x[0][2])))

context = {
"summary": sorted_summary,
"statuses": statuses,
"genders": {code: name for code, name in GENDERS},
"total": filtered_grants.count(),
"totals_by_status": totals_by_status,
"totals_per_continent": totals_per_continent,
"gender_summary": gender_summary,
"filters": human_readable_filters,
**self.admin_site.each_context(request),
}
return TemplateResponse(request, "admin/grants/grant_summary.html", context)

def _aggregate_data_by_country(self, grants_data, statuses):
"""
Aggregates grant data by country and status.
"""

summary = {}
totals_by_status = {status[0]: 0 for status in statuses}
totals_per_continent = {}

for data in grants_data:
country = countries.get(code=data["travelling_from"])
continent = country.continent.name if country else "Unknown"
country_name = f"{country.name} {country.emoji}" if country else "Unknown"
country_code = country.code if country else "Unknown"
key = (continent, country_name, country_code)

# Initialize country summary
if key not in summary:
summary[key] = {status[0]: 0 for status in statuses}

summary[key][data["status"]] += data["total"]
totals_by_status[data["status"]] += data["total"]

# Update continent totals
if continent not in totals_per_continent:
totals_per_continent[continent] = {status[0]: 0 for status in statuses}
totals_per_continent[continent][data["status"]] += data["total"]

return summary, totals_by_status, totals_per_continent

def _aggregate_data_by_gender(self, filtered_grants, statuses):
"""
Aggregates grant data by gender and status.
"""
gender_data = filtered_grants.values("user__gender", "status").annotate(
total=Count("id")
)
gender_summary = {
gender: {status[0]: 0 for status in statuses} for gender, _ in GENDERS
}
gender_summary[""] = {
status[0]: 0 for status in statuses
} # For unspecified genders

for data in gender_data:
gender = data["user__gender"] if data["user__gender"] else ""
status = data["status"]
total = data["total"]
gender_summary[gender][status] += total

return gender_summary

def _format_filters_for_display(self, filter_params):
"""
Formats raw filter keys to user-friendly names.
Copy link
Member

Choose a reason for hiding this comment

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

this comment doesn't look useful as it just explains what's the code after it? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

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

I changed this method and updated the description, let me know if now is more useful :)

"""
filter_mapping = {
"conference__id": "Conference ID",
"status": "Status",
"country_type": "Country Type",
"occupation": "Occupation",
"grant_type": "Grant Type",
"travelling_from": "Country",
}

extra_context = {"results": results, "footer": footer}
return super().changelist_view(request, extra_context=extra_context)
def map_filter_key(key):
for suffix in [
"__exact",
"__in",
"__gt",
"__lt",
"__contains",
"__startswith",
]:
if key.endswith(suffix):
return filter_mapping.get(key[: -len(suffix)], key)
return filter_mapping.get(key, key)

return {map_filter_key(key): value for key, value in filter_params.items()}

class Media:
js = ["admin/js/jquery.init.js"]
15 changes: 15 additions & 0 deletions backend/grants/migrations/0016_delete_grantrecap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Generated by Django 4.2.7 on 2023-12-29 19:31

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("grants", "0015_alter_grant_travelling_from"),
]

operations = [
migrations.DeleteModel(
name="GrantRecap",
),
]
7 changes: 0 additions & 7 deletions backend/grants/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,3 @@ def get_admin_url(self):
"admin:%s_%s_change" % (self._meta.app_label, self._meta.model_name),
args=(self.pk,),
)


class GrantRecap(Grant):
class Meta:
proxy = True
verbose_name = _("Grant recap")
verbose_name_plural = _("Grants recap")
93 changes: 0 additions & 93 deletions backend/grants/templates/admin/grants/change_list.html

This file was deleted.

7 changes: 7 additions & 0 deletions backend/grants/templates/admin/grants/grant/change_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
<li>
<a href="{% url 'admin:grants-summary' %}?{{ request.GET.urlencode }}" class="button">Grant Summary</a>
</li>
{{ block.super }}
{% endblock %}
Loading
Loading