Skip to content

Commit

Permalink
Add more subject types.
Browse files Browse the repository at this point in the history
Closes #3130.
  • Loading branch information
fniessink committed Jun 8, 2024
1 parent 1d4ff49 commit e4c9b4e
Show file tree
Hide file tree
Showing 24 changed files with 395 additions and 122 deletions.
2 changes: 1 addition & 1 deletion components/api_server/src/model/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def copy_subject(subject, change_name: bool = True):
"metrics": {uuid(): copy_metric(metric, change_name=False) for metric in subject["metrics"].values()},
}
if change_name:
kwargs["name"] = f"{subject.get('name') or DATA_MODEL.subjects[subject['type']].name} (copy)"
kwargs["name"] = f"{subject.get('name') or DATA_MODEL.all_subjects[subject['type']].name} (copy)"
return copy_item(subject, **kwargs)


Expand Down
2 changes: 1 addition & 1 deletion components/api_server/src/model/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,5 @@ def default_report_attributes() -> dict[str, str | dict]:

def default_subject_attributes(subject_type: str) -> dict[str, Any]:
"""Return the default attributes with their default values for the specified subject type."""
subject = DATA_MODEL.subjects[subject_type]
subject = DATA_MODEL.all_subjects[subject_type]
return {"type": subject_type, "name": None, "description": subject.description, "metrics": {}}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ async def __get_metrics_and_entities(self, response: Response) -> list[tuple[dic
if self.__metric_is_to_be_measured(metric, metric_types, source_types, tags):
metric["report_uuid"] = report["report_uuid"]
metric["subject_uuid"] = subject_uuid
subject_name = str(subject.get("name") or DATA_MODEL.subjects[subject["type"]].name)
subject_name = str(subject.get("name") or DATA_MODEL.all_subjects[subject["type"]].name)
entity = Entity(key=metric_uuid, report=report["title"], subject=subject_name)
metrics_and_entities.append((metric, entity))
return metrics_and_entities
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""Quality-time missing metrics collector."""

from typing import cast

from collector_utilities.functions import match_string_or_regular_expression
from collector_utilities.type import URL
from model import SourceMeasurement, SourceResponses
Expand Down Expand Up @@ -103,4 +101,8 @@ def __subject_missing_metric_types(self, data_model: dict, subject: dict) -> lis
@staticmethod
def __subject_possible_metric_types(data_model: dict, subject: dict) -> list[str]:
"""Return the subject's possible metric types."""
return cast(list[str], data_model["subjects"][subject["type"]]["metrics"])
subject_type = data_model["subjects"][subject["type"]]
metric_types = set(subject_type.get("metrics", []))
for child_subject in subject_type.get("subjects", {}).values():
metric_types |= set(child_subject.get("metrics", []))
return sorted(metric_types)
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ def setUp(self):
"""Extend to set up fixtures for Quality-time metrics unit tests."""
super().setUp()
self.url = "https://quality_time"
self.set_source_parameter("reports", ["r1"])
self.set_source_parameter("status", ["target not met (red)"])
self.set_source_parameter("tags", ["security"])
self.set_source_parameter("metric_type", ["Tests", "Violations"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,12 @@ async def test_nr_of_metrics(self):

async def test_nr_of_metrics_without_reports(self):
"""Test that the number of metrics is returned."""
self.set_source_parameter("reports", [])
response = await self.collect(get_request_json_return_value={"reports": []})
self.assert_measurement(response, parse_error="No reports found")

async def test_nr_of_metrics_without_correct_report(self):
"""Test that the number of metrics is returned."""
self.reports["reports"].pop(0)
"""Test that an error is thrown for reports that don't exist."""
self.set_source_parameter("reports", ["r42"])
response = await self.collect(get_request_json_return_value=self.reports)
self.assert_measurement(response, parse_error="No reports found with title or id")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ def setUp(self):
"""Set up test data."""
super().setUp()
self.data_model = json.loads(DATA_MODEL_JSON)
self.set_source_parameter("reports", ["r1", "r3"])
self.expected_software_metrics = str(2 * len(self.data_model["subjects"]["software"]["metrics"]))
self.expected_software_metrics = str(2 * len(self.subject_metrics(self.data_model["subjects"]["software"])))
self.reports["reports"].append(
{
"title": "R3",
Expand Down Expand Up @@ -48,22 +47,32 @@ def setUp(self):
},
)
self.entities = []
metric_types = self.subject_metrics(self.data_model["subjects"]["software"])
for report in self.reports["reports"]:
for subject_uuid, subject in report.get("subjects", {}).items():
for metric_type in self.data_model["subjects"]["software"]["metrics"]:
for metric_type in metric_types:
if metric_type not in ["violations", "loc"]:
self.entities.append(
{
"key": f"{report['report_uuid']}:{subject_uuid}:{metric_type}",
"report": report["title"],
"report_url": f"https://quality_time/{report['report_uuid']}",
"subject": subject["name"],
"subject_url": f"https://quality_time/{report['report_uuid']}#{subject_uuid}",
"subject_uuid": f"{subject_uuid}",
"subject_type": self.data_model["subjects"][subject["type"]]["name"],
"metric_type": self.data_model["metrics"][metric_type]["name"],
},
)
self.entities.append(self.create_entity(report, subject_uuid, subject, metric_type))

def subject_metrics(self, subject_type) -> list[str]:
"""Return the metric types supported by the subject type."""
metric_types = set(subject_type.get("metrics", []))
for child_subject_type in subject_type.get("subjects", {}).values():
metric_types |= set(child_subject_type.get("metrics", []))
return sorted(metric_types)

def create_entity(self, report, subject_uuid: str, subject, metric_type: str) -> dict[str, str]:
"""Create a missing metric entity."""
return {
"key": f"{report['report_uuid']}:{subject_uuid}:{metric_type}",
"report": report["title"],
"report_url": f"https://quality_time/{report['report_uuid']}",
"subject": subject["name"],
"subject_url": f"https://quality_time/{report['report_uuid']}#{subject_uuid}",
"subject_uuid": f"{subject_uuid}",
"subject_type": self.data_model["subjects"][subject["type"]]["name"],
"metric_type": self.data_model["metrics"][metric_type]["name"],
}

async def test_nr_of_metrics(self):
"""Test that the number of missing metrics is returned."""
Expand All @@ -77,7 +86,6 @@ async def test_nr_of_metrics(self):

async def test_nr_of_missing_metrics_without_reports(self):
"""Test that no reports in the parameter equals all reports."""
self.set_source_parameter("reports", [])
response = await self.collect(get_request_json_side_effect=[self.data_model, self.reports])
self.assert_measurement(
response,
Expand All @@ -88,7 +96,7 @@ async def test_nr_of_missing_metrics_without_reports(self):

async def test_nr_of_missing_metrics_without_correct_report(self):
"""Test that an error is thrown for reports that don't exist."""
self.reports["reports"] = []
self.set_source_parameter("reports", ["r42"])
response = await self.collect(get_request_json_side_effect=[self.data_model, self.reports])
self.assert_measurement(response, parse_error="No reports found with title or id")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def setUp(self):

async def test_source_up_to_dateness(self):
"""Test that the source up-to-dateness of all reports can be measured."""
self.set_source_parameter("reports", ["r1"])
response = await self.collect(get_request_json_return_value=self.reports)
expected_age = days_ago(parse("2020-06-24T07:53:17+00:00"))
self.assert_measurement(response, value=str(expected_age), total="100", entities=[])
Expand Down
5 changes: 4 additions & 1 deletion components/frontend/src/metric/MetricType.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DataModel } from "../context/DataModel"
import { EDIT_REPORT_PERMISSION } from "../context/Permissions"
import { SingleChoiceInput } from "../fields/SingleChoiceInput"
import { Header } from "../semantic_ui_react_wrappers"
import { getSubjectTypeMetrics } from "../utils"

export function metricTypeOption(key, metricType) {
return {
Expand All @@ -18,7 +19,9 @@ export function metricTypeOption(key, metricType) {

export function metricTypeOptions(dataModel, subjectType) {
// Return menu options for all metric that support the subject type
return dataModel.subjects[subjectType].metrics.map((key) => metricTypeOption(key, dataModel.metrics[key]))
return getSubjectTypeMetrics(subjectType, dataModel.subjects).map((key) =>
metricTypeOption(key, dataModel.metrics[key]),
)
}

export function allMetricTypeOptions(dataModel) {
Expand Down
10 changes: 10 additions & 0 deletions components/frontend/src/sharedPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,19 @@ export const metricTypePropType = shape({
name: string,
})

// Construct a recursive prop type for the subject type
const subjectTypeShape = {
description: string,
metrics: stringsPropType,
name: string,
}
subjectTypeShape.subjects = arrayOf(shape(subjectTypeShape))
export const subjectTypePropType = shape(subjectTypeShape)

export const dataModelPropType = shape({
metrics: objectOf(metricTypePropType),
sources: objectOf(sourceTypePropType),
subjects: objectOf(subjectTypePropType),
})

export const destinationPropType = shape({
Expand Down
4 changes: 2 additions & 2 deletions components/frontend/src/subject/SubjectTitle.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { EDIT_REPORT_PERMISSION, ReadOnlyOrEditable } from "../context/Permissio
import { Header, Tab } from "../semantic_ui_react_wrappers"
import { Share } from "../share/Share"
import { reportPropType, settingsPropType } from "../sharedPropTypes"
import { slugify } from "../utils"
import { getSubjectType, slugify } from "../utils"
import { DeleteButton, ReorderButtonGroup } from "../widgets/Button"
import { FocusableTab } from "../widgets/FocusableTab"
import { HeaderWithDetails } from "../widgets/HeaderWithDetails"
Expand Down Expand Up @@ -76,7 +76,7 @@ export function SubjectTitle({
}) {
const dataModel = useContext(DataModel)
const tabIndex = activeTabIndex(settings.expandedItems, subject_uuid)
const subjectType = dataModel.subjects[subject.type] || { name: "Unknown subject type" }
const subjectType = getSubjectType(subject.type, dataModel.subjects) || { name: "Unknown subject type" }
const subjectName = subject.name || subjectType.name
const subjectTitle = (atReportsOverview ? report.title + " ❯ " : "") + subjectName
const subjectUrl = `${window.location}#${subject_uuid}`
Expand Down
22 changes: 18 additions & 4 deletions components/frontend/src/subject/SubjectType.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
import { func, objectOf, string } from "prop-types"
import { func, number, objectOf, string } from "prop-types"
import { useContext } from "react"
import { HeaderContent, HeaderSubheader } from "semantic-ui-react"

import { DataModel } from "../context/DataModel"
import { EDIT_REPORT_PERMISSION } from "../context/Permissions"
import { SingleChoiceInput } from "../fields/SingleChoiceInput"
import { Header } from "../semantic_ui_react_wrappers"
import { Header, Icon } from "../semantic_ui_react_wrappers"
import { subjectPropType } from "../sharedPropTypes"

export function subjectTypes(subjectTypesMapping) {
export function subjectTypes(subjectTypesMapping, level = 0) {
const options = []
const headingLevel = `h${Math.min(level, 2) + 4}` // Ensure the heading level is at least h4 and at most h6
const bullet = level === 0 ? null : <Icon name="circle" size="tiny" style={{ paddingLeft: `${level}em` }} />
Object.entries(subjectTypesMapping).forEach(([key, subjectType]) => {
options.push({
key: key,
text: subjectType.name,
value: key,
content: <Header as="h4" content={subjectType.name} subheader={subjectType.description} />,
content: (
<Header as={headingLevel}>
{bullet}
<HeaderContent>
{subjectType.name}
<HeaderSubheader>{subjectType.description}</HeaderSubheader>
</HeaderContent>
</Header>
),
})
options.push(...subjectTypes(subjectType?.subjects ?? [], level + 1))
})
return options
}
subjectTypes.propTypes = {
subjectTypesMapping: objectOf(subjectPropType),
level: number,
}

export function SubjectType({ subjectType, setValue }) {
Expand All @@ -30,6 +43,7 @@ export function SubjectType({ subjectType, setValue }) {
label="Subject type"
options={subjectTypes(useContext(DataModel).subjects)}
set_value={(value) => setValue(value)}
sort={false}
value={subjectType}
/>
)
Expand Down
54 changes: 52 additions & 2 deletions components/frontend/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
reportsPropType,
scalePropType,
stringsPropType,
subjectTypePropType,
} from "./sharedPropTypes"
import { HyperLink } from "./widgets/HyperLink"

Expand Down Expand Up @@ -75,8 +76,56 @@ export function getSourceName(source, dataModel) {
return source.name || dataModel.sources[source.type].name
}

function allMetrics(subject) {
// Return all metrics of the subject, recursively
const metrics = [...(subject.metrics ?? [])]
Object.values(subject.subjects ?? {}).forEach((childSubject) => metrics.push(...allMetrics(childSubject)))
return metrics
}

export function getSubjectTypeMetrics(subjectTypeKey, subjects) {
// Return the metric types supported by the specified subject type
const metrics = []
Object.entries(subjects ?? {}).forEach(([key, subject]) => {
if (key === subjectTypeKey) {
metrics.push(...allMetrics(subject))
} else {
metrics.push(...getSubjectTypeMetrics(subjectTypeKey, subject.subjects))
}
})
return Array.from(new Set(metrics))
}
getSubjectTypeMetrics.propTypes = {
subjectTypeKey: string,
subjects: objectOf(subjectTypePropType),
}

function childSubjects(subjects) {
return Object.values(subjects).filter((subject) => !!subject.subjects)
}
childSubjects.propTypes = {
subjects: objectOf(subjectTypePropType),
}

export function getSubjectType(subjectTypeKey, subjects) {
// Return the subject type object
if (Object.keys(subjects).includes(subjectTypeKey)) {
return subjects[subjectTypeKey]
}
for (const childSubject of childSubjects(subjects)) {
const result = getSubjectType(subjectTypeKey, childSubject.subjects)
if (result) {
return result
}
}
}
getSubjectType.propTypes = {
subjectTypeKey: string,
subjects: objectOf(subjectTypePropType),
}

export function getSubjectName(subject, dataModel) {
return subject.name || dataModel.subjects[subject.type].name
return subject.name || getSubjectType(subject.type, dataModel.subjects).name
}

export function getMetricTarget(metric) {
Expand Down Expand Up @@ -386,7 +435,8 @@ export function dropdownOptions(options) {
}

export function slugify(name) {
return `#${name?.toLowerCase().replaceAll(" ", "-").replaceAll("(", "").replaceAll(")", "")}`
// The hash isn't really part of the slug, but to prevent duplication it is included anyway
return `#${name?.toLowerCase().replaceAll(" ", "-").replaceAll("(", "").replaceAll(")", "").replaceAll("/", "")}`
}

export function sum(object) {
Expand Down
Loading

0 comments on commit e4c9b4e

Please sign in to comment.