Merge pull request #19

Excel export
This commit is contained in:
prtsie
2024-09-29 21:02:29 +03:00
committed by GitHub
16 changed files with 411 additions and 10 deletions

View File

@@ -61,4 +61,6 @@ public class ApplicantModel
/// <inheritdoc cref="Applicant.IsNonResident" /> /// <inheritdoc cref="Applicant.IsNonResident" />
[Required] [Required]
public bool IsNonResident { get; set; } public bool IsNonResident { get; set; }
public override string ToString() => Name.ToString();
} }

View File

@@ -16,4 +16,6 @@ public class NameModel
[MaxLength(ConfigurationConstraints.NameLength)] [MaxLength(ConfigurationConstraints.NameLength)]
public string? Patronymic { get; set; } public string? Patronymic { get; set; }
public override string ToString() => $"{FirstName} {Surname} {Patronymic}".TrimEnd();
} }

View File

@@ -23,5 +23,9 @@ public interface IVisaApplicationRequestsHandler
/// Sets application status to closed /// Sets application status to closed
Task HandleCloseRequestAsync(Guid applicationId, CancellationToken cancellationToken); Task HandleCloseRequestAsync(Guid applicationId, CancellationToken cancellationToken);
/// Sets application status to approved or rejected
Task SetApplicationStatusFromAuthorityAsync(Guid applicationId, AuthorityRequestStatuses status, CancellationToken cancellationToken); Task SetApplicationStatusFromAuthorityAsync(Guid applicationId, AuthorityRequestStatuses status, CancellationToken cancellationToken);
/// Returns stream with file with formatted application data to download
Task<Stream> ApplicationToStreamAsync(Guid applicationId, CancellationToken cancellationToken);
} }

View File

@@ -17,7 +17,8 @@ public class VisaApplicationRequestsHandler(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
IMapper mapper, IMapper mapper,
IDateTimeProvider dateTimeProvider, IDateTimeProvider dateTimeProvider,
IUserIdProvider userIdProvider) : IVisaApplicationRequestsHandler IUserIdProvider userIdProvider,
IEntityWriter entityWriter) : IVisaApplicationRequestsHandler
{ {
async Task<List<VisaApplicationPreview>> IVisaApplicationRequestsHandler.GetPendingAsync(CancellationToken cancellationToken) async Task<List<VisaApplicationPreview>> IVisaApplicationRequestsHandler.GetPendingAsync(CancellationToken cancellationToken)
{ {
@@ -34,7 +35,8 @@ public class VisaApplicationRequestsHandler(
return mapper.Map<List<VisaApplicationPreview>>(visaApplications); return mapper.Map<List<VisaApplicationPreview>>(visaApplications);
} }
async Task<VisaApplicationModel> IVisaApplicationRequestsHandler.GetApplicationForApplicantAsync(Guid id, CancellationToken cancellationToken) /// <summary> <inheritdoc cref="IVisaApplicationRequestsHandler.GetApplicationForApplicantAsync"/> </summary>
public async Task<VisaApplicationModel> GetApplicationForApplicantAsync(Guid id, CancellationToken cancellationToken)
{ {
var applicant = await applicants.FindByUserIdAsync(userIdProvider.GetUserId(), cancellationToken); var applicant = await applicants.FindByUserIdAsync(userIdProvider.GetUserId(), cancellationToken);
var application = await applications.GetByApplicantAndApplicationIdAsync(applicant.Id, id, cancellationToken); var application = await applications.GetByApplicantAndApplicationIdAsync(applicant.Id, id, cancellationToken);
@@ -104,4 +106,10 @@ public class VisaApplicationRequestsHandler(
await unitOfWork.SaveAsync(cancellationToken); await unitOfWork.SaveAsync(cancellationToken);
} }
async Task<Stream> IVisaApplicationRequestsHandler.ApplicationToStreamAsync(Guid applicationId, CancellationToken cancellationToken)
{
var application = await GetApplicationForApplicantAsync(applicationId, cancellationToken);
return await entityWriter.WriteEntityToStream(application, cancellationToken);
}
} }

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text;
using ApplicationLayer.Services.Applicants.Models; using ApplicationLayer.Services.Applicants.Models;
using Domains.VisaApplicationDomain; using Domains.VisaApplicationDomain;
@@ -33,8 +34,7 @@ public class VisaApplicationModel
/// <inheritdoc cref="VisaApplication.PermissionToDestCountry" /> /// <inheritdoc cref="VisaApplication.PermissionToDestCountry" />
public PermissionToDestCountryModel? PermissionToDestCountry { get; set; } public PermissionToDestCountryModel? PermissionToDestCountry { get; set; }
[Required] [Required] public List<PastVisitModel> PastVisits { get; set; } = null!;
public List<PastVisitModel> PastVisits { get; set; } = null!;
/// <inheritdoc cref="VisaApplication.VisaCategory" /> /// <inheritdoc cref="VisaApplication.VisaCategory" />
[Required] [Required]
@@ -55,4 +55,35 @@ public class VisaApplicationModel
/// <inheritdoc cref="VisaApplication.ValidDaysRequested" /> /// <inheritdoc cref="VisaApplication.ValidDaysRequested" />
[Required] [Required]
public int ValidDaysRequested { get; set; } public int ValidDaysRequested { get; set; }
public string ForGroupToString() => ForGroup ? "For group" : "Individual";
public string PastVisasToString()
{
var stringBuilder = new StringBuilder();
foreach (var visa in PastVisas)
{
stringBuilder.AppendLine($"{visa.Name} issued at {visa.IssueDate.ToShortDateString()} and valid for {visa.ExpirationDate.ToShortDateString()}");
}
return stringBuilder.ToString();
}
public string PastVisitsToString()
{
var stringBuilder = new StringBuilder();
foreach (var visit in PastVisits)
{
stringBuilder.AppendLine($"Visit to {visit.DestinationCountry} started at {visit.StartDate.ToShortDateString()} and ends {visit.EndDate.ToShortDateString()}");
}
return stringBuilder.ToString();
}
public string PermissionToDestCountryToString()
{
return VisaCategory is VisaCategory.Transit
? $"Issued by{PermissionToDestCountry!.Issuer}, expires at {PermissionToDestCountry.ExpirationDate.ToShortDateString()}"
: "Non-transit";
}
} }

