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 4, 2024
1 parent 6feef96 commit a6dce15
Show file tree
Hide file tree
Showing 19 changed files with 316 additions and 105 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
Expand Up @@ -51,19 +51,18 @@ def setUp(self):
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"]:
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(
{
"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 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
21 changes: 17 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 Down
44 changes: 43 additions & 1 deletion 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,49 @@ export function getSourceName(source, dataModel) {
return source.name || dataModel.sources[source.type].name
}

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

export function getSubjectTypeMetrics(subjectTypeKey, subjects) {
// Return the metric types supported by the specified subject type, and all parents of the subject type, recursively
if (Object.keys(subjects).includes(subjectTypeKey)) {
return subjects[subjectTypeKey].metrics
}
for (const childSubject of childSubjects(subjects)) {
const metrics = getSubjectTypeMetrics(subjectTypeKey, childSubject.subjects)
if (metrics) {
return [...childSubject.metrics, ...metrics]
}
}
}
getSubjectTypeMetrics.propTypes = {
subjectTypeKey: string,
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
43 changes: 43 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,47 @@ 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(["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(["second metric", "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
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,20 @@
import pathlib
from typing import Self

from pydantic import BaseModel, model_validator
from pydantic import model_validator

from .metric import Metric
from .scale import Scale
from .source import Source
from .subject import Subject
from .subject import SubjectContainer


class DataModel(BaseModel):
class DataModel(SubjectContainer):
"""Data model model."""

scales: dict[str, Scale]
metrics: dict[str, Metric]
sources: dict[str, Source]
subjects: dict[str, Subject]

@model_validator(mode="after")
def check_scales(self) -> Self:
Expand Down Expand Up @@ -137,7 +136,7 @@ def check_quality_time_source_types(self) -> None:
def check_subjects(self) -> Self:
"""Check that each metric belongs to at least one subject."""
for metric_key in self.metrics:
for subject in self.subjects.values():
for subject in self.all_subjects.values():
if metric_key in subject.metrics:
break
else:
Expand Down
23 changes: 21 additions & 2 deletions components/shared_code/src/shared_data_model/meta/subject.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
"""Data model subject."""

from pydantic import Field
from __future__ import annotations

from pydantic import BaseModel, Field

from .base import DescribedModel


class Subject(DescribedModel):
class SubjectContainer(BaseModel):
"""Base model for subject containers."""

subjects: dict[str, Subject] = Field(default_factory=dict)

@property
def all_subjects(self) -> dict[str, Subject]:
"""Return all subjects, recursively."""
all_subjects = {**self.subjects}
for subject in self.subjects.values():
all_subjects.update(**subject.all_subjects)
return all_subjects


class Subject(SubjectContainer, DescribedModel):
"""Base model for subjects."""

metrics: list[str] = Field(..., min_length=1)


SubjectContainer.model_rebuild()
Loading

0 comments on commit a6dce15

Please sign in to comment.