Skip to content

Commit

Permalink
Initial commit v1.0.1 without tests
Browse files Browse the repository at this point in the history
  • Loading branch information
pgarrison committed Jul 20, 2021
0 parents commit 8a03f76
Show file tree
Hide file tree
Showing 74 changed files with 23,378 additions and 0 deletions.
10 changes: 10 additions & 0 deletions .babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// For Jest, which uses babel-jest by default
// This configuration is copied from:
// https://jestjs.io/docs/en/webpack#using-with-webpack-2
{
"env": {
"test": {
"plugins": ["transform-es2015-modules-commonjs"]
}
}
}
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
.idea/
.DS_Store
*.swp
*.swo
public/main.js*
public/*.main.js*
.env
3 changes: 3 additions & 0 deletions .jshintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"esversion": 8
}
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
engine-strict=true
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM node:16
COPY . .
RUN npm install --production
RUN npm run build
RUN npm install -g forever
EXPOSE 8000
CMD forever -c "npm start" .
11 changes: 11 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Copyright (c) 2021 University of Washington.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.

Neither the name of the University of Washington nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE UNIVERSITY OF WASHINGTON AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE UNIVERSITY OF WASHINGTON OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# CCIS-Dashboard

## Production deployment with Docker
1. Install [Docker](https://docs.docker.com/get-docker/) and [git](https://git-scm.com/downloads)
2. Clone this repository and `cd` into it
3. Get a [Mapbox token](#mapbox-api)
4. Setup your `.env` file ([instructions below](#environment-variables))
5. Build the docker container
```
docker build -t ccis-dashboard .
```
6. Start the application, replacing `80` with the port where the application should be available
```
docker run -p 80:8000 ccis-dashboard
```
7. Application should now be available from outside the docker container at `localhost:80`. Setup a webserver (e.g. nginx) to expose the application to the internet

## Mapbox API
To use the Mapbox API, you must have an API key. The API key is used as an identifier for billing purposes. To create a Mabox API Key, first make a [Mapbox account](https://www.mapbox.com/), then [generate a new one](https://docs.mapbox.com/help/glossary/access-token/) or use your default public token.

Once the key is created, place it in a `.env` file as specified below.

## Environment Variables
Create a file named `.env` in the top level, and fill in the following fields
for your deployment.
```
DB_SERVER=
DB_NAME=
DB_USER=
DB_PASS=
MAPBOX_API_TOKEN=
COOKIE_KEY=
ODKX_AUTH_URL=
ODKX_TEST_USER=
ODKX_TEST_PASSWORD=
```
* `DB_SERVER` is the URL of a Microsoft T-SQL server
* `DB_NAME` is the name of a database on that server
* `DB_USER` and `DB_PASS` are the login credentials for a (read-only) user on that database
* `MAPBOX_API_TOKEN` is the token generated above
* `COOKIE_KEY` should be newly-generated, a strong random secret which is used to encrypt authentication details
* `ODKX_AUTH_URL` is the URL of an ODK-X sync endpoint
* `ODKX_TEST_USER` and `ODKX_TEST_PASSWORD` are only required for testing. These are credentials for a user on that ODK-X server

## Development installation
Clone the repository and `cd` into it. Set up your `.env` file.\
From there run:
```
npm install
npm run build
npm start
```
The dashboard will be available at localhost:8000 \
You can log in to the dashboard using any user from the connected ODK-X server.\
To exit, type `Ctrl+C` in the terminal

## Testing
The dashboard has a suite of tests in the `__tests__` folder. Some of these run
in the Firefox browser, and some of them use a local test database configured
with Docker. To run the tests, you'll need to install these dependencies.
1. Confirm that you have a `.env` file in the top level of the project.
2. [Install docker](https://docs.docker.com/get-docker/) (this is used by the `pretest` step to run a local Microsoft SQL server)
3. [Install docker-compose](https://docs.docker.com/compose/install/) if not installed by step 1
4. Install firefox
5. From the directory of this repository, run `npm install`
6. Run `npm test`

To run a single test file:
1. Run `sudo docker-compose up`, or just `docker-compose up` depending on your user permissions. This starts a local Microsoft SQL server. Note: you must only have one local SQL server running at a time. This step is only necessary for some test files; if your tests pass without it, it is not necessary.
2. Run `npx jest __tests__/<name of test file>` (the `npx` command is part of npm, and jest is the test environment we use)

Notes for Windows:
* If you run into an issue with your BIOS, check out this [SO post](https://stackoverflow.com/questions/39684974/docker-for-windows-error-hardware-assisted-virtualization-and-data-execution-p/39989990#39989990)

## Style Guidelines
Current code uses the following style
* Use `require` instead of `import`
* When defining objects with `{ }`, put spaces inside the braces like `{ logIn }`
* Single quotes `''` for javascript, double quotes `""` for html
* In comments, put a space after the `//`

## Architecture
The following dependency graphs describe the internal structure of the files
here.

The server-side:

![server-side dependency graph](docs/dependenciesBackend.svg)

The client-side:

![client-side dependency graph](docs/dependenciesFrontend.svg)

To update these images, make sure you have graphviz installed (we need the `dot` command) and run `npm run dependency-graph`
2 changes: 2 additions & 0 deletions __mocks__/styleMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// To replace css files
module.exports = {};
51 changes: 51 additions & 0 deletions controller/exportQueries.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
function getRefrigeratorQuery() {
return `SELECT g.levelNumber, g.regionLevel1, g.regionLevel2, g.regionLevel3, g.regionLevel4, g.regionLevel5, g.regionName,
h.id_health_facilities, h.admin_region_id, h.primary_facility_id, h.facility_name, h.ownership, h.facility_level,
h.Location_latitude, h.Location_longitude,
r.year_installed, r.serial_number, r.tracking_id, r.power_source, r.functional_status,
r.utilization, r.maintenance_priority, r.reason_not_working, r.notes, r.temperature_monitoring_device,
r.voltage_regulator, r.voltage_regulator_functional_status, r.voltage_regulator_serial_number,
r.temperature_monitoring_device_functional_status,
rt.id_refrigerator_types, rt.lastUpdateUser_refrigerator_types, rt.savepointTimestamp_refrigerator_types,
rt.catalog_id, rt.equipment_type, rt.manufacturer, rt.power_source,
rt.model_id, rt.freezer_gross_volume, rt.freezer_net_volume, rt.refrigerator_picture_contentType,
rt.refrigerator_picture_uriFragment
FROM geographic_regions_odkx as g,
health_facilities2_odkx as h,
refrigerators_odkx as r,
refrigerator_types_odkx as rt
WHERE g.id_geographic_regions = h.admin_region_id
AND h.id_health_facilities = r.facility_row_id
AND r.model_row_id = rt.id_refrigerator_types`;
}

function getFacilityQuery() {
return `SELECT g.levelNumber, g.regionLevel1, g.regionLevel2, g.regionLevel3, g.regionLevel4, g.regionLevel5, g.regionName,
h.id_health_facilities, h.lastUpdateUser_health_facilities, h.contact_name, h.contact_phone_number, h.contact_title,
h.electricity_source, h.fuel_availability, h.grid_power_availability, h.vaccine_supply_interval,
h.vaccine_supply_mode, h.distance_to_supply, h.immunization_services_offered, h.number_of_cold_boxes,
h.number_of_vaccine_carriers, h.number_of_l3_packs, h.number_of_l4_packs, h.number_of_l6_packs,
h.spare_fuel_cylinders, h.spare_temp_monitoring_devices, h.savepointTimestamp_health_facilities, h.primary_facility_id, h.secondary_facility_id,
h.facility_name,
h.ownership, h.authority, h.Location_latitude, h.Location_longitude, h.catchment_population, h.facility_level, h.facility_status,
(CONVERT(int, h.catchment_population) * 0.06) as facility_storage_requirement,
(SELECT COUNT(*) FROM refrigerators_odkx WHERE refrigerators_odkx.facility_row_id = h.id_health_facilities
AND refrigerators_odkx.functional_status = 'functioning')
as facility_storage_volume,
(SELECT
CASE MAX(case maintenance_priority when 'high' then 3 when 'medium' then 2 when 'low' then 1 else 0 end)
when 3 then 'high' when 2 then 'medium' when 1 then 'low' else 'na'
END FROM refrigerators_odkx WHERE refrigerators_odkx.facility_row_id = h.id_health_facilities) as facility_maintanance_priority
FROM geographic_regions_odkx as g,
health_facilities2_odkx as h
WHERE g.id_geographic_regions = h.admin_region_id`;
}

module.exports = {
getRefrigeratorQuery,
getFacilityQuery
};
12 changes: 12 additions & 0 deletions controller/filterOptions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const { mapValues } = require('async');
const filterSpecification = require('./filterSpecification');

function getDistinctFilterOptions(db) {
return mapValues(filterSpecification, async ({ table, column, useInDropdowns }) => {
if (!useInDropdowns) return null;
const rows = await db.query(`SELECT DISTINCT ${column} FROM ${table}`);
return rows.map(row => row[column]);
});
}

module.exports = getDistinctFilterOptions;
33 changes: 33 additions & 0 deletions controller/filterSpecification.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// This file defines what options the user can select for a filter and where
// the application looks for them in the database
module.exports = {
facilityTypes: {
table: 'health_facilities2_odkx',
column: 'facility_level',
useInDropdowns: true
},
refrigeratorTypes: {
table: 'refrigerator_types_odkx',
column: 'model_id',
useInDropdowns: true
},
maintenancePriorities: {
table: 'refrigerators_odkx',
column: 'maintenance_priority',
useInDropdowns: true
},
regions: {
table: 'geographic_regions_odkx',
useInDropdowns: false,
multiColumn: true,
columns: [
// Order matters here: the input will be something like
// [ 'Uganda', 'Kampala', 'Kampala' ]
// and each of those needs to get matched up to the right column
// of geographic_regions_odkx. If only some columns are included,
// it's okay: only the ones specified in the filter must match.
// So [ 'Uganda' ] will match any region within Uganda.
'regionLevel2', 'regionLevel3'
]
}
};
92 changes: 92 additions & 0 deletions controller/formatResponse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
const d3 = require('d3-array');
const { uniq, uniqWith, fromPairs } = require('lodash');
const visualizations = require('./../shared/visualizations.js');

function formatResponse(vizSpec, dataTSQL, legendDataTSQL) {
if (vizSpec.style === 'map') {
return {data: dataTSQL};
}
const metadata = makeMetadata(vizSpec, dataTSQL, legendDataTSQL);
return {
data: formatForD3Stack(dataTSQL, metadata.fullColorDomain),
metadata
};
}

/*
* Takes an array of objects and creates nested arrays, first grouping by
* repeatLabel and then xlabel inside that
* For the future, this could become a bit more generic instead of expecting
* fields named 'colorLabel', 'repeatLabel', and 'xlabel'
* See queryTools.test.js for examples
*/
function formatForD3Stack(data, fullColorDomain) {
return d3.rollups(data, colorLabelAsKeys.bind({}, fullColorDomain),
data => data.repeatLabel, data => data.xlabel);
}

