diff --git a/dashboard/conftest.py b/dashboard/conftest.py
new file mode 100644
index 0000000..67d462a
--- /dev/null
+++ b/dashboard/conftest.py
@@ -0,0 +1,28 @@
+import sys
+import types
+
+from django.http import HttpResponse
+
+
+# Creat a "fake" mozilla_django_oidc.views so that tests will run,
+# even if mozilla_django_oidc is not available at import time for
+# tests.
+
+if "mozilla_django_oidc" not in sys.modules:
+ oidc_module = types.ModuleType("mozilla_django_oidc")
+ oidc_views_module = types.ModuleType("mozilla_django_oidc.views")
+
+ class _DummyOIDCView:
+ @classmethod
+ def as_view(cls):
+ def _view(request, *args, **kwargs):
+ return HttpResponse("")
+
+ return _view
+
+ oidc_views_module.OIDCAuthenticationRequestView = _DummyOIDCView
+ oidc_views_module.OIDCAuthenticationCallbackView = _DummyOIDCView
+ oidc_views_module.OIDCLogoutView = _DummyOIDCView
+ oidc_module.views = oidc_views_module
+ sys.modules["mozilla_django_oidc"] = oidc_module
+ sys.modules["mozilla_django_oidc.views"] = oidc_views_module
\ No newline at end of file
diff --git a/dashboard/framework/models.py b/dashboard/framework/models.py
index f2741cc..8b9a0b1 100644
--- a/dashboard/framework/models.py
+++ b/dashboard/framework/models.py
@@ -166,6 +166,7 @@ def save(self, *args, **kwargs):
from projects.models import (
ProjectObjective,
ProjectObjectiveCondition,
+ Commitment
) # avoids circular import
super().save(*args, **kwargs)
@@ -178,6 +179,13 @@ def save(self, *args, **kwargs):
objective=projectobjective.objective,
condition=self,
)
+ for work_cycle in WorkCycle.objects.all():
+ Commitment.objects.get_or_create(
+ work_cycle=work_cycle,
+ project=projectobjective.project,
+ objective=projectobjective.objective,
+ level=self.level,
+ )
class Meta:
ordering = ["objective__name", "level__value"]
diff --git a/dashboard/framework/test_models.py b/dashboard/framework/test_models.py
index 0647fff..677470e 100644
--- a/dashboard/framework/test_models.py
+++ b/dashboard/framework/test_models.py
@@ -153,3 +153,29 @@ def test_new_objective_means_new_commitments(
assert project.commitment_set.count() == 2
assert work_cycle.commitment_set.count() == 2
+
+
+@pytest.mark.django_db
+def test_new_condition_with_new_level_backfills_commitment(
+ project, objective, condition, work_cycle
+):
+ # A new condition at a new level should create a matching commitment
+ # for existing rows.
+
+ assert Commitment.objects.filter(
+ project=project, objective=objective, work_cycle=work_cycle
+ ).count() == 1
+
+ new_level = Level.objects.create(name="test_level_2", value=2)
+ Condition.objects.create(
+ name="test_condition_2", objective=objective, level=new_level
+ )
+
+ # Expected behaviour: creating a new condition/level backfills
+ # commitments.
+ assert Commitment.objects.filter(
+ project=project,
+ objective=objective,
+ work_cycle=work_cycle,
+ level=new_level,
+ ).exists()
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/test_views.py b/dashboard/projects/test_views.py
index b70c423..38500e2 100644
--- a/dashboard/projects/test_views.py
+++ b/dashboard/projects/test_views.py
@@ -1,10 +1,377 @@
import pytest
+from urllib.parse import parse_qs, urlparse
from django.urls import reverse
+from django.contrib.auth.models import Permission, User
+
+from framework.models import Condition, Level, Objective, ObjectiveGroup, Reason, WorkCycle
+from projects.models import Commitment, Project, ProjectObjective, ProjectObjectiveCondition, QI
def test_toggle_condition_url_patterns():
- assert reverse(
+ url = reverse("projects:action_toggle_condition", args=[1])
+ assert url == "/action_toggle_condition/1"
+
+
+@pytest.fixture
+def user_without_permissions(client):
+ user = User.objects.create_user(username="no_perm", password="password")
+ client.login(username="no_perm", password="password")
+ return user
+
+
+@pytest.fixture
+def user_can_change_commitment(client):
+ user = User.objects.create_user(username="change_commitment", password="password")
+ permission = Permission.objects.get(
+ codename="change_commitment",
+ content_type__app_label="projects",
+ )
+ user.user_permissions.add(permission)
+ client.login(username="change_commitment", password="password")
+ return user
+
+
+@pytest.fixture
+def user_can_change_projectobjectivecondition(client):
+ user = User.objects.create_user(
+ username="change_projectobjectivecondition", password="password"
+ )
+ permission = Permission.objects.get(
+ codename="change_projectobjectivecondition",
+ content_type__app_label="projects",
+ )
+ user.user_permissions.add(permission)
+ client.login(username="change_projectobjectivecondition", password="password")
+ return user
+
+
+@pytest.fixture
+def user_can_change_projectobjective(client):
+ user = User.objects.create_user(
+ username="change_projectobjective", password="password"
+ )
+ permission = Permission.objects.get(
+ codename="change_projectobjective",
+ content_type__app_label="projects",
+ )
+ user.user_permissions.add(permission)
+ client.login(username="change_projectobjective", password="password")
+ return user
+
+
+@pytest.fixture
+def user_can_change_project(client):
+ user = User.objects.create_user(username="change_project", password="password")
+ permission = Permission.objects.get(
+ codename="change_project",
+ content_type__app_label="projects",
+ )
+ user.user_permissions.add(permission)
+ client.login(username="change_project", password="password")
+ return user
+
+
+@pytest.fixture
+def objective_group():
+ return ObjectiveGroup.objects.create(name="group")
+
+
+@pytest.fixture
+def objective(objective_group):
+ return Objective.objects.create(name="objective", group=objective_group, weight=1)
+
+
+@pytest.fixture
+def level():
+ return Level.objects.create(name="level", value=1)
+
+
+@pytest.fixture
+def work_cycle():
+ return WorkCycle.objects.create(name="wc", timestamp="2026-01-01", is_current=True)
+
+
+@pytest.fixture
+def project(objective, level, work_cycle):
+ return Project.objects.create(name="project")
+
+
+@pytest.fixture
+def condition(objective, level):
+ return Condition.objects.create(name="condition", objective=objective, level=level)
+
+
+@pytest.fixture
+def project_objective(project, objective):
+ return ProjectObjective.objects.get(project=project, objective=objective)
+
+
+@pytest.fixture
+def project_objective_condition(project, objective, condition):
+ return ProjectObjectiveCondition.objects.get(
+ project=project,
+ objective=objective,
+ condition=condition,
+ )
+
+
+@pytest.fixture
+def commitment(project, objective, level, work_cycle):
+ return Commitment.objects.get(
+ project=project,
+ objective=objective,
+ level=level,
+ work_cycle=work_cycle,
+ )
+
+
+@pytest.fixture
+def reason():
+ return Reason.objects.create(name="not-started", value=1)
+
+
+@pytest.mark.django_db
+def test_project_basic_form_save_denies_unauthenticated_user(client, project):
+ original_owner = project.owner
+ url = reverse("projects:project_basic_form_save", args=[project.id])
+ response = client.post(
+ url,
+ data={
+ "name": project.name,
+ "url": project.url,
+ "group": "",
+ "owner": "changed owner",
+ "driver": project.driver or "",
+ "agreement_status": "",
+ "last_review": "",
+ "last_review_status": "",
+ },
+ )
+
+ project.refresh_from_db()
+ assert response.status_code == 302
+ assert response.url == f"{reverse('login')}?next={url}"
+ assert project.owner == original_owner
+
+
+@pytest.mark.django_db
+def test_project_basic_form_save_denies_user_without_permission(
+ client, user_without_permissions, project
+):
+ original_owner = project.owner
+ url = reverse("projects:project_basic_form_save", args=[project.id])
+ response = client.post(
+ url,
+ data={
+ "name": project.name,
+ "url": project.url,
+ "group": "",
+ "owner": "changed owner",
+ "driver": project.driver or "",
+ "agreement_status": "",
+ "last_review": "",
+ "last_review_status": "",
+ },
+ )
+
+ project.refresh_from_db()
+ assert response.status_code == 302
+ assert response.url == f"{reverse('login')}?next={url}"
+ assert project.owner == original_owner
+
+
+@pytest.mark.django_db
+def test_project_basic_form_save_allows_user_with_permission(client, user_can_change_project, project):
+ url = reverse("projects:project_basic_form_save", args=[project.id])
+ response = client.post(
+ url,
+ data={
+ "name": project.name,
+ "url": project.url,
+ "group": "",
+ "owner": "changed owner",
+ "driver": project.driver or "",
+ "agreement_status": "",
+ "last_review": "",
+ "last_review_status": "",
+ },
+ )
+
+ project.refresh_from_db()
+ assert response.status_code == 200
+ assert project.owner == "changed owner"
+
+
+@pytest.mark.django_db
+def test_action_toggle_commitment_denies_user_without_permission(
+ client, user_without_permissions, commitment
+):
+ url = reverse("projects:action_toggle_commitment", args=[commitment.id])
+ response = client.put(url)
+
+ assert response.status_code == 302
+ expected_redirect = f"{reverse('login')}?next={url}"
+ assert response.url == expected_redirect
+
+
+@pytest.mark.django_db
+def test_action_toggle_condition_denies_user_without_permission(
+ client, user_without_permissions, project_objective_condition
+):
+ url = (
+ reverse(
+ "projects:action_toggle_condition",
+ args=[project_objective_condition.id],
+ )
+ + "?status=&target=done"
+ )
+ response = client.put(url)
+
+ assert response.status_code == 302
+ parsed = urlparse(response.url)
+ assert parsed.path == reverse("login")
+ assert parse_qs(parsed.query)["next"][0] == url
+
+
+@pytest.mark.django_db
+def test_action_select_reason_denies_user_without_permission(
+ client, user_without_permissions, project_objective, reason
+):
+ url = reverse("projects:action_select_reason", args=[project_objective.id])
+ response = client.generic(
+ "PUT",
+ url,
+ data=f"ifnotstarted={reason.id}",
+ content_type="application/x-www-form-urlencoded",
+ )
+
+ assert response.status_code == 302
+ expected_redirect = f"{reverse('login')}?next={url}"
+ assert response.url == expected_redirect
+
+
+@pytest.mark.django_db
+def test_action_toggle_commitment_rejects_non_put_method(
+ client, user_can_change_commitment, commitment
+):
+ url = reverse("projects:action_toggle_commitment", args=[commitment.id])
+ response = client.get(url)
+
+ assert response.status_code == 405
+
+
+@pytest.mark.django_db
+def test_action_toggle_commitment_allows_authorized_put_and_updates_commitment(
+ client, user_can_change_commitment, commitment
+):
+ assert commitment.committed is False
+
+ url = reverse("projects:action_toggle_commitment", args=[commitment.id])
+ response = client.put(url)
+
+ commitment.refresh_from_db()
+ assert response.status_code == 200
+ assert commitment.committed is True
+ assert response["HX-Trigger-After-Swap"] == "updateCommitment"
+
+
+@pytest.mark.django_db
+def test_action_toggle_condition_rejects_non_put_method(
+ client, user_can_change_projectobjectivecondition, project_objective_condition
+):
+ url = (
+ reverse(
"projects:action_toggle_condition",
- args=[1],
- query={"status": "DO", "target": "done"}
- ) == "/action_toggle_condition/1?status=DO&target=done"
+ args=[project_objective_condition.id],
+ )
+ + "?status=&target=done"
+ )
+ response = client.get(url)
+
+ assert response.status_code == 405
+
+
+@pytest.mark.django_db
+def test_action_toggle_condition_allows_authorized_put_and_updates_status(
+ client, user_can_change_projectobjectivecondition, project_objective_condition
+):
+ assert project_objective_condition.status == ""
+
+ url = (
+ reverse(
+ "projects:action_toggle_condition",
+ args=[project_objective_condition.id],
+ )
+ + "?status=&target=done"
+ )
+ response = client.put(url)
+
+ project_objective_condition.refresh_from_db()
+ assert response.status_code == 200
+ assert project_objective_condition.status == "DO"
+ assert "HX-Trigger-After-Swap" in response
+
+
+@pytest.mark.django_db
+def test_action_select_reason_rejects_non_put_method(
+ client, user_can_change_projectobjective, project_objective
+):
+ url = reverse("projects:action_select_reason", args=[project_objective.id])
+ response = client.get(url)
+
+ assert response.status_code == 405
+
+
+@pytest.mark.django_db
+def test_action_select_reason_allows_authorized_put_and_sets_reason(
+ client, user_can_change_projectobjective, project_objective, reason
+):
+ assert project_objective.unstarted_reason is None
+
+ url = reverse("projects:action_select_reason", args=[project_objective.id])
+ response = client.generic(
+ "PUT",
+ url,
+ data=f"ifnotstarted={reason.id}",
+ content_type="application/x-www-form-urlencoded",
+ )
+
+ project_objective.refresh_from_db()
+ assert response.status_code == 200
+ 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
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 4e88c26..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
@@ -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)
+
+ 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
@@ -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
@@ -234,6 +256,7 @@ def action_select_reason(request, projectobjective_id):
# form methods
+@permission_required("projects.change_project")
@require_http_methods(["POST"])
def project_basic_form_save(request, project_id):
instance = Project.objects.get(id=project_id)
diff --git a/dashboard/requirements.txt b/dashboard/requirements.txt
index ebc23e5..1ccb8b6 100644
--- a/dashboard/requirements.txt
+++ b/dashboard/requirements.txt
@@ -2,7 +2,7 @@ Django==5.2.13
PyYAML==6.0.2
docutils==0.21.2
whitenoise==6.9.0
-psycopg2-binary==2.9.10
+psycopg2-binary==2.9.11
tzdata==2025.1
django-browser-reload==1.18.0
django-tinymce==4.1.0