From 6b9528fa477e2c6b2ef9843061be9377e2010de4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 08:05:32 +0000 Subject: [PATCH 001/133] Bump the all-updates group with 15 updates Bumps coverlet.collector from 10.0.0 to 10.0.1 Bumps dotnet-ef from 10.0.7 to 10.0.8 Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.7 to 10.0.8 Bumps Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore from 10.0.7 to 10.0.8 Bumps Microsoft.EntityFrameworkCore from 10.0.7 to 10.0.8 Bumps Microsoft.EntityFrameworkCore.Design from 10.0.7 to 10.0.8 Bumps Microsoft.EntityFrameworkCore.InMemory from 10.0.7 to 10.0.8 Bumps Microsoft.EntityFrameworkCore.Proxies from 10.0.7 to 10.0.8 Bumps Microsoft.EntityFrameworkCore.Sqlite from 10.0.7 to 10.0.8 Bumps Microsoft.EntityFrameworkCore.SqlServer from 10.0.7 to 10.0.8 Bumps Microsoft.EntityFrameworkCore.Tools from 10.0.7 to 10.0.8 Bumps Microsoft.Extensions.Configuration.Abstractions from 10.0.7 to 10.0.8 Bumps Microsoft.Playwright.NUnit from 1.59.0 to 1.60.0 Bumps NUnit from 4.6.0 to 4.6.1 Bumps Radzen.Blazor from 10.4.1 to 10.4.4 --- updated-dependencies: - dependency-name: coverlet.collector dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: dotnet-ef dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: Microsoft.EntityFrameworkCore dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: Microsoft.EntityFrameworkCore.Design dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: Microsoft.Extensions.Configuration.Abstractions dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: Microsoft.EntityFrameworkCore.Design dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: Microsoft.EntityFrameworkCore.InMemory dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: Microsoft.EntityFrameworkCore.Proxies dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: Microsoft.EntityFrameworkCore.Sqlite dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: Microsoft.EntityFrameworkCore.SqlServer dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: Microsoft.EntityFrameworkCore.Tools dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: Microsoft.Playwright.NUnit dependency-version: 1.60.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-updates - dependency-name: NUnit dependency-version: 4.6.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates - dependency-name: Radzen.Blazor dependency-version: 10.4.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-updates ... Signed-off-by: dependabot[bot] --- .config/dotnet-tools.json | 4 ++-- PPMTool.Data/PPMTool.Data.csproj | 20 ++++++++++---------- PPMTool.Tests/PPMTool.Tests.csproj | 6 +++--- PPMTool/PPMTool.csproj | 4 ++-- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 9b44eff4..ec7cf470 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,10 +3,10 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "10.0.7", + "version": "10.0.8", "commands": [ "dotnet-ef" ] } } -} +} \ No newline at end of file diff --git a/PPMTool.Data/PPMTool.Data.csproj b/PPMTool.Data/PPMTool.Data.csproj index 2b1b80df..e017f61c 100644 --- a/PPMTool.Data/PPMTool.Data.csproj +++ b/PPMTool.Data/PPMTool.Data.csproj @@ -13,23 +13,23 @@ SPDX-License-Identifier: apache-2.0 - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + diff --git a/PPMTool.Tests/PPMTool.Tests.csproj b/PPMTool.Tests/PPMTool.Tests.csproj index 058f8f85..d159b8bb 100644 --- a/PPMTool.Tests/PPMTool.Tests.csproj +++ b/PPMTool.Tests/PPMTool.Tests.csproj @@ -16,13 +16,13 @@ SPDX-License-Identifier: apache-2.0 - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/PPMTool/PPMTool.csproj b/PPMTool/PPMTool.csproj index 4a9bcee3..2c642827 100644 --- a/PPMTool/PPMTool.csproj +++ b/PPMTool/PPMTool.csproj @@ -40,8 +40,8 @@ SPDX-License-Identifier: apache-2.0 - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive From 5a9a946b459876a55c46c95b334a5e7ea0aad25f Mon Sep 17 00:00:00 2001 From: "Donal K. Fellows" Date: Tue, 26 May 2026 13:37:30 +0100 Subject: [PATCH 002/133] Adjustments to github workflows These adjustments are mostly to define permissions, and to switch check-ruleset to common reusable workflow. --- .github/workflows/check-ruleset.yaml | 44 +++++-------------- .../docker-images-build-health-check.yml | 6 ++- .github/workflows/dotnet.yml | 6 ++- .github/workflows/naming-conventions.yml | 10 +++-- 4 files changed, 25 insertions(+), 41 deletions(-) diff --git a/.github/workflows/check-ruleset.yaml b/.github/workflows/check-ruleset.yaml index fed6ee57..7f9f471d 100644 --- a/.github/workflows/check-ruleset.yaml +++ b/.github/workflows/check-ruleset.yaml @@ -1,5 +1,5 @@ # GitHub Actions Workflow: Check Repository Ruleset -# +# # This workflow ensures that the repository's branch protection rules # match the expected ruleset defined in the template repository. # @@ -31,36 +31,14 @@ on: branches: [main] pull_request: +permissions: + contents: read jobs: - check-ruleset: - if: github.actor != 'dependabot[bot]' - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v5 - - - name: Create GitHub App token - id: app-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ vars.ORG_ACTIONS_BOT_APP_ID }} - private-key: ${{ secrets.ORG_ACTIONS_BOT_PRIVATE_KEY }} - owner: UoMResearchIT - - - name: Fetch Standard RSE Ruleset - id: expected - uses: UoMResearchIT/actions/get-repo-ruleset@v0.3 - with: - repository: UoMResearchIT/RSE-Repository-Template - token: ${{ steps.app-token.outputs.token }} - - - name: Fetch This Repository's Ruleset - id: current - uses: UoMResearchIT/actions/get-repo-ruleset@v0.3 - - - name: Compare Rulesets - uses: UoMResearchIT/actions/check-ruleset-containment@v0.3 - with: - current: ${{ steps.current.outputs.filename }} - expected: ${{ steps.expected.outputs.filename }} + check: + if: github.repository_owner == 'UoMResearchIT' + name: Check Ruleset + uses: UoMResearchIT/actions/.github/workflows/check-ruleset.yaml@main + with: + bot-id: ${{ vars.ORG_ACTIONS_BOT_APP_ID }} + secrets: + bot-key: ${{ secrets.ORG_ACTIONS_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/docker-images-build-health-check.yml b/.github/workflows/docker-images-build-health-check.yml index b5f3bafa..07e45138 100644 --- a/.github/workflows/docker-images-build-health-check.yml +++ b/.github/workflows/docker-images-build-health-check.yml @@ -6,6 +6,8 @@ on: - dev pull_request: +permissions: + contents: read jobs: build: runs-on: ubuntu-latest @@ -26,13 +28,13 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - + - name: Create state directory for bind mount run: mkdir -p capx-state - name: Build Docker images run: docker compose build - + - name: Start container and wait for health checks run: docker compose up --wait --wait-timeout 120 diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 3155d35d..c6e57c96 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -16,13 +16,15 @@ jobs: defaults: run: working-directory: ./PPMTool + permissions: + contents: read steps: - name: Create GitHub App token id: app-token uses: actions/create-github-app-token@v3 with: - app-id: ${{ vars.ORG_ACTIONS_BOT_APP_ID }} + client-id: ${{ vars.ORG_ACTIONS_BOT_APP_ID }} private-key: ${{ secrets.ORG_ACTIONS_BOT_PRIVATE_KEY }} owner: UoMResearchIT @@ -38,4 +40,4 @@ jobs: dotnet-version: 10.0.x - name: Format - run: dotnet format PPMTool.sln --exclude ../Blazor-ApexCharts/src/Blazor-ApexCharts.csproj --verify-no-changes --no-restore --verbosity detailed \ No newline at end of file + run: dotnet format PPMTool.sln --exclude ../Blazor-ApexCharts/src/Blazor-ApexCharts.csproj --verify-no-changes --no-restore --verbosity detailed diff --git a/.github/workflows/naming-conventions.yml b/.github/workflows/naming-conventions.yml index 9c20433b..8ec621aa 100644 --- a/.github/workflows/naming-conventions.yml +++ b/.github/workflows/naming-conventions.yml @@ -5,6 +5,7 @@ on: pull_request: branches: - '**' +permissions: {} jobs: check-repo-name-conventions: @@ -14,13 +15,14 @@ jobs: steps: - name: Check repository name run: | - REPO_NAME=${{ github.repository }} REPO_NAME=${REPO_NAME##*/} if [[ ! "$REPO_NAME" =~ ^([A-Z][A-Za-z0-9]*)(-[A-Z][A-Za-z0-9]*)*$ ]]; then echo "Repository name '$REPO_NAME' does not follow the naming convention established in the RSE Agile Method Handbook." exit 1 fi - + env: + REPO_NAME: ${{ github.repository }} + check-branch-name-conventions: if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' runs-on: ubuntu-latest @@ -31,9 +33,9 @@ jobs: - name: Check branch name run: | - BRANCH_NAME=${{ github.event.pull_request.head.ref }} if [[ ! "$BRANCH_NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then echo "Branch name '$BRANCH_NAME' does not follow the naming convention established in the RSE Agile Method Handbook." exit 1 fi - + env: + BRANCH_NAME: ${{ github.event.pull_request.head.ref }} From 875aa4875e8de8c2269e6d6c43d5188e9b6c1803 Mon Sep 17 00:00:00 2001 From: "Donal K. Fellows" Date: Wed, 27 May 2026 13:17:17 +0100 Subject: [PATCH 003/133] More changes, including zizmor vulnerability scanner --- .github/dependabot.yml | 9 +++++ .../docker-images-build-health-check.yml | 4 +- .github/workflows/dotnet.yml | 6 +-- .github/workflows/naming-conventions.yml | 2 +- .github/workflows/run-zizmor.yml | 38 +++++++++++++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/run-zizmor.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2ea52b1a..f3306fc9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,3 +12,12 @@ updates: all-updates: patterns: - "*" + cooldown: + default-days: 7 + - package-ecosystem: "github-actions" + directories: + - "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 diff --git a/.github/workflows/docker-images-build-health-check.yml b/.github/workflows/docker-images-build-health-check.yml index 07e45138..8228e344 100644 --- a/.github/workflows/docker-images-build-health-check.yml +++ b/.github/workflows/docker-images-build-health-check.yml @@ -24,10 +24,10 @@ jobs: SUPERUSER_EMAIL: captain.marvel@manchester.ac.uk steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Create state directory for bind mount run: mkdir -p capx-state diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c6e57c96..c02ae8b4 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -22,20 +22,20 @@ jobs: steps: - name: Create GitHub App token id: app-token - uses: actions/create-github-app-token@v3 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.ORG_ACTIONS_BOT_APP_ID }} private-key: ${{ secrets.ORG_ACTIONS_BOT_PRIVATE_KEY }} owner: UoMResearchIT - - uses: actions/checkout@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: 'true' token: ${{ steps.app-token.outputs.token }} persist-credentials: false - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: dotnet-version: 10.0.x diff --git a/.github/workflows/naming-conventions.yml b/.github/workflows/naming-conventions.yml index 8ec621aa..541a3cc2 100644 --- a/.github/workflows/naming-conventions.yml +++ b/.github/workflows/naming-conventions.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check branch name run: | diff --git a/.github/workflows/run-zizmor.yml b/.github/workflows/run-zizmor.yml new file mode 100644 index 00000000..87d805e6 --- /dev/null +++ b/.github/workflows/run-zizmor.yml @@ -0,0 +1,38 @@ +# Copyright (c) 2026 The University of Manchester +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: GitHub Actions Security Analysis with zizmor 🌈 + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + +permissions: {} + +jobs: + zizmor: + name: Scan with Zizmor + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run zizmor 🌈 + uses: zizmorcore/zizmor-action@a16621b09c6db4281f81a93cb393b05dcd7b7165 # v0.5.5 + with: + advanced-security: false From 469fe246f3179e788e6b05d87c6ad52211de0add Mon Sep 17 00:00:00 2001 From: Samantha Finnigan <1038320+sjmf@users.noreply.github.com> Date: Wed, 27 May 2026 13:22:27 +0100 Subject: [PATCH 004/133] fix: update timestamp columns to use 'timestamp without time zone' across database schema (towards #1340) - Added migration to alter timestamp columns in various tables to 'timestamp without time zone' to ensure proper handling of date and time values. - Updated model snapshot to reflect changes in column types for consistency. --- PPMTool.Data/Context/PPMToolContext.cs | 16 + ...0527120344_FixDateTimeTimezone.Designer.cs | 1458 +++++++++++++++++ .../20260527120344_FixDateTimeTimezone.cs | 216 +++ .../Migrations/PPMToolContextModelSnapshot.cs | 40 +- 4 files changed, 1710 insertions(+), 20 deletions(-) create mode 100644 PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.Designer.cs create mode 100644 PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.cs diff --git a/PPMTool.Data/Context/PPMToolContext.cs b/PPMTool.Data/Context/PPMToolContext.cs index 50fd502a..a9832602 100644 --- a/PPMTool.Data/Context/PPMToolContext.cs +++ b/PPMTool.Data/Context/PPMToolContext.cs @@ -70,6 +70,22 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); + + // Npgsql v6+ maps DateTime -> timestamptz and rejects Kind=Unspecified. + // The codebase uses DateTime.Today/DateTime.Now throughout, which produce + // Kind=Unspecified. Map all DateTime columns to timestamp without time zone + // so Npgsql treats them as local/unspecified rather than requiring UTC. + if (Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL") + { + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties() + .Where(p => p.ClrType == typeof(DateTime) || p.ClrType == typeof(DateTime?))) + { + property.SetColumnType("timestamp without time zone"); + } + } + } } /// diff --git a/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.Designer.cs b/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.Designer.cs new file mode 100644 index 00000000..6a8875ab --- /dev/null +++ b/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.Designer.cs @@ -0,0 +1,1458 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PPMTool.Data.Context; + +#nullable disable + +namespace PPMTool.Migrations.PostgreSql.Migrations +{ + [DbContext(typeof(PPMToolContext))] + [Migration("20260527120344_FixDateTimeTimezone")] + partial class FixDateTimeTimezone + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PPMTool.Data.Entities.Absence", b => + { + b.Property("AbsenceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AbsenceId")); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("PersonId") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.HasKey("AbsenceId"); + + b.HasIndex("PersonId"); + + b.ToTable("Absence"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.ApiKey", b => + { + b.Property("ApiKeyId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ApiKeyId")); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp without time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerUserId") + .HasColumnType("integer"); + + b.HasKey("ApiKeyId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Competency", b => + { + b.Property("CompetencyId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CompetencyId")); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedDate") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Grade") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LegacyId") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Objective") + .IsRequired() + .HasColumnType("text"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("RevisionDate") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("CompetencyId"); + + b.ToTable("Competencies"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.CompetencyAssessment", b => + { + b.Property("CompetencyAssessmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CompetencyAssessmentId")); + + b.Property("CompetencyDescription") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompetencyId") + .HasColumnType("integer"); + + b.Property("CompetencyObjective") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompetencyRevision") + .HasColumnType("integer"); + + b.Property("DateCreated") + .IsRequired() + .HasColumnType("text"); + + b.Property("Evidence") + .HasColumnType("text"); + + b.Property("PersonId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("CompetencyAssessmentId"); + + b.HasIndex("CompetencyId"); + + b.HasIndex("PersonId"); + + b.ToTable("CompetencyAssessments"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Faculty", b => + { + b.Property("FacultyId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("FacultyId")); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("FacultyId"); + + b.ToTable("Faculties"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Feature", b => + { + b.Property("FeatureId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("FeatureId")); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("FeatureType") + .HasColumnType("integer"); + + b.Property("MustAlwaysBeEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("FeatureId"); + + b.ToTable("Features"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.FinancialReference", b => + { + b.Property("FinancialReferenceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("FinancialReferenceId")); + + b.Property("FinancialYear") + .HasColumnType("integer"); + + b.Property("Grade41Costs") + .HasColumnType("real"); + + b.Property("Grade51Costs") + .HasColumnType("real"); + + b.Property("Grade55Costs") + .HasColumnType("real"); + + b.Property("Grade65Costs") + .HasColumnType("real"); + + b.Property("Grade71Costs") + .HasColumnType("real"); + + b.Property("Grade75Costs") + .HasColumnType("real"); + + b.Property("RecoveryTarget") + .HasColumnType("real"); + + b.HasKey("FinancialReferenceId"); + + b.ToTable("FinancialReferences"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.FundingSource", b => + { + b.Property("FundingSourceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("FundingSourceId")); + + b.Property("AccountCode") + .HasColumnType("text"); + + b.Property("AmountAvailable") + .HasColumnType("double precision"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FundingSourceType") + .HasColumnType("integer"); + + b.Property("HasAccountCode") + .HasColumnType("boolean"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.HasKey("FundingSourceId"); + + b.HasIndex("ProjectId"); + + b.ToTable("FundingSources"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.InnateCode", b => + { + b.Property("InnateCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("InnateCodeId")); + + b.Property("ActivityCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActivityName") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.HasKey("InnateCodeId"); + + b.ToTable("InnateCodes"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.InnateCodeTask", b => + { + b.Property("InnateCodeTaskId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("InnateCodeTaskId")); + + b.Property("Duty") + .HasColumnType("integer"); + + b.Property("InnateCodeId") + .HasColumnType("integer"); + + b.Property("TaskName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("InnateCodeTaskId"); + + b.HasIndex("InnateCodeId"); + + b.ToTable("InnateCodeTasks"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Invoice", b => + { + b.Property("InvoiceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("InvoiceId")); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvoiceReference") + .HasColumnType("text"); + + b.Property("InvoiceUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("KeyDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("double precision"); + + b.HasKey("InvoiceId"); + + b.HasIndex("ProjectId"); + + b.ToTable("Invoices"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Note", b => + { + b.Property("NoteId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NoteId")); + + b.Property("AuthorUserId") + .HasColumnType("integer"); + + b.Property("CompletedDate") + .HasColumnType("timestamp without time zone"); + + b.Property("CreatedDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DueDate") + .HasColumnType("timestamp without time zone"); + + b.Property("EditedDate") + .HasColumnType("timestamp without time zone"); + + b.Property("EditorUserId") + .HasColumnType("integer"); + + b.Property("HtmlContent") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsFinanceInfo") + .HasColumnType("boolean"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.HasKey("NoteId"); + + b.HasIndex("AuthorUserId"); + + b.HasIndex("EditorUserId"); + + b.HasIndex("ProjectId"); + + b.ToTable("Notes"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.OwnedSkill", b => + { + b.Property("OwnedSkillId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("OwnedSkillId")); + + b.Property("FavouriteSkill") + .HasColumnType("boolean"); + + b.Property("LastUsed") + .HasColumnType("timestamp without time zone"); + + b.Property("OwnerPersonId") + .HasColumnType("integer"); + + b.Property("Proficiency") + .HasColumnType("integer"); + + b.Property("SkillTagId") + .HasColumnType("integer"); + + b.HasKey("OwnedSkillId"); + + b.HasIndex("OwnerPersonId"); + + b.HasIndex("SkillTagId"); + + b.ToTable("OwnedSkills"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Payment", b => + { + b.Property("PaymentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PaymentId")); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvoiceId") + .HasColumnType("integer"); + + b.Property("KeyDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("SourceFundingSourceId") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("double precision"); + + b.HasKey("PaymentId"); + + b.HasIndex("InvoiceId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("SourceFundingSourceId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Person", b => + { + b.Property("PersonId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersonId")); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("FTE") + .HasColumnType("double precision"); + + b.Property("LineManagerPersonId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ShortName") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TimesheetTemplateData") + .HasColumnType("text"); + + b.HasKey("PersonId"); + + b.HasIndex("LineManagerPersonId"); + + b.ToTable("People"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Project", b => + { + b.Property("ProjectId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectId")); + + b.Property("ActualCost") + .HasColumnType("double precision"); + + b.Property("ActualIndirectCost") + .HasColumnType("double precision"); + + b.Property("ActualLeadershipCosts") + .HasColumnType("double precision"); + + b.Property("ActualWorkHours") + .HasColumnType("double precision"); + + b.Property("ActualsLastUpdated") + .HasColumnType("text"); + + b.Property("Budget") + .HasColumnType("double precision"); + + b.Property("BudgetedIndirects") + .HasColumnType("double precision"); + + b.Property("CostModel") + .HasColumnType("integer"); + + b.Property("DayRate") + .HasColumnType("double precision"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("InnateActivityInnateCodeId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PI") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlannedCost") + .HasColumnType("double precision"); + + b.Property("PlannedIndirectCost") + .HasColumnType("double precision"); + + b.Property("PlannedLeadershipCosts") + .HasColumnType("double precision"); + + b.Property("PlannedWorkHours") + .HasColumnType("double precision"); + + b.Property("ProjectManagerPersonId") + .HasColumnType("integer"); + + b.Property("ProjectStatus") + .HasColumnType("integer"); + + b.Property("RTP") + .HasColumnType("integer"); + + b.Property("RequestDocLink") + .IsRequired() + .HasColumnType("text"); + + b.Property("SchoolId") + .HasColumnType("integer"); + + b.Property("ScrumProjectLink") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.HasKey("ProjectId"); + + b.HasIndex("InnateActivityInnateCodeId"); + + b.HasIndex("ProjectManagerPersonId"); + + b.HasIndex("SchoolId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Resource", b => + { + b.Property("ResourceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ResourceId")); + + b.Property("ActualCost") + .HasColumnType("double precision"); + + b.Property("ActualIndirectCost") + .HasColumnType("double precision"); + + b.Property("ActualWorkHours") + .HasColumnType("double precision"); + + b.Property("AssignmentFTE") + .HasColumnType("double precision"); + + b.Property("BilledFTE") + .HasColumnType("double precision"); + + b.Property("DayRate") + .HasColumnType("double precision"); + + b.Property("FundedFromFundingSourceId") + .HasColumnType("integer"); + + b.Property("IsProvisional") + .HasColumnType("boolean"); + + b.Property("PersonId") + .HasColumnType("integer"); + + b.Property("PlannedCost") + .HasColumnType("double precision"); + + b.Property("PlannedIndirectCost") + .HasColumnType("double precision"); + + b.Property("PlannedWorkHours") + .HasColumnType("double precision"); + + b.Property("SubTaskId") + .HasColumnType("integer"); + + b.Property("UseProjectDayRate") + .HasColumnType("boolean"); + + b.HasKey("ResourceId"); + + b.HasIndex("FundedFromFundingSourceId"); + + b.HasIndex("PersonId"); + + b.HasIndex("SubTaskId"); + + b.ToTable("Resources"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.School", b => + { + b.Property("SchoolId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SchoolId")); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("FacultyId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("SchoolId"); + + b.HasIndex("FacultyId"); + + b.ToTable("Schools"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Setting", b => + { + b.Property("SettingId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SettingId")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("SettingType") + .HasColumnType("integer"); + + b.Property("SettingValue") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("SettingId"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.SkillTag", b => + { + b.Property("SkillTagId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SkillTagId")); + + b.Property("ControlledName") + .IsRequired() + .HasColumnType("text"); + + b.Property("HasValidWikiLink") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rareness") + .HasColumnType("integer"); + + b.Property("RarenessCount") + .HasColumnType("integer"); + + b.HasKey("SkillTagId"); + + b.ToTable("SkillTags"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.SubTask", b => + { + b.Property("SubTaskId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubTaskId")); + + b.Property("ActualCost") + .HasColumnType("double precision"); + + b.Property("ActualIndirectCost") + .HasColumnType("double precision"); + + b.Property("ActualWorkHours") + .HasColumnType("double precision"); + + b.Property("Demand") + .HasColumnType("double precision"); + + b.Property("DurationBillableDays") + .HasColumnType("integer"); + + b.Property("DurationDays") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("HasFixedEndDate") + .HasColumnType("boolean"); + + b.Property("HasFixedStart") + .HasColumnType("boolean"); + + b.Property("IsLeadershipTask") + .HasColumnType("boolean"); + + b.Property("Lag") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OriginalDemand") + .HasColumnType("double precision"); + + b.Property("OwningProjectProjectId") + .HasColumnType("integer"); + + b.Property("PlannedCost") + .HasColumnType("double precision"); + + b.Property("PlannedIndirectCost") + .HasColumnType("double precision"); + + b.Property("PlannedWorkHours") + .HasColumnType("double precision"); + + b.Property("PredecessorSubTaskId") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TaskType") + .HasColumnType("integer"); + + b.Property("UnmetDemand") + .HasColumnType("double precision"); + + b.HasKey("SubTaskId"); + + b.HasIndex("OwningProjectProjectId"); + + b.HasIndex("PredecessorSubTaskId"); + + b.ToTable("SubTasks"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Timesheet", b => + { + b.Property("TimesheetId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TimesheetId")); + + b.Property("CreatedDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DateStatusChanged") + .HasColumnType("timestamp without time zone"); + + b.Property("Info") + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StatusChangedByPersonId") + .HasColumnType("integer"); + + b.HasKey("TimesheetId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("StatusChangedByPersonId"); + + b.ToTable("Timesheets"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.TimesheetEntry", b => + { + b.Property("TimesheetEntryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TimesheetEntryId")); + + b.Property("FridayHours") + .HasColumnType("double precision"); + + b.Property("InnateCodeTaskId") + .HasColumnType("integer"); + + b.Property("MondayHours") + .HasColumnType("double precision"); + + b.Property("SaturdayHours") + .HasColumnType("double precision"); + + b.Property("SundayHours") + .HasColumnType("double precision"); + + b.Property("ThursdayHours") + .HasColumnType("double precision"); + + b.Property("TimesheetId") + .HasColumnType("integer"); + + b.Property("TuesdayHours") + .HasColumnType("double precision"); + + b.Property("WednesdayHours") + .HasColumnType("double precision"); + + b.HasKey("TimesheetEntryId"); + + b.HasIndex("InnateCodeTaskId"); + + b.HasIndex("TimesheetId"); + + b.ToTable("TimesheetEntries"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("CASUserName") + .IsRequired() + .HasColumnType("text"); + + b.Property("EmailAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastLoggedIn") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PersonId") + .HasColumnType("integer"); + + b.Property("RoleType") + .HasColumnType("integer"); + + b.HasKey("UserId"); + + b.HasIndex("PersonId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.WorkloadModelChange", b => + { + b.Property("WorkloadModelChangeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WorkloadModelChangeId")); + + b.Property("ArchitectureFTE") + .HasColumnType("double precision"); + + b.Property("BusinessAsUsualFTE") + .HasColumnType("double precision"); + + b.Property("ChangeDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Grade") + .HasColumnType("integer"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PersonId") + .HasColumnType("integer"); + + b.Property("PersonalDevelopmentFTE") + .HasColumnType("double precision"); + + b.Property("ProjectAndServiceManagementFTE") + .HasColumnType("double precision"); + + b.Property("ProjectManagementFTE") + .HasColumnType("double precision"); + + b.Property("ProjectWorkFTE") + .HasColumnType("double precision"); + + b.Property("ServiceManagementFTE") + .HasColumnType("double precision"); + + b.Property("StaffManagementFTE") + .HasColumnType("double precision"); + + b.HasKey("WorkloadModelChangeId"); + + b.HasIndex("PersonId"); + + b.ToTable("WorkloadModelChanges"); + }); + + modelBuilder.Entity("PersonProject", b => + { + b.Property("FollowedProjectsProjectId") + .HasColumnType("integer"); + + b.Property("FollowersPersonId") + .HasColumnType("integer"); + + b.HasKey("FollowedProjectsProjectId", "FollowersPersonId"); + + b.HasIndex("FollowersPersonId"); + + b.ToTable("PersonProject"); + }); + + modelBuilder.Entity("SkillTagSubTask", b => + { + b.Property("SkillsRequiredSkillTagId") + .HasColumnType("integer"); + + b.Property("TasksNeedingThisSkillSubTaskId") + .HasColumnType("integer"); + + b.HasKey("SkillsRequiredSkillTagId", "TasksNeedingThisSkillSubTaskId"); + + b.HasIndex("TasksNeedingThisSkillSubTaskId"); + + b.ToTable("SkillTagSubTask"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Absence", b => + { + b.HasOne("PPMTool.Data.Entities.Person", "Person") + .WithMany("Absences") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.ApiKey", b => + { + b.HasOne("PPMTool.Data.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.CompetencyAssessment", b => + { + b.HasOne("PPMTool.Data.Entities.Competency", null) + .WithMany("Assessments") + .HasForeignKey("CompetencyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PPMTool.Data.Entities.Person", null) + .WithMany("Assessments") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.FundingSource", b => + { + b.HasOne("PPMTool.Data.Entities.Project", "Project") + .WithMany("FundingSources") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.InnateCodeTask", b => + { + b.HasOne("PPMTool.Data.Entities.InnateCode", "InnateCode") + .WithMany("Tasks") + .HasForeignKey("InnateCodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("InnateCode"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Invoice", b => + { + b.HasOne("PPMTool.Data.Entities.Project", "Project") + .WithMany("Invoices") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Note", b => + { + b.HasOne("PPMTool.Data.Entities.User", "Author") + .WithMany("AuthoredNotes") + .HasForeignKey("AuthorUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PPMTool.Data.Entities.User", "Editor") + .WithMany("EditedNotes") + .HasForeignKey("EditorUserId"); + + b.HasOne("PPMTool.Data.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("Editor"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.OwnedSkill", b => + { + b.HasOne("PPMTool.Data.Entities.Person", "Owner") + .WithMany("OwnedSkills") + .HasForeignKey("OwnerPersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PPMTool.Data.Entities.SkillTag", "SkillTag") + .WithMany("OwnedSkills") + .HasForeignKey("SkillTagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("SkillTag"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Payment", b => + { + b.HasOne("PPMTool.Data.Entities.Invoice", "Invoice") + .WithMany("Payments") + .HasForeignKey("InvoiceId"); + + b.HasOne("PPMTool.Data.Entities.Project", "Project") + .WithMany("Payments") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PPMTool.Data.Entities.FundingSource", "Source") + .WithMany("PaymentsFromSource") + .HasForeignKey("SourceFundingSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Invoice"); + + b.Navigation("Project"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Person", b => + { + b.HasOne("PPMTool.Data.Entities.Person", "LineManager") + .WithMany("PeopleManaged") + .HasForeignKey("LineManagerPersonId"); + + b.Navigation("LineManager"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Project", b => + { + b.HasOne("PPMTool.Data.Entities.InnateCode", "InnateActivity") + .WithMany() + .HasForeignKey("InnateActivityInnateCodeId"); + + b.HasOne("PPMTool.Data.Entities.Person", "ProjectManager") + .WithMany("ManagedProjects") + .HasForeignKey("ProjectManagerPersonId"); + + b.HasOne("PPMTool.Data.Entities.School", "School") + .WithMany() + .HasForeignKey("SchoolId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("InnateActivity"); + + b.Navigation("ProjectManager"); + + b.Navigation("School"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Resource", b => + { + b.HasOne("PPMTool.Data.Entities.FundingSource", "FundedFrom") + .WithMany("ResourcesFunded") + .HasForeignKey("FundedFromFundingSourceId"); + + b.HasOne("PPMTool.Data.Entities.Person", "Person") + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PPMTool.Data.Entities.SubTask", "SubTask") + .WithMany("AssignedResources") + .HasForeignKey("SubTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FundedFrom"); + + b.Navigation("Person"); + + b.Navigation("SubTask"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.School", b => + { + b.HasOne("PPMTool.Data.Entities.Faculty", "Faculty") + .WithMany("Schools") + .HasForeignKey("FacultyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Faculty"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.SubTask", b => + { + b.HasOne("PPMTool.Data.Entities.Project", "OwningProject") + .WithMany("SubTasks") + .HasForeignKey("OwningProjectProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PPMTool.Data.Entities.SubTask", "Predecessor") + .WithMany("Successors") + .HasForeignKey("PredecessorSubTaskId"); + + b.Navigation("OwningProject"); + + b.Navigation("Predecessor"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Timesheet", b => + { + b.HasOne("PPMTool.Data.Entities.Person", "Owner") + .WithMany("Timesheets") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PPMTool.Data.Entities.Person", "StatusChangedBy") + .WithMany("TimesheetsChanged") + .HasForeignKey("StatusChangedByPersonId"); + + b.Navigation("Owner"); + + b.Navigation("StatusChangedBy"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.TimesheetEntry", b => + { + b.HasOne("PPMTool.Data.Entities.InnateCodeTask", "InnateCodeTask") + .WithMany() + .HasForeignKey("InnateCodeTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PPMTool.Data.Entities.Timesheet", "Timesheet") + .WithMany("TimesheetEntries") + .HasForeignKey("TimesheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("InnateCodeTask"); + + b.Navigation("Timesheet"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.User", b => + { + b.HasOne("PPMTool.Data.Entities.Person", "Person") + .WithMany() + .HasForeignKey("PersonId"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.WorkloadModelChange", b => + { + b.HasOne("PPMTool.Data.Entities.Person", "Person") + .WithMany("WorkloadModelChanges") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("PersonProject", b => + { + b.HasOne("PPMTool.Data.Entities.Project", null) + .WithMany() + .HasForeignKey("FollowedProjectsProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PPMTool.Data.Entities.Person", null) + .WithMany() + .HasForeignKey("FollowersPersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SkillTagSubTask", b => + { + b.HasOne("PPMTool.Data.Entities.SkillTag", null) + .WithMany() + .HasForeignKey("SkillsRequiredSkillTagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PPMTool.Data.Entities.SubTask", null) + .WithMany() + .HasForeignKey("TasksNeedingThisSkillSubTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Competency", b => + { + b.Navigation("Assessments"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Faculty", b => + { + b.Navigation("Schools"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.FundingSource", b => + { + b.Navigation("PaymentsFromSource"); + + b.Navigation("ResourcesFunded"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.InnateCode", b => + { + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Invoice", b => + { + b.Navigation("Payments"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Person", b => + { + b.Navigation("Absences"); + + b.Navigation("Assessments"); + + b.Navigation("ManagedProjects"); + + b.Navigation("OwnedSkills"); + + b.Navigation("PeopleManaged"); + + b.Navigation("Timesheets"); + + b.Navigation("TimesheetsChanged"); + + b.Navigation("WorkloadModelChanges"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Project", b => + { + b.Navigation("FundingSources"); + + b.Navigation("Invoices"); + + b.Navigation("Payments"); + + b.Navigation("SubTasks"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.SkillTag", b => + { + b.Navigation("OwnedSkills"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.SubTask", b => + { + b.Navigation("AssignedResources"); + + b.Navigation("Successors"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.Timesheet", b => + { + b.Navigation("TimesheetEntries"); + }); + + modelBuilder.Entity("PPMTool.Data.Entities.User", b => + { + b.Navigation("AuthoredNotes"); + + b.Navigation("EditedNotes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.cs b/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.cs new file mode 100644 index 00000000..ce01f15e --- /dev/null +++ b/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.cs @@ -0,0 +1,216 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PPMTool.Migrations.PostgreSql.Migrations +{ + /// + public partial class FixDateTimeTimezone : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Scaffolded AlterColumn omits the USING clause, which PostgreSQL requires when + // casting timestamptz -> timestamp. Use raw SQL with AT TIME ZONE 'UTC' to + // preserve the existing wall-clock dates exactly (timestamptz is stored as UTC). + var columns = new[] + { + ("WorkloadModelChanges", "ChangeDate"), + ("Timesheets", "StartDate"), + ("Timesheets", "DateStatusChanged"), + ("Timesheets", "CreatedDate"), + ("SubTasks", "StartDate"), + ("SubTasks", "EndDate"), + ("Projects", "StartDate"), + ("Projects", "EndDate"), + ("People", "StartDate"), + ("People", "EndDate"), + ("Payments", "KeyDate"), + ("OwnedSkills", "LastUsed"), + ("Notes", "EditedDate"), + ("Notes", "DueDate"), + ("Notes", "CreatedDate"), + ("Notes", "CompletedDate"), + ("Invoices", "KeyDate"), + ("ApiKeys", "ExpiresAt"), + ("Absence", "StartDate"), + ("Absence", "EndDate"), + }; + + foreach (var (table, column) in columns) + { + migrationBuilder.Sql( + $"ALTER TABLE \"{table}\" ALTER COLUMN \"{column}\" TYPE timestamp without time zone USING \"{column}\" AT TIME ZONE 'UTC';"); + } + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "ChangeDate", + table: "WorkloadModelChanges", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "StartDate", + table: "Timesheets", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "DateStatusChanged", + table: "Timesheets", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "CreatedDate", + table: "Timesheets", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "StartDate", + table: "SubTasks", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "EndDate", + table: "SubTasks", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "StartDate", + table: "Projects", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "EndDate", + table: "Projects", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "StartDate", + table: "People", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "EndDate", + table: "People", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "KeyDate", + table: "Payments", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "LastUsed", + table: "OwnedSkills", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "EditedDate", + table: "Notes", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "DueDate", + table: "Notes", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedDate", + table: "Notes", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "CompletedDate", + table: "Notes", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "KeyDate", + table: "Invoices", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "ExpiresAt", + table: "ApiKeys", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "StartDate", + table: "Absence", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "EndDate", + table: "Absence", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + } + } +} diff --git a/PPMTool.Migrations.PostgreSql/Migrations/PPMToolContextModelSnapshot.cs b/PPMTool.Migrations.PostgreSql/Migrations/PPMToolContextModelSnapshot.cs index dd5b58a3..6fa0ee56 100644 --- a/PPMTool.Migrations.PostgreSql/Migrations/PPMToolContextModelSnapshot.cs +++ b/PPMTool.Migrations.PostgreSql/Migrations/PPMToolContextModelSnapshot.cs @@ -35,13 +35,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AbsenceId")); b.Property("EndDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("PersonId") .HasColumnType("integer"); b.Property("StartDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.HasKey("AbsenceId"); @@ -66,7 +66,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text"); b.Property("ExpiresAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("Key") .IsRequired() @@ -369,7 +369,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text"); b.Property("KeyDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("ProjectId") .HasColumnType("integer"); @@ -399,16 +399,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer"); b.Property("CompletedDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("CreatedDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("DueDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("EditedDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("EditorUserId") .HasColumnType("integer"); @@ -446,7 +446,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean"); b.Property("LastUsed") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("OwnerPersonId") .HasColumnType("integer"); @@ -482,7 +482,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer"); b.Property("KeyDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("ProjectId") .HasColumnType("integer"); @@ -513,7 +513,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PersonId")); b.Property("EndDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("FTE") .HasColumnType("double precision"); @@ -530,7 +530,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text"); b.Property("StartDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("TimesheetTemplateData") .HasColumnType("text"); @@ -582,7 +582,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text"); b.Property("EndDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("InnateActivityInnateCodeId") .HasColumnType("integer"); @@ -627,7 +627,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text"); b.Property("StartDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.HasKey("ProjectId"); @@ -810,7 +810,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer"); b.Property("EndDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("HasFixedEndDate") .HasColumnType("boolean"); @@ -847,7 +847,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer"); b.Property("StartDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("TaskType") .HasColumnType("integer"); @@ -873,10 +873,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TimesheetId")); b.Property("CreatedDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("DateStatusChanged") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("Info") .HasColumnType("text"); @@ -885,7 +885,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer"); b.Property("StartDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("Status") .HasColumnType("integer"); @@ -997,7 +997,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("double precision"); b.Property("ChangeDate") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp without time zone"); b.Property("Grade") .HasColumnType("integer"); From 88ad48c4b6c9e32a8b9c430e52ffc01feaeb86c3 Mon Sep 17 00:00:00 2001 From: "Donal K. Fellows" Date: Wed, 27 May 2026 13:32:36 +0100 Subject: [PATCH 005/133] Some small fixes --- .github/workflows/check-ruleset.yaml | 2 +- .github/workflows/docker-images-build-health-check.yml | 2 ++ .github/workflows/dotnet.yml | 1 + .github/workflows/naming-conventions.yml | 4 ++++ .github/workflows/run-zizmor.yml | 2 +- 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-ruleset.yaml b/.github/workflows/check-ruleset.yaml index 7f9f471d..56d51c99 100644 --- a/.github/workflows/check-ruleset.yaml +++ b/.github/workflows/check-ruleset.yaml @@ -28,7 +28,7 @@ name: Check Repository Ruleset on: push: - branches: [main] + branches: [dev] pull_request: permissions: diff --git a/.github/workflows/docker-images-build-health-check.yml b/.github/workflows/docker-images-build-health-check.yml index 8228e344..64ad0271 100644 --- a/.github/workflows/docker-images-build-health-check.yml +++ b/.github/workflows/docker-images-build-health-check.yml @@ -25,6 +25,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c02ae8b4..ae6e9975 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -27,6 +27,7 @@ jobs: client-id: ${{ vars.ORG_ACTIONS_BOT_APP_ID }} private-key: ${{ secrets.ORG_ACTIONS_BOT_PRIVATE_KEY }} owner: UoMResearchIT + permission-contents: read - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/.github/workflows/naming-conventions.yml b/.github/workflows/naming-conventions.yml index 541a3cc2..f0d508da 100644 --- a/.github/workflows/naming-conventions.yml +++ b/.github/workflows/naming-conventions.yml @@ -26,10 +26,14 @@ jobs: check-branch-name-conventions: if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Check out repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Check branch name run: | diff --git a/.github/workflows/run-zizmor.yml b/.github/workflows/run-zizmor.yml index 87d805e6..1644c0f5 100644 --- a/.github/workflows/run-zizmor.yml +++ b/.github/workflows/run-zizmor.yml @@ -16,7 +16,7 @@ name: GitHub Actions Security Analysis with zizmor 🌈 on: push: - branches: ["main"] + branches: ["dev"] pull_request: branches: ["**"] From 090a82541fe5609352c7e7ed43db069813eb746e Mon Sep 17 00:00:00 2001 From: "Donal K. Fellows" Date: Wed, 27 May 2026 13:37:19 +0100 Subject: [PATCH 006/133] Bind our own action versions --- .github/workflows/check-ruleset.yaml | 2 +- .github/workflows/dotnet.yml | 2 +- .github/workflows/license-copyright-add.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check-ruleset.yaml b/.github/workflows/check-ruleset.yaml index 56d51c99..2f533192 100644 --- a/.github/workflows/check-ruleset.yaml +++ b/.github/workflows/check-ruleset.yaml @@ -37,7 +37,7 @@ jobs: check: if: github.repository_owner == 'UoMResearchIT' name: Check Ruleset - uses: UoMResearchIT/actions/.github/workflows/check-ruleset.yaml@main + uses: UoMResearchIT/actions/.github/workflows/check-ruleset.yaml@631e75042fd39b85beba5d00729d2650ee8f4013 # main with: bot-id: ${{ vars.ORG_ACTIONS_BOT_APP_ID }} secrets: diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index ae6e9975..a6d0babc 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -27,8 +27,8 @@ jobs: client-id: ${{ vars.ORG_ACTIONS_BOT_APP_ID }} private-key: ${{ secrets.ORG_ACTIONS_BOT_PRIVATE_KEY }} owner: UoMResearchIT + repositories: CapX # No current submodules permission-contents: read - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: 'true' diff --git a/.github/workflows/license-copyright-add.yml b/.github/workflows/license-copyright-add.yml index f5698a16..87f8498d 100644 --- a/.github/workflows/license-copyright-add.yml +++ b/.github/workflows/license-copyright-add.yml @@ -12,4 +12,4 @@ jobs: permissions: pull-requests: write contents: write - uses: UoMResearchIT/actions/.github/workflows/apply-reuse.yml@main + uses: UoMResearchIT/actions/.github/workflows/apply-reuse.yml@631e75042fd39b85beba5d00729d2650ee8f4013 # main From 180bea578a29bcb071a99370189a8359e1cff6de Mon Sep 17 00:00:00 2001 From: "Donal K. Fellows" Date: Wed, 27 May 2026 14:23:56 +0100 Subject: [PATCH 007/133] Trigger rebuild --- .github/workflows/dotnet.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index a6d0babc..f3bf81a1 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -29,6 +29,7 @@ jobs: owner: UoMResearchIT repositories: CapX # No current submodules permission-contents: read + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: 'true' From d7ba60c341296bc527a0ed197bdb355f8376beda Mon Sep 17 00:00:00 2001 From: "Donal K. Fellows" Date: Wed, 27 May 2026 14:38:16 +0100 Subject: [PATCH 008/133] Need newer zizmor revision --- .github/workflows/dotnet.yml | 3 ++- .github/workflows/run-zizmor.yml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index f3bf81a1..e1ccae91 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -27,7 +27,8 @@ jobs: client-id: ${{ vars.ORG_ACTIONS_BOT_APP_ID }} private-key: ${{ secrets.ORG_ACTIONS_BOT_PRIVATE_KEY }} owner: UoMResearchIT - repositories: CapX # No current submodules + # NB: No current submodules; the .gitmodules file is empty + repositories: CapX permission-contents: read - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/run-zizmor.yml b/.github/workflows/run-zizmor.yml index 1644c0f5..444294e3 100644 --- a/.github/workflows/run-zizmor.yml +++ b/.github/workflows/run-zizmor.yml @@ -33,6 +33,6 @@ jobs: persist-credentials: false - name: Run zizmor 🌈 - uses: zizmorcore/zizmor-action@a16621b09c6db4281f81a93cb393b05dcd7b7165 # v0.5.5 + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 with: advanced-security: false From 964b09b510aca7bf00b54ec936db13be3cc6aac8 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Thu, 28 May 2026 11:06:46 +0100 Subject: [PATCH 009/133] Updated contributing file --- CONTRIBUTING.md | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 564f91ed..371db9c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,10 +22,10 @@ By contributing to this repository, you agree that your contributions will be li This project is open source and welcomes contributions from the wider community. However, its development and release process follows a structured governance model. -- The software is owned and licensed by **The University of Manchester**. -- Strategic direction, release planning, and final decisions on what is included in the codebase are managed by the **dRTP Operational Management Community of Practice**. +- The software was originally developed by **The University of Manchester** and is licensed under an open-source licence. +- Strategic direction, release planning, and final decisions on what is included in the codebase are managed by the **dRTP Operational Management Community of Practice** funded by the UKRI Digital Research Infrastructure Programme via the DisCouRSE Network Plus flexible fund. -This means that while the repository is open to contributions, inclusion in official releases is determined collectively by this group. +This means that while the repository is open to contributions, inclusion in official releases is determined collectively by this group. There is no funded development team to address issues but members of the **dRTP Operational Management Community of Practice** will progress priority items on a best-efforts basis. ## How Contributions Work @@ -48,31 +48,27 @@ If you wish to contribute code: All pull requests: -- Must pass any configured checks -- Must be reviewed and approved by members of the Community of Practice before merging -- May be discussed or refined collaboratively +- Must pass any configured checks. +- Must be reviewed and approved by members of the **dRTP Operational Management Community of Practice** before merging. +- May be discussed or refined collaboratively. ## Decision Making and Releases The **dRTP Operational Management Community of Practice**: -- Reviews proposed changes -- Prioritises development work -- Determines what changes are accepted -- Decides when releases are made +- Reviews proposed changes from the perspective of deployed instances. +- Prioritises development work based on urgency and impact. +- Determines what changes are accepted based on a proportionate review process. +- Decides when releases are made and how they are grouped. -This is similar to governance models used by large open source ecosystems (e.g., .NET), where: - -- The project is open and community-driven -- A defined group maintains responsibility for quality, direction, and release cadence +This is similar to governance models used by large open-source ecosystems (e.g., .NET), where the project is open and community-driven but defined group maintains responsibility for quality, direction, and release cadence. This review and ratification process will happen at least once a month. ## Expectations -Please note: - -- Not all contributions will be accepted -- Feature requests may be deferred or declined based on priorities -- Timelines for review and release are determined by the governing community +- Not all contributions will be accepted. +- Feature requests may be deferred based on priorities or declined. +- Timelines for review and release are determined by the **dRTP Operational Management Community of Practice**. +- Backwards compatibility should be preserved unless the **dRTP Operational Management Community of Practice** decides otherwise. ## Code of Conduct From 689a6d016c873f3791ffd1deec5abe6e5165257b Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Thu, 28 May 2026 11:32:10 +0100 Subject: [PATCH 010/133] Updated readme --- README.md | 72 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 0276cc91..633a9373 100644 --- a/README.md +++ b/README.md @@ -5,41 +5,39 @@ SPDX-License-Identifier: apache-2.0 --> # Capacity eXtended (CapX) -This is tool initially started as a basic project and portfolio management (PPM) tool. Its first feature was capacity management, but it has since been extended to incorporate a much larger, more complex data model useful for an increased number of operational management activities. Written in .NET Blazor (Server) with a SQLite database, it is used for managing many aspects of the service delivery of the RSE department and the development of its staff. +This is tool initially started as a basic project and portfolio management (PPM) tool. Its first feature was capacity management, but it has since been extended to incorporate a much larger, more complex data model useful for an increased number of operational management activities. Written in .NET Blazor (Server) with a SQLite database (other providers are supported), it is used for managing many aspects of the service delivery of the digital research technical professional departments and the development of its staff. -The production version of CapX is currently deployed to [balex.itservices.manchester.ac.uk](https://balex.itservices.manchester.ac.uk) built from the `release` branch in the repo. This is a 10.99 private IP so users will need to be on the VPN to access. - -There is a development version of CapX deployed to [balextest.itservices.manchester.ac.uk](https://balextest.itservices.manchester.ac.uk) which is a build of the `dev` branch and showcases new features but might not be entirely stable. This is also on the private network. - -CapX also offers an API integrated into the web application accessed via [https://balex.itservices.manchester.ac.uk/api](https://balex.itservices.manchester.ac.uk/api) and [https://balextest.itservices.manchester.ac.uk/api](https://balextest.itservices.manchester.ac.uk/api) in production and pre-production respectively. Endpoints require an API key to be supplied in the request header which can be generated in the developer settings part of the main web application. Swagger is also enabled for exploring the API at `...manchester.ac.uk/swagger`. +> [!IMPORTANT] +> The [dRTP Operational Management Community Practice (dRTP-OMCoP)](https://uomresearchit.github.io/DRTP-Op-Man-CoP-Website/) are responsible for its strategic direction. We’d love to hear if you are using this tool. If you would like to be part of dRTP-OMCoP then please let us know! ## User Accounts and Access -The app is integrated with UoM CAS/Shibboleth as well as Azure AD / Entra with access to restricted parts of the app managed within the app using a Role-Based Access Control (RBAC) database table. Super-users are able to manage user roles and access via the "Manage Access" page. +The app suports integration with CAS / Shibboleth as well as Azure AD / Entra with access to restricted parts of the app managed within the app using a Role-Based Access Control (RBAC) database table. Super-users are able to manage user roles and access via the "Manage Access" page. -The production version of CapX uses the production (DS) CAS/Shib/Entra and users with a standard UoM user account can authenticate. The development version of CapX authenticates using the pre-production (PPAD) CAS/Shib/Entra instances; users will need a UoM PPAD account to use the development version. If the app is run in the "Local" solution configuration then thrid-party authentication is disabled and instead, users can select any name from a dropdown list to log in as any user from the header bar -- this mode is intended for debugging or demos. +If the app is run in the "Local" solution configuration then third-party authentication is disabled and instead, users can select any name from a dropdown list to log in as any user from the header bar -- this mode is intended for debugging or demos. ## API Access Any user of the web application can gain access to the API endpoints. Note that their success in using the endpoints is dictated by their role in the web app RBAC database table. To access the API, users need to generate an API key from the "Developer Settings" in the web app under the "Developer Settings". The successful generation of an API key in the web app depends on a suitable secret (minimum 32 characters) being injected into the `Jwt:SecretKey` configuration parameter for the web application. This secret parameter can be injected via an environment variable named `API_KEY_SECRET`, or during development if using Visual Studio, this can be done by simply opening "Manage User Secrets" for the project and adding `"Jwt:SecretKey" : "some-32-char-long-value"` to the .NET secrets manager. -## Automated Deployment -CapX makes use of automated deployment. As the VMs are on the University private network, they are not visible to GitHub so we cannot simply use a GitHub action to auto-deploy. Instead, the build/test VMs run cron jobs which long-poll the repository every 10 minutes, using `git fetch` and `git status` to determine programmtically whether the source code on the VM is behind the remote on the `dev` and `release` branches. If it is, it will pull the latest source code for the branch, authenticating with GitHub using an SSH key, and then build the docker containers. The development deploy script additionally copies the database from the production VM prior to applying migrations to ensure the development version is tested on real data. The production database is also backed-up as part of the deployment process in case of failure. Any time a DB file is to be copied, all the `PPMTool.db*` files are copied since WAL is enabled. `sqlite3 PPMTool.db VACUUM;` is used to flush the WAL journals before any manipulation of the DB takes place. - -Deployment scripts can be found in the `deployment` folder in the repo. Documentation on how to use the config files to setup the reverse proxy can be found on the [old MDS Wiki](https://github.com/UoMResearchIT/MDS-Essentials/wiki/.NET-Web-Hosting-on-Ubuntu#deployment-of-net-blazor-app-on-ubuntu-with-nginx). +## Deployment +Deployment scripts as well as reverse proxy configurations and sample environment variable files are provided in the `deployment` directory. These scripts can be used with cron to long-poll GitHub for changes to the relevant branch, kicking off a re-build of the container as appropriate. The development deploy script additionally copies the database from the production VM prior to applying migrations to ensure the development version is tested on real data. The production database is also backed-up as part of the deployment process in case of failure. Any time a DB file is to be copied, all the `PPMTool.db*` files are copied since WAL is enabled. `sqlite3 PPMTool.db VACUUM;` is used to flush the WAL journals before any manipulation of the DB takes place. > [!WARNING] > The app will fail to start if it detects that required variables are not set. Exception details will be written to the log files in the `Logs` folder as well as `syslog` or `docker log`. -## Database Backups -The databases are backed up (including flushing of the WAL journals) as part of the deployment automation pipeline. However, there is also an hourly cronjob that flushes and backs up the DB to a different directory. This mechanism stores 72 backups, deleting the oldest when this file count is exceeded. +### Database Backups +Database backups can be scheduled using the relevant script in the `deployment` directory. ## Documentation and User Guides -Documentation of features and how to use them is available in the Wiki associated with this repository. This is admittedly not kept up-to-date. +Documentation of some of the features and how to use them is available in the Wiki associated with this repository. This is admittedly not kept up-to-date. ## Building and Running from Source -The software can be cloned with the usual `git clone` command. However, depending on the version checked out, it may contain submodules which can be initialised as part of the initial clone or as a separate step after the fact with `git submodule update --init --recursive`. If using Visual Studio 2022, developers will need to run `Update-Database` from the package manager console to create the DB and run the migrations before running the solution. +The software can be cloned with the usual `git clone` command. However, depending on the version checked out, it may contain submodules which can be initialised as part of the initial clone or as a separate step after the fact with `git submodule update --init --recursive`. + +> [!TIP] +> By defualt, cloning the repository will checkout the `dev` branch. If you simply want to deploy a particular release then you can checkout the relevant tagged commit using `git checkout ` e.g. `git checkout Release_v1.14.0` then follow the instructions below to spin-up the container with Docker Compose below. ### Database Connection -The database connection string needs to be specified in a `CONNECTION_STRING` environment variable. During development in Visual Studio, See the `.env.sample` file for example connection strings. Note that this is also required at "design-time" when running EF Core tools to update the database. The University of Manchester instance of the CapX API also connects the [institutional leave booking system](https://holiday.its.manchester.ac.uk/) database. The connection string for this connection also needs to be specified in the same way in a variable called `LEAVEBOOKINGS_CONNECTION_STRING=`. If using CapX elsewhere, this can simply a be a dummy string as long as it is not blank. During development, User Secrets can be used to override the blank values specified in the `appsettings.json`. +The database connection string needs to be specified in a `CONNECTION_STRING` environment variable. During development in Visual Studio, See the `.env.sample` file for example connection strings. Note that this is also required at "design-time" when running EF Core tools to update the database. The connection string for `LEAVEBOOKINGS_CONNECTION_STRING=` is only relevant to The University of Manchester. If using CapX elsewhere, this can simply a be a dummy string as long as it is not blank. During development, User Secrets can be used to override the blank values specified in the `appsettings.json`. ### Seeding the Database The app will run migrations every time it starts. If the connection string permits it, it will therefore create a blank database on first run. Furthermore, a single super user will be added to allow you to login. A blank database limits the ability to test new features or to demo the software without first adding records to the blank tables through the UI which takes time. To faciltate better testing, developers can set the `SEED_DUMMY_DATA` environment variable to "TRUE" (case insensitive) to have the software populate all the empty tables with dummy data on start-up. @@ -64,9 +62,13 @@ dotnet ef database update --context PPMToolContext --project PPMTool.Migrations. > If you are adding a migration for one provider due to a DB schema change, it is expected that you would add migrations for all the others that replicate the same change. Otherwise different DB providers will end up with DB schemas that differ from each other! ### Solution/Build and Launch Configurations -The Visual Studio solution no-longer has _launch_ configurations since the web app and the API are now integrated into one application. However, there are two _solution_ configurations: `Local` and `Release` that combine project-level _build_ configurations. +There are two _solution_ configurations: `Local` and `Release` that combine project-level _build_ configurations. + - `Local` is to be used for development on your own machine as it bypasses third-party CAS authentication integrations and instead allows the developer to "sign-in" with any user in the database for testing purposes. -- `Release` is designed to be used on test and production servers and integrates with third party CAS authentication providers. They also include additional logging and crash reporting integration with Sentry that are not included in the `Local` configuration. +- `Release` is designed to be used on test and production servers and integrates with third party CAS authentication providers. They also include additional logging and crash reporting integration with Sentry that are not included in the `Local` configuration. + +> [!TIP] +> The `ASPNETCORE_ENVIRONMENT` variable is used to specify whether the app should run in production or development mode. This is a runtime variable and has nothing to do with the compile-time _build_ configuration. ### Running with Docker Compose Docker Compose runs a single container with a single volume containing the database. @@ -80,11 +82,11 @@ To set this up, create a `.env` file in the repository root with the following r | `ASPNETCORE_ENVIRONMENT` | ASP.NET Core runtime environment. Valid values: `Development` or `Production`. This determines which `appsettings.*.json` file is loaded at runtime. | | `BUILD_CONFIGURATION` | .NET build configuration (`-c` flag). Valid values: `Local`, `Debug`, or `Release`. See [Solution/Build and Launch Configurations](#solutionbuild-and-launch-configurations) for details. | | `CONNECTION_STRING` | SQLite connection string, e.g. `Data Source=state/PPMTool.db` | -| `LEAVEBOOKINGS_CONNECTION_STRING` | Connection string for the leave bookings database | +| `LEAVEBOOKINGS_CONNECTION_STRING` | Connection string for the leave bookings database (The University of Manchester only) | | `API_KEY_SECRET` | Secret for API key generation (minimum 16 characters). Use `openssl rand -hex 16` to generate a strong key. | | `CAPX_HTTP_PORT` | Port for the web application (e.g. `3000`) | | `CAPX_API_PORT` | Port for the API (e.g. `3001`) | -| `CAPX_STATE_DIR` | Where the SQLite DB lives outside the container | +| `CAPX_STATE_DIR` | Where the SQLite DB lives outside the container (if using this DB provider) | | `SEED_DUMMY_DATA` | Set to `TRUE` to seed dummy data on startup | | `SUPERUSER_NAME` | Name of the superuser (required if seeding) | | `SUPERUSER_USERNAME` | Username of the superuser (required if seeding) | @@ -96,7 +98,7 @@ The following variables need only be set when not using the "Local" solution con | Variable | Description | |----------|-------------| -| `SENTRY_DSN` | The URL to which crash reports from the Sentry library are sent | +| `SENTRY_DSN` | The URL to which crash reports from the Sentry library are sent if using this crash monitoring solution | | `MAIL_FROM_ADDRESS` | Email address from which notifications are sent | | `MAIL_SMTP_SERVER` | URL of the SMTP server to send email requests to | | `CAS_PROTOCOL` | The protocol that CAS/Shib should use (either 2 or 3) | @@ -121,7 +123,7 @@ docker compose up --build You can then access: - The web application at `http://localhost:3000` (or your configured `CAPX_HTTP_PORT`) - The API at `http://localhost:3000/api` (or your configured `CAPX_HTTP_PORT`) -- The swagger interface at http://localhost:3000/swagger` (or your configured `CAPX_HTTP_PORT`) +- The swagger interface at `http://localhost:3000/swagger` (or your configured `CAPX_HTTP_PORT`) Use Ctrl-C to bring the container down. The database state is maintained in a Docker volume. To wipe the volume and start from the initial state, use: @@ -148,3 +150,27 @@ TBC 2. Use of the Bitwarden browser plugin has been know to slow down the response of the interactive graphs. See [Issue 302](https://github.com/UoMResearchIT/CapX/issues/302) for details. 3. CapX does not work properly in Safari on macOS when run from Docker. See [Issue 570](https://github.com/UoMResearchIT/CapX/issues/570) for details. 4. Use of GitInfo library means that the app must have access to the `.git` directory at compile-time. If checked out as a submodule this wont' be the case so this is not supported. See [Issue 1255](https://github.com/UoMResearchIT/CapX/issues/1255) for details. + +--- + +## CapX @ The University of Manchester +The production version of CapX is currently deployed to [balex.itservices.manchester.ac.uk](https://balex.itservices.manchester.ac.uk) built from the `release` branch in the repo. This machine is on the UoM private network so users will need to be on the VPN to access. + +There is a development version of CapX deployed to [balextest.itservices.manchester.ac.uk](https://balextest.itservices.manchester.ac.uk) which is a build of the `dev` branch and showcases new features but might not be entirely stable. This is also on the private network. + +### API +CapX also offers an API integrated into the web application accessed via [https://balex.itservices.manchester.ac.uk/api](https://balex.itservices.manchester.ac.uk/api) and [https://balextest.itservices.manchester.ac.uk/api](https://balextest.itservices.manchester.ac.uk/api) in production and pre-production respectively. Endpoints require an API key to be supplied in the request header which can be generated in the developer settings part of the main web application. Swagger is also enabled for exploring the API at `...manchester.ac.uk/swagger`. + +### Authentication +Authentication is provided by Entra production and pre-production systems for production and test instances respectively. Users will need a UoM PPAD account to access the test system. + +### Deployment +CapX makes use of automated deployment. As the VMs are on the University private network, they are not visible to GitHub so we cannot simply use a GitHub action to auto-deploy. Instead, the build/test VMs run cron jobs which long-poll the repository every 10 minutes, using `git fetch` and `git status` to determine programmtically whether the source code on the VM is behind the remote on the `dev` and `release` branches. If it is, it will pull the latest source code for the branch, authenticating with GitHub using an SSH key, and then build the docker containers. The scripts used a provided in the `deployment` directory along with reverse proxy configuration for Nginx. Documentation on how to use the config files to setup the reverse proxy can be found on the [old MDS Wiki](https://github.com/UoMResearchIT/MDS-Essentials/wiki/.NET-Web-Hosting-on-Ubuntu#deployment-of-net-blazor-app-on-ubuntu-with-nginx). + +### Database Backups +The databases are backed up (including flushing of the WAL journals) as part of the deployment automation pipeline. However, there is also an hourly cronjob that flushes and backs up the DB to a different directory. This mechanism stores 72 backups, deleting the oldest when this file count is exceeded. + +### Leave Booking database +The University of Manchester instance of the CapX API also connects the [institutional leave booking system](https://holiday.its.manchester.ac.uk/) database. This connection string needs to be specified properly for this connection to work. + + From a83808c36df5cdcc23d1730a9484fa340879c526 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Thu, 28 May 2026 11:57:48 +0100 Subject: [PATCH 011/133] Added code owners doc --- CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..d5a11c8b --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,2 @@ +# Everything +* @aharwood2 @PhilBradbury @sjmf @philiptharrison @gdonval \ No newline at end of file From 29cee1d0724662517d633269c35b8d0227432ba6 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Thu, 28 May 2026 11:58:10 +0100 Subject: [PATCH 012/133] Removed the temporary migration tool used for UoM update to v14 --- migration1314/db_migrate.py | 424 ----------------------------------- migration1314/readme | 4 - migration1314/readme.license | 3 - 3 files changed, 431 deletions(-) delete mode 100644 migration1314/db_migrate.py delete mode 100644 migration1314/readme delete mode 100644 migration1314/readme.license diff --git a/migration1314/db_migrate.py b/migration1314/db_migrate.py deleted file mode 100644 index c10481a2..00000000 --- a/migration1314/db_migrate.py +++ /dev/null @@ -1,424 +0,0 @@ -# SPDX-FileCopyrightText: 2026 University of Manchester -# -# SPDX-License-Identifier: apache-2.0 - -import sqlite3 - -OLD_DB = "PPMTool-v13.9.2.db" -NEW_DB = "PPMTool.db" - -def connect(path): - conn = sqlite3.connect(path) - conn.row_factory = sqlite3.Row - return conn - - -def assert_count(old, new, table, where_old=None, where_new=None): - wo = f" WHERE {where_old}" if where_old else "" - wn = f" WHERE {where_new}" if where_new else "" - - c_old = old.execute(f"SELECT COUNT(*) FROM {table}{wo}").fetchone()[0] - c_new = new.execute(f"SELECT COUNT(*) FROM {table}{wn}").fetchone()[0] - - # Skip assertion if source table was empty - if c_old == 0: - print(f"Skipping count check for {table} (no rows in old DB)") - return - - if c_old != c_new: - raise RuntimeError( - f"Row count mismatch for {table}: old={c_old}, new={c_new}" - ) - -def clear_table_if_needed(old, new, table, where=None): - w = f" WHERE {where}" if where else "" - - c_old = old.execute(f"SELECT COUNT(*) FROM {table}{w}").fetchone()[0] - - if c_old > 0: - print(f"🧹 Clearing {table} in new DB (old has {c_old} rows)") - new.execute(f"DELETE FROM {table}") - return True - else: - print(f"Skipping {table} (no rows in old DB)") - return False - -# ───────────────────────── -# SIMPLE 1:1 TABLES -# ───────────────────────── - -def migrate_simple(old, new, table, columns, where=None): - cols = ", ".join(columns) - w = f" WHERE {where}" if where else "" - - if not clear_table_if_needed(old, new, table, where): - return - - sql = f""" - INSERT INTO {table} ({cols}) - SELECT {cols} - FROM olddb.{table}{w} - """ - print(sql.strip()) - - new.execute(sql) - - # safe: assert_count already skips when old == 0 - assert_count(old, new, table, where_old=where, where_new=where) - - -# ───────────────────────── -# MIGRATIONS -# ───────────────────────── - -def migrate_faculties(old, new): - migrate_simple(old, new, "Faculties", - ["FacultyId", "Name", "Code", "IsActive"]) - -def migrate_schools(old, new): - migrate_simple(old, new, "Schools", - ["SchoolId", "FacultyId", "Name", "Code", "IsActive"]) - -def migrate_features(old, new): - migrate_simple(old, new, "Features", - ["FeatureId", "FeatureType", "Name", "Description", - "Enabled", "MustAlwaysBeEnabled"]) - -def migrate_financial_references(old, new): - migrate_simple(old, new, "FinancialReferences", - ["FinancialReferenceId", "FinancialYear", - "Grade41Costs", "Grade51Costs", "Grade55Costs", - "Grade65Costs", "Grade71Costs", "Grade75Costs", - "RecoveryTarget"]) - -def migrate_innate_codes(old, new): - migrate_simple(old, new, "InnateCodes", - ["InnateCodeId", "ActivityCode", - "ActivityName", "IsActive", "IsSensitive"]) - -def migrate_competencies(old, new): - migrate_simple(old, new, "Competencies", - ["CompetencyId", "Description", "Objective", "Grade", - "Category", "Revision", "CreatedDate", - "RevisionDate", "IsActive", "LegacyId", "Number"]) - -def migrate_skill_tags(old, new): - migrate_simple(old, new, "SkillTags", - ["SkillTagId", "Name", "ControlledName", - "HasValidWikiLink", "Rareness", "RarenessCount"]) - -def migrate_people(old, new): - - if not clear_table_if_needed(old, new, "People"): - return - - rows = [] - for r in old.execute("SELECT * FROM People"): - short = r["ShortName"] or r["Name"].split()[0] - rows.append(( - r["PersonId"], r["Name"], short, - r["StartDate"], r["EndDate"], - r["FTE"], r["LineManagerPersonId"], - r["TimesheetTemplateData"] - )) - new.executemany(""" - INSERT INTO People ( - PersonId, Name, ShortName, - StartDate, EndDate, - FTE, LineManagerPersonId, TimesheetTemplateData - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, rows) - assert_count(old, new, "People") - -def migrate_users(old, new): - - if not clear_table_if_needed(old, new, "Users"): - return - - rows = [] - for r in old.execute("SELECT * FROM Users"): - email = r["EmailAddress"] or f"{r['CASUserName']}@invalid.local" - rows.append(( - r["UserId"], r["RoleType"], r["CASUserName"], - r["Name"], r["PersonId"], - r["LastLoggedIn"], email - )) - new.executemany(""" - INSERT INTO Users ( - UserId, RoleType, CASUserName, - Name, PersonId, LastLoggedIn, EmailAddress - ) VALUES (?, ?, ?, ?, ?, ?, ?) - """, rows) - assert_count(old, new, "Users") - -def migrate_projects(old, new): - migrate_simple(old, new, "Projects", - ["ProjectId", "RTP", "PI", "SchoolId", - "ProjectManagerPersonId", "Budget", "DayRate", - "CostModel", "ProjectStatus", "InnateActivityInnateCodeId", - "Description", "ScrumProjectLink", "RequestDocLink", - "PlannedLeadershipCosts", "ActualLeadershipCosts", - "BudgetedIndirects", "ActualsLastUpdated", - "PlannedWorkHours", "ActualWorkHours", - "PlannedCost", "ActualCost", - "PlannedIndirectCost", "ActualIndirectCost", - "Name", "StartDate", "EndDate"]) - -def migrate_subtasks(old, new): - migrate_simple(old, new, "SubTasks", - ["SubTaskId", "TaskType", "PredecessorSubTaskId", - "HasFixedStart", "HasFixedEndDate", - "Demand", "OriginalDemand", "UnmetDemand", - "Lag", "OwningProjectProjectId", "IsLeadershipTask", - "PlannedWorkHours", "ActualWorkHours", - "PlannedCost", "ActualCost", - "PlannedIndirectCost", "ActualIndirectCost", - "Name", "StartDate", "EndDate", - "DurationDays", "DurationBillableDays"], - where="OwningProjectProjectId IS NOT NULL") - -def migrate_innate_code_tasks(old, new): - migrate_simple(old, new, "InnateCodeTasks", - ["InnateCodeTaskId", "TaskName", "Duty", "InnateCodeId"]) - -def migrate_timesheets(old, new): - migrate_simple(old, new, "Timesheets", - ["TimesheetId", "OwnerId", "CreatedDate", - "StartDate", "Info", "Status", - "DateStatusChanged", "StatusChangedByPersonId"]) - -def migrate_timesheet_entries(old, new): - migrate_simple(old, new, "TimesheetEntries", - ["TimesheetEntryId", "TimesheetId", "InnateCodeTaskId", - "MondayHours", "TuesdayHours", "WednesdayHours", - "ThursdayHours", "FridayHours", - "SaturdayHours", "SundayHours"]) - -def migrate_absence(old, new): - migrate_simple(old, new, "Absence", - ["AbsenceId", "StartDate", "EndDate", "PersonId"], - where="PersonId IS NOT NULL") - -def migrate_workload(old, new): - migrate_simple(old, new, "WorkloadModelChanges", - ["WorkloadModelChangeId", "Grade", "ChangeDate", - "ProjectWorkFTE", "BusinessAsUsualFTE", - "PersonalDevelopmentFTE", "StaffManagementFTE", - "ProjectAndServiceManagementFTE", "ArchitectureFTE", - "ServiceManagementFTE", "ProjectManagementFTE", - "Notes", "PersonId"], - where="PersonId IS NOT NULL") - -def migrate_owned_skills(old, new): - - if not clear_table_if_needed(old, new, "OwnedSkills"): - return - - rows = [] - for r in old.execute("SELECT * FROM OwnedSkills"): - rows.append(( - r["OwnedSkillId"], r["OwnerPersonId"], r["SkillTagId"], - r["LastUsed"] or "0001-01-01 00:00:00", - r["Proficiency"] or 0, - r["FavouriteSkill"] or 0 - )) - new.executemany(""" - INSERT INTO OwnedSkills ( - OwnedSkillId, OwnerPersonId, SkillTagId, - LastUsed, Proficiency, FavouriteSkill - ) VALUES (?, ?, ?, ?, ?, ?) - """, rows) - assert_count(old, new, "OwnedSkills") - -def migrate_funding_sources(old, new): - migrate_simple(old, new, "FundingSources", - ["FundingSourceId", "FundingSourceType", "HasAccountCode", - "AccountCode", "Description", "AmountAvailable", - "ProjectId"]) - -def migrate_invoices(old, new): - migrate_simple(old, new, "Invoices", - ["InvoiceId", "InvoiceReference", "Status", - "InvoiceUrl", "ProjectId", "KeyDate", - "Value", "Description"]) - -def migrate_payments(old, new): - migrate_simple(old, new, "Payments", - ["PaymentId", "InvoiceId", "SourceFundingSourceId", - "ProjectId", "KeyDate", "Value", "Description"]) - -def migrate_notes(old, new): - migrate_simple(old, new, "Notes", - ["NoteId", "HtmlContent", "AuthorUserId", - "ProjectId", "CreatedDate", "EditedDate", - "EditorUserId", "IsFinanceInfo", - "DueDate", "CompletedDate"]) - -def migrate_person_project(old, new): - migrate_simple(old, new, "PersonProject", - ["FollowedProjectsProjectId", "FollowersPersonId"]) - -def migrate_api_keys(old, new): - migrate_simple(old, new, "ApiKeys", - ["ApiKeyId", "OwnerUserId", "Key", - "Description", "Active", "ExpiresAt"]) - -def migrate_competency_assessments(old, new): - migrate_simple(old, new, "CompetencyAssessments", - ["CompetencyAssessmentId", "Evidence", "DateCreated", - "Status", "CompetencyRevision", - "CompetencyDescription", "CompetencyObjective", - "CompetencyId", "PersonId"]) - -def migrate_resources(old, new): - valid_subtask_ids = { - row["SubTaskId"] - for row in new.execute("SELECT SubTaskId FROM SubTasks") - } - - if not clear_table_if_needed(old, new, "Resources"): - return - - rows = [] - for r in old.execute("SELECT * FROM Resources"): - if r["SubTaskId"] in valid_subtask_ids: - rows.append(( - r["ResourceId"], r["PersonId"], r["DayRate"], - r["AssignmentFTE"], r["BilledFTE"] or 0, - r["IsProvisional"], r["UseProjectDayRate"], - r["FundedFromFundingSourceId"], - r["SubTaskId"], - r["PlannedWorkHours"], r["ActualWorkHours"], - r["PlannedCost"], r["ActualCost"], - r["PlannedIndirectCost"], r["ActualIndirectCost"] - )) - new.executemany(""" - INSERT INTO Resources ( - ResourceId, PersonId, DayRate, - AssignmentFTE, BilledFTE, - IsProvisional, UseProjectDayRate, - FundedFromFundingSourceId, - SubTaskId, - PlannedWorkHours, ActualWorkHours, - PlannedCost, ActualCost, - PlannedIndirectCost, ActualIndirectCost - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, rows) - new_count = new.execute("SELECT COUNT(*) FROM Resources").fetchone()[0] - assert len(rows) == new_count, ( - f"Resources count mismatch: expected {len(rows)}, got {new_count}" - ) - -def migrate_skill_tag_subtask(old, new): - valid_subtask_ids = { - row["SubTaskId"] - for row in new.execute("SELECT SubTaskId FROM SubTasks") - } - - if not clear_table_if_needed(old, new, "SkillTagSubTask"): - return - - rows = [] - for r in old.execute(""" - SELECT SkillsRequiredSkillTagId, TasksNeedingThisSkillSubTaskId - FROM SkillTagSubTask - """): - if r["TasksNeedingThisSkillSubTaskId"] in valid_subtask_ids: - rows.append(( - r["SkillsRequiredSkillTagId"], - r["TasksNeedingThisSkillSubTaskId"] - )) - - new.executemany(""" - INSERT INTO SkillTagSubTask ( - SkillsRequiredSkillTagId, TasksNeedingThisSkillSubTaskId - ) VALUES (?, ?) - """, rows) - - new_count = new.execute("SELECT COUNT(*) FROM SkillTagSubTask").fetchone()[0] - assert len(rows) == new_count, ( - f"SkillTagSubTask count mismatch: expected {len(rows)}, got {new_count}" - ) - - -def old_table_exists(old, table_name): - """Return True when the source database contains the specified table.""" - if not isinstance(table_name, str): - raise TypeError("table_name must be a string") - - row = old.execute(""" - SELECT 1 - FROM sqlite_master - WHERE type = 'table' - AND name = ? - """, (table_name,)).fetchone() - - return row is not None - - -# ───────────────────────── -# MAIN -# ───────────────────────── - -def main(): - old = connect(OLD_DB) - new = connect(NEW_DB) - - try: - new.execute("PRAGMA foreign_keys = OFF") - new.execute("ATTACH DATABASE ? AS olddb", (OLD_DB,)) - new.execute("BEGIN") - - migrate_faculties(old, new) - migrate_schools(old, new) - if old_table_exists(old, "Features"): - migrate_features(old, new) - else: - print("⚠️ Features table missing in old DB – skipping features migration") - migrate_financial_references(old, new) - migrate_innate_codes(old, new) - migrate_competencies(old, new) - migrate_skill_tags(old, new) - migrate_people(old, new) - migrate_users(old, new) - migrate_projects(old, new) - migrate_subtasks(old, new) - migrate_innate_code_tasks(old, new) - migrate_timesheets(old, new) - migrate_timesheet_entries(old, new) - migrate_absence(old, new) - migrate_workload(old, new) - migrate_owned_skills(old, new) - migrate_funding_sources(old, new) - migrate_invoices(old, new) - migrate_payments(old, new) - migrate_notes(old, new) - migrate_person_project(old, new) - migrate_api_keys(old, new) - migrate_competency_assessments(old, new) - migrate_resources(old, new) - migrate_skill_tag_subtask(old, new) - - fk_violations = new.execute("PRAGMA foreign_key_check").fetchall() - if fk_violations: - violation = fk_violations[0] - raise RuntimeError( - "Foreign key violations detected after migration, " - f"first violation: table={violation[0]}, rowid={violation[1]}, " - f"parent={violation[2]}, fk_index={violation[3]}" - ) - - new.commit() - print("✅ FULL migration completed successfully") - - except Exception: - new.rollback() - raise - finally: - new.execute("PRAGMA foreign_keys = ON") - old.close() - new.close() - - -if __name__ == "__main__": - main() diff --git a/migration1314/readme b/migration1314/readme deleted file mode 100644 index a0da9429..00000000 --- a/migration1314/readme +++ /dev/null @@ -1,4 +0,0 @@ -This is a temporary folder to aid the migration of the application from the closed-source v1.13.X to the open source v1.14.0 where the migrations are squashed into a single migration -- Step 1: Delete any old DB with NEW_DB filename specified in the script. -- Step 2: Run the app to create a blank DB with the InitialCreate migration run and inserted into the migrations history table. -- Step 3: Run the script with python to copy all the data from the v13 DB to the new one. \ No newline at end of file diff --git a/migration1314/readme.license b/migration1314/readme.license deleted file mode 100644 index ef401e00..00000000 --- a/migration1314/readme.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: 2026 University of Manchester - -SPDX-License-Identifier: apache-2.0 From 9f8a828c4a815defde77bf439981f45005c76da0 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 28 May 2026 10:59:37 +0000 Subject: [PATCH 013/133] Add License and Copyright Headers --- CODEOWNERS | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index d5a11c8b..7c592ea8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1,6 @@ +# SPDX-FileCopyrightText: 2026 University of Manchester +# +# SPDX-License-Identifier: apache-2.0 + # Everything * @aharwood2 @PhilBradbury @sjmf @philiptharrison @gdonval \ No newline at end of file From 01d0cac94d745fd13abdbc6bb21f24e8d4ef3d97 Mon Sep 17 00:00:00 2001 From: Adrian Harwood Date: Thu, 28 May 2026 14:40:49 +0100 Subject: [PATCH 014/133] Apply suggestions from code review Fixed typos Co-authored-by: Phil Bradbury --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 633a9373..84434f10 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ This is tool initially started as a basic project and portfolio management (PPM) > The [dRTP Operational Management Community Practice (dRTP-OMCoP)](https://uomresearchit.github.io/DRTP-Op-Man-CoP-Website/) are responsible for its strategic direction. We’d love to hear if you are using this tool. If you would like to be part of dRTP-OMCoP then please let us know! ## User Accounts and Access -The app suports integration with CAS / Shibboleth as well as Azure AD / Entra with access to restricted parts of the app managed within the app using a Role-Based Access Control (RBAC) database table. Super-users are able to manage user roles and access via the "Manage Access" page. +The app supports integration with CAS / Shibboleth as well as Azure AD / Entra with access to restricted parts of the app managed within the app using a Role-Based Access Control (RBAC) database table. Super-users are able to manage user roles and access via the "Manage Access" page. -If the app is run in the "Local" solution configuration then third-party authentication is disabled and instead, users can select any name from a dropdown list to log in as any user from the header bar -- this mode is intended for debugging or demos. +If the app is run in the "Local" solution configuration then third-party authentication is disabled. Users can then select any name from the dropdown list in the header bar to log in as that person and role. This mode is intended for debugging or demos. ## API Access Any user of the web application can gain access to the API endpoints. Note that their success in using the endpoints is dictated by their role in the web app RBAC database table. To access the API, users need to generate an API key from the "Developer Settings" in the web app under the "Developer Settings". The successful generation of an API key in the web app depends on a suitable secret (minimum 32 characters) being injected into the `Jwt:SecretKey` configuration parameter for the web application. This secret parameter can be injected via an environment variable named `API_KEY_SECRET`, or during development if using Visual Studio, this can be done by simply opening "Manage User Secrets" for the project and adding `"Jwt:SecretKey" : "some-32-char-long-value"` to the .NET secrets manager. @@ -34,7 +34,7 @@ Documentation of some of the features and how to use them is available in the Wi The software can be cloned with the usual `git clone` command. However, depending on the version checked out, it may contain submodules which can be initialised as part of the initial clone or as a separate step after the fact with `git submodule update --init --recursive`. > [!TIP] -> By defualt, cloning the repository will checkout the `dev` branch. If you simply want to deploy a particular release then you can checkout the relevant tagged commit using `git checkout ` e.g. `git checkout Release_v1.14.0` then follow the instructions below to spin-up the container with Docker Compose below. +> By default, cloning the repository will checkout the `dev` branch. If you simply want to deploy a particular release then you can checkout the relevant tagged commit using `git checkout ` e.g. `git checkout Release_v1.14.0` then follow the instructions below to spin-up the container with Docker Compose below. ### Database Connection The database connection string needs to be specified in a `CONNECTION_STRING` environment variable. During development in Visual Studio, See the `.env.sample` file for example connection strings. Note that this is also required at "design-time" when running EF Core tools to update the database. The connection string for `LEAVEBOOKINGS_CONNECTION_STRING=` is only relevant to The University of Manchester. If using CapX elsewhere, this can simply a be a dummy string as long as it is not blank. During development, User Secrets can be used to override the blank values specified in the `appsettings.json`. From a21b0455fb56b299db522f6d7db4195d272b8a2e Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Thu, 28 May 2026 15:09:55 +0100 Subject: [PATCH 015/133] Removed serilog --- PPMTool/PPMTool.csproj | 4 ---- PPMTool/Program.cs | 16 ++++------------ 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/PPMTool/PPMTool.csproj b/PPMTool/PPMTool.csproj index 4a9bcee3..e9767a63 100644 --- a/PPMTool/PPMTool.csproj +++ b/PPMTool/PPMTool.csproj @@ -48,10 +48,6 @@ SPDX-License-Identifier: apache-2.0 - - - - diff --git a/PPMTool/Program.cs b/PPMTool/Program.cs index 13b02194..edb877a6 100644 --- a/PPMTool/Program.cs +++ b/PPMTool/Program.cs @@ -30,25 +30,17 @@ #if RELEASE using GSS.Authentication.CAS.AspNetCore; using GSS.Authentication.CAS.Validation; -using Serilog; #endif // Add environment variables to the configuration var builder = WebApplication.CreateBuilder(args); EnvironmentHelper.LoadEnvironmentVariables(builder); -#if RELEASE -// Configure logging -builder.Logging.AddSerilog(new LoggerConfiguration() - .WriteTo.Console() - .WriteTo.File( - path: builder.Configuration.GetValue("LogPath"), - rollingInterval: RollingInterval.Day, - retainedFileCountLimit: null, - retainedFileTimeLimit: TimeSpan.FromDays(30) - ) -.CreateLogger()); +// Configure logging -- use console logging only +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); +#if RELEASE // Configure Sentry SentrySdk.Init(o => { From 14883944ba519be3eb58906bf9eff2acd037050f Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Thu, 28 May 2026 15:10:05 +0100 Subject: [PATCH 016/133] Setup production errors only --- PPMTool/appsettings.Production.json | 1 + 1 file changed, 1 insertion(+) diff --git a/PPMTool/appsettings.Production.json b/PPMTool/appsettings.Production.json index c2816a08..7fbc1db4 100644 --- a/PPMTool/appsettings.Production.json +++ b/PPMTool/appsettings.Production.json @@ -4,6 +4,7 @@ "LogLevel": { "Default": "Warning", "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", "Microsoft.EntityFrameworkCore.Database.Command": "Error" } } From cf64a8e7e8b0c778f42708605e954262daa2afa8 Mon Sep 17 00:00:00 2001 From: "Donal K. Fellows" Date: Thu, 28 May 2026 15:21:43 +0100 Subject: [PATCH 017/133] Fix suggestions and tidy up triggers --- .github/workflows/docker-images-build-health-check.yml | 3 +-- .github/workflows/dotnet.yml | 5 ++--- .github/workflows/license-copyright-add.yml | 3 +-- .github/workflows/naming-conventions.yml | 2 +- .github/workflows/run-zizmor.yml | 3 +-- 5 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/docker-images-build-health-check.yml b/.github/workflows/docker-images-build-health-check.yml index 64ad0271..88967444 100644 --- a/.github/workflows/docker-images-build-health-check.yml +++ b/.github/workflows/docker-images-build-health-check.yml @@ -2,8 +2,7 @@ name: Docker Images Build and Health Check on: push: - branches: - - dev + branches: [dev] pull_request: permissions: diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index e1ccae91..74fe36ed 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -5,9 +5,8 @@ name: C# .NET on: push: - branches: ["dev"] + branches: [dev] pull_request: - branches: ["dev"] jobs: format-check: @@ -43,4 +42,4 @@ jobs: dotnet-version: 10.0.x - name: Format - run: dotnet format PPMTool.sln --exclude ../Blazor-ApexCharts/src/Blazor-ApexCharts.csproj --verify-no-changes --no-restore --verbosity detailed + run: dotnet format PPMTool.sln --verify-no-changes --no-restore --verbosity detailed diff --git a/.github/workflows/license-copyright-add.yml b/.github/workflows/license-copyright-add.yml index 87f8498d..258d226d 100644 --- a/.github/workflows/license-copyright-add.yml +++ b/.github/workflows/license-copyright-add.yml @@ -4,8 +4,7 @@ name: Add Licence and Copyright Headers -on: - pull_request +on: pull_request jobs: add-headers: diff --git a/.github/workflows/naming-conventions.yml b/.github/workflows/naming-conventions.yml index f0d508da..9649c007 100644 --- a/.github/workflows/naming-conventions.yml +++ b/.github/workflows/naming-conventions.yml @@ -37,7 +37,7 @@ jobs: - name: Check branch name run: | - if [[ ! "$BRANCH_NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + if [[ ! "$BRANCH_NAME" =~ ^(copilot/)?[a-z0-9]+(-[a-z0-9]+)*$ ]]; then echo "Branch name '$BRANCH_NAME' does not follow the naming convention established in the RSE Agile Method Handbook." exit 1 fi diff --git a/.github/workflows/run-zizmor.yml b/.github/workflows/run-zizmor.yml index 444294e3..263ed7e5 100644 --- a/.github/workflows/run-zizmor.yml +++ b/.github/workflows/run-zizmor.yml @@ -16,9 +16,8 @@ name: GitHub Actions Security Analysis with zizmor 🌈 on: push: - branches: ["dev"] + branches: [dev] pull_request: - branches: ["**"] permissions: {} From 8ed2d0db67e4ed5010105bf32bfe518d26a07c8d Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Thu, 28 May 2026 20:39:06 +0100 Subject: [PATCH 018/133] Some more tweaks --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 84434f10..058723b9 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ If the app is run in the "Local" solution configuration then third-party authent Any user of the web application can gain access to the API endpoints. Note that their success in using the endpoints is dictated by their role in the web app RBAC database table. To access the API, users need to generate an API key from the "Developer Settings" in the web app under the "Developer Settings". The successful generation of an API key in the web app depends on a suitable secret (minimum 32 characters) being injected into the `Jwt:SecretKey` configuration parameter for the web application. This secret parameter can be injected via an environment variable named `API_KEY_SECRET`, or during development if using Visual Studio, this can be done by simply opening "Manage User Secrets" for the project and adding `"Jwt:SecretKey" : "some-32-char-long-value"` to the .NET secrets manager. ## Deployment -Deployment scripts as well as reverse proxy configurations and sample environment variable files are provided in the `deployment` directory. These scripts can be used with cron to long-poll GitHub for changes to the relevant branch, kicking off a re-build of the container as appropriate. The development deploy script additionally copies the database from the production VM prior to applying migrations to ensure the development version is tested on real data. The production database is also backed-up as part of the deployment process in case of failure. Any time a DB file is to be copied, all the `PPMTool.db*` files are copied since WAL is enabled. `sqlite3 PPMTool.db VACUUM;` is used to flush the WAL journals before any manipulation of the DB takes place. +Deployment scripts, as well as reverse proxy configurations and sample environment variable files, are provided in the `deployment` directory. These scripts can be used with cron to long-poll GitHub for changes to the relevant branch, kicking off a re-build of the container as appropriate. The development deploy script additionally copies the database from the production VM prior to applying migrations to ensure the development version is tested on real data. The production database is also backed-up as part of the deployment process in case of failure. Any time a DB file is to be copied, all the `PPMTool.db*` files are copied since WAL is enabled. `sqlite3 PPMTool.db VACUUM;` is used to flush the WAL journals before any manipulation of the DB takes place. > [!WARNING] > The app will fail to start if it detects that required variables are not set. Exception details will be written to the log files in the `Logs` folder as well as `syslog` or `docker log`. @@ -37,7 +37,7 @@ The software can be cloned with the usual `git clone` command. However, dependin > By default, cloning the repository will checkout the `dev` branch. If you simply want to deploy a particular release then you can checkout the relevant tagged commit using `git checkout ` e.g. `git checkout Release_v1.14.0` then follow the instructions below to spin-up the container with Docker Compose below. ### Database Connection -The database connection string needs to be specified in a `CONNECTION_STRING` environment variable. During development in Visual Studio, See the `.env.sample` file for example connection strings. Note that this is also required at "design-time" when running EF Core tools to update the database. The connection string for `LEAVEBOOKINGS_CONNECTION_STRING=` is only relevant to The University of Manchester. If using CapX elsewhere, this can simply a be a dummy string as long as it is not blank. During development, User Secrets can be used to override the blank values specified in the `appsettings.json`. +The database connection string needs to be specified in a `CONNECTION_STRING` environment variable. See the `.env.sample` file for example connection strings. Note that this is also required at "design-time" when running EF Core tools to update the database. The connection string for `LEAVEBOOKINGS_CONNECTION_STRING=` is only relevant to The University of Manchester. If using CapX elsewhere, this can simply a be a dummy string as long as it is not blank. During development, User Secrets can be used to override the blank values specified in the `appsettings.json`. ### Seeding the Database The app will run migrations every time it starts. If the connection string permits it, it will therefore create a blank database on first run. Furthermore, a single super user will be added to allow you to login. A blank database limits the ability to test new features or to demo the software without first adding records to the blank tables through the UI which takes time. To faciltate better testing, developers can set the `SEED_DUMMY_DATA` environment variable to "TRUE" (case insensitive) to have the software populate all the empty tables with dummy data on start-up. @@ -46,7 +46,7 @@ The app will run migrations every time it starts. If the connection string permi > This feature overwrites all data in the tables as soon as the app starts! ### Database Migrations -As CapX supports multiple DB providers, the migrations required to set up the database and its tables are specific to the provider. Each supported provider has its own `.Migrations` project containing the relevant migrations for ensuring the DB is aligned to the models in the code. To add a new migration for a particular provider, set the `DB_PROVIDER` variable in the environment appropraitely then run following command from the root directory to invoke the EF Core Tools. For example, to add a migration for SQLite, set `DB_PROVIDER=sqlite` (typically using User Secrets if developing with Visual Studio or VS Code): +As CapX supports multiple DB providers, the migrations required to set up the database and its tables are specific to the provider. Each supported provider has its own `.Migrations` project containing the relevant migrations for ensuring the DB is aligned to the models in the code. To add a new migration for a particular provider, set the `DB_PROVIDER` variable in the environment appropriately then run following command from the root directory to invoke the EF Core Tools. For example, to add a migration for SQLite, set `DB_PROVIDER=sqlite` (typically using User Secrets if developing with Visual Studio or VS Code): ``` dotnet ef migrations add NameOfMigrationHere --context PPMToolContext --project PPMTool.Migrations.Sqlite/PPMTool.Migrations.Sqlite.csproj --startup-project PPMTool/PPMTool.csproj From f8485a6275e73bad7271de571c9af6ab05a255f2 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Thu, 28 May 2026 21:00:58 +0100 Subject: [PATCH 019/133] Changed send email to just focus on one task, one email. --- PPMTool/Services/EmailService.cs | 37 +++++++++++++++++++------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/PPMTool/Services/EmailService.cs b/PPMTool/Services/EmailService.cs index 730f8208..08c89dcc 100644 --- a/PPMTool/Services/EmailService.cs +++ b/PPMTool/Services/EmailService.cs @@ -43,12 +43,12 @@ ILogger logger public ILogger Logger { get; } /// - /// Send an email to the list of recipients provided. + /// Send an email to the recipient provided. /// /// /// /// - public void SendEmail(IEnumerable to, string subject, string message) + public void SendEmail(string to, string subject, string message) { var mailMessage = new MailMessage @@ -58,13 +58,9 @@ public void SendEmail(IEnumerable to, string subject, string message) Body = message, IsBodyHtml = true, }; + mailMessage.To.Add(to); - foreach (var recipient in to.Distinct()) - { - mailMessage.To.Add(recipient); - } - - Logger.LogInformation($"Sending email to {string.Join(',', mailMessage.To)}, subject {mailMessage.Subject}"); + Logger.LogInformation($"Sending email to {mailMessage.To}, subject {mailMessage.Subject}"); #if RELEASE // Launch a background task to do the sending @@ -78,7 +74,7 @@ public void SendEmail(IEnumerable to, string subject, string message) } catch (Exception e) { - Logger.LogError($"Failed to send email to {string.Join(',', mailMessage.To)}, subject {mailMessage.Subject}:\n{e}"); + Logger.LogError($"Failed to send email to {mailMessage.To}, subject {mailMessage.Subject}:\n{e}"); } }); #endif @@ -94,7 +90,7 @@ public async Task SendTimesheetSubmissionEmailNotificationAsync(Person staff, Ti List recipients = new List(); // Run a background thread to do the sending and updating - await Task.Run(() => + await Task.Run(async () => { try { @@ -128,7 +124,11 @@ await Task.Run(() => // Send email Debug.WriteLine($"** Sending Timesheet Submission email to {string.Join("|", recipients)}"); - SendEmail(recipients, subject, body.ToString()); + foreach (var recipient in recipients) + { + SendEmail(recipient, subject, body.ToString()); + await Task.Delay(1000); + } } } } @@ -310,9 +310,13 @@ await Task.Run(async () => } recipients.AddRange(lineManagerEmailAddresses); } + Debug.WriteLine($"** Sending email to {string.Join(',', recipients)}"); - SendEmail(recipients, subject, body.ToString()); - await Task.Delay(1000); + foreach (var recipient in recipients) + { + SendEmail(recipient, subject, body.ToString()); + await Task.Delay(1000); + } } } } @@ -470,8 +474,11 @@ await Task.Run(async () => recipients.AddRange(lineManagerEmailAddresses); } Debug.WriteLine($"** Sending email to {string.Join(',', recipients)}"); - SendEmail(recipients, subject, body.ToString()); - await Task.Delay(1000); + foreach (var recipient in recipients) + { + SendEmail(recipient, subject, body.ToString()); + await Task.Delay(1000); + } } } } From 45030c57226f418d2cb575a4d5a3eced7ed38d10 Mon Sep 17 00:00:00 2001 From: Samantha Finnigan <1038320+sjmf@users.noreply.github.com> Date: Sat, 30 May 2026 01:26:40 +0100 Subject: [PATCH 020/133] fix: adjust date handling to use DateTime.Today for project status checks and `SpecifyKind` for API key expiration (towards #1340) --- PPMTool/Pages/Components/ApiKeyConfigComponent.razor | 6 +++--- PPMTool/Services/InnateCodeService.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/PPMTool/Pages/Components/ApiKeyConfigComponent.razor b/PPMTool/Pages/Components/ApiKeyConfigComponent.razor index c25f8d24..c0be2596 100644 --- a/PPMTool/Pages/Components/ApiKeyConfigComponent.razor +++ b/PPMTool/Pages/Components/ApiKeyConfigComponent.razor @@ -92,9 +92,9 @@ SPDX-License-Identifier: apache-2.0 { Active = true, Owner = ActiveUser, - ExpiresAt = DateTime.UtcNow.AddDays( + ExpiresAt = DateTime.SpecifyKind(DateTime.UtcNow.AddDays( (default(ApiKeyExpirationOptions)).GetAttribute() - .Days) + .Days), DateTimeKind.Unspecified) }; // Statuses for the dropdown @@ -173,7 +173,7 @@ SPDX-License-Identifier: apache-2.0 private void ValidityChosen(object value) { var days = ((ApiKeyExpirationOptions)value).GetAttribute().Days; - apiKey.ExpiresAt = DateTime.UtcNow.AddDays(days); + apiKey.ExpiresAt = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(days), DateTimeKind.Unspecified); Debug.WriteLine($"Expiry set to {apiKey.ExpiresAt.ToString("R")} => {days} Days"); } } \ No newline at end of file diff --git a/PPMTool/Services/InnateCodeService.cs b/PPMTool/Services/InnateCodeService.cs index 7dc26965..c20f6731 100644 --- a/PPMTool/Services/InnateCodeService.cs +++ b/PPMTool/Services/InnateCodeService.cs @@ -152,7 +152,7 @@ public async Task> GetCodesToDeactivateAsync(PPMTo // Project is cancelled or finished more than 28 days ago ((int)p.ProjectStatus > (int)ProjectStatus.Finished || - (p.ProjectStatus == ProjectStatus.Finished && p.EndDate <= DateTime.UtcNow.AddDays(-28))) + (p.ProjectStatus == ProjectStatus.Finished && p.EndDate <= DateTime.Today.AddDays(-28))) ) ) .ToListAsync(); From 556e16b87efdfd9f3b69da6d1e2a02bc26517e58 Mon Sep 17 00:00:00 2001 From: Samantha Finnigan <1038320+sjmf@users.noreply.github.com> Date: Sat, 30 May 2026 01:35:41 +0100 Subject: [PATCH 021/133] fix: update database operations to use asynchronous methods for PostgreSQL npgsql compatibility --- PPMTool/Program.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/PPMTool/Program.cs b/PPMTool/Program.cs index 13b02194..7f77c03e 100644 --- a/PPMTool/Program.cs +++ b/PPMTool/Program.cs @@ -365,11 +365,13 @@ // Delete existing DB if seeding to ensure a clean slate if (shouldSeed) { - context.Database.EnsureDeleted(); + await context.Database.EnsureDeletedAsync(); } - // Run migrations - context.Database.Migrate(); + // MigrateAsync rather than Migrate: Npgsql 10 does not support synchronous command + // execution (throws InvalidCastException: PoolingDataSource -> MultiplexingDataSource), + // so the synchronous overload silently fails on PostgreSQL, leaving the schema out of date. + await context.Database.MigrateAsync(); } // Seed the default superuser from the settings if it doesn't already exist From 7a3cb7fbe7333479e94231638157b2cc475d06b2 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 30 May 2026 01:40:42 +0000 Subject: [PATCH 022/133] Add License and Copyright Headers --- .../20260527120344_FixDateTimeTimezone.Designer.cs | 6 +++++- .../Migrations/20260527120344_FixDateTimeTimezone.cs | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.Designer.cs b/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.Designer.cs index 6a8875ab..da6d3845 100644 --- a/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.Designer.cs +++ b/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.Designer.cs @@ -1,4 +1,8 @@ -// +// SPDX-FileCopyrightText: 2026 University of Manchester +// +// SPDX-License-Identifier: apache-2.0 + +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; diff --git a/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.cs b/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.cs index ce01f15e..6b89ae89 100644 --- a/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.cs +++ b/PPMTool.Migrations.PostgreSql/Migrations/20260527120344_FixDateTimeTimezone.cs @@ -1,4 +1,8 @@ -using System; +// SPDX-FileCopyrightText: 2026 University of Manchester +// +// SPDX-License-Identifier: apache-2.0 + +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable From 38f9e6e44ecb0f851b5efc0a7d507c5273bcc8db Mon Sep 17 00:00:00 2001 From: Samantha Finnigan <1038320+sjmf@users.noreply.github.com> Date: Sat, 30 May 2026 03:18:30 +0100 Subject: [PATCH 023/133] fix: retrieve contexts to Lists in SeedHelper before iteration to prevent `NpgsqlOperationInProgressException` on PostgreSQL. Iterating directly over DbSet inside a loop that issues further queries on the same context fails on PostgreSQL (no MARS support). Fixed by calling ToList() before the affected foreach loops in SeedOwnedSkillsForPeople, SeedCompetencyAssessments, and SeedTimesheets. --- PPMTool/Helpers/SeedHelper.cs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/PPMTool/Helpers/SeedHelper.cs b/PPMTool/Helpers/SeedHelper.cs index ae27f229..9360cf20 100644 --- a/PPMTool/Helpers/SeedHelper.cs +++ b/PPMTool/Helpers/SeedHelper.cs @@ -883,7 +883,9 @@ internal static void SeedOwnedSkillsForPeople(IServiceProvider serviceProvider) // For each person, add some random owned skills var random = new Random(); - foreach (var person in context.People) + var allPeople = context.People.ToList(); + var allSkillTags = context.SkillTags.ToList(); + foreach (var person in allPeople) { var skillIdsOwned = new List(); for (int i = 0; i < 5; ++i) @@ -892,10 +894,10 @@ internal static void SeedOwnedSkillsForPeople(IServiceProvider serviceProvider) var proficiencyRating = random.Next(Enum.GetValues().Count()); // Get random skill tag that is not already owned by this person - var skillTag = context.SkillTags.ElementAt(random.Next(context.SkillTags.Count())); + var skillTag = allSkillTags.ElementAt(random.Next(allSkillTags.Count)); while (skillIdsOwned.Contains(skillTag.SkillTagId)) { - skillTag = context.SkillTags.ElementAt(random.Next(context.SkillTags.Count())); + skillTag = allSkillTags.ElementAt(random.Next(allSkillTags.Count)); } // Add the skill tag to the list of owned skills @@ -919,7 +921,7 @@ internal static void SeedOwnedSkillsForPeople(IServiceProvider serviceProvider) var totalActivePeople = context.People .Where(x => x.StartDate <= DateTime.Today && (x.EndDate == null || x.EndDate >= DateTime.Today)) .Count(); - foreach (var skillTag in context.SkillTags) + foreach (var skillTag in allSkillTags) { var totalInstances = context.OwnedSkills.Include(x => x.SkillTag).Where(x => x.SkillTag.SkillTagId == skillTag.SkillTagId).Count(); skillTag.UpdateRareness(totalInstances, totalActivePeople); @@ -948,12 +950,14 @@ internal static void SeedCompetencyAssessments(IServiceProvider serviceProvider) // For each person, add assessments for half of the competencies var random = new Random(); - foreach (var person in context.People) + var allPeople = context.People.ToList(); + var allCompetencies = context.Competencies.ToList(); + foreach (var person in allPeople) { for (int i = 0; i < competencyCount / 2; ++i) { // Get a random competency - var competency = context.Competencies.ElementAt(random.Next(competencyCount)); + var competency = allCompetencies.ElementAt(random.Next(competencyCount)); // Create a new competency assessment var assessment = new CompetencyAssessment @@ -2434,7 +2438,8 @@ internal static void SeedTimesheets(IServiceProvider serviceProvider) logger.LogInformation("Seeding timesheets..."); using (var context = dbContextFactory.CreateDbContext()) { - foreach (var person in context.People.Include(x => x.WorkloadModelChanges)) + var allPeople = context.People.Include(x => x.WorkloadModelChanges).ToList(); + foreach (var person in allPeople) { // Set start and end dates to the nearest Monday var startDate = person.StartDate; From 2bd5d7f64c58ce348e270454cd473fe202dc1ded Mon Sep 17 00:00:00 2001 From: Samantha Finnigan <1038320+sjmf@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:54:03 +0100 Subject: [PATCH 024/133] Fix PgSQL MARS multi access in GetSkillsForProject by calling .ToList() --- PPMTool/Services/SkillTagService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PPMTool/Services/SkillTagService.cs b/PPMTool/Services/SkillTagService.cs index b48b81cf..996b3e63 100644 --- a/PPMTool/Services/SkillTagService.cs +++ b/PPMTool/Services/SkillTagService.cs @@ -160,7 +160,8 @@ public IEnumerable GetSkillsForProject(PPMToolContext context, int pro // Find all the subtasks for the project var subtasks = context.SubTasks .Include(x => x.OwningProject) - .Where(x => x.OwningProject.ProjectId == projectId); + .Where(x => x.OwningProject.ProjectId == projectId) + .ToList(); var skills = new List(); foreach (var subtask in subtasks) From 15bd29c71e7ab84b63f16c6c980ddfb96ee13d6f Mon Sep 17 00:00:00 2001 From: Samantha Finnigan <1038320+sjmf@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:56:16 +0100 Subject: [PATCH 025/133] fix: retrieve managed staff numbers as a list in TimesheetService (towards #1355) --- PPMTool/Services/TimesheetService.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/PPMTool/Services/TimesheetService.cs b/PPMTool/Services/TimesheetService.cs index e1822816..520042f0 100644 --- a/PPMTool/Services/TimesheetService.cs +++ b/PPMTool/Services/TimesheetService.cs @@ -319,8 +319,9 @@ public async Task GetIssueCountAsync(PPMToolContext context, int activeUser // Get line managed staff numbers (submitted timesheets) var peopleManaged = context.People - .Where(x => x.LineManager != null && x.LineManager.PersonId == activeUserId); - if (await peopleManaged.CountAsync() > 0) + .Where(x => x.LineManager != null && x.LineManager.PersonId == activeUserId) + .ToList(); + if (peopleManaged.Count > 0) { foreach (Person p in peopleManaged) { From 186608e447fc047aef6295b6bf81d3e898f9c241 Mon Sep 17 00:00:00 2001 From: Samantha Finnigan <1038320+sjmf@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:17:43 +0100 Subject: [PATCH 026/133] fix: resolve sync-over-async deadlock in SettingsService.GetAll Replaces GetAllSettingsAsync(context).Result with context.Settings.ToList(), fixing a Blazor Server circuit deadlock that caused /managesettings to always 504. Towards #1359. --- PPMTool/Services/SettingsService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PPMTool/Services/SettingsService.cs b/PPMTool/Services/SettingsService.cs index 74d942e1..6912870d 100644 --- a/PPMTool/Services/SettingsService.cs +++ b/PPMTool/Services/SettingsService.cs @@ -218,7 +218,7 @@ public override int Add(PPMToolContext context, Setting entity, bool commitChang /// public override IEnumerable GetAll(PPMToolContext context) { - return GetAllSettingsAsync(context).Result; + return context.Settings.ToList(); } /// From 2e211b377ecb06771284119545afce0959449dbd Mon Sep 17 00:00:00 2001 From: Samantha Finnigan <1038320+sjmf@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:41:38 +0100 Subject: [PATCH 027/133] fix: await service cache initialisation to prevent context disposal race (towards #1361) Replaces fire-and-forget _ = with await on both IntialiseServiceCacheAsync calls in the startup using block. On PostgreSQL this caused a hard crash (NpgsqlOperationInProgressException); on other providers the cache could be partially or fully uninitialised. --- PPMTool/Program.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PPMTool/Program.cs b/PPMTool/Program.cs index 13b02194..6b3595a6 100644 --- a/PPMTool/Program.cs +++ b/PPMTool/Program.cs @@ -413,9 +413,9 @@ using (var context = dbContextFactory.CreateDbContext()) { var settingsService = app.Services.GetRequiredService(); - _ = settingsService.IntialiseServiceCacheAsync(context); + await settingsService.IntialiseServiceCacheAsync(context); var featureService = app.Services.GetRequiredService(); - _ = featureService.IntialiseServiceCacheAsync(context); + await featureService.IntialiseServiceCacheAsync(context); } // Run the app From 57c1b5c043d7445b18091b26702da5f97f531544 Mon Sep 17 00:00:00 2001 From: PhilBradbury Date: Mon, 8 Jun 2026 15:32:51 +0100 Subject: [PATCH 028/133] Removed creating an email address using username@manchester.ac.uk if none already registered. If no email then don't send anything. --- PPMTool/Services/EmailService.cs | 46 +++++++++++++++++--------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/PPMTool/Services/EmailService.cs b/PPMTool/Services/EmailService.cs index 08c89dcc..8bbab55b 100644 --- a/PPMTool/Services/EmailService.cs +++ b/PPMTool/Services/EmailService.cs @@ -99,35 +99,37 @@ await Task.Run(async () => { Person lineManager = staff.LineManager; - if (lineManager != staff) // No point in AH emailing himself about his timesheet. :) + if (lineManager != staff) // No point in Superuser emailing themselves about their timesheet. :) { User lineManagerUser = UserService.GetAll(context).First(p => p.Person.PersonId == lineManager.PersonId); var lineManagerEmailAddresses = lineManagerUser.GetNormalisedEmailAddresses(); - if (!lineManagerEmailAddresses.Any()) + if (lineManagerEmailAddresses.Any()) { - lineManagerEmailAddresses.Add($"{lineManagerUser.CASUserName}@manchester.ac.uk"); - } - foreach (var lineManagerEmailAddress in lineManagerEmailAddresses) - { - recipients.Add(lineManagerEmailAddress); + foreach (var lineManagerEmailAddress in lineManagerEmailAddresses) + { + recipients.Add(lineManagerEmailAddress); + } } - // Create email - var subject = $"{Configuration["Email:TimesheetSubmissionEmailSubject"]}. {staff.ShortName} [{timesheet.StartDate.ToString("dd/MM/yy")}]"; - - StringBuilder body = new StringBuilder(); - body.Append($"

