Skip to content

Commit 8b00a01

Browse files
Merge pull request #117 from abnamro/2225564-metrics-audits-by-auditor
2225564 metrics audits by auditor
2 parents 4b8ada9 + 268c40f commit 8b00a01

File tree

12 files changed

+427
-6
lines changed

12 files changed

+427
-6
lines changed

components/resc-backend/src/resc_backend/constants.py

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
RWS_ROUTE_RULE_PACKS = "/rule-packs"
2424
RWS_ROUTE_VCS = "/vcs-instances"
2525

26+
27+
RWS_ROUTE_AUDIT_COUNT_BY_AUDITOR_OVER_TIME = "/audit-count-by-auditor-over-time"
2628
RWS_ROUTE_AUDITED_COUNT_OVER_TIME = "/audited-count-over-time"
2729
RWS_ROUTE_UN_TRIAGED_COUNT_OVER_TIME = "/un-triaged-count-over-time"
2830
RWS_ROUTE_COUNT_BY_TIME = "/count-by-time"

components/resc-backend/src/resc_backend/resc_web_service/crud/audit.py

+32-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# pylint: disable=R0916,R0912,C0121
22
# Standard Library
33
import logging
4-
from datetime import datetime
4+
from datetime import datetime, timedelta
55

66
# Third Party
7-
from sqlalchemy import func
7+
from sqlalchemy import extract, func
8+
from sqlalchemy.engine import Row
89
from sqlalchemy.orm import Session
910