/*
* Takes an array of objects and makes a single object with keys
* corresponding to the values of `colorLabel` in `rowSubset` and values
* corresponding to the value of `count` for the unique(!) item in
* `rowSubset` with that color label. The returned object will have a
* value for every key listed in fullColorDomain, filling with zeros where
* necessary.
*
* @param rowSubset array
* rowSubset must be an array of objects, where no two object share the
* same colorLabel.
* @param fullColorDomain array of strings
*
* For example, given:
* fullColorDomain = ['Model 1', 'Model 2', 'Model 3', 'Model 4']
* and rowSubset =
* [
* { xlabel: "2015", repeatLabel: "Gas", colorLabel: "Model 1", count: 10 },
* { xlabel: "2015", repeatLabel: "Gas", colorLabel: "Model 2", count: 13 },
* { xlabel: "2015", repeatLabel: "Gas", colorLabel: "Model 3", count: 30 }
* ]
* The output will be the following javascript map
* {
* "Model 1": 10,
* "Model 2": 13,
* "Model 3": 30,
* "Model 4": 0
* }
*/
function colorLabelAsKeys(fullColorDomain, rowSubset) {
// Because of our requirement that the categories are unique, we know
// that the reduce function can assume the input is an array of length 1
const returnObj = d3.rollup(rowSubset, ([row]) => row.count, row => row.colorLabel);
fullColorDomain.forEach(colorLabel => {
if (!returnObj.has(colorLabel)) returnObj.set(colorLabel, 0);
});
// Need to convert to a normal object for JSON serialization
return mapToObject(returnObj);
}

