Skip to content

Commit

Permalink
Allow for showing all metrics on the reports overview page. Closes #7215
Browse files Browse the repository at this point in the history
.
  • Loading branch information
fniessink committed Oct 20, 2023
1 parent 323009b commit 86b9abe
Show file tree
Hide file tree
Showing 39 changed files with 452 additions and 389 deletions.
2 changes: 1 addition & 1 deletion components/api_server/src/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
post_report_issue_tracker_attribute,
post_report_new,
)
from .reports_overview import get_reports_overview, post_reports_overview_attribute
from .reports_overview import export_reports_overview_as_pdf, get_reports_overview, post_reports_overview_attribute
from .server import get_server, QUALITY_TIME_VERSION
from .settings import get_settings, update_settings
from .source import (
Expand Down
23 changes: 23 additions & 0 deletions components/api_server/src/routes/pdf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Shared code to export reports overview and reports to PDF."""

import os
from urllib import parse

import bottle
import requests

from shared.utils.type import ReportId


def export_as_pdf(report_uuid: ReportId | None = None):
"""Export the URL as PDF."""
renderer_host = os.environ.get("RENDERER_HOST", "renderer")
renderer_port = os.environ.get("RENDERER_PORT", "9000")
render_url = f"http://{renderer_host}:{renderer_port}/api/render"
# Tell the frontend to not display toast messages to prevent them from being included in the PDF:
query_string = "?hide_toasts=true" + (f"&{bottle.request.query_string}" if bottle.request.query_string else "")
path = parse.quote(f"{report_uuid or ''}{query_string}")
response = requests.get(f"{render_url}?path={path}", timeout=120)
response.raise_for_status()
bottle.response.content_type = "application/pdf"
return response.content
51 changes: 6 additions & 45 deletions components/api_server/src/routes/report.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
"""Report routes."""

import os
from collections.abc import Callable
from functools import partial, wraps
from http import HTTPStatus
from typing import TypeVar, cast
from urllib import parse

import bottle
import requests
from pymongo.database import Database

from shared.utils.functions import iso_timestamp
from shared.utils.type import ReportId
from shared_data_model import DATA_MODEL
from shared_data_model.parameters import PrivateToken
Expand All @@ -30,6 +26,7 @@
)
from utils.functions import DecryptionError, check_url_availability, report_date_time, sanitize_html, uuid

from .pdf import export_as_pdf
from .plugins.auth_plugin import EDIT_REPORT_PERMISSION


Expand Down Expand Up @@ -68,20 +65,12 @@ def get_report(database: Database, report_uuid: ReportId | None = None):
data_model = latest_datamodel(database, date_time)
reports = latest_reports_before_timestamp(database, data_model, date_time)
summarized_reports = []

if report_uuid and report_uuid.startswith("tag-"):
report = tag_report(data_model, report_uuid[4:], reports)
if len(report.subjects) > 0:
for report in reports:
if not report_uuid or report["report_uuid"] == report_uuid:
measurements = recent_measurements(database, report.metrics_dict, date_time)
summarized_reports.append(report.summarize(measurements))
else:
for report in reports:
if not report_uuid or report["report_uuid"] == report_uuid:
measurements = recent_measurements(database, report.metrics_dict, date_time)
summarized_reports.append(report.summarize(measurements))
else:
summarized_reports.append(report)

else:
summarized_reports.append(report)
hide_credentials(data_model, *summarized_reports)
return {"ok": True, "reports": summarized_reports}

Expand Down Expand Up @@ -140,16 +129,7 @@ def post_report_copy(database: Database, report: Report, report_uuid: ReportId):
@bottle.get("/api/v3/report/<report_uuid>/pdf", authentication_required=False)
def export_report_as_pdf(report_uuid: ReportId):
"""Download the report as PDF."""
renderer_host = os.environ.get("RENDERER_HOST", "renderer")
renderer_port = os.environ.get("RENDERER_PORT", "9000")
render_url = f"http://{renderer_host}:{renderer_port}/api/render"
# Tell the frontend to not display toast messages to prevent them from being included in the PDF:
query_string = "?hide_toasts=true" + (f"&{bottle.request.query_string}" if bottle.request.query_string else "")
report_path = parse.quote(f"{report_uuid}{query_string}")
response = requests.get(f"{render_url}?path={report_path}", timeout=120)
response.raise_for_status()
bottle.response.content_type = "application/pdf"
return response.content
return export_as_pdf(report_uuid)


@bottle.get("/api/v3/report/<report_uuid>/json", authentication_required=True)
Expand Down Expand Up @@ -246,22 +226,3 @@ def get_report_issue_tracker_options(database: Database, report: Report): # noq
"""Get options for the issue tracker attributes such as project key and issue type."""
issue_tracker = report.issue_tracker()
return issue_tracker.get_options().as_dict() | {"ok": True}


def tag_report(data_model, tag: str, reports: list[Report]) -> Report:
"""Create a report for a tag."""
subjects = {}
for report in reports:
for subject in report.subjects:
if tag_subject := subject.tag_subject(tag):
subjects[subject.uuid] = tag_subject

return Report(
data_model,
{
"title": f'Report for tag "{tag}"',
"report_uuid": f"tag-{tag}",
"timestamp": iso_timestamp(),
"subjects": subjects,
},
)
7 changes: 7 additions & 0 deletions components/api_server/src/routes/reports_overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from database.reports import insert_new_reports_overview, latest_reports_overview
from utils.functions import report_date_time, sanitize_html

from .pdf import export_as_pdf
from .plugins.auth_plugin import EDIT_REPORT_PERMISSION


Expand All @@ -16,6 +17,12 @@ def get_reports_overview(database: Database):
return latest_reports_overview(database, report_date_time())


@bottle.get("/api/v3/reports_overview/pdf", authentication_required=False)
def export_reports_overview_as_pdf():
"""Download the reports overview as PDF."""
return export_as_pdf()


@bottle.post("/api/v3/reports_overview/attribute/<reports_attribute>", permissions_required=[EDIT_REPORT_PERMISSION])
def post_reports_overview_attribute(reports_attribute: str, database: Database):
"""Set a reports overview attribute."""
Expand Down
90 changes: 0 additions & 90 deletions components/api_server/tests/routes/test_report.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Unit tests for the report routes."""

from datetime import datetime, UTC
from typing import cast
from unittest.mock import Mock, patch
import copy

Expand Down Expand Up @@ -438,84 +436,6 @@ def test_issue_status(self):
report = get_report(self.database, REPORT_ID)["reports"][0]
self.assertEqual(issue_status, report["subjects"][SUBJECT_ID]["metrics"][METRIC_ID]["issue_status"][0])

@patch("shared.utils.functions.datetime")
def test_get_tag_report(self, date_time):
"""Test that a tag report can be retrieved."""
date_time.now.return_value = now = datetime.now(tz=UTC)
self.database.reports.find_one.return_value = {
"_id": "id",
"report_uuid": REPORT_ID,
"title": "Report",
"subjects": {
"subject_without_metrics": {"metrics": {}},
SUBJECT_ID: {
"name": "Subject",
"type": "software",
"metrics": {
"metric_with_tag": {"type": "violations", "tags": ["tag"]},
"metric_without_tag": {"type": "violations", "tags": ["other tag"]},
},
},
},
}
expected_counts = {"blue": 0, "red": 0, "green": 0, "yellow": 0, "grey": 0, "white": 1}
self.assertDictEqual(
{
"ok": True,
"reports": [
{
"summary": expected_counts,
"title": 'Report for tag "tag"',
"report_uuid": "tag-tag",
"timestamp": now.replace(microsecond=0).isoformat(),
"subjects": {
SUBJECT_ID: {
"name": "Report ❯ Subject", # noqa: RUF001
"type": "software",
"metrics": {
"metric_with_tag": {
"status": None,
"status_start": None,
"scale": "count",
"sources": {},
"recent_measurements": [],
"latest_measurement": None,
"type": "violations",
"tags": ["tag"],
},
},
},
},
},
],
},
get_report(self.database, "tag-tag"),
)

@patch("shared.utils.functions.datetime")
def test_no_empty_tag_report(self, date_time):
"""Test that empty tag reports are omitted."""
date_time.now.return_value = datetime.now(tz=UTC)
self.database.reports.find.return_value = [
{
"_id": "id",
"report_uuid": REPORT_ID,
"title": "Report",
"subjects": {
"subject_without_metrics": {"metrics": {}},
SUBJECT_ID: {
"name": "Subject",
"type": "software",
"metrics": {
"metric_with_tag": {"type": "metric_type", "tags": ["tag"]},
"metric_without_tag": {"type": "metric_type", "tags": ["other tag"]},
},
},
},
},
]
self.assertDictEqual({"ok": True, "reports": []}, get_report(self.database, "tag-non-existing-tag"))

def test_add_report(self):
"""Test that a report can be added."""
self.assertTrue(post_report_new(self.database)["ok"])
Expand Down Expand Up @@ -564,16 +484,6 @@ def test_get_pdf_report(self, requests_get):
timeout=120,
)