View File

@@ -0,0 +1,7 @@
namespace ApplicationLayer.Services.VisaApplications.NeededServices
{
public interface IEntityWriter
{
Task<Stream> WriteEntityToStream(object entity, CancellationToken cancellationToken);
}
}

View File

@@ -26,7 +26,7 @@
<tr> <tr>
<td colspan="3"> <td colspan="3">
Country and city of birth:<br/> Country and city of birth:<br/>
<em>@application.Applicant.Passport.Number</em> <em>@application.Applicant.CountryOfBirth, @application.Applicant.CityOfBirth</em>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -34,7 +34,7 @@
Citizenship:<br/> Citizenship:<br/>
<em>@application.Applicant.Citizenship</em> <em>@application.Applicant.Citizenship</em>
</td> </td>
<td > <td>
Citizenship by birth:<br/> Citizenship by birth:<br/>
<em>@application.Applicant.CitizenshipByBirth</em> <em>@application.Applicant.CitizenshipByBirth</em>
</td> </td>

View File

@@ -40,12 +40,17 @@
<td>@application.Status.GetDisplayName()</td> <td>@application.Status.GetDisplayName()</td>
<td> <td>
<NavLink href="@($"/applications/{application.Id}")"> <NavLink href="@($"/applications/{application.Id}")">
<button class="btn-outline-primary">See</button> <button class="btn-primary">See</button>
</NavLink> </NavLink>
@if (currentRole == Constants.ApplicantRole && application.Status is ApplicationStatus.Pending) @if (currentRole == Constants.ApplicantRole)
{ {
<span> | </span> <span> | </span>
<input type="button" class="border-danger" @onclick="() => CloseApplication(application)" value="Close"/> <input type="button" class="btn-outline-primary" @onclick="() => DownloadApplication(application)" value="Download"/>
if (application.Status is ApplicationStatus.Pending)
{
<span> | </span>
<input type="button" class="border-danger" @onclick="() => CloseApplication(application)" value="Close"/>
}
} }
</td> </td>
</tr> </tr>
@@ -53,12 +58,28 @@
</tbody> </tbody>
</table > </table >
<script>
window.downloadFileFromStream = async (contentStreamReference) => {
const arrayBuffer = await contentStreamReference.arrayBuffer();
const blob = new Blob([arrayBuffer]);
const url = URL.createObjectURL(blob);
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = 'Application.xlsx';
anchorElement.click();
anchorElement.remove();
URL.revokeObjectURL(url);
}
</script>
@code { @code {
private string currentRole = null!; private string currentRole = null!;
private List<VisaApplicationPreview> applications = []; private List<VisaApplicationPreview> applications = [];
[Inject] private IUserDataProvider UserDataProvider { get; set; } = null!; [Inject] private IUserDataProvider UserDataProvider { get; set; } = null!;
[Inject] private IJSRuntime JavaScriptInterop { get; set; } = null!;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try try
@@ -69,6 +90,7 @@
{ {
ErrorHandler.Handle(e); ErrorHandler.Handle(e);
} }
await Fetch(); await Fetch();
} }
@@ -103,4 +125,19 @@
} }
} }
private async Task DownloadApplication(VisaApplicationPreview application)
{
try
{
var response = await Client.DownloadApplicationForApplicantAsync(application.Id);
using var streamRef = new DotNetStreamReference(stream: response.Stream);
await JavaScriptInterop.InvokeVoidAsync("downloadFileFromStream", streamRef);
}
catch (Exception e)
{
ErrorHandler.Handle(e);
}
}
} }

