diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 3a77ae95..383fc077 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -58,6 +58,35 @@ configuration keys control how these fields appear on the map: forms. Supported types: ``str``, ``list``, ``int``, ``float``, ``bool``, ``dict``. +``data_templates`` + Per-value defaults keyed by a field name and its possible values. When a + point has a matching field value, the corresponding template fields are + merged in. Point-level fields always win. + + This lets you define "presets" for recurring location types so shared + fields don't have to be repeated on every record. + + .. code-block:: json + + { + "data_templates": { + "type_of_place": { + "big bridge": { + "accessible_by": ["pedestrians", "cars"], + "material": "steel" + }, + "small bridge": { + "accessible_by": ["pedestrians"], + "material": "concrete" + } + } + } + } + + With this configuration a point that only stores ``{"type_of_place": "big bridge"}`` + will automatically receive ``accessible_by`` and ``material`` without those + fields being duplicated in every record. + Example configuration in your data source: .. code-block:: json @@ -67,7 +96,13 @@ Example configuration in your data source: "meta_data": ["uuid"], "location_obligatory_fields": [ ["test_category", "list[str]"] - ] + ], + "data_templates": { + "type_of_place": { + "big bridge": {"accessible_by": ["pedestrians", "cars"]}, + "small bridge": {"accessible_by": ["pedestrians"]} + } + } } .. _data-model-visible_data: diff --git a/examples/e2e_test_data.json b/examples/e2e_test_data.json index 79a91d92..5aef8225 100644 --- a/examples/e2e_test_data.json +++ b/examples/e2e_test_data.json @@ -1,18 +1,27 @@ { "map": { + "data_templates": { + "type_of_place": { + "big bridge": { + "accessible_by": ["pedestrians", "cars"], + "material": "steel", + "river": "Odra" + }, + "small bridge": { + "accessible_by": ["pedestrians"], + "material": "concrete", + "river": "Odra" + } + } + }, "data": [ { "name": "Grunwaldzki", - "position": [ - 51.1095, - 17.0525 - ], - "accessible_by": [ - "pedestrians", - "cars" - ], + "position": [51.1095, 17.0525], "type_of_place": "big bridge", - "uuid": "hidden", + "uuid": "grunwaldzki", + "year_built": 1910, + "length_m": 112, "CTA": { "type": "CTA", "value": "https://www.example.com", @@ -21,66 +30,98 @@ }, { "name": "Zwierzyniecka", - "position": [ - 51.10655, - 17.0555 - ], - "accessible_by": [ - "bikes", - "pedestrians" - ], + "position": [51.10655, 17.0555], "type_of_place": "small bridge", - "uuid": "dattarro", + "uuid": "zwierzyniecka", + "year_built": 1897, + "length_m": 35, "remark": "very old bridge" + }, + { + "name": "Mieszczański", + "position": [51.1150, 17.0390], + "type_of_place": "big bridge", + "uuid": "mieszczanski", + "year_built": 1949, + "length_m": 145 + }, + { + "name": "Szczytnicki", + "position": [51.1080, 17.0620], + "type_of_place": "small bridge", + "uuid": "szczytnicki", + "year_built": 1927, + "length_m": 28 + }, + { + "name": "Pokoju", + "position": [51.1200, 17.0310], + "type_of_place": "big bridge", + "uuid": "pokoju", + "year_built": 1947, + "length_m": 130 + }, + { + "name": "Osobowicki", + "position": [51.1350, 17.0280], + "type_of_place": "big bridge", + "uuid": "osobowicki", + "year_built": 1935, + "length_m": 160 + }, + { + "name": "Rędziński", + "position": [51.1420, 17.0180], + "type_of_place": "big bridge", + "uuid": "redzinski", + "year_built": 2011, + "length_m": 612, + "material": "cable-stayed", + "remark": "longest cable-stayed bridge in Poland" + }, + { + "name": "Trzebnicki", + "position": [51.1280, 17.0200], + "type_of_place": "small bridge", + "uuid": "trzebnicki", + "year_built": 1912, + "length_m": 42 + }, + { + "name": "Bolesława Chrobrego", + "position": [51.1070, 17.0470], + "type_of_place": "small bridge", + "uuid": "chrobrego", + "year_built": 1904, + "length_m": 31, + "accessible_by": ["pedestrians", "bikes"] + }, + { + "name": "Łużycki", + "position": [51.1030, 17.0590], + "type_of_place": "small bridge", + "uuid": "luzycki", + "year_built": 1966, + "length_m": 22 } ], "location_obligatory_fields": [ - [ - "name", - "str" - ], - [ - "accessible_by", - "list" - ], - [ - "type_of_place", - "str" - ] + ["name", "str"], + ["accessible_by", "list"], + ["type_of_place", "str"] ], "reported_issue_types": ["under construction", "has a hole"], "categories": { - "accessible_by": [ - "bikes", - "cars", - "pedestrians" - ], - "type_of_place": [ - "big bridge", - "small bridge" - ] + "accessible_by": ["bikes", "cars", "pedestrians"], + "type_of_place": ["big bridge", "small bridge"] }, - "categories_help": [ - "accessible_by" - ], + "categories_help": ["accessible_by"], "categories_options_help": { - "type_of_place": [ - "small bridge" - ], - "accessible_by": [ - "cars", - "pedestrians" - ] + "type_of_place": ["small bridge"], + "accessible_by": ["cars", "pedestrians"] }, - "visible_data": [ - "remark", - "accessible_by", - "type_of_place", - "CTA" - ], - "meta_data": [ - "uuid" - ] + "visible_data": ["remark", "accessible_by", "type_of_place", "material", "year_built", "length_m", "CTA"], + "meta_data": ["uuid", "river"] }, "site_content": { "pages": [ diff --git a/goodmap/db.py b/goodmap/db.py index 91aec3c5..bc0b107e 100644 --- a/goodmap/db.py +++ b/goodmap/db.py @@ -682,6 +682,32 @@ def get_category_data(db): return globals()[f"{db.module_name}_get_category_data"] +# ------------------------------------------------ +# data_templates helpers + + +def apply_data_defaults(map_data: dict, point: dict) -> dict: + """Merge data_templates defaults into a point. Point fields always win. + + data_templates structure: + "data_templates": { + "type_of_place": { + "big bridge": {"accessible_by": ["pedestrians", "cars"]}, + "small bridge": {"accessible_by": ["pedestrians"]} + } + } + """ + type_defaults: dict = {} + for field, variants in map_data.get("data_templates", {}).items(): + value = point.get(field) + if value is not None and value in variants: + type_defaults.update(variants[value]) + + if not type_defaults: + return point + return {**type_defaults, **point} + + # ------------------------------------------------ # get_location @@ -697,8 +723,10 @@ def get_location_from_raw_data(raw_data, uuid, location_model): Returns: Validated location model instance, or None if not found. """ - point = next((point for point in raw_data["data"] if point["uuid"] == uuid), None) - return location_model.model_validate(point) if point else None + point = next((p for p in raw_data["data"] if p["uuid"] == uuid), None) + if point is None: + return None + return location_model.model_validate(apply_data_defaults(raw_data, point)) def google_json_db_get_location(self, uuid, location_model): @@ -744,7 +772,8 @@ def get_locations_list_from_raw_data(map_data, query, location_model): Returns: List of validated location model instances. """ - filtered_locations = get_queried_data(map_data["data"], map_data["categories"], query) + merged_data = [apply_data_defaults(map_data, p) for p in map_data["data"]] + filtered_locations = get_queried_data(merged_data, map_data["categories"], query) return [location_model.model_validate(point) for point in filtered_locations] diff --git a/tests/unit_tests/test_db.py b/tests/unit_tests/test_db.py index 893e53d9..33036457 100644 --- a/tests/unit_tests/test_db.py +++ b/tests/unit_tests/test_db.py @@ -2410,3 +2410,147 @@ def custom_extract(query): items = [{"name": "test", "custom_field": "test_value"}] result = PaginationHelper.create_paginated_response(items, query, custom_extract) assert len(result["items"]) == 1 # Custom filter should pass the item + + +# ------------------------------------------------ +# apply_data_defaults + + +from goodmap.db import apply_data_defaults, get_locations_list_from_raw_data # noqa: E402 + + +class TestApplyDataDefaults: + def test_no_templates_returns_point_unchanged(self): + map_data = {"data": [], "categories": {}} + point = {"uuid": "1", "name": "A", "accessible_by": ["cars"]} + assert apply_data_defaults(map_data, point) == point + + def test_missing_data_templates_key_returns_point_unchanged(self): + map_data = {} + point = {"uuid": "1"} + assert apply_data_defaults(map_data, point) is point + + def test_original_point_dict_not_mutated(self): + map_data = { + "data_templates": { + "type_of_place": {"big bridge": {"accessible_by": ["pedestrians", "cars"]}} + } + } + point = {"uuid": "1", "name": "Bridge", "type_of_place": "big bridge"} + apply_data_defaults(map_data, point) + assert "accessible_by" not in point + + def test_data_templates_applied_by_field_value(self): + map_data = { + "data_templates": { + "type_of_place": { + "big bridge": {"accessible_by": ["pedestrians", "cars"], "material": "steel"}, + "small bridge": {"accessible_by": ["pedestrians"], "material": "concrete"}, + } + } + } + big = apply_data_defaults( + map_data, {"uuid": "1", "name": "Big", "type_of_place": "big bridge"} + ) + small = apply_data_defaults( + map_data, {"uuid": "2", "name": "Small", "type_of_place": "small bridge"} + ) + assert big["accessible_by"] == ["pedestrians", "cars"] + assert big["material"] == "steel" + assert small["accessible_by"] == ["pedestrians"] + assert small["material"] == "concrete" + + def test_point_field_overrides_template(self): + map_data = { + "data_templates": { + "type_of_place": { + "big bridge": {"accessible_by": ["pedestrians", "cars"], "material": "steel"}, + } + } + } + point = { + "uuid": "1", + "name": "Special", + "type_of_place": "big bridge", + "material": "cable-stayed", + } + result = apply_data_defaults(map_data, point) + assert result["material"] == "cable-stayed" + assert result["accessible_by"] == ["pedestrians", "cars"] + + def test_unmatched_template_key_ignored(self): + map_data = { + "data_templates": { + "type_of_place": { + "big bridge": {"accessible_by": ["pedestrians", "cars"]}, + } + } + } + point = {"uuid": "1", "name": "Small", "type_of_place": "small bridge"} + result = apply_data_defaults(map_data, point) + assert "accessible_by" not in result + + +class TestGetLocationsListFromRawDataWithDefaults: + def setup_method(self): + self.location_model = create_location_model( + [("name", "str"), ("accessible_by", "list")], {"accessible_by": ["pedestrians", "cars"]} + ) + + def test_templates_applied_before_filtering(self): + map_data = { + "data_templates": { + "type_of_place": {"small bridge": {"accessible_by": ["pedestrians"]}} + }, + "data": [ + {"uuid": "1", "name": "Small Bridge", "position": [51.1, 17.0], "type_of_place": "small bridge"}, + ], + "categories": {"accessible_by": ["pedestrians", "cars"]}, + } + query = {"accessible_by": ["pedestrians"]} + result = get_locations_list_from_raw_data(map_data, query, self.location_model) + assert len(result) == 1 + + def test_point_override_wins_in_filtering(self): + map_data = { + "data_templates": { + "type_of_place": {"small bridge": {"accessible_by": ["pedestrians"]}} + }, + "data": [ + {"uuid": "1", "name": "Small Bridge", "position": [51.1, 17.0], "type_of_place": "small bridge"}, + { + "uuid": "2", + "name": "Big Bridge", + "position": [51.2, 17.1], + "type_of_place": "small bridge", + "accessible_by": ["pedestrians", "cars"], + }, + ], + "categories": {"accessible_by": ["pedestrians", "cars"]}, + } + query = {"accessible_by": ["cars"]} + result = get_locations_list_from_raw_data(map_data, query, self.location_model) + assert len(result) == 1 + assert result[0].uuid == "2" + + def test_no_defaults_behavior_unchanged(self): + map_data = { + "data": [ + { + "uuid": "1", + "name": "Bridge A", + "position": [51.1, 17.0], + "accessible_by": ["pedestrians"], + }, + { + "uuid": "2", + "name": "Bridge B", + "position": [51.2, 17.1], + "accessible_by": ["cars"], + }, + ], + "categories": {"accessible_by": ["pedestrians", "cars"]}, + } + query = {} + result = get_locations_list_from_raw_data(map_data, query, self.location_model) + assert len(result) == 2