Skip to content

Commit

Permalink
Code completed; Pending testing and bug fixing
Browse files Browse the repository at this point in the history
  • Loading branch information
adrianwium committed Dec 5, 2024
1 parent 2c5815f commit 8fa4963
Show file tree
Hide file tree
Showing 10 changed files with 252 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,21 @@ public async Task<IActionResult> FinalizeVerificationManual([FromBody] MyOpportu

return StatusCode((int)HttpStatusCode.OK);
}

[SwaggerOperation(Summary = "Import completions for the specified organization from a CSV file (Admin or Organization Admin roles required)")]
[HttpPost("action/verify/csv")]
[ProducesResponseType((int)HttpStatusCode.OK)]
[Authorize(Roles = $"{Constants.Role_Admin}, {Constants.Role_OrganizationAdmin}")]
public async Task<IActionResult> PerformActionImportVerificationFromCSV([FromForm] MyOpportunityRequestVerifyImportCsv request)
{
_logger.LogInformation("Handling request {requestName}", nameof(PerformActionImportVerificationFromCSV));

await _myOpportunityService.PerformActionImportVerificationFromCSV(request, true);

_logger.LogInformation("Request {requestName} handled", nameof(PerformActionImportVerificationFromCSV));

return StatusCode((int)HttpStatusCode.OK);
}
#endregion Administrative Actions

