From 5d6c61f7b2f0fa33315a50b836e3c9642367e4eb Mon Sep 17 00:00:00 2001 From: Dave Walker Date: Sat, 17 Jan 2026 17:17:53 +0000 Subject: [PATCH] Add the port maintenance pages to the UI --- .../Controllers/ExportController.cs | 84 +++++-- .../Controllers/ImportController.cs | 18 +- .../Controllers/PortsController.cs | 6 +- .../Entities/AllExportWorkItem.cs | 8 + .../ApiClient/ExportClient.cs | 41 ++++ .../ApiClient/PortClient.cs | 20 +- .../Interfaces/IExportClient.cs | 6 + .../Interfaces/IPortClient.cs | 3 +- .../Controllers/DataExchangeControllerBase.cs | 2 + .../Controllers/ExportController.cs | 2 + .../Controllers/ImportController.cs | 2 + .../Controllers/LocationsController.cs | 2 +- .../Controllers/OperatorsController.cs | 2 +- .../Controllers/PortsController.cs | 227 ++++++++++++++++++ .../Controllers/VesselTypesController.cs | 2 +- .../Controllers/VesselsController.cs | 2 +- .../Entities/DataExchangeType.cs | 1 + .../Helpers/CountryListGenerator.cs | 2 +- .../Helpers/DataExchangeTypeExtensions.cs | 1 + .../Models/AddPortViewModel.cs | 6 + .../Models/EditPortViewModel.cs | 6 + .../Models/ImportViewModel.cs | 2 +- .../Models/PortListViewModel.cs | 30 +++ src/ShippingRecorder.Mvc/Models/PortModel.cs | 20 ++ src/ShippingRecorder.Mvc/Startup.cs | 1 + .../Views/Ports/Add.cshtml | 29 +++ .../Views/Ports/Edit.cshtml | 29 +++ .../Views/Ports/Index.cshtml | 110 +++++++++ .../Views/Ports/Port.cshtml | 45 ++++ .../Views/Shared/_Layout.cshtml | 1 + src/ShippingRecorder.Mvc/appsettings.json | 4 + .../Client/ExportClientTest.cs | 70 ++++++ .../Client/PortClientTest.cs | 23 ++ 33 files changed, 762 insertions(+), 45 deletions(-) create mode 100644 src/ShippingRecorder.Api/Entities/AllExportWorkItem.cs create mode 100644 src/ShippingRecorder.Client/ApiClient/ExportClient.cs create mode 100644 src/ShippingRecorder.Client/Interfaces/IExportClient.cs create mode 100644 src/ShippingRecorder.Mvc/Controllers/PortsController.cs create mode 100644 src/ShippingRecorder.Mvc/Models/AddPortViewModel.cs create mode 100644 src/ShippingRecorder.Mvc/Models/EditPortViewModel.cs create mode 100644 src/ShippingRecorder.Mvc/Models/PortListViewModel.cs create mode 100644 src/ShippingRecorder.Mvc/Models/PortModel.cs create mode 100644 src/ShippingRecorder.Mvc/Views/Ports/Add.cshtml create mode 100644 src/ShippingRecorder.Mvc/Views/Ports/Edit.cshtml create mode 100644 src/ShippingRecorder.Mvc/Views/Ports/Index.cshtml create mode 100644 src/ShippingRecorder.Mvc/Views/Ports/Port.cshtml create mode 100644 src/ShippingRecorder.Tests/Client/ExportClientTest.cs diff --git a/src/ShippingRecorder.Api/Controllers/ExportController.cs b/src/ShippingRecorder.Api/Controllers/ExportController.cs index 47ca90a..b69cbf1 100644 --- a/src/ShippingRecorder.Api/Controllers/ExportController.cs +++ b/src/ShippingRecorder.Api/Controllers/ExportController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using ShippingRecorder.Api.Interfaces; using ShippingRecorder.Api.Entities; +using ShippingRecorder.Entities.Jobs; namespace HealthTracker.Api.Controllers { @@ -14,35 +15,55 @@ public class ExportController : Controller private readonly IBackgroundQueue _countryQueue; private readonly IBackgroundQueue _locationQueue; private readonly IBackgroundQueue _operatorQueue; + private readonly IBackgroundQueue _portQueue; + private readonly IBackgroundQueue _sightingQueue; private readonly IBackgroundQueue _vesselQueue; private readonly IBackgroundQueue _vesselTypeQueue; private readonly IBackgroundQueue _voyageQueue; - private readonly IBackgroundQueue _sightingQueue; - private readonly IBackgroundQueue _portQueue; public ExportController( IBackgroundQueue countryQueue, IBackgroundQueue locationQueue, IBackgroundQueue operatorQueue, + IBackgroundQueue portQueue, + IBackgroundQueue sightingQueue, IBackgroundQueue vesselQueue, IBackgroundQueue vesselTypeQueue, - IBackgroundQueue voyageQueue, - IBackgroundQueue sightingQueue, - IBackgroundQueue portQueue) + IBackgroundQueue voyageQueue) { _countryQueue = countryQueue; _locationQueue = locationQueue; _operatorQueue = operatorQueue; + _portQueue = portQueue; + _sightingQueue = sightingQueue; _vesselQueue = vesselQueue; _vesselTypeQueue = vesselTypeQueue; _voyageQueue = voyageQueue; - _sightingQueue = sightingQueue; - _portQueue = portQueue; + } + + [HttpPost] + [Route("all")] + public IActionResult ExportAll([FromBody] AllExportWorkItem item) + { + // Get the file name without the extension or path + var fileName = Path.GetFileNameWithoutExtension(item.FileName); + + // Queue exports for each data type + _countryQueue.Enqueue(BuildWorkItem("Countries", fileName)); + _locationQueue.Enqueue(BuildWorkItem("Locations", fileName)); + _operatorQueue.Enqueue(BuildWorkItem("Operators", fileName)); + _portQueue.Enqueue(BuildWorkItem("Ports", fileName)); + _sightingQueue.Enqueue(BuildWorkItem("Sightings", fileName)); + _vesselQueue.Enqueue(BuildWorkItem("Vessels", fileName)); + _vesselTypeQueue.Enqueue(BuildWorkItem("Vessel-Types", fileName)); + _voyageQueue.Enqueue(BuildWorkItem("Voyages", fileName)); + + return Accepted(); } [HttpPost] [Route("countries")] - public IActionResult ExportCountris([FromBody] CountryExportWorkItem item) + public IActionResult ExportCountries([FromBody] CountryExportWorkItem item) { item.JobName = "Country Export"; _countryQueue.Enqueue(item); @@ -67,6 +88,24 @@ public IActionResult ExportOperators([FromBody] OperatorExportWorkItem item) return Accepted(); } + [HttpPost] + [Route("ports")] + public IActionResult ExportPorts([FromBody] PortExportWorkItem item) + { + item.JobName = "Port Export"; + _portQueue.Enqueue(item); + return Accepted(); + } + + [HttpPost] + [Route("sightings")] + public IActionResult ExportSightings([FromBody] SightingExportWorkItem item) + { + item.JobName = "Sighting Export"; + _sightingQueue.Enqueue(item); + return Accepted(); + } + [HttpPost] [Route("vessels")] public IActionResult ExportVessels([FromBody] VesselExportWorkItem item) @@ -85,15 +124,6 @@ public IActionResult ExportVesselTypes([FromBody] VesselTypeExportWorkItem item) return Accepted(); } - [HttpPost] - [Route("sightings")] - public IActionResult ExportSightings([FromBody] SightingExportWorkItem item) - { - item.JobName = "Sighting Export"; - _sightingQueue.Enqueue(item); - return Accepted(); - } - [HttpPost] [Route("voyages")] public IActionResult ExportVoyages([FromBody] VoyageExportWorkItem item) @@ -103,13 +133,17 @@ public IActionResult ExportVoyages([FromBody] VoyageExportWorkItem item) return Accepted(); } - [HttpPost] - [Route("ports")] - public IActionResult ExportPorts([FromBody] PortExportWorkItem item) - { - item.JobName = "Port Export"; - _portQueue.Enqueue(item); - return Accepted(); - } + /// + /// Helper method to build a work item for the specified entity type + /// + /// + /// + /// + private T BuildWorkItem(string entityName, string fileName) where T : ExportWorkItem, new() + => new() + { + JobName = $"{entityName} Export", + FileName = $"{fileName}-{entityName}.csv" + }; } } diff --git a/src/ShippingRecorder.Api/Controllers/ImportController.cs b/src/ShippingRecorder.Api/Controllers/ImportController.cs index 3a6911d..3c5dc33 100644 --- a/src/ShippingRecorder.Api/Controllers/ImportController.cs +++ b/src/ShippingRecorder.Api/Controllers/ImportController.cs @@ -76,6 +76,15 @@ public IActionResult ImportPorts([FromBody] PortImportWorkItem item) return Accepted(); } + [HttpPost] + [Route("sightings")] + public IActionResult ImportSightings([FromBody] SightingImportWorkItem item) + { + item.JobName = "Sighting Import"; + _sightingQueue.Enqueue(item); + return Accepted(); + } + [HttpPost] [Route("vessels")] public IActionResult ImportVessels([FromBody] VesselImportWorkItem item) @@ -94,15 +103,6 @@ public IActionResult ImportVesselTypes([FromBody] VesselTypeImportWorkItem item) return Accepted(); } - [HttpPost] - [Route("sightings")] - public IActionResult ImportSightings([FromBody] SightingImportWorkItem item) - { - item.JobName = "Sighting Import"; - _sightingQueue.Enqueue(item); - return Accepted(); - } - [HttpPost] [Route("voyages")] public IActionResult ImportVoyages([FromBody] VoyageImportWorkItem item) diff --git a/src/ShippingRecorder.Api/Controllers/PortsController.cs b/src/ShippingRecorder.Api/Controllers/PortsController.cs index 7455643..a6e91f1 100644 --- a/src/ShippingRecorder.Api/Controllers/PortsController.cs +++ b/src/ShippingRecorder.Api/Controllers/PortsController.cs @@ -4,6 +4,7 @@ using ShippingRecorder.Entities.Logging; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System.Linq.Expressions; namespace ShippingRecorder.Api.Controllers { @@ -23,7 +24,10 @@ public async Task>> GetPortsAsync(int countryId, int pag { LogMessage(Severity.Debug, $"Retrieving list of ports (page {pageNumber}, page size {pageSize})"); - List ports = await Factory.Ports.ListAsync(x => x.CountryId == countryId, pageNumber, pageSize).ToListAsync(); + // If the country's 0 or less, just return all ports. Otherwise, return only ports for the specified + // country ID + Expression> predicate = x => countryId > 0 ? x.CountryId == countryId : true; + List ports = await Factory.Ports.ListAsync(predicate, pageNumber, pageSize).ToListAsync(); LogMessage(Severity.Debug, $"Retrieved {ports.Count} port(s)"); diff --git a/src/ShippingRecorder.Api/Entities/AllExportWorkItem.cs b/src/ShippingRecorder.Api/Entities/AllExportWorkItem.cs new file mode 100644 index 0000000..7e34571 --- /dev/null +++ b/src/ShippingRecorder.Api/Entities/AllExportWorkItem.cs @@ -0,0 +1,8 @@ +using ShippingRecorder.Entities.Jobs; + +namespace ShippingRecorder.Api.Entities +{ + public class AllExportWorkItem : ExportWorkItem + { + } +} diff --git a/src/ShippingRecorder.Client/ApiClient/ExportClient.cs b/src/ShippingRecorder.Client/ApiClient/ExportClient.cs new file mode 100644 index 0000000..599e7d8 --- /dev/null +++ b/src/ShippingRecorder.Client/ApiClient/ExportClient.cs @@ -0,0 +1,41 @@ +using ShippingRecorder.Client.Interfaces; +using ShippingRecorder.Entities.Interfaces; +using Microsoft.Extensions.Logging; +using System.Threading.Tasks; +using ShippingRecorder.Entities.Db; +using System.Net.Http; +using System.Linq; +using System.Collections.Generic; +using System.IO; +using System; + +namespace ShippingRecorder.Client.ApiClient +{ + public class ExportClient : ShippingRecorderClientBase, IExportClient + { + private const string RouteKey = "Export"; + + public ExportClient( + IShippingRecorderHttpClient client, + IShippingRecorderApplicationSettings settings, + IAuthenticationTokenProvider tokenProvider, + ICacheWrapper cache, + ILogger logger) + : base(client, settings, tokenProvider, cache, logger) + { + } + + /// + /// Request an export of allexports to a named file in the export allexport + /// + /// + /// + /// + public async Task ExportAsync(string fileName) + { + dynamic data = new{ FileName = fileName }; + var json = Serialize(data); + await SendIndirectAsync(RouteKey, json, HttpMethod.Post); + } + } +} diff --git a/src/ShippingRecorder.Client/ApiClient/PortClient.cs b/src/ShippingRecorder.Client/ApiClient/PortClient.cs index aa0ba4d..d4c1d8f 100644 --- a/src/ShippingRecorder.Client/ApiClient/PortClient.cs +++ b/src/ShippingRecorder.Client/ApiClient/PortClient.cs @@ -26,6 +26,21 @@ public PortClient( { } + /// + /// Return a port given its ID + /// + /// + /// + public async Task GetAsync(long id) + { + // Request the specified port + string baseRoute = @$"{Settings.ApiRoutes.First(r => r.Name == RouteKey).Route}"; + var route = $"{baseRoute}/{id}"; + string json = await SendDirectAsync(route, null, HttpMethod.Get); + Port port = Deserialize(json); + return port; + } + /// /// Return a port given a UN/LOCODE /// @@ -40,7 +55,6 @@ public async Task GetAsync(string code) Port port = Deserialize(json); return port; } - /// /// Add a new port to the database @@ -110,11 +124,11 @@ public async Task DeleteAsync(long id) /// /// /// - public async Task> ListAsync(long countryId, int pageNumber, int pageSize) + public async Task> ListAsync(long? countryId, int pageNumber, int pageSize) { // Request a list of countries string baseRoute = @$"{Settings.ApiRoutes.First(r => r.Name == RouteKey).Route}"; - var route = $"{baseRoute}/{countryId}/{pageNumber}/{pageSize}"; + var route = $"{baseRoute}/{countryId ?? 0}/{pageNumber}/{pageSize}"; string json = await SendDirectAsync(route, null, HttpMethod.Get); // The returned JSON will be empty if there are no countries in the database diff --git a/src/ShippingRecorder.Client/Interfaces/IExportClient.cs b/src/ShippingRecorder.Client/Interfaces/IExportClient.cs new file mode 100644 index 0000000..3ce7b47 --- /dev/null +++ b/src/ShippingRecorder.Client/Interfaces/IExportClient.cs @@ -0,0 +1,6 @@ +namespace ShippingRecorder.Client.Interfaces +{ + public interface IExportClient : IExporter + { + } +} \ No newline at end of file diff --git a/src/ShippingRecorder.Client/Interfaces/IPortClient.cs b/src/ShippingRecorder.Client/Interfaces/IPortClient.cs index bd8ec5a..7f5b697 100644 --- a/src/ShippingRecorder.Client/Interfaces/IPortClient.cs +++ b/src/ShippingRecorder.Client/Interfaces/IPortClient.cs @@ -7,9 +7,10 @@ namespace ShippingRecorder.Client.Interfaces { public interface IPortClient : IPortsRetriever, IImporter, IExporter { + Task GetAsync(long id); Task AddAsync(long countryId, string code, string name); Task DeleteAsync(long id); - Task> ListAsync(long countryId, int pageNumber, int pageSize); + Task> ListAsync(long? countryId, int pageNumber, int pageSize); Task UpdateAsync(long id, long countryId, string code, string name); } } \ No newline at end of file diff --git a/src/ShippingRecorder.Mvc/Controllers/DataExchangeControllerBase.cs b/src/ShippingRecorder.Mvc/Controllers/DataExchangeControllerBase.cs index f8e7e26..17cb6c9 100644 --- a/src/ShippingRecorder.Mvc/Controllers/DataExchangeControllerBase.cs +++ b/src/ShippingRecorder.Mvc/Controllers/DataExchangeControllerBase.cs @@ -23,6 +23,7 @@ public abstract class DataExchangeControllerBase : ShippingRecorderControllerBas public DataExchangeControllerBase( ICountryClient countryClient, + IExportClient exportClient, ILocationClient locationClient, IOperatorClient operatorClient, IPortClient portClient, @@ -42,6 +43,7 @@ public DataExchangeControllerBase( _importers.Add(DataExchangeType.VesselTypes, vesselTypeClient); _importers.Add(DataExchangeType.Voyages, voyageClient); + _exporters.Add(DataExchangeType.All, exportClient); _exporters.Add(DataExchangeType.Countries, countryClient); _exporters.Add(DataExchangeType.Locations, locationClient); _exporters.Add(DataExchangeType.Operators, operatorClient); diff --git a/src/ShippingRecorder.Mvc/Controllers/ExportController.cs b/src/ShippingRecorder.Mvc/Controllers/ExportController.cs index cac041b..f88d463 100644 --- a/src/ShippingRecorder.Mvc/Controllers/ExportController.cs +++ b/src/ShippingRecorder.Mvc/Controllers/ExportController.cs @@ -12,6 +12,7 @@ public class ExportController : DataExchangeControllerBase { public ExportController( ICountryClient countryClient, + IExportClient exportClient, ILocationClient locationClient, IOperatorClient operatorClient, IPortClient portClient, @@ -22,6 +23,7 @@ public ExportController( IPartialViewToStringRenderer renderer, ILogger logger) : base( countryClient, + exportClient, locationClient, operatorClient, portClient, diff --git a/src/ShippingRecorder.Mvc/Controllers/ImportController.cs b/src/ShippingRecorder.Mvc/Controllers/ImportController.cs index 0cb9609..ea0eaba 100644 --- a/src/ShippingRecorder.Mvc/Controllers/ImportController.cs +++ b/src/ShippingRecorder.Mvc/Controllers/ImportController.cs @@ -15,6 +15,7 @@ public class ImportController : DataExchangeControllerBase public ImportController( ICountryClient countryClient, + IExportClient exportClient, ILocationClient locationClient, IOperatorClient operatorClient, IPortClient portClient, @@ -25,6 +26,7 @@ public ImportController( IPartialViewToStringRenderer renderer, ILogger logger) : base( countryClient, + exportClient, locationClient, operatorClient, portClient, diff --git a/src/ShippingRecorder.Mvc/Controllers/LocationsController.cs b/src/ShippingRecorder.Mvc/Controllers/LocationsController.cs index 7872bf3..780cbdc 100644 --- a/src/ShippingRecorder.Mvc/Controllers/LocationsController.cs +++ b/src/ShippingRecorder.Mvc/Controllers/LocationsController.cs @@ -72,7 +72,7 @@ public async Task Index(LocationListViewModel model) // and amend the page number, above, then apply it, below ModelState.Clear(); - // Retrieve the matching airport records + // Retrieve the matching location records var locations = await _client.ListAsync(page, _settings.SearchPageSize); model.SetLocations(locations, page, _settings.SearchPageSize); } diff --git a/src/ShippingRecorder.Mvc/Controllers/OperatorsController.cs b/src/ShippingRecorder.Mvc/Controllers/OperatorsController.cs index 3837d1d..8877f90 100644 --- a/src/ShippingRecorder.Mvc/Controllers/OperatorsController.cs +++ b/src/ShippingRecorder.Mvc/Controllers/OperatorsController.cs @@ -72,7 +72,7 @@ public async Task Index(OperatorListViewModel model) // and amend the page number, above, then apply it, below ModelState.Clear(); - // Retrieve the matching airport records + // Retrieve the matching operator records var operators = await _client.ListAsync(page, _settings.SearchPageSize); model.SetOperators(operators, page, _settings.SearchPageSize); } diff --git a/src/ShippingRecorder.Mvc/Controllers/PortsController.cs b/src/ShippingRecorder.Mvc/Controllers/PortsController.cs new file mode 100644 index 0000000..f8519b7 --- /dev/null +++ b/src/ShippingRecorder.Mvc/Controllers/PortsController.cs @@ -0,0 +1,227 @@ +using ShippingRecorder.Client.Interfaces; +using ShippingRecorder.Entities.Db; +using ShippingRecorder.Mvc.Entities; +using ShippingRecorder.Mvc.Interfaces; +using ShippingRecorder.Mvc.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ShippingRecorder.Entities.Interfaces; + +namespace ShippingRecorder.Mvc.Controllers +{ + [Authorize] + public class PortsController : ShippingRecorderControllerBase + { + private readonly IPortClient _portClient; + private readonly ICountryClient _countryClient; + private readonly ICountryListGenerator _countryListGenerator; + private readonly IShippingRecorderApplicationSettings _settings; + + public PortsController( + IPortClient portClient, + ICountryClient countryClient, + ICountryListGenerator countryListGenerator, + IShippingRecorderApplicationSettings settings, + IPartialViewToStringRenderer renderer, + ILogger logger) : base (renderer, logger) + { + _portClient = portClient; + _countryClient = countryClient; + _countryListGenerator = countryListGenerator; + _settings = settings; + } + + /// + /// Serve the ports list page + /// + /// + /// + [HttpGet] + public async Task Index(int countryId = 0) + { + // Get the list of current ports for the specified country, or all ports if the ID is 0 + List ports = await _portClient.ListAsync(countryId, 1, _settings.SearchPageSize) ?? []; + var plural = ports.Count == 1 ? "" : "s"; + _logger.LogDebug($"{ports.Count} port{plural} loaded via the service"); + + // Construct the view model and serve the page + var model = new PortListViewModel + { + CountryId = countryId, + Countries = await _countryListGenerator.Create() + }; + model.SetPorts(ports, 1, _settings.SearchPageSize); + return View(model); + } + + /// + /// Handle POST events for page navigation + /// + /// + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Index(PortListViewModel model) + { + if (ModelState.IsValid) + { + int page = model.PageNumber; + switch (model.Action) + { + case ControllerActions.ActionPreviousPage: + page -= 1; + break; + case ControllerActions.ActionNextPage: + page += 1; + break; + default: + break; + } + + // Need to clear model state here or the page number that was posted + // is returned and page navigation doesn't work correctly. So, capture + // and amend the page number, above, then apply it, below + ModelState.Clear(); + + // Retrieve the matching port records + var ports = await _portClient.ListAsync(model.CountryId, page, _settings.SearchPageSize); + model.SetPorts(ports, page, _settings.SearchPageSize); + } + else + { + LogModelState(); + } + + // Reload the countries list + model.Countries = await _countryListGenerator.Create(); + return View(model); + } + + /// + /// Serve the page to add a new port + /// + /// + [HttpGet] + public async Task Add() + { + return View(new AddPortViewModel + { + Countries = await _countryListGenerator.Create() + }); + } + + /// + /// Handle POST events to save new ports + /// + /// + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Add(AddPortViewModel model) + { + // If the model is nominally valid, make sure the UN/LOCODE matches the country + if (ModelState.IsValid) + { + var country = await _countryClient.GetAsync(model.CountryId); + var unlocode = model.Code.ToUpper(); + if (country.Code != unlocode[..2]) + { + ModelState.AddModelError("Code", "The beginning of the UN/LOCODE must match the country code"); + } + } + + if (ModelState.IsValid) + { + _logger.LogDebug($"Adding port: CountryId = {model.CountryId}, UN/LOCODE = {model.Code}, Name = {model.Name}"); + Port port = await _portClient.AddAsync(model.CountryId, model.Code, model.Name); + ModelState.Clear(); + model.Clear(); + model.Message = $"Port '{port.Code}' added successfully"; + } + else + { + LogModelState(); + } + + // Reload the countries list + model.Countries = await _countryListGenerator.Create(); + return View(model); + } + + /// + /// Serve the port editing page + /// + /// + /// + [HttpGet] + public async Task Edit(long id) + { + var port = await _portClient.GetAsync(id); + var model = new EditPortViewModel + { + Countries = await _countryListGenerator.Create(), + CountryId = port.CountryId, + Code = port.Code, + Name = port.Name + }; + return View(model); + } + + /// + /// Handle POST events to save updated ports + /// + /// + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(EditPortViewModel model) + { + IActionResult result; + + // If the model is nominally valid, make sure the UN/LOCODE matches the country + if (ModelState.IsValid) + { + var country = await _countryClient.GetAsync(model.CountryId); + var unlocode = model.Code.ToUpper(); + if (country.Code != unlocode[..2]) + { + ModelState.AddModelError("Code", "The beginning of the UN/LOCODE must match the country code"); + } + } + + if (ModelState.IsValid) + { + _logger.LogDebug($"Updating port: ID = {model.Id}, Country ID = {model.CountryId}, Code = {model.Code}, Name = {model.Name}"); + await _portClient.UpdateAsync(model.Id, model.CountryId, model.Code, model.Name); + result = RedirectToAction("Index", new { countryId = model.CountryId }); + } + else + { + LogModelState(); + result = View(model); + } + + return result; + } + + /// + /// Handle POST events to delete an existing voyage + /// + /// + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete([FromForm] long id) + { + // Retrieve the port + var port = await _portClient.GetAsync(id); + _logger.LogDebug($"Retrieved port {port}"); + + // Delete the port + _logger.LogDebug($"Deleting port: ID = {id}"); + await _portClient.DeleteAsync(id); + + return RedirectToAction("Index", new { countryId = port.CountryId }); + } + } +} diff --git a/src/ShippingRecorder.Mvc/Controllers/VesselTypesController.cs b/src/ShippingRecorder.Mvc/Controllers/VesselTypesController.cs index a268acd..7a7cc4e 100644 --- a/src/ShippingRecorder.Mvc/Controllers/VesselTypesController.cs +++ b/src/ShippingRecorder.Mvc/Controllers/VesselTypesController.cs @@ -72,7 +72,7 @@ public async Task Index(VesselTypeListViewModel model) // and amend the page number, above, then apply it, below ModelState.Clear(); - // Retrieve the matching airport records + // Retrieve the matching vessel type records var vesselTypes = await _client.ListAsync(page, _settings.SearchPageSize); model.SetVesselTypes(vesselTypes, page, _settings.SearchPageSize); } diff --git a/src/ShippingRecorder.Mvc/Controllers/VesselsController.cs b/src/ShippingRecorder.Mvc/Controllers/VesselsController.cs index 37f3dd2..758f5f5 100644 --- a/src/ShippingRecorder.Mvc/Controllers/VesselsController.cs +++ b/src/ShippingRecorder.Mvc/Controllers/VesselsController.cs @@ -85,7 +85,7 @@ public async Task Index(VesselListViewModel model) // and amend the page number, above, then apply it, below ModelState.Clear(); - // Retrieve the matching airport records + // Retrieve the matching vessel records var vessels = await _vesselClient.ListAsync(page, _settings.SearchPageSize); model.SetVessels(vessels, page, _settings.SearchPageSize); } diff --git a/src/ShippingRecorder.Mvc/Entities/DataExchangeType.cs b/src/ShippingRecorder.Mvc/Entities/DataExchangeType.cs index 7da92ea..503f401 100644 --- a/src/ShippingRecorder.Mvc/Entities/DataExchangeType.cs +++ b/src/ShippingRecorder.Mvc/Entities/DataExchangeType.cs @@ -3,6 +3,7 @@ namespace ShippingRecorder.Mvc.Enumerations public enum DataExchangeType { None, + All, Countries, Locations, Operators, diff --git a/src/ShippingRecorder.Mvc/Helpers/CountryListGenerator.cs b/src/ShippingRecorder.Mvc/Helpers/CountryListGenerator.cs index 7a5c96f..d00d1b4 100644 --- a/src/ShippingRecorder.Mvc/Helpers/CountryListGenerator.cs +++ b/src/ShippingRecorder.Mvc/Helpers/CountryListGenerator.cs @@ -42,7 +42,7 @@ public async Task> Create() { foreach (var country in countries) { - list.Add(new SelectListItem() { Text = country.Name, Value = country.Id.ToString() }); + list.Add(new SelectListItem() { Text = $"{country.Name} ({country.Code})", Value = country.Id.ToString() }); } } diff --git a/src/ShippingRecorder.Mvc/Helpers/DataExchangeTypeExtensions.cs b/src/ShippingRecorder.Mvc/Helpers/DataExchangeTypeExtensions.cs index a3be9fa..9e96ce1 100644 --- a/src/ShippingRecorder.Mvc/Helpers/DataExchangeTypeExtensions.cs +++ b/src/ShippingRecorder.Mvc/Helpers/DataExchangeTypeExtensions.cs @@ -14,6 +14,7 @@ public static string ToName(this DataExchangeType type) return type switch { DataExchangeType.None => "", + DataExchangeType.All => "All Data", DataExchangeType.Countries => "Countries", DataExchangeType.Locations => "Locations", DataExchangeType.Operators => "Operators", diff --git a/src/ShippingRecorder.Mvc/Models/AddPortViewModel.cs b/src/ShippingRecorder.Mvc/Models/AddPortViewModel.cs new file mode 100644 index 0000000..5783d13 --- /dev/null +++ b/src/ShippingRecorder.Mvc/Models/AddPortViewModel.cs @@ -0,0 +1,6 @@ +namespace ShippingRecorder.Mvc.Models +{ + public class AddPortViewModel : PortModel + { + } +} \ No newline at end of file diff --git a/src/ShippingRecorder.Mvc/Models/EditPortViewModel.cs b/src/ShippingRecorder.Mvc/Models/EditPortViewModel.cs new file mode 100644 index 0000000..840a29f --- /dev/null +++ b/src/ShippingRecorder.Mvc/Models/EditPortViewModel.cs @@ -0,0 +1,6 @@ +namespace ShippingRecorder.Mvc.Models +{ + public class EditPortViewModel : PortModel + { + } +} \ No newline at end of file diff --git a/src/ShippingRecorder.Mvc/Models/ImportViewModel.cs b/src/ShippingRecorder.Mvc/Models/ImportViewModel.cs index 830d899..e4e5add 100644 --- a/src/ShippingRecorder.Mvc/Models/ImportViewModel.cs +++ b/src/ShippingRecorder.Mvc/Models/ImportViewModel.cs @@ -18,7 +18,7 @@ public class ImportViewModel : DataExchangeViewModel public ImportViewModel() { - foreach (var importType in Enum.GetValues()) + foreach (var importType in Enum.GetValues().Where(x => x != DataExchangeType.All)) { var importTypeName = importType.ToName(); ImportTypes.Add(new SelectListItem() { Text = $"{importTypeName}", Value = importType.ToString() }); diff --git a/src/ShippingRecorder.Mvc/Models/PortListViewModel.cs b/src/ShippingRecorder.Mvc/Models/PortListViewModel.cs new file mode 100644 index 0000000..1f02fa1 --- /dev/null +++ b/src/ShippingRecorder.Mvc/Models/PortListViewModel.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; +using Microsoft.AspNetCore.Mvc.Rendering; +using ShippingRecorder.Entities.Db; + +namespace ShippingRecorder.Mvc.Models +{ + public class PortListViewModel : PaginatedViewModelBase + { + public IList Countries { get; set; } + public IEnumerable Ports { get; private set; } + + [DisplayName("Country")] + public long CountryId { get; set; } + + public string Message { get; set; } + + /// + /// Set the list of ports to be exposed by this view model + /// + /// + /// + /// + public void SetPorts(IEnumerable ports, int pageNumber, int pageSize) + { + Ports = ports ?? []; + PageNumber = pageNumber; + SetPreviousNextEnabled(Ports.Count(), pageNumber, pageSize); + } + } +} \ No newline at end of file diff --git a/src/ShippingRecorder.Mvc/Models/PortModel.cs b/src/ShippingRecorder.Mvc/Models/PortModel.cs new file mode 100644 index 0000000..1e862d4 --- /dev/null +++ b/src/ShippingRecorder.Mvc/Models/PortModel.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc.Rendering; +using ShippingRecorder.Entities.Db; + +namespace ShippingRecorder.Mvc.Models +{ + public class PortModel : Port + { + public IList Countries { get; set; } + public string Message { get; set; } + + public void Clear() + { + Id = 0; + CountryId = 0; + Code = ""; + Name = ""; + Message = ""; + } + } +} \ No newline at end of file diff --git a/src/ShippingRecorder.Mvc/Startup.cs b/src/ShippingRecorder.Mvc/Startup.cs index 078b1fb..c29fee4 100644 --- a/src/ShippingRecorder.Mvc/Startup.cs +++ b/src/ShippingRecorder.Mvc/Startup.cs @@ -86,6 +86,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(provider => ShippingRecorderHttpClient.Instance); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/ShippingRecorder.Mvc/Views/Ports/Add.cshtml b/src/ShippingRecorder.Mvc/Views/Ports/Add.cshtml new file mode 100644 index 0000000..f3925fe --- /dev/null +++ b/src/ShippingRecorder.Mvc/Views/Ports/Add.cshtml @@ -0,0 +1,29 @@ +@model ShippingRecorder.Mvc.Models.AddPortViewModel + +@{ + ViewData["Title"] = "Add Port"; +} + +
+ @using (Html.BeginForm()) + { +
+
+ Add Port +
+
+
+ + @if (!string.IsNullOrEmpty(Model.Message)) + { +
+
+ @Html.Raw(Model.Message) +
+
+
+ } + + @await Html.PartialAsync("Port", Model); + } +
diff --git a/src/ShippingRecorder.Mvc/Views/Ports/Edit.cshtml b/src/ShippingRecorder.Mvc/Views/Ports/Edit.cshtml new file mode 100644 index 0000000..b7c359f --- /dev/null +++ b/src/ShippingRecorder.Mvc/Views/Ports/Edit.cshtml @@ -0,0 +1,29 @@ +@model ShippingRecorder.Mvc.Models.EditPortViewModel + +@{ + ViewData["Title"] = "Edit Port"; +} + +
+ @using (Html.BeginForm()) + { +
+
+ Edit Port +
+
+
+ + @if (!string.IsNullOrEmpty(Model.Message)) + { +
+
+ @Html.Raw(Model.Message) +
+
+
+ } + + @await Html.PartialAsync("Port", Model); + } +
diff --git a/src/ShippingRecorder.Mvc/Views/Ports/Index.cshtml b/src/ShippingRecorder.Mvc/Views/Ports/Index.cshtml new file mode 100644 index 0000000..f029058 --- /dev/null +++ b/src/ShippingRecorder.Mvc/Views/Ports/Index.cshtml @@ -0,0 +1,110 @@ +@using ShippingRecorder.Mvc.Entities +@model ShippingRecorder.Mvc.Models.PortListViewModel + +@{ + ViewData["Title"] = "Ports"; + string previousDisabled = (Model.PreviousEnabled) ? "" : "disabled"; + string nextDisabled = (Model.NextEnabled) ? "" : "disabled"; +} + +

+ + Ports + +
+ + + Manage the locations at that can be associated with voyages + + +

+ +
+ @using (Html.BeginForm("Delete", "Ports", FormMethod.Post, new { name = "delete-form", id = "delete-form" })) + { + @Html.AntiForgeryToken() + @Html.Hidden("id", "0") + } + + @using (Html.BeginForm("Index", "Ports", FormMethod.Post)) + { + @Html.AntiForgeryToken() + @Html.HiddenFor(m => m.PageNumber) + +
+
+ @Html.LabelFor(m => m.CountryId) +
+ @Html.DropDownListFor(m => m.CountryId, Model.Countries, new { @class = "form-control" }) +
+ @Html.ValidationMessageFor(m => m.CountryId, "", new { @class = "text-danger" }) +
+
+ +
+ +
+
+
+
+ + @if (Model.Ports?.Count() > 0) + { + + + + + + + + @foreach (var port in Model.Ports) + { + + + + + + + + + } +
Country CodeCountryUN/LOCODEName +
@port.Country.Code@port.Country.Name@port.Code@port.Name + + + + + + + +
+
+

+ + + Add +

+ } + else if (Model.HasNoMatchingResults) + { +
+ @Html.Raw("No matching ports") + } + } +
+ + diff --git a/src/ShippingRecorder.Mvc/Views/Ports/Port.cshtml b/src/ShippingRecorder.Mvc/Views/Ports/Port.cshtml new file mode 100644 index 0000000..a34d195 --- /dev/null +++ b/src/ShippingRecorder.Mvc/Views/Ports/Port.cshtml @@ -0,0 +1,45 @@ +@model ShippingRecorder.Mvc.Models.PortModel + +@Html.AntiForgeryToken() +@Html.HiddenFor(m => m.Id) + +
+
+ @Html.LabelFor(m => m.CountryId) +
+ @Html.DropDownListFor(m => m.CountryId, Model.Countries, new { @class = "form-control" }) +
+ @Html.ValidationMessageFor(m => m.CountryId, "", new { @class = "text-danger" }) +
+
+
+ +
+
+ @Html.LabelFor(m => m.Code) +
+ @Html.EditorFor(m => m.Code, new { @class = "form-control" }) +
+ @Html.ValidationMessageFor(m => m.Code, "", new { @class = "text-danger" }) +
+
+
+ +
+
+ @Html.LabelFor(m => m.Name) +
+ @Html.EditorFor(m => m.Name, new { @class = "form-control" }) +
+ @Html.ValidationMessageFor(m => m.Name, "", new { @class = "text-danger" }) +
+
+
+ +
+
+
+ +
+
+
\ No newline at end of file diff --git a/src/ShippingRecorder.Mvc/Views/Shared/_Layout.cshtml b/src/ShippingRecorder.Mvc/Views/Shared/_Layout.cshtml index 67caf15..dd8a306 100644 --- a/src/ShippingRecorder.Mvc/Views/Shared/_Layout.cshtml +++ b/src/ShippingRecorder.Mvc/Views/Shared/_Layout.cshtml @@ -61,6 +61,7 @@ Countries Locations Operators + Ports Vessels Vessel Types Voyages diff --git a/src/ShippingRecorder.Mvc/appsettings.json b/src/ShippingRecorder.Mvc/appsettings.json index cee0e18..98ebd2f 100644 --- a/src/ShippingRecorder.Mvc/appsettings.json +++ b/src/ShippingRecorder.Mvc/appsettings.json @@ -146,6 +146,10 @@ { "Name": "ExportPort", "Route": "/export/ports" + }, + { + "Name": "Export", + "Route": "/export/all" } ], "UseCustomErrorPageInDevelopment": true, diff --git a/src/ShippingRecorder.Tests/Client/ExportClientTest.cs b/src/ShippingRecorder.Tests/Client/ExportClientTest.cs new file mode 100644 index 0000000..df0a8cb --- /dev/null +++ b/src/ShippingRecorder.Tests/Client/ExportClientTest.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using ShippingRecorder.Client.Interfaces; +using ShippingRecorder.Tests.Mocks; +using Microsoft.Extensions.Logging; +using Moq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ShippingRecorder.Entities.Config; +using ShippingRecorder.Client.ApiClient; +using System.Threading.Tasks; +using System.Net.Http; +using System.Collections.Generic; +using System.Linq; +using System.IO; + +namespace ShippingRecorder.Tests.Client +{ + [TestClass] + public class ExportClientTest + { + private readonly string ApiToken = "An API Token"; + private readonly MockShippingRecorderHttpClient _httpClient = new(); + private IExportClient _client; + private string _filePath; + + private readonly ShippingRecorderApplicationSettings _settings = new() + { + ApiUrl = "http://server/", + ApiRoutes = [ + new() { Name = "Export", Route = "/export/all" } + ] + }; + + [TestInitialize] + public void Initialise() + { + var provider = new Mock(); + provider.Setup(x => x.GetToken()).Returns(ApiToken); + var logger = new Mock>(); + var cache = new Mock(); + _client = new ExportClient(_httpClient, _settings, provider.Object, cache.Object, logger.Object); + } + + [TestCleanup] + public void CleanUp() + { + if (!string.IsNullOrEmpty(_filePath) && File.Exists(_filePath)) + { + File.Delete(_filePath); + } + } + + [TestMethod] + public async Task ExportTest() + { + _filePath = Path.ChangeExtension(Path.GetTempFileName(), "csv"); + _httpClient.AddResponse(""); + + var json = JsonSerializer.Serialize(new { FileName = _filePath }); + var expectedRoute = _settings.ApiRoutes.First(x => x.Name == "Export").Route; + + await _client.ExportAsync(_filePath); + + Assert.AreEqual($"Bearer {ApiToken}", _httpClient.DefaultRequestHeaders.Authorization.ToString()); + Assert.AreEqual($"{_settings.ApiUrl}", _httpClient.BaseAddress.ToString()); + Assert.AreEqual(HttpMethod.Post, _httpClient.Requests[0].Method); + Assert.AreEqual(expectedRoute, _httpClient.Requests[0].Uri); + Assert.AreEqual(json, await _httpClient.Requests[0].Content.ReadAsStringAsync()); + } + } +} diff --git a/src/ShippingRecorder.Tests/Client/PortClientTest.cs b/src/ShippingRecorder.Tests/Client/PortClientTest.cs index 7071010..d64e802 100644 --- a/src/ShippingRecorder.Tests/Client/PortClientTest.cs +++ b/src/ShippingRecorder.Tests/Client/PortClientTest.cs @@ -140,6 +140,29 @@ public async Task GetTest() var json = JsonSerializer.Serialize(port); _httpClient.AddResponse(json); + var retrieved = await _client.GetAsync(port.Id); + var expectedRoute = $"{_settings.ApiRoutes.First(x => x.Name == "Port").Route}/{port.Id}"; + + Assert.AreEqual($"Bearer {ApiToken}", _httpClient.DefaultRequestHeaders.Authorization.ToString()); + Assert.AreEqual($"{_settings.ApiUrl}", _httpClient.BaseAddress.ToString()); + Assert.AreEqual(HttpMethod.Get, _httpClient.Requests[0].Method); + Assert.AreEqual(expectedRoute, _httpClient.Requests[0].Uri); + + Assert.IsNull(_httpClient.Requests[0].Content); + Assert.IsNotNull(retrieved); + Assert.AreEqual(port.Id, retrieved.Id); + Assert.AreEqual(port.CountryId, retrieved.CountryId); + Assert.AreEqual(port.Code, retrieved.Code); + Assert.AreEqual(port.Name, retrieved.Name); + } + + [TestMethod] + public async Task GetByCodeTest() + { + var port = DataGenerator.CreatePort(); + var json = JsonSerializer.Serialize(port); + _httpClient.AddResponse(json); + var retrieved = await _client.GetAsync(port.Code); var expectedRoute = $"{_settings.ApiRoutes.First(x => x.Name == "Port").Route}/unlocode/{port.Code}";