Dear {lineManager.Name},

"); - body.Append($"

{Configuration["Email:TimesheetSubmissionEmailBody"]} by {staff.Name} for the week commencing {timesheet.StartDate.ToString("dd/MM/yy")}.

"); - body.Append($"

{Configuration["Email:TimesheetSubmissionEmailEndBody"]}

"); - body.Append($"

Review this timesheet on CapX

"); - body.Append("

Sent from CapX

"); - - // Send email - Debug.WriteLine($"** Sending Timesheet Submission email to {string.Join("|", recipients)}"); - foreach (var recipient in recipients) + if (recipients.Any()) // Build the email and send it if there are any email addresses { - SendEmail(recipient, subject, body.ToString()); - await Task.Delay(1000); + // Create email + var subject = $"{Configuration["Email:TimesheetSubmissionEmailSubject"]}. {staff.ShortName} [{timesheet.StartDate.ToString("dd/MM/yy")}]"; + + StringBuilder body = new StringBuilder(); + body.Append($"

Dear {lineManager.Name},

"); + body.Append($"

{Configuration["Email:TimesheetSubmissionEmailBody"]} by {staff.Name} for the week commencing {timesheet.StartDate.ToString("dd/MM/yy")}.

"); + body.Append($"

{Configuration["Email:TimesheetSubmissionEmailEndBody"]}

