diff --git a/components/api_server/src/initialization/migrations.py b/components/api_server/src/initialization/migrations.py index 73d764501a..4ec2dd93a2 100644 --- a/components/api_server/src/initialization/migrations.py +++ b/components/api_server/src/initialization/migrations.py @@ -2,32 +2,39 @@ import logging +import pymongo +from pymongo.collection import Collection from pymongo.database import Database +from shared.model.metric import Metric + def perform_migrations(database: Database) -> None: # pragma: no feature-test-cover """Perform database migrations.""" - change_accessibility_violation_metrics_to_violations(database) - fix_branch_parameters_without_value(database) + for report in database.reports.find(filter={"last": True, "deleted": {"$exists": False}}): + change_accessibility_violation_metrics_to_violations(database, report) + fix_branch_parameters_without_value(database, report) + add_source_parameter_hash(database, report) -def change_accessibility_violation_metrics_to_violations(database: Database) -> None: # pragma: no feature-test-cover +def change_accessibility_violation_metrics_to_violations( # pragma: no feature-test-cover + database: Database, report +) -> None: """Replace accessibility metrics with the violations metric.""" # Added after Quality-time v5.5.0, see https://github.com/ICTU/quality-time/issues/562 - for report in database.reports.find(filter={"last": True, "deleted": {"$exists": False}}): - report_uuid = report["report_uuid"] - logging.info("Checking report for accessibility metrics: %s", report_uuid) - changed = False - for subject in report["subjects"].values(): - for metric in subject["metrics"].values(): - if metric["type"] == "accessibility": - change_accessibility_violations_metric_to_violations(metric) - changed = True - if changed: - logging.info("Updating report to change its accessibility metrics to violations metrics: %s", report_uuid) - replace_report(database, report) - else: - logging.info("No accessibility metrics found in report: %s", report_uuid) + report_uuid = report["report_uuid"] + logging.info("Checking report for accessibility metrics: %s", report_uuid) + changed = False + for subject in report["subjects"].values(): + for metric in subject["metrics"].values(): + if metric["type"] == "accessibility": + change_accessibility_violations_metric_to_violations(metric) + changed = True + if changed: + logging.info("Updating report to change its accessibility metrics to violations metrics: %s", report_uuid) + replace_document(database.reports, report) + else: + logging.info("No accessibility metrics found in report: %s", report_uuid) def change_accessibility_violations_metric_to_violations(metric: dict) -> None: # pragma: no feature-test-cover @@ -39,22 +46,21 @@ def change_accessibility_violations_metric_to_violations(metric: dict) -> None: metric["unit"] = "accessibility violations" -def fix_branch_parameters_without_value(database: Database) -> None: # pragma: no feature-test-cover +def fix_branch_parameters_without_value(database: Database, report) -> None: # pragma: no feature-test-cover """Set the branch parameter of sources to 'master' (the previous default) if they have no value.""" # Added after Quality-time v5.11.0, see https://github.com/ICTU/quality-time/issues/8045 - for report in database.reports.find(filter={"last": True, "deleted": {"$exists": False}}): - report_uuid = report["report_uuid"] - logging.info("Checking report for sources with empty branch parameters: %s", report_uuid) - changed = False - for source in sources_with_branch_parameter(report): - if not source["parameters"].get("branch"): - source["parameters"]["branch"] = "master" - changed = True - if changed: - logging.info("Updating report to change sources with empty branch parameter: %s", report_uuid) - replace_report(database, report) - else: - logging.info("No sources with empty branch parameters found in report: %s", report_uuid) + report_uuid = report["report_uuid"] + logging.info("Checking report for sources with empty branch parameters: %s", report_uuid) + changed = False + for source in sources_with_branch_parameter(report): + if not source["parameters"].get("branch"): + source["parameters"]["branch"] = "master" + changed = True + if changed: + logging.info("Updating report to change sources with empty branch parameter: %s", report_uuid) + replace_document(database.reports, report) + else: + logging.info("No sources with empty branch parameters found in report: %s", report_uuid) METRICS_WITH_SOURCES_WITH_BRANCH_PARAMETER = { @@ -87,8 +93,25 @@ def sources_with_branch_parameter(report: dict): # pragma: no feature-test-cove yield source -def replace_report(database: Database, report) -> None: # pragma: no feature-test-cover - """Replace the report in the database.""" - report_id = report["_id"] - del report["_id"] - database.reports.replace_one({"_id": report_id}, report) +def add_source_parameter_hash(database: Database, report) -> None: # pragma: no feature-test-cover + """Add source parameter hashes to the latest measurements.""" + # Added after Quality-time v5.12.0, see https://github.com/ICTU/quality-time/issues/8736 + for subject in report["subjects"].values(): + for metric_uuid, metric in subject["metrics"].items(): + latest_measurement = database.measurements.find_one( + filter={"metric_uuid": metric_uuid}, + sort=[("start", pymongo.DESCENDING)], + ) + if not latest_measurement: + continue + if latest_measurement.get("source_parameter_hash"): + continue + latest_measurement["source_parameter_hash"] = Metric({}, metric, metric_uuid).source_parameter_hash() + replace_document(database.measurements, latest_measurement) + + +def replace_document(collection: Collection, document) -> None: # pragma: no feature-test-cover + """Replace the document in the collection.""" + document_id = document["_id"] + del document["_id"] + collection.replace_one({"_id": document_id}, document) diff --git a/components/api_server/tests/initialization/test_migrations.py b/components/api_server/tests/initialization/test_migrations.py index 4193dca145..990e7c479f 100644 --- a/components/api_server/tests/initialization/test_migrations.py +++ b/components/api_server/tests/initialization/test_migrations.py @@ -27,6 +27,22 @@ def inserted_report(self, **kwargs): return report +class NoOpMigrationTest(MigrationTestCase): + """Unit tests for empty database and empty reports.""" + + def test_no_reports(self): + """Test that the migration succeeds without reports.""" + self.database.reports.find.return_value = [] + perform_migrations(self.database) + self.database.reports.replace_one.assert_not_called() + + def test_empty_reports(self): + """Test that the migration succeeds when the report does not have anything to migrate.""" + self.database.reports.find.return_value = [self.existing_report("issues")] + perform_migrations(self.database) + self.database.reports.replace_one.assert_not_called() + + class ChangeAccessibilityViolationsTest(MigrationTestCase): """Unit tests for the accessibility violations database migration.""" @@ -55,18 +71,6 @@ def inserted_report( metric_type="violations", metric_name=metric_name, metric_unit=metric_unit, **kwargs ) - def test_no_reports(self): - """Test that the migration succeeds without reports.""" - self.database.reports.find.return_value = [] - perform_migrations(self.database) - self.database.reports.replace_one.assert_not_called() - - def test_report_without_accessibility_metrics(self): - """Test that the migration succeeds with reports, but without accessibility metrics.""" - self.database.reports.find.return_value = [self.existing_report(metric_type="loc")] - perform_migrations(self.database) - self.database.reports.replace_one.assert_not_called() - def test_report_with_accessibility_metric(self): """Test that the migration succeeds with an accessibility metric.""" self.database.reports.find.return_value = [self.existing_report()] @@ -101,22 +105,9 @@ def existing_report( """Extend to add sources and an extra metric without sources.""" report = super().existing_report(metric_type=metric_type) report["subjects"][SUBJECT_ID]["metrics"][METRIC_ID2] = {"type": "issues"} - if sources: - report["subjects"][SUBJECT_ID]["metrics"][METRIC_ID]["sources"] = sources + report["subjects"][SUBJECT_ID]["metrics"][METRIC_ID]["sources"] = sources return report - def test_no_reports(self): - """Test that the migration succeeds without reports.""" - self.database.reports.find.return_value = [] - perform_migrations(self.database) - self.database.reports.replace_one.assert_not_called() - - def test_report_without_branch_parameter(self): - """Test that the migration succeeds with reports, but without metrics with a branch parameter.""" - self.database.reports.find.return_value = [self.existing_report()] - perform_migrations(self.database) - self.database.reports.replace_one.assert_not_called() - def test_report_with_non_empty_branch_parameter(self): """Test that the migration succeeds when the branch parameter is not empty.""" self.database.reports.find.return_value = [ @@ -140,3 +131,36 @@ def test_report_with_branch_parameter_without_value(self): } inserted_report = self.inserted_report(metric_type="source_up_to_dateness", sources=inserted_sources) self.database.reports.replace_one.assert_called_once_with({"_id": "id"}, inserted_report) + + +class SourceParameterHashMigrationTest(MigrationTestCase): + """Unit tests for the source parameter hash database migration.""" + + def existing_report(self, sources: dict[SourceId, dict[str, str | dict[str, str]]] | None = None): + """Extend to add sources and an extra metric without sources.""" + report = super().existing_report(metric_type="loc") + report["subjects"][SUBJECT_ID]["metrics"][METRIC_ID2] = {"type": "issues"} + if sources: + report["subjects"][SUBJECT_ID]["metrics"][METRIC_ID]["sources"] = sources + return report + + def test_report_with_sources_without_source_parameter_hash(self): + """Test a report with sources and measurements.""" + self.database.measurements.find_one.return_value = {"_id": "id", "metric_uuid": METRIC_ID} + self.database.reports.find.return_value = [self.existing_report(sources={SOURCE_ID: {"type": "cloc"}})] + perform_migrations(self.database) + inserted_measurement = {"metric_uuid": METRIC_ID, "source_parameter_hash": "8c3b464958e9ad0f20fb2e3b74c80519"} + self.database.measurements.replace_one.assert_called_once_with({"_id": "id"}, inserted_measurement) + + def test_report_without_sources(self): + """Test a report without sources.""" + self.database.reports.find.return_value = [self.existing_report()] + perform_migrations(self.database) + self.database.measurements.replace_one.assert_not_called() + + def test_metric_without_measurement(self): + """Test a metric without measurements.""" + self.database.measurements.find_one.return_value = None + self.database.reports.find.return_value = [self.existing_report(sources={SOURCE_ID: {"type": "cloc"}})] + perform_migrations(self.database) + self.database.measurements.replace_one.assert_not_called() diff --git a/docs/src/changelog.md b/docs/src/changelog.md index 8942523cdb..c3ec885d61 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -19,6 +19,7 @@ If your currently installed *Quality-time* version is v4.10.0 or older, please r - When creating an issue fails, show the reason in the toaster message instead of "undefined". Fixes [#8567](https://github.com/ICTU/quality-time/issues/8567). - Hiding metrics without issues would not hide metrics with deleted issues. Fixes [#8699](https://github.com/ICTU/quality-time/issues/8699). - The spinner indicating that the latest measurement of a metric is not up-to-date with the latest source configuration would not disappear if the measurement value made with the latest source configuration was equal to the measurement value made with the previous source configuration. Fixes [#8702](https://github.com/ICTU/quality-time/issues/8702). +- The spinner indicating that the latest measurement of a metric is not up-to-date with the latest source configuration would not appear on the first edit of a metric after upgrading to v5.12.0. Fixes [#8736](https://github.com/ICTU/quality-time/issues/8736). ### Added