View File

@@ -8,6 +8,7 @@ using Infrastructure.Database.Applicants.Repositories;
using Infrastructure.Database.Generic; using Infrastructure.Database.Generic;
using Infrastructure.Database.Users.Repositories; using Infrastructure.Database.Users.Repositories;
using Infrastructure.Database.VisaApplications.Repositories; using Infrastructure.Database.VisaApplications.Repositories;
using Infrastructure.EntityToExcelTemplateWriter;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -37,6 +38,7 @@ public static class DependencyInjection
services.AddScoped<IUsersRepository, UsersRepository>(); services.AddScoped<IUsersRepository, UsersRepository>();
services.AddSingleton<IDateTimeProvider, DateTimeProvider>(); services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
services.AddSingleton<IEntityWriter, ExcelWriter>();
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddScoped<IUserIdProvider, UserIdProvider>(); services.AddScoped<IUserIdProvider, UserIdProvider>();

View File

@@ -0,0 +1,131 @@
using System.Reflection;
using System.Text;
using ApplicationLayer.Services.VisaApplications.NeededServices;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
using Microsoft.Extensions.Primitives;
namespace Infrastructure.EntityToExcelTemplateWriter
{
/// <summary>
/// Writes object to excel using template.xlsx file and reflections
/// </summary>
public class ExcelWriter : IEntityWriter
{
private const char InsertionSymbol = '$';
private readonly char[] endChars = [',', ';'];
/// <summary>
/// Write object to stream in Excel table format
/// </summary>
/// <param name="entity"> object to write </param>
/// <param name="cancellationToken"> cancellation token </param>
/// <returns> Stream with template.xlsx file with replaced entries like '$EntityPropName.AnotherProp' </returns>
/// <exception cref="NullReferenceException"> thrown when template file is incorrect </exception>
/// <exception cref="InvalidOperationException"> thrown if any property path in template is incorrect</exception>
public async Task<Stream> WriteEntityToStream(object entity, CancellationToken cancellationToken)
{
var outStream = new MemoryStream();
await using (var stream = File.Open("template.xlsx", FileMode.Open, FileAccess.Read))
{
await stream.CopyToAsync(outStream, cancellationToken);
}
using var spreadsheetDocument = SpreadsheetDocument.Open(outStream, true);
var workbookPart = spreadsheetDocument.WorkbookPart
?? throw new NullReferenceException("There is no workbook part in document");
var shareStringTable = workbookPart.SharedStringTablePart?.SharedStringTable ??
throw new NullReferenceException("There is no data in document");
var shareStringTableItems = shareStringTable.Elements<SharedStringItem>().ToArray();
foreach (var item in shareStringTableItems)
{
if (string.IsNullOrEmpty(item.InnerText))
{
continue;
}
var entries = item.InnerText.Split();
for (var i = 0; i < entries.Length; i++)
{
var entry = entries[i];
if (entry.FirstOrDefault() is not InsertionSymbol || entry.Length <= 1)
{
continue;
}
entry = entry[1..];
var trimmedCount = entry.Length - entry.TrimEnd(endChars).Length;
var trimmed = entry[^trimmedCount..];
entry = entry.TrimEnd(endChars);
var memberPath = entry.Split('.');
var value = GetValueFor(entity, memberPath.First());
var stringToInsert = "None";
foreach (var memberName in memberPath.Skip(1))
{
if (value is null)
{
break;
}
value = GetValueFor(value, memberName);
}
if (value is not null)
{
switch (value)
{
case DateTime date:
stringToInsert = date.ToShortDateString();
break;
case Enum val:
var enumString = val.ToString();
var stringBuilder = new StringBuilder();
for (var charIndex = 0; charIndex < enumString.Length - 1; charIndex++)
{
stringBuilder.Append(enumString[charIndex]);
if (char.IsUpper(enumString[charIndex + 1]))
{
stringBuilder.Append(' ');
}
}
stringBuilder.Append(enumString.Last());
stringToInsert = stringBuilder.ToString();
break;
default:
stringToInsert = value.ToString();
break;
}
}
entries[i] = stringToInsert! + trimmed;
}
item.Text!.Text = string.Join(' ', entries);
}
spreadsheetDocument.Save();
return outStream;
}
private static object? GetValueFor(object entity, string member)
{
var memberInfo = entity.GetType()
.GetMembers()
.FirstOrDefault(p => p.Name == member)
?? throw new InvalidOperationException(
$"Invalid member path in document. Not found: {member}");
return memberInfo switch
{
PropertyInfo propertyInfo => propertyInfo.GetValue(entity),
MethodInfo methodInfo => methodInfo.Invoke(entity, []),
_ => throw new InvalidOperationException("Only properties and methods allowed.")
};
}
}
}

