From e0383c39026aa8aaa43d3b9980f18327f2c204f6 Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Tue, 21 Apr 2026 21:56:47 +0100 Subject: [PATCH 1/4] Added a select_related to ProjectListView --- dashboard/projects/views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dashboard/projects/views.py b/dashboard/projects/views.py index 73e7a89..4f752c1 100644 --- a/dashboard/projects/views.py +++ b/dashboard/projects/views.py @@ -27,6 +27,11 @@ class ProjectListView(ConditionalLoginRequiredMixin, ListView): model = Project + def get_queryset(self): + return super().get_queryset().select_related( + "group", "agreement_status", "last_review_status" + ) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) From 28495859ed3e649d2ef63bba4e92c6ddd2c83aed Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Wed, 22 Apr 2026 23:35:00 +0100 Subject: [PATCH 2/4] Implemented bulkloading for the project list in the view Previously, the project list was lazy-loaded with htmx. This update restores the bulk delivery of the page, along with opportuities for substantially reducing the number of per-row queries. With luck, this should turn out to be fast enough in practice that we can keep this approach. --- .../projects/partial_project_list_row.html | 4 +- .../templates/projects/project_list.html | 10 +-- dashboard/projects/urls.py | 6 -- dashboard/projects/views.py | 75 ++++++++++++------- 4 files changed, 49 insertions(+), 46 deletions(-) diff --git a/dashboard/projects/templates/projects/partial_project_list_row.html b/dashboard/projects/templates/projects/partial_project_list_row.html index d8026d1..ec61264 100644 --- a/dashboard/projects/templates/projects/partial_project_list_row.html +++ b/dashboard/projects/templates/projects/partial_project_list_row.html @@ -9,9 +9,9 @@ {{ project.last_review|default:"" }} - {% for qi in project.quality_history %}{{ qi.value }}{% endfor %} + {% for qi in project.quality_history_values %}{{ qi.value }}{% endfor %} - {{ project.quality_indicator }} + {{ project.quality_indicator_value }} {{ project.expectations_review_status|default:"Unreviewed" }} {% for po in project.projectobjectives %} diff --git a/dashboard/projects/templates/projects/project_list.html b/dashboard/projects/templates/projects/project_list.html index 310e7d2..af1e45d 100644 --- a/dashboard/projects/templates/projects/project_list.html +++ b/dashboard/projects/templates/projects/project_list.html @@ -52,15 +52,7 @@ {{ group }} {% endif %} {% for project in projects %} - - - … - - + {% include "projects/partial_project_list_row.html" %} {% endfor %} {% endfor %} diff --git a/dashboard/projects/urls.py b/dashboard/projects/urls.py index 2119715..3cf7d56 100644 --- a/dashboard/projects/urls.py +++ b/dashboard/projects/urls.py @@ -11,17 +11,11 @@ status_projectobjective, status_dashboardprojectobjective, admin_recalculate_all_levels, - project_row ) app_name = "projects" urlpatterns = [ path("", ProjectListView.as_view(), name="project_list"), - path( - "status_row/", - project_row, - name="project_row", - ), path("/", project, name="project"), # forms path( diff --git a/dashboard/projects/views.py b/dashboard/projects/views.py index 4f752c1..0280102 100644 --- a/dashboard/projects/views.py +++ b/dashboard/projects/views.py @@ -1,5 +1,5 @@ -import datetime import json +from django.db.models import F, Sum from django.shortcuts import render, HttpResponse, HttpResponseRedirect from django.views.generic import ListView from django.views.decorators.http import require_http_methods @@ -30,20 +30,56 @@ class ProjectListView(ConditionalLoginRequiredMixin, ListView): def get_queryset(self): return super().get_queryset().select_related( "group", "agreement_status", "last_review_status" - ) + ).prefetch_related("qi_set") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["workcycle_list"] = WorkCycle.objects.all() - context["workcycle_count"] = WorkCycle.objects.count() - context["objective_list"] = Objective.objects.all() - context["objective_count"] = Objective.objects.count() - context["column_count"] = ( - Objective.objects.count() + WorkCycle.objects.count() + 7 - ) - context["quality_cols_count"] = 4 + WorkCycle.objects.count() + + # Build a per-project objective map once to avoid repeated row-level lookups. + pos_by_project = {} + for po in ProjectObjective.objects.all().values( + "project_id", + "objective__name", + "id", + "level_achieved__name", + "unstarted_reason__name", + ): + pos_by_project.setdefault(po["project_id"], []).append(po) + + projects = list(context["object_list"]) + project_ids = [project.id for project in projects] + + # Collect quality indicator totals in one grouped query. + quality_indicator_by_project = { + row["project_id"]: row["total"] + for row in ProjectObjective.objects.filter( + project_id__in=project_ids, level_achieved__isnull=False + ) + .values("project_id") + .annotate(total=Sum(F("level_achieved__value") * F("objective__weight"))) + } + + # Attach prepared values to each project so the template can render without extra ORM work. + for project in projects: + project.projectobjectives = pos_by_project.get(project.id, []) + project.quality_history_values = project.qi_set.all() + project.quality_indicator_value = quality_indicator_by_project.get( + project.id, 0 + ) + + workcycle_list = list(WorkCycle.objects.all()) + objective_list = list(Objective.objects.all()) + workcycle_count = len(workcycle_list) + objective_count = len(objective_list) + + context["workcycle_list"] = workcycle_list + context["workcycle_count"] = workcycle_count + context["objective_list"] = objective_list + context["objective_count"] = objective_count + context["column_count"] = objective_count + workcycle_count + 7 + context["quality_cols_count"] = 4 + workcycle_count return context @@ -142,25 +178,6 @@ def status_dashboardprojectobjective(request, projectobjective_id): }, ) -def project_row(request, project_id): - project = Project.objects.get(id=project_id) - - project.projectobjectives = project.projectobjective_set.all().values( - "objective__name", - "id", - "level_achieved__name", - "unstarted_reason__name" - ) - - return render( - request, - "projects/partial_project_list_row.html", - { - "project": project, - } - ) - - # action methods From 7ee6dc6e85ace43b8ad50e068f9e8d57709a926e Mon Sep 17 00:00:00 2001 From: David Wilding Date: Sun, 26 Apr 2026 11:03:51 +0800 Subject: [PATCH 3/4] Added a test to check that key data get to the dashboard (recreated) --- dashboard/projects/test_views.py | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/dashboard/projects/test_views.py b/dashboard/projects/test_views.py index 371ebd1..a19c0e7 100644 --- a/dashboard/projects/test_views.py +++ b/dashboard/projects/test_views.py @@ -17,6 +17,7 @@ Project, ProjectObjective, ProjectObjectiveCondition, + QI, ) @@ -297,6 +298,42 @@ def test_action_select_reason_allows_authorized_put_and_sets_reason( assert project_objective.unstarted_reason_id == reason.id +@pytest.mark.django_db +def test_project_list_renders_qi_history_current_qi_and_levels( + client, objective, project, project_objective, work_cycle +): + user = User.objects.create_user(username="viewer", password="password") + client.login(username="viewer", password="password") + + second_work_cycle = WorkCycle.objects.create( + name="wc-2", timestamp="2026-02-01", is_current=False + ) + QI.objects.update_or_create( + project=project, + workcycle=work_cycle, + defaults={"value": 3}, + ) + QI.objects.update_or_create( + project=project, + workcycle=second_work_cycle, + defaults={"value": 5}, + ) + + level_for_display = Level.objects.create(name="LEVEL-ASSERT-ONLY", value=7) + ProjectObjective.objects.filter(id=project_objective.id).update( + level_achieved=level_for_display + ) + + response = client.get(reverse("projects:project_list")) + content = response.content.decode() + + assert response.status_code == 200 + assert "3" in content + assert "5" in content + assert ">7" in content + assert "LEVEL-ASSERT-ONLY" in content + + # Check that the project list and project detail pages are correctly public/private, # depending on whether OIDC is configured. From d96a18c5e14c3ac8247fdea60e3dcadc646dbabf Mon Sep 17 00:00:00 2001 From: David Wilding Date: Sun, 26 Apr 2026 11:05:02 +0800 Subject: [PATCH 4/4] use user_without_permissions fixture --- dashboard/projects/test_views.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/dashboard/projects/test_views.py b/dashboard/projects/test_views.py index a19c0e7..c5e030a 100644 --- a/dashboard/projects/test_views.py +++ b/dashboard/projects/test_views.py @@ -300,11 +300,8 @@ def test_action_select_reason_allows_authorized_put_and_sets_reason( @pytest.mark.django_db def test_project_list_renders_qi_history_current_qi_and_levels( - client, objective, project, project_objective, work_cycle + client, user_without_permissions, objective, project, project_objective, work_cycle ): - user = User.objects.create_user(username="viewer", password="password") - client.login(username="viewer", password="password") - second_work_cycle = WorkCycle.objects.create( name="wc-2", timestamp="2026-02-01", is_current=False )