1011
# First Party
@@ -78,6 +79,34 @@ def get_finding_audits_count(db_connection: Session, finding_id: int) -> int:
7879
:return: total_count
7980
count of audit entries
8081
"""
81-
total_count = db_connection.query(func.count(model.DBaudit.id_))\
82+
total_count = db_connection.query(func.count(model.DBaudit.id_)) \
8283
.filter(model.DBaudit.finding_id == finding_id).scalar()
8384
return total_count
85+
86+
87+
def get_audit_count_by_auditor_over_time(db_connection: Session, weeks: int = 13) -> list[Row]:
88+
"""
89+
Retrieve count audits by auditor over time for given weeks
90+
:param db_connection:
91+
Session of the database connection
92+
:param weeks:
93+
optional, filter on last n weeks, default 13
94+
:return: count_over_time
95+
list of rows containing audit count over time per week
96+
"""
97+
last_nth_week_date_time = datetime.utcnow() - timedelta(weeks=weeks)
98+
99+
query = db_connection.query(extract('year', model.DBaudit.timestamp).label("year"),
100+
extract('week', model.DBaudit.timestamp).label("week"),
101+
model.DBaudit.auditor,
102+
func.count(model.DBaudit.id_).label("audit_count")) \
103+
.filter(model.DBaudit.timestamp >= last_nth_week_date_time) \
104+
.group_by(extract('year', model.DBaudit.timestamp).label("year"),
105+
extract('week', model.DBaudit.timestamp).label("week"),
106+
model.DBaudit.auditor) \
107+
.order_by(extract('year', model.DBaudit.timestamp).label("year"),
108+
extract('week', model.DBaudit.timestamp).label("week"),
109+
model.DBaudit.auditor)
110+
finding_audits = query.all()
111+
112+
return finding_audits

components/resc-backend/src/resc_backend/resc_web_service/endpoints/metrics.py

+48
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@
1111
ERROR_MESSAGE_500,
1212
ERROR_MESSAGE_503,
1313
METRICS_TAG,
14+
RWS_ROUTE_AUDIT_COUNT_BY_AUDITOR_OVER_TIME,
1415
RWS_ROUTE_AUDITED_COUNT_OVER_TIME,
1516
RWS_ROUTE_COUNT_PER_VCS_PROVIDER_BY_WEEK,
1617
RWS_ROUTE_METRICS,
1718
RWS_ROUTE_UN_TRIAGED_COUNT_OVER_TIME
1819
)
1920
from resc_backend.db.connection import Session
21+
from resc_backend.resc_web_service.crud import audit as audit_crud
2022
from resc_backend.resc_web_service.crud import finding as finding_crud
2123
from resc_backend.resc_web_service.dependencies import get_db_connection
24+
from resc_backend.resc_web_service.schema.audit_count_over_time import AuditCountOverTime
2225
from resc_backend.resc_web_service.schema.finding_count_over_time import FindingCountOverTime
2326
from resc_backend.resc_web_service.schema.finding_status import FindingStatus
2427
from resc_backend.resc_web_service.schema.vcs_provider import VCSProviders
@@ -142,3 +145,48 @@ def convert_rows_to_finding_count_over_time(count_over_time: dict, weeks: int) -
142145

143146
output.append(week_data)
144147
return output
148+
149+
150+
@router.get(f"{RWS_ROUTE_AUDIT_COUNT_BY_AUDITOR_OVER_TIME}",
151+
response_model=list[AuditCountOverTime],
152+
summary="Get count of Audits by Auditor over time for given weeks",
153+
status_code=status.HTTP_200_OK,
154+
responses={
155+
200: {"description": "Retrieve count of Audits by Auditor over time for given weeks"},
156+
500: {"description": ERROR_MESSAGE_500},
157+
503: {"description": ERROR_MESSAGE_503}
158+
})
159+
def get_audit_count_by_auditor_over_time(db_connection: Session = Depends(get_db_connection),
160+
weeks: Optional[int] = Query(default=13, ge=1)) \
161+
-> list[AuditCountOverTime]:
162+
"""
163+
Retrieve count of Audits by Auditor over time for given weeks
164+
- **db_connection**: Session of the database connection
165+
- **weeks**: Nr of weeks for which to retrieve the audit counts
166+
- **return**: [AuditCountOverTime]
167+
The output will contain a list of AuditCountOverTime type objects
168+
"""
169+
audit_counts = audit_crud.get_audit_count_by_auditor_over_time(db_connection=db_connection, weeks=weeks)
170+
171+
# get the unique auditors from the data
172+
auditors_default = {}
173+
for audit in audit_counts:
174+
auditors_default[audit['auditor']] = 0
175+
176+
# default to 0 per auditor for all weeks in range
177+
weekly_audit_counts = {}
178+
for week in range(0, weeks):
179+
nth_week = datetime.utcnow() - timedelta(weeks=week)
180+
week = f"{nth_week.isocalendar().year} W{nth_week.isocalendar().week:02d}"
181+
weekly_audit_counts[week] = AuditCountOverTime(time_period=week, audit_by_auditor_count=dict(auditors_default))
182+
weekly_audit_counts = dict(sorted(weekly_audit_counts.items()))
183+
184+
# set the counts based on the data from the database
185+
for audit in audit_counts:
186+
audit_week = f"{audit['year']} W{audit['week']:02d}"
187+
weekly_audit_counts.get(audit_week).audit_by_auditor_count[audit['auditor']] = audit['audit_count']
188+
weekly_audit_counts.get(audit_week).total += audit['audit_count']
189+
190+
sorted_weekly_audit_counts = dict(sorted(weekly_audit_counts.items()))
191+
output = list(sorted_weekly_audit_counts.values())
192+
return output
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# pylint: disable=no-name-in-module
2+
# Standard Library
3+
from typing import Dict
4+
5+
# Third Party
6+
from pydantic import BaseModel
7+
8+
9+
class AuditCountOverTime(BaseModel):
10+
time_period: str
11+
audit_by_auditor_count: Dict[str, int]
12+
total: int = 0

components/resc-backend/tests/newman_tests/RESC_web_service.postman_collection.json

+50-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"info": {
3-
"_postman_id": "3d5d6abf-2f53-4200-b744-d3965c492658",
3+
"_postman_id": "6db9b02e-2513-4d4e-a31d-c90f3a766832",
44
"name": "Repository Scanner (RESC)",
55
"description": "RESC API helps you to perform several operations upon findings obtained from multiple source code repositories. 🚀",
66
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
@@ -18815,6 +18815,55 @@
1881518815
"body": "[\n {\n \"time_period\": \"sed cupidatat dolore commodo\",\n \"vcs_provider_finding_count\": {\n \"AZURE_DEVOPS\": 0,\n \"BITBUCKET\": 0,\n \"GITHUB_PUBLIC\": 0\n },\n \"total\": 0\n },\n {\n \"time_period\": \"veniam\",\n \"vcs_provider_finding_count\": {\n \"AZURE_DEVOPS\": 0,\n \"BITBUCKET\": 0,\n \"GITHUB_PUBLIC\": 0\n },\n \"total\": 0\n }\n]"
1881618816
}
1881718817
]
18818+
},
18819+
{
18820+
"name": "Get count of auditds by auditer over time for given weeks",
18821+
"event": [
18822+
{
18823+
"listen": "test",
18824+
"script": {
18825+
"exec": [
18826+
"pm.test(\"Status code is 200\", function () {",
18827+
" pm.response.to.have.status(200);",
18828+
"});",
18829+
"",
18830+
"pm.test(\"Status is OK\"), function (){",
18831+
" pm.response.to.have.property(\"status\",\" OK\")",
18832+
"}",
18833+
"",
18834+
"const responseJson = pm.response.json();",
18835+
"pm.test(\"Response body matches\", function() {",
18836+
" pm.expect(responseJson).to.be.an(\"array\");",
18837+
" pm.expect(responseJson.length).to.eql(13);",
18838+
" responseJson.forEach((obj) => {",
18839+
" pm.expect(obj).to.have.property('time_period');",
18840+
" pm.expect(obj).to.have.property('audit_by_auditor_count');",
18841+
" pm.expect(obj).to.have.property('total');",
18842+
" });",
18843+
" ",
18844+
"});"
18845+
],
18846+
"type": "text/javascript"
18847+
}
18848+
}
18849+
],
18850+
"request": {
18851+
"method": "GET",
18852+
"header": [],
18853+
"url": {
18854+
"raw": "{{baseUrl}}/resc/v1/metrics/audit-count-by-auditor-over-time",
18855+
"host": [
18856+
"{{baseUrl}}"
18857+
],
18858+
"path": [
18859+
"resc",
18860+
"v1",
18861+
"metrics",
18862+
"audit-count-by-auditor-over-time"
18863+
]
18864+
}
18865+
},
18866+
"response": []
1881818867
}
1881918868
]
1882018869
}

components/resc-backend/tests/resc_backend/resc_web_service/endpoints/test_metrics.py

+16
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
# First Party
1010
from resc_backend.constants import (
11+
RWS_ROUTE_AUDIT_COUNT_BY_AUDITOR_OVER_TIME,
1112
RWS_ROUTE_AUDITED_COUNT_OVER_TIME,
1213
RWS_ROUTE_COUNT_PER_VCS_PROVIDER_BY_WEEK,
1314
RWS_ROUTE_METRICS,
@@ -90,3 +91,18 @@ def test_convert_rows_to_finding_count_over_time(self):
9091
assert data[1].total == data2["finding_count"]+data3["finding_count"]
9192
assert data[0].time_period == f"{third_week.isocalendar().year} W{third_week.isocalendar().week:02d}"
9293
assert data[0].total == 0
94+
95+
@patch("resc_backend.resc_web_service.crud.audit.get_audit_count_by_auditor_over_time")
96+
def test_get_audit_count_by_auditor_over_time(self, get_audit_count_by_auditor_over_time):
97+
get_audit_count_by_auditor_over_time.return_value = {}
98+
response = self.client.get(f"{RWS_VERSION_PREFIX}{RWS_ROUTE_METRICS}"
99+
f"{RWS_ROUTE_AUDIT_COUNT_BY_AUDITOR_OVER_TIME}")
100+
101+
assert response.status_code == 200, response.text
102+
data = response.json()
103+
assert len(data) == 13
104+
first_week = datetime.utcnow() - timedelta(weeks=0)
105+
nth_week = datetime.utcnow() - timedelta(weeks=len(data)-1)
106+
assert data[len(data)-1]["time_period"] == f"{first_week.isocalendar().year} " \
107+
f"W{first_week.isocalendar().week:02d}"
108+
assert data[0]["time_period"] == f"{nth_week.isocalendar().year} W{nth_week.isocalendar().week:02d}"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<template>
2+
<div>
3+
<div class="col-md-2 pt-2 text-left page-title">
4+
<h3><small class="text-nowrap">Audit Metrics</small></h3>
5+
</div>
6+
<div class="pl-2">
7+
<div class="row">
8+
<div class="col-md-5 pt-2">
9+
<h5><small class="text-nowrap">Audits by Auditor per week</small></h5>
10+
<Spinner :active="!loadedAuditCounts" />
11+
<MultiLineChart
12+
v-if="loadedAuditCounts"
13+
:chart-data="chartDataForAuditCountsGraph"
14+
:chart-options="chartOptions"
15+
/>
16+
</div>
17+
</div>
18+
</div>
19+
</div>
20+
</template>
21+
22+
<script>
23+
import AxiosConfig from '@/configuration/axios-config.js';
24+
import FindingsService from '@/services/findings-service';
25+
import MultiLineChart from '@/components/Charts/MultiLineChart.vue';
26+
import Spinner from '@/components/Common/Spinner.vue';
27+
28+
export default {
29+
name: 'AuditMetrics',
30+
components: {
31+
MultiLineChart,
32+
Spinner,
33+
},
34+
data() {
35+
return {
36+
loadedAuditCounts: false,
37+
loadedAuditCountsAuditors: false,
38+
chartDataForAuditCountsGraph: { labels: [], datasets: [] },
39+
auditCounts: [],
40+
chartOptions: {
41+
responsive: true,
42+
maintainAspectRatio: false,
43+
},
44+
};
45+
},
46+
methods: {
47+
arrayContainsAllZeros(arr) {
48+
return arr.every((item) => item === 0);
49+
},
50+
getGraphData() {
51+
FindingsService.getAuditsByAuditorPerWeek()
52+
.then((response) => {
53+
this.auditCounts = response.data;
54+
let datasets = {};
55+
this.auditCounts.forEach((data) => {
56+
this.chartDataForAuditCountsGraph['labels'].push(data.time_period);
57+
58+
if (!this.loadedAuditCountsAuditors) {
59+
Object.entries(data.audit_by_auditor_count).forEach((auditorData) => {
60+
datasets[auditorData[0]] = this.prepareDataSet(auditorData[0], auditorData[1]);
61+
});
62+
datasets['Total'] = this.prepareDataSet('Total', data.total);
63+
this.loadedAuditCountsAuditors = true;
64+
} else {
65+
Object.entries(data.audit_by_auditor_count).forEach((auditorData) => {
66+
datasets[auditorData[0]].data.push(auditorData[1]);
67+
});
68+
datasets['Total'].data.push(data.total);
69+
}
70+
});
71+
Object.entries(datasets).forEach((auditorDataset) => {
72+
this.chartDataForAuditCountsGraph.datasets.push(auditorDataset[1]);
73+
});
74+
this.loadedAuditCounts = true;
75+
})
76+
.catch((error) => {
77+
AxiosConfig.handleError(error);
78+
});
79+
},
80+
prepareDataSet(datasetLabel, datasetFirstValue) {
81+
const datasetsObj = {};
82+
let colourCode = '#' + Math.floor(Math.random() * 16777215).toString(16);
83+
datasetsObj.borderWidth = 1.5;
84+
datasetsObj.cubicInterpolationMode = 'monotone';
85+
datasetsObj.data = [datasetFirstValue];
86+
datasetsObj.pointStyle = 'circle';
87+
datasetsObj.pointRadius = 3;
88+
datasetsObj.pointHoverRadius = 8;
89+
datasetsObj.label = datasetLabel;
90+
91+
if (datasetLabel === 'Total') {
92+
datasetsObj.hidden = true;
93+
}
94+
datasetsObj.borderColor = colourCode;
95+
datasetsObj.pointBackgroundColor = colourCode;
96+
datasetsObj.backgroundColor = colourCode;
97+
98+
return datasetsObj;
99+
},
100+
},
101+
mounted() {
102+
this.getGraphData();
103+
},
104+
};
105+
</script>

components/resc-frontend/src/components/Navigation/Navigation.vue

+12-2
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ export default {
1717
},
1818
child: [
1919
{
20-
href: '/metrics/rule-metrics',
21-
title: 'Rule Metrics',
20+
href: '/metrics/audit-metrics',
21+
title: 'Audit Metrics',
2222
icon: {
2323
element: 'font-awesome-icon',
2424
attributes: {
@@ -36,6 +36,16 @@ export default {
3636
},
3737
},
3838
},
39+
{
40+
href: '/metrics/rule-metrics',
41+
title: 'Rule Metrics',
42+
icon: {
43+
element: 'font-awesome-icon',
44+
attributes: {
45+
icon: 'chart-bar',
46+
},
47+
},
48+
},
3949
],
4050
},
4151
{

components/resc-frontend/src/router/index.js

+6
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Vue from 'vue';
22
import VueRouter from 'vue-router';
33
import Config from '@/configuration/config';
44
import FindingMetrics from '@/components/Metrics/FindingMetrics';
5+
import AuditMetrics from '@/components/Metrics/AuditMetrics';
56
import Store from '@/store/index.js';
67
import Analytics from '@/views/Analytics';
78
import Repositories from '@/views/Repositories';
@@ -49,6 +50,11 @@ const routes = [
4950
name: 'FindingMetrics',
5051
component: FindingMetrics,
5152
},
53+
{
54+
path: '/metrics/audit-metrics',
55+
name: 'AuditMetrics',
56+
component: AuditMetrics,
57+
},
5258
{
5359
path: '/rulepacks',
5460
name: 'RulePacks',

components/resc-frontend/src/services/findings-service.js

+3
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ const FindingsService = {
8787
async getTruePositiveCountPerVcsProviderPerWeek() {
8888
return axios.get(`/metrics/audited-count-over-time`);
8989
},
90+
async getAuditsByAuditorPerWeek() {
91+
return axios.get(`/metrics/audit-count-by-auditor-over-time`);
92+
},
9093

9194
async getFindingAudits(findingId, perPage, skipRowCount) {
9295
return axios.get(`findings/${findingId}/audit`, {

0 commit comments

Comments
 (0)