"); + body.Append($"

Review this timesheet on CapX

"); + body.Append("

Sent from CapX

"); + + // Send email + Debug.WriteLine($"** Sending Timesheet Submission email to {string.Join("|", recipients)}"); + foreach (var recipient in recipients) + { + SendEmail(recipient, subject, body.ToString()); + await Task.Delay(1000); + } } } } From 160437d0449bf67941186e14f47f5dc4c5b50ae2 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Tue, 9 Jun 2026 10:38:47 +0100 Subject: [PATCH 029/133] Fixed some typos --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 058723b9..30dc763a 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ SPDX-License-Identifier: apache-2.0 --> # Capacity eXtended (CapX) -This is tool initially started as a basic project and portfolio management (PPM) tool. Its first feature was capacity management, but it has since been extended to incorporate a much larger, more complex data model useful for an increased number of operational management activities. Written in .NET Blazor (Server) with a SQLite database (other providers are supported), it is used for managing many aspects of the service delivery of the digital research technical professional departments and the development of its staff. +This tool initially started as a basic project and portfolio management (PPM) tool. Its first feature was capacity management, but it has since been extended to incorporate a much larger, more complex data model useful for an increased number of operational management activities. Written in .NET Blazor (Server) with a SQLite database (other providers are supported), it is used for managing many aspects of the service delivery of the digital research technical professional departments and the development of its staff. > [!IMPORTANT] -> The [dRTP Operational Management Community Practice (dRTP-OMCoP)](https://uomresearchit.github.io/DRTP-Op-Man-CoP-Website/) are responsible for its strategic direction. We’d love to hear if you are using this tool. If you would like to be part of dRTP-OMCoP then please let us know! +> The [dRTP Operational Management Peer Advice and Shared Support (dRTP COMPASS)](https://uomresearchit.github.io/DRTP-Op-Man-CoP-Website/) is responsible for its strategic direction. We’d love to hear if you are using this tool. If you would like to be part of dRTP-OMCoP then please let us know! ## User Accounts and Access The app supports integration with CAS / Shibboleth as well as Azure AD / Entra with access to restricted parts of the app managed within the app using a Role-Based Access Control (RBAC) database table. Super-users are able to manage user roles and access via the "Manage Access" page. From 6a6c56a01f2958e9a52436d817a2dcb1c5e9c5b1 Mon Sep 17 00:00:00 2001 From: Adrian Harwood Date: Tue, 9 Jun 2026 10:43:14 +0100 Subject: [PATCH 030/133] Update README.md Fixed typo Co-authored-by: Phil Bradbury --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 30dc763a..43991faf 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ The app will run migrations every time it starts. If the connection string permi > This feature overwrites all data in the tables as soon as the app starts! ### Database Migrations -As CapX supports multiple DB providers, the migrations required to set up the database and its tables are specific to the provider. Each supported provider has its own `.Migrations` project containing the relevant migrations for ensuring the DB is aligned to the models in the code. To add a new migration for a particular provider, set the `DB_PROVIDER` variable in the environment appropriately then run following command from the root directory to invoke the EF Core Tools. For example, to add a migration for SQLite, set `DB_PROVIDER=sqlite` (typically using User Secrets if developing with Visual Studio or VS Code): +As CapX supports multiple DB providers, the migrations required to set up the database and its tables are specific to the provider. Each supported provider has its own `.Migrations` project containing the relevant migrations for ensuring the DB is aligned to the models in the code. To add a new migration for a particular provider, set the `DB_PROVIDER` variable in the environment appropriately then run the following command from the root directory to invoke the EF Core Tools. For example, to add a migration for SQLite, set `DB_PROVIDER=sqlite` (typically using User Secrets if developing with Visual Studio or VS Code): ``` dotnet ef migrations add NameOfMigrationHere --context PPMToolContext --project PPMTool.Migrations.Sqlite/PPMTool.Migrations.Sqlite.csproj --startup-project PPMTool/PPMTool.csproj From 3a895ab1817cbd0d15eeabc6f461a00ed58343fb Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Tue, 9 Jun 2026 11:24:05 +0100 Subject: [PATCH 031/133] Fixed regex in the rule --- .github/workflows/naming-conventions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/naming-conventions.yml b/.github/workflows/naming-conventions.yml index 9649c007..da5a63ad 100644 --- a/.github/workflows/naming-conventions.yml +++ b/.github/workflows/naming-conventions.yml @@ -37,7 +37,7 @@ jobs: - name: Check branch name run: | - if [[ ! "$BRANCH_NAME" =~ ^(copilot/)?[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + if [[ ! "$BRANCH_NAME" =~ ^(copilot\/)?[a-z0-9]+(-[a-z0-9]+)*$ ]]; then echo "Branch name '$BRANCH_NAME' does not follow the naming convention established in the RSE Agile Method Handbook." exit 1 fi From 6f489eb9b2f9ee28fb91754c642e46d3e9191614 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Tue, 9 Jun 2026 11:24:18 +0100 Subject: [PATCH 032/133] Updated workflow to use the ignore enabled version --- .github/workflows/check-ruleset.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-ruleset.yaml b/.github/workflows/check-ruleset.yaml index 2f533192..9e06ef40 100644 --- a/.github/workflows/check-ruleset.yaml +++ b/.github/workflows/check-ruleset.yaml @@ -37,8 +37,9 @@ jobs: check: if: github.repository_owner == 'UoMResearchIT' name: Check Ruleset - uses: UoMResearchIT/actions/.github/workflows/check-ruleset.yaml@631e75042fd39b85beba5d00729d2650ee8f4013 # main + uses: UoMResearchIT/actions/.github/workflows/check-ruleset.yaml@7a30635d87460270dde919e71e31c5dd1f789f20 # temporary feature branch that allows "ignore" argument with: bot-id: ${{ vars.ORG_ACTIONS_BOT_APP_ID }} + ignore: '["require_code_owner_review"]' secrets: bot-key: ${{ secrets.ORG_ACTIONS_BOT_PRIVATE_KEY }} From d271dcdbeddce8cfeca271a55d947b80307ce5a4 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Tue, 9 Jun 2026 11:30:49 +0100 Subject: [PATCH 033/133] Updated hash --- .github/workflows/check-ruleset.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-ruleset.yaml b/.github/workflows/check-ruleset.yaml index 9e06ef40..517f1863 100644 --- a/.github/workflows/check-ruleset.yaml +++ b/.github/workflows/check-ruleset.yaml @@ -37,7 +37,7 @@ jobs: check: if: github.repository_owner == 'UoMResearchIT' name: Check Ruleset - uses: UoMResearchIT/actions/.github/workflows/check-ruleset.yaml@7a30635d87460270dde919e71e31c5dd1f789f20 # temporary feature branch that allows "ignore" argument + uses: UoMResearchIT/actions/.github/workflows/check-ruleset.yaml@8839be4e18a5a880aa052da40d9730445bb42e99 # temporary feature branch that allows "ignore" argument with: bot-id: ${{ vars.ORG_ACTIONS_BOT_APP_ID }} ignore: '["require_code_owner_review"]' From 341e41161b62550d567c17a309ec07cc3a914ab0 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Tue, 9 Jun 2026 11:41:02 +0100 Subject: [PATCH 034/133] And again --- .github/workflows/check-ruleset.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-ruleset.yaml b/.github/workflows/check-ruleset.yaml index 517f1863..534ae188 100644 --- a/.github/workflows/check-ruleset.yaml +++ b/.github/workflows/check-ruleset.yaml @@ -37,7 +37,7 @@ jobs: check: if: github.repository_owner == 'UoMResearchIT' name: Check Ruleset - uses: UoMResearchIT/actions/.github/workflows/check-ruleset.yaml@8839be4e18a5a880aa052da40d9730445bb42e99 # temporary feature branch that allows "ignore" argument + uses: UoMResearchIT/actions/.github/workflows/check-ruleset.yaml@1e3c3051f32f99d2cdb71a2f0c768108de2807c9 # temporary feature branch that allows "ignore" argument with: bot-id: ${{ vars.ORG_ACTIONS_BOT_APP_ID }} ignore: '["require_code_owner_review"]' From 267fbd71220526876668e765bc50a12aebb2da14 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Tue, 9 Jun 2026 11:48:54 +0100 Subject: [PATCH 035/133] Updated workflow hash --- .github/workflows/check-ruleset.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-ruleset.yaml b/.github/workflows/check-ruleset.yaml index 534ae188..ec2abbc9 100644 --- a/.github/workflows/check-ruleset.yaml +++ b/.github/workflows/check-ruleset.yaml @@ -37,7 +37,7 @@ jobs: check: if: github.repository_owner == 'UoMResearchIT' name: Check Ruleset - uses: UoMResearchIT/actions/.github/workflows/check-ruleset.yaml@1e3c3051f32f99d2cdb71a2f0c768108de2807c9 # temporary feature branch that allows "ignore" argument + uses: UoMResearchIT/actions/.github/workflows/check-ruleset.yaml@4d788481041a2b787c6cdda341d631755809a465 # temporary feature branch that allows "ignore" argument with: bot-id: ${{ vars.ORG_ACTIONS_BOT_APP_ID }} ignore: '["require_code_owner_review"]' From 25ead13e60e4151d3fdadd086600a6c82419e2ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:53:49 +0000 Subject: [PATCH 036/133] Bump actions/setup-dotnet from 5.2.0 to 5.3.0 Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.2.0 to 5.3.0. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7...9a946fdbd5fb07b82b2f5a4466058b876ab72bb2) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/dotnet.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 74fe36ed..f3333b42 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -37,7 +37,7 @@ jobs: persist-credentials: false - name: Setup .NET - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 with: dotnet-version: 10.0.x From c5431e4f08a40c515c0bb58cbd84536957683ab1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:54:01 +0000 Subject: [PATCH 037/133] Bump UoMResearchIT/actions/.github/workflows/apply-reuse.yml Bumps [UoMResearchIT/actions/.github/workflows/apply-reuse.yml](https://github.com/uomresearchit/actions) from 631e75042fd39b85beba5d00729d2650ee8f4013 to ff421cc61cc2f129dd095dcc0058d77f5c7606e0. - [Release notes](https://github.com/uomresearchit/actions/releases) - [Commits](https://github.com/uomresearchit/actions/compare/631e75042fd39b85beba5d00729d2650ee8f4013...ff421cc61cc2f129dd095dcc0058d77f5c7606e0) --- updated-dependencies: - dependency-name: UoMResearchIT/actions/.github/workflows/apply-reuse.yml dependency-version: ff421cc61cc2f129dd095dcc0058d77f5c7606e0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/license-copyright-add.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/license-copyright-add.yml b/.github/workflows/license-copyright-add.yml index 258d226d..ff2c6ced 100644 --- a/.github/workflows/license-copyright-add.yml +++ b/.github/workflows/license-copyright-add.yml @@ -11,4 +11,4 @@ jobs: permissions: pull-requests: write contents: write - uses: UoMResearchIT/actions/.github/workflows/apply-reuse.yml@631e75042fd39b85beba5d00729d2650ee8f4013 # main + uses: UoMResearchIT/actions/.github/workflows/apply-reuse.yml@ff421cc61cc2f129dd095dcc0058d77f5c7606e0 # main From 99eb977df3792ebd475e03398961d7a2b924caaa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:54:06 +0000 Subject: [PATCH 038/133] Bump actions/checkout from 6.0.2 to 6.0.3 Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 6.0.3. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/docker-images-build-health-check.yml | 2 +- .github/workflows/dotnet.yml | 2 +- .github/workflows/naming-conventions.yml | 2 +- .github/workflows/run-zizmor.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-images-build-health-check.yml b/.github/workflows/docker-images-build-health-check.yml index 88967444..cd458807 100644 --- a/.github/workflows/docker-images-build-health-check.yml +++ b/.github/workflows/docker-images-build-health-check.yml @@ -23,7 +23,7 @@ jobs: SUPERUSER_EMAIL: captain.marvel@manchester.ac.uk steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 74fe36ed..5b5bc228 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -30,7 +30,7 @@ jobs: repositories: CapX permission-contents: read - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: 'true' token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/naming-conventions.yml b/.github/workflows/naming-conventions.yml index da5a63ad..bbfc0123 100644 --- a/.github/workflows/naming-conventions.yml +++ b/.github/workflows/naming-conventions.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/run-zizmor.yml b/.github/workflows/run-zizmor.yml index 263ed7e5..44f3b2b0 100644 --- a/.github/workflows/run-zizmor.yml +++ b/.github/workflows/run-zizmor.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false From 07ddcc5b6d7d70e6042c43b0e5c672a802fdd2e7 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Tue, 9 Jun 2026 11:55:37 +0100 Subject: [PATCH 039/133] Fixed name --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 43991faf..79edf4de 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ SPDX-License-Identifier: apache-2.0 This tool initially started as a basic project and portfolio management (PPM) tool. Its first feature was capacity management, but it has since been extended to incorporate a much larger, more complex data model useful for an increased number of operational management activities. Written in .NET Blazor (Server) with a SQLite database (other providers are supported), it is used for managing many aspects of the service delivery of the digital research technical professional departments and the development of its staff. > [!IMPORTANT] -> The [dRTP Operational Management Peer Advice and Shared Support (dRTP COMPASS)](https://uomresearchit.github.io/DRTP-Op-Man-CoP-Website/) is responsible for its strategic direction. We’d love to hear if you are using this tool. If you would like to be part of dRTP-OMCoP then please let us know! +> The [dRTP Community of Operational Management, Peer Advice and Shared Support (dRTP COMPASS)](https://uomresearchit.github.io/DRTP-Op-Man-CoP-Website/) is responsible for its strategic direction. We’d love to hear if you are using this tool. If you would like to be part of dRTP-OMCoP then please let us know! ## User Accounts and Access The app supports integration with CAS / Shibboleth as well as Azure AD / Entra with access to restricted parts of the app managed within the app using a Role-Based Access Control (RBAC) database table. Super-users are able to manage user roles and access via the "Manage Access" page. From ac43e482dd1559aa5578dd06331fa9e4e4c848ef Mon Sep 17 00:00:00 2001 From: Adrian Harwood Date: Tue, 9 Jun 2026 12:06:47 +0100 Subject: [PATCH 040/133] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- PPMTool/Services/EmailService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PPMTool/Services/EmailService.cs b/PPMTool/Services/EmailService.cs index 8bbab55b..bd2ccfba 100644 --- a/PPMTool/Services/EmailService.cs +++ b/PPMTool/Services/EmailService.cs @@ -99,7 +99,7 @@ await Task.Run(async () => { Person lineManager = staff.LineManager; - if (lineManager != staff) // No point in Superuser emailing themselves about their timesheet. :) +if (lineManager != staff) // No point emailing someone about their own timesheet if they are their own line manager. :) { User lineManagerUser = UserService.GetAll(context).First(p => p.Person.PersonId == lineManager.PersonId); var lineManagerEmailAddresses = lineManagerUser.GetNormalisedEmailAddresses(); From 3024f15f7dfe827ae22b118943c8e9dff20a25c6 Mon Sep 17 00:00:00 2001 From: Adrian Harwood Date: Tue, 9 Jun 2026 12:07:17 +0100 Subject: [PATCH 041/133] Potential fix for pull request finding Logging fix Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- PPMTool/Services/EmailService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PPMTool/Services/EmailService.cs b/PPMTool/Services/EmailService.cs index bd2ccfba..6a94915b 100644 --- a/PPMTool/Services/EmailService.cs +++ b/PPMTool/Services/EmailService.cs @@ -60,7 +60,7 @@ public void SendEmail(string to, string subject, string message) }; mailMessage.To.Add(to); - Logger.LogInformation($"Sending email to {mailMessage.To}, subject {mailMessage.Subject}"); +Logger.LogInformation("Sending email to {To}, subject {Subject}", to, mailMessage.Subject); #if RELEASE // Launch a background task to do the sending From 5af5bc76b904ec44fb05fce0ba770885db74288d Mon Sep 17 00:00:00 2001 From: Adrian Harwood Date: Tue, 9 Jun 2026 12:07:30 +0100 Subject: [PATCH 042/133] Potential fix for pull request finding Logging fix Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- PPMTool/Services/EmailService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PPMTool/Services/EmailService.cs b/PPMTool/Services/EmailService.cs index 6a94915b..8cb32851 100644 --- a/PPMTool/Services/EmailService.cs +++ b/PPMTool/Services/EmailService.cs @@ -74,7 +74,7 @@ public void SendEmail(string to, string subject, string message) } catch (Exception e) { - Logger.LogError($"Failed to send email to {mailMessage.To}, subject {mailMessage.Subject}:\n{e}"); +Logger.LogError(e, "Failed to send email to {To}, subject {Subject}", to, mailMessage.Subject); } }); #endif From 028e03030c6790485bd38f868ae0ecfe4bdd9246 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Tue, 9 Jun 2026 12:13:45 +0100 Subject: [PATCH 043/133] Fixed formatting --- PPMTool/Services/EmailService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/PPMTool/Services/EmailService.cs b/PPMTool/Services/EmailService.cs index 8cb32851..c57a3833 100644 --- a/PPMTool/Services/EmailService.cs +++ b/PPMTool/Services/EmailService.cs @@ -50,7 +50,6 @@ ILogger logger /// public void SendEmail(string to, string subject, string message) { - var mailMessage = new MailMessage { From = new MailAddress(Configuration["Email:From"]), @@ -60,7 +59,7 @@ public void SendEmail(string to, string subject, string message) }; mailMessage.To.Add(to); -Logger.LogInformation("Sending email to {To}, subject {Subject}", to, mailMessage.Subject); + Logger.LogInformation($"Sending email to {to}, subject {mailMessage.Subject}"); #if RELEASE // Launch a background task to do the sending @@ -74,7 +73,7 @@ public void SendEmail(string to, string subject, string message) } catch (Exception e) { -Logger.LogError(e, "Failed to send email to {To}, subject {Subject}", to, mailMessage.Subject); + Logger.LogInformation($"Failed to send email to {to}, subject {mailMessage.Subject}"); } }); #endif @@ -99,7 +98,7 @@ await Task.Run(async () => { Person lineManager = staff.LineManager; -if (lineManager != staff) // No point emailing someone about their own timesheet if they are their own line manager. :) + if (lineManager != staff) // No point emailing someone about their own timesheet if they are their own line manager. :) { User lineManagerUser = UserService.GetAll(context).First(p => p.Person.PersonId == lineManager.PersonId); var lineManagerEmailAddresses = lineManagerUser.GetNormalisedEmailAddresses(); @@ -111,7 +110,8 @@ await Task.Run(async () => } } - if (recipients.Any()) // Build the email and send it if there are any email addresses + // Only build the email and send it if there are any email addresses + if (recipients.Any()) { // Create email var subject = $"{Configuration["Email:TimesheetSubmissionEmailSubject"]}. {staff.ShortName} [{timesheet.StartDate.ToString("dd/MM/yy")}]"; From cc485a83e40b525b8004eaf16f13c4e0fe42c87f Mon Sep 17 00:00:00 2001 From: PhilBradbury Date: Wed, 10 Jun 2026 11:42:03 +0100 Subject: [PATCH 044/133] Added new property to hold the current calculated grde so we can filter on it as a simple integer where required. --- PPMTool.Data/Entities/Person.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/PPMTool.Data/Entities/Person.cs b/PPMTool.Data/Entities/Person.cs index 7823693e..418af7a7 100644 --- a/PPMTool.Data/Entities/Person.cs +++ b/PPMTool.Data/Entities/Person.cs @@ -308,5 +308,13 @@ public double GetWorkloadModelTotalOnDate(DateTime date) .OrderBy(x => x.ChangeDate) .LastOrDefault(); } + + /// + /// Stores the calculated grade based on the person's current WLM and the date. + /// Used for filtering by Grade on the Manage People page currently. + /// + /// + [NotMapped] + public int CurrentGrade { get; set; } } } From d92f4ee14abc59409b00cb5f2288c191f970d1f5 Mon Sep 17 00:00:00 2001 From: PhilBradbury Date: Wed, 10 Jun 2026 11:43:02 +0100 Subject: [PATCH 045/133] Altered the filtering for Line Manager to use the Person.LineManager.Name property rather than custom filtering based on pattern matching and Linq. --- PPMTool/Pages/People.razor | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/PPMTool/Pages/People.razor b/PPMTool/Pages/People.razor index 52e01d8e..a048dbf0 100644 --- a/PPMTool/Pages/People.razor +++ b/PPMTool/Pages/People.razor @@ -64,7 +64,7 @@ SPDX-License-Identifier: apache-2.0 - + - - - + + + - + @if (skillsEnabled) @@ -95,7 +95,7 @@ SPDX-License-Identifier: apache-2.0 - + From bc0f797d94a0e47db725ec0c462bf79329c6ffb2 Mon Sep 17 00:00:00 2001 From: PhilBradbury Date: Wed, 10 Jun 2026 11:44:27 +0100 Subject: [PATCH 046/133] Kept the Skilltag filtering as custom but removed similar for Line Manager filtering. Also adjusted filtering by CurrentGrade to use a calculated integer value. --- PPMTool/Pages/People.razor.cs | 113 +++++++++++++++------------------- 1 file changed, 50 insertions(+), 63 deletions(-) diff --git a/PPMTool/Pages/People.razor.cs b/PPMTool/Pages/People.razor.cs index bd54972e..f5659f6b 100644 --- a/PPMTool/Pages/People.razor.cs +++ b/PPMTool/Pages/People.razor.cs @@ -112,87 +112,74 @@ private void LoadData(LoadDataArgs args) query = query.Where(x => ActiveUser.Person != null && x.PersonId == ActiveUser.Person!.PersonId); } - // Now apply the custom filters (pattern matching syntax for non-null object) + + // ---- CUSTOM FILTERS ONLY ---- if (args.Filters is { } filters && filters.Any()) { - // Filter on Skill Tags - var filter = args.Filters.FirstOrDefault(x => x.Property == "SkillTags"); - var filterValue = filter?.FilterValue as string; - if (filter != null && filterValue != null) - { - query = query.Where(x => x.OwnedSkills.Any(x => x.SkillTag.Name.Trim().ToLower().Contains(filterValue.Trim().ToLower()))); - } + // SkillTags custom filter + var skillFilter = filters.FirstOrDefault(x => x.Property == "SkillTags"); + var skillFilterValue = (skillFilter?.FilterValue as string)?.Trim(); - // Add filter on Line Manager - filter = filters.FirstOrDefault(f => f.Property == "LineManager"); - filterValue = (filter?.FilterValue as string)?.Trim(); - if (!string.IsNullOrEmpty(filterValue)) + if (!string.IsNullOrWhiteSpace(skillFilterValue)) { - var filterValueLower = filterValue.ToLower(); + var filterValueLower = skillFilterValue.ToLower(); + query = query.Where(x => - x.LineManager != null && - (x.LineManager.Name ?? "").Trim().ToLower().Contains(filterValueLower)); + x.OwnedSkills.Any(s => + (s.SkillTag.Name ?? "").Trim().ToLower().Contains(filterValueLower))); } + } - // Add any filters than are none of the special ones - var stdFilters = args.Filters.Where(x => x.Property != "SkillTags" && x.Property != "LineManager"); - if (stdFilters.Any()) - { - // Filter via the Where method - query = query.Where(args.Filter); - } + // ---- BUILT-IN RADZEN FILTERING ---- + if (!string.IsNullOrWhiteSpace(args.Filter)) + { + query = query.Where(args.Filter); } - // Sorting needed - if (!string.IsNullOrEmpty(args.OrderBy)) + // ---- SORTING ---- + if (args.Sorts != null && args.Sorts.Count() > 0) { - // Check details of the sort - if (args.Sorts is { } sorts && sorts.Any()) + var sort = args.Sorts.First(); + + // Special-case sort + if (sort.Property == "SkillsCount") + { + query = sort.SortOrder == SortOrder.Ascending + ? query.OrderBy(x => x.OwnedSkills.Count()) + : query.OrderByDescending(x => x.OwnedSkills.Count()); + } + else { - var sort = args.Sorts?.First(); - if (sort.Property == "SkillsCount") - { - // Apply the ordering process on skills count manually - if (sort.SortOrder == SortOrder.Ascending) - { - query = query.OrderBy(x => TagService.GetCountForPerson(Context, x.PersonId)); - } - else - { - query = query.OrderByDescending(x => TagService.GetCountForPerson(Context, x.PersonId)); - } - } - else if (sort.Property == "LineManager") - { - query = sort.SortOrder == SortOrder.Ascending ? - query.OrderBy(x => x.LineManager != null ? x.LineManager.Name : "") : - query.OrderByDescending(x => x.LineManager != null ? x.LineManager.Name : ""); - } - - // No special handling required - else - { - // Sort via the OrderBy method - query = query.OrderBy(args.OrderBy); - } + query = query.OrderBy(args.OrderBy); } } - - // Important!!! Make sure the Count property of RadzenDataGrid is set. - var data = query.ToList(); - count = data.Count(); - - // Perform paging via Skip and Take. - if (args.Skip == null) + else if (!string.IsNullOrWhiteSpace(args.OrderBy)) { - people = data.Take(pageCount).ToList(); + query = query.OrderBy(args.OrderBy); } - else + + // ---- COUNT BEFORE PAGING ---- + count = query.Count(); + + + foreach (Person p in query) { - people = data.Skip(args.Skip.Value).Take(args.Top.Value).ToList(); + p.CurrentGrade = p.WorkloadModelChanges + .Where(x => x.ChangeDate <= DateTime.Today) + .OrderBy(x => x.ChangeDate) + .Select(x => x.Grade) + .LastOrDefault(); } - Debug.WriteLine($"** {data.Count()} people loaded. {people.Count()} displayed."); + + // ---- PAGING ---- + var skip = args.Skip ?? 0; + var take = args.Top ?? pageCount; + + people = query.Skip(skip).Take(take).ToList(); + + Debug.WriteLine($"** {count} people loaded. {people.Count()} displayed."); + } } } \ No newline at end of file From a097e1e9bb8d73b2b58e063356302b8dc8490842 Mon Sep 17 00:00:00 2001 From: PhilBradbury Date: Wed, 10 Jun 2026 13:14:01 +0100 Subject: [PATCH 047/133] Updated the CurrenGrade property to get the grade based on today's date rather than being set in the calling code. --- PPMTool.Data/Entities/Person.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PPMTool.Data/Entities/Person.cs b/PPMTool.Data/Entities/Person.cs index 418af7a7..f30e427b 100644 --- a/PPMTool.Data/Entities/Person.cs +++ b/PPMTool.Data/Entities/Person.cs @@ -315,6 +315,6 @@ public double GetWorkloadModelTotalOnDate(DateTime date) ///
/// [NotMapped] - public int CurrentGrade { get; set; } + public int? CurrentGrade { get => GetGradeOnDate(DateTime.Today); } } } From 622b517080ce704dac22a49d173cd5ec0208acd1 Mon Sep 17 00:00:00 2001 From: PhilBradbury Date: Wed, 10 Jun 2026 13:14:45 +0100 Subject: [PATCH 048/133] Removed population of the CurrentGrade property from the code as it's been moved into the entity. --- PPMTool/Pages/People.razor.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/PPMTool/Pages/People.razor.cs b/PPMTool/Pages/People.razor.cs index f5659f6b..744a4326 100644 --- a/PPMTool/Pages/People.razor.cs +++ b/PPMTool/Pages/People.razor.cs @@ -161,17 +161,6 @@ private void LoadData(LoadDataArgs args) // ---- COUNT BEFORE PAGING ---- count = query.Count(); - - foreach (Person p in query) - { - p.CurrentGrade = p.WorkloadModelChanges - .Where(x => x.ChangeDate <= DateTime.Today) - .OrderBy(x => x.ChangeDate) - .Select(x => x.Grade) - .LastOrDefault(); - } - - // ---- PAGING ---- var skip = args.Skip ?? 0; var take = args.Top ?? pageCount; From 15ff715200ffaf56469b3fd48f51ba9fc7b3b49f Mon Sep 17 00:00:00 2001 From: PhilBradbury Date: Wed, 10 Jun 2026 13:47:57 +0100 Subject: [PATCH 049/133] Adjusted default FilterMode to be simple. Adjusted Faculty and School filtering to use the Code for each as the filterable property. Removed filter from some columns where it didn't make much sense (costs, day rate, dates etc.) --- PPMTool/Pages/Projects.razor | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/PPMTool/Pages/Projects.razor b/PPMTool/Pages/Projects.razor index 1ccabce3..03d33715 100644 --- a/PPMTool/Pages/Projects.razor +++ b/PPMTool/Pages/Projects.razor @@ -44,7 +44,7 @@ SPDX-License-Identifier: apache-2.0 - + @@ -71,18 +71,18 @@ SPDX-License-Identifier: apache-2.0 @p.ProjectStatus.ToNiceString() - + - + - + - + - + - - + + } - + - + - + @@ -113,20 +127,48 @@ SPDX-License-Identifier: apache-2.0 @(p.CostModel.GetAttribute().Name) - + - - - + + + } - + From cb582a778e9ad7827b81aaf9baae5a957eab48fe Mon Sep 17 00:00:00 2001 From: Adrian Harwood Date: Wed, 17 Jun 2026 09:16:46 +0100 Subject: [PATCH 079/133] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- PPMTool/Pages/AddInnateCode.razor | 4 ++-- PPMTool/Pages/AddInnateCode.razor.cs | 2 +- PPMTool/Services/BaseEntityService.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/PPMTool/Pages/AddInnateCode.razor b/PPMTool/Pages/AddInnateCode.razor index da016e01..62c81cdb 100644 --- a/PPMTool/Pages/AddInnateCode.razor +++ b/PPMTool/Pages/AddInnateCode.razor @@ -110,8 +110,8 @@ SPDX-License-Identifier: apache-2.0 class="m-1" Click="@(args => DeleteRow(c))" @onclick:stopPropagation="true" - MouseEnter="@(args => ShowTooltip(args, $"{(tasksWithBookings.Any(id => id == c.InnateCodeTaskId) ? "Cannot Delete (In Use)" : "Delete")}"))" - Disabled="@(tasksWithBookings.Any(id => id == c.InnateCodeTaskId))" /> + MouseEnter="@(args => ShowTooltip(args, tasksWithBookings.Contains(c.InnateCodeTaskId) ? "Cannot Delete (In Use)" : "Delete"))" + Disabled="@(tasksWithBookings.Contains(c.InnateCodeTaskId))" /> diff --git a/PPMTool/Pages/AddInnateCode.razor.cs b/PPMTool/Pages/AddInnateCode.razor.cs index 6c5c8c73..746427ce 100644 --- a/PPMTool/Pages/AddInnateCode.razor.cs +++ b/PPMTool/Pages/AddInnateCode.razor.cs @@ -167,7 +167,7 @@ private async void AddDefaults() } /// - /// Callback when the swtich is changed + /// Callback when the switch is changed /// private void ActivityActiveChanged(bool value) { diff --git a/PPMTool/Services/BaseEntityService.cs b/PPMTool/Services/BaseEntityService.cs index 9ba8f208..2b02dd6a 100644 --- a/PPMTool/Services/BaseEntityService.cs +++ b/PPMTool/Services/BaseEntityService.cs @@ -14,7 +14,7 @@ public abstract class BaseEntityService : IEntityService /// /// Logger for logging service interactions /// - protected ILogger logger = null!; + protected readonly ILogger logger; /// /// Constructor but with DI From d8914732d63da26cb1e828fbdbdeac8fc0ad1c6c Mon Sep 17 00:00:00 2001 From: Adrian Harwood Date: Wed, 17 Jun 2026 09:22:14 +0100 Subject: [PATCH 080/133] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- PPMTool.Data/Entities/Project.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PPMTool.Data/Entities/Project.cs b/PPMTool.Data/Entities/Project.cs index 74ebb35b..f366e098 100644 --- a/PPMTool.Data/Entities/Project.cs +++ b/PPMTool.Data/Entities/Project.cs @@ -442,7 +442,7 @@ public string GetSensibleObjectName() } /// - /// Property to allow filtering on FundingReceived in datagrids. + /// Property to allow filtering on FundsReceived in datagrids. /// Can't populate in here without adding the PaymentService though. /// /// From e0da64fc5bccb04909d1c7b3d4196b00b0cab70c Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Wed, 17 Jun 2026 09:41:46 +0100 Subject: [PATCH 081/133] Converted the timesheet help URL to a config variable --- PPMTool/Pages/AddTimesheet.razor | 3 +-- PPMTool/Pages/AddTimesheet.razor.cs | 3 +++ PPMTool/appsettings.json | 5 +++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/PPMTool/Pages/AddTimesheet.razor b/PPMTool/Pages/AddTimesheet.razor index 35d963cf..cf5023df 100644 --- a/PPMTool/Pages/AddTimesheet.razor +++ b/PPMTool/Pages/AddTimesheet.razor @@ -10,7 +10,6 @@ SPDX-License-Identifier: apache-2.0 @using PPMTool.Data.Entities @using PPMTool.Data.Enums @using PPMTool.Pages.Components -@using PPMTool.Services @using Radzen.Blazor @inject IJSRuntime JSRuntime @@ -66,7 +65,7 @@ SPDX-License-Identifier: apache-2.0

