Skip to content
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
14 changes: 14 additions & 0 deletions api/src/Feature.DataExport/Start/Endpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@
}
}

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);
}
}
Comment on lines +61 to +68

var exportedData = CreateExportedData(req);

await repository.AddAsync(exportedData, ct);
Expand Down Expand Up @@ -97,6 +106,11 @@
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 });
}

Expand Down
5 changes: 5 additions & 0 deletions api/src/Feature.DataExport/Start/Validator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,10 @@ public Validator()
{
RuleFor(x => x.ElectionRoundId).NotEmpty();
});

When(x => x.ExportedDataType == ExportedDataType.PollingStationInformation, () =>
{
RuleFor(x => x.ElectionRoundId).NotEmpty();
});
}
}
202 changes: 202 additions & 0 deletions api/src/Feature.PollingStation.Information/Export/Endpoint.cs
Original file line number Diff line number Diff line change
@@ -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<Request>
{
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<PsiDataRowModel> rows;
using (var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct))
{
rows = (await dbConnection.QueryAsync<PsiDataRowModel>(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<Guid, IReadOnlyList<BaseAnswer>>()
: (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<BaseQuestion> 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<BaseQuestion> questions,
IReadOnlyList<BaseAnswer> 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<string, string>? 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();
}
}
7 changes: 7 additions & 0 deletions api/src/Feature.PollingStation.Information/Export/Request.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using Feature.PollingStation.Information.PsiData;

namespace Feature.PollingStation.Information.Export;

public class Request : PsiDataFilters
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Feature.PollingStation.Information.Export;

public class Validator : Validator<Request>
{
public Validator()
{
RuleFor(x => x.ElectionRoundId).NotEmpty();
}
}
45 changes: 45 additions & 0 deletions api/src/Feature.PollingStation.Information/ListAll/Endpoint.cs
Original file line number Diff line number Diff line change
@@ -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<Request, PagedResponse<PsiDataRowModel>>
{
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<PagedResponse<PsiDataRowModel>> 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<int>().Single();
var items = multi.Read<PsiDataRowModel>().ToList();

return new PagedResponse<PsiDataRowModel>(items, totalCount, req.PageNumber, req.PageSize);
}
}
7 changes: 7 additions & 0 deletions api/src/Feature.PollingStation.Information/ListAll/Request.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using Feature.PollingStation.Information.PsiData;

namespace Feature.PollingStation.Information.ListAll;

public class Request : PsiDataFilters
{
}
11 changes: 11 additions & 0 deletions api/src/Feature.PollingStation.Information/ListAll/Validator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Feature.PollingStation.Information.ListAll;

public class Validator : Validator<Request>
{
public Validator()
{
RuleFor(x => x.ElectionRoundId).NotEmpty();
RuleFor(x => x.PageNumber).GreaterThanOrEqualTo(1);
RuleFor(x => x.PageSize).InclusiveBetween(1, 100);
}
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading
Loading