/*
* input: a Javascript Map
* output: a Javascript Object
*/
function mapToObject(map) {
return fromPairs(Array.from(map));
}


function makeMetadata(vizSpec, dataTSQL, legendDataTSQL) {
const result = {
fullDomain: uniq(dataTSQL.map(d => d.xlabel)),
fullColorDomain: uniq(legendDataTSQL.map(d => d.colorLabel))
};
return result;
}

formatResponse._test = {
formatForD3Stack,
makeMetadata,
colorLabelAsKeys,
mapToObject
};

module.exports = formatResponse;
19 changes: 19 additions & 0 deletions controller/indicatorQueries.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
function getIndicatorsQuery() {
// information about datetime conversion
// https://stackoverflow.com/questions/10207900/convert-nvarchar-iso-8601-date-to-datetime-in-sql-server
// https://docs.microsoft.com/en-us/sql/t-sql/functions/cast-and-convert-transact-sql?view=sql-server-ver15
// 126 is the date time code for times stored in ISO8601 format
// list of other SQL-supported date time codes: https://www.w3schools.com/sql/func_sqlserver_convert.asp
const DATE_TIME_CODE = 126;
return `SELECT (SELECT COUNT(h.id_health_facilities) FROM health_facilities2_odkx as h) as num_hf,
MAX(CONVERT(datetime2, r.savepointTimestamp_refrigerators, ${DATE_TIME_CODE})) as last_updated_ref,
(SELECT MAX(CONVERT(datetime2, h.savepointTimestamp_health_facilities, ${DATE_TIME_CODE})) FROM health_facilities2_odkx as h) as last_updated_fac,
COUNT(r.id_refrigerators) as num_ref,
SUM(CASE WHEN r.maintenance_priority = 'high' THEN 1
WHEN r.maintenance_priority = 'low' THEN 1
ELSE 0
END) as need_maintanance
FROM refrigerators_odkx as r`;
}

module.exports = { getIndicatorsQuery };
31 changes: 31 additions & 0 deletions controller/legendQuery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const tableName = require('../model/tableName');

const AGE_GROUPS_LEGEND = [
{ colorLabel: '0-5 Years' },
{ colorLabel: '6-10 Years' },
{ colorLabel: '>10 Years' },
{ colorLabel: 'Missing data' }
];

function legendQuery(db, vizSpec) {
if (isAgeGroups(vizSpec)) {
// age brackets hardcoded into the legend are from makeBucketByAge() CASE statement in controller/QueryTemplate.js
return AGE_GROUPS_LEGEND;
}
else {
return db.query(makeLegendQuery(vizSpec));
}
}

function isAgeGroups(vizSpec) {
const colName = vizSpec.colorBy;
return colName === '[Age Groups]';
}

function makeLegendQuery(vizSpec) {
const colName = vizSpec.colorBy;
return `SELECT DISTINCT ISNULL(NULLIF(${colName}, ''), 'Missing data') as colorLabel
FROM ${tableName[colName]} ORDER BY colorLabel DESC`;
}

module.exports = legendQuery;
Loading

0 comments on commit 8a03f76

Please sign in to comment.