From 86b9abe6f9c9d67cb01d0ab7831e6ac208213a6a Mon Sep 17 00:00:00 2001 From: Frank Niessink Date: Tue, 17 Oct 2023 11:03:17 +0200 Subject: [PATCH] Allow for showing all metrics on the reports overview page. Closes #7215. --- components/api_server/src/routes/__init__.py | 2 +- components/api_server/src/routes/pdf.py | 23 +++++ components/api_server/src/routes/report.py | 51 ++--------- .../api_server/src/routes/reports_overview.py | 7 ++ .../api_server/tests/routes/test_report.py | 90 ------------------- .../tests/routes/test_reports_overview.py | 13 ++- components/frontend/src/App.js | 9 +- components/frontend/src/AppUI.js | 30 +++++-- components/frontend/src/PageContent.js | 68 ++++++++------ .../frontend/src/__fixtures__/fixtures.js | 3 +- components/frontend/src/api/report.js | 3 +- .../frontend/src/header_footer/Menubar.js | 4 +- .../src/header_footer/SettingsPanel.js | 5 +- .../src/header_footer/SettingsPanel.test.js | 2 +- components/frontend/src/report/Report.js | 7 +- .../frontend/src/report/ReportsOverview.js | 85 +++++++++++++++--- .../src/report/ReportsOverview.test.js | 80 ++++++++++++++--- .../src/report/ReportsOverviewTitle.js | 4 + components/frontend/src/sharedPropTypes.js | 18 ++-- components/frontend/src/subject/Subject.js | 9 +- .../frontend/src/subject/Subject.test.js | 16 ++-- .../frontend/src/subject/SubjectTable.js | 29 ++++-- .../frontend/src/subject/SubjectTitle.js | 10 ++- components/frontend/src/subject/Subjects.js | 70 +++++++-------- .../frontend/src/subject/Subjects.test.js | 42 ++++++++- .../frontend/src/subject/SubjectsButtonRow.js | 5 +- components/frontend/src/utils.js | 8 +- components/frontend/src/utils.test.js | 22 ++--- components/frontend/src/widgets/Button.js | 8 +- .../frontend/src/widgets/Button.test.js | 8 +- .../shared_code/src/shared/model/report.py | 14 ++- .../shared_code/src/shared/model/subject.py | 12 +-- .../tests/shared/model/test_subject.py | 24 ----- docs/src/changelog.md | 1 + docs/src/usage.md | 8 ++ .../src/features/reports.feature | 5 ++ .../src/features/tagreport.feature | 17 ---- tests/feature_tests/src/steps/report.py | 4 +- tests/feature_tests/src/steps/tagreport.py | 25 ------ 39 files changed, 452 insertions(+), 389 deletions(-) create mode 100644 components/api_server/src/routes/pdf.py delete mode 100644 tests/feature_tests/src/features/tagreport.feature delete mode 100644 tests/feature_tests/src/steps/tagreport.py diff --git a/components/api_server/src/routes/__init__.py b/components/api_server/src/routes/__init__.py index a1e4371b6d..84d5d531c1 100644 --- a/components/api_server/src/routes/__init__.py +++ b/components/api_server/src/routes/__init__.py @@ -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 ( diff --git a/components/api_server/src/routes/pdf.py b/components/api_server/src/routes/pdf.py new file mode 100644 index 0000000000..6e2770570e --- /dev/null +++ b/components/api_server/src/routes/pdf.py @@ -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 diff --git a/components/api_server/src/routes/report.py b/components/api_server/src/routes/report.py index 5ea21fdb2b..c7c9e70147 100644 --- a/components/api_server/src/routes/report.py +++ b/components/api_server/src/routes/report.py @@ -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 @@ -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 @@ -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} @@ -140,16 +129,7 @@ def post_report_copy(database: Database, report: Report, report_uuid: ReportId): @bottle.get("/api/v3/report//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//json", authentication_required=True) @@ -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, - }, - ) diff --git a/components/api_server/src/routes/reports_overview.py b/components/api_server/src/routes/reports_overview.py index 6e3b65c4d0..990ec10837 100644 --- a/components/api_server/src/routes/reports_overview.py +++ b/components/api_server/src/routes/reports_overview.py @@ -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 @@ -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/", permissions_required=[EDIT_REPORT_PERMISSION]) def post_reports_overview_attribute(reports_attribute: str, database: Database): """Set a reports overview attribute.""" diff --git a/components/api_server/tests/routes/test_report.py b/components/api_server/tests/routes/test_report.py index f99270e446..5db08d6ed3 100644 --- a/components/api_server/tests/routes/test_report.py +++ b/components/api_server/tests/routes/test_report.py @@ -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 @@ -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"]) @@ -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)) diff --git a/components/api_server/tests/routes/test_reports_overview.py b/components/api_server/tests/routes/test_reports_overview.py index f165d69503..c89d494f81 100644 --- a/components/api_server/tests/routes/test_reports_overview.py +++ b/components/api_server/tests/routes/test_reports_overview.py @@ -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 @@ -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) diff --git a/components/frontend/src/App.js b/components/frontend/src/App.js index 44d1b268a5..b43a7e2598 100644 --- a/components/frontend/src/App.js +++ b/components/frontend/src/App.js @@ -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'; @@ -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()); } diff --git a/components/frontend/src/AppUI.js b/components/frontend/src/AppUI.js index 1d94ae55a3..b7c6fbca70 100644 --- a/components/frontend/src/AppUI.js +++ b/components/frontend/src/AppUI.js @@ -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, @@ -47,10 +48,10 @@ 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}` : "" @@ -58,7 +59,7 @@ export function AppUI({ 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"); @@ -95,7 +96,6 @@ export function AppUI({ const darkMode = userPrefersDarkMode(uiMode); const backgroundColor = darkMode ? "rgb(40, 40, 40)" : "white" - const atReportsOverview = report_uuid === "" return (
@@ -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 } \ No newline at end of file diff --git a/components/frontend/src/PageContent.js b/components/frontend/src/PageContent.js index 83b111013d..7179ddca6f 100644 --- a/components/frontend/src/PageContent.js +++ b/components/frontend/src/PageContent.js @@ -5,7 +5,7 @@ import { Segment } from './semantic_ui_react_wrappers'; import { Report } from './report/Report'; import { ReportsOverview } from './report/ReportsOverview'; import { get_measurements } from './api/measurement'; -import { metricsToHidePropType, stringsPropType } from './sharedPropTypes'; +import { datePropType, issueSettingsPropType, metricsToHidePropType, reportsPropType, sortDirectionPropType, stringsPropType } from './sharedPropTypes'; function getColumnDates(reportDate, dateInterval, dateOrder, nrDates) { const baseDate = reportDate ? new Date(reportDate) : new Date(); @@ -29,8 +29,8 @@ export function PageContent({ handleSort, hiddenColumns, hiddenTags, - metricsToHide, issueSettings, + metricsToHide, loading, nrDates, nrMeasurements, @@ -60,45 +60,63 @@ export function PageContent({ if (loading) { content = } else { + const commonProps = { + changed_fields: changed_fields, + dates: dates, + handleSort: handleSort, + hiddenColumns: hiddenColumns, + hiddenTags: hiddenTags, + issueSettings: issueSettings, + measurements: measurements, + metricsToHide: metricsToHide, + reload: reload, + reports: reports, + report_date: report_date, + sortColumn: sortColumn, + sortDirection: sortDirection, + toggleHiddenTag: toggleHiddenTag, + toggleVisibleDetailsTab: toggleVisibleDetailsTab, + visibleDetailsTabs: visibleDetailsTabs + } if (report_uuid) { content = } else { content = } } return {content} } PageContent.propTypes = { + changed_fields: stringsPropType, + current_report: PropTypes.object, + dateInterval: PropTypes.number, + dateOrder: sortDirectionPropType, + handleSort: PropTypes.func, + hiddenColumns: stringsPropType, hiddenTags: stringsPropType, + issueSettings: issueSettingsPropType, metricsToHide: metricsToHidePropType, + loading: PropTypes.bool, + nrDates: PropTypes.number, + nrMeasurements: PropTypes.number, + open_report: PropTypes.func, openReportsOverview: PropTypes.func, - toggleHiddenTag: PropTypes.func + reload: PropTypes.func, + report_date: datePropType, + report_uuid: PropTypes.string, + reports: reportsPropType, + reports_overview: PropTypes.object, + sortColumn: PropTypes.string, + sortDirection: sortDirectionPropType, + toggleHiddenTag: PropTypes.func, + toggleVisibleDetailsTab: PropTypes.func, + visibleDetailsTabs: stringsPropType } diff --git a/components/frontend/src/__fixtures__/fixtures.js b/components/frontend/src/__fixtures__/fixtures.js index 25b1f6b78c..dab539a18f 100644 --- a/components/frontend/src/__fixtures__/fixtures.js +++ b/components/frontend/src/__fixtures__/fixtures.js @@ -44,5 +44,6 @@ export const report = { } } } - } + }, + title: "Report title" } diff --git a/components/frontend/src/api/report.js b/components/frontend/src/api/report.js index 0a2d098c1e..1765796e73 100644 --- a/components/frontend/src/api/report.js +++ b/components/frontend/src/api/report.js @@ -33,7 +33,8 @@ export function set_reports_attribute(attribute, value, reload) { } export function get_report_pdf(report_uuid, query_string) { - return fetch_server_api('get', `report/${report_uuid}/pdf${query_string}`, {}, 'application/pdf') + const endpoint = (report_uuid ? `report/${report_uuid}` : "reports_overview") + `/pdf${query_string}` + return fetch_server_api('get', endpoint, {}, 'application/pdf') } export function get_report_issue_tracker_options(report_uuid) { diff --git a/components/frontend/src/header_footer/Menubar.js b/components/frontend/src/header_footer/Menubar.js index b2cfe8b406..013f37be6d 100644 --- a/components/frontend/src/header_footer/Menubar.js +++ b/components/frontend/src/header_footer/Menubar.js @@ -6,7 +6,7 @@ import FocusLock from 'react-focus-lock'; import { login, logout } from '../api/auth'; import { Avatar } from '../widgets/Avatar'; import { DatePicker } from '../widgets/DatePicker'; -import { datePropType, uiModePropType } from '../sharedPropTypes'; +import { datePropType, stringsPropType, uiModePropType } from '../sharedPropTypes'; import { UIModeMenu } from './UIModeMenu'; import './Menubar.css'; @@ -178,5 +178,5 @@ Menubar.propTypes = { setUIMode: PropTypes.func, uiMode: uiModePropType, user: PropTypes.string, - visibleDetailsTabs: PropTypes.array + visibleDetailsTabs: stringsPropType } diff --git a/components/frontend/src/header_footer/SettingsPanel.js b/components/frontend/src/header_footer/SettingsPanel.js index 9d0257e62a..a7c1de8e36 100644 --- a/components/frontend/src/header_footer/SettingsPanel.js +++ b/components/frontend/src/header_footer/SettingsPanel.js @@ -494,6 +494,7 @@ function ResetSettingsButton( visibleDetailsTabs } ) { + const metricsToHideDefault = atReportsOverview ? "all" : "none" return (