diff --git a/angular/package.json b/angular/package.json index 68b6d2bc0..3ea82406e 100644 --- a/angular/package.json +++ b/angular/package.json @@ -71,6 +71,7 @@ "@tinymce/tinymce-angular": "3.4.0", "async": "~3.1.0", "bootstrap": "~4.5.0", + "chart.js": "^2.9.4", "classlist.js": "~1.1.20150312", "core-js": "^2.5.4", "hammerjs": "~2.0.8", diff --git a/angular/projects/admin-nrpti/src/app/home/home.component.html b/angular/projects/admin-nrpti/src/app/home/home.component.html index a2bdf2de8..f3b50e0c4 100644 --- a/angular/projects/admin-nrpti/src/app/home/home.component.html +++ b/angular/projects/admin-nrpti/src/app/home/home.component.html @@ -28,5 +28,27 @@

Records

+
+
+
+

Agency Records

+

Records by Agency published to NRCED over the last 365 days

+
+ +
+
+
+
+
+

Records by Type

+

Records by Type Published to NRCED

+
+ +
+
- + \ No newline at end of file diff --git a/angular/projects/admin-nrpti/src/app/home/home.component.spec.ts b/angular/projects/admin-nrpti/src/app/home/home.component.spec.ts index e5192196c..28102c81d 100644 --- a/angular/projects/admin-nrpti/src/app/home/home.component.spec.ts +++ b/angular/projects/admin-nrpti/src/app/home/home.component.spec.ts @@ -6,6 +6,7 @@ import { RouterTestingModule } from '@angular/router/testing'; import { LoadingScreenService } from 'nrpti-angular-components'; import { Constants } from '../utils/constants/misc'; import { KeycloakService } from '../services/keycloak.service'; +import { MetricService } from '../services/metric.service'; describe('HomeComponent', () => { let component: HomeComponent; @@ -37,6 +38,12 @@ describe('HomeComponent', () => { } }; + const mockMetricService = { + getMetric: (code) => { + return []; + } + }; + beforeEach((() => { TestBed.configureTestingModule({ declarations: [HomeComponent], @@ -44,7 +51,8 @@ describe('HomeComponent', () => { providers: [ { provide: 'Router', useValue: mockRouter }, { provide: LoadingScreenService, useValue: mockLoadingScreenService }, - { provide: KeycloakService, useValue: mockKeyCloakService } + { provide: KeycloakService, useValue: mockKeyCloakService }, + { provide: MetricService, useValue: mockMetricService } ] }).compileComponents(); })); diff --git a/angular/projects/admin-nrpti/src/app/home/home.component.ts b/angular/projects/admin-nrpti/src/app/home/home.component.ts index 4d18dff2d..ae9992db9 100644 --- a/angular/projects/admin-nrpti/src/app/home/home.component.ts +++ b/angular/projects/admin-nrpti/src/app/home/home.component.ts @@ -1,7 +1,9 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; import { LoadingScreenService } from 'nrpti-angular-components'; -import { Router } from '@angular/router'; import { KeycloakService } from '../services/keycloak.service'; +import { Chart } from 'chart.js'; +import { MetricService } from '../services/metric.service'; @Component({ selector: 'app-home', @@ -9,13 +11,126 @@ import { KeycloakService } from '../services/keycloak.service'; styleUrls: ['./home.component.scss'] }) export class HomeComponent implements OnInit { + @ViewChild('chart1') chart1; + data365Chart; + @ViewChild('chart2') chart2; + dataRecordType; + public loading = true; + constructor( + public route: ActivatedRoute, public keycloakService: KeycloakService, + private metricService: MetricService, private router: Router, private loadingScreenService: LoadingScreenService ) { } - ngOnInit() { } + async ngOnInit() { + const issuingAgencyPublished365 = await this.metricService.getMetric('IssuingAgencyPublished365'); + // tslint:disable-next-line: prefer-const + let labels365 = []; + // tslint:disable-next-line: prefer-const + let data365 = []; + // tslint:disable-next-line:prefer-for-of + for (let i = 0; i < issuingAgencyPublished365.length; i++) { + const keyName = this.getKeyName(issuingAgencyPublished365[i]); + labels365.push(issuingAgencyPublished365[i][keyName]); + data365.push(issuingAgencyPublished365[i]['count']); + } + this.data365Chart = new Chart(this.chart1.nativeElement, { + type: 'pie', + data: { + labels: labels365, + datasets: [{ + data: data365, + backgroundColor: [ + 'rgba(255, 99, 132, 0.2)', + 'rgba(54, 162, 235, 0.2)', + 'rgba(255, 206, 86, 0.2)', + 'rgba(75, 192, 192, 0.2)', + 'rgba(153, 102, 255, 0.2)', + 'rgba(255, 159, 64, 0.2)', + 'rgba(100, 33, 155, 0.2)', + 'rgba(100, 33, 77, 0.2)' + ], + borderColor: [ + 'rgba(255, 99, 132, 1)', + 'rgba(54, 162, 235, 1)', + 'rgba(255, 206, 86, 1)', + 'rgba(75, 192, 192, 1)', + 'rgba(153, 102, 255, 1)', + 'rgba(255, 159, 64, 1)', + 'rgba(100, 33, 155, 1)', + 'rgba(100, 33, 77, 0.2)' + ], + borderWidth: 1 + }] + }, + options: { + cutoutPercentage: 50 + } + }); + + // RecordByType + const recordTypes = await this.metricService.getMetric('RecordByType'); + // tslint:disable-next-line: prefer-const + let typeLabels = []; + // tslint:disable-next-line: prefer-const + let typeData = []; + // tslint:disable-next-line:prefer-for-of + for (let i = 0; i < recordTypes.length; i++) { + const keyName = this.getKeyName(recordTypes[i]); + typeLabels.push(recordTypes[i][keyName]); + typeData.push(recordTypes[i]['count']); + } + this.dataRecordType = new Chart(this.chart2.nativeElement, { + type: 'pie', + data: { + labels: typeLabels, + datasets: [{ + data: typeData, + backgroundColor: [ + 'rgba(255, 99, 132, 0.2)', + 'rgba(54, 162, 235, 0.2)', + 'rgba(255, 206, 86, 0.2)', + 'rgba(75, 192, 192, 0.2)', + 'rgba(153, 102, 255, 0.2)', + 'rgba(255, 159, 64, 0.2)', + 'rgba(100, 33, 155, 0.2)', + 'rgba(100, 33, 77, 0.2)' + ], + borderColor: [ + 'rgba(255, 99, 132, 1)', + 'rgba(54, 162, 235, 1)', + 'rgba(255, 206, 86, 1)', + 'rgba(75, 192, 192, 1)', + 'rgba(153, 102, 255, 1)', + 'rgba(255, 159, 64, 1)', + 'rgba(100, 33, 155, 1)', + 'rgba(100, 33, 77, 0.2)' + ], + borderWidth: 1 + }] + }, + options: { + cutoutPercentage: 50 + } + }); + } + + getKeyName(metricObject) { + // Determine the non-count key by popping the first one off, checking + // if it's `count` and if it is, pop the next one as there are only + // ever 2 attributes in the metric report. This is how metabase creates + // it's queries. + const theKeys = Object.keys(metricObject); + let keyName = theKeys.pop(); + + if (keyName === 'count') { + keyName = theKeys.pop(); + } + return keyName; + } activateLoading(path) { this.loadingScreenService.setLoadingState(true, 'body'); diff --git a/angular/projects/admin-nrpti/src/app/services/metric.service.ts b/angular/projects/admin-nrpti/src/app/services/metric.service.ts new file mode 100644 index 000000000..5b36c19be --- /dev/null +++ b/angular/projects/admin-nrpti/src/app/services/metric.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { ApiService } from './api.service'; + +@Injectable({ providedIn: 'root' }) +export class MetricService { + + constructor(public apiService: ApiService, public http: HttpClient) { } + + public getMetric(code: string): Promise { + if (!code) { + throw Error('MetricService - getMetric - missing required code param'); + } + + const queryString = `metric/${code}/data`; + return this.http.get(`${this.apiService.pathAPI}/${queryString}`).toPromise(); + } +} + diff --git a/api/migrations/20210317202310-addMetric.js b/api/migrations/20210317202310-addMetric.js new file mode 100644 index 000000000..b39c973ac --- /dev/null +++ b/api/migrations/20210317202310-addMetric.js @@ -0,0 +1,266 @@ +'use strict'; + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = async function(db) { + console.log('**** Adding reports ****'); + + const mClient = await db.connection.connect(db.connectionString, { + native_parser: true + }); + + try { + const nrpti = await mClient.collection('nrpti'); + + await nrpti.insert({ + _schemaName: "Metric", + code: "IssuingAgencyPublished365", + header: "Agency Records", + description: "Records by Agency published to NRCED over the last 365 days", + operation: JSON.stringify([ + { + "$project": { + "_schemaName": "$_schemaName", + "datePublished": "$datePublished", + "issuingAgency": "$issuingAgency", + "dateIssued~~~day": { + "$let": { + "vars": { + "column": "$dateIssued" + }, + "in": { + "___date": { + "$dateToString": { + "format": "%Y-%m-%d", + "date": "$$column" + } + } + } + } + } + } + }, + { + "$match": { + "$and": [ + { + "$or": [ + { + "_schemaName": { + "$eq": "AdministrativePenaltyNRCED" + } + }, + { + "_schemaName": { + "$eq": "AdministrativeSanctionNRCED" + } + }, + { + "_schemaName": { + "$eq": "CourtConvictionNRCED" + } + }, + { + "_schemaName": { + "$eq": "InspectionNRCED" + } + }, + { + "_schemaName": { + "$eq": "OrderNRCED" + } + }, + { + "_schemaName": { + "$eq": "RestorativeJusticeNRCED" + } + }, + { + "_schemaName": { + "$eq": "TicketNRCED" + } + } + ] + }, + { + "datePublished": { + "$ne": null + } + }, + { + "issuingAgency": { + "$ne": null + } + }, + { + "dateIssued~~~day": { + "$gte": { + "___date": "2020-01-23" + }, + "$lte": { + "___date": "2021-01-21" + } + } + } + ] + } + }, + { + "$project": { + "_id": "$_id", + "___group": { + "issuingAgency": "$issuingAgency" + } + } + }, + { + "$group": { + "_id": "$___group", + "count": { + "$sum": 1 + } + } + }, + { + "$sort": { + "_id": 1 + } + }, + { + "$project": { + "_id": false, + "issuingAgency": "$_id.issuingAgency", + "count": true + } + }, + { + "$sort": { + "count": -1, + "issuingAgency": 1 + } + }]) + }); + + await nrpti.insert({ + _schemaName: "Metric", + header: "Records by Type", + code: "RecordByType", + description: "Records by Type Published to NRCED", + operation: JSON.stringify( + [ + { + "$match": { + "$and": [ + { + "$or": [ + { + "_schemaName": { + "$eq": "AdministrativePenaltyNRCED" + } + }, + { + "_schemaName": { + "$eq": "AdministrativeSanctionNRCED" + } + }, + { + "_schemaName": { + "$eq": "CourtConvictionNRCED" + } + }, + { + "_schemaName": { + "$eq": "InspectionNRCED" + } + }, + { + "_schemaName": { + "$eq": "OrderNRCED" + } + }, + { + "_schemaName": { + "$eq": "RestorativeJusticeNRCED" + } + }, + { + "_schemaName": { + "$eq": "TicketNRCED" + } + } + ] + }, + { + "datePublished": { + "$ne": null + } + } + ] + } + }, + { + "$project": { + "_id": "$_id", + "___group": { + "recordType": "$recordType" + } + } + }, + { + "$group": { + "_id": "$___group", + "count": { + "$sum": 1 + } + } + }, + { + "$sort": { + "_id": 1 + } + }, + { + "$project": { + "_id": false, + "recordType": "$_id.recordType", + "count": true + } + }, + { + "$sort": { + "recordType": 1 + } + } + ] + )} + ); + + console.log(`Finished inserting reports`); + } catch (err) { + console.log(`Error inserting reports: ${err}`); + } finally { + mClient.close(); + } + + return null; +}; + +exports.down = function(db) { + return null; +}; + +exports._meta = { + "version": 1 +}; diff --git a/api/src/controllers/metric.js b/api/src/controllers/metric.js new file mode 100644 index 000000000..5fda6645c --- /dev/null +++ b/api/src/controllers/metric.js @@ -0,0 +1,65 @@ +const QueryActions = require('../utils/query-actions'); +const { metric: Metric } = require('../models/index'); + +exports.protectedOptions = function (args, res, next) { + res.status(200).send(); +}; + +exports.protectedList = async function (args, res, next) { + // protected by swagger route by the scope + const agg = [ + { + $match: { + _schemaName: "Metric" + } + }, + { + $project: { + code: 1 + } + } + ]; + + const metrics = await Metric.aggregate(agg); + + QueryActions.sendResponse(res, 200, metrics); +}; + +exports.protectedGet = async function (args, res, next) { + if (!args.swagger.params.code) { + return QueryActions.sendResponse(res, 400, {}); + } + + const query = { + _schemaName: "Metric", + code: args.swagger.params.code.value + }; + + const metric = await Metric.find(query); + + QueryActions.sendResponse(res, 200, metric); +}; + +exports.protectedGetData = async function (args, res, next) { + const query = { + _schemaName: "Metric", + code: args.swagger.params.code.value + }; + + // Future: Set read/write roles on metric, guard against execution at this point. + const metric = await Metric.findOne(query); + + if (!metric.operation) { + return QueryActions.sendResponse(res, 400, {}); + } + + try { + const aggregateOperation = JSON.parse(metric.operation); + + const data = await Metric.aggregate(aggregateOperation); + + QueryActions.sendResponse(res, 200, data); + } catch (e) { + QueryActions.sendResponse(res, 400, e); + } +}; diff --git a/api/src/models/index.js b/api/src/models/index.js index 357a93c16..9ade505a3 100644 --- a/api/src/models/index.js +++ b/api/src/models/index.js @@ -7,6 +7,7 @@ exports.document = require('./document'); exports.epicProject = require('./epicProject'); exports.communicationPackage = require('./communicationPackage'); exports.mapLayerInfo = require('./mapLayerInfo'); +exports.metric = require('./metric'); // master require('./master'); diff --git a/api/src/models/metric.js b/api/src/models/metric.js new file mode 100644 index 000000000..0bfc8a503 --- /dev/null +++ b/api/src/models/metric.js @@ -0,0 +1,20 @@ +module.exports = require('../utils/model-schema-generator')( + 'Metric', + { + _schemaName: { type: String, default: 'Metric', index: true }, + code: { type: String, default: '', index: true }, + description: { type: String, default: '' }, + operation: { type: String, default: '' }, + + addedBy: { type: String, default: null }, + dateAdded: { type: Date, default: Date.now() }, + + updatedBy: { type: String, default: null }, + dateUpdated: { type: Date, default: null }, + + // Permissions + write: [{ type: String, trim: true, default: 'sysadmin' }], + read: [{ type: String, trim: true, default: 'sysadmin' }] + }, + 'nrpti' +); diff --git a/api/src/swagger/swagger.yaml b/api/src/swagger/swagger.yaml index 14ba58c76..4fe5a8f00 100644 --- a/api/src/swagger/swagger.yaml +++ b/api/src/swagger/swagger.yaml @@ -127,6 +127,10 @@ definitions: ConfigObject: type: object + ### Metric Definitions + MetricListObject: + type: object + ### Search Definitions SearchObject: type: object @@ -342,6 +346,171 @@ paths: description: 'Access Denied' schema: $ref: '#/definitions/Error' + /metric: + x-swagger-router-controller: metric + options: + tags: + - metric + summary: 'Pre-flight request' + operationId: protectedOptions + description: 'Options on metric Route' + responses: + '200': + description: 'Success' + schema: + $ref: '#/definitions/MetricListObject' + '403': + description: 'Access Denied' + schema: + $ref: '#/definitions/Error' + get: + tags: + - metric + summary: 'Get a list of metrics' + operationId: protectedList + description: 'Retreiving a list of metrics stored in the system.' + security: + - Bearer: [] + x-security-scopes: + - sysadmin + - admin:lng + - admin:nrced + - admin:bcmi + - admin:wf + - admin:flnro + - admin:agri + - admin:env-epd + - admin:alc + - admin:flnr-nro + - admin:env-cos + - admin:env-bcparks + responses: + '200': + description: 'Success' + schema: + $ref: '#/definitions/MetricListObject' + '403': + description: 'Access Denied' + schema: + $ref: '#/definitions/Error' + /metric/{code}: + x-swagger-router-controller: metric + options: + tags: + - metric + summary: 'Pre-flight request' + operationId: protectedOptions + description: 'Options on metric Route' + parameters: + - name: code + in: path + description: 'code of metric' + required: true + type: string + responses: + '200': + description: 'Success' + schema: + $ref: '#/definitions/MetricListObject' + '403': + description: 'Access Denied' + schema: + $ref: '#/definitions/Error' + get: + tags: + - metric + summary: 'Get a metric' + operationId: protectedGet + description: 'Retreives a metric.' + security: + - Bearer: [] + x-security-scopes: + - sysadmin + - admin:lng + - admin:nrced + - admin:bcmi + - admin:wf + - admin:flnro + - admin:agri + - admin:env-epd + - admin:alc + - admin:flnr-nro + - admin:env-cos + - admin:env-bcparks + parameters: + - name: code + in: path + description: 'code of metric' + required: true + type: string + responses: + '200': + description: 'Success' + schema: + $ref: '#/definitions/MetricListObject' + '403': + description: 'Access Denied' + schema: + $ref: '#/definitions/Error' + /metric/{code}/data: + x-swagger-router-controller: metric + options: + tags: + - metric + summary: 'Pre-flight request' + operationId: protectedOptions + description: 'Options on metric Route' + parameters: + - name: code + in: path + description: 'code of metric' + required: true + type: string + responses: + '200': + description: 'Success' + schema: + $ref: '#/definitions/MetricListObject' + '403': + description: 'Access Denied' + schema: + $ref: '#/definitions/Error' + get: + tags: + - metric + summary: 'Get data associated with a metric' + operationId: protectedGetData + description: "Retreives data from a metric after it's aggregation is run." + security: + - Bearer: [] + x-security-scopes: + - sysadmin + - admin:lng + - admin:nrced + - admin:bcmi + - admin:wf + - admin:flnro + - admin:agri + - admin:env-epd + - admin:alc + - admin:flnr-nro + - admin:env-cos + - admin:env-bcparks + parameters: + - name: code + in: path + description: 'code of metric' + required: true + type: string + responses: + '200': + description: 'Success' + schema: + $ref: '#/definitions/MetricListObject' + '403': + description: 'Access Denied' + schema: + $ref: '#/definitions/Error' /search: x-swagger-router-controller: search options: