WebApi + ClientApp, GraphQL, Reflection
This commit is contained in:
26
Events-WebApi/Events.FilesAPI/Events.FilesAPI.csproj
Normal file
26
Events-WebApi/Events.FilesAPI/Events.FilesAPI.csproj
Normal file
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>PI</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LargeXlsx" Version="2.0.1" />
|
||||
<PackageReference Include="MassTransit.RabbitMQ" Version="8.5.9" />
|
||||
<PackageReference Include="MediatR" Version="14.1.0" />
|
||||
<PackageReference Include="PdfSharpCore" Version="1.3.67" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Events.WebAPI.Contract\Events.WebAPI.Contract.csproj" />
|
||||
<ProjectReference Include="..\Events.WebAPI.Handlers.EF\Events.WebAPI.Handlers.EF.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Text;
|
||||
using PdfSharpCore;
|
||||
using PdfSharpCore.Drawing;
|
||||
using PdfSharpCore.Fonts;
|
||||
using PdfSharpCore.Pdf;
|
||||
using PdfSharpCore.Utils;
|
||||
|
||||
namespace Events.FilesAPI.Features.Certificates;
|
||||
|
||||
internal static class CertificatePdfDocumentWriter
|
||||
{
|
||||
private const string FontFamilyName = "Arial";
|
||||
private static int initialized;
|
||||
|
||||
public static byte[] CreateCertificate(CertificatePdfModel model)
|
||||
{
|
||||
EnsureFontsConfigured();
|
||||
|
||||
using var document = new PdfDocument();
|
||||
PdfPage page = document.AddPage();
|
||||
page.Size = PageSize.A4;
|
||||
|
||||
using XGraphics graphics = XGraphics.FromPdfPage(page);
|
||||
var titleFont = new XFont(FontFamilyName, 20, XFontStyle.Bold);
|
||||
var headingFont = new XFont(FontFamilyName, 13, XFontStyle.Bold);
|
||||
var textFont = new XFont(FontFamilyName, 12, XFontStyle.Regular);
|
||||
|
||||
double marginLeft = 50;
|
||||
double y = 60;
|
||||
double contentWidth = page.Width - marginLeft * 2;
|
||||
|
||||
graphics.DrawString(model.Title, titleFont, XBrushes.DarkBlue, new XRect(marginLeft, y, contentWidth, 30), XStringFormats.TopLeft);
|
||||
y += 52;
|
||||
|
||||
foreach (string paragraph in BuildParagraphs(model))
|
||||
{
|
||||
DrawParagraph(graphics, paragraph, textFont, marginLeft, ref y, contentWidth);
|
||||
y += 8;
|
||||
}
|
||||
|
||||
graphics.DrawString("Sports", headingFont, XBrushes.Black, new XRect(marginLeft, y, contentWidth, 20), XStringFormats.TopLeft);
|
||||
y += 26;
|
||||
|
||||
foreach (string sportName in model.SportNames)
|
||||
{
|
||||
DrawParagraph(graphics, $"- {sportName}", textFont, marginLeft + 12, ref y, contentWidth - 12);
|
||||
y += 4;
|
||||
}
|
||||
|
||||
y += 12;
|
||||
DrawParagraph(graphics, $"Event ID: {model.EventId}", textFont, marginLeft, ref y, contentWidth);
|
||||
y += 4;
|
||||
DrawParagraph(graphics, $"Person ID: {model.PersonId}", textFont, marginLeft, ref y, contentWidth);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
document.Save(stream, false);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void EnsureFontsConfigured()
|
||||
{
|
||||
if (Interlocked.Exchange(ref initialized, 1) == 1)
|
||||
return;
|
||||
|
||||
GlobalFontSettings.FontResolver = new FontResolver();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildParagraphs(CertificatePdfModel model)
|
||||
{
|
||||
yield return $"This confirms that {model.PersonFullName} participated in the event \"{model.EventName}\".";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(model.PersonFullNameTranscription) &&
|
||||
!string.Equals(model.PersonFullName, model.PersonFullNameTranscription, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return $"Transcribed full name: {model.PersonFullNameTranscription}.";
|
||||
}
|
||||
|
||||
yield return $"Event date: {model.EventDate:dd.MM.yyyy}.";
|
||||
yield return "The person competed in the following sports:";
|
||||
}
|
||||
|
||||
private static void DrawParagraph(XGraphics graphics, string text, XFont font, double left, ref double y, double width)
|
||||
{
|
||||
foreach (string line in WrapText(graphics, text, font, width))
|
||||
{
|
||||
graphics.DrawString(line, font, XBrushes.Black, new XRect(left, y, width, 18), XStringFormats.TopLeft);
|
||||
y += 18;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> WrapText(XGraphics graphics, string text, XFont font, double width)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
yield return string.Empty;
|
||||
yield break;
|
||||
}
|
||||
|
||||
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var lineBuilder = new StringBuilder();
|
||||
|
||||
foreach (string word in words)
|
||||
{
|
||||
string candidate = lineBuilder.Length == 0 ? word : $"{lineBuilder} {word}";
|
||||
if (graphics.MeasureString(candidate, font).Width <= width)
|
||||
{
|
||||
lineBuilder.Clear();
|
||||
lineBuilder.Append(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lineBuilder.Length > 0)
|
||||
{
|
||||
yield return lineBuilder.ToString();
|
||||
lineBuilder.Clear();
|
||||
lineBuilder.Append(word);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return word;
|
||||
}
|
||||
}
|
||||
|
||||
if (lineBuilder.Length > 0)
|
||||
yield return lineBuilder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Events.FilesAPI.Features.Certificates;
|
||||
|
||||
internal sealed class CertificatePdfModel
|
||||
{
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string PersonFullName { get; init; } = string.Empty;
|
||||
public string PersonFullNameTranscription { get; init; } = string.Empty;
|
||||
public string EventName { get; init; } = string.Empty;
|
||||
public DateOnly EventDate { get; init; }
|
||||
public int EventId { get; init; }
|
||||
public int PersonId { get; init; }
|
||||
public IReadOnlyList<string> SportNames { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Events.WebAPI.Contract.Messages;
|
||||
using Events.FilesAPI.Features.Certificates.Synchronize;
|
||||
using MassTransit;
|
||||
using MediatR;
|
||||
|
||||
namespace Events.FilesAPI.Features.Certificates;
|
||||
|
||||
public class CertificateRegistrationEventsConsumer :
|
||||
IConsumer<RegistrationCreated>,
|
||||
IConsumer<RegistrationUpdated>,
|
||||
IConsumer<RegistrationDeleted>
|
||||
{
|
||||
private readonly IMediator mediator;
|
||||
|
||||
public CertificateRegistrationEventsConsumer(IMediator mediator)
|
||||
{
|
||||
this.mediator = mediator;
|
||||
}
|
||||
|
||||
public Task Consume(ConsumeContext<RegistrationCreated> context)
|
||||
{
|
||||
return mediator.Send(new SynchronizeCertificateCommand(
|
||||
context.Message.EventId,
|
||||
context.Message.PersonId), context.CancellationToken);
|
||||
}
|
||||
|
||||
public async Task Consume(ConsumeContext<RegistrationUpdated> context)
|
||||
{
|
||||
await mediator.Send(new SynchronizeCertificateCommand(
|
||||
context.Message.EventId,
|
||||
context.Message.PersonId), context.CancellationToken);
|
||||
|
||||
if (context.Message.PreviousEventId != context.Message.EventId ||
|
||||
context.Message.PreviousPersonId != context.Message.PersonId)
|
||||
{
|
||||
await mediator.Send(new SynchronizeCertificateCommand(
|
||||
context.Message.PreviousEventId,
|
||||
context.Message.PreviousPersonId), context.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public Task Consume(ConsumeContext<RegistrationDeleted> context)
|
||||
{
|
||||
return mediator.Send(new SynchronizeCertificateCommand(
|
||||
context.Message.EventId,
|
||||
context.Message.PersonId), context.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Globalization;
|
||||
using Events.FilesAPI.Infrastructure.Files;
|
||||
using Events.FilesAPI.Infrastructure.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Events.FilesAPI.Features.Certificates.Download;
|
||||
|
||||
public sealed class CertificateFileLocator(
|
||||
IHostEnvironment hostEnvironment,
|
||||
IOptions<GeneratedFilesOptions> generatedFilesOptions)
|
||||
{
|
||||
public GeneratedFileReference? TryGet(int eventId, int personId)
|
||||
{
|
||||
string rootPath = Path.IsPathRooted(generatedFilesOptions.Value.OutputPath)
|
||||
? generatedFilesOptions.Value.OutputPath
|
||||
: Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, generatedFilesOptions.Value.OutputPath));
|
||||
|
||||
string certificatePath = Path.Combine(
|
||||
rootPath,
|
||||
eventId.ToString(CultureInfo.InvariantCulture),
|
||||
$"{eventId}-{personId}.pdf");
|
||||
|
||||
if (!File.Exists(certificatePath))
|
||||
return null;
|
||||
|
||||
return new GeneratedFileReference
|
||||
{
|
||||
FileName = Path.GetFileName(certificatePath),
|
||||
PhysicalPath = certificatePath
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Events.FilesAPI.Infrastructure.Files;
|
||||
using Events.FilesAPI.Features.Certificates.Synchronize;
|
||||
using Events.WebAPI.Handlers.EF.Data.Postgres;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Events.FilesAPI.Features.Certificates.Download;
|
||||
|
||||
public sealed class DownloadCertificateHandler(
|
||||
EventsContext context,
|
||||
CertificateFileGenerator generator,
|
||||
CertificateFileLocator fileLocator) : IRequestHandler<DownloadCertificateQuery, DownloadCertificateResult>
|
||||
{
|
||||
public async Task<DownloadCertificateResult> Handle(DownloadCertificateQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var registration = await context.Registrations
|
||||
.AsNoTracking()
|
||||
.Where(r => r.Id == request.RegistrationId)
|
||||
.Select(r => new { r.EventId, r.PersonId })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (registration == null)
|
||||
return new DownloadCertificateResult(false, null);
|
||||
|
||||
GeneratedFileReference? file = fileLocator.TryGet(registration.EventId, registration.PersonId);
|
||||
|
||||
if (file == null)
|
||||
{
|
||||
await generator.GenerateAsync(registration.EventId, registration.PersonId, cancellationToken);
|
||||
file = fileLocator.TryGet(registration.EventId, registration.PersonId);
|
||||
}
|
||||
|
||||
return new DownloadCertificateResult(true, file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Events.FilesAPI.Infrastructure.Files;
|
||||
using MediatR;
|
||||
|
||||
namespace Events.FilesAPI.Features.Certificates.Download;
|
||||
|
||||
public sealed record DownloadCertificateQuery(int RegistrationId) : IRequest<DownloadCertificateResult>;
|
||||
|
||||
public sealed record DownloadCertificateResult(bool RegistrationFound, GeneratedFileReference? File);
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Events.FilesAPI.Features.Certificates.Download;
|
||||
using MediatR;
|
||||
|
||||
namespace Events.FilesAPI.Features.Certificates;
|
||||
|
||||
[ApiController]
|
||||
[Route("Registrations")]
|
||||
public class DownloadCertificateController : ControllerBase
|
||||
{
|
||||
private const string PdfContentType = "application/pdf";
|
||||
|
||||
[HttpGet("{id}/Certificate")]
|
||||
[ProducesResponseType(typeof(PhysicalFileResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DownloadCertificate(
|
||||
int id,
|
||||
[FromServices] IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DownloadCertificateResult result = await mediator.Send(new DownloadCertificateQuery(id), cancellationToken);
|
||||
if (!result.RegistrationFound)
|
||||
return Problem(statusCode: StatusCodes.Status404NotFound, detail: $"Invalid id = {id}");
|
||||
|
||||
if (result.File == null)
|
||||
return Problem(statusCode: StatusCodes.Status404NotFound, detail: "Certificate could not be generated.");
|
||||
|
||||
return PhysicalFile(result.File.PhysicalPath, PdfContentType, result.File.FileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Globalization;
|
||||
using Events.FilesAPI.Infrastructure.Options;
|
||||
using Events.WebAPI.Handlers.EF.Data.Postgres;
|
||||
using Events.WebAPI.Handlers.EF.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Events.FilesAPI.Features.Certificates.Synchronize;
|
||||
|
||||
public sealed class CertificateFileGenerator(
|
||||
EventsContext context,
|
||||
IHostEnvironment hostEnvironment,
|
||||
IOptions<GeneratedFilesOptions> generatedFilesOptions,
|
||||
ILogger<CertificateFileGenerator> logger)
|
||||
{
|
||||
public async Task GenerateAsync(int eventId, int personId, CancellationToken cancellationToken)
|
||||
{
|
||||
var registrations = await context.Set<Registration>()
|
||||
.AsNoTracking()
|
||||
.Where(r => r.EventId == eventId && r.PersonId == personId)
|
||||
.OrderBy(r => r.Sport.Name)
|
||||
.Select(r => new CertificateRegistrationData
|
||||
{
|
||||
EventId = r.EventId,
|
||||
EventName = r.Event.Name,
|
||||
EventDate = r.Event.EventDate,
|
||||
PersonId = r.PersonId,
|
||||
FirstName = r.Person.FirstName,
|
||||
LastName = r.Person.LastName,
|
||||
FirstNameTranscription = r.Person.FirstNameTranscription,
|
||||
LastNameTranscription = r.Person.LastNameTranscription,
|
||||
SportName = r.Sport.Name
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
string certificatePath = GetCertificatePath(eventId, personId);
|
||||
if (registrations.Count == 0)
|
||||
{
|
||||
DeleteCertificateIfExists(certificatePath);
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(certificatePath)!);
|
||||
|
||||
byte[] pdfBytes = CertificatePdfDocumentWriter.CreateCertificate(BuildModel(registrations));
|
||||
await File.WriteAllBytesAsync(certificatePath, pdfBytes, cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Registration certificate generated for event #{EventId}, person #{PersonId} at {Path}",
|
||||
eventId,
|
||||
personId,
|
||||
certificatePath);
|
||||
}
|
||||
|
||||
private string GetCertificatePath(int eventId, int personId)
|
||||
{
|
||||
string rootPath = Path.IsPathRooted(generatedFilesOptions.Value.OutputPath)
|
||||
? generatedFilesOptions.Value.OutputPath
|
||||
: Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, generatedFilesOptions.Value.OutputPath));
|
||||
|
||||
return Path.Combine(rootPath, eventId.ToString(CultureInfo.InvariantCulture), $"{eventId}-{personId}.pdf");
|
||||
}
|
||||
|
||||
private void DeleteCertificateIfExists(string certificatePath)
|
||||
{
|
||||
DeleteFileIfExists(certificatePath, "Registration certificate deleted at {Path}");
|
||||
|
||||
string? directory = Path.GetDirectoryName(certificatePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory) &&
|
||||
Directory.Exists(directory) &&
|
||||
!Directory.EnumerateFileSystemEntries(directory).Any())
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteFileIfExists(string path, string logMessage)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return;
|
||||
|
||||
File.Delete(path);
|
||||
logger.LogInformation(logMessage, path);
|
||||
}
|
||||
|
||||
private static CertificatePdfModel BuildModel(IReadOnlyList<CertificateRegistrationData> registrations)
|
||||
{
|
||||
CertificateRegistrationData first = registrations[0];
|
||||
string originalFullName = $"{first.FirstName} {first.LastName}".Trim();
|
||||
string transcriptionFullName = $"{first.FirstNameTranscription} {first.LastNameTranscription}".Trim();
|
||||
|
||||
var sports = registrations
|
||||
.Select(r => r.SportName)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new CertificatePdfModel
|
||||
{
|
||||
Title = "Certificate of participation",
|
||||
PersonFullName = originalFullName,
|
||||
PersonFullNameTranscription = transcriptionFullName,
|
||||
EventName = first.EventName,
|
||||
EventDate = first.EventDate,
|
||||
EventId = first.EventId,
|
||||
PersonId = first.PersonId,
|
||||
SportNames = sports
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class CertificateRegistrationData
|
||||
{
|
||||
public int EventId { get; init; }
|
||||
public string EventName { get; init; } = string.Empty;
|
||||
public DateOnly EventDate { get; init; }
|
||||
public int PersonId { get; init; }
|
||||
public string? FirstName { get; init; } = string.Empty;
|
||||
public string? LastName { get; init; } = string.Empty;
|
||||
public string FirstNameTranscription { get; init; } = string.Empty;
|
||||
public string LastNameTranscription { get; init; } = string.Empty;
|
||||
public string SportName { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Events.FilesAPI.Features.Certificates.Synchronize;
|
||||
|
||||
public sealed record SynchronizeCertificateCommand(int EventId, int PersonId) : IRequest;
|
||||
@@ -0,0 +1,12 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Events.FilesAPI.Features.Certificates.Synchronize;
|
||||
|
||||
public sealed class SynchronizeCertificateHandler(
|
||||
CertificateFileGenerator generator) : IRequestHandler<SynchronizeCertificateCommand>
|
||||
{
|
||||
public async Task Handle(SynchronizeCertificateCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
await generator.GenerateAsync(request.EventId, request.PersonId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Events.FilesAPI.Infrastructure.Files;
|
||||
using Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
|
||||
using Events.WebAPI.Handlers.EF.Data.Postgres;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MediatR;
|
||||
|
||||
namespace Events.FilesAPI.Features.RegistrationsExcel.Download;
|
||||
|
||||
public sealed class DownloadRegistrationsExcelHandler(
|
||||
EventsContext context,
|
||||
RegistrationsExcelFileGenerator generator,
|
||||
RegistrationsExcelFileLocator fileLocator) : IRequestHandler<DownloadRegistrationsExcelQuery, DownloadRegistrationsExcelResult>
|
||||
{
|
||||
public async Task<DownloadRegistrationsExcelResult> Handle(DownloadRegistrationsExcelQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
bool exists = await context.Events
|
||||
.AsNoTracking()
|
||||
.AnyAsync(e => e.Id == request.EventId, cancellationToken);
|
||||
|
||||
if (!exists)
|
||||
return new DownloadRegistrationsExcelResult(false, null);
|
||||
|
||||
GeneratedFileReference? file = fileLocator.TryGet(request.EventId);
|
||||
|
||||
if (file == null)
|
||||
{
|
||||
await generator.GenerateAsync(request.EventId, cancellationToken);
|
||||
file = fileLocator.TryGet(request.EventId);
|
||||
}
|
||||
|
||||
return new DownloadRegistrationsExcelResult(true, file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Events.FilesAPI.Infrastructure.Files;
|
||||
using MediatR;
|
||||
|
||||
namespace Events.FilesAPI.Features.RegistrationsExcel.Download;
|
||||
|
||||
public sealed record DownloadRegistrationsExcelQuery(int EventId) : IRequest<DownloadRegistrationsExcelResult>;
|
||||
|
||||
public sealed record DownloadRegistrationsExcelResult(bool EventFound, GeneratedFileReference? File);
|
||||
@@ -0,0 +1,27 @@
|
||||
using Events.FilesAPI.Infrastructure.Files;
|
||||
using Events.FilesAPI.Infrastructure.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Events.FilesAPI.Features.RegistrationsExcel.Download;
|
||||
|
||||
public sealed class RegistrationsExcelFileLocator(
|
||||
IHostEnvironment hostEnvironment,
|
||||
IOptions<GeneratedFilesOptions> generatedFilesOptions)
|
||||
{
|
||||
public GeneratedFileReference? TryGet(int eventId)
|
||||
{
|
||||
string rootPath = Path.IsPathRooted(generatedFilesOptions.Value.OutputPath)
|
||||
? generatedFilesOptions.Value.OutputPath
|
||||
: Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, generatedFilesOptions.Value.OutputPath));
|
||||
|
||||
string excelPath = Path.Combine(rootPath, $"{eventId}.xlsx");
|
||||
if (!File.Exists(excelPath))
|
||||
return null;
|
||||
|
||||
return new GeneratedFileReference
|
||||
{
|
||||
FileName = Path.GetFileName(excelPath),
|
||||
PhysicalPath = excelPath
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Events.FilesAPI.Features.RegistrationsExcel.Download;
|
||||
using MediatR;
|
||||
|
||||
namespace Events.FilesAPI.Features.RegistrationsExcel;
|
||||
|
||||
[ApiController]
|
||||
[Route("Events")]
|
||||
public class DownloadRegistrationsExcelController : ControllerBase
|
||||
{
|
||||
private const string XlsxContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
|
||||
[HttpGet("{id}/RegistrationsExcel")]
|
||||
[ProducesResponseType(typeof(PhysicalFileResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DownloadRegistrationsExcel(
|
||||
int id,
|
||||
[FromServices] IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DownloadRegistrationsExcelResult result = await mediator.Send(new DownloadRegistrationsExcelQuery(id), cancellationToken);
|
||||
if (!result.EventFound)
|
||||
return Problem(statusCode: StatusCodes.Status404NotFound, detail: $"Invalid id = {id}");
|
||||
|
||||
if (result.File == null)
|
||||
return Problem(statusCode: StatusCodes.Status404NotFound, detail: "Registrations Excel could not be generated.");
|
||||
|
||||
return PhysicalFile(result.File.PhysicalPath, XlsxContentType, result.File.FileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using LargeXlsx;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Events.FilesAPI.Features.RegistrationsExcel;
|
||||
|
||||
internal static class EventRegistrationsExcelWriter
|
||||
{
|
||||
public static async Task WriteAsync(
|
||||
string path,
|
||||
IQueryable<RowData> rows,
|
||||
RowData firstRow,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
await using var writer = new XlsxWriter(stream);
|
||||
|
||||
string[] headers =
|
||||
[
|
||||
"Registration ID",
|
||||
"Registration date",
|
||||
"Person ID",
|
||||
"Last name",
|
||||
"First name",
|
||||
"Last name transcription",
|
||||
"First name transcription",
|
||||
"Country",
|
||||
"Sport"
|
||||
];
|
||||
|
||||
string[] firstRowValues = GetRowValues(firstRow);
|
||||
var columns = headers
|
||||
.Select((header, index) => XlsxColumn.Formatted(GetWidth(header, firstRowValues[index])))
|
||||
.ToArray();
|
||||
|
||||
writer
|
||||
.BeginWorksheet("Registrations", columns: columns)
|
||||
.BeginRow();
|
||||
|
||||
foreach (string header in headers)
|
||||
{
|
||||
writer.Write(header);
|
||||
}
|
||||
|
||||
await foreach (RowData row in rows.AsAsyncEnumerable().WithCancellation(cancellationToken))
|
||||
{
|
||||
writer
|
||||
.BeginRow()
|
||||
.Write(row.RegistrationId)
|
||||
.Write(FormatRegisteredAt(row.RegisteredAt))
|
||||
.Write(row.PersonId)
|
||||
.Write(row.LastName)
|
||||
.Write(row.FirstName)
|
||||
.Write(row.LastNameTranscription)
|
||||
.Write(row.FirstNameTranscription)
|
||||
.Write(row.CountryName)
|
||||
.Write(row.SportName);
|
||||
}
|
||||
|
||||
await writer.CommitAsync();
|
||||
}
|
||||
|
||||
private static string[] GetRowValues(RowData row)
|
||||
{
|
||||
return
|
||||
[
|
||||
row.RegistrationId.ToString(),
|
||||
FormatRegisteredAt(row.RegisteredAt),
|
||||
row.PersonId.ToString(),
|
||||
row.LastName,
|
||||
row.FirstName,
|
||||
row.LastNameTranscription,
|
||||
row.FirstNameTranscription,
|
||||
row.CountryName,
|
||||
row.SportName
|
||||
];
|
||||
}
|
||||
|
||||
private static double GetWidth(string header, string sample)
|
||||
{
|
||||
int maxLength = Math.Max(header.Length, sample.Length);
|
||||
double paddedWidth = Math.Ceiling(maxLength * 1.25d + 4d);
|
||||
return Math.Clamp(paddedWidth, 10d, 60d);
|
||||
}
|
||||
|
||||
private static string FormatRegisteredAt(DateTime? value)
|
||||
{
|
||||
return value.HasValue ? value.Value.ToString("yyyy-MM-dd HH:mm:ss") : string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class RowData
|
||||
{
|
||||
public int RegistrationId { get; init; }
|
||||
public DateTime? RegisteredAt { get; init; }
|
||||
public int PersonId { get; init; }
|
||||
public string FirstName { get; init; } = string.Empty;
|
||||
public string LastName { get; init; } = string.Empty;
|
||||
public string FirstNameTranscription { get; init; } = string.Empty;
|
||||
public string LastNameTranscription { get; init; } = string.Empty;
|
||||
public string CountryName { get; init; } = string.Empty;
|
||||
public string SportName { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Events.WebAPI.Contract.Messages;
|
||||
using Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
|
||||
using MassTransit;
|
||||
using MediatR;
|
||||
|
||||
namespace Events.FilesAPI.Features.RegistrationsExcel;
|
||||
|
||||
public class RegistrationsExcelEventsConsumer :
|
||||
IConsumer<RegistrationCreated>,
|
||||
IConsumer<RegistrationUpdated>,
|
||||
IConsumer<RegistrationDeleted>
|
||||
{
|
||||
private readonly IMediator mediator;
|
||||
|
||||
public RegistrationsExcelEventsConsumer(IMediator mediator)
|
||||
{
|
||||
this.mediator = mediator;
|
||||
}
|
||||
|
||||
public Task Consume(ConsumeContext<RegistrationCreated> context)
|
||||
{
|
||||
return mediator.Send(new SynchronizeRegistrationsExcelCommand(
|
||||
context.Message.EventId), context.CancellationToken);
|
||||
}
|
||||
|
||||
public async Task Consume(ConsumeContext<RegistrationUpdated> context)
|
||||
{
|
||||
await mediator.Send(new SynchronizeRegistrationsExcelCommand(
|
||||
context.Message.EventId), context.CancellationToken);
|
||||
|
||||
if (context.Message.PreviousEventId != context.Message.EventId)
|
||||
{
|
||||
await mediator.Send(new SynchronizeRegistrationsExcelCommand(
|
||||
context.Message.PreviousEventId), context.CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public Task Consume(ConsumeContext<RegistrationDeleted> context)
|
||||
{
|
||||
return mediator.Send(new SynchronizeRegistrationsExcelCommand(
|
||||
context.Message.EventId), context.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Events.FilesAPI.Infrastructure.Options;
|
||||
using Events.WebAPI.Handlers.EF.Data.Postgres;
|
||||
using Events.WebAPI.Handlers.EF.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
|
||||
|
||||
public sealed class RegistrationsExcelFileGenerator(
|
||||
EventsContext context,
|
||||
IHostEnvironment hostEnvironment,
|
||||
IOptions<GeneratedFilesOptions> generatedFilesOptions,
|
||||
ILogger<RegistrationsExcelFileGenerator> logger)
|
||||
{
|
||||
public async Task GenerateAsync(int eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
var registrations = context.Set<Registration>()
|
||||
.AsNoTracking()
|
||||
.Where(r => r.EventId == eventId)
|
||||
.OrderBy(r => r.Person.LastName)
|
||||
.ThenBy(r => r.Person.FirstName)
|
||||
.ThenBy(r => r.Sport.Name)
|
||||
.Select(r => new EventRegistrationsExcelWriter.RowData
|
||||
{
|
||||
RegistrationId = r.Id,
|
||||
RegisteredAt = r.RegisteredAt,
|
||||
PersonId = r.PersonId,
|
||||
FirstName = r.Person.FirstName,
|
||||
LastName = r.Person.LastName,
|
||||
FirstNameTranscription = r.Person.FirstNameTranscription,
|
||||
LastNameTranscription = r.Person.LastNameTranscription,
|
||||
CountryName = r.Person.CountryCodeNavigation.Name,
|
||||
SportName = r.Sport.Name
|
||||
});
|
||||
|
||||
string excelPath = GetPath(eventId);
|
||||
EventRegistrationsExcelWriter.RowData? firstRow = await registrations.FirstOrDefaultAsync(cancellationToken);
|
||||
if (firstRow == null)
|
||||
{
|
||||
DeleteFileIfExists(excelPath);
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(excelPath)!);
|
||||
await EventRegistrationsExcelWriter.WriteAsync(excelPath, registrations, firstRow, cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Event registrations Excel generated for event #{EventId} at {Path}",
|
||||
eventId,
|
||||
excelPath);
|
||||
}
|
||||
|
||||
private string GetPath(int eventId)
|
||||
{
|
||||
string rootPath = Path.IsPathRooted(generatedFilesOptions.Value.OutputPath)
|
||||
? generatedFilesOptions.Value.OutputPath
|
||||
: Path.GetFullPath(Path.Combine(hostEnvironment.ContentRootPath, generatedFilesOptions.Value.OutputPath));
|
||||
|
||||
return Path.Combine(rootPath, $"{eventId}.xlsx");
|
||||
}
|
||||
|
||||
private void DeleteFileIfExists(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return;
|
||||
|
||||
File.Delete(path);
|
||||
logger.LogInformation("Event registrations Excel deleted at {Path}", path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
|
||||
|
||||
public sealed record SynchronizeRegistrationsExcelCommand(int EventId) : IRequest;
|
||||
@@ -0,0 +1,12 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Events.FilesAPI.Features.RegistrationsExcel.Synchronize;
|
||||
|
||||
public sealed class SynchronizeRegistrationsExcelHandler(
|
||||
RegistrationsExcelFileGenerator generator) : IRequestHandler<SynchronizeRegistrationsExcelCommand>
|
||||
{
|
||||
public async Task Handle(SynchronizeRegistrationsExcelCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
await generator.GenerateAsync(request.EventId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Events.FilesAPI.Infrastructure.Files;
|
||||
|
||||
public sealed class GeneratedFileReference
|
||||
{
|
||||
public string PhysicalPath { get; init; } = string.Empty;
|
||||
public string FileName { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Events.FilesAPI.Features.Certificates;
|
||||
using Events.FilesAPI.Features.RegistrationsExcel;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Events.FilesAPI.Infrastructure.Messaging;
|
||||
|
||||
public static class MassTransitSetupExtensions
|
||||
{
|
||||
public static void SetupMassTransit(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<RabbitMqSettings>()
|
||||
.Bind(configuration.GetSection("RabbitMq"))
|
||||
.ValidateDataAnnotations()
|
||||
.Validate(
|
||||
settings => Uri.TryCreate(settings.Host, UriKind.Absolute, out var uri) &&
|
||||
uri.Scheme == "rabbitmq" &&
|
||||
!string.IsNullOrWhiteSpace(uri.Host),
|
||||
"RabbitMq:Host must be a valid absolute rabbitmq:// URI.")
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddMassTransit(x =>
|
||||
{
|
||||
x.AddConsumer<CertificateRegistrationEventsConsumer>();
|
||||
x.AddConsumer<RegistrationsExcelEventsConsumer>();
|
||||
|
||||
x.UsingRabbitMq((context, cfg) =>
|
||||
{
|
||||
var settings = context.GetRequiredService<IOptions<RabbitMqSettings>>().Value;
|
||||
|
||||
cfg.Host(new Uri(settings.Host), h =>
|
||||
{
|
||||
h.Username(settings.Username);
|
||||
h.Password(settings.Password);
|
||||
});
|
||||
|
||||
cfg.ReceiveEndpoint("events-filesapi-registration-changes", e =>
|
||||
{
|
||||
e.ConfigureConsumer<CertificateRegistrationEventsConsumer>(context);
|
||||
e.ConfigureConsumer<RegistrationsExcelEventsConsumer>(context);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Events.FilesAPI.Infrastructure.Messaging;
|
||||
|
||||
public class RabbitMqSettings
|
||||
{
|
||||
[Required]
|
||||
public string Host { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Events.FilesAPI.Infrastructure.Options;
|
||||
|
||||
public class GeneratedFilesOptions
|
||||
{
|
||||
[Required]
|
||||
public string OutputPath { get; set; } = string.Empty;
|
||||
}
|
||||
32
Events-WebApi/Events.FilesAPI/Program.cs
Normal file
32
Events-WebApi/Events.FilesAPI/Program.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Events.FilesAPI.Features.Certificates;
|
||||
using Events.FilesAPI.Features.RegistrationsExcel;
|
||||
using Events.FilesAPI.Infrastructure.Messaging;
|
||||
using Events.FilesAPI.Infrastructure.Options;
|
||||
using Events.WebAPI.Handlers.EF.Data.Postgres;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
builder.Services.AddDbContext<EventsContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("EventDB")));
|
||||
|
||||
builder.Services.AddOptions<GeneratedFilesOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Paths"))
|
||||
.ValidateDataAnnotations()
|
||||
.Validate(
|
||||
settings => !string.IsNullOrWhiteSpace(settings.OutputPath),
|
||||
"GeneratedFilesOptions:OutputPath must be configured.")
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
|
||||
|
||||
builder.Services.SetupMassTransit(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
14
Events-WebApi/Events.FilesAPI/Properties/launchSettings.json
Normal file
14
Events-WebApi/Events.FilesAPI/Properties/launchSettings.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7296",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Events-WebApi/Events.FilesAPI/appsettings.json
Normal file
20
Events-WebApi/Events.FilesAPI/appsettings.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"RabbitMq": {
|
||||
"Host": "rabbitmq://localhost",
|
||||
"Username": "guest",
|
||||
"Password": "guest"
|
||||
},
|
||||
"Paths": {
|
||||
"OutputPath": "./Certificates"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"EventDB": "Host=localhost;Port=5432;Database=events;Username=sport;Password=go and look in the secrets file;Persist Security Info=True;"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user