MatDenDagen/Components/Pages/Admin/Export.razor
+7
-0
diff --git a/MatDenDagen/Components/Pages/Admin/Export.razor b/MatDenDagen/Components/Pages/Admin/Export.razor
new file mode 100644
index 0000000..23ecbaf
@@ -0,0 +1,7 @@
@page "/admin/export"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize(Roles = "Admin")]
<h1>Export</h1>
<p>Ladda ner alla svar: <a href="/admin/export.zip" download="export.zip">export.zip</a></p>
MatDenDagen/Program.cs
+22
-1
diff --git a/MatDenDagen/Program.cs b/MatDenDagen/Program.cs
index 0c31dba..f6762c4 100644
@@ -1,16 +1,20 @@
using System.Threading;
using MatDenDagen;
using MatDenDagen.Components;
using MatDenDagen.Infrastructure.Storage;
using MatDenDagen.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.ConfigureOpenTelemetry();
builder.Services.AddStorageServices().AddAdminService().AddDateService();
builder.Services.AddStorageServices().AddAdminService().AddDateService().AddExportService();
builder.Services.AddRazorComponents();
@@ -34,4 +38,21 @@ app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages:
app.MapStaticAssets();
app.MapRazorComponents<App>().DisableAntiforgery();
app.MapGet(
"/admin/export.zip",
async (
[FromServices] ExportService exportService,
HttpContext httpContext,
HttpResponse httpResponse,
CancellationToken cancellationToken
) =>
{
httpContext.Features.Get<IHttpBodyControlFeature>()?.AllowSynchronousIO = true;
httpResponse.Headers.ContentType = "application/zip";
httpResponse.Headers.ContentDisposition = "attachment; filename=\"export.zip\"";
await exportService.ExportAllSubmissions(httpResponse.Body, cancellationToken);
}
)
.RequireAuthorization(p => p.RequireRole("Admin"));
app.Run();
MatDenDagen/Services/ExportService.cs
+125
-0
diff --git a/MatDenDagen/Services/ExportService.cs b/MatDenDagen/Services/ExportService.cs
new file mode 100644
index 0000000..0608019
@@ -0,0 +1,125 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MatDenDagen.Infrastructure.Storage.BlobStorage;
using MatDenDagen.Infrastructure.Storage.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace MatDenDagen.Services;
public sealed class ExportService(
ILogger<ExportService> logger,
QuestionnaireContext questionnaireContext,
BlobStorageService blobStorage
)
{
public async Task ExportAllSubmissions(Stream outputStream, CancellationToken cancellationToken)
{
await using var archive = await ZipArchive.CreateAsync(
outputStream,
ZipArchiveMode.Create,
leaveOpen: true,
Encoding.UTF8,
cancellationToken
);
var questions = await questionnaireContext.Questions.ToDictionaryAsync(
q => q.Id,
q => q.Text,
cancellationToken
);
var submissions = await questionnaireContext
.Submissions.Include(s => s.Answers)
.Include(s => s.Uploads)
.ToArrayAsync(cancellationToken);
{
var entry = archive.CreateEntry("export.md");
await using var entryStream = await entry.OpenAsync(cancellationToken);
await using var writer = new StreamWriter(entryStream);
await writer.WriteLineAsync(
$"""
# Export
Svar från {submissions.Length} deltagare
""".AsMemory(),
cancellationToken
);
}
foreach (var submission in submissions)
{
var participant = await questionnaireContext.Participants.SingleAsync(
p => p.Id == submission.Participant,
cancellationToken
);
{
var entry = archive.CreateEntry(Path.Join(participant.Name, "svar.md"));
await using var entryStream = await entry.OpenAsync(cancellationToken);
await using var writer = new StreamWriter(entryStream);
await writer.WriteLineAsync(
$"""
# {participant.Name}
""".AsMemory(),
cancellationToken
);
foreach (var answer in submission.Answers)
{
if (!questions.TryGetValue(answer.QuestionId, out var question))
{
logger.LogError("An answer to {QuestionId} exist, but no question.", answer.QuestionId);
question = answer.QuestionId.ToString();
}
await writer.WriteLineAsync(
$"""
## {question}
{answer.Text}
""".AsMemory(),
cancellationToken
);
}
}
foreach (var upload in submission.Uploads)
{
var entry = archive.CreateEntry(Path.Join(participant.Name, upload.Name));
await using var entryStream = await entry.OpenAsync(cancellationToken);
var blobStream = blobStorage.GetBlob(upload.Id);
if (blobStream is null)
{
logger.LogError("Upload {UploadId} is missing from blob storage.", upload.Id);
await using var writer = new StreamWriter(entryStream);
await writer.WriteLineAsync("Filen saknas.".AsMemory(), cancellationToken);
}
else
{
await using (blobStream)
{
await blobStream.CopyToAsync(entryStream, cancellationToken);
}
}
}
}
}
}
public static class ExportServiceCollectionExtensions
{
public static IServiceCollection AddExportService(this IServiceCollection services) =>
services.AddTransient<ExportService>();
}
README.md
+1
-1
diff --git a/README.md b/README.md
index 10438a2..1fb466b 100644
@@ -15,7 +15,7 @@
- [x] Phone number for notifications
- [x] Time of day the want to be notified
- [x] Configure questions
- [ ] Export answers
- [x] Export answers
- [ ] Internals
- [ ] Job that sends out notifications
- [x] Storage for text answers