Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(summary): AZ function Task owner report sync #713

Merged
merged 19 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions infrastructure/arm/environment.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,22 @@
"maxDeliveryCount": 2
}
},
{
"type": "Microsoft.ServiceBus/namespaces/queues",
"apiVersion": "2017-04-01",
"name": "[concat(variables('sb-name'), '/scheduled-weekly-project-report')]",
"location": "North Europe",
"dependsOn": [
"[resourceId('Microsoft.ServiceBus/namespaces', variables('sb-name'))]"
],
"properties": {
"maxSizeInMegabytes": 1024,
"duplicateDetectionHistoryTimeWindow": "P1D",
"defaultMessageTimeToLive": "PT1H",
"deadLetteringOnMessageExpiration": true,
"maxDeliveryCount": 2
}
},

/* SECRETS */
{
Expand Down
5 changes: 4 additions & 1 deletion pipelines/templates/deploy-summary-function-pr-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,12 @@ steps:
#
# Sett correct resources URI based on environment
$resourcesFunctionUri = "https://resources-api-pr-$pullRequestNumber.fusion-dev.net/"
$summaryFunctionUri = "https://summary-api-pr-$pullRequestNumber.fusion-dev.net/"
$summaryFunctionUri = "https://fra-summary-$pullRequestNumber.pr.api.fusion-dev.net/"

$settings = @{
clientId = "${{ parameters.clientId }}"
departmentFilter = "PRD"
projectTypeFilter = "PRD"
secretIds = @{
clientSecret = "https://$envVaultName.vault.azure.net:443/secrets/AzureAd--ClientSecret"
serviceBus = "https://$envVaultName.vault.azure.net:443/secrets/Connectionstrings--ServiceBus"
Expand All @@ -63,12 +64,14 @@ steps:
notifications = "https://fusion-s-notification-$fusionEnvironment.azurewebsites.net"
context = "https://fusion-s-context-$fusionEnvironment.azurewebsites.net"
portal = "https://fusion-s-portal-$fusionEnvironment.azurewebsites.net"
roles = "https://fusion-s-roles-$fusionEnvironment.azurewebsites.net"
}
resources = @{
fusion = "${{ parameters.fusionResource }}"
}
queues = @{
departmentSummaryWeeklyQueue = "scheduled-weekly-department-report"
projectSummaryWeeklyQueue = "scheduled-weekly-project-report"
}
}

Expand Down
3 changes: 3 additions & 0 deletions pipelines/templates/deploy-summary-function-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ steps:
$settings = @{
clientId = "${{ parameters.clientId }}"
departmentFilter = "PRD"
projectTypeFilter = "PRD"
secretIds = @{
clientSecret = Get-SecretsId -secret AzureAd--ClientSecret
serviceBus = Get-SecretsId -secret Connectionstrings--ServiceBus
Expand All @@ -61,12 +62,14 @@ steps:
notifications = "https://fusion-s-notification-$fusionEnvironment.azurewebsites.net"
context = "https://fusion-s-context-$fusionEnvironment.azurewebsites.net"
portal = "https://fusion-s-portal-$fusionEnvironment.azurewebsites.net"
roles = "https://fusion-s-roles-$fusionEnvironment.azurewebsites.net"
}
resources = @{
fusion = "${{ parameters.fusionResource }}"
}
queues = @{
departmentSummaryWeeklyQueue = "scheduled-weekly-department-report"
projectSummaryWeeklyQueue = "scheduled-weekly-project-report"
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@

namespace Fusion.Resources.Functions.Common.ApiClients.ApiModels;

public class ApiSinglePersonRole
{
public ApiSingleRoleScope Scope { get; init; } = null!;

public ApiPerson? Person { get; init; }

public DateTimeOffset? ValidTo { get; init; }
}

public class ApiSingleRoleScope
{
public ApiSingleRoleScope(string type, string value)
{
Type = type;
Value = value;
}

public string Type { get; init; }
public string Value { get; init; }
}

public class ApiPerson
{
public Guid Id { get; init; }
public string? Mail { get; init; }
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using Fusion.ApiClients.Org;
using Fusion.Integration.Core.Http.OData;
using Fusion.Services.Org.ApiModels;
using Newtonsoft.Json;

namespace Fusion.Resources.Functions.Common.ApiClients;

public interface IOrgClient
{
Task<ApiChangeLog> GetChangeLog(string projectId, DateTime timestamp);

Task<List<ApiProjectV2>> GetProjectsAsync(ODataQuery? query = null, CancellationToken cancellationToken = default);
}

#region model
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Fusion.Resources.Functions.Common.ApiClients.ApiModels;

namespace Fusion.Resources.Functions.Common.ApiClients;

public interface IRolesApiClient
{
public Task<Dictionary<Guid, ICollection<ApiSinglePersonRole>>> GetAdminRolesForOrgProjects(ICollection<Guid> projectIds, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace Fusion.Resources.Functions.Common.ApiClients;

public interface ISummaryApiClient
{
/// <exception cref="SummaryApiException"></exception>
/// <exception cref="SummaryApiError"></exception>
public Task PutDepartmentAsync(ApiResourceOwnerDepartment departments,
CancellationToken cancellationToken = default);

Expand All @@ -23,6 +23,12 @@ public Task PutDepartmentAsync(ApiResourceOwnerDepartment departments,
/// <exception cref="SummaryApiError"></exception>
public Task PutWeeklySummaryReportAsync(string departmentSapId, ApiWeeklySummaryReport report,
CancellationToken cancellationToken = default);

/// <exception cref="SummaryApiError" />
public Task<ICollection<ApiProject>> GetProjectsAsync(CancellationToken cancellationToken = default);

/// <exception cref="SummaryApiError" />
public Task<ApiProject> PutProjectAsync(ApiProject project, CancellationToken cancellationToken = default);
}

#region Models
Expand Down Expand Up @@ -84,4 +90,17 @@ public record ApiEndingPosition
public DateTime EndDate { get; set; }
}

[DebuggerDisplay("{Id} - {Name}")]
public class ApiProject
{
public required Guid Id { get; set; }

public required string Name { get; set; }
public required Guid OrgProjectExternalId { get; set; }

public Guid? DirectorAzureUniqueId { get; set; }

public Guid[] AssignedAdminsAzureUniqueId { get; set; } = [];
}

#endregion
10 changes: 8 additions & 2 deletions src/Fusion.Resources.Functions.Common/ApiClients/OrgApiClient.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#nullable enable
using Fusion.Resources.Functions.Common.Integration.Http;
using Fusion.Resources.Functions.Common.Integration.Http;
using Fusion.Services.Org.ApiModels;

namespace Fusion.Resources.Functions.Common.ApiClients;

Expand All @@ -22,4 +22,10 @@ await orgClient.GetAsJsonAsync<ApiChangeLog>(

return data;
}

public Task<List<ApiProjectV2>> GetProjectsAsync(ODataQuery? query = null, CancellationToken cancellationToken = default)
{
var url = ODataQuery.ApplyQueryString("/projects", query);
return orgClient.GetAsJsonAsync<List<ApiProjectV2>>(url, cancellationToken: cancellationToken);
}
}
36 changes: 36 additions & 0 deletions src/Fusion.Resources.Functions.Common/ApiClients/RolesApiClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System.Net.Http.Json;
using System.Text.Json;
using Fusion.Resources.Functions.Common.ApiClients.ApiModels;
using Fusion.Resources.Functions.Common.Integration.Http;

namespace Fusion.Resources.Functions.Common.ApiClients;

public class RolesApiClient : IRolesApiClient
{
private readonly HttpClient rolesClient;

public RolesApiClient(IHttpClientFactory clientFactory)
{
rolesClient = clientFactory.CreateClient(HttpClientNames.Application.Roles);
}

private static string GetActiveOrgAdminsOdataQuery() => "scope.type eq 'OrgChart' and roleName eq 'Fusion.OrgChart.Admin' and source eq 'Fusion.Roles' and " +
$"validTo gt '{DateTime.UtcNow:O}'";

public async Task<Dictionary<Guid, ICollection<ApiSinglePersonRole>>> GetAdminRolesForOrgProjects(ICollection<Guid> projectIds, CancellationToken cancellationToken = default)
{
var odataQuery = new ODataQuery();

odataQuery.Filter = GetActiveOrgAdminsOdataQuery();

var url = ODataQuery.ApplyQueryString("/roles", odataQuery);
var data = await rolesClient.GetFromJsonAsync<List<ApiSinglePersonRole>>(url, cancellationToken: cancellationToken)
?? throw new InvalidOperationException("Roles response was null");

return data
// Filter roles to projects in memory to avoid a very lage OData query (url)
.Where(r => Guid.TryParse(r.Scope.Value, out var projectId) && projectIds.Contains(projectId))
.GroupBy(r => Guid.Parse(r.Scope.Value))
.ToDictionary(g => g.Key, ICollection<ApiSinglePersonRole> (g) => g.ToArray());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ public async Task PutDepartmentAsync(ApiResourceOwnerDepartment department,
await ThrowIfUnsuccessfulAsync(response);
}

public async Task<ApiProject> PutProjectAsync(ApiProject project, CancellationToken cancellationToken = default)
{
using var body = new JsonContent(JsonSerializer.Serialize(project, jsonSerializerOptions));

// Error logging is handled by http middleware => FunctionHttpMessageHandler
using var response = await summaryClient.PutAsync($"projects/{project.Id}", body, cancellationToken);

await ThrowIfUnsuccessfulAsync(response);

await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);

return (await JsonSerializer.DeserializeAsync<ApiProject>(contentStream, jsonSerializerOptions, cancellationToken: cancellationToken))!;
}

public async Task<ICollection<ApiResourceOwnerDepartment>?> GetDepartmentsAsync(
CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -78,6 +92,18 @@ public async Task PutWeeklySummaryReportAsync(string departmentSapId, ApiWeeklyS
await ThrowIfUnsuccessfulAsync(response);
}

public async Task<ICollection<ApiProject>> GetProjectsAsync(CancellationToken cancellationToken = default)
{
using var response = await summaryClient.GetAsync("projects", cancellationToken);

await ThrowIfUnsuccessfulAsync(response);

await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);

return await JsonSerializer.DeserializeAsync<ICollection<ApiProject>>(contentStream,
jsonSerializerOptions, cancellationToken: cancellationToken) ?? [];
}

private async Task ThrowIfUnsuccessfulAsync(HttpResponseMessage response)
=> await response.ThrowIfUnsuccessfulAsync((responseBody) => new SummaryApiError(response, responseBody));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ public static IServiceCollection AddHttpClients(this IServiceCollection services
builder.AddOrgClient();
services.AddScoped<IOrgClient, OrgClient>();

builder.AddRolesClient();
services.AddScoped<IRolesApiClient, RolesApiClient>();

return services;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Newtonsoft.Json;

namespace Fusion.Resources.Functions.Common.Extensions;

public static class ObjectExtensions
{
public static string ToJson(this object obj)
{
return JsonConvert.SerializeObject(obj, Formatting.Indented);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Fusion.Services.Org.ApiModels" Version="8.0.5"/>
<PackageReference Include="AdaptiveCards" Version="3.1.0" />
<PackageReference Include="Fusion.ApiClients.Org" Version="8.0.4"/>
<PackageReference Include="Fusion.Integration" Version="8.0.8"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ namespace Fusion.Resources.Functions.Common.Integration.Errors
{
public class ApiError : Exception
{
public ApiError(string url, HttpStatusCode statusCode, string body, string message) : base($"{message}. Status code: {statusCode}, Body: {body.Substring(0, 500)}")
public ApiError(string url, HttpStatusCode statusCode, string body, string message) :
base($"{message}. Status code: {statusCode}, Body: {(body.Length > 500 ? body[..500] : body)}")
{
Url = url;
StatusCode = statusCode;
Expand All @@ -17,4 +18,4 @@ public ApiError(string url, HttpStatusCode statusCode, string body, string messa

public string Body { get; }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Fusion.Resources.Functions.Common.Integration.Authentication;
using Fusion.Resources.Functions.Common.Integration.ServiceDiscovery;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Fusion.Resources.Functions.Common.Integration.Http.Handlers;

public class RolesHttpHandler : FunctionHttpMessageHandler
{
private readonly IOptions<HttpClientsOptions> options;

public RolesHttpHandler(ILoggerFactory loggerFactory, ITokenProvider tokenProvider, IServiceDiscovery serviceDiscovery, IOptions<HttpClientsOptions> options)
: base(loggerFactory.CreateLogger<RolesHttpHandler>(), tokenProvider, serviceDiscovery)
{
this.options = options;
}

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
await SetEndpointUriForRequestAsync(request, ServiceEndpoint.Roles);
await AddAuthHeaderForRequestAsync(request, options.Value.Fusion);

return await base.SendAsync(request, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ namespace Fusion.Resources.Functions.Common.Integration.Http
{
public static class HttpClientExtensions
{
public static async Task<T> GetAsJsonAsync<T>(this HttpClient client, string url) where T : class
public static async Task<T> GetAsJsonAsync<T>(this HttpClient client, string url, CancellationToken cancellationToken = default) where T : class
{
var response = await client.GetAsync(url);
var body = await response.Content.ReadAsStringAsync();
var response = await client.GetAsync(url, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);

if (!response.IsSuccessStatusCode)
throw new ApiError(response.RequestMessage!.RequestUri!.ToString(), response.StatusCode, body, "Response from API call indicates error");
Expand All @@ -17,10 +17,10 @@ public static async Task<T> GetAsJsonAsync<T>(this HttpClient client, string url
return deserialized;
}

public static async Task<IEnumerable<string>> OptionsAsync(this HttpClient client, string url)
public static async Task<IEnumerable<string>> OptionsAsync(this HttpClient client, string url, CancellationToken cancellationToken = default)
{
var message = new HttpRequestMessage(HttpMethod.Options, url);
var resp = await client.SendAsync(message);
var resp = await client.SendAsync(message, cancellationToken);

resp.Content.Headers.TryGetValues("Allow", out var allowHeaders);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ public HttpClientFactoryBuilder AddLineOrgClient()
return this;
}

public HttpClientFactoryBuilder AddRolesClient()
{
services.AddTransient<RolesHttpHandler>();
services.AddHttpClient(HttpClientNames.Application.Roles, client =>
{
client.BaseAddress = new Uri("https://fusion-notifications");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
})
.AddHttpMessageHandler<RolesHttpHandler>()
.AddTransientHttpErrorPolicy(DefaultRetryPolicy());

return this;
}

private readonly TimeSpan[] DefaultSleepDurations = new[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10) };

private Func<PolicyBuilder<HttpResponseMessage>, IAsyncPolicy<HttpResponseMessage>> DefaultRetryPolicy(TimeSpan[] sleepDurations = null) =>
Expand Down
Loading