View File

@@ -11,6 +11,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="DocumentFormat.OpenXml" Version="3.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0-preview.7.24405.3" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0-preview.7.24405.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.0-preview.7.24405.3" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.0-preview.7.24405.3" />

View File

@@ -119,4 +119,20 @@ public class VisaApplicationController(
await visaApplicationRequestsHandler.SetApplicationStatusFromAuthorityAsync(applicationId, status, cancellationToken); await visaApplicationRequestsHandler.SetApplicationStatusFromAuthorityAsync(applicationId, status, cancellationToken);
return Ok(); return Ok();
} }
/// <summary> Returns application </summary>
/// <remarks> Accessible only for applicant </remarks>
[HttpGet("/forApplicant/{applicationId:guid}/download")]
[Produces("application/octet-stream")]
[ProducesResponseType<object>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Authorize(policy: PolicyConstants.ApplicantPolicy)]
public async Task<IActionResult> DownloadApplicationForApplicant(Guid applicationId, CancellationToken cancellationToken)
{
var result = await visaApplicationRequestsHandler.ApplicationToStreamAsync(applicationId, cancellationToken);
result.Position = 0;
return File(result, "application/octet-stream", "Application.xlsx");
}
} }

View File

@@ -61,6 +61,8 @@ public class GlobalExceptionsFilter : IAsyncExceptionFilter
problemDetails.Status = StatusCodes.Status500InternalServerError; problemDetails.Status = StatusCodes.Status500InternalServerError;
problemDetails.Title = "An unhandled error occured"; problemDetails.Title = "An unhandled error occured";
problemDetails.Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.6.1"; problemDetails.Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.6.1";
Console.WriteLine($"Error!!!: {exception.Message}");
break; break;
} }

Binary file not shown.

View File