#region Authenticated User Based Actions
Expand Down Expand Up @@ -309,6 +324,6 @@ public async Task<IActionResult> PerformActionSendForVerificationManualDelete([F
return StatusCode((int)HttpStatusCode.OK);
}
#endregion Authenticated User Based Actions
#endregion
#endregion Public Members
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,7 @@ public interface IMyOpportunityService
Dictionary<Guid, int>? ListAggregatedOpportunityByCompleted(bool includeExpired);

Task PerformActionInstantVerification(Guid linkId);

Task PerformActionImportVerificationFromCSV(MyOpportunityRequestVerifyImportCsv request, bool ensureOrganizationAuthorization);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;

namespace Yoma.Core.Domain.MyOpportunity.Models
{
public class MyOpportunityInfoCsvImport
{
public string? Email { get; set; }

public string? PhoneNumber { get; set; }

public string? FirstName { get; set; }

public string? Surname { get; set; }

public string? Gender { get; set; }

public string? Country { get; set; }

[Required]
public string OpporunityExternalId { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ public class MyOpportunityRequestVerify
internal bool OverridePending { get; set; }

[JsonIgnore]
internal bool InstantVerification { get; set; }
internal bool InstantOrImportedVerification { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Http;

namespace Yoma.Core.Domain.MyOpportunity.Models
{
public class MyOpportunityRequestVerifyImportCsv
{
public IFormFile File { get; set; }

public Guid OrganizationId { get; set; }

public string? Comment { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
using Yoma.Core.Domain.Opportunity.Interfaces.Lookups;
using Yoma.Core.Domain.Reward.Interfaces;
using Yoma.Core.Domain.SSI.Interfaces;
using CsvHelper.Configuration.Attributes;
using System.Globalization;
using System.Reflection;
using Yoma.Core.Domain.Lookups.Interfaces;

namespace Yoma.Core.Domain.MyOpportunity.Services
{
Expand All @@ -54,10 +58,13 @@ public class MyOpportunityService : IMyOpportunityService
private readonly ILinkService _linkService;
private readonly INotificationURLFactory _notificationURLFactory;
private readonly INotificationDeliveryService _notificationDeliveryService;
private readonly ICountryService _countryService;
private readonly IGenderService _genderService;
private readonly MyOpportunitySearchFilterValidator _myOpportunitySearchFilterValidator;
private readonly MyOpportunityRequestValidatorVerify _myOpportunityRequestValidatorVerify;
private readonly MyOpportunityRequestValidatorVerifyFinalize _myOpportunityRequestValidatorVerifyFinalize;
private readonly MyOpportunityRequestValidatorVerifyFinalizeBatch _myOpportunityRequestValidatorVerifyFinalizeBatch;
private readonly MyOpportunityRequestValidatorVerifyImportCsv _myOpportunityRequestValidatorVerifyImportCsv;
private readonly IRepositoryBatchedWithNavigation<Models.MyOpportunity> _myOpportunityRepository;
private readonly IRepository<MyOpportunityVerification> _myOpportunityVerificationRepository;
private readonly IExecutionStrategyService _executionStrategyService;
Expand Down Expand Up @@ -85,10 +92,13 @@ public MyOpportunityService(ILogger<MyOpportunityService> logger,
ILinkService linkService,
INotificationURLFactory notificationURLFactory,
INotificationDeliveryService notificationDeliveryService,
ICountryService countryService,
IGenderService genderService,
MyOpportunitySearchFilterValidator myOpportunitySearchFilterValidator,
MyOpportunityRequestValidatorVerify myOpportunityRequestValidatorVerify,
MyOpportunityRequestValidatorVerifyFinalize myOpportunityRequestValidatorVerifyFinalize,
MyOpportunityRequestValidatorVerifyFinalizeBatch myOpportunityRequestValidatorVerifyFinalizeBatch,
MyOpportunityRequestValidatorVerifyImportCsv myOpportunityRequestValidatorVerifyImportCsv,
IRepositoryBatchedWithNavigation<Models.MyOpportunity> myOpportunityRepository,
IRepository<MyOpportunityVerification> myOpportunityVerificationRepository,
IExecutionStrategyService executionStrategyService)
Expand All @@ -109,10 +119,13 @@ public MyOpportunityService(ILogger<MyOpportunityService> logger,
_linkService = linkService;
_notificationURLFactory = notificationURLFactory;
_notificationDeliveryService = notificationDeliveryService;
_countryService = countryService;
_genderService = genderService;
_myOpportunitySearchFilterValidator = myOpportunitySearchFilterValidator;
_myOpportunityRequestValidatorVerify = myOpportunityRequestValidatorVerify;
_myOpportunityRequestValidatorVerifyFinalize = myOpportunityRequestValidatorVerifyFinalize;
_myOpportunityRequestValidatorVerifyFinalizeBatch = myOpportunityRequestValidatorVerifyFinalizeBatch;
_myOpportunityRequestValidatorVerifyImportCsv = myOpportunityRequestValidatorVerifyImportCsv;
_myOpportunityRepository = myOpportunityRepository;
_myOpportunityVerificationRepository = myOpportunityVerificationRepository;
_executionStrategyService = executionStrategyService;
Expand Down Expand Up @@ -727,7 +740,7 @@ await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () =>

await _linkService.LogUsage(link.Id);

var request = new MyOpportunityRequestVerify { InstantVerification = true };
var request = new MyOpportunityRequestVerify { InstantOrImportedVerification = true };
await PerformActionSendForVerification(user, link.EntityId, request, null); //any verification method

await FinalizeVerification(user, opportunity, VerificationStatus.Completed, true, "Auto-verification");
Expand Down Expand Up @@ -932,9 +945,148 @@ public async Task FinalizeVerificationManual(MyOpportunityRequestVerifyFinalize

return queryGrouped.ToDictionary(o => o.OpportunityId, o => o.Count);
}

public async Task PerformActionImportVerificationFromCSV(MyOpportunityRequestVerifyImportCsv request, bool ensureOrganizationAuthorization)
{
ArgumentNullException.ThrowIfNull(request, nameof(request));

await _myOpportunityRequestValidatorVerifyImportCsv.ValidateAndThrowAsync(request);

var organization = _organizationService.GetById(request.OrganizationId, false, false, ensureOrganizationAuthorization);

request.Comment = request.Comment?.Trim();
if (string.IsNullOrEmpty(request.Comment)) request.Comment = "Auto-verification";

using var stream = request.File.OpenReadStream();
using var reader = new StreamReader(stream);

var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
Delimiter = ",",
HasHeaderRecord = true,
MissingFieldFound = args =>
{
if (args.Context?.Reader?.HeaderRecord == null)
throw new ValidationException("The file is missing a header row");

var fieldName = args.HeaderNames?[args.Index] ?? $"Field at index {args.Index}";

var modelType = typeof(MyOpportunityInfoCsvImport);

var property = modelType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.FirstOrDefault(p =>
string.Equals(
p.GetCustomAttributes(typeof(NameAttribute), true)
.Cast<NameAttribute>()
.FirstOrDefault()?.Names.FirstOrDefault() ?? p.Name,
fieldName,
StringComparison.OrdinalIgnoreCase));

if (property == null) return;

var isRequired = property.GetCustomAttributes(typeof(System.ComponentModel.DataAnnotations.RequiredAttribute), true).Length > 0;

if (isRequired)
{
var rowNumber = args.Context?.Parser?.Row.ToString() ?? "Unknown";
throw new ValidationException($"Missing required field '{fieldName}' in row '{rowNumber}'");
}
},
BadDataFound = args =>
{
var rowNumber = args.Context?.Parser?.Row.ToString() ?? "Unknown";
throw new ValidationException($"Bad data format in row '{rowNumber}': Raw field data: '{args.Field}'");
}
};

using var csv = new CsvHelper.CsvReader(reader, config);

if (!csv.Read() || csv.Context?.Reader?.HeaderRecord?.Length == 0)
throw new ValidationException("The file is missing a header row");

csv.ReadHeader();

await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () =>
{
using var scope = new TransactionScope(TransactionScopeOption.RequiresNew, TransactionScopeAsyncFlowOption.Enabled);

while (await csv.ReadAsync())
{
var rowNumber = csv.Context?.Parser?.Row ?? -1;
try
{
var record = csv.GetRecord<MyOpportunityInfoCsvImport>();

await ProcessImportVerification(request, record);
}
catch (Exception ex)
{
throw new ValidationException($"Error processing row '{(rowNumber == -1 ? "Unknown" : rowNumber)}': {ex.Message}");
}
}
scope.Complete();
});
}
#endregion

#region Private Members
private async Task ProcessImportVerification(MyOpportunityRequestVerifyImportCsv requestImport, MyOpportunityInfoCsvImport item)
{
item.Email = string.IsNullOrWhiteSpace(item.Email) ? null : item.Email.Trim();
item.PhoneNumber = string.IsNullOrWhiteSpace(item.PhoneNumber) ? null : item.PhoneNumber.Trim();
item.FirstName = string.IsNullOrWhiteSpace(item.FirstName) ? null : item.FirstName.Trim();
item.Surname = string.IsNullOrWhiteSpace(item.Surname) ? null : item.Surname.Trim();

Domain.Lookups.Models.Country? country = null;
if (!string.IsNullOrEmpty(item.Country)) country = _countryService.GetByCodeAplha2(item.Country);

Domain.Lookups.Models.Gender? gender = null;
if (!string.IsNullOrEmpty(item.Gender)) gender = _genderService.GetByName(item.Gender);

var username = item.Email ?? item.PhoneNumber;
if (string.IsNullOrEmpty(username))
throw new ValidationException("Email or phone number required");

if (string.IsNullOrWhiteSpace(item.OpporunityExternalId))
throw new ValidationException("Opportunity external id required");

var opportunity = _opportunityService.GetByExternalId(requestImport.OrganizationId, item.OpporunityExternalId, true, true);
if(opportunity.VerificationMethod != VerificationMethod.Automatic)
throw new ValidationException($"Verification import not supported for opporunity '{opportunity.Title}'. The verification method must be set to 'Automatic'");

var user = _userService.GetByUsernameOrNull(username, false, false);
//user is created if not existing, or updated if not linked to an identity provider
if (user == null || !user.ExternalId.HasValue)
{
var request = new UserRequest
{
Username = username,
Email = item.Email,
FirstName = item.FirstName,
Surname = item.Surname,
EmailConfirmed = item.Email == null ? null : false,
PhoneNumberConfirmed = item.PhoneNumber == null ? null : false,
CountryId = country?.Id,
GenderId = gender?.Id
};

user = await _userService.Upsert(request);
}

await _executionStrategyService.ExecuteInExecutionStrategyAsync(async () =>
{
using var scope = new TransactionScope(TransactionScopeOption.Required, TransactionScopeAsyncFlowOption.Enabled);

var requestVerify = new MyOpportunityRequestVerify { InstantOrImportedVerification = true };
await PerformActionSendForVerification(user, opportunity.Id, requestVerify, null); //any verification method

await FinalizeVerification(user, opportunity, VerificationStatus.Completed, true, requestImport.Comment);

scope.Complete();
});

}

private static List<(DateTime WeekEnding, int Count)> SummaryGroupByWeekItems(List<MyOpportunityInfo> items)
{
var results = items
Expand Down Expand Up @@ -1198,7 +1350,7 @@ private async Task PerformActionSendForVerification(User user, Guid opportunityI
myOpportunity.ZltoReward = opportunity.ZltoReward;
myOpportunity.YomaReward = opportunity.YomaReward;

if (request.InstantVerification || opportunity.VerificationMethod == VerificationMethod.Automatic) return; //with instant-verifications or automatic verification pending notifications are not sent
if (request.InstantOrImportedVerification) return; //with instant or imported verifications, pending notifications are not sent

//sent to youth
await SendNotification(myOpportunity, NotificationType.Opportunity_Verification_Pending);
Expand Down Expand Up @@ -1328,7 +1480,7 @@ private async Task PerformActionSendForVerificationProcessVerificationTypes(MyOp
Models.MyOpportunity myOpportunity,
List<BlobObject> itemsNewBlobs)
{
if (request.InstantVerification) return; //with instant-verifications bypass any verification type requirements
if (request.InstantOrImportedVerification) return; //with instant or imported verifications bypass any verification type requirements
if (opportunity.VerificationTypes == null) return;

foreach (var verificationType in opportunity.VerificationTypes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public MyOpportunityRequestValidatorVerify()
.WithMessage("3 or more coordinate points expected per coordinate set i.e. Point: X-coordinate (longitude -180 to +180), Y-coordinate (latitude -90 to +90), Z-elevation.")
.When(x => x.Geometry != null && x.Geometry.Type != Core.SpatialType.None);
//with instant-verifications start or end date not captured
RuleFor(x => x.DateStart).NotEmpty().When(x => !x.InstantVerification).WithMessage("{PropertyName} is required.");
RuleFor(x => x.DateStart).NotEmpty().When(x => !x.InstantOrImportedVerification).WithMessage("{PropertyName} is required.");
RuleFor(model => model.DateEnd)
.GreaterThanOrEqualTo(model => model.DateStart)
.When(model => model.DateEnd.HasValue)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using FluentValidation;
using Yoma.Core.Domain.Entity.Interfaces;
using Yoma.Core.Domain.MyOpportunity.Models;

namespace Yoma.Core.Domain.MyOpportunity.Validators
{
public class MyOpportunityRequestValidatorVerifyImportCsv : AbstractValidator<MyOpportunityRequestVerifyImportCsv>
{
#region Class Variables
private readonly IOrganizationService _organizationService;
#endregion

#region Constructor
public MyOpportunityRequestValidatorVerifyImportCsv(IOrganizationService organizationService)
{
_organizationService = organizationService;

RuleFor(x => x.File).Must(file => file != null && file.Length > 0).WithMessage("{PropertyName} is required.");
RuleFor(x => x.OrganizationId).NotEmpty().Must(OrganizationExists).WithMessage($"Specified organization does not exist.");
}
#endregion

#region Private Members
private bool OrganizationExists(Guid id)
{
if (id == Guid.Empty) return false;
return _organizationService.GetByIdOrNull(id, false, false, false) != null;
}
#endregion
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public interface IOpportunityService

Models.Opportunity? GetByTitleOrNull(string title, bool includeChildItems, bool includeComputed);

Models.Opportunity GetByExternalId(Guid organizationId, string externalId, bool includeChildItems, bool includeComputed);

Models.Opportunity? GetByExternalIdOrNull(Guid organizationId, string externalId, bool includeChildItems, bool includeComputed);

List<Models.Opportunity> Contains(string value, bool includeChildItems, bool includeComputed);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,6 @@ public OpportunityService(ILogger<OpportunityService> logger,
#region Public Members
public Models.Opportunity GetById(Guid id, bool includeChildItems, bool includeComputed, bool ensureOrganizationAuthorization)
{
if (id == Guid.Empty)
throw new ArgumentNullException(nameof(id));

var result = GetByIdOrNull(id, includeChildItems, includeComputed, ensureOrganizationAuthorization)
?? throw new EntityNotFoundException($"{nameof(Models.Opportunity)} with id '{id}' does not exist");

Expand Down Expand Up @@ -222,6 +219,14 @@ public Models.Opportunity GetById(Guid id, bool includeChildItems, bool includeC
return result;
}

public Models.Opportunity GetByExternalId(Guid organizationId, string externalId, bool includeChildItems, bool includeComputed)
{
var result = GetByExternalIdOrNull(organizationId, externalId, includeChildItems, includeComputed)
?? throw new EntityNotFoundException($"Opportunity with external id '{externalId}' does not exist for the specified organization with id {organizationId}");

return result;
}

public Models.Opportunity? GetByExternalIdOrNull(Guid organizationId, string externalId, bool includeChildItems, bool includeComputed)
{
if (organizationId == Guid.Empty)
Expand Down Expand Up @@ -1044,10 +1049,7 @@ public async Task ImportFromCSV(IFormFile file, Guid organizationId, bool ensure
if (file == null || file.Length == 0)
throw new ArgumentNullException(nameof(file));

var organization = _organizationService.GetById(organizationId, false, false, false);

if (ensureOrganizationAuthorization)
_organizationService.IsAdmin(organization.Id, true);
var organization = _organizationService.GetById(organizationId, false, false, ensureOrganizationAuthorization);

using var stream = file.OpenReadStream();
using var reader = new StreamReader(stream);
Expand Down

0 comments on commit 8fa4963

Please sign in to comment.