From 1008b271f9d14c90830a8f313258035d2b673405 Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Thu, 22 Aug 2024 14:02:16 +0200 Subject: [PATCH 1/2] feat: department-summary-weekly-worker (#668) - [x] New feature - [ ] Bug fix - [ ] High impact **Description of work:** [AB#51133](https://statoil-proview.visualstudio.com/787035c2-8cf2-4d73-a83e-bb0e6d937eec/_workitems/edit/51133) This takes the old report creation code and moves it to the new function app. The ResourceOwnerReportDataCreator.cs code remains untouched. **Testing:** - [ ] Can be tested - [ ] Automatic tests created / updated - [x] Local tests are passing I did some simple testing with hardcoded messages as the queue is not set up yet. **Checklist:** - [x] Considered automated tests - [x] Considered updating specification / documentation - [x] Considered work items - [x] Considered security - [x] Performed developer testing - [x] Checklist finalized / ready for review --- .../ApiClients/ISummaryApiClient.cs | 5 +- .../ApiClients/SummaryApiClient.cs | 34 +- .../Extensions/DateTimeExtensions.cs | 31 ++ .../Deployment/disabled-functions.json | 3 +- .../WeeklyDepartmentSummaryWorker.cs | 117 +++++++ .../Fusion.Summary.Functions.csproj | 1 + .../ResourceOwnerReportDataCreator.cs | 317 ++++++++++++++++++ .../local.settings.template.json | 2 + 8 files changed, 484 insertions(+), 26 deletions(-) create mode 100644 src/Fusion.Resources.Functions.Common/Extensions/DateTimeExtensions.cs create mode 100644 src/Fusion.Summary.Functions/Functions/WeeklyDepartmentSummaryWorker.cs create mode 100644 src/Fusion.Summary.Functions/ReportCreator/ResourceOwnerReportDataCreator.cs diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/ISummaryApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/ISummaryApiClient.cs index acf8dfc51..c31a9fa3f 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/ISummaryApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/ISummaryApiClient.cs @@ -14,6 +14,9 @@ public Task PutDepartmentAsync(ApiResourceOwnerDepartment departments, /// public Task GetLatestWeeklyReportAsync(string departmentSapId, CancellationToken cancellationToken = default); + + public Task PutWeeklySummaryReportAsync(string departmentSapId, ApiWeeklySummaryReport report, + CancellationToken cancellationToken = default); } #region Models @@ -71,7 +74,7 @@ public record ApiWeeklySummaryReport public record ApiPersonnelMoreThan100PercentFTE { public string FullName { get; set; } = "-"; - public int FTE { get; set; } = -1; + public double FTE { get; set; } = -1; } public record ApiEndingPosition diff --git a/src/Fusion.Resources.Functions.Common/ApiClients/SummaryApiClient.cs b/src/Fusion.Resources.Functions.Common/ApiClients/SummaryApiClient.cs index 131c67a92..ea7f406f2 100644 --- a/src/Fusion.Resources.Functions.Common/ApiClients/SummaryApiClient.cs +++ b/src/Fusion.Resources.Functions.Common/ApiClients/SummaryApiClient.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Fusion.Resources.Functions.Common.Extensions; using Fusion.Resources.Functions.Common.Integration.Http; namespace Fusion.Resources.Functions.Common.ApiClients; @@ -47,12 +48,10 @@ public async Task PutDepartmentAsync(ApiResourceOwnerDepartment department, public async Task GetLatestWeeklyReportAsync(string departmentSapId, CancellationToken cancellationToken = default) { - // Get the date of the last monday or today if today is monday - // So the weekly report is based on the week that has passed - var lastMonday = GetCurrentOrLastMondayDate(); + var lastMonday = DateTime.UtcNow.GetPreviousWeeksMondayDate(); var queryString = - $"summary-reports/{departmentSapId}/weekly?$filter=Period eq '{lastMonday.Date:O}'&$top=1"; + $"resource-owners-summary-reports/{departmentSapId}/weekly?$filter=Period eq '{lastMonday.Date:O}'&$top=1"; using var response = await summaryClient.GetAsync(queryString, cancellationToken); if (!response.IsSuccessStatusCode) @@ -65,26 +64,13 @@ public async Task PutDepartmentAsync(ApiResourceOwnerDepartment department, cancellationToken: cancellationToken))?.Items?.FirstOrDefault(); } - private static DateTime GetCurrentOrLastMondayDate() + public async Task PutWeeklySummaryReportAsync(string departmentSapId, ApiWeeklySummaryReport report, + CancellationToken cancellationToken = default) { - var date = DateTime.UtcNow; - switch (date.DayOfWeek) - { - case DayOfWeek.Sunday: - return date.AddDays(-6); - case DayOfWeek.Monday: - return date; - case DayOfWeek.Tuesday: - case DayOfWeek.Wednesday: - case DayOfWeek.Thursday: - case DayOfWeek.Friday: - case DayOfWeek.Saturday: - default: - { - var daysUntilMonday = (int)date.DayOfWeek - 1; - - return date.AddDays(-daysUntilMonday); - } - } + using var body = new JsonContent(JsonSerializer.Serialize(report, jsonSerializerOptions)); + + // Error logging is handled by http middleware => FunctionHttpMessageHandler + using var _ = await summaryClient.PutAsync($"resource-owners-summary-reports/{departmentSapId}/weekly", body, + cancellationToken); } } \ No newline at end of file diff --git a/src/Fusion.Resources.Functions.Common/Extensions/DateTimeExtensions.cs b/src/Fusion.Resources.Functions.Common/Extensions/DateTimeExtensions.cs new file mode 100644 index 000000000..b72e94358 --- /dev/null +++ b/src/Fusion.Resources.Functions.Common/Extensions/DateTimeExtensions.cs @@ -0,0 +1,31 @@ +namespace Fusion.Resources.Functions.Common.Extensions; + +public static class DateTimeExtensions +{ + /// + /// Returns a new DateTime object with the date set to monday last week. + /// + public static DateTime GetPreviousWeeksMondayDate(this DateTime date) + { + switch (date.DayOfWeek) + { + case DayOfWeek.Sunday: + return date.AddDays(-6); + case DayOfWeek.Monday: + return date.AddDays(-7); + case DayOfWeek.Tuesday: + case DayOfWeek.Wednesday: + case DayOfWeek.Thursday: + case DayOfWeek.Friday: + case DayOfWeek.Saturday: + default: + { + // Calculate days until previous monday + // Go one week back and then remove the days until the monday + var daysUntilLastWeeksMonday = (1 - (int)date.DayOfWeek) - 7; + + return date.AddDays(daysUntilLastWeeksMonday); + } + } + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Functions/Deployment/disabled-functions.json b/src/Fusion.Summary.Functions/Deployment/disabled-functions.json index cd6c76af9..0a590e596 100644 --- a/src/Fusion.Summary.Functions/Deployment/disabled-functions.json +++ b/src/Fusion.Summary.Functions/Deployment/disabled-functions.json @@ -3,7 +3,8 @@ "environment": "pr", "disabledFunctions": [ "department-resource-owner-sync", - "weekly-report-sender" + "weekly-report-sender", + "weekly-department-summary-worker" ] } ] diff --git a/src/Fusion.Summary.Functions/Functions/WeeklyDepartmentSummaryWorker.cs b/src/Fusion.Summary.Functions/Functions/WeeklyDepartmentSummaryWorker.cs new file mode 100644 index 000000000..fcc322d92 --- /dev/null +++ b/src/Fusion.Summary.Functions/Functions/WeeklyDepartmentSummaryWorker.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Azure.Messaging.ServiceBus; +using Fusion.Integration.Profile; +using Fusion.Resources.Functions.Common.ApiClients; +using Fusion.Resources.Functions.Common.Extensions; +using Fusion.Summary.Functions.ReportCreator; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.ServiceBus; +using Microsoft.Extensions.Logging; +using JsonSerializer = System.Text.Json.JsonSerializer; + +namespace Fusion.Summary.Functions.Functions; + +public class WeeklyDepartmentSummaryWorker +{ + private readonly IResourcesApiClient _resourceClient; + private readonly ISummaryApiClient _summaryApiClient; + private readonly ILogger _logger; + + public WeeklyDepartmentSummaryWorker(IResourcesApiClient resourceClient, ILogger logger, ISummaryApiClient summaryApiClient) + { + _resourceClient = resourceClient; + _logger = logger; + _summaryApiClient = summaryApiClient; + } + + [FunctionName("weekly-department-summary-worker")] + public async Task RunAsync( + [ServiceBusTrigger("%department_summary_weekly_queue%", Connection = "AzureWebJobsServiceBus")] + ServiceBusReceivedMessage message, ServiceBusMessageActions messageReceiver) + { + try + { + var dto = await JsonSerializer.DeserializeAsync(message.Body.ToStream()); + + if (string.IsNullOrEmpty(dto.FullDepartmentName)) + throw new Exception("FullDepartmentIdentifier not valid."); + + await CreateAndStoreReportAsync(dto); + } + catch (Exception e) + { + _logger.LogError(e, "Error while processing message"); + throw; + } + finally + { + // Complete the message regardless of outcome. + await messageReceiver.CompleteMessageAsync(message); + } + } + + private async Task CreateAndStoreReportAsync(ApiResourceOwnerDepartment message) + { + var departmentRequests = (await _resourceClient.GetAllRequestsForDepartment(message.FullDepartmentName)).ToList(); + + var departmentPersonnel = (await _resourceClient.GetAllPersonnelForDepartment(message.FullDepartmentName)) + .Where(per => + per.AccountType != FusionAccountType.Consultant.ToString() && + per.AccountType != FusionAccountType.External.ToString()) + .ToList(); + + // Check if the department has personnel, abort if not + if (departmentPersonnel.Count == 0) + { + _logger.LogInformation("Department contains no personnel, no need to store report"); + return; + } + + var report = await BuildSummaryReportAsync(departmentPersonnel, departmentRequests, message.DepartmentSapId); + + await _summaryApiClient.PutWeeklySummaryReportAsync(message.DepartmentSapId, report); + } + + + private static Task BuildSummaryReportAsync( + List personnel, + List requests, + string departmentSapId) + { + var report = new ApiWeeklySummaryReport() + { + Period = DateTime.UtcNow.GetPreviousWeeksMondayDate(), + DepartmentSapId = departmentSapId, + PositionsEnding = ResourceOwnerReportDataCreator + .GetPersonnelPositionsEndingWithNoFutureAllocation(personnel) + .Select(ep => new ApiEndingPosition() + { + EndDate = ep.EndDate.GetValueOrDefault(DateTime.MinValue), + FullName = ep.FullName + }) + .ToArray(), + PersonnelMoreThan100PercentFTE = ResourceOwnerReportDataCreator + .GetPersonnelAllocatedMoreThan100Percent(personnel) + .Select(ep => new ApiPersonnelMoreThan100PercentFTE() + { + FullName = ep.FullName, + FTE = ep.TotalWorkload + }) + .ToArray(), + NumberOfPersonnel = ResourceOwnerReportDataCreator.GetTotalNumberOfPersonnel(personnel).ToString(), + CapacityInUse = ResourceOwnerReportDataCreator.GetCapacityInUse(personnel).ToString(), + NumberOfRequestsLastPeriod = ResourceOwnerReportDataCreator.GetNumberOfRequestsLastWeek(requests).ToString(), + NumberOfOpenRequests = ResourceOwnerReportDataCreator.GetNumberOfOpenRequests(requests).ToString(), + NumberOfRequestsStartingInLessThanThreeMonths = ResourceOwnerReportDataCreator.GetNumberOfRequestsStartingInLessThanThreeMonths(requests).ToString(), + NumberOfRequestsStartingInMoreThanThreeMonths = ResourceOwnerReportDataCreator.GetNumberOfRequestsStartingInMoreThanThreeMonths(requests).ToString(), + AverageTimeToHandleRequests = ResourceOwnerReportDataCreator.GetAverageTimeToHandleRequests(requests).ToString(), + AllocationChangesAwaitingTaskOwnerAction = ResourceOwnerReportDataCreator.GetAllocationChangesAwaitingTaskOwnerAction(requests).ToString(), + ProjectChangesAffectingNextThreeMonths = ResourceOwnerReportDataCreator.CalculateDepartmentChangesLastWeek(personnel).ToString() + }; + + return Task.FromResult(report); + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Functions/Fusion.Summary.Functions.csproj b/src/Fusion.Summary.Functions/Fusion.Summary.Functions.csproj index a7ddd6657..f05f8c045 100644 --- a/src/Fusion.Summary.Functions/Fusion.Summary.Functions.csproj +++ b/src/Fusion.Summary.Functions/Fusion.Summary.Functions.csproj @@ -5,6 +5,7 @@ + diff --git a/src/Fusion.Summary.Functions/ReportCreator/ResourceOwnerReportDataCreator.cs b/src/Fusion.Summary.Functions/ReportCreator/ResourceOwnerReportDataCreator.cs new file mode 100644 index 000000000..fad349e20 --- /dev/null +++ b/src/Fusion.Summary.Functions/ReportCreator/ResourceOwnerReportDataCreator.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Fusion.Resources.Functions.Common.ApiClients; + +namespace Fusion.Summary.Functions.ReportCreator; + +public abstract class ResourceOwnerReportDataCreator +{ + public static int GetTotalNumberOfPersonnel( + IEnumerable listOfInternalPersonnel) + { + return listOfInternalPersonnel.Count(); + } + + public static int GetCapacityInUse( + List listOfInternalPersonnel) + { + var actualWorkLoad = 0.0; + var actualLeave = 0.0; + foreach (var personnel in listOfInternalPersonnel) + { + actualWorkLoad += personnel.PositionInstances.Where(pos => pos.IsActive).Select(pos => pos.Workload).Sum(); + + actualWorkLoad += personnel.EmploymentStatuses + .Where(ab => ab.Type == IResourcesApiClient.ApiAbsenceType.OtherTasks && ab.IsActive) + .Select(ab => ab.AbsencePercentage) + .Sum() ?? 0; + + actualLeave += personnel.EmploymentStatuses + .Where(ab => + ab.Type is IResourcesApiClient.ApiAbsenceType.Absence + or IResourcesApiClient.ApiAbsenceType.Vacation && ab.IsActive) + .Select(ab => ab.AbsencePercentage) + .Sum() ?? 0; + } + + var maximumPotentialWorkLoad = listOfInternalPersonnel.Count * 100; + var potentialWorkLoad = maximumPotentialWorkLoad - actualLeave; + if (potentialWorkLoad <= 0) + return 0; + var capacityInUse = actualWorkLoad / potentialWorkLoad * 100; + if (capacityInUse < 0) + return 0; + + return (int)Math.Round(capacityInUse); + } + + public static int GetNumberOfRequestsLastWeek( + IEnumerable requests) + { + return requests + .Count(req => + req.Type != null && !req.Type.Equals(RequestType.ResourceOwnerChange.ToString(), + StringComparison.OrdinalIgnoreCase) + && req.Created > DateTime.UtcNow.AddDays(-7) && !req.IsDraft); + } + + public static int GetNumberOfOpenRequests( + IEnumerable requests) + => requests.Count(req => + req.State != null && req.Type != null && !req.Type.Equals(RequestType.ResourceOwnerChange.ToString(), + StringComparison.OrdinalIgnoreCase) && + !req.HasProposedPerson && + !req.State.Equals(RequestState.Completed.ToString(), StringComparison.OrdinalIgnoreCase)); + + + public static int GetNumberOfRequestsStartingInMoreThanThreeMonths( + IEnumerable requests) + { + var threeMonthsFromToday = DateTime.UtcNow.AddMonths(3); + return requests + .Count(x => x.Type != null && + x.OrgPositionInstance != null && + x.State != null && + !x.State.Equals(RequestState.Completed.ToString(), StringComparison.OrdinalIgnoreCase) && + !x.Type.Equals(RequestType.ResourceOwnerChange.ToString(), + StringComparison.OrdinalIgnoreCase) && + x.OrgPositionInstance.AppliesFrom > threeMonthsFromToday); + } + + public static int GetNumberOfRequestsStartingInLessThanThreeMonths( + IEnumerable requests) + { + var threeMonthsFromToday = DateTime.UtcNow.AddMonths(3); + var today = DateTime.UtcNow; + return requests + .Count(x => x.Type != null && + x.OrgPositionInstance != null && + x.State != null && + !x.State.Equals(RequestState.Completed.ToString(), StringComparison.OrdinalIgnoreCase) && + !x.Type.Equals(RequestType.ResourceOwnerChange.ToString(), + StringComparison.OrdinalIgnoreCase) && + x.OrgPositionInstance.AppliesFrom < threeMonthsFromToday && + x.OrgPositionInstance.AppliesFrom > today && !x.HasProposedPerson); + } + + public static int GetAverageTimeToHandleRequests( + IEnumerable requests) + { + /* + * Average time to handle request: average number of days from request created/sent to candidate is proposed - last 12 months + * Calculation: + * We find all the requests for the last 12 months that are not of type "ResourceOwnerChange" (for the specific Department) + * For each of these request we find the number of days that it takes from when a requests is created (which is when the request is sent from Task Owner to ResourceOwner) + * to the requests is handeled by ResourceOwner (a person is proposed). If the request is still being processed it will not have a date for when + * it is handled (proposed date), and then we will use todays date. + * We then sum up the total amount of days used to handle a request and divide by the total number of requests for which we have found the handle-time + */ + + var requestsHandledByResourceOwner = 0; + var totalNumberOfDays = 0.0; + var twelveMonths = DateTime.UtcNow.AddMonths(-12); + + + // Not to include requests that are sent by ResourceOwners (ResourceOwnerChange) or requests created more than 3 months ago + var requestsLastTwelveMonthsWithoutResourceOwnerChangeRequest = requests + .Where(req => req.Created > twelveMonths) + .Where(r => r.Workflow is not null) + .Where(_ => true) + .Where(req => req.Type != null && !req.Type.Equals(RequestType.ResourceOwnerChange.ToString(), + StringComparison.OrdinalIgnoreCase)); + + foreach (var request in requestsLastTwelveMonthsWithoutResourceOwnerChangeRequest) + { + // If the requests doesnt have state it means that it is in draft. Do not need to check these + if (request.State == null) + continue; + if (request.Workflow?.Steps is null) + continue; + + // First: find the date for creation (this implies that the request has been sent to resourceowner) + var dateForCreation = request.Workflow.Steps + .FirstOrDefault(step => step.Name.Equals("Created") && step.IsCompleted)?.Completed.Value.DateTime; + + if (dateForCreation == null) + continue; + + //Second: Try to find the date for proposed (this implies that resourceowner have handled the request) + var dateOfApprovalOrToday = request.Workflow.Steps + .FirstOrDefault(step => step.Name.Equals("Proposed") && step.IsCompleted)?.Completed.Value.DateTime; + + // if there are no proposal date we will used todays date for calculation + dateOfApprovalOrToday = dateOfApprovalOrToday ?? DateTime.UtcNow; + + + requestsHandledByResourceOwner++; + var timespanDifference = dateOfApprovalOrToday - dateForCreation; + var differenceInDays = timespanDifference.Value.TotalDays; + totalNumberOfDays += differenceInDays; + } + + if (requestsHandledByResourceOwner <= 0) + return 0; + + var averageAmountOfTimeDouble = totalNumberOfDays / requestsHandledByResourceOwner; + return (int)Math.Round(averageAmountOfTimeDouble); + } + + public static int GetAllocationChangesAwaitingTaskOwnerAction( + IEnumerable requests) + { + return requests + .Where(req => + req.Type.Equals(RequestType.ResourceOwnerChange.ToString(), StringComparison.OrdinalIgnoreCase)) + .Where(req => + req.State != null && + req.State.Equals(RequestState.Created.ToString(), StringComparison.OrdinalIgnoreCase)) + .ToList() + .Count; + } + + public static int CalculateDepartmentChangesLastWeek(IEnumerable internalPersonnel) + { + /* + * How we calculate the changes: + * Find all active instanses or all instanses for each personnel that starts within 3 months + * To find the instances that have changes related to them: + * Find all instances that have the field "AllocationState" not set to null + * Find all instances where AllocationUpdated > 7 days ago + */ + + var threeMonthsFromToday = DateTime.UtcNow.AddMonths(3); + var today = DateTime.UtcNow; + var weekBackInTime = DateTime.UtcNow.AddDays(-7); + + // Find all active (IsActive) instances or instances that have start date (appliesFrom) > threeMonthsFromToday + var instancesThatAreActiveOrBecomesActiveWithinThreeMonths = internalPersonnel + .SelectMany(per => per.PositionInstances + .Where(pis => (pis.AppliesFrom < threeMonthsFromToday && pis.AppliesFrom > today) || pis.AppliesTo > today || pis.IsActive)); + + var instancesWithAllocationStateSetAndAllocationUpdateWithinLastWeek = instancesThatAreActiveOrBecomesActiveWithinThreeMonths + .Where(per => per.AllocationState != null) + .Where(pos => pos.AllocationUpdated != null && pos.AllocationUpdated > weekBackInTime).ToList(); + + return instancesWithAllocationStateSetAndAllocationUpdateWithinLastWeek.Count(); + } + + public static IEnumerable GetPersonnelPositionsEndingWithNoFutureAllocation( + IEnumerable listOfInternalPersonnel) + { + return listOfInternalPersonnel + .Where(AllocatedPersonWithNoFutureAllocation.GotFutureAllocation) + .Select(AllocatedPersonWithNoFutureAllocation.Create); + } + + public static IEnumerable GetPersonnelAllocatedMoreThan100Percent( + IEnumerable listOfInternalPersonnel) + { + return listOfInternalPersonnel + .Select(AllocatedPersonnelWithWorkLoad.Create) + .Where(p => p.TotalWorkload > 100); + } +} + +public class AllocatedPersonnel +{ + public string FullName { get; } + + protected AllocatedPersonnel(IResourcesApiClient.InternalPersonnelPerson person) + { + FullName = person.Name; + } +} + +public class AllocatedPersonnelWithWorkLoad : AllocatedPersonnel +{ + public double TotalWorkload { get; } + + private AllocatedPersonnelWithWorkLoad(IResourcesApiClient.InternalPersonnelPerson person) : base(person) + { + TotalWorkload = CalculateTotalWorkload(person); + } + + public static AllocatedPersonnelWithWorkLoad Create(IResourcesApiClient.InternalPersonnelPerson person) + { + return new AllocatedPersonnelWithWorkLoad(person); + } + + private double CalculateTotalWorkload(IResourcesApiClient.InternalPersonnelPerson person) + { + var totalWorkLoad = person.EmploymentStatuses + .Where(ab => ab.Type != IResourcesApiClient.ApiAbsenceType.Absence && ab.IsActive) + .Select(ab => ab.AbsencePercentage).Sum(); + totalWorkLoad += person.PositionInstances.Where(pos => pos.IsActive).Select(pos => pos.Workload).Sum(); + if (totalWorkLoad is null) + return 0; + + if (totalWorkLoad < 0) + return 0; + + return totalWorkLoad.Value; + } +} + +public class AllocatedPersonWithNoFutureAllocation : AllocatedPersonnel +{ + public DateTime? EndDate { get; } + + + private AllocatedPersonWithNoFutureAllocation(IResourcesApiClient.InternalPersonnelPerson person) : base(person) + { + var endingPosition = person.PositionInstances.Find(instance => instance.IsActive); + if (endingPosition is null) + { + EndDate = null; + return; + } + + EndDate = endingPosition.AppliesTo; + } + + public static AllocatedPersonWithNoFutureAllocation Create(IResourcesApiClient.InternalPersonnelPerson person) + { + return new AllocatedPersonWithNoFutureAllocation(person); + } + + public static bool GotFutureAllocation(IResourcesApiClient.InternalPersonnelPerson person) + { + var gotLongLastingPosition = person.PositionInstances.Any(pdi => pdi.AppliesTo >= DateTime.UtcNow.AddMonths(3)); + if (gotLongLastingPosition) + return false; + + var gotFutureAllocation = person.PositionInstances.Any(pdi => pdi.AppliesFrom > DateTime.UtcNow); + if (gotFutureAllocation) + return false; + + return person.PositionInstances.Any(pdi => pdi.IsActive); + } +} + +public enum RequestState +{ + Approval, + Proposal, + Provisioning, + Created, + Completed +} + +public enum RequestType +{ + Allocation, + ResourceOwnerChange +} + +public enum ChangeType +{ + PositionInstanceCreated, + PersonAssignedToPosition, + PositionInstanceAllocationStateChanged, + PositionInstanceAppliesToChanged, + PositionInstanceAppliesFromChanged, + PositionInstanceParentPositionIdChanged, + PositionInstancePercentChanged, + PositionInstanceLocationChanged +} \ No newline at end of file diff --git a/src/Fusion.Summary.Functions/local.settings.template.json b/src/Fusion.Summary.Functions/local.settings.template.json index a7470406d..d49fbe185 100644 --- a/src/Fusion.Summary.Functions/local.settings.template.json +++ b/src/Fusion.Summary.Functions/local.settings.template.json @@ -6,6 +6,8 @@ "AzureAd_TenantId": "3aa4a235-b6e2-48d5-9195-7fcf05b459b0", "AzureAd_ClientId": "5a842df8-3238-415d-b168-9f16a6a6031b", "AzureAd_Secret": "[REPLACE WITH SECRET]", + "department_summary_weekly_queue": "department-summary-weekly-queue-[REPLACE WITH DEV QUEUE]", + "AzureWebJobsServiceBus": "[REPLACE WITH SB CONNECTION STRING]", "Endpoints_lineorg": "https://fusion-s-lineorg-ci.azurewebsites.net", "Endpoints_people": "https://fusion-s-people-ci.azurewebsites.net", "Endpoints_org": "https://fusion-s-org-ci.azurewebsites.net", From 9bc94fd805d89b18e392ea44838085a60166e929 Mon Sep 17 00:00:00 2001 From: Jonathan Idland Olsnes <73334350+Jonathanio123@users.noreply.github.com> Date: Thu, 22 Aug 2024 15:05:23 +0200 Subject: [PATCH 2/2] chore(summary): Add key vault and fusion sql token support (#666) - [x] New feature - [ ] Bug fix - [ ] High impact **Description of work:** [AB#55619](https://statoil-proview.visualstudio.com/787035c2-8cf2-4d73-a83e-bb0e6d937eec/_workitems/edit/55619) This adds the keyvault support and azure sql token support. I've tested connecting to the Summary CI database which works. **Testing:** - [ ] Can be tested - [ ] Automatic tests created / updated - [x] Local tests are passing **Checklist:** - [ ] Considered automated tests - [ ] Considered updating specification / documentation - [x] Considered work items - [x] Considered security - [x] Performed developer testing - [x] Checklist finalized / ready for review --- .../ConfigurationExtensions.cs | 30 +++++++++++++++++++ .../Database/SqlTokenProvider.cs | 19 ++++++++++++ .../Fusion.Summary.Api.csproj | 1 + src/Fusion.Summary.Api/Program.cs | 26 +++++++++++----- 4 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 src/Fusion.Summary.Api/ConfigurationExtensions.cs create mode 100644 src/Fusion.Summary.Api/Database/SqlTokenProvider.cs diff --git a/src/Fusion.Summary.Api/ConfigurationExtensions.cs b/src/Fusion.Summary.Api/ConfigurationExtensions.cs new file mode 100644 index 000000000..519c315ab --- /dev/null +++ b/src/Fusion.Summary.Api/ConfigurationExtensions.cs @@ -0,0 +1,30 @@ +using Azure.Identity; + +namespace Fusion.Summary.Api; + +public static class ConfigurationExtensions +{ + public static void AddKeyVault(this WebApplicationBuilder builder) + { + var configuration = builder.Configuration; + var clientId = configuration["AzureAd:ClientId"]; + var tenantId = configuration["AzureAd:TenantId"]; + var clientSecret = configuration["AzureAd:ClientSecret"]; + var keyVaultUrl = configuration["KEYVAULT_URL"]; + + if (string.IsNullOrWhiteSpace(keyVaultUrl)) + { + Console.WriteLine("Skipping key vault as url is null, empty or whitespace."); + return; + } + + if (string.IsNullOrWhiteSpace(clientSecret)) + { + Console.WriteLine("Skipping key vault as clientSecret is null, empty or whitespace."); + return; + } + + var credential = new ClientSecretCredential(tenantId, clientId, clientSecret); + configuration.AddAzureKeyVault(new Uri(keyVaultUrl), credential); + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Database/SqlTokenProvider.cs b/src/Fusion.Summary.Api/Database/SqlTokenProvider.cs new file mode 100644 index 000000000..cef00b878 --- /dev/null +++ b/src/Fusion.Summary.Api/Database/SqlTokenProvider.cs @@ -0,0 +1,19 @@ +using Fusion.Infrastructure.Database; +using Fusion.Integration.Configuration; + +namespace Fusion.Summary.Api.Database; + +public class SqlTokenProvider : ISqlTokenProvider +{ + private readonly IFusionTokenProvider fusionTokenProvider; + + public SqlTokenProvider(IFusionTokenProvider fusionTokenProvider) + { + this.fusionTokenProvider = fusionTokenProvider; + } + + public Task GetAccessTokenAsync() + { + return fusionTokenProvider.GetApplicationTokenAsync("https://database.windows.net/"); + } +} \ No newline at end of file diff --git a/src/Fusion.Summary.Api/Fusion.Summary.Api.csproj b/src/Fusion.Summary.Api/Fusion.Summary.Api.csproj index 838b39f97..0a9d61b22 100644 --- a/src/Fusion.Summary.Api/Fusion.Summary.Api.csproj +++ b/src/Fusion.Summary.Api/Fusion.Summary.Api.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Fusion.Summary.Api/Program.cs b/src/Fusion.Summary.Api/Program.cs index 516551114..5f3858339 100644 --- a/src/Fusion.Summary.Api/Program.cs +++ b/src/Fusion.Summary.Api/Program.cs @@ -3,6 +3,7 @@ using FluentValidation.AspNetCore; using Fusion.AspNetCore.Versioning; using Fusion.Resources.Api.Middleware; +using Fusion.Summary.Api; using Fusion.Summary.Api.Database; using Fusion.Summary.Api.Middleware; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -13,24 +14,30 @@ var builder = WebApplication.CreateBuilder(args); -builder.Configuration - .AddJsonFile("/app/secrets/appsettings.secrets.yaml", optional: true) - .AddJsonFile("/app/static/config/env.json", optional: true, reloadOnChange: true); +if (Environment.GetEnvironmentVariable("INTEGRATION_TEST_RUN") != "true") +{ + builder.Configuration + .AddJsonFile("/app/secrets/appsettings.secrets.yaml", optional: true) + .AddJsonFile("/app/config/appsettings.json", optional: true); // to be able to override settings by using a config map in kubernetes + + builder.AddKeyVault(); +} var azureAdClientId = builder.Configuration["AzureAd:ClientId"]; var azureAdClientSecret = builder.Configuration["AzureAd:ClientSecret"]; +var certThumbprint = builder.Configuration["Config:CertThumbprint"]; +var environment = builder.Configuration["Environment"]; var fusionEnvironment = builder.Configuration["FUSION_ENVIRONMENT"]; -var databaseConnectionString = builder.Configuration.GetConnectionString(nameof(SummaryDbContext)); +var databaseConnectionString = builder.Configuration.GetConnectionString(nameof(SummaryDbContext))!; builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddHealthChecks() .AddCheck("liveness", () => HealthCheckResult.Healthy()) .AddCheck("db", () => HealthCheckResult.Healthy(), tags: ["ready"]); -// TODO: Add a real health check, when database is added +// TODO: Add a real health check, when database is added in deployment pipelines and PR pipelines // .AddDbContextCheck("db", tags: new[] { "ready" }); - builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => @@ -54,17 +61,20 @@ builder.Services.AddFusionIntegration(f => { f.AddFusionAuthorization(); - f.UseServiceInformation("Fusion.Summary.Api", "Dev"); + f.UseServiceInformation("Fusion.Summary.Api", environment); f.UseDefaultEndpointResolver(fusionEnvironment ?? "ci"); f.UseDefaultTokenProvider(opts => { opts.ClientId = azureAdClientId ?? throw new InvalidOperationException("Missing AzureAd:ClientId"); opts.ClientSecret = azureAdClientSecret; + opts.CertificateThumbprint = certThumbprint; }); }); builder.Services.AddApplicationInsightsTelemetry(); -builder.Services.AddDbContext(options => options.UseSqlServer(databaseConnectionString)); +builder.Services.AddSqlDbContext(databaseConnectionString, enablePullRequestEnv: false) + .AddSqlTokenProvider() + .AddAccessTokenSupport(); builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); builder.Services.AddFluentValidationAutoValidation().AddValidatorsFromAssembly(typeof(Program).Assembly);