From 7a2a281249697aff21a3f8dff1f875b4892754c0 Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Mon, 20 Apr 2026 06:26:24 +0300 Subject: [PATCH 01/12] Added a series of tests for view functions Updated: test_toggle_condition_url_patterns (Simplified, and now only checks the reversed route, rather than the string) New checks for user permissions: * test_action_toggle_commitment_denies_user_without_permission * test_action_toggle_condition_denies_user_without_permission * test_action_select_reason_denies_user_without_permission New checks for HTTP methods: * test_action_toggle_commitment_rejects_non_put_method * test_action_toggle_condition_rejects_non_put_method * test_action_select_reason_rejects_non_put_method New checks for expected results: new: test_action_toggle_commitment_allows_authorized_put_and_updates_commitment new: test_action_toggle_condition_allows_authorized_put_and_updates_status new: test_action_select_reason_allows_authorized_put_and_sets_reason I used Copilot to help create these tests. --- dashboard/projects/test_views.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/dashboard/projects/test_views.py b/dashboard/projects/test_views.py index 371ebd1..9fb732e 100644 --- a/dashboard/projects/test_views.py +++ b/dashboard/projects/test_views.py @@ -3,6 +3,10 @@ from django.test import override_settings 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 from framework.models import ( Condition, @@ -85,6 +89,7 @@ def reason(): @pytest.mark.django_db +<<<<<<< HEAD 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]) @@ -159,6 +164,8 @@ def test_project_basic_form_save_allows_user_with_permission( @pytest.mark.django_db +======= +>>>>>>> 3a8a10d (Added a series of tests for view functions) def test_action_toggle_commitment_denies_user_without_permission( client, user_without_permissions, commitment ): @@ -176,8 +183,13 @@ def test_action_toggle_condition_denies_user_without_permission( ): url = ( reverse( +<<<<<<< HEAD "projects:action_toggle_condition", args=[project_objective_condition.id], +======= + "projects:action_toggle_condition", + args=[project_objective_condition.id], +>>>>>>> 3a8a10d (Added a series of tests for view functions) ) + "?status=&target=done" ) @@ -237,8 +249,13 @@ def test_action_toggle_condition_rejects_non_put_method( ): url = ( reverse( +<<<<<<< HEAD "projects:action_toggle_condition", args=[project_objective_condition.id], +======= + "projects:action_toggle_condition", + args=[project_objective_condition.id], +>>>>>>> 3a8a10d (Added a series of tests for view functions) ) + "?status=&target=done" ) @@ -255,8 +272,13 @@ def test_action_toggle_condition_allows_authorized_put_and_updates_status( url = ( reverse( +<<<<<<< HEAD "projects:action_toggle_condition", args=[project_objective_condition.id], +======= + "projects:action_toggle_condition", + args=[project_objective_condition.id], +>>>>>>> 3a8a10d (Added a series of tests for view functions) ) + "?status=&target=done" ) @@ -295,6 +317,7 @@ def test_action_select_reason_allows_authorized_put_and_sets_reason( project_objective.refresh_from_db() assert response.status_code == 200 assert project_objective.unstarted_reason_id == reason.id +<<<<<<< HEAD # Check that the project list and project detail pages are correctly public/private, @@ -351,3 +374,5 @@ def test_project_detail_oidc_logged_in(client, user_without_permissions, project url = reverse("projects:project", kwargs={"id": project.id}) response = client.get(url) assert response.status_code == 200 +======= +>>>>>>> 3a8a10d (Added a series of tests for view functions) From 0de26db2745b1091a2edd04ae2361ffe6acd9011 Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Mon, 20 Apr 2026 07:01:19 +0300 Subject: [PATCH 02/12] Added a permissions constraint for project_basic_form_save This fixes an auth issue. It was possible to POST a change to a project, without even being authenticated. The only safeguard was the user interface, that didn't offer make it possible. This adds @permission_required("projects.change_project") to the view, and some tests. * added a fixture for a user with permission to change a project * added a test: do we reject changes from unauthenticated users? * added a test: do we reject changes from unauthorised users? * added a test: do we allow and save changes from users with the right permissions? I used Copilot to uncover the issue and help create the tests. --- dashboard/projects/test_views.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dashboard/projects/test_views.py b/dashboard/projects/test_views.py index 9fb732e..8595f92 100644 --- a/dashboard/projects/test_views.py +++ b/dashboard/projects/test_views.py @@ -90,6 +90,9 @@ def reason(): @pytest.mark.django_db <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> 7781581 (Added a permissions constraint for project_basic_form_save) 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]) @@ -140,9 +143,13 @@ def test_project_basic_form_save_denies_user_without_permission( @pytest.mark.django_db +<<<<<<< HEAD def test_project_basic_form_save_allows_user_with_permission( client, user_can_change_project, project ): +======= +def test_project_basic_form_save_allows_user_with_permission(client, user_can_change_project, project): +>>>>>>> 7781581 (Added a permissions constraint for project_basic_form_save) url = reverse("projects:project_basic_form_save", args=[project.id]) response = client.post( url, @@ -164,8 +171,11 @@ def test_project_basic_form_save_allows_user_with_permission( @pytest.mark.django_db +<<<<<<< HEAD ======= >>>>>>> 3a8a10d (Added a series of tests for view functions) +======= +>>>>>>> 7781581 (Added a permissions constraint for project_basic_form_save) def test_action_toggle_commitment_denies_user_without_permission( client, user_without_permissions, commitment ): From fe9137f6cef2c10c5d595972b391b8f3cc5e101a Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Tue, 21 Apr 2026 21:56:47 +0100 Subject: [PATCH 03/12] 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 836af67a74f49dfdf200fdf4e4d1efb05aaf158a Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Wed, 22 Apr 2026 22:43:43 +0100 Subject: [PATCH 04/12] Added a test to check that key data get to the dashboard --- dashboard/projects/test_views.py | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/dashboard/projects/test_views.py b/dashboard/projects/test_views.py index 8595f92..75f5d12 100644 --- a/dashboard/projects/test_views.py +++ b/dashboard/projects/test_views.py @@ -89,10 +89,6 @@ def reason(): @pytest.mark.django_db -<<<<<<< HEAD -<<<<<<< HEAD -======= ->>>>>>> 7781581 (Added a permissions constraint for project_basic_form_save) 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]) @@ -143,13 +139,9 @@ def test_project_basic_form_save_denies_user_without_permission( @pytest.mark.django_db -<<<<<<< HEAD def test_project_basic_form_save_allows_user_with_permission( client, user_can_change_project, project ): -======= -def test_project_basic_form_save_allows_user_with_permission(client, user_can_change_project, project): ->>>>>>> 7781581 (Added a permissions constraint for project_basic_form_save) url = reverse("projects:project_basic_form_save", args=[project.id]) response = client.post( url, @@ -171,11 +163,6 @@ def test_project_basic_form_save_allows_user_with_permission(client, user_can_ch @pytest.mark.django_db -<<<<<<< HEAD -======= ->>>>>>> 3a8a10d (Added a series of tests for view functions) -======= ->>>>>>> 7781581 (Added a permissions constraint for project_basic_form_save) def test_action_toggle_commitment_denies_user_without_permission( client, user_without_permissions, commitment ): @@ -193,13 +180,8 @@ def test_action_toggle_condition_denies_user_without_permission( ): url = ( reverse( -<<<<<<< HEAD "projects:action_toggle_condition", args=[project_objective_condition.id], -======= - "projects:action_toggle_condition", - args=[project_objective_condition.id], ->>>>>>> 3a8a10d (Added a series of tests for view functions) ) + "?status=&target=done" ) @@ -259,13 +241,8 @@ def test_action_toggle_condition_rejects_non_put_method( ): url = ( reverse( -<<<<<<< HEAD "projects:action_toggle_condition", args=[project_objective_condition.id], -======= - "projects:action_toggle_condition", - args=[project_objective_condition.id], ->>>>>>> 3a8a10d (Added a series of tests for view functions) ) + "?status=&target=done" ) @@ -282,13 +259,8 @@ def test_action_toggle_condition_allows_authorized_put_and_updates_status( url = ( reverse( -<<<<<<< HEAD "projects:action_toggle_condition", args=[project_objective_condition.id], -======= - "projects:action_toggle_condition", - args=[project_objective_condition.id], ->>>>>>> 3a8a10d (Added a series of tests for view functions) ) + "?status=&target=done" ) @@ -327,7 +299,6 @@ def test_action_select_reason_allows_authorized_put_and_sets_reason( project_objective.refresh_from_db() assert response.status_code == 200 assert project_objective.unstarted_reason_id == reason.id -<<<<<<< HEAD # Check that the project list and project detail pages are correctly public/private, @@ -384,5 +355,3 @@ def test_project_detail_oidc_logged_in(client, user_without_permissions, project url = reverse("projects:project", kwargs={"id": project.id}) response = client.get(url) assert response.status_code == 200 -======= ->>>>>>> 3a8a10d (Added a series of tests for view functions) From 9654afedf1491588cf32c58aa9ab7cbedcc318f7 Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Wed, 22 Apr 2026 23:35:00 +0100 Subject: [PATCH 05/12] 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 b43f34408b62bc19d3a46f37c05d2981475a0407 Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Thu, 23 Apr 2026 18:53:32 +0100 Subject: [PATCH 06/12] Adjusted TinyMCE settings to match default styles --- dashboard/dashboard/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dashboard/dashboard/settings.py b/dashboard/dashboard/settings.py index 90e579b..710fb72 100644 --- a/dashboard/dashboard/settings.py +++ b/dashboard/dashboard/settings.py @@ -236,4 +236,5 @@ "toolbar": False, "statusbar": False, "valid_elements": "a[href|target=_blank],strong,em,p,ul,ol,li", + "content_style": "body { font-size: 13px; margin: 4px; }", } From 0ccd534dd259e388ebfc2d5ff621d8ad60ec35ed Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Thu, 23 Apr 2026 20:39:51 +0100 Subject: [PATCH 07/12] Improved vertical spacing in project detail view --- dashboard/dashboard/static/base-styles.css | 27 ---------- dashboard/dashboard/static/project-detail.css | 54 +++++++++++++++++++ .../partial_project_detail_condition.html | 8 +-- 3 files changed, 59 insertions(+), 30 deletions(-) diff --git a/dashboard/dashboard/static/base-styles.css b/dashboard/dashboard/static/base-styles.css index 0a1be39..cf63403 100644 --- a/dashboard/dashboard/static/base-styles.css +++ b/dashboard/dashboard/static/base-styles.css @@ -17,18 +17,6 @@ div.auth {margin-bottom: 1em;} th {text-align: left;} -table.objectives.detail { - width: 100%; - border-collapse: collapse; -} -table.objectives tr {background: var(--body-bg)} -table.objectives td {border-bottom: none;} - -table.objectives td, th {padding: 4px 8px 4px;} - - -tr.condition td, th {padding: 2px 8px 2px;} - th.projectobjectivegroup { background: #D9EAD3; @@ -53,18 +41,3 @@ td.agreed {color: white; background: #38761D} td.started {color: black; background: #B6D7A8} td.first-results {color: black; background: #6AA84F} td.mature-results, td.done, td.all-ok {color: white; background: #38761D} - -/* condition items */ - -.attributes {float: right; color: #bbb; padding-left: 1em;} -.attributes span {margin-left: 1em; padding: 0px 4px;} - -tr.condition.candidate span.condition.candidate {color: orange;} -tr.condition.not-applicable span.condition.not-applicable {color: orange;} - -tr.condition.not-applicable .condition.name {color: #bbb; text-decoration: line-through;} - -tr.condition span.condition.has-perms:hover { - cursor: default; - border-radius: 999em; outline: 1px solid orange; -} diff --git a/dashboard/dashboard/static/project-detail.css b/dashboard/dashboard/static/project-detail.css index ce44664..9123b1e 100644 --- a/dashboard/dashboard/static/project-detail.css +++ b/dashboard/dashboard/static/project-detail.css @@ -1,3 +1,17 @@ +table.objectives.detail { + width: 100%; + border-collapse: collapse; +} +table.objectives tr {background: var(--body-bg)} +table.objectives td {border-bottom: none;} + +table.objectives td, table.objectives th {padding: 4px 8px 4px;} +table.objectives tr.condition td, table.objectives tr.condition th {padding: 2px 8px 2px; vertical-align: middle;} +table.objectives tr.condition + tr.condition td, +table.objectives tr.condition + tr.condition th {padding-top: 5px;} +table.objectives tr.condition td:first-child {vertical-align: top;} +table.objectives tr.condition td:first-child input[type="checkbox"] {margin: 0;} + table.objectives tr.objective td.name {background-color: #205067; color: white; font-weight: bold;} table.objectives tr.level {background-color: #f0f0f0; font-weight: bold;} @@ -11,3 +25,43 @@ table.objectives td.commitment-description {color: black; background: #D9EAD3; f table.objectives td.no-activity {outline: 1px solid black;} +/* condition items */ + +.condition.name .condition-layout { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.condition.name .condition-text { + min-width: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.condition.name .condition-text > * { + margin-top: 0; + margin-bottom: 0; +} + +.attributes { + color: #bbb; + padding-left: 0; + white-space: nowrap; + flex: 0 0 auto; +} +.attributes span {margin-left: 1em; padding: 0px 4px;} + +tr.condition.candidate span.condition.candidate {color: orange;} +tr.condition.not-applicable span.condition.not-applicable {color: orange;} + +tr.condition.not-applicable .condition.name {color: #bbb; text-decoration: line-through;} + +tr.condition span.condition.has-perms:hover { + cursor: default; + border-radius: 999em; outline: 1px solid orange; +} + diff --git a/dashboard/projects/templates/projects/partial_project_detail_condition.html b/dashboard/projects/templates/projects/partial_project_detail_condition.html index 9f55856..0116004 100644 --- a/dashboard/projects/templates/projects/partial_project_detail_condition.html +++ b/dashboard/projects/templates/projects/partial_project_detail_condition.html @@ -21,8 +21,9 @@ - {{ condition.name|safe }} -
+
+
{{ condition.name|safe }}
+
candidate -
+
+
From da67563a8566a77c19b1ad233aa873d7394b8ffe Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Thu, 23 Apr 2026 20:40:58 +0100 Subject: [PATCH 08/12] Stopped HTML tags appearing in condition admin --- dashboard/framework/admin.py | 11 ++++++++++- dashboard/framework/models.py | 3 ++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/dashboard/framework/admin.py b/dashboard/framework/admin.py index d0c7026..6784064 100644 --- a/dashboard/framework/admin.py +++ b/dashboard/framework/admin.py @@ -16,9 +16,18 @@ class ConditionAdmin(admin.ModelAdmin): - list_display = ["name", "objective", "level"] + list_display = ["display_name", "objective", "level"] list_editable = ["objective", "level"] + formfield_overrides = { + models.TextField: {"widget": TinyMCE}, + } + + def display_name(self, obj): + from django.utils.html import strip_tags + return strip_tags(obj.name) + display_name.short_description = "name" + class ConditionInline(admin.TabularInline): model = Condition extra = 1 diff --git a/dashboard/framework/models.py b/dashboard/framework/models.py index 8b9a0b1..b25c49e 100644 --- a/dashboard/framework/models.py +++ b/dashboard/framework/models.py @@ -158,7 +158,8 @@ class Condition(models.Model): level = models.ForeignKey(Level, on_delete=models.CASCADE) def __str__(self): - return self.name + from django.utils.html import strip_tags + return strip_tags(self.name) def save(self, *args, **kwargs): # when a new Condition is added propagate it to all existing ProjectObjectives From fb8f675cf8c826ef6e745911b6ceb6f9a428a95b Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Thu, 23 Apr 2026 20:57:00 +0100 Subject: [PATCH 09/12] Tidied up some CSS --- dashboard/dashboard/static/project-detail.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dashboard/dashboard/static/project-detail.css b/dashboard/dashboard/static/project-detail.css index 9123b1e..c3d2cdd 100644 --- a/dashboard/dashboard/static/project-detail.css +++ b/dashboard/dashboard/static/project-detail.css @@ -15,9 +15,8 @@ table.objectives tr.condition td:first-child input[type="checkbox"] {margin: 0;} table.objectives tr.objective td.name {background-color: #205067; color: white; font-weight: bold;} table.objectives tr.level {background-color: #f0f0f0; font-weight: bold;} -table.objectives td p, th p {margin: .2rem 0} +table.objectives td p, table.objectives th p {margin: .2rem 0} -table.objectives select {height: 1.2rem;} table.objectives select {height: 1.2rem; padding: 0px 6px;} /* padding is required for Chrome for some reason */ table.objectives td.field-committed {color: white; background: #93C47D;} From 727abc84778809f99135a69f4664b2f1422d7c86 Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Fri, 24 Apr 2026 07:42:05 +0100 Subject: [PATCH 10/12] Added tabbed interface; improved overview The interface now has tabs, for each objective group. The overview tab has been given some basic styling. --- dashboard/dashboard/static/project-detail.css | 323 +++++++++++++++++- .../partial_project_detail_basics.html | 61 ++-- .../partial_project_detail_commitments.html | 15 +- .../partial_project_detail_objectives.html | 88 ++--- .../projects/templates/projects/project.html | 99 +++--- 5 files changed, 461 insertions(+), 125 deletions(-) diff --git a/dashboard/dashboard/static/project-detail.css b/dashboard/dashboard/static/project-detail.css index c3d2cdd..1c174f4 100644 --- a/dashboard/dashboard/static/project-detail.css +++ b/dashboard/dashboard/static/project-detail.css @@ -1,3 +1,325 @@ +/* tab panels */ + +.tab-panel { display: none; } +.tab-panel:target { display: block; } +.tab-panel:has(:target) { display: block; } + +/* for the special case of the overview tab */ + +#tab-overview { display: block; } +.tab-panels:has(.tab-panel:target:not(#tab-overview)) #tab-overview { display: none; } +.tab-panels:has(.tab-panel:has(:target):not(#tab-overview)) #tab-overview { display: none; } + +/* overview presentation */ + +.overview-shell { + max-width: 1100px; + margin: 0 auto; + padding: 0.4rem 0 0.8rem; + font-size: 1.03rem; +} + +.overview-header { + background: linear-gradient(315deg, #cde3c3 0%, #9fc68f 100%); + border: 1px solid #7fab6d; + border-radius: 8px; + padding: 0.6rem 0.85rem; + margin-bottom: 0.6rem; +} + +.overview-header .navigation ul { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.75rem; + align-items: center; + padding: 0; + margin: 0; + list-style: none; + font-size: 0.92rem; +} + +.overview-header .navigation li.nav-user { + margin-left: auto; +} + +.overview-header .navigation li form { + margin: 0; +} + +.overview-header .navigation a, +.overview-header .navigation button { + color: #18475e; + text-decoration: none; + padding: 0.12rem 0.35rem; + border-radius: 6px; + background: rgb(255 255 255 / 90%); + border: 1px solid transparent; + font: inherit; + font-size: 0.84rem; + line-height: 1.15; + cursor: pointer; +} + +.overview-header .navigation a:hover, +.overview-header .navigation button:hover { + background: #fff; + text-decoration: underline; + text-decoration-color: #93c47d; +} + +.overview-header h1 { + margin: 0.45rem 0 0; + line-height: 1.15; + font-size: 1.55rem; +} + +.overview-card { + background: #fff; + border: 1px solid #e6efe2; + border-radius: 8px; + padding: 0.65rem 0.85rem; + box-shadow: 0 1px 2px rgb(32 80 103 / 7%); + margin-bottom: 0.6rem; +} + +.overview-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 0.6rem; +} + +.overview-columns .overview-card { + margin-bottom: 0; +} + +.basics-panel { + margin: 0; +} + +.basics-form { + display: grid; + gap: 0.35rem; +} + +.basics-row { + display: grid; + grid-template-columns: 150px minmax(0, 1fr); + gap: 0.55rem; + align-items: start; + padding: 0.28rem 0; + border-bottom: 1px solid #edf4ea; +} + +.basics-row:last-of-type { + border-bottom: none; +} + +.basics-label { + color: black; + font-weight: 600; + padding-top: 0.2rem; + font-size: 1rem; +} + +.basics-label label { + font-weight: 600; + color: black; +} + +.basics-input { + min-width: 0; + font-size: 1rem; + color: black; +} + +.basics-input a { + color: black; + text-decoration: underline; + text-decoration-color: #93c47d; + text-underline-offset: 0.14em; + word-break: break-word; +} + +.basics-input a:hover { + color: black; + text-decoration-color: #6aa84f; +} + +.basics-input input, +.basics-input select, +.basics-input textarea { + width: 100%; + max-width: 100%; + padding: 0.42rem 0.55rem; + border: 1px solid #cfe4c6; + border-radius: 6px; + background: #fff; + font: inherit; + color: black; + line-height: 1.35; + box-sizing: border-box; +} + +.basics-input textarea { + min-height: 80px; + resize: vertical; +} + +.basics-input input:focus, +.basics-input select:focus, +.basics-input textarea:focus { + outline: 2px solid #93c47d; + outline-offset: 1px; + border-color: #93c47d; +} + +.basics-help { + font-size: 0.93rem; + color: black; + margin-top: 0.2rem; +} + +.basics-error { + font-size: 0.93rem; + color: #9a1d1d; + margin-top: 0.25rem; +} + +.basics-actions { + padding-top: 0.2rem; + display: flex; + justify-content: flex-end; +} + +.basics-save { + background: #6aa84f; + color: black; + border: none; + border-radius: 6px; + padding: 0.45rem 0.9rem; + font-weight: 600; + cursor: pointer; +} + +.basics-save:hover { + background: #5f9647; +} + +@media (max-width: 740px) { + .basics-row { + grid-template-columns: 1fr; + gap: 0.35rem; + } + + .basics-label { + padding-top: 0; + } +} + +@media (max-width: 740px) { + .overview-columns { + grid-template-columns: 1fr; + } +} + +.overview-section-title { + margin: 0 0 0.35rem; + font-size: 1.15rem; + font-weight: 600; + color: #205067; +} + +.overview-table { + width: 100%; + border-collapse: collapse; + font-size: 1rem; +} + +.overview-table th, +.overview-table td { + padding: 0.32rem 0.45rem; + border-bottom: 1px solid #edf4ea; +} + +.overview-table tbody tr:last-child th, +.overview-table tbody tr:last-child td { + border-bottom: none; +} + +.overview-table th { + background: #f6faf4; + color: #205067; + font-weight: 600; +} + +.overview-empty-state { + margin: 0; + min-height: 180px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + color: black; + font-size: 0.98rem; +} + +#commitments-panel:has(.overview-empty-state) { + display: flex; + align-items: center; + justify-content: center; +} + +#commitments-panel:has(.overview-empty-state) .overview-empty-state { + min-height: 0; + width: 100%; +} + +.commitments-table a { + color: #18475e; + text-decoration: none; + border-bottom: 1px dotted #93c47d; +} + +.commitments-table a:hover { + color: #103847; + border-bottom-color: #6aa84f; +} + +/* tab bar — fixed at bottom of viewport */ + +.tab-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + flex-wrap: wrap; + gap: 2px; + padding: 0 8px; + background: white; + border-top: 3px solid #8fb37a; + z-index: 100; +} + +.tab-bar a { + padding: 8px 16px; + text-decoration: none; + color: black; + font-size: 1rem; + font-weight: 400; + background: #d9ead3; + border-radius: 0 0 6px 6px; + white-space: nowrap; +} + +.tab-bar a:hover { + background: #cfe4c6; + color: black; +} + +/* leave room for the tab bar */ +body { padding-bottom: 3rem; } + table.objectives.detail { width: 100%; border-collapse: collapse; @@ -63,4 +385,3 @@ tr.condition span.condition.has-perms:hover { cursor: default; border-radius: 999em; outline: 1px solid orange; } - diff --git a/dashboard/projects/templates/projects/partial_project_detail_basics.html b/dashboard/projects/templates/projects/partial_project_detail_basics.html index b8aa035..c60f436 100644 --- a/dashboard/projects/templates/projects/partial_project_detail_basics.html +++ b/dashboard/projects/templates/projects/partial_project_detail_basics.html @@ -1,30 +1,43 @@ -
-
-
- +
+ + {% if project.url %} +
+
Website
+ +
+ {% endif %} - {% if project.url %} -
- - - - {% endif %} + {% for field in basics_form %} +
+
+ +
+
+ {{ field }} - {% for field in basics_form %} -
- {% endfor %} + {% if field.help_text %} +
{{ field.help_text|safe }}
+ {% endif %} -
Website{{ project.url }}
{{ field.label_tag }}{{ field }}
+ {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+
+ {% endfor %} - {% if perms.projects.change_projectobjectivecondition %} - - {% endif %} - - - + {% if perms.projects.change_projectobjectivecondition %} +
+ +
+ {% endif %} + diff --git a/dashboard/projects/templates/projects/partial_project_detail_commitments.html b/dashboard/projects/templates/projects/partial_project_detail_commitments.html index 39b8466..4f801bb 100644 --- a/dashboard/projects/templates/projects/partial_project_detail_commitments.html +++ b/dashboard/projects/templates/projects/partial_project_detail_commitments.html @@ -1,12 +1,6 @@ - - -

{% if current_work_cycle_name %}Commitments for {{ current_work_cycle_name }}{% else %}Commitments{% endif %}

+{% if current_commitments %} +

{% if current_work_cycle_name %}Commitments for {{ current_work_cycle_name }}{% else %}Commitments{% endif %}

+
{% for commitment in current_commitments %} @@ -24,3 +18,6 @@

{% if current_work_cycle_name %}Commitments for {{ current_work_cycle_name } {% endfor %}

ObjectiveTargetAchieved
+{% else %} +

No commitments for this cycle

+{% endif %} diff --git a/dashboard/projects/templates/projects/partial_project_detail_objectives.html b/dashboard/projects/templates/projects/partial_project_detail_objectives.html index 9306d21..a5c72d0 100644 --- a/dashboard/projects/templates/projects/partial_project_detail_objectives.html +++ b/dashboard/projects/templates/projects/partial_project_detail_objectives.html @@ -1,67 +1,51 @@ {% load project_tags %} +{% load project_tags %} -{% regroup project.projectobjective_set.all by objective.group as projectobjectivegroups %} - - - - {% for projectobjectivegroup, projectobjectives in projectobjectivegroups %} - - {% if projectobjectivegroup %} - - - - {% endif %} - - {% for projectobjective in projectobjectives %} - - +{% for projectobjective in projectobjectives %} - - - + - - - - + + + - - {% include "projects/partial_project_detail_objectivestatus.html" %} - - {% for work_cycle in work_cycles %} - - {% endfor %} - + + + + - {% regroup projectobjective.projectobjectiveconditions by level as levels %} - {% regroup projectobjective.commitments by level as commitments %} - {% pack levels commitments as level_collections %} + + {% include "projects/partial_project_detail_objectivestatus.html" %} + + {% for work_cycle in work_cycles %} + + {% endfor %} + - {% for condition, commitment in level_collections %} + {% regroup projectobjective.projectobjectiveconditions by level as levels %} + {% regroup projectobjective.commitments by level as commitments %} + {% pack levels commitments as level_collections %} - - + {% for condition, commitment in level_collections %} - {% for commitment in commitment.list %} - {% include "projects/partial_project_detail_commitment.html" %} - {% endfor %} + + - + {% for commitment in commitment.list %} + {% include "projects/partial_project_detail_commitment.html" %} + {% endfor %} - {% for condition in condition.list %} - {% include "projects/partial_project_detail_condition.html" %} - {% endfor %} + - {% endfor %} - + {% for condition in condition.list %} + {% include "projects/partial_project_detail_condition.html" %} + {% endfor %} {% endfor %} - {% endfor %} -
- {{ projectobjectivegroup }} -
- {{ projectobjective.objective }} -
- {{ projectobjective.objective.description|safe|default:"" }} - Commitments
+ {{ projectobjective.objective }} +
{{ work_cycle }}
+ {{ projectobjective.objective.description|default_if_none:""|safe }} + Commitments
{{ work_cycle }}
{{ condition.grouper }}
{{ condition.grouper }}
+ +{% endfor %} diff --git a/dashboard/projects/templates/projects/project.html b/dashboard/projects/templates/projects/project.html index 1e5bce4..5eaee91 100644 --- a/dashboard/projects/templates/projects/project.html +++ b/dashboard/projects/templates/projects/project.html @@ -13,48 +13,69 @@ {% block body_attributes %}hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'{% endblock %} {% block content %} +{% regroup project.projectobjective_set.all by objective.group as projectobjectivegroups %} + +
+
+ +
+
+
+ + +

{{ project }}

+
+ +
+
+ {% include "projects/partial_project_detail_basics.html" %} +
+ +
+ {% include "projects/partial_project_detail_commitments.html" %} +
+
+
+
-
- - - -

{{ project }}

- -
-
-
-
- -
{% include "projects/partial_project_detail_basics.html" %}
- -
- -
{% include "projects/partial_project_detail_commitments.html" %}
+ {% for pg in projectobjectivegroups %} +
+ + {% include "projects/partial_project_detail_objectives.html" with projectobjectives=pg.list %} +
+
+ {% endfor %} -
+
- {% include "projects/partial_project_detail_objectives.html" %} + -
-
- -
-
+
{% endblock content %} From 05688a780ce6b085078a431d666fee888c7809b9 Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Fri, 24 Apr 2026 08:19:47 +0100 Subject: [PATCH 11/12] Added tab highlighting --- .../projects/templates/projects/project.html | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/dashboard/projects/templates/projects/project.html b/dashboard/projects/templates/projects/project.html index 5eaee91..bdc4dba 100644 --- a/dashboard/projects/templates/projects/project.html +++ b/dashboard/projects/templates/projects/project.html @@ -15,6 +15,35 @@ {% block content %} {% regroup project.projectobjective_set.all by objective.group as projectobjectivegroups %} + +
From 19592177d520a6c7b67dfeba0db388148cfa9a5b Mon Sep 17 00:00:00 2001 From: Daniele Procida Date: Fri, 24 Apr 2026 08:34:05 +0100 Subject: [PATCH 12/12] Save button only appears when required --- dashboard/dashboard/static/form-dirty.js | 4 ++++ dashboard/dashboard/static/project-detail.css | 7 ++++++- dashboard/projects/templates/projects/project.html | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 dashboard/dashboard/static/form-dirty.js diff --git a/dashboard/dashboard/static/form-dirty.js b/dashboard/dashboard/static/form-dirty.js new file mode 100644 index 0000000..652e94c --- /dev/null +++ b/dashboard/dashboard/static/form-dirty.js @@ -0,0 +1,4 @@ +document.addEventListener('change', function (e) { + const form = e.target.closest('.basics-form'); + if (form) form.dataset.dirty = ''; +}); diff --git a/dashboard/dashboard/static/project-detail.css b/dashboard/dashboard/static/project-detail.css index 1c174f4..f0db815 100644 --- a/dashboard/dashboard/static/project-detail.css +++ b/dashboard/dashboard/static/project-detail.css @@ -187,16 +187,21 @@ .basics-actions { padding-top: 0.2rem; - display: flex; + display: none; justify-content: flex-end; } +.basics-form[data-dirty] .basics-actions { + display: flex; +} + .basics-save { background: #6aa84f; color: black; border: none; border-radius: 6px; padding: 0.45rem 0.9rem; + font-size: 1rem; font-weight: 600; cursor: pointer; } diff --git a/dashboard/projects/templates/projects/project.html b/dashboard/projects/templates/projects/project.html index bdc4dba..46ece50 100644 --- a/dashboard/projects/templates/projects/project.html +++ b/dashboard/projects/templates/projects/project.html @@ -6,6 +6,7 @@ {% block extrahead %} + {% endblock %} {% block title %}{{ project}}{% endblock %}