Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
<a href="{% url 'projects:project' project.id %}">{{ project.last_review|default:"" }}</a>
</td>

{% for qi in project.quality_history %}<td>{{ qi.value }}</td>{% endfor %}
{% for qi in project.quality_history_values %}<td>{{ qi.value }}</td>{% endfor %}

<td><a href="{% url 'projects:project' project.id %}">{{ project.quality_indicator }}</a></td>
<td><a href="{% url 'projects:project' project.id %}">{{ project.quality_indicator_value }}</a></td>
<td class="{{ project.expectations_review_status|slugify }}"><a href="{% url 'projects:project' project.id %}">{{ project.expectations_review_status|default:"Unreviewed" }}</a></td>

{% for po in project.projectobjectives %}
Expand Down
10 changes: 1 addition & 9 deletions dashboard/projects/templates/projects/project_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,7 @@
<tr class="row-group"><td colspan="{{ column_count }}">{{ group }}</td></tr>
{% endif %}
{% for project in projects %}

<tr
hx-get="{% url 'projects:project_row' project.id %}"
hx-trigger="intersect"
hx-swap="outerHTML"
>
<td>…</td>
</tr>

{% include "projects/partial_project_list_row.html" %}
{% endfor %}
{% endfor %}
</table>
Expand Down
34 changes: 34 additions & 0 deletions dashboard/projects/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
Project,
ProjectObjective,
ProjectObjectiveCondition,
QI,
)


Expand Down Expand Up @@ -297,6 +298,39 @@ 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, user_without_permissions, objective, project, project_objective, work_cycle
):
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 "<td>3</td>" in content
assert "<td>5</td>" in content
assert ">7</a></td>" 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.

Expand Down
6 changes: 0 additions & 6 deletions dashboard/projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<int:project_id>",
project_row,
name="project_row",
),
path("<int:id>/", project, name="project"),
# forms
path(
Expand Down
78 changes: 50 additions & 28 deletions dashboard/projects/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -27,18 +27,59 @@
class ProjectListView(ConditionalLoginRequiredMixin, ListView):
model = Project

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)
Comment on lines +40 to +49
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

pos_by_project is built from ProjectObjective.objects.all() and without any ordering. This will load objectives for every project in the database (hurting performance on large datasets) and may render objective columns in an arbitrary order, misaligning cells with objective_list (which is ordered by Objective.Meta.ordering). Build projects/project_ids first, then query ProjectObjective with project_id__in=project_ids and an explicit order_by that matches the objective header order (e.g., objective group/name or objective_id).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

@dwilding dwilding Apr 28, 2026

Choose a reason for hiding this comment

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

This is a good comment, but we can disregard it:

  • Ordering - Per docs, Django will use the default ordering on the related model, in this case following our specified ordering of Project and Objective objects.
  • Performance - We want to load ProjectObjectives for every project, because we're rendering the all-projects view.


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

Expand Down Expand Up @@ -137,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

Expand Down
Loading