Timesheet Entries

- + diff --git a/PPMTool/Pages/AddTimesheet.razor.cs b/PPMTool/Pages/AddTimesheet.razor.cs index 8125c76e..308c2308 100644 --- a/PPMTool/Pages/AddTimesheet.razor.cs +++ b/PPMTool/Pages/AddTimesheet.razor.cs @@ -34,6 +34,9 @@ public partial class AddTimesheet : DataGridPage [Inject] public EmailService EmailService { get; set; } + [Inject] + public IConfiguration Configuration { get; set; } + private Timesheet timesheet; private IList innateCodeDropdownSource = new List(); private IEnumerable innateCodeTaskDropdownSource = new List(); diff --git a/PPMTool/appsettings.json b/PPMTool/appsettings.json index 7337e0df..c2556993 100644 --- a/PPMTool/appsettings.json +++ b/PPMTool/appsettings.json @@ -60,6 +60,7 @@ }, "DbProvider": "sqlite", "DataProtection": { - "KeyPath" : "" - } + "KeyPath": "" + }, + "TimesheetTaskCategorisationDocumentUrl": "" } \ No newline at end of file From f42617e69cabdd187981a4978f0a77ee040df4bd Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Wed, 17 Jun 2026 09:51:09 +0100 Subject: [PATCH 082/133] Only show button if a URL is available --- PPMTool/Pages/AddTimesheet.razor | 9 ++++++--- PPMTool/appsettings.json | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/PPMTool/Pages/AddTimesheet.razor b/PPMTool/Pages/AddTimesheet.razor index cf5023df..b9a5c0f1 100644 --- a/PPMTool/Pages/AddTimesheet.razor +++ b/PPMTool/Pages/AddTimesheet.razor @@ -65,9 +65,12 @@ SPDX-License-Identifier: apache-2.0