@@ -1722,6 +1722,129 @@ namespace VisaApiClient
} }
} }
/// <summary>
/// Returns application
/// </summary>
/// <remarks>
/// Accessible only for applicant
/// </remarks>
/// <returns>Success</returns>
/// <exception cref="ApiException">A server side error occurred.</exception>
public virtual System.Threading.Tasks.Task<FileResponse> DownloadApplicationForApplicantAsync(System.Guid applicationId)
{
return DownloadApplicationForApplicantAsync(applicationId, System.Threading.CancellationToken.None);
}
/// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
/// <summary>
/// Returns application
/// </summary>
/// <remarks>
/// Accessible only for applicant
/// </remarks>
/// <returns>Success</returns>
/// <exception cref="ApiException">A server side error occurred.</exception>
public virtual async System.Threading.Tasks.Task<FileResponse> DownloadApplicationForApplicantAsync(System.Guid applicationId, System.Threading.CancellationToken cancellationToken)
{
if (applicationId == null)
throw new System.ArgumentNullException("applicationId");
var client_ = _httpClient;
var disposeClient_ = false;
try
{
using (var request_ = await CreateHttpRequestMessageAsync(cancellationToken).ConfigureAwait(false))
{
request_.Method = new System.Net.Http.HttpMethod("GET");
request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/octet-stream"));
var urlBuilder_ = new System.Text.StringBuilder();
if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl);
// Operation Path: "forApplicant/{applicationId}/download"
urlBuilder_.Append("forApplicant/");
urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(applicationId, System.Globalization.CultureInfo.InvariantCulture)));
urlBuilder_.Append("/download");
await PrepareRequestAsync(client_, request_, urlBuilder_, cancellationToken).ConfigureAwait(false);
var url_ = urlBuilder_.ToString();
request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute);
await PrepareRequestAsync(client_, request_, url_, cancellationToken).ConfigureAwait(false);
var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
var disposeResponse_ = true;
try
{
var headers_ = new System.Collections.Generic.Dictionary<string, System.Collections.Generic.IEnumerable<string>>();
foreach (var item_ in response_.Headers)
headers_[item_.Key] = item_.Value;
if (response_.Content != null && response_.Content.Headers != null)
{
foreach (var item_ in response_.Content.Headers)
headers_[item_.Key] = item_.Value;
}
await ProcessResponseAsync(client_, response_, cancellationToken).ConfigureAwait(false);
var status_ = (int)response_.StatusCode;
if (status_ == 200 || status_ == 206)
{
var responseStream_ = response_.Content == null ? System.IO.Stream.Null : await response_.Content.ReadAsStreamAsync().ConfigureAwait(false);
var fileResponse_ = new FileResponse(status_, headers_, responseStream_, null, response_);
disposeClient_ = false; disposeResponse_ = false; // response and client are disposed by FileResponse
return fileResponse_;
}
else
if (status_ == 401)
{
var objectResponse_ = await ReadObjectResponseAsync<ProblemDetails>(response_, headers_, cancellationToken).ConfigureAwait(false);
if (objectResponse_.Object == null)
{
throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
}
throw new ApiException<ProblemDetails>("Unauthorized", status_, objectResponse_.Text, headers_, objectResponse_.Object, null);
}
else
if (status_ == 403)
{
var objectResponse_ = await ReadObjectResponseAsync<ProblemDetails>(response_, headers_, cancellationToken).ConfigureAwait(false);
if (objectResponse_.Object == null)
{
throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
}
throw new ApiException<ProblemDetails>("Forbidden", status_, objectResponse_.Text, headers_, objectResponse_.Object, null);
}
else
if (status_ == 404)
{
var objectResponse_ = await ReadObjectResponseAsync<ProblemDetails>(response_, headers_, cancellationToken).ConfigureAwait(false);
if (objectResponse_.Object == null)
{
throw new ApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
}
throw new ApiException<ProblemDetails>("Not Found", status_, objectResponse_.Text, headers_, objectResponse_.Object, null);
}
else
{
var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
throw new ApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
}
}
finally
{
if (disposeResponse_)
response_.Dispose();
}
}
}
finally
{
if (disposeClient_)
client_.Dispose();
}
}
protected struct ObjectResponseResult<T> protected struct ObjectResponseResult<T>
{ {
public ObjectResponseResult(T responseObject, string responseText) public ObjectResponseResult(T responseObject, string responseText)
@@ -2443,6 +2566,41 @@ namespace VisaApiClient
} }
[System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")]
public partial class FileResponse : System.IDisposable
{
private System.IDisposable? _client;
private System.IDisposable? _response;
public int StatusCode { get; private set; }
public System.Collections.Generic.IReadOnlyDictionary<string, System.Collections.Generic.IEnumerable<string>> Headers { get; private set; }
public System.IO.Stream Stream { get; private set; }
public bool IsPartial
{
get { return StatusCode == 206; }
}
public FileResponse(int statusCode, System.Collections.Generic.IReadOnlyDictionary<string, System.Collections.Generic.IEnumerable<string>> headers, System.IO.Stream stream, System.IDisposable? client, System.IDisposable? response)
{
StatusCode = statusCode;
Headers = headers;
Stream = stream;
_client = client;
_response = response;
}
public void Dispose()
{
Stream.Dispose();
if (_response != null)
_response.Dispose();
if (_client != null)
_client.Dispose();
}
}
[System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")] [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")]

File diff suppressed because one or more lines are too long