Skip to content

Commit

Permalink
Merge pull request #410 from bcgov/1.2
Browse files Browse the repository at this point in the history
Version 1.2
  • Loading branch information
ychung-mot authored May 6, 2020
2 parents f8e4eed + d6dc7a7 commit 3e180fc
Show file tree
Hide file tree
Showing 110 changed files with 3,488 additions and 769 deletions.
109 changes: 108 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,109 @@
# HMCR
Build a system that contractors can upload their activity for highway maintenance, validate the GPS locations, provide summary reports. The system is to reject invalid uploads.

As part of the Highway Maintenance Contract Renewal (HMCR) process the business area defined the reporting requirements for the Maintenance Contractors (MCs). The contracts outlined the fields that have to be reported on and the format that it needs to be reported in.

This presents a problem on how to best collect the data being provided by the MCs and ensure its quality, so that it can be put to use for the Program and Ministry needs. Through this project the program wants to meet the need to automate the data gathering, as well as data validation process then capture the successfully validated data in a database which can then be immediately available to HQ and District Offices.

## Prerequisites

- .Net Core 3.1 SDK
- Node.JS v10.0 or newer
- Microsoft SQL Server 2017 or newer

## Dependencies

- Working KeyCloak Realm with BC Gov IDIR and BCeID
- Ministry of Transportation and Infrastructure GeoServer access
- IDIR service account with access to BC Gov BceID WebService

## Local Development

### Configuration

Use the following steps to configure the local development environment

1. Clone the repository

```
git clone https://github.com/bcgov/HMCR.git
```

2. Create the HMR_DEV database in MS SQL Server

- Delete all existing tables
- Run scripts in `database/V01.1` directory
- Apply incremental scripts `(V14.1 to Vxx.x)` in ascending order
- Create the first admin user in `HMR_SYSTEM_USER` table and assign the `SYSTEM_ADMIN` role in the `HMR_USER_ROLE` table

3. Configure API Server settings

- Copy `api/Hmcr.API/appsettigns.json` to `api/Hmcr.API/appsettigns.Development.json`
- Update the placeholder values with real values, eg., replace the `<app-id>` with actual KeyCloak client id in the `{ "JWT": { "Audience": "<app-id>" } }` field
- Update the connection string to match the database
- Make note of or update the port for the API Server in Visual Studio or through the `properties/launchSettings.json` file.

4. Configure Hangfire Server settings

- Copy `api/Hmcr.Hangfire/appsettigns.json` to `api/Hmcr.Hangfire/appsettigns.Development.json`
- Update the placeholder values with real values, eg., replace the `<ServiceAccount:User>` with actual IDIR service account in the `{ "ServiceAccount": { "User": "<ServiceAccount:User>" } }` field
- Update the connection string to match the database

5. Configure the React development settings

- Create the `client/.env.development.local` file and add the following content

```
# use port value from step 3
REACT_APP_API_HOST=http://localhost:<api-port>
REACT_APP_SSO_HOST=https://sso-dev.pathfinder.gov.bc.ca/auth
REACT_APP_SSO_CLIENT=<client-id>
REACT_APP_SSO_REALM=<realm-id>
REACT_APP_DEFAULT_PAGE_SIZE_OPTIONS=25,50,100,200
REACT_APP_DEFAULT_PAGE_SIZE=25
# Optional, default port is 3000
# PORT=3001
```

- Replace the placeholder values

### Run

Use the following steps to run the local development environment

1. Run the API Server

- F5 in Visual Studio
- Or from console

```
cd api/Hmcr.Api
dotnet restore
dotnet build
dotnet run
```

2. Run the Hangfire Server. _It's only neccessary to run the Hangfire Server if debugging Hangfire jobs_

- F5 in Visual Studio
- Or from console

```
cd api/Hmcr.Hangfire
dotnet restore
dotnet build
dotnet run
```

3. Run the React frontend
```
cd client
npm install
npm start
```

## OpenShift Deployment

Refer to [this document](openshift/README.md) for OpenShift Deployment and Pipeline related topics
8 changes: 6 additions & 2 deletions api/Hmcr.Api/Authentication/HmcrJwtBearerEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ public override async Task TokenValidated(TokenValidatedContext context)