@patch("requests.get")
def test_get_pdf_tag_report(self, requests_get):
"""Test that a PDF version of a tag report can be retrieved."""
requests_get.return_value = Mock(content=b"PDF")
self.assertEqual(b"PDF", export_report_as_pdf(cast(ReportId, "tag-security")))
requests_get.assert_called_once_with(
"http://renderer:9000/api/render?path=tag-security%3Fhide_toasts%3Dtrue",
timeout=120,
)

def test_delete_report(self):
"""Test that the report can be deleted."""
self.assertEqual({"ok": True}, delete_report(self.database, REPORT_ID))
Expand Down
13 changes: 11 additions & 2 deletions components/api_server/tests/routes/test_reports_overview.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Unit tests for the reports routes."""

from unittest.mock import patch
from unittest.mock import Mock, patch

from routes import get_reports_overview, post_reports_overview_attribute
from routes import export_reports_overview_as_pdf, get_reports_overview, post_reports_overview_attribute
from routes.plugins.auth_plugin import EDIT_ENTITY_PERMISSION, EDIT_REPORT_PERMISSION

from tests.base import DataModelTestCase
Expand Down Expand Up @@ -136,3 +136,12 @@ def test_can_remove_own_edit_entity_permission(self, request):
def test_get_reports_overview(self):
"""Test that a report can be retrieved and credentials are hidden."""
self.assertEqual({"_id": "id", "title": "Reports", "subtitle": ""}, get_reports_overview(self.database))

@patch("requests.get")
def test_get_pdf_report(self, requests_get):
"""Test that a PDF version of the reports overview can be retrieved."""
response = Mock()
response.content = b"PDF"
requests_get.return_value = response
self.assertEqual(b"PDF", export_reports_overview_as_pdf())
requests_get.assert_called_once_with("http://renderer:9000/api/render?path=%3Fhide_toasts%3Dtrue", timeout=120)
9 changes: 1 addition & 8 deletions components/frontend/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { get_report, get_reports_overview } from './api/report';
import { nr_measurements_api } from './api/measurement';
import { login } from './api/auth';
import { showMessage, showConnectionMessage } from './widgets/toast';
import { isValidDate_YYYYMMDD, registeredURLSearchParams, reportIsTagReport, toISODateStringInCurrentTZ } from './utils'
import { isValidDate_YYYYMMDD, registeredURLSearchParams, toISODateStringInCurrentTZ } from './utils'
import { AppUI } from './AppUI';
import 'react-toastify/dist/ReactToastify.css';
import './App.css';
Expand Down Expand Up @@ -132,13 +132,6 @@ class App extends Component {
open_report(event, report_uuid) {
event.preventDefault();
this.history_push(encodeURI(report_uuid))
if (reportIsTagReport(report_uuid)) {
showMessage(
"info",
"Tag reports are read-only",
"You opened a report for a specific metric tag. These reports are generated dynamically. Editing is not possible."
)
}
this.setState({ report_uuid: report_uuid, loading: true }, () => this.reload());
}

Expand Down
30 changes: 22 additions & 8 deletions components/frontend/src/AppUI.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { DataModel } from './context/DataModel';
import { DarkMode } from './context/DarkMode';
import { Permissions } from './context/Permissions';
import { PageContent } from './PageContent';
import { getReportsTags, getUserPermissions, reportIsTagReport, userPrefersDarkMode, useURLSearchQuery } from './utils'
import { getReportsTags, getUserPermissions, userPrefersDarkMode, useURLSearchQuery } from './utils'
import { datePropType, reportsPropType, stringsPropType } from './sharedPropTypes';

export function AppUI({
changed_fields,
Expand Down Expand Up @@ -47,18 +48,18 @@ export function AppUI({
return () => mediaQueryList.removeEventListener("change", changeMode);
}, [uiMode, setUIMode]);

const user_permissions = getUserPermissions(
user, email, reportIsTagReport(report_uuid), report_date, reports_overview.permissions || {}
)
const current_report = reports.filter((report) => report.report_uuid === report_uuid)[0] || null;
const user_permissions = getUserPermissions(user, email, report_date, reports_overview.permissions || {})
const atReportsOverview = report_uuid === ""
const current_report = atReportsOverview ? null : reports.filter((report) => report.report_uuid === report_uuid)[0];
const metricsToHideDefault = atReportsOverview ? "all" : "none"
// Make the settings changeable per report (and separately for the reports overview) by adding the report UUID as
// postfix to the settings key:
const urlSearchQueryKeyPostfix = report_uuid ? `_${report_uuid}` : ""
const [dateInterval, setDateInterval] = useURLSearchQuery("date_interval" + urlSearchQueryKeyPostfix, "integer", 7);
const [dateOrder, setDateOrder] = useURLSearchQuery("date_order" + urlSearchQueryKeyPostfix, "string", "descending");
const [hiddenColumns, toggleHiddenColumn, clearHiddenColumns] = useURLSearchQuery("hidden_columns" + urlSearchQueryKeyPostfix, "array");
const [hiddenTags, toggleHiddenTag, clearHiddenTags] = useURLSearchQuery("hidden_tags" + urlSearchQueryKeyPostfix, "array");
const [metricsToHide, setMetricsToHide] = useURLSearchQuery("metrics_to_hide" + urlSearchQueryKeyPostfix, "string", "none");
const [metricsToHide, setMetricsToHide] = useURLSearchQuery("metrics_to_hide" + urlSearchQueryKeyPostfix, "string", metricsToHideDefault);
const [nrDates, setNrDates] = useURLSearchQuery("nr_dates" + urlSearchQueryKeyPostfix, "integer", 1);
const [sortColumn, setSortColumn] = useURLSearchQuery("sort_column" + urlSearchQueryKeyPostfix, "string", null);
const [sortDirection, setSortDirection] = useURLSearchQuery("sort_direction" + urlSearchQueryKeyPostfix, "string", "ascending");
Expand Down Expand Up @@ -95,7 +96,6 @@ export function AppUI({

const darkMode = userPrefersDarkMode(uiMode);
const backgroundColor = darkMode ? "rgb(40, 40, 40)" : "white"
const atReportsOverview = report_uuid === ""
return (
<div style={{ display: "flex", minHeight: "100vh", flexDirection: "column", backgroundColor: backgroundColor }}>
<DarkMode.Provider value={darkMode}>
Expand Down Expand Up @@ -182,5 +182,19 @@ export function AppUI({
)
}
AppUI.propTypes = {
openReportsOverview: PropTypes.func
changed_fields: stringsPropType,
datamodel: PropTypes.object,
email: PropTypes.string,
handleDateChange: PropTypes.func,
last_update: datePropType,
loading: PropTypes.bool,
open_report: PropTypes.func,
openReportsOverview: PropTypes.func,
reload: PropTypes.func,
report_date: datePropType,
report_uuid: PropTypes.string,
reports: reportsPropType,
reports_overview: PropTypes.object,
set_user: PropTypes.func,
user: PropTypes.string
}
Loading

0 comments on commit 86b9abe

Please sign in to comment.