diff --git a/api/src/Feature.DataExport/Start/Endpoint.cs b/api/src/Feature.DataExport/Start/Endpoint.cs index 138c47c73..76af4837c 100644 --- a/api/src/Feature.DataExport/Start/Endpoint.cs +++ b/api/src/Feature.DataExport/Start/Endpoint.cs @@ -58,6 +58,15 @@ public override async Task, NotFound, ProblemDetails>> Exec } } + if (req.ExportedDataType == ExportedDataType.PollingStationInformation) + { + if (!userRoleProvider.IsPlatformAdmin()) + { + AddError(x => x.ExportedDataType, "Only platform admins can export this type of data"); + return new ProblemDetails(ValidationFailures); + } + } + var exportedData = CreateExportedData(req); await repository.AddAsync(exportedData, ct); @@ -97,6 +106,11 @@ public override async Task, NotFound, ProblemDetails>> Exec jobService.EnqueueExportFormSubmissionsSimplified(req.ElectionRoundId, userProvider.GetNgoId()!.Value, exportedData.Id); } + if (req.ExportedDataType == ExportedDataType.PollingStationInformation) + { + jobService.EnqueueExportPollingStationInformation(req.ElectionRoundId, exportedData.Id); + } + return TypedResults.Ok(new Response { ExportedDataId = exportedData.Id, EnqueuedAt = timeProvider.UtcNow }); } diff --git a/api/src/Feature.DataExport/Start/Validator.cs b/api/src/Feature.DataExport/Start/Validator.cs index df70ec772..2db207c51 100644 --- a/api/src/Feature.DataExport/Start/Validator.cs +++ b/api/src/Feature.DataExport/Start/Validator.cs @@ -37,5 +37,10 @@ public Validator() { RuleFor(x => x.ElectionRoundId).NotEmpty(); }); + + When(x => x.ExportedDataType == ExportedDataType.PollingStationInformation, () => + { + RuleFor(x => x.ElectionRoundId).NotEmpty(); + }); } } diff --git a/api/src/Feature.PollingStation.Information/Export/Endpoint.cs b/api/src/Feature.PollingStation.Information/Export/Endpoint.cs new file mode 100644 index 000000000..dc380fd56 --- /dev/null +++ b/api/src/Feature.PollingStation.Information/Export/Endpoint.cs @@ -0,0 +1,202 @@ +using System.Globalization; +using Authorization.Policies; +using CsvHelper; +using Dapper; +using Feature.PollingStation.Information.PsiData; +using Microsoft.EntityFrameworkCore; +using Vote.Monitor.Domain; +using Vote.Monitor.Domain.ConnectionFactory; +using Vote.Monitor.Domain.Entities.FormAnswerBase.Answers; +using Vote.Monitor.Domain.Entities.FormBase.Questions; + +namespace Feature.PollingStation.Information.Export; + +public class Endpoint(INpgsqlConnectionFactory dbConnectionFactory, VoteMonitorContext context) : Endpoint +{ + public override void Configure() + { + Get("/api/election-rounds/{electionRoundId}/information:export"); + DontAutoTag(); + Options(x => x + .WithTags("polling-station-information") + .Produces(200, typeof(string), contentType: "text/csv")); + Policies(PolicyNames.PlatformAdminsOnly); + Summary(s => + { + s.Summary = "Exports polling station information of an election round to CSV (platform admins)"; + }); + } + + public override async Task HandleAsync(Request req, CancellationToken ct) + { + var sql = $""" + {PsiDataQuery.SelectColumns} + {PsiDataQuery.FromWhere} + {PsiDataQuery.OrderBy}; + """; + + List rows; + using (var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct)) + { + rows = (await dbConnection.QueryAsync(sql, PsiDataQuery.BuildArgs(req, paged: false))) + .ToList(); + } + + var form = await context.PollingStationInformationForms + .AsNoTracking() + .FirstOrDefaultAsync(x => x.ElectionRoundId == req.ElectionRoundId, ct); + + var questions = form?.Questions ?? []; + var language = form?.DefaultLanguage ?? string.Empty; + + var rowIds = rows.Select(x => x.Id).ToList(); + var answersById = rowIds.Count == 0 + ? new Dictionary>() + : (await context.PollingStationInformation + .AsNoTracking() + .Where(x => x.ElectionRoundId == req.ElectionRoundId && rowIds.Contains(x.Id)) + .Select(x => new { x.Id, x.Answers }) + .ToListAsync(ct)) + .ToDictionary(x => x.Id, x => x.Answers); + + using var memoryStream = new MemoryStream(); + await using (var streamWriter = new StreamWriter(memoryStream, leaveOpen: true)) + await using (var csv = new CsvWriter(streamWriter, CultureInfo.InvariantCulture)) + { + WriteHeader(csv, questions, language); + foreach (var row in rows) + { + answersById.TryGetValue(row.Id, out var answers); + WriteRow(csv, row, questions, answers ?? [], language); + } + + await streamWriter.FlushAsync(ct); + } + + await SendBytesAsync(memoryStream.ToArray(), $"psi-data-{req.ElectionRoundId}.csv", contentType: "text/csv", + cancellation: ct); + } + + private static readonly string[] BaseHeader = + [ + "SubmissionId", "PollingStationId", "Level1", "Level2", "Level3", "Level4", "Level5", "Number", "Address", + "Ngo", "ArrivalTime", "DepartureTime", "MinutesMonitoring", "QuestionsAnswered", "FlaggedAnswers", + "IsCompleted", "LastUpdatedAt" + ]; + + private static void WriteHeader(CsvWriter csv, IReadOnlyList questions, string language) + { + foreach (var column in BaseHeader) + { + csv.WriteField(column); + } + + foreach (var question in questions) + { + csv.WriteField($"{question.Code} - {Translate(question.Text, language)}"); + if (HasFreeTextOption(question)) + { + csv.WriteField($"{question.Code} - FreeText"); + } + } + + csv.NextRecord(); + } + + private static void WriteRow(CsvWriter csv, PsiDataRowModel row, IReadOnlyList questions, + IReadOnlyList answers, string language) + { + csv.WriteField(row.Id); + csv.WriteField(row.PollingStationId); + csv.WriteField(row.Level1); + csv.WriteField(row.Level2); + csv.WriteField(row.Level3); + csv.WriteField(row.Level4); + csv.WriteField(row.Level5); + csv.WriteField(row.Number); + csv.WriteField(row.Address); + csv.WriteField(row.NgoName); + csv.WriteField(row.ArrivalTime?.ToString("s")); + csv.WriteField(row.DepartureTime?.ToString("s")); + csv.WriteField(Math.Round(row.MinutesMonitoring, 1)); + csv.WriteField(row.NumberOfQuestionsAnswered); + csv.WriteField(row.NumberOfFlaggedAnswers); + csv.WriteField(row.IsCompleted); + csv.WriteField(row.LastUpdatedAt.ToString("s")); + + var answersByQuestion = answers + .GroupBy(x => x.QuestionId) + .ToDictionary(x => x.Key, x => x.First()); + + foreach (var question in questions) + { + answersByQuestion.TryGetValue(question.Id, out var answer); + var (value, freeText) = FormatAnswer(question, answer, language); + csv.WriteField(value); + if (HasFreeTextOption(question)) + { + csv.WriteField(freeText); + } + } + + csv.NextRecord(); + } + + private static (string Value, string FreeText) FormatAnswer(BaseQuestion question, BaseAnswer? answer, + string language) + { + switch (answer) + { + case null: + return (string.Empty, string.Empty); + case TextAnswer text: + return (text.Text ?? string.Empty, string.Empty); + case NumberAnswer number: + return (number.Value.ToString(CultureInfo.InvariantCulture), string.Empty); + case RatingAnswer rating: + return (rating.Value.ToString(CultureInfo.InvariantCulture), string.Empty); + case DateAnswer date: + return (date.Date.ToString("s"), string.Empty); + case SingleSelectAnswer single: + { + var option = (question as SingleSelectQuestion)?.Options + .FirstOrDefault(x => x.Id == single.Selection.OptionId); + return (option is null ? string.Empty : Translate(option.Text, language), + single.Selection.Text ?? string.Empty); + } + case MultiSelectAnswer multi: + { + var selectedIds = multi.Selection.Select(x => x.OptionId).ToHashSet(); + var options = (question as MultiSelectQuestion)?.Options + .Where(x => selectedIds.Contains(x.Id)) + .Select(x => Translate(x.Text, language)) ?? []; + var freeTexts = multi.Selection.Select(x => x.Text).Where(x => !string.IsNullOrWhiteSpace(x)); + return (string.Join(", ", options), string.Join(", ", freeTexts)); + } + default: + return (string.Empty, string.Empty); + } + } + + private static bool HasFreeTextOption(BaseQuestion question) => question switch + { + SingleSelectQuestion single => single.Options.Any(x => x.IsFreeText), + MultiSelectQuestion multi => multi.Options.Any(x => x.IsFreeText), + _ => false + }; + + private static string Translate(IReadOnlyDictionary? text, string language) + { + if (text is null || text.Count == 0) + { + return string.Empty; + } + + if (!string.IsNullOrEmpty(language) && text.TryGetValue(language, out var value)) + { + return value; + } + + return text.Values.First(); + } +} diff --git a/api/src/Feature.PollingStation.Information/Export/Request.cs b/api/src/Feature.PollingStation.Information/Export/Request.cs new file mode 100644 index 000000000..3acf19e89 --- /dev/null +++ b/api/src/Feature.PollingStation.Information/Export/Request.cs @@ -0,0 +1,7 @@ +using Feature.PollingStation.Information.PsiData; + +namespace Feature.PollingStation.Information.Export; + +public class Request : PsiDataFilters +{ +} diff --git a/api/src/Feature.PollingStation.Information/Export/Validator.cs b/api/src/Feature.PollingStation.Information/Export/Validator.cs new file mode 100644 index 000000000..d8e2d9d16 --- /dev/null +++ b/api/src/Feature.PollingStation.Information/Export/Validator.cs @@ -0,0 +1,9 @@ +namespace Feature.PollingStation.Information.Export; + +public class Validator : Validator +{ + public Validator() + { + RuleFor(x => x.ElectionRoundId).NotEmpty(); + } +} diff --git a/api/src/Feature.PollingStation.Information/ListAll/Endpoint.cs b/api/src/Feature.PollingStation.Information/ListAll/Endpoint.cs new file mode 100644 index 000000000..c3df27619 --- /dev/null +++ b/api/src/Feature.PollingStation.Information/ListAll/Endpoint.cs @@ -0,0 +1,45 @@ +using Authorization.Policies; +using Dapper; +using Feature.PollingStation.Information.PsiData; +using Vote.Monitor.Core.Models; +using Vote.Monitor.Domain.ConnectionFactory; + +namespace Feature.PollingStation.Information.ListAll; + +public class Endpoint(INpgsqlConnectionFactory dbConnectionFactory) + : Endpoint> +{ + public override void Configure() + { + Get("/api/election-rounds/{electionRoundId}/information:all"); + DontAutoTag(); + Options(x => x.WithTags("polling-station-information")); + Policies(PolicyNames.PlatformAdminsOnly); + Summary(s => + { + s.Summary = "Lists all polling station information submitted in an election round (platform admins)"; + }); + } + + public override async Task> ExecuteAsync(Request req, CancellationToken ct) + { + var sql = $""" + SELECT COUNT(*) + {PsiDataQuery.FromWhere}; + + {PsiDataQuery.SelectColumns} + {PsiDataQuery.FromWhere} + {PsiDataQuery.OrderBy} + OFFSET @offset ROWS + FETCH NEXT @pageSize ROWS ONLY; + """; + + using var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct); + using var multi = await dbConnection.QueryMultipleAsync(sql, PsiDataQuery.BuildArgs(req, paged: true)); + + var totalCount = multi.Read().Single(); + var items = multi.Read().ToList(); + + return new PagedResponse(items, totalCount, req.PageNumber, req.PageSize); + } +} diff --git a/api/src/Feature.PollingStation.Information/ListAll/Request.cs b/api/src/Feature.PollingStation.Information/ListAll/Request.cs new file mode 100644 index 000000000..eb5b4d123 --- /dev/null +++ b/api/src/Feature.PollingStation.Information/ListAll/Request.cs @@ -0,0 +1,7 @@ +using Feature.PollingStation.Information.PsiData; + +namespace Feature.PollingStation.Information.ListAll; + +public class Request : PsiDataFilters +{ +} diff --git a/api/src/Feature.PollingStation.Information/ListAll/Validator.cs b/api/src/Feature.PollingStation.Information/ListAll/Validator.cs new file mode 100644 index 000000000..72141aa1e --- /dev/null +++ b/api/src/Feature.PollingStation.Information/ListAll/Validator.cs @@ -0,0 +1,11 @@ +namespace Feature.PollingStation.Information.ListAll; + +public class Validator : Validator +{ + public Validator() + { + RuleFor(x => x.ElectionRoundId).NotEmpty(); + RuleFor(x => x.PageNumber).GreaterThanOrEqualTo(1); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100); + } +} diff --git a/api/src/Feature.PollingStation.Information/PsiData/PsiDataFilters.cs b/api/src/Feature.PollingStation.Information/PsiData/PsiDataFilters.cs new file mode 100644 index 000000000..9c8fd9887 --- /dev/null +++ b/api/src/Feature.PollingStation.Information/PsiData/PsiDataFilters.cs @@ -0,0 +1,22 @@ +using Vote.Monitor.Core.Models; + +namespace Feature.PollingStation.Information.PsiData; + +public class PsiDataFilters : BaseSortPaginatedRequest +{ + public Guid ElectionRoundId { get; set; } + + [QueryParam] public string? SearchText { get; set; } + + [QueryParam] public string? Level1Filter { get; set; } + [QueryParam] public string? Level2Filter { get; set; } + [QueryParam] public string? Level3Filter { get; set; } + [QueryParam] public string? Level4Filter { get; set; } + [QueryParam] public string? Level5Filter { get; set; } + + [QueryParam] public Guid? MonitoringNgoId { get; set; } + + [QueryParam] public bool? IsCompleted { get; set; } + [QueryParam] public DateTime? FromDateFilter { get; set; } + [QueryParam] public DateTime? ToDateFilter { get; set; } +} diff --git a/api/src/Feature.PollingStation.Information/PsiData/PsiDataQuery.cs b/api/src/Feature.PollingStation.Information/PsiData/PsiDataQuery.cs new file mode 100644 index 000000000..bb5be8564 --- /dev/null +++ b/api/src/Feature.PollingStation.Information/PsiData/PsiDataQuery.cs @@ -0,0 +1,96 @@ +using Vote.Monitor.Domain.Specifications; + +namespace Feature.PollingStation.Information.PsiData; + +internal static class PsiDataQuery +{ + public const string FromWhere = """ + FROM "PollingStationInformation" PSI + INNER JOIN "PollingStations" PS ON PS."Id" = PSI."PollingStationId" + INNER JOIN "MonitoringObservers" MO ON MO."Id" = PSI."MonitoringObserverId" + INNER JOIN "MonitoringNgos" MN ON MN."Id" = MO."MonitoringNgoId" + INNER JOIN "Ngos" N ON N."Id" = MN."NgoId" + WHERE PSI."ElectionRoundId" = @electionRoundId + AND (@level1 IS NULL OR PS."Level1" = @level1) + AND (@level2 IS NULL OR PS."Level2" = @level2) + AND (@level3 IS NULL OR PS."Level3" = @level3) + AND (@level4 IS NULL OR PS."Level4" = @level4) + AND (@level5 IS NULL OR PS."Level5" = @level5) + AND (@monitoringNgoId IS NULL OR MN."Id" = @monitoringNgoId) + AND (@isCompleted IS NULL OR PSI."IsCompleted" = @isCompleted) + AND (@fromDate IS NULL OR PSI."LastUpdatedAt" >= @fromDate::timestamp) + AND (@toDate IS NULL OR PSI."LastUpdatedAt" <= @toDate::timestamp) + AND (@searchText IS NULL OR PS."Number" ILIKE @searchText OR PS."Address" ILIKE @searchText) + """; + + public const string SelectColumns = """ + SELECT + PSI."Id", + PS."Id" AS "PollingStationId", + PS."Level1", + PS."Level2", + PS."Level3", + PS."Level4", + PS."Level5", + PS."Number", + PS."Address", + MN."Id" AS "MonitoringNgoId", + N."Name" AS "NgoName", + PSI."ArrivalTime", + PSI."DepartureTime", + COALESCE("ComputeMinutesMonitoring"(PSI."ArrivalTime", PSI."DepartureTime", PSI."Breaks"), 0) AS "MinutesMonitoring", + PSI."NumberOfQuestionsAnswered", + PSI."NumberOfFlaggedAnswers", + PSI."IsCompleted", + PSI."LastUpdatedAt" + """; + + public const string OrderBy = """ + ORDER BY + CASE WHEN @sortExpression = 'LastUpdatedAt ASC' THEN PSI."LastUpdatedAt" END ASC, + CASE WHEN @sortExpression = 'LastUpdatedAt DESC' THEN PSI."LastUpdatedAt" END DESC, + CASE WHEN @sortExpression = 'Number ASC' THEN PS."Number" END ASC, + CASE WHEN @sortExpression = 'Number DESC' THEN PS."Number" END DESC, + CASE WHEN @sortExpression = 'NgoName ASC' THEN N."Name" END ASC, + CASE WHEN @sortExpression = 'NgoName DESC' THEN N."Name" END DESC, + CASE WHEN @sortExpression = 'Location ASC' THEN PS."Level1" END ASC, + CASE WHEN @sortExpression = 'Location DESC' THEN PS."Level1" END DESC, + PSI."LastUpdatedAt" DESC, + PSI."Id" + """; + + public static object BuildArgs(PsiDataFilters req, bool paged) => new + { + electionRoundId = req.ElectionRoundId, + level1 = NullIfEmpty(req.Level1Filter), + level2 = NullIfEmpty(req.Level2Filter), + level3 = NullIfEmpty(req.Level3Filter), + level4 = NullIfEmpty(req.Level4Filter), + level5 = NullIfEmpty(req.Level5Filter), + monitoringNgoId = req.MonitoringNgoId, + isCompleted = req.IsCompleted, + fromDate = req.FromDateFilter?.ToString("O"), + toDate = req.ToDateFilter?.ToString("O"), + searchText = string.IsNullOrWhiteSpace(req.SearchText) ? null : $"%{req.SearchText.Trim()}%", + sortExpression = GetSortExpression(req.SortColumnName, req.IsAscendingSorting), + offset = paged ? PaginationHelper.CalculateSkip(req.PageSize, req.PageNumber) : 0, + pageSize = paged ? req.PageSize : int.MaxValue + }; + + private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; + + private static string GetSortExpression(string? sortColumnName, bool isAscending) + { + var order = isAscending ? "ASC" : "DESC"; + var column = sortColumnName?.Trim().ToLowerInvariant() switch + { + "number" => "Number", + "ngoname" => "NgoName", + "location" or "level1" => "Location", + "lastupdatedat" => "LastUpdatedAt", + _ => null + }; + + return column is null ? "LastUpdatedAt DESC" : $"{column} {order}"; + } +} diff --git a/api/src/Feature.PollingStation.Information/PsiData/PsiDataRowModel.cs b/api/src/Feature.PollingStation.Information/PsiData/PsiDataRowModel.cs new file mode 100644 index 000000000..abc5c0ff6 --- /dev/null +++ b/api/src/Feature.PollingStation.Information/PsiData/PsiDataRowModel.cs @@ -0,0 +1,23 @@ +namespace Feature.PollingStation.Information.PsiData; + +public record PsiDataRowModel +{ + public Guid Id { get; init; } + public Guid PollingStationId { get; init; } + public string Level1 { get; init; } = string.Empty; + public string Level2 { get; init; } = string.Empty; + public string Level3 { get; init; } = string.Empty; + public string Level4 { get; init; } = string.Empty; + public string Level5 { get; init; } = string.Empty; + public string Number { get; init; } = string.Empty; + public string Address { get; init; } = string.Empty; + public Guid MonitoringNgoId { get; init; } + public string NgoName { get; init; } = string.Empty; + public DateTime? ArrivalTime { get; init; } + public DateTime? DepartureTime { get; init; } + public double MinutesMonitoring { get; init; } + public int NumberOfQuestionsAnswered { get; init; } + public int NumberOfFlaggedAnswers { get; init; } + public bool IsCompleted { get; init; } + public DateTime LastUpdatedAt { get; init; } +} diff --git a/api/src/Feature.Statistics/GetCoalitionStatistics/CoalitionHeader.cs b/api/src/Feature.Statistics/GetCoalitionStatistics/CoalitionHeader.cs new file mode 100644 index 000000000..c34a40c49 --- /dev/null +++ b/api/src/Feature.Statistics/GetCoalitionStatistics/CoalitionHeader.cs @@ -0,0 +1,7 @@ +namespace Feature.Statistics.GetCoalitionStatistics; + +internal sealed class CoalitionHeader +{ + public string Name { get; set; } = string.Empty; + public int NumberOfMembers { get; set; } +} diff --git a/api/src/Feature.Statistics/GetCoalitionStatistics/Endpoint.cs b/api/src/Feature.Statistics/GetCoalitionStatistics/Endpoint.cs new file mode 100644 index 000000000..d9019ae18 --- /dev/null +++ b/api/src/Feature.Statistics/GetCoalitionStatistics/Endpoint.cs @@ -0,0 +1,661 @@ +using Authorization.Policies; +using Dapper; +using Feature.Statistics.GetNgoAdminStatistics.Models; +using Feature.Statistics.Options; +using Microsoft.Extensions.Options; +using Vote.Monitor.Domain.ConnectionFactory; +using ZiggyCreatures.Caching.Fusion; + +namespace Feature.Statistics.GetCoalitionStatistics; + +public class Endpoint( + INpgsqlConnectionFactory dbConnectionFactory, + IFusionCache cache, + IOptions options) : Endpoint, NotFound>> +{ + private readonly StatisticsFeatureOptions _options = options.Value; + + public override void Configure() + { + Get("/api/election-rounds/{electionRoundId}/coalitions/{coalitionId}:stats"); + DontAutoTag(); + Options(x => x.WithTags("statistics")); + Policies(PolicyNames.PlatformAdminsOnly); + Summary(s => { s.Summary = "Statistics for a coalition in an election round"; }); + } + + public override async Task, NotFound>> ExecuteAsync(Request req, CancellationToken ct) + { + var cacheKey = $"statistics-coalition-{req.ElectionRoundId}-{req.CoalitionId}"; + + var cached = await cache.TryGetAsync(cacheKey, token: ct); + if (cached.HasValue) + { + return TypedResults.Ok(cached.Value); + } + + var result = await GetStatisticsAsync(req, ct); + if (result is null) + { + return TypedResults.NotFound(); + } + + await cache.SetAsync( + cacheKey, + result, + options => options.SetDuration(TimeSpan.FromMinutes(_options.CacheDurationInMinutes)), + token: ct); + + return TypedResults.Ok(result); + } + + private async Task GetStatisticsAsync(Request req, CancellationToken ct) + { + const string sql = + """ + SELECT + C."Name", + (SELECT COUNT(*) FROM "CoalitionMemberships" CM WHERE CM."CoalitionId" = C."Id")::INT AS "NumberOfMembers" + FROM "Coalitions" C + WHERE C."Id" = @coalitionId + AND C."ElectionRoundId" = @electionRoundId; + ------------------------------ + + SELECT + COUNT(MO."Id") FILTER ( + WHERE MO."Status" = 'Active' + AND U."Status" = 'Active' + ) AS "ActiveObservers", + COUNT(MO."Id") FILTER ( + WHERE MO."Status" = 'Pending' + OR U."Status" = 'Pending' + ) AS "PendingObservers", + COUNT(MO."Id") FILTER ( + WHERE MO."Status" = 'Suspended' + OR U."Status" = 'Deactivated' + ) AS "SuspendedObservers" + FROM "MonitoringObservers" MO + INNER JOIN "AspNetUsers" U ON U."Id" = MO."ObserverId" + WHERE MO."ElectionRoundId" = @electionRoundId + AND MO."MonitoringNgoId" IN (SELECT CM."MonitoringNgoId" FROM "CoalitionMemberships" CM WHERE CM."CoalitionId" = @coalitionId AND CM."ElectionRoundId" = @electionRoundId); + ------------------------------ + + WITH + "AvailableObservers" AS ( + SELECT MO."Id" AS "MonitoringObserverId" + FROM "MonitoringObservers" MO + WHERE MO."ElectionRoundId" = @electionRoundId + AND MO."MonitoringNgoId" IN (SELECT CM."MonitoringNgoId" FROM "CoalitionMemberships" CM WHERE CM."CoalitionId" = @coalitionId AND CM."ElectionRoundId" = @electionRoundId) + ), + "ActiveObservers" AS ( + SELECT + FS."PollingStationId", + FS."MonitoringObserverId" + FROM "FormSubmissions" FS + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = FS."MonitoringObserverId" + UNION + SELECT + PSI."PollingStationId", + PSI."MonitoringObserverId" + FROM "PollingStationInformation" PSI + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = PSI."MonitoringObserverId" + UNION + SELECT + QR."PollingStationId", + QR."MonitoringObserverId" + FROM "QuickReports" QR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = QR."MonitoringObserverId" + WHERE QR."PollingStationId" IS NOT NULL + UNION + SELECT + IR."PollingStationId", + IR."MonitoringObserverId" + FROM "IncidentReports" IR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = IR."MonitoringObserverId" + WHERE IR."PollingStationId" IS NOT NULL + ), + "PollingStationsStatsRaw" AS ( + SELECT + FS."PollingStationId", + 0 AS "NumberOfIncidentReports", + 0 AS "NumberOfQuickReports", + COUNT(1) AS "NumberOfFormSubmissions", + 0::float AS "MinutesMonitoring", + SUM(FS."NumberOfFlaggedAnswers") AS "NumberOfFlaggedAnswers", + SUM(FS."NumberOfQuestionsAnswered") AS "NumberOfQuestionsAnswered" + FROM "FormSubmissions" FS + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = FS."MonitoringObserverId" + GROUP BY FS."PollingStationId" + UNION ALL + SELECT + PSI."PollingStationId", + 0 AS "NumberOfIncidentReports", + 0 AS "NumberOfQuickReports", + COUNT(1) AS "NumberOfFormSubmissions", + SUM("ComputeMinutesMonitoring"("ArrivalTime", "DepartureTime", "Breaks")) AS "MinutesMonitoring", + SUM(PSI."NumberOfFlaggedAnswers") AS "NumberOfFlaggedAnswers", + SUM(PSI."NumberOfQuestionsAnswered") AS "NumberOfQuestionsAnswered" + FROM "PollingStationInformation" PSI + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = PSI."MonitoringObserverId" + GROUP BY PSI."PollingStationId" + UNION ALL + SELECT + QR."PollingStationId", + 0 AS "NumberOfIncidentReports", + COUNT(1) AS "NumberOfQuickReports", + 0 AS "NumberOfFormSubmissions", + 0::float AS "MinutesMonitoring", + 0 AS "NumberOfFlaggedAnswers", + 0 AS "NumberOfQuestionsAnswered" + FROM "QuickReports" QR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = QR."MonitoringObserverId" + WHERE QR."PollingStationId" IS NOT NULL + GROUP BY QR."PollingStationId" + UNION ALL + SELECT + IR."PollingStationId", + COUNT(1) AS "NumberOfIncidentReports", + 0 AS "NumberOfQuickReports", + 0 AS "NumberOfFormSubmissions", + 0::float AS "MinutesMonitoring", + 0 AS "NumberOfFlaggedAnswers", + 0 AS "NumberOfQuestionsAnswered" + FROM "IncidentReports" IR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = IR."MonitoringObserverId" + WHERE IR."PollingStationId" IS NOT NULL + GROUP BY IR."PollingStationId" + ), + "PollingStationsStats" AS ( + SELECT + "PollingStationId", + SUM("NumberOfIncidentReports") AS "NumberOfIncidentReports", + SUM("NumberOfQuickReports") AS "NumberOfQuickReports", + SUM("NumberOfFormSubmissions") AS "NumberOfFormSubmissions", + SUM("MinutesMonitoring") AS "MinutesMonitoring", + SUM("NumberOfFlaggedAnswers") AS "NumberOfFlaggedAnswers", + SUM("NumberOfQuestionsAnswered") AS "NumberOfQuestionsAnswered" + FROM "PollingStationsStatsRaw" + GROUP BY "PollingStationId" + ), + "ElectionPollingStations" AS ( + SELECT + PS."Id", + NULLIF(PS."Level1", '') AS "Level1", + NULLIF(PS."Level2", '') AS "Level2", + NULLIF(PS."Level3", '') AS "Level3", + NULLIF(PS."Level4", '') AS "Level4", + NULLIF(PS."Level5", '') AS "Level5" + FROM "PollingStations" PS + WHERE PS."ElectionRoundId" = @electionRoundId + AND PS."Level1" != '' + ), + "PollingStationsPerLevel" AS ( + SELECT + CASE + WHEN GROUPING(PS."Level1") = 1 THEN 0 + WHEN GROUPING(PS."Level2") = 1 THEN 1 + WHEN GROUPING(PS."Level3") = 1 THEN 2 + WHEN GROUPING(PS."Level4") = 1 THEN 3 + WHEN GROUPING(PS."Level5") = 1 THEN 4 + ELSE 5 + END AS "Level", + CASE + WHEN GROUPING(PS."Level1") = 1 THEN '/' + ELSE CONCAT_WS(' / ', PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + END AS "Path", + COUNT(PS."Id") AS "NumberOfPollingStations" + FROM "ElectionPollingStations" PS + GROUP BY GROUPING SETS ( + (), + (PS."Level1"), + (PS."Level1", PS."Level2"), + (PS."Level1", PS."Level2", PS."Level3"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + ) + HAVING + GROUPING(PS."Level1") = 1 + OR ( + GROUPING(PS."Level2") = 1 + AND PS."Level1" IS NOT NULL + ) + OR ( + GROUPING(PS."Level3") = 1 + AND GROUPING(PS."Level2") = 0 + AND PS."Level2" IS NOT NULL + ) + OR ( + GROUPING(PS."Level4") = 1 + AND GROUPING(PS."Level3") = 0 + AND PS."Level3" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 1 + AND GROUPING(PS."Level4") = 0 + AND PS."Level4" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 0 + AND PS."Level5" IS NOT NULL + ) + ), + "PollingStationLevelsStats" AS ( + SELECT + CASE + WHEN GROUPING(PS."Level1") = 1 THEN 0 + WHEN GROUPING(PS."Level2") = 1 THEN 1 + WHEN GROUPING(PS."Level3") = 1 THEN 2 + WHEN GROUPING(PS."Level4") = 1 THEN 3 + WHEN GROUPING(PS."Level5") = 1 THEN 4 + ELSE 5 + END AS "Level", + CASE + WHEN GROUPING(PS."Level1") = 1 THEN '/' + ELSE CONCAT_WS(' / ', PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + END AS "Path", + COUNT(PSV."PollingStationId") AS "NumberOfVisitedPollingStations", + COALESCE(SUM(PSV."NumberOfIncidentReports"), 0) AS "NumberOfIncidentReports", + COALESCE(SUM(PSV."NumberOfQuickReports"), 0) AS "NumberOfQuickReports", + COALESCE(SUM(PSV."NumberOfFormSubmissions"), 0) AS "NumberOfFormSubmissions", + COALESCE(SUM(PSV."MinutesMonitoring"), 0) AS "MinutesMonitoring", + COALESCE(SUM(PSV."NumberOfFlaggedAnswers"), 0) AS "NumberOfFlaggedAnswers", + COALESCE(SUM(PSV."NumberOfQuestionsAnswered"), 0) AS "NumberOfQuestionsAnswered" + FROM "PollingStationsStats" PSV + INNER JOIN "ElectionPollingStations" PS ON PS."Id" = PSV."PollingStationId" + GROUP BY GROUPING SETS ( + (), + (PS."Level1"), + (PS."Level1", PS."Level2"), + (PS."Level1", PS."Level2", PS."Level3"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + ) + HAVING + GROUPING(PS."Level1") = 1 + OR ( + GROUPING(PS."Level2") = 1 + AND PS."Level1" IS NOT NULL + ) + OR ( + GROUPING(PS."Level3") = 1 + AND GROUPING(PS."Level2") = 0 + AND PS."Level2" IS NOT NULL + ) + OR ( + GROUPING(PS."Level4") = 1 + AND GROUPING(PS."Level3") = 0 + AND PS."Level3" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 1 + AND GROUPING(PS."Level4") = 0 + AND PS."Level4" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 0 + AND PS."Level5" IS NOT NULL + ) + ), + "ActiveObserversPerLevel" AS ( + SELECT + CASE + WHEN GROUPING(PS."Level1") = 1 THEN 0 + WHEN GROUPING(PS."Level2") = 1 THEN 1 + WHEN GROUPING(PS."Level3") = 1 THEN 2 + WHEN GROUPING(PS."Level4") = 1 THEN 3 + WHEN GROUPING(PS."Level5") = 1 THEN 4 + ELSE 5 + END AS "Level", + CASE + WHEN GROUPING(PS."Level1") = 1 THEN '/' + ELSE CONCAT_WS(' / ', PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + END AS "Path", + COUNT(DISTINCT AO."MonitoringObserverId") AS "ActiveObservers" + FROM "ActiveObservers" AO + INNER JOIN "ElectionPollingStations" PS ON PS."Id" = AO."PollingStationId" + GROUP BY GROUPING SETS ( + (), + (PS."Level1"), + (PS."Level1", PS."Level2"), + (PS."Level1", PS."Level2", PS."Level3"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4"), + (PS."Level1", PS."Level2", PS."Level3", PS."Level4", PS."Level5") + ) + HAVING + GROUPING(PS."Level1") = 1 + OR ( + GROUPING(PS."Level2") = 1 + AND PS."Level1" IS NOT NULL + ) + OR ( + GROUPING(PS."Level3") = 1 + AND GROUPING(PS."Level2") = 0 + AND PS."Level2" IS NOT NULL + ) + OR ( + GROUPING(PS."Level4") = 1 + AND GROUPING(PS."Level3") = 0 + AND PS."Level3" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 1 + AND GROUPING(PS."Level4") = 0 + AND PS."Level4" IS NOT NULL + ) + OR ( + GROUPING(PS."Level5") = 0 + AND PS."Level5" IS NOT NULL + ) + ) + SELECT + S."Path", + S."Level", + S."NumberOfVisitedPollingStations", + PS."NumberOfPollingStations", + S."NumberOfIncidentReports", + S."NumberOfQuickReports", + S."NumberOfFormSubmissions", + S."MinutesMonitoring", + S."NumberOfFlaggedAnswers", + S."NumberOfQuestionsAnswered", + AOL."ActiveObservers", + ( + S."NumberOfVisitedPollingStations" * 100.0 / NULLIF(PS."NumberOfPollingStations", 0) + ) AS "CoveragePercentage" + FROM "PollingStationLevelsStats" S + INNER JOIN "PollingStationsPerLevel" PS ON S."Level" = PS."Level" AND S."Path" = PS."Path" + INNER JOIN "ActiveObserversPerLevel" AOL ON S."Level" = AOL."Level" AND S."Path" = AOL."Path"; + + WITH + "LocationStatsRaw" AS ( + SELECT + CR."LocationId", + COUNT(1) AS "NumberOfCitizenReports", + SUM(CR."NumberOfFlaggedAnswers") AS "NumberOfFlaggedAnswers", + SUM(CR."NumberOfQuestionsAnswered") AS "NumberOfQuestionsAnswered" + FROM "CitizenReports" CR + INNER JOIN "ElectionRounds" ER ON ER."Id" = CR."ElectionRoundId" + WHERE CR."ElectionRoundId" = @electionRoundId + AND ER."MonitoringNgoForCitizenReportingId" IN (SELECT CM."MonitoringNgoId" FROM "CoalitionMemberships" CM WHERE CM."CoalitionId" = @coalitionId AND CM."ElectionRoundId" = @electionRoundId) + AND CR."NumberOfQuestionsAnswered" > 0 + GROUP BY CR."LocationId" + ), + "ElectionLocations" AS ( + SELECT + L."Id", + NULLIF(L."Level1", '') AS "Level1", + NULLIF(L."Level2", '') AS "Level2", + NULLIF(L."Level3", '') AS "Level3", + NULLIF(L."Level4", '') AS "Level4", + NULLIF(L."Level5", '') AS "Level5" + FROM "Locations" L + WHERE L."ElectionRoundId" = @electionRoundId + AND L."Level1" != '' + ), + "LocationsPerLevel" AS ( + SELECT + CASE + WHEN GROUPING(L."Level1") = 1 THEN 0 + WHEN GROUPING(L."Level2") = 1 THEN 1 + WHEN GROUPING(L."Level3") = 1 THEN 2 + WHEN GROUPING(L."Level4") = 1 THEN 3 + WHEN GROUPING(L."Level5") = 1 THEN 4 + ELSE 5 + END AS "Level", + CASE + WHEN GROUPING(L."Level1") = 1 THEN '/' + ELSE CONCAT_WS(' / ', L."Level1", L."Level2", L."Level3", L."Level4", L."Level5") + END AS "Path", + COUNT(L."Id") AS "NumberOfLocations" + FROM "ElectionLocations" L + GROUP BY GROUPING SETS ( + (), + (L."Level1"), + (L."Level1", L."Level2"), + (L."Level1", L."Level2", L."Level3"), + (L."Level1", L."Level2", L."Level3", L."Level4"), + (L."Level1", L."Level2", L."Level3", L."Level4", L."Level5") + ) + HAVING + GROUPING(L."Level1") = 1 + OR ( + GROUPING(L."Level2") = 1 + AND L."Level1" IS NOT NULL + ) + OR ( + GROUPING(L."Level3") = 1 + AND GROUPING(L."Level2") = 0 + AND L."Level2" IS NOT NULL + ) + OR ( + GROUPING(L."Level4") = 1 + AND GROUPING(L."Level3") = 0 + AND L."Level3" IS NOT NULL + ) + OR ( + GROUPING(L."Level5") = 1 + AND GROUPING(L."Level4") = 0 + AND L."Level4" IS NOT NULL + ) + OR ( + GROUPING(L."Level5") = 0 + AND L."Level5" IS NOT NULL + ) + ), + "LocationLevelsStats" AS ( + SELECT + CASE + WHEN GROUPING(L."Level1") = 1 THEN 0 + WHEN GROUPING(L."Level2") = 1 THEN 1 + WHEN GROUPING(L."Level3") = 1 THEN 2 + WHEN GROUPING(L."Level4") = 1 THEN 3 + WHEN GROUPING(L."Level5") = 1 THEN 4 + ELSE 5 + END AS "Level", + CASE + WHEN GROUPING(L."Level1") = 1 THEN '/' + ELSE CONCAT_WS(' / ', L."Level1", L."Level2", L."Level3", L."Level4", L."Level5") + END AS "Path", + COUNT(LS."LocationId") AS "NumberOfVisitedLocations", + COALESCE(SUM(LS."NumberOfCitizenReports"), 0) AS "NumberOfCitizenReports", + COALESCE(SUM(LS."NumberOfFlaggedAnswers"), 0) AS "NumberOfFlaggedAnswers", + COALESCE(SUM(LS."NumberOfQuestionsAnswered"), 0) AS "NumberOfQuestionsAnswered" + FROM "LocationStatsRaw" LS + INNER JOIN "ElectionLocations" L ON L."Id" = LS."LocationId" + GROUP BY GROUPING SETS ( + (), + (L."Level1"), + (L."Level1", L."Level2"), + (L."Level1", L."Level2", L."Level3"), + (L."Level1", L."Level2", L."Level3", L."Level4"), + (L."Level1", L."Level2", L."Level3", L."Level4", L."Level5") + ) + HAVING + GROUPING(L."Level1") = 1 + OR ( + GROUPING(L."Level2") = 1 + AND L."Level1" IS NOT NULL + ) + OR ( + GROUPING(L."Level3") = 1 + AND GROUPING(L."Level2") = 0 + AND L."Level2" IS NOT NULL + ) + OR ( + GROUPING(L."Level4") = 1 + AND GROUPING(L."Level3") = 0 + AND L."Level3" IS NOT NULL + ) + OR ( + GROUPING(L."Level5") = 1 + AND GROUPING(L."Level4") = 0 + AND L."Level4" IS NOT NULL + ) + OR ( + GROUPING(L."Level5") = 0 + AND L."Level5" IS NOT NULL + ) + ) + SELECT + S."Path", + S."Level", + S."NumberOfVisitedLocations", + L."NumberOfLocations", + S."NumberOfCitizenReports", + S."NumberOfFlaggedAnswers", + S."NumberOfQuestionsAnswered", + ( + S."NumberOfVisitedLocations" * 100.0 / NULLIF(L."NumberOfLocations", 0) + ) AS "CoveragePercentage" + FROM "LocationLevelsStats" S + INNER JOIN "LocationsPerLevel" L ON S."Level" = L."Level" AND S."Path" = L."Path"; + + WITH "AvailableObservers" AS ( + SELECT MO."Id" AS "MonitoringObserverId" + FROM "MonitoringObservers" MO + WHERE MO."ElectionRoundId" = @electionRoundId + AND MO."MonitoringNgoId" IN (SELECT CM."MonitoringNgoId" FROM "CoalitionMemberships" CM WHERE CM."CoalitionId" = @coalitionId AND CM."ElectionRoundId" = @electionRoundId) + ), + submission_histogram AS ( + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE('utc', FS."LastUpdatedAt") + )::timestamptz AS bucket, + 1 AS forms_submitted, + FS."NumberOfQuestionsAnswered" AS number_of_questions_answered, + FS."NumberOfFlaggedAnswers" AS number_of_flagged_answers + FROM "FormSubmissions" FS + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = FS."MonitoringObserverId" + + UNION ALL + + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE('utc', qr."LastUpdatedAt") + )::timestamptz AS bucket, + 1 AS forms_submitted, + qr."NumberOfQuestionsAnswered" AS number_of_questions_answered, + qr."NumberOfFlaggedAnswers" AS number_of_flagged_answers + FROM "PollingStationInformation" qr + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = qr."MonitoringObserverId" + ) + SELECT + bucket AS "Bucket", + COUNT(*) AS "FormsSubmitted", + SUM(number_of_questions_answered) AS "NumberOfQuestionsAnswered", + SUM(number_of_flagged_answers) AS "NumberOfFlaggedAnswers" + FROM submission_histogram + GROUP BY bucket + ORDER BY bucket; + + WITH "AvailableObservers" AS ( + SELECT MO."Id" AS "MonitoringObserverId" + FROM "MonitoringObservers" MO + WHERE MO."ElectionRoundId" = @electionRoundId + AND MO."MonitoringNgoId" IN (SELECT CM."MonitoringNgoId" FROM "CoalitionMemberships" CM WHERE CM."CoalitionId" = @coalitionId AND CM."ElectionRoundId" = @electionRoundId) + ) + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE('utc', QR."LastUpdatedAt") + )::TIMESTAMPTZ "Bucket", + COUNT(1) "Value" + FROM "QuickReports" QR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = QR."MonitoringObserverId" + GROUP BY 1; + + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE( + 'utc', + COALESCE(CR."LastModifiedOn", CR."CreatedOn") + ) + )::TIMESTAMPTZ "Bucket", + COUNT(1) "Value" + FROM "CitizenReports" CR + INNER JOIN "ElectionRounds" ER ON ER."Id" = CR."ElectionRoundId" + WHERE CR."ElectionRoundId" = @electionRoundId + AND ER."MonitoringNgoForCitizenReportingId" IN (SELECT CM."MonitoringNgoId" FROM "CoalitionMemberships" CM WHERE CM."CoalitionId" = @coalitionId AND CM."ElectionRoundId" = @electionRoundId) + GROUP BY 1; + + WITH "AvailableObservers" AS ( + SELECT MO."Id" AS "MonitoringObserverId" + FROM "MonitoringObservers" MO + WHERE MO."ElectionRoundId" = @electionRoundId + AND MO."MonitoringNgoId" IN (SELECT CM."MonitoringNgoId" FROM "CoalitionMemberships" CM WHERE CM."CoalitionId" = @coalitionId AND CM."ElectionRoundId" = @electionRoundId) + ) + SELECT + DATE_TRUNC( + 'hour', + TIMEZONE('utc', IR."LastUpdatedAt") + )::TIMESTAMPTZ "Bucket", + COUNT(1) "Value" + FROM "IncidentReports" IR + INNER JOIN "AvailableObservers" MO ON MO."MonitoringObserverId" = IR."MonitoringObserverId" + GROUP BY 1; + """; + + var queryArgs = new + { + electionRoundId = req.ElectionRoundId, + coalitionId = req.CoalitionId + }; + + using var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct); + using var multi = await dbConnection.QueryMultipleAsync(sql, queryArgs); + + var coalition = multi.Read().SingleOrDefault(); + if (coalition is null) + { + return null; + } + + var observersStats = multi.ReadSingle(); + var visitedPollingStationsStats = multi.Read().ToList(); + var visitedLocationStats = multi.Read().ToList(); + var formSubmissionsHistogram = multi.Read().ToList(); + var quickReportsHistogram = multi.Read().ToList(); + var citizenReportsHistogram = multi.Read().ToList(); + var incidentReportsHistogram = multi.Read().ToList(); + + return new Response + { + Name = coalition.Name, + NumberOfMembers = coalition.NumberOfMembers, + ObserversStats = observersStats, + TotalStats = visitedPollingStationsStats.FirstOrDefault(x => x.Level == 0), + Level1Stats = visitedPollingStationsStats.Where(x => x.Level == 1).ToList(), + Level2Stats = visitedPollingStationsStats.Where(x => x.Level == 2).ToList(), + Level3Stats = visitedPollingStationsStats.Where(x => x.Level == 3).ToList(), + Level4Stats = visitedPollingStationsStats.Where(x => x.Level == 4).ToList(), + Level5Stats = visitedPollingStationsStats.Where(x => x.Level == 5).ToList(), + TotalLocationStats = visitedLocationStats.FirstOrDefault(x => x.Level == 0), + LocationLevel1Stats = visitedLocationStats.Where(x => x.Level == 1).ToList(), + LocationLevel2Stats = visitedLocationStats.Where(x => x.Level == 2).ToList(), + LocationLevel3Stats = visitedLocationStats.Where(x => x.Level == 3).ToList(), + LocationLevel4Stats = visitedLocationStats.Where(x => x.Level == 4).ToList(), + LocationLevel5Stats = visitedLocationStats.Where(x => x.Level == 5).ToList(), + FormsHistogram = formSubmissionsHistogram.Select(x => new HistogramPoint + { + Bucket = x.Bucket, + Value = x.FormsSubmitted + }).ToArray(), + QuestionsHistogram = formSubmissionsHistogram.Select(x => new HistogramPoint + { + Bucket = x.Bucket, + Value = x.NumberOfQuestionsAnswered + }).ToArray(), + FlaggedAnswersHistogram = formSubmissionsHistogram.Select(x => new HistogramPoint + { + Bucket = x.Bucket, + Value = x.NumberOfFlaggedAnswers + }).ToArray(), + QuickReportsHistogram = quickReportsHistogram.ToArray(), + IncidentReportsHistogram = incidentReportsHistogram.ToArray(), + CitizenReportsHistogram = citizenReportsHistogram.ToArray() + }; + } +} diff --git a/api/src/Feature.Statistics/GetCoalitionStatistics/Request.cs b/api/src/Feature.Statistics/GetCoalitionStatistics/Request.cs new file mode 100644 index 000000000..fa2afb848 --- /dev/null +++ b/api/src/Feature.Statistics/GetCoalitionStatistics/Request.cs @@ -0,0 +1,7 @@ +namespace Feature.Statistics.GetCoalitionStatistics; + +public class Request +{ + public Guid ElectionRoundId { get; set; } + public Guid CoalitionId { get; set; } +} diff --git a/api/src/Feature.Statistics/GetCoalitionStatistics/Response.cs b/api/src/Feature.Statistics/GetCoalitionStatistics/Response.cs new file mode 100644 index 000000000..71b3555d7 --- /dev/null +++ b/api/src/Feature.Statistics/GetCoalitionStatistics/Response.cs @@ -0,0 +1,31 @@ +using Feature.Statistics.GetNgoAdminStatistics.Models; + +namespace Feature.Statistics.GetCoalitionStatistics; + +public class Response +{ + public string Name { get; set; } = string.Empty; + public int NumberOfMembers { get; set; } + public ObserversStats ObserversStats { get; set; } = new(); + + public VisitedPollingStationLevelStats? TotalStats { get; set; } + public List Level1Stats { get; set; } = []; + public List Level2Stats { get; set; } = []; + public List Level3Stats { get; set; } = []; + public List Level4Stats { get; set; } = []; + public List Level5Stats { get; set; } = []; + + public VisitedLocationLevelStats? TotalLocationStats { get; set; } + public List LocationLevel1Stats { get; set; } = []; + public List LocationLevel2Stats { get; set; } = []; + public List LocationLevel3Stats { get; set; } = []; + public List LocationLevel4Stats { get; set; } = []; + public List LocationLevel5Stats { get; set; } = []; + + public HistogramPoint[] FormsHistogram { get; set; } = []; + public HistogramPoint[] QuestionsHistogram { get; set; } = []; + public HistogramPoint[] FlaggedAnswersHistogram { get; set; } = []; + public HistogramPoint[] QuickReportsHistogram { get; set; } = []; + public HistogramPoint[] IncidentReportsHistogram { get; set; } = []; + public HistogramPoint[] CitizenReportsHistogram { get; set; } = []; +} diff --git a/api/src/Feature.Statistics/GetCoalitionStatistics/Validator.cs b/api/src/Feature.Statistics/GetCoalitionStatistics/Validator.cs new file mode 100644 index 000000000..9e705befd --- /dev/null +++ b/api/src/Feature.Statistics/GetCoalitionStatistics/Validator.cs @@ -0,0 +1,10 @@ +namespace Feature.Statistics.GetCoalitionStatistics; + +public class Validator : Validator +{ + public Validator() + { + RuleFor(x => x.ElectionRoundId).NotEmpty(); + RuleFor(x => x.CoalitionId).NotEmpty(); + } +} diff --git a/api/src/Feature.Statistics/GetElectionRoundStatistics/Endpoint.cs b/api/src/Feature.Statistics/GetElectionRoundStatistics/Endpoint.cs index c5d2bb31e..8e2ceca4a 100644 --- a/api/src/Feature.Statistics/GetElectionRoundStatistics/Endpoint.cs +++ b/api/src/Feature.Statistics/GetElectionRoundStatistics/Endpoint.cs @@ -49,6 +49,11 @@ private async Task GetStatisticsAsync(Request req, CancellationToken c WHERE "ElectionRoundId" = @electionRoundId; ------------------------------ + SELECT COUNT(*)::INT + FROM "Coalitions" + WHERE "ElectionRoundId" = @electionRoundId; + ------------------------------ + SELECT COUNT(MO."Id") FILTER ( WHERE MO."Status" = 'Active' @@ -580,6 +585,7 @@ SELECT MO."Id" AS "MonitoringObserverId" var queryArgs = new { electionRoundId = req.ElectionRoundId }; NgosStats ngosStats; + int numberOfCoalitions; ObserversStats observersStats; List visitedPollingStationsStats; List visitedLocationStats; @@ -593,6 +599,7 @@ SELECT MO."Id" AS "MonitoringObserverId" using var multi = await dbConnection.QueryMultipleAsync(sql, queryArgs); ngosStats = multi.ReadSingle(); + numberOfCoalitions = multi.ReadSingle(); observersStats = multi.ReadSingle(); visitedPollingStationsStats = multi.Read().ToList(); visitedLocationStats = multi.Read().ToList(); @@ -605,6 +612,7 @@ SELECT MO."Id" AS "MonitoringObserverId" return new Response { NgosStats = ngosStats, + NumberOfCoalitions = numberOfCoalitions, ObserversStats = observersStats, TotalStats = visitedPollingStationsStats.FirstOrDefault(x => x.Level == 0), Level1Stats = visitedPollingStationsStats.Where(x => x.Level == 1).ToList(), diff --git a/api/src/Feature.Statistics/GetElectionRoundStatistics/NgosStats.cs b/api/src/Feature.Statistics/GetElectionRoundStatistics/NgosStats.cs index e2a388df2..df9c93d36 100644 --- a/api/src/Feature.Statistics/GetElectionRoundStatistics/NgosStats.cs +++ b/api/src/Feature.Statistics/GetElectionRoundStatistics/NgosStats.cs @@ -4,4 +4,6 @@ public class NgosStats { public int ActiveNgos { get; set; } public int InactiveNgos { get; set; } + + public int TotalNumberOfNgos => ActiveNgos + InactiveNgos; } diff --git a/api/src/Feature.Statistics/GetElectionRoundStatistics/Response.cs b/api/src/Feature.Statistics/GetElectionRoundStatistics/Response.cs index 0bcdd1083..2e6230c17 100644 --- a/api/src/Feature.Statistics/GetElectionRoundStatistics/Response.cs +++ b/api/src/Feature.Statistics/GetElectionRoundStatistics/Response.cs @@ -5,6 +5,7 @@ namespace Feature.Statistics.GetElectionRoundStatistics; public class Response { public NgosStats NgosStats { get; set; } = new(); + public int NumberOfCoalitions { get; set; } public ObserversStats ObserversStats { get; set; } = new(); public VisitedPollingStationLevelStats? TotalStats { get; set; } diff --git a/api/src/Job.Contracts/IJobService.cs b/api/src/Job.Contracts/IJobService.cs index e53c2f00a..f008ad67a 100644 --- a/api/src/Job.Contracts/IJobService.cs +++ b/api/src/Job.Contracts/IJobService.cs @@ -11,4 +11,5 @@ public interface IJobService void EnqueueExportIncidentReports(Guid electionRoundId, Guid ngoId, Guid exportedDataId); void EnqueueSendNotifications(List userIdentifiers, string title, string body); void EnqueueExportFormSubmissionsSimplified(Guid electionRoundId, Guid ngoId, Guid exportedDataId); + void EnqueueExportPollingStationInformation(Guid electionRoundId, Guid exportedDataId); } diff --git a/api/src/Job.Contracts/Jobs/IExportPollingStationInformationJob.cs b/api/src/Job.Contracts/Jobs/IExportPollingStationInformationJob.cs new file mode 100644 index 000000000..b44c7bd04 --- /dev/null +++ b/api/src/Job.Contracts/Jobs/IExportPollingStationInformationJob.cs @@ -0,0 +1,6 @@ +namespace Job.Contracts.Jobs; + +public interface IExportPollingStationInformationJob +{ + Task Run(Guid electionRoundId, Guid exportedDataId, CancellationToken ct); +} diff --git a/api/src/Vote.Monitor.Api/Hangfire/HangfireJobService.cs b/api/src/Vote.Monitor.Api/Hangfire/HangfireJobService.cs index 24c426d1d..a94fe1084 100644 --- a/api/src/Vote.Monitor.Api/Hangfire/HangfireJobService.cs +++ b/api/src/Vote.Monitor.Api/Hangfire/HangfireJobService.cs @@ -58,4 +58,10 @@ public void EnqueueExportFormSubmissionsSimplified(Guid electionRoundId, Guid ng backgroundJobClient.Enqueue(job => job.Run(electionRoundId, ngoId, exportedDataId, CancellationToken.None)); } + + public void EnqueueExportPollingStationInformation(Guid electionRoundId, Guid exportedDataId) + { + backgroundJobClient.Enqueue(job => + job.Run(electionRoundId, exportedDataId, CancellationToken.None)); + } } \ No newline at end of file diff --git a/api/src/Vote.Monitor.Api/Hangfire/NoopJobService.cs b/api/src/Vote.Monitor.Api/Hangfire/NoopJobService.cs index 1ad09d22e..dba327216 100644 --- a/api/src/Vote.Monitor.Api/Hangfire/NoopJobService.cs +++ b/api/src/Vote.Monitor.Api/Hangfire/NoopJobService.cs @@ -39,4 +39,8 @@ public void EnqueueSendNotifications(List userIdentifiers, string title, public void EnqueueExportFormSubmissionsSimplified(Guid electionRoundId, Guid ngoId, Guid exportedDataId) { } + + public void EnqueueExportPollingStationInformation(Guid electionRoundId, Guid exportedDataId) + { + } } \ No newline at end of file diff --git a/api/src/Vote.Monitor.Domain/Entities/ExportedDataAggregate/ExportedDataType.cs b/api/src/Vote.Monitor.Domain/Entities/ExportedDataAggregate/ExportedDataType.cs index ff54e60b7..107adc3ea 100644 --- a/api/src/Vote.Monitor.Domain/Entities/ExportedDataAggregate/ExportedDataType.cs +++ b/api/src/Vote.Monitor.Domain/Entities/ExportedDataAggregate/ExportedDataType.cs @@ -10,6 +10,7 @@ public sealed class ExportedDataType : SmartEnum public static readonly ExportedDataType CitizenReports = new(nameof(CitizenReports), nameof(CitizenReports)); public static readonly ExportedDataType Locations = new(nameof(Locations), nameof(Locations)); public static readonly ExportedDataType IncidentReports = new(nameof(IncidentReports), nameof(IncidentReports)); + public static readonly ExportedDataType PollingStationInformation = new(nameof(PollingStationInformation), nameof(PollingStationInformation)); /// Gets an item associated with the specified value. Parses SmartEnum when used as query params /// this issue diff --git a/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/ExportPollingStationInformationJob.cs b/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/ExportPollingStationInformationJob.cs new file mode 100644 index 000000000..d6823c87f --- /dev/null +++ b/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/ExportPollingStationInformationJob.cs @@ -0,0 +1,110 @@ +using Dapper; +using Job.Contracts.Jobs; +using Microsoft.EntityFrameworkCore; +using Vote.Monitor.Core.FileGenerators; +using Vote.Monitor.Core.Services.Time; +using Vote.Monitor.Domain; +using Vote.Monitor.Domain.ConnectionFactory; +using Vote.Monitor.Domain.Entities.ExportedDataAggregate; +using Vote.Monitor.Hangfire.Jobs.Export.PollingStationInformation.ReadModels; + +namespace Vote.Monitor.Hangfire.Jobs.Export.PollingStationInformation; + +public class ExportPollingStationInformationJob( + VoteMonitorContext context, + INpgsqlConnectionFactory dbConnectionFactory, + ILogger logger, + ITimeProvider timeProvider) : IExportPollingStationInformationJob +{ + public async Task Run(Guid electionRoundId, Guid exportedDataId, CancellationToken ct) + { + var exportedData = await context + .ExportedData + .Where(x => x.Id == exportedDataId) + .FirstOrDefaultAsync(ct); + + if (exportedData == null) + { + logger.LogWarning("ExportData was not found for {exportDataType} {electionRoundId} {exportedDataId}", + ExportedDataType.PollingStationInformation, electionRoundId, exportedDataId); + throw new ExportedDataWasNotFoundException(ExportedDataType.PollingStationInformation, electionRoundId, + exportedDataId); + } + + try + { + if (exportedData.ExportStatus == ExportedDataStatus.Completed) + { + logger.LogWarning("ExportData was completed for {electionRoundId} {exportedDataId}", + electionRoundId, exportedDataId); + return; + } + + var utcNow = timeProvider.UtcNow; + + var form = await context.PollingStationInformationForms + .AsNoTracking() + .FirstOrDefaultAsync(x => x.ElectionRoundId == electionRoundId, ct); + + var submissions = await GetSubmissionsAsync(electionRoundId, ct); + + var excelFileGenerator = ExcelFileGenerator.New(); + var sheetData = form is null + ? PsiSubmissionsDataTable.Empty().WithData().ForSubmissions(submissions).Please() + : PsiSubmissionsDataTable.FromForm(form).WithData().ForSubmissions(submissions).Please(); + + excelFileGenerator.WithSheet("psi-submissions", sheetData.header, sheetData.dataTable); + + var base64EncodedData = excelFileGenerator.Please(); + var fileName = $"psi-submissions-{utcNow:yyyyMMdd_HHmmss}.xlsx"; + exportedData.Complete(fileName, base64EncodedData, utcNow); + + await context.SaveChangesAsync(ct); + } + catch (Exception e) + { + logger.LogError(e, "An error occured when exporting data"); + exportedData.Fail(); + await context.SaveChangesAsync(ct); + + throw; + } + } + + private async Task> GetSubmissionsAsync(Guid electionRoundId, CancellationToken ct) + { + const string sql = + """ + SELECT + PSI."Id" AS "SubmissionId", + PS."Id" AS "PollingStationId", + PS."Level1", + PS."Level2", + PS."Level3", + PS."Level4", + PS."Level5", + PS."Number", + PS."Address", + N."Name" AS "NgoName", + PSI."ArrivalTime", + PSI."DepartureTime", + COALESCE("ComputeMinutesMonitoring"(PSI."ArrivalTime", PSI."DepartureTime", PSI."Breaks"), 0) AS "MinutesMonitoring", + PSI."NumberOfQuestionsAnswered", + PSI."NumberOfFlaggedAnswers", + PSI."IsCompleted", + PSI."LastUpdatedAt", + PSI."Answers" + FROM "PollingStationInformation" PSI + INNER JOIN "PollingStations" PS ON PS."Id" = PSI."PollingStationId" + INNER JOIN "MonitoringObservers" MO ON MO."Id" = PSI."MonitoringObserverId" + INNER JOIN "MonitoringNgos" MN ON MN."Id" = MO."MonitoringNgoId" + INNER JOIN "Ngos" N ON N."Id" = MN."NgoId" + WHERE PSI."ElectionRoundId" = @electionRoundId + ORDER BY PSI."LastUpdatedAt" DESC, PSI."Id"; + """; + + using var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct); + var submissions = await dbConnection.QueryAsync(sql, new { electionRoundId }); + return submissions.ToList(); + } +} diff --git a/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/PsiAnswerWriter.cs b/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/PsiAnswerWriter.cs new file mode 100644 index 000000000..9bf22987a --- /dev/null +++ b/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/PsiAnswerWriter.cs @@ -0,0 +1,105 @@ +using Vote.Monitor.Domain.Entities.FormAnswerBase.Answers; +using Vote.Monitor.Domain.Entities.FormBase.Questions; + +namespace Vote.Monitor.Hangfire.Jobs.Export.PollingStationInformation; + +public class PsiAnswerWriter +{ + private readonly List _header = []; + private readonly string _defaultLanguage; + private readonly BaseQuestion _question; + private readonly Dictionary> _submissionsData = new(); + + public Guid QuestionId => _question.Id; + public List Header => _header; + + public PsiAnswerWriter(string defaultLanguage, BaseQuestion question) + { + _defaultLanguage = defaultLanguage; + _question = question; + WriteHeader(question); + } + + private void WriteHeader(BaseQuestion question) + { + _header.Add($"{question.Code} - {question.Text[_defaultLanguage]}"); + + if (question is SingleSelectQuestion single && single.Options.Any(x => x.IsFreeText)) + { + _header.Add($"{question.Code} - FreeText"); + } + + if (question is MultiSelectQuestion multi && multi.Options.Any(x => x.IsFreeText)) + { + _header.Add($"{question.Code} - FreeText"); + } + } + + public void WithSubmission(Guid submissionId, BaseAnswer answer) + { + List data = []; + + switch (answer) + { + case DateAnswer dateAnswer: + data.Add(dateAnswer.Date.ToString("s")); + break; + case NumberAnswer numberAnswer: + data.Add(numberAnswer.Value); + break; + case RatingAnswer ratingAnswer: + data.Add(ratingAnswer.Value); + break; + case TextAnswer textAnswer: + data.Add(textAnswer.Text); + break; + case SingleSelectAnswer singleSelectAnswer: + { + var singleSelectQuestion = (SingleSelectQuestion)_question; + var selectedOptionText = singleSelectQuestion.Options + .First(x => x.Id == singleSelectAnswer.Selection.OptionId).Text; + data.Add(selectedOptionText[_defaultLanguage]); + + if (singleSelectQuestion.Options.Any(x => x.IsFreeText)) + { + data.Add(singleSelectAnswer.Selection.Text ?? string.Empty); + } + + break; + } + case MultiSelectAnswer multiSelectAnswer: + { + var multiSelectQuestion = (MultiSelectQuestion)_question; + var selectedOptionIds = multiSelectAnswer.Selection.Select(x => x.OptionId).ToHashSet(); + var optionTexts = multiSelectQuestion.Options + .Where(x => selectedOptionIds.Contains(x.Id)) + .Select(x => x.Text[_defaultLanguage]); + data.Add(string.Join(",", optionTexts)); + + if (multiSelectQuestion.Options.Any(x => x.IsFreeText)) + { + var freeTexts = multiSelectAnswer.Selection + .Select(x => x.Text) + .Where(x => !string.IsNullOrWhiteSpace(x)); + data.Add(string.Join(",", freeTexts)); + } + + break; + } + default: + throw new ArgumentException($"Unsupported answer type: {answer.GetType().Name}"); + } + + _submissionsData[submissionId] = data; + } + + public List Write(Guid submissionId) + { + if (_submissionsData.TryGetValue(submissionId, out var data)) + { + return data; + } + + return _header.Select(_ => (object)string.Empty).ToList(); + } +} diff --git a/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/PsiSubmissionsDataTable.cs b/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/PsiSubmissionsDataTable.cs new file mode 100644 index 000000000..f9b5c2d4e --- /dev/null +++ b/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/PsiSubmissionsDataTable.cs @@ -0,0 +1,46 @@ +using Vote.Monitor.Domain.Entities.FormBase.Questions; +using Vote.Monitor.Domain.Entities.PollingStationInfoFormAggregate; + +namespace Vote.Monitor.Hangfire.Jobs.Export.PollingStationInformation; + +public class PsiSubmissionsDataTable +{ + private readonly List _header; + private readonly List> _dataTable; + private readonly List _answerWriters; + + private PsiSubmissionsDataTable(string defaultLanguage, IReadOnlyList questions) + { + _header = + [ + "SubmissionId", + "PollingStationId", + "Level1", + "Level2", + "Level3", + "Level4", + "Level5", + "Number", + "Address", + "Ngo", + "ArrivalTime", + "DepartureTime", + "MinutesMonitoring", + "QuestionsAnswered", + "FlaggedAnswers", + "IsCompleted", + "LastUpdatedAt" + ]; + _dataTable = []; + _answerWriters = questions.Select(q => new PsiAnswerWriter(defaultLanguage, q)).ToList(); + } + + public static PsiSubmissionsDataTable FromForm(PollingStationInformationForm form) => + new(form.DefaultLanguage, form.Questions); + + public static PsiSubmissionsDataTable Empty() => + new(string.Empty, []); + + public PsiSubmissionsDataTableGenerator WithData() => + PsiSubmissionsDataTableGenerator.For(_header, _dataTable, _answerWriters); +} diff --git a/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/PsiSubmissionsDataTableGenerator.cs b/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/PsiSubmissionsDataTableGenerator.cs new file mode 100644 index 000000000..04e862286 --- /dev/null +++ b/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/PsiSubmissionsDataTableGenerator.cs @@ -0,0 +1,91 @@ +using Vote.Monitor.Hangfire.Jobs.Export.PollingStationInformation.ReadModels; + +namespace Vote.Monitor.Hangfire.Jobs.Export.PollingStationInformation; + +public class PsiSubmissionsDataTableGenerator +{ + private readonly List _header; + private readonly List> _dataTable; + private readonly Dictionary _answerWriters; + private readonly List _submissions = []; + + private PsiSubmissionsDataTableGenerator( + List header, + List> dataTable, + List answerWriters) + { + _header = header; + _dataTable = dataTable; + _answerWriters = answerWriters.ToDictionary(x => x.QuestionId); + } + + internal static PsiSubmissionsDataTableGenerator For( + List header, + List> dataTable, + List answerWriters) => + new(header, dataTable, answerWriters); + + public PsiSubmissionsDataTableGenerator ForSubmissions(IEnumerable submissions) + { + foreach (var submission in submissions) + { + ForSubmission(submission); + } + + return this; + } + + public PsiSubmissionsDataTableGenerator ForSubmission(PsiSubmissionModel submission) + { + foreach (var answer in submission.Answers) + { + if (_answerWriters.TryGetValue(answer.QuestionId, out var writer)) + { + writer.WithSubmission(submission.SubmissionId, answer); + } + } + + _submissions.Add(submission); + _dataTable.Add( + [ + submission.SubmissionId.ToString(), + submission.PollingStationId.ToString(), + submission.Level1, + submission.Level2, + submission.Level3, + submission.Level4, + submission.Level5, + submission.Number, + submission.Address, + submission.NgoName, + submission.ArrivalTime?.ToString("s") ?? string.Empty, + submission.DepartureTime?.ToString("s") ?? string.Empty, + Math.Round(submission.MinutesMonitoring, 1), + submission.NumberOfQuestionsAnswered, + submission.NumberOfFlaggedAnswers, + submission.IsCompleted, + submission.LastUpdatedAt.ToString("s") + ]); + + return this; + } + + public (List header, List> dataTable) Please() + { + foreach (var writer in _answerWriters.Values) + { + _header.AddRange(writer.Header); + } + + for (var index = 0; index < _submissions.Count; index++) + { + var submission = _submissions[index]; + foreach (var writer in _answerWriters.Values) + { + _dataTable[index].AddRange(writer.Write(submission.SubmissionId)); + } + } + + return (_header, _dataTable); + } +} diff --git a/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/ReadModels/PsiSubmissionModel.cs b/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/ReadModels/PsiSubmissionModel.cs new file mode 100644 index 000000000..f203aa94e --- /dev/null +++ b/api/src/Vote.Monitor.Hangfire/Jobs/Export/PollingStationInformation/ReadModels/PsiSubmissionModel.cs @@ -0,0 +1,25 @@ +using Vote.Monitor.Domain.Entities.FormAnswerBase.Answers; + +namespace Vote.Monitor.Hangfire.Jobs.Export.PollingStationInformation.ReadModels; + +public class PsiSubmissionModel +{ + public Guid SubmissionId { get; init; } + public Guid PollingStationId { get; init; } + public string Level1 { get; init; } = string.Empty; + public string Level2 { get; init; } = string.Empty; + public string Level3 { get; init; } = string.Empty; + public string Level4 { get; init; } = string.Empty; + public string Level5 { get; init; } = string.Empty; + public string Number { get; init; } = string.Empty; + public string Address { get; init; } = string.Empty; + public string NgoName { get; init; } = string.Empty; + public DateTime? ArrivalTime { get; init; } + public DateTime? DepartureTime { get; init; } + public double MinutesMonitoring { get; init; } + public int NumberOfQuestionsAnswered { get; init; } + public int NumberOfFlaggedAnswers { get; init; } + public bool IsCompleted { get; init; } + public DateTime LastUpdatedAt { get; init; } + public BaseAnswer[] Answers { get; init; } = []; +} diff --git a/api/src/Vote.Monitor.Hangfire/Program.cs b/api/src/Vote.Monitor.Hangfire/Program.cs index 6b1def37f..41c6454d8 100644 --- a/api/src/Vote.Monitor.Hangfire/Program.cs +++ b/api/src/Vote.Monitor.Hangfire/Program.cs @@ -39,6 +39,7 @@ using Vote.Monitor.Hangfire.Jobs.Export.FormSubmissions; using Vote.Monitor.Hangfire.Jobs.Export.IncidentReports; using Vote.Monitor.Hangfire.Jobs.Export.Locations; +using Vote.Monitor.Hangfire.Jobs.Export.PollingStationInformation; using Vote.Monitor.Hangfire.Jobs.Export.PollingStations; using Vote.Monitor.Hangfire.Jobs.Export.QuickReports; using Vote.Monitor.Hangfire.Jobs.Export.QuickReports.ReadModels; @@ -130,6 +131,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); #endregion var dbConnectionString = builder.Configuration.GetNpgsqlConnectionString("Core:HangfireConnectionConfig");