private async Task<bool> PopulateCurrentUserFromDb(ClaimsPrincipal principal)
{
_curentUser.UserName = principal.FindFirstValue(HmcrClaimTypes.KcUsername);
var isApiClient = false;
bool.TryParse(principal.FindFirstValue(HmcrClaimTypes.KcIsApiClient), out isApiClient);

_curentUser.UserName = isApiClient ? principal.FindFirstValue(HmcrClaimTypes.KcApiUsername) : principal.FindFirstValue(HmcrClaimTypes.KcUsername);
var usernames = _curentUser.UserName.Split("@");
var username = usernames[0].ToUpperInvariant();
var directory = usernames[1].ToUpperInvariant();
Expand Down Expand Up @@ -105,8 +108,9 @@ private async Task<bool> PopulateCurrentUserFromDb(ClaimsPrincipal principal)
_curentUser.UserName = username;
_curentUser.FirstName = user.FirstName;
_curentUser.LastName = user.LastName;
_curentUser.ApiClientId = user.ApiClientId;

if (user.Username.ToUpperInvariant() != username || user.Email.ToUpperInvariant() != email)
if (!isApiClient && user.Username.ToUpperInvariant() != username || user.Email.ToUpperInvariant() != email)
{
_logger.LogWarning($"Username/Email changed from {user.Username}/{user.Email} to {user.Email}/{email}.");
await _userService.UpdateUserFromBceidAsync(userGuid, username, user.UserType, user.ConcurrencyControlNumber);
Expand Down
48 changes: 28 additions & 20 deletions api/Hmcr.Api/Controllers/ExportController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,14 @@ public ExportController(HmcrCurrentUser currentUser, IExportApi exportApi)
/// </summary>
/// <param name="serviceAreas">1 ~ 28</param>
/// <param name="typeName">hmr:HMR_WORK_REPORT_VW, hmr:HMR_WILDLIFE_REPORT_VW, hmr:HMR_ROCKFALL_REPORT_VW</param>
/// <param name="format">csv, application/json, application/vnd.google-earth.kml+xml</param>
/// <param name="format">Supported formats: CSV, JSON, KML, GML</param>
/// <param name="fromDate">From date in yyyy-MM-dd format</param>
/// <param name="toDate">To date in yyyy-MM-dd format</param>
/// <param name="cql_filter">Filter</param>
/// <param name="propertyName">Property names of the columns to export</param>
/// <returns></returns>
[HttpGet("report", Name = "Export")]
[RequiresPermission(Permissions.Export)]
[RequiresPermission(Permissions.Export)]
public async Task<IActionResult> ExportReport(string serviceAreas, string typeName, string format, DateTime fromDate, DateTime toDate, string cql_filter, string propertyName)
{
var serviceAreaNumbers = serviceAreas.ToDecimalArray();
Expand All @@ -52,15 +53,15 @@ public async Task<IActionResult> ExportReport(string serviceAreas, string typeNa
{
serviceAreaNumbers = _currentUser.UserInfo.ServiceAreas.Select(x => x.ServiceAreaNumber).ToArray();
}

var invalidResult = ValidateQueryParameters(serviceAreaNumbers, typeName, format, fromDate, toDate);

if (invalidResult != null)
{
return invalidResult;
}

var (mimeType, fileName, outputFormat) = GetContentType(format);
var (mimeType, fileName, outputFormat, endpointConfigName) = GetContentType(format);

if (mimeType == null)
{
Expand All @@ -70,7 +71,7 @@ public async Task<IActionResult> ExportReport(string serviceAreas, string typeNa

var dateColName = GetDateColName(typeName);

var (result, exists) = await MatchExists(serviceAreaNumbers, fromDate, toDate, outputFormat, dateColName);
var (result, exists) = await MatchExists(serviceAreaNumbers, fromDate, toDate, "text/csv;charset=UTF-8", dateColName, ExportQueryEndpointConfigName.WFS);

if (result != null)
{
Expand All @@ -82,9 +83,9 @@ public async Task<IActionResult> ExportReport(string serviceAreas, string typeNa
return NotFound();
}

var query = BuildQuery(serviceAreaNumbers, fromDate, toDate, outputFormat, dateColName, false);
var query = BuildQuery(serviceAreaNumbers, fromDate, toDate, outputFormat, dateColName, endpointConfigName, false);

var responseMessage = await _exportApi.ExportReport(query);
var responseMessage = await _exportApi.ExportReport(query, endpointConfigName);

var bytes = await responseMessage.Content.ReadAsByteArrayAsync();

Expand All @@ -103,19 +104,20 @@ public async Task<IActionResult> ExportReport(string serviceAreas, string typeNa
/// csv: csv format
/// json: geo-json format
/// kml: kml format
/// gml: gml foramt
/// gml: gml format
/// </summary>
/// <returns></returns>
[HttpGet("supportedformats", Name = "SupportedFormats")]
[ProducesResponseType(typeof(OutputFormatDto), 200)]
public IActionResult GetSupportedFormats()
{
return Ok(OutputFormatDto.GetSupportedFormats());
}

private async Task<(UnprocessableEntityObjectResult result, bool exists)> MatchExists(decimal[] serviceAreaNumbers, DateTime fromDate, DateTime toDate, string outputFormat, string dateColName)
private async Task<(UnprocessableEntityObjectResult result, bool exists)> MatchExists(decimal[] serviceAreaNumbers, DateTime fromDate, DateTime toDate, string outputFormat, string dateColName, string endpointConfigName)
{
var query = BuildQuery(serviceAreaNumbers, fromDate, toDate, outputFormat, dateColName, true);
var responseMessage = await _exportApi.ExportReport(query);
var query = BuildQuery(serviceAreaNumbers, fromDate, toDate, outputFormat, dateColName, ExportQueryEndpointConfigName.WFS, true);
var responseMessage = await _exportApi.ExportReport(query, endpointConfigName);

if (responseMessage.StatusCode != HttpStatusCode.OK)
{
Expand Down Expand Up @@ -174,31 +176,31 @@ private UnprocessableEntityObjectResult ValidateQueryParameters(decimal[] servic
return null;
}

private (string mimeType, string fileName, string format) GetContentType(string outputFormat)
private (string mimeType, string fileName, string format, string endpointConfigName) GetContentType(string outputFormat)
{
if (outputFormat == null)
{
return (null, null, null);
return (null, null, null, null);
}

outputFormat = outputFormat.ToLowerInvariant();

switch (outputFormat)
{
case OutputFormatDto.Csv:
return ("text/csv;charset=UTF-8", "export.csv", "csv");
return ("text/csv;charset=UTF-8", "export.csv", "csv", ExportQueryEndpointConfigName.WFS);
case OutputFormatDto.Json:
return ("application/json;charset=UTF-8", "export.json", "application/json");
return ("application/json;charset=UTF-8", "export.json", "application/json", ExportQueryEndpointConfigName.WFS);
case OutputFormatDto.Kml:
return ("application/vnd.google-earth.kml+xml;charset=UTF-8", "export.kml", "application/vnd.google-earth.kml+xml");
return ("application/vnd.google-earth.kml+xml;charset=UTF-8", "export.kml", "application/vnd.google-earth.kml+xml", ExportQueryEndpointConfigName.WMS);
case OutputFormatDto.Gml:
return ("application/gml+xml; version=3.2;charset=UTF-8", "export.gml", "application/gml+xml; version=3.2");
return ("application/gml+xml; version=3.2;charset=UTF-8", "export.gml", "application/gml+xml; version=3.2", ExportQueryEndpointConfigName.WFS);
default:
return (null, null, null);
return (null, null, null, null);
}
}

private string BuildQuery(decimal[] serviceAreaNumbers, DateTime fromDate, DateTime toDate, string outputFormat, string dateColName, bool count)
private string BuildQuery(decimal[] serviceAreaNumbers, DateTime fromDate, DateTime toDate, string outputFormat, string dateColName, string endpointConfigName, bool count)
{
var saCql = BuildCsqlFromParameters(serviceAreaNumbers, fromDate, toDate, dateColName);

Expand All @@ -225,6 +227,12 @@ private string BuildQuery(decimal[] serviceAreaNumbers, DateTime fromDate, DateT
pq.Remove(ExportQuery.ToDate);
pq.Remove(ExportQuery.Format);

if (endpointConfigName == ExportQueryEndpointConfigName.WMS)
{
pq.Add(ExportQuery.Layers, pq[ExportQuery.TypeName]);
pq.Remove(ExportQuery.TypeName);
}

if (count)
{
pq.Add(ExportQuery.Count, "1");
Expand Down Expand Up @@ -253,7 +261,7 @@ private string BuildCsqlFromParameters(decimal[] serviceAreaNumbers, DateTime fr

csql.Append("SERVICE_AREA IN (");

foreach(var serviceAreaNumber in serviceAreaNumbers)
foreach (var serviceAreaNumber in serviceAreaNumbers)
{
csql.Append($"{(int)serviceAreaNumber},");
}
Expand Down
58 changes: 55 additions & 3 deletions api/Hmcr.Api/Controllers/UsersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
using Hmcr.Domain.Services;
using Hmcr.Model;
using Hmcr.Model.Dtos;
using Hmcr.Model.Dtos.Keycloak;
using Hmcr.Model.Dtos.User;
using Hmcr.Model.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;

Expand All @@ -19,11 +20,13 @@ namespace Hmcr.Api.Controllers
public class UsersController : HmcrControllerBase
{
private IUserService _userService;
private IKeycloakService _keyCloakService;
private HmcrCurrentUser _currentUser;

public UsersController(IUserService userService, HmcrCurrentUser currentUser)
public UsersController(IUserService userService, IKeycloakService keyCloakService, HmcrCurrentUser currentUser)
{
_userService = userService;
_keyCloakService = keyCloakService;
_currentUser = currentUser;
}

Expand Down Expand Up @@ -83,6 +86,7 @@ public ActionResult<IEnumerable<UserTypeDto>> GetUserStatus()
/// <param name="pageSize">Page size</param>
/// <param name="pageNumber">Page number</param>
/// <param name="orderBy">Order by column(s). Example: orderby=username</param>
/// <param name="direction">Oder by direction. Example: asc, desc</param>
/// <returns></returns>
[HttpGet]
[RequiresPermission(Permissions.UserRead)]
Expand All @@ -97,7 +101,7 @@ public async Task<ActionResult<PagedDto<UserSearchDto>>> GetUsersAsync(
[RequiresPermission(Permissions.UserRead)]
public async Task<ActionResult<UserDto>> GetUsersAsync(decimal id)
{
var user = await _userService.GetUserAsync(id);
var user = await _userService.GetUserAsync(id);

if (user == null)
return NotFound();
Expand Down Expand Up @@ -155,6 +159,7 @@ public async Task<ActionResult> UpdateUser(decimal id, UserUpdateDto user)
return NoContent();
}


[HttpDelete("{id}")]
[RequiresPermission(Permissions.UserWrite)]
public async Task<ActionResult> DeleteUser(decimal id, UserDeleteDto user)
Expand All @@ -178,5 +183,52 @@ public async Task<ActionResult> DeleteUser(decimal id, UserDeleteDto user)

return NoContent();
}

#region API Client
[HttpGet("api-client", Name = "GetUserKeycloakClient")]
public async Task<ActionResult<KeycloakClientDto>> GetUserKeycloakClient()
{
var client = await _keyCloakService.GetUserClientAsync();

if (client == null)
{
return NotFound();
}

return Ok(client);
}

[HttpPost("api-client")]
public async Task<ActionResult<KeycloakClientDto>> CreateUserKeycloakClient()
{
var response = await _keyCloakService.CreateUserClientAsync();

if (response.Errors.Count > 0)
{
return ValidationUtils.GetValidationErrorResult(response.Errors, ControllerContext);
}

return CreatedAtRoute("GetUserKeycloakClient", await _keyCloakService.GetUserClientAsync());
}

[HttpPost("api-client/secret")]
public async Task<ActionResult> RegenerateUserKeycloakClientSecret()
{
var response = await _keyCloakService.RegenerateUserClientSecretAsync();

if (response.NotFound)
{
return NotFound();
}

if (!string.IsNullOrEmpty(response.Error))
{
return ValidationUtils.GetValidationErrorResult(ControllerContext,
StatusCodes.Status500InternalServerError, "Unable to regenerate Keycloak client secret", response.Error);
}

return CreatedAtRoute("GetUserKeycloakClient", await _keyCloakService.GetUserClientAsync());
}
#endregion
}
}
Loading

0 comments on commit 3e180fc

Please sign in to comment.