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 7, 2024
1 parent 1d4ff49 commit 60f4f92
Show file tree
Hide file tree
Showing 21 changed files with 394 additions and 116 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 @@ -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,25 +47,36 @@ 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."""
self.set_source_parameter("reports", ["r1", "r3"])
response = await self.collect(get_request_json_side_effect=[self.data_model, self.reports])
self.assert_measurement(
response,
Expand Down Expand Up @@ -94,12 +104,14 @@ async def test_nr_of_missing_metrics_without_correct_report(self):

async def test_subjects_to_ignore_by_name(self):
"""Test that the number of non-ignored missing metrics is returned when filtered by name."""
self.set_source_parameter("reports", ["r1", "r3"])
self.set_source_parameter("subjects_to_ignore", ["S2"])
response = await self.collect(get_request_json_side_effect=[self.data_model, self.reports])
self.assert_measurement(response, value=str(int(len(self.entities) / 2)), total=self.expected_software_metrics)

async def test_subjects_to_ignore_by_uuid(self):
"""Test that the number of non-ignored missing metrics is returned when filtered by uuid."""
self.set_source_parameter("reports", ["r1", "r3"])
first_subject_uuid = first(first(self.reports["reports"])["subjects"].keys())
self.set_source_parameter("subjects_to_ignore", [first_subject_uuid])
response = await self.collect(get_request_json_side_effect=[self.data_model, self.reports])
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
57 changes: 57 additions & 0 deletions components/frontend/src/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
getSourceName,
getStatusName,
getSubjectName,
getSubjectType,
getSubjectTypeMetrics,
getUserPermissions,
niceNumber,
nrMetricsInReport,
Expand Down Expand Up @@ -139,6 +141,61 @@ it("gets the source name from the data model if the source has no name", () => {
)
})

it("gets the subject type", () => {
const subject = { name: "Subject" }
expect(getSubjectType("subject", { subject: subject })).toStrictEqual({ name: "Subject" })
})

it("gets the subject type recursively", () => {
const subject = { name: "Subject" }
expect(getSubjectType("subject", { parent: { subjects: { subject: subject } } })).toStrictEqual({
name: "Subject",
})
})

it("gets the subject type recursively from the second subject type", () => {
const subject = { name: "Subject" }
expect(
getSubjectType("subject", {
first: { subjects: { foo: { name: "Foo" } } },
second: { subjects: { subject: subject } },
}),
).toStrictEqual(subject)
})

it("gets the subject type metrics", () => {
expect(getSubjectTypeMetrics("subject", { subject: { metrics: ["metric"] } })).toStrictEqual(["metric"])
})

it("gets the subject type metrics recursively", () => {
const metrics = getSubjectTypeMetrics("child", {
parent: { metrics: ["parent metric"], subjects: { child: { metrics: ["child metric"] } } },
})
expect(metrics).toStrictEqual(["child metric"])
})

it("gets the subject type metrics recursively, including child metrics", () => {
const metrics = getSubjectTypeMetrics("parent", {
parent: { metrics: ["parent metric"], subjects: { child: { metrics: ["child metric"] } } },
})
expect(metrics).toStrictEqual(["parent metric", "child metric"])
})

it("gets the subject type metrics recursively from the second subject type", () => {
const metrics = getSubjectTypeMetrics("child", {
first: { subjects: { foo: { metrics: ["foo metric"] } } },
second: { metrics: ["second metric"], subjects: { child: { metrics: ["child metric"] } } },
})
expect(metrics).toStrictEqual(["child metric"])
})

it("gets the subject type metrics deduplicated", () => {
const metrics = getSubjectTypeMetrics("parent", {
parent: { subjects: { child1: { metrics: ["child metric"] }, child2: { metrics: ["child metric"] } } },
})
expect(metrics).toStrictEqual(["child metric"])
})

it("gets the subject name", () => {
expect(getSubjectName({ name: "subject" }, {})).toStrictEqual("subject")
})
Expand Down
4 changes: 2 additions & 2 deletions components/frontend/src/widgets/menu_options.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { getMetricName, getSourceName, getSubjectName } from "../utils"
import { getMetricName, getSourceName, getSubjectName, getSubjectTypeMetrics } from "../utils"
import { ItemBreadcrumb } from "./ItemBreadcrumb"

export function metric_options(reports, dataModel, current_subject_type, current_subject_uuid) {
const subject_metrics = dataModel.subjects[current_subject_type].metrics
const subject_metrics = getSubjectTypeMetrics(current_subject_type, dataModel.subjects)
let options = []
reports.forEach((report) => {
Object.entries(report.subjects).forEach(([subject_uuid, subject]) => {
Expand Down
2 changes: 1 addition & 1 deletion components/notifier/src/models/metric_notification_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def __init__(
self.measurements = measurements
self.metric_name = metric["name"] or DATA_MODEL.metrics[metric["type"]].name
self.metric_unit = metric["unit"] or DATA_MODEL.metrics[metric["type"]].unit.value
self.subject_name = subject.get("name") or DATA_MODEL.subjects[subject["type"]].name
self.subject_name = subject.get("name") or DATA_MODEL.all_subjects[subject["type"]].name
self.scale = metric["scale"]
self.status = self.__status(LAST)
self.new_metric_value = self.__value(LAST)
Expand Down
Loading

0 comments on commit 60f4f92

Please sign in to comment.