Timesheet Entries

- - - + @if (!string.IsNullOrWhiteSpace(Configuration["TimesheetHelpUrl"])) + { + + + + }
diff --git a/PPMTool/appsettings.json b/PPMTool/appsettings.json index c2556993..69705881 100644 --- a/PPMTool/appsettings.json +++ b/PPMTool/appsettings.json @@ -62,5 +62,5 @@ "DataProtection": { "KeyPath": "" }, - "TimesheetTaskCategorisationDocumentUrl": "" + "TimesheetHelpUrl": "" } \ No newline at end of file From eff10ddf037efe4a2541464d00058278d46f7268 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Wed, 17 Jun 2026 09:51:35 +0100 Subject: [PATCH 083/133] Added rest if the pipeline from environment to config --- .env.sample | 5 ++++- PPMTool/Helpers/EnvironmentHelper.cs | 5 ++++- README.md | 1 + compose.yml | 1 + 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.env.sample b/.env.sample index 8817a207..d0d16f90 100644 --- a/.env.sample +++ b/.env.sample @@ -18,7 +18,7 @@ CONNECTION_STRING=Data Source=/app/state/PPMTool.db;Cache=Shared;Mode=ReadWriteC LEAVEBOOKINGS_CONNECTION_STRING=Data Source=/app/state/LeaveBookings.db;Cache=Shared;Mode=ReadWriteCreate; # Connection to Sentry crash monitoring -SENTRY_DSN="https://sentry.io/" +SENTRY_DSN=https://sentry.io/ # Port mappings CAPX_HTTP_PORT=5001 @@ -56,3 +56,6 @@ DB_PROVIDER=sqlite # Data Protection Path (where to store the encryption keys for app data) # If not set will use the default ASP.NET setting of storing in memory rather than on disk DP_KEY_PATH=state/.data-protection-keys + +# Timesheet help documentation URL +TIMESHEET_HELP_URL=https://sharepoint.com/some-documentation diff --git a/PPMTool/Helpers/EnvironmentHelper.cs b/PPMTool/Helpers/EnvironmentHelper.cs index 9050b499..5515a438 100644 --- a/PPMTool/Helpers/EnvironmentHelper.cs +++ b/PPMTool/Helpers/EnvironmentHelper.cs @@ -68,7 +68,7 @@ internal static void LoadEnvironmentVariables(WebApplicationBuilder builder) var overridingValues = new Dictionary(); // Get the API key secret - EnvironmentHelper.ReadValue("API_KEY_SECRET", "Jwt:SecretKey", ref overridingValues); + ReadValue("API_KEY_SECRET", "Jwt:SecretKey", ref overridingValues); // Get Sentry DSN ReadValue("SENTRY_DSN", "Sentry:Dsn", ref overridingValues); @@ -104,6 +104,9 @@ internal static void LoadEnvironmentVariables(WebApplicationBuilder builder) // Data protection ReadValue("DP_KEY_PATH", "DataProtection:KeyPath", ref overridingValues); + // Other settings + ReadValue("TIMESHEET_HELP_URL", "TimesheetHelpUrl", ref overridingValues); + // Set seed dummy data flag if environment variable is set to true (case insensitive) var seedDummyData = Environment.GetEnvironmentVariable("SEED_DUMMY_DATA"); if (seedDummyData?.ToLowerInvariant() == true.ToString().ToLowerInvariant()) diff --git a/README.md b/README.md index 79edf4de..30e35b68 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ To set this up, create a `.env` file in the repository root with the following r | `SUPERUSER_EMAIL` | Email of the superuser (required if seeding) | | `DB_PROVIDER` | Which DB provider should be used. Supports sqlite, sqlserver, postgresql values. | | `DP_KEY_PATH` | Optional path to where on disk the data protection keys should be stored. | +| `TIMESHEET_HELP_URL` | URL to the timesheet help document. | The following variables need only be set when not using the "Local" solution configuration: diff --git a/compose.yml b/compose.yml index 73588ac2..fee3cd8b 100644 --- a/compose.yml +++ b/compose.yml @@ -35,6 +35,7 @@ services: - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID:-} - ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET:-} - ENTRA_CALLBACK_PATH=${ENTRA_CALLBACK_PATH:-/signin-oidc} + - TIMESHEET_HELP_URL=${TIMESHEET_HELP_URL:-} ports: - ${CAPX_HTTP_PORT:?}:8080 command: ["dotnet", "PPMTool.dll"] From 033f0443d514f3b44cd3ac4c5cb436a29bda3c80 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Wed, 17 Jun 2026 09:52:41 +0100 Subject: [PATCH 084/133] Revert "Added rest if the pipeline from environment to config" This reverts commit eff10ddf037efe4a2541464d00058278d46f7268. --- .env.sample | 5 +---- PPMTool/Helpers/EnvironmentHelper.cs | 5 +---- README.md | 1 - compose.yml | 1 - 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/.env.sample b/.env.sample index d0d16f90..8817a207 100644 --- a/.env.sample +++ b/.env.sample @@ -18,7 +18,7 @@ CONNECTION_STRING=Data Source=/app/state/PPMTool.db;Cache=Shared;Mode=ReadWriteC LEAVEBOOKINGS_CONNECTION_STRING=Data Source=/app/state/LeaveBookings.db;Cache=Shared;Mode=ReadWriteCreate; # Connection to Sentry crash monitoring -SENTRY_DSN=https://sentry.io/ +SENTRY_DSN="https://sentry.io/" # Port mappings CAPX_HTTP_PORT=5001 @@ -56,6 +56,3 @@ DB_PROVIDER=sqlite # Data Protection Path (where to store the encryption keys for app data) # If not set will use the default ASP.NET setting of storing in memory rather than on disk DP_KEY_PATH=state/.data-protection-keys - -# Timesheet help documentation URL -TIMESHEET_HELP_URL=https://sharepoint.com/some-documentation diff --git a/PPMTool/Helpers/EnvironmentHelper.cs b/PPMTool/Helpers/EnvironmentHelper.cs index 5515a438..9050b499 100644 --- a/PPMTool/Helpers/EnvironmentHelper.cs +++ b/PPMTool/Helpers/EnvironmentHelper.cs @@ -68,7 +68,7 @@ internal static void LoadEnvironmentVariables(WebApplicationBuilder builder) var overridingValues = new Dictionary(); // Get the API key secret - ReadValue("API_KEY_SECRET", "Jwt:SecretKey", ref overridingValues); + EnvironmentHelper.ReadValue("API_KEY_SECRET", "Jwt:SecretKey", ref overridingValues); // Get Sentry DSN ReadValue("SENTRY_DSN", "Sentry:Dsn", ref overridingValues); @@ -104,9 +104,6 @@ internal static void LoadEnvironmentVariables(WebApplicationBuilder builder) // Data protection ReadValue("DP_KEY_PATH", "DataProtection:KeyPath", ref overridingValues); - // Other settings - ReadValue("TIMESHEET_HELP_URL", "TimesheetHelpUrl", ref overridingValues); - // Set seed dummy data flag if environment variable is set to true (case insensitive) var seedDummyData = Environment.GetEnvironmentVariable("SEED_DUMMY_DATA"); if (seedDummyData?.ToLowerInvariant() == true.ToString().ToLowerInvariant()) diff --git a/README.md b/README.md index 30e35b68..79edf4de 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,6 @@ To set this up, create a `.env` file in the repository root with the following r | `SUPERUSER_EMAIL` | Email of the superuser (required if seeding) | | `DB_PROVIDER` | Which DB provider should be used. Supports sqlite, sqlserver, postgresql values. | | `DP_KEY_PATH` | Optional path to where on disk the data protection keys should be stored. | -| `TIMESHEET_HELP_URL` | URL to the timesheet help document. | The following variables need only be set when not using the "Local" solution configuration: diff --git a/compose.yml b/compose.yml index fee3cd8b..73588ac2 100644 --- a/compose.yml +++ b/compose.yml @@ -35,7 +35,6 @@ services: - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID:-} - ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET:-} - ENTRA_CALLBACK_PATH=${ENTRA_CALLBACK_PATH:-/signin-oidc} - - TIMESHEET_HELP_URL=${TIMESHEET_HELP_URL:-} ports: - ${CAPX_HTTP_PORT:?}:8080 command: ["dotnet", "PPMTool.dll"] From b60ba82eedbe92c3757c3a0bd6fbf80d448a0090 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Wed, 17 Jun 2026 09:56:52 +0100 Subject: [PATCH 085/133] Switched from an environment variable to a runtime setting instead as it makes more sense --- PPMTool.Data/Enums/SettingType.cs | 4 ++++ PPMTool/Helpers/EnvironmentHelper.cs | 2 +- PPMTool/Pages/AddTimesheet.razor | 4 ++-- PPMTool/Pages/AddTimesheet.razor.cs | 12 +++++++----- PPMTool/appsettings.json | 3 +-- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/PPMTool.Data/Enums/SettingType.cs b/PPMTool.Data/Enums/SettingType.cs index 4e898b8c..52f02547 100644 --- a/PPMTool.Data/Enums/SettingType.cs +++ b/PPMTool.Data/Enums/SettingType.cs @@ -75,5 +75,9 @@ public enum SettingType [Description("Optional logo that is displayed in the header in dark mode.")] [DefaultSettingValue("")] OrganisationLogoDark = 15, + + [Description("URL to documentation that provides help for the timesheet functionality.")] + [DefaultSettingValue("")] + TimesheetHelpUrl = 16 } } diff --git a/PPMTool/Helpers/EnvironmentHelper.cs b/PPMTool/Helpers/EnvironmentHelper.cs index 9050b499..17e91f7a 100644 --- a/PPMTool/Helpers/EnvironmentHelper.cs +++ b/PPMTool/Helpers/EnvironmentHelper.cs @@ -68,7 +68,7 @@ internal static void LoadEnvironmentVariables(WebApplicationBuilder builder) var overridingValues = new Dictionary(); // Get the API key secret - EnvironmentHelper.ReadValue("API_KEY_SECRET", "Jwt:SecretKey", ref overridingValues); + ReadValue("API_KEY_SECRET", "Jwt:SecretKey", ref overridingValues); // Get Sentry DSN ReadValue("SENTRY_DSN", "Sentry:Dsn", ref overridingValues); diff --git a/PPMTool/Pages/AddTimesheet.razor b/PPMTool/Pages/AddTimesheet.razor index b9a5c0f1..59c4227c 100644 --- a/PPMTool/Pages/AddTimesheet.razor +++ b/PPMTool/Pages/AddTimesheet.razor @@ -65,9 +65,9 @@ SPDX-License-Identifier: apache-2.0

Timesheet Entries

- @if (!string.IsNullOrWhiteSpace(Configuration["TimesheetHelpUrl"])) + @if (!string.IsNullOrWhiteSpace(timesheetHelpUrl)) { - + } diff --git a/PPMTool/Pages/AddTimesheet.razor.cs b/PPMTool/Pages/AddTimesheet.razor.cs index 308c2308..f76e12f6 100644 --- a/PPMTool/Pages/AddTimesheet.razor.cs +++ b/PPMTool/Pages/AddTimesheet.razor.cs @@ -64,6 +64,13 @@ public partial class AddTimesheet : DataGridPage private WorkloadModelChange currentWLM; private WLMWeeklyDataChartItem wlmChartItem; private double totalFTEForTimesheet; + private string timesheetHelpUrl = null; + + protected override void OnInitialized() + { + base.OnInitialized(); + timesheetHelpUrl = GetSetting(); + } protected override async Task OnParametersSetAsync() { @@ -278,11 +285,6 @@ void OnDataLoad(LoadDataArgs args) } } - protected override void OnInitialized() - { - base.OnInitialized(); - } - /// /// Load innate codes to populate the dropdown source. If there is a timesheet then remove codes that have been used and have no tasks left. /// diff --git a/PPMTool/appsettings.json b/PPMTool/appsettings.json index 69705881..78b2b0ab 100644 --- a/PPMTool/appsettings.json +++ b/PPMTool/appsettings.json @@ -61,6 +61,5 @@ "DbProvider": "sqlite", "DataProtection": { "KeyPath": "" - }, - "TimesheetHelpUrl": "" + } } \ No newline at end of file From 57373b533c27564914cf977af7ed58b4c2abd178 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Wed, 17 Jun 2026 09:59:27 +0100 Subject: [PATCH 086/133] Fixed call --- PPMTool/Pages/AddTimesheet.razor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PPMTool/Pages/AddTimesheet.razor.cs b/PPMTool/Pages/AddTimesheet.razor.cs index f76e12f6..f7ee2384 100644 --- a/PPMTool/Pages/AddTimesheet.razor.cs +++ b/PPMTool/Pages/AddTimesheet.razor.cs @@ -69,7 +69,7 @@ public partial class AddTimesheet : DataGridPage protected override void OnInitialized() { base.OnInitialized(); - timesheetHelpUrl = GetSetting(); + timesheetHelpUrl = GetSetting(SettingType.TimesheetHelpUrl); } protected override async Task OnParametersSetAsync() From bf5799db77612a8b53b0e391ec81c21d6ade85d4 Mon Sep 17 00:00:00 2001 From: PhilBradbury Date: Wed, 17 Jun 2026 10:46:13 +0100 Subject: [PATCH 087/133] Updated EndDate column to be checking against correct property. --- PPMTool/Pages/Projects.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PPMTool/Pages/Projects.razor b/PPMTool/Pages/Projects.razor index 3be2e25e..385c2414 100644 --- a/PPMTool/Pages/Projects.razor +++ b/PPMTool/Pages/Projects.razor @@ -110,13 +110,13 @@ SPDX-License-Identifier: apache-2.0 FilterOperator="FilterOperator.LessThanOrEquals" Width="13rem"> From 64d5558a2631d1a7b77a10ea4a09dc60db17605b Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Wed, 17 Jun 2026 17:33:17 +0100 Subject: [PATCH 088/133] Removed old reference --- PPMTool/Pages/AddTimesheet.razor.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/PPMTool/Pages/AddTimesheet.razor.cs b/PPMTool/Pages/AddTimesheet.razor.cs index f7ee2384..a3163c63 100644 --- a/PPMTool/Pages/AddTimesheet.razor.cs +++ b/PPMTool/Pages/AddTimesheet.razor.cs @@ -34,9 +34,6 @@ public partial class AddTimesheet : DataGridPage [Inject] public EmailService EmailService { get; set; } - [Inject] - public IConfiguration Configuration { get; set; } - private Timesheet timesheet; private IList innateCodeDropdownSource = new List(); private IEnumerable innateCodeTaskDropdownSource = new List(); From 1762276baddec67337205590f64548de1fa58079 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Wed, 17 Jun 2026 17:39:04 +0100 Subject: [PATCH 089/133] Add filter by active on tasks to select --- PPMTool/Pages/AddTimesheet.razor.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/PPMTool/Pages/AddTimesheet.razor.cs b/PPMTool/Pages/AddTimesheet.razor.cs index c3d4897e..d2704f7c 100644 --- a/PPMTool/Pages/AddTimesheet.razor.cs +++ b/PPMTool/Pages/AddTimesheet.razor.cs @@ -529,7 +529,8 @@ private async Task OnInnateCodeChangedAsync(object value) // Load the innate tasks associated with the selected innate code Debug.WriteLine($"** Selected {value}"); var tasks = innateCodeDropdownSource - .FirstOrDefault(x => x.GetCodeAsString() == (value as string))?.Tasks + .FirstOrDefault(x => x.GetCodeAsString() == (value as string))? + .Tasks .ToList(); // Find all existing entries that use this same code @@ -542,6 +543,9 @@ private async Task OnInnateCodeChangedAsync(object value) // Remove the tasks from the list that are already in use tasks?.RemoveAll(x => tasksInUse.Contains(x)); + // Now filter the remaining list to only include active tasks + tasks = tasks?.Where(x => x.IsActive).ToList(); + // Assign the tasks innateCodeTaskDropdownSource = tasks; Debug.WriteLine($"** {tasks.Count} tasks in list"); From 6639df00b356225bbe42cd449c9729a3b4c268d6 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Wed, 17 Jun 2026 18:18:59 +0100 Subject: [PATCH 090/133] Fixed reference to incorrect field in UI --- PPMTool/Pages/Components/NoteComponent.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PPMTool/Pages/Components/NoteComponent.razor b/PPMTool/Pages/Components/NoteComponent.razor index 2aa464a6..ce48c4f6 100644 --- a/PPMTool/Pages/Components/NoteComponent.razor +++ b/PPMTool/Pages/Components/NoteComponent.razor @@ -67,7 +67,7 @@ SPDX-License-Identifier: apache-2.0 { - + } From e779c178c5f9ab9bf90d6fc17e2528629df766b3 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Mon, 22 Jun 2026 11:37:20 +0100 Subject: [PATCH 091/133] Unrelated fix to rounding on the invoice status decision --- PPMTool/Services/PaymentService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PPMTool/Services/PaymentService.cs b/PPMTool/Services/PaymentService.cs index 05ec8def..da4d4e3c 100644 --- a/PPMTool/Services/PaymentService.cs +++ b/PPMTool/Services/PaymentService.cs @@ -101,11 +101,12 @@ public InvoiceStatus GetInvoiceStatusFromPayments(PPMToolContext context, Invoic .Where(x => x.Invoice.InvoiceId == invoice.InvoiceId) .ToList(); var totalPaid = payments.RoundedSum(x => x.Value, 0); + var invoiceValue = Math.Round(invoice.Value, 0); if (totalPaid <= 0) { return InvoiceStatus.Unpaid; } - else if (totalPaid < invoice.Value) + else if (totalPaid < invoiceValue) { return InvoiceStatus.PartiallyPaid; } From 123c90a3e679f34d389ca48f9855a61742232824 Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Mon, 22 Jun 2026 11:52:48 +0100 Subject: [PATCH 092/133] Added some better protection against stale references in the delete method --- PPMTool/Pages/DataGridPage.cs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/PPMTool/Pages/DataGridPage.cs b/PPMTool/Pages/DataGridPage.cs index f2961763..427abb9d 100644 --- a/PPMTool/Pages/DataGridPage.cs +++ b/PPMTool/Pages/DataGridPage.cs @@ -80,18 +80,30 @@ protected virtual void CancelEdit(T entity) /// protected virtual async Task DeleteRow(T entity) { - Reset(); - if (dataGridEntities.Contains(entity)) + var isInDataSource = dataGridEntities.Contains(entity); + var isTrackedEditRow = ReferenceEquals(entityToInsert, entity) || ReferenceEquals(entityToUpdate, entity); + + if (isInDataSource) { LogInformation($"Delete row in data grid source for <{entity?.GetSensibleObjectName()}>"); dataGridEntities.Remove(entity); } - else + else if (isTrackedEditRow) { LogInformation($"Cancel edit row in view for <{entity?.GetSensibleObjectName()}>"); dataGrid.CancelEditRow(entity); } - await dataGrid.Reload(); + else + { + LogWarning($"Ignoring delete request for stale row <{entity?.GetSensibleObjectName()}>."); + } + + Reset(); + + if (dataGrid != null && (isInDataSource || isTrackedEditRow)) + { + await dataGrid.Reload(); + } } /// From a7b1a02a44c41b432baa2870ca5b0fa744a0628f Mon Sep 17 00:00:00 2001 From: AdrianHarwood Date: Mon, 22 Jun 2026 14:42:19 +0100 Subject: [PATCH 093/133] A few tweaks I noticed as I walked through how this works. --- PPMTool/Helpers/ChartHelper.cs | 2 +- PPMTool/Pages/AddPersonSkill.razor | 2 +- PPMTool/Pages/Components/SuggestResourcesComponent.razor | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/PPMTool/Helpers/ChartHelper.cs b/PPMTool/Helpers/ChartHelper.cs index 26d0c702..960371a3 100644 --- a/PPMTool/Helpers/ChartHelper.cs +++ b/PPMTool/Helpers/ChartHelper.cs @@ -27,7 +27,7 @@ public abstract class ChartHelper /// Function to define the secondary value of a given block /// Function that fills gaps in the chart items /// Function to provide HTML string to be shown as tooltip messages for block based on list of assignments that fall within the block - /// If true, does not create a block if it has a value of 0 for value 1, leaving a gap + /// If true, does not create a block if it has a value of 0 for value 1, leaving a gap. Default is false. /// public static IEnumerable ConvertAssignmentsToChartItems( IEnumerable assignments, diff --git a/PPMTool/Pages/AddPersonSkill.razor b/PPMTool/Pages/AddPersonSkill.razor index 0a817eff..1cfd984d 100644 --- a/PPMTool/Pages/AddPersonSkill.razor +++ b/PPMTool/Pages/AddPersonSkill.razor @@ -65,7 +65,7 @@ SPDX-License-Identifier: apache-2.0 Data="@ownedTags" TItem="OwnedSkill">