From 0623f9c852873f3da75369a9bee84b20196ef482 Mon Sep 17 00:00:00 2001 From: Ernesto Ruge Date: Sat, 22 Nov 2025 10:18:10 +0100 Subject: [PATCH 1/2] location test, OpenAPI --- requirements-dev.txt | 2 + tests/integration/conftest.py | 34 +++- tests/integration/helpers.py | 87 ++++++++ .../integration/model_generators/business.py | 38 ++++ .../integration/model_generators/connector.py | 56 ++++++ tests/integration/model_generators/evse.py | 107 ++++++++++ .../integration/model_generators/location.py | 102 ++++++++++ tests/integration/model_generators/source.py | 31 +++ tests/integration/public_api/conftest.py | 29 +++ .../public_api/location_api_responses.py | 189 ++++++++++++++++++ .../public_api/location_api_test.py | 77 +++++++ webapp/app.py | 4 +- webapp/models/location.py | 3 + .../location_api/location_rest_api.py | 70 +------ webapp/repositories/base_repository.py | 7 +- webapp/repositories/location_repository.py | 18 +- .../import_services/generic_import_runner.py | 3 + webapp/shared/ocpi_schema.py | 127 ++++++++++-- 18 files changed, 881 insertions(+), 103 deletions(-) create mode 100644 tests/integration/helpers.py create mode 100644 tests/integration/model_generators/business.py create mode 100644 tests/integration/model_generators/connector.py create mode 100644 tests/integration/model_generators/evse.py create mode 100644 tests/integration/model_generators/location.py create mode 100644 tests/integration/model_generators/source.py create mode 100644 tests/integration/public_api/conftest.py create mode 100644 tests/integration/public_api/location_api_responses.py create mode 100644 tests/integration/public_api/location_api_test.py diff --git a/requirements-dev.txt b/requirements-dev.txt index 024f858..07f573c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,3 +2,5 @@ pytest~=9.0.1 pytest-cov~=7.0.0 requests-mock~=1.12.1 ruff~=0.14.5 +# openapi-core 0.19.5 conflicts with werkzeug 3.1.3 +openapi-core~=0.19.4 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 759a478..15bbda5 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -17,26 +17,30 @@ """ import os -from typing import Generator +from typing import Callable, Generator import pytest +from tests.integration.helpers import OpenApiFlaskClient, TestApp, empty_all_tables from webapp import launch from webapp.common.flask_app import App from webapp.common.sqlalchemy import SQLAlchemy from webapp.extensions import db as flask_sqlalchemy -@pytest.fixture +@pytest.fixture(scope='session') def flask_app() -> Generator[App, None, None]: # Load default development config instead of config.yaml for testing to avoid issues with local setups os.environ['CONFIG_FILE'] = os.environ.get('TEST_CONFIG_FILE', 'config_dist_dev.yaml') app = launch( + app_class=TestApp, config_overrides={ 'TESTING': True, 'DEBUG': True, - } + 'DISABLE_EVENTS': True, + 'SERVER_NAME': 'http://localhost:5010', + }, ) with app.app_context(): @@ -46,10 +50,32 @@ def flask_app() -> Generator[App, None, None]: yield app # type: ignore -@pytest.fixture +@pytest.fixture(scope='function') def db(flask_app: App) -> Generator[SQLAlchemy, None, None]: """ Yields the database as a function-scoped fixture with freshly emptied tables. """ + empty_all_tables(db=flask_sqlalchemy) + yield flask_sqlalchemy + + +@pytest.fixture +def test_cli(flask_app: TestApp) -> Generator[Callable, None, None]: + def check_cli(fnc: Callable, *args, **kwargs) -> None: + runner = flask_app.test_cli_runner() + cli_result = runner.invoke(fnc, args=args, **kwargs) + + if cli_result.exception: + raise cli_result.exception + + assert cli_result.exit_code == 0 + + yield check_cli + + +@pytest.fixture +def test_client(flask_app: TestApp) -> Generator[OpenApiFlaskClient, None, None]: + with flask_app.test_client() as client: + yield client # type: ignore diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py new file mode 100644 index 0000000..eb77de3 --- /dev/null +++ b/tests/integration/helpers.py @@ -0,0 +1,87 @@ +""" +Open ChargePoint DataBase OCPDB +Copyright (C) 2025 binary butterfly GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +import os +from typing import Any + +from flask.testing import FlaskClient +from flask_openapi.generator import generate_openapi +from openapi_core import OpenAPI +from openapi_core.contrib.werkzeug import WerkzeugOpenAPIRequest, WerkzeugOpenAPIResponse +from sqlalchemy import text +from werkzeug.test import TestResponse + +from webapp.common.flask_app import App +from webapp.common.sqlalchemy import SQLAlchemy + +OPENAPI_BY_REALM = {} + + +class OpenApiFlaskClient(FlaskClient): + openapi_realm: str | None = None + + def __init__(self, *args: Any, openapi_realm: str | None = None, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.openapi_realm = openapi_realm + + def open(self, *args: Any, **kwargs: Any) -> TestResponse: + response = super().open(*args, **kwargs) + + if self.openapi_realm is None or os.environ.get('OPENAPI_SKIP_VALIDATION'): + return response + + if self.openapi_realm not in OPENAPI_BY_REALM: + openapi_dict = generate_openapi(self.openapi_realm) + openapi_dict = self.no_additional_properties(openapi_dict) + + OPENAPI_BY_REALM[self.openapi_realm] = OpenAPI.from_dict(openapi_dict) + + OPENAPI_BY_REALM[self.openapi_realm].validate_response( + WerkzeugOpenAPIRequest(response.request), + WerkzeugOpenAPIResponse(response), + ) + + return response + + def no_additional_properties(self, data: Any): + if isinstance(data, dict): + # Patch in additionalProperties in case of an object field if it's not already set + if data.get('type') == 'object' and 'additionalProperties' not in data: + data['additionalProperties'] = False + + return {key: self.no_additional_properties(value) for key, value in data.items()} + + if isinstance(data, list): + return [self.no_additional_properties(item) for item in data] + + return data + + +class TestApp(App): + test_client_class = OpenApiFlaskClient + + +def empty_all_tables(db: SQLAlchemy) -> None: + """ + empty all tables in the database + (this is much faster than completely deleting the database and creating a new one) + """ + db.session.close() + with db.engine.connect() as connection: + connection.execute(text(f'TRUNCATE {", ".join(db.metadata.tables.keys())} RESTART IDENTITY;')) + connection.commit() diff --git a/tests/integration/model_generators/business.py b/tests/integration/model_generators/business.py new file mode 100644 index 0000000..e307332 --- /dev/null +++ b/tests/integration/model_generators/business.py @@ -0,0 +1,38 @@ +""" +Open ChargePoint DataBase OCPDB +Copyright (C) 2025 binary butterfly GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +from webapp.models import Business + +BUSINESS_1_NAME = 'Electro Inc' +BUSINESS_2_NAME = 'Power Inc' + + +def get_business(**kwargs) -> Business: + default_data = { + 'name': BUSINESS_1_NAME, + } + data = {**default_data, **kwargs} + return Business(**data) + + +def get_business_1(**kwargs) -> Business: + return get_business(**kwargs) + + +def get_business_2(**kwargs) -> Business: + return get_business(name=BUSINESS_2_NAME, **kwargs) diff --git a/tests/integration/model_generators/connector.py b/tests/integration/model_generators/connector.py new file mode 100644 index 0000000..a7044f7 --- /dev/null +++ b/tests/integration/model_generators/connector.py @@ -0,0 +1,56 @@ +""" +Open ChargePoint DataBase OCPDB +Copyright (C) 2025 binary butterfly GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +from datetime import datetime, timezone + +from webapp.models import Connector +from webapp.models.connector import ConnectorFormat, ConnectorType, PowerType + +CONNECTOR_1_UID = 'CONNECTOR-1' +CONNECTOR_2_UID = 'CONNECTOR-2' + + +def get_connector(**kwargs) -> Connector: + default_data = { + 'uid': CONNECTOR_1_UID, + 'standard': ConnectorType.IEC_62196_T2, + 'format': ConnectorFormat.SOCKET, + 'power_type': PowerType.AC_3_PHASE, + 'max_voltage': 400, + 'max_amperage': 32, + 'max_electric_power': 22000, + 'last_updated': datetime.now(tz=timezone.utc), + } + data = {**default_data, **kwargs} + return Connector(**data) + + +def get_ac_connector(**kwargs) -> Connector: + return get_connector(**kwargs) + + +def get_dc_connector(**kwargs) -> Connector: + return get_connector( + power_type=PowerType.DC, + standard=ConnectorType.IEC_62196_T2_COMBO, + format=ConnectorFormat.CABLE, + max_voltage=400, + max_amperage=350, + max_electric_power=150000, + **kwargs, + ) diff --git a/tests/integration/model_generators/evse.py b/tests/integration/model_generators/evse.py new file mode 100644 index 0000000..1ef29f5 --- /dev/null +++ b/tests/integration/model_generators/evse.py @@ -0,0 +1,107 @@ +""" +Open ChargePoint DataBase OCPDB +Copyright (C) 2025 binary butterfly GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +from datetime import datetime, timezone + +from tests.integration.model_generators.connector import get_ac_connector, get_dc_connector +from webapp.models import Evse +from webapp.models.evse import EvseStatus + +EVSE_UID_1 = 'EVSE-1' +EVSE_UID_2 = 'EVSE-2' +EVSE_UID_3 = 'EVSE-3' +EVSE_UID_4 = 'EVSE-4' +EVSE_UID_5 = 'EVSE-5' +EVSE_UID_6 = 'EVSE-6' + + +def get_evse(**kwargs) -> Evse: + default_data = { + 'uid': EVSE_UID_1, + 'evse_id': EVSE_UID_1, + 'status': EvseStatus.AVAILABLE, + 'last_updated': datetime.now(timezone.utc), + } + data = {**default_data, **kwargs} + return Evse(**data) + + +def get_evse_1(**kwargs) -> Evse: + return get_evse(**kwargs) + + +def get_evse_2(**kwargs) -> Evse: + return get_evse(uid=EVSE_UID_2, **kwargs) + + +def get_evse_3(**kwargs) -> Evse: + return get_evse(uid=EVSE_UID_3, **kwargs) + + +def get_evse_4(**kwargs) -> Evse: + return get_evse(uid=EVSE_UID_4, **kwargs) + + +def get_evse_5(**kwargs) -> Evse: + return get_evse(uid=EVSE_UID_5, **kwargs) + + +def get_evse_6(**kwargs) -> Evse: + return get_evse(uid=EVSE_UID_6, **kwargs) + + +def get_full_evse_1(**kwargs) -> Evse: + return get_evse_1( + connectors=[get_ac_connector()], + **kwargs, + ) + + +def get_full_evse_2(**kwargs) -> Evse: + return get_evse_2( + connectors=[get_ac_connector()], + **kwargs, + ) + + +def get_full_evse_3(**kwargs) -> Evse: + return get_evse_3( + connectors=[get_dc_connector()], + **kwargs, + ) + + +def get_full_evse_4(**kwargs) -> Evse: + return get_evse_4( + connectors=[get_dc_connector()], + **kwargs, + ) + + +def get_full_evse_5(**kwargs) -> Evse: + return get_evse_5( + connectors=[get_ac_connector()], + **kwargs, + ) + + +def get_full_evse_6(**kwargs) -> Evse: + return get_evse_6( + connectors=[get_ac_connector()], + **kwargs, + ) diff --git a/tests/integration/model_generators/location.py b/tests/integration/model_generators/location.py new file mode 100644 index 0000000..4cc3573 --- /dev/null +++ b/tests/integration/model_generators/location.py @@ -0,0 +1,102 @@ +""" +Open ChargePoint DataBase OCPDB +Copyright (C) 2025 binary butterfly GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +from datetime import datetime, timezone +from decimal import Decimal + +from tests.integration.model_generators.business import get_business_1, get_business_2 +from tests.integration.model_generators.evse import ( + get_full_evse_1, + get_full_evse_2, + get_full_evse_3, + get_full_evse_4, + get_full_evse_5, + get_full_evse_6, +) +from tests.integration.model_generators.source import SOURCE_UID_1, SOURCE_UID_2 +from webapp.models import Location + +LOCATION_UID_1 = 'LOCATION-1' +LOCATION_UID_2 = 'LOCATION-2' +LOCATION_UID_3 = 'LOCATION-3' + + +def get_location(**kwargs) -> Location: + default_data = { + 'uid': LOCATION_UID_1, + 'source': SOURCE_UID_1, + 'name': 'Test Location', + 'address': 'Test Street 123', + 'postal_code': '12345', + 'city': 'Test City', + 'country': 'DEU', + 'lat': Decimal('52.52003'), + 'lon': Decimal('13.40489'), + 'time_zone': 'Europe/Berlin', + 'last_updated': datetime.now(tz=timezone.utc), + 'twentyfourseven': True, + } + + data = {**default_data, **kwargs} + return Location(**data) + + +def get_location_1(**kwargs) -> Location: + return get_location(**kwargs) + + +def get_location_2(**kwargs) -> Location: + return get_location(uid=LOCATION_UID_2, **kwargs) + + +def get_location_3(**kwargs) -> Location: + return get_location(uid=LOCATION_UID_3, **kwargs) + + +def get_full_location_1(**kwargs) -> Location: + return get_location_1( + evses=[ + get_full_evse_1(), + get_full_evse_2(), + ], + operator=get_business_1(), + **kwargs, + ) + + +def get_full_location_2(**kwargs) -> Location: + return get_location_2( + evses=[ + get_full_evse_3(), + get_full_evse_4(), + ], + operator=get_business_1(), + **kwargs, + ) + + +def get_full_location_3(**kwargs) -> Location: + return get_location_1( + evses=[ + get_full_evse_5(), + get_full_evse_6(), + ], + operator=get_business_2(), + source=SOURCE_UID_2, + **kwargs, + ) diff --git a/tests/integration/model_generators/source.py b/tests/integration/model_generators/source.py new file mode 100644 index 0000000..3bcc89a --- /dev/null +++ b/tests/integration/model_generators/source.py @@ -0,0 +1,31 @@ +""" +Open ChargePoint DataBase OCPDB +Copyright (C) 2025 binary butterfly GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +from webapp.models import Source + +SOURCE_UID_1 = 'SOURCE-1' +SOURCE_UID_2 = 'SOURCE-2' + + +def get_source(**kwargs) -> Source: + default_data = { + 'uid': SOURCE_UID_1, + 'name': 'Test Source', + } + data = {**default_data, **kwargs} + return Source(**data) diff --git a/tests/integration/public_api/conftest.py b/tests/integration/public_api/conftest.py new file mode 100644 index 0000000..cd636af --- /dev/null +++ b/tests/integration/public_api/conftest.py @@ -0,0 +1,29 @@ +""" +Open ChargePoint DataBase OCPDB +Copyright (C) 2025 binary butterfly GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +from typing import Generator + +import pytest + +from tests.integration.helpers import OpenApiFlaskClient, TestApp + + +@pytest.fixture +def admin_test_client(flask_app: TestApp) -> Generator[OpenApiFlaskClient, None, None]: + with flask_app.test_client(openapi_realm='public') as client: + yield client # type: ignore diff --git a/tests/integration/public_api/location_api_responses.py b/tests/integration/public_api/location_api_responses.py new file mode 100644 index 0000000..6569ea8 --- /dev/null +++ b/tests/integration/public_api/location_api_responses.py @@ -0,0 +1,189 @@ +""" +Open ChargePoint DataBase OCPDB +Copyright (C) 2025 binary butterfly GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +from unittest.mock import ANY + +LOCATIONS_1_RESPONSE = { + 'name': 'Test Location', + 'address': 'Test Street 123', + 'postal_code': '12345', + 'city': 'Test City', + 'country': 'DEU', + 'time_zone': 'Europe/Berlin', + 'last_updated': ANY, + 'id': '1', + 'publish': True, + 'opening_times': {'twentyfourseven': True}, + 'coordinates': {'latitude': '52.5200300', 'longitude': '13.4048900'}, + 'operator': {'name': 'Electro Inc'}, + 'evses': [ + { + 'uid': '1', + 'evse_id': 'EVSE-1', + 'status': 'AVAILABLE', + 'last_updated': ANY, + 'connectors': [ + { + 'standard': 'IEC_62196_T2', + 'format': 'SOCKET', + 'power_type': 'AC_3_PHASE', + 'max_voltage': 400, + 'max_amperage': 32, + 'max_electric_power': 22000, + 'last_updated': ANY, + 'id': '1', + }, + ], + }, + { + 'uid': '2', + 'evse_id': 'EVSE-1', + 'status': 'AVAILABLE', + 'last_updated': ANY, + 'connectors': [ + { + 'standard': 'IEC_62196_T2', + 'format': 'SOCKET', + 'power_type': 'AC_3_PHASE', + 'max_voltage': 400, + 'max_amperage': 32, + 'max_electric_power': 22000, + 'last_updated': ANY, + 'id': '2', + }, + ], + }, + ], +} + +LOCATIONS_2_RESPONSE = { + 'name': 'Test Location', + 'address': 'Test Street 123', + 'postal_code': '12345', + 'city': 'Test City', + 'country': 'DEU', + 'time_zone': 'Europe/Berlin', + 'last_updated': ANY, + 'id': '2', + 'publish': True, + 'opening_times': {'twentyfourseven': True}, + 'coordinates': {'latitude': '52.5200300', 'longitude': '13.4048900'}, + 'operator': {'name': 'Electro Inc'}, + 'evses': [ + { + 'uid': '3', + 'evse_id': 'EVSE-1', + 'status': 'AVAILABLE', + 'last_updated': ANY, + 'connectors': [ + { + 'standard': 'IEC_62196_T2_COMBO', + 'format': 'CABLE', + 'power_type': 'DC', + 'max_voltage': 400, + 'max_amperage': 350, + 'max_electric_power': 150000, + 'last_updated': ANY, + 'id': '3', + }, + ], + }, + { + 'uid': '4', + 'evse_id': 'EVSE-1', + 'status': 'AVAILABLE', + 'last_updated': ANY, + 'connectors': [ + { + 'standard': 'IEC_62196_T2_COMBO', + 'format': 'CABLE', + 'power_type': 'DC', + 'max_voltage': 400, + 'max_amperage': 350, + 'max_electric_power': 150000, + 'last_updated': ANY, + 'id': '4', + }, + ], + }, + ], +} + + +LOCATIONS_3_RESPONSE = { + 'name': 'Test Location', + 'address': 'Test Street 123', + 'postal_code': '12345', + 'city': 'Test City', + 'country': 'DEU', + 'time_zone': 'Europe/Berlin', + 'last_updated': ANY, + 'id': '3', + 'publish': True, + 'opening_times': {'twentyfourseven': True}, + 'coordinates': {'latitude': '52.5200300', 'longitude': '13.4048900'}, + 'operator': {'name': 'Power Inc'}, + 'evses': [ + { + 'uid': '5', + 'evse_id': 'EVSE-1', + 'status': 'AVAILABLE', + 'last_updated': ANY, + 'connectors': [ + { + 'standard': 'IEC_62196_T2', + 'format': 'SOCKET', + 'power_type': 'AC_3_PHASE', + 'max_voltage': 400, + 'max_amperage': 32, + 'max_electric_power': 22000, + 'last_updated': ANY, + 'id': '5', + }, + ], + }, + { + 'uid': '6', + 'evse_id': 'EVSE-1', + 'status': 'AVAILABLE', + 'last_updated': ANY, + 'connectors': [ + { + 'standard': 'IEC_62196_T2', + 'format': 'SOCKET', + 'power_type': 'AC_3_PHASE', + 'max_voltage': 400, + 'max_amperage': 32, + 'max_electric_power': 22000, + 'last_updated': ANY, + 'id': '6', + }, + ], + }, + ], +} + + +LOCATIONS_RESPONSE = { + 'items': [ + LOCATIONS_1_RESPONSE, + LOCATIONS_2_RESPONSE, + LOCATIONS_3_RESPONSE, + ], + 'total_count': 3, +} diff --git a/tests/integration/public_api/location_api_test.py b/tests/integration/public_api/location_api_test.py new file mode 100644 index 0000000..361d514 --- /dev/null +++ b/tests/integration/public_api/location_api_test.py @@ -0,0 +1,77 @@ +""" +Open ChargePoint DataBase OCPDB +Copyright (C) 2025 binary butterfly GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +from http import HTTPStatus + +from tests.integration.helpers import OpenApiFlaskClient +from tests.integration.model_generators.location import get_full_location_1, get_full_location_2, get_full_location_3 +from tests.integration.model_generators.source import SOURCE_UID_1 +from tests.integration.public_api.location_api_responses import ( + LOCATIONS_1_RESPONSE, + LOCATIONS_2_RESPONSE, + LOCATIONS_RESPONSE, +) +from webapp.common.sqlalchemy import SQLAlchemy + + +def test_get_locations_strict( + db: SQLAlchemy, + admin_test_client: OpenApiFlaskClient, +) -> None: + db.session.add_all([get_full_location_1(), get_full_location_2(), get_full_location_3()]) + db.session.commit() + + response = admin_test_client.get( + path='/api/public/v1/locations?strict=true', + ) + assert response.status_code == HTTPStatus.OK + assert response.json == LOCATIONS_RESPONSE + + +def test_get_locations_by_source_strict( + db: SQLAlchemy, + admin_test_client: OpenApiFlaskClient, +) -> None: + db.session.add_all([get_full_location_1(), get_full_location_2(), get_full_location_3()]) + db.session.commit() + + response = admin_test_client.get( + path=f'/api/public/v1/locations?strict=true&source_uid={SOURCE_UID_1}', + ) + assert response.status_code == HTTPStatus.OK + assert response.json == { + 'items': [ + LOCATIONS_1_RESPONSE, + LOCATIONS_2_RESPONSE, + ], + 'total_count': 2, + } + + +def test_get_location_strict( + db: SQLAlchemy, + admin_test_client: OpenApiFlaskClient, +) -> None: + db.session.add(get_full_location_1()) + db.session.commit() + + response = admin_test_client.get( + path='/api/public/v1/locations/1?strict=true', + ) + assert response.status_code == HTTPStatus.OK + assert response.json == LOCATIONS_1_RESPONSE diff --git a/webapp/app.py b/webapp/app.py index 6e48808..8bb9b0d 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -39,8 +39,8 @@ __all__ = ['launch'] -def launch(config_overrides: dict | None = None) -> App: - app = App(BaseConfig.PROJECT_NAME) +def launch(app_class: type[App] = App, config_overrides: dict | None = None) -> App: + app = app_class(BaseConfig.PROJECT_NAME) configure_app(app, config_overrides) configure_extensions(app) configure_blueprints(app) diff --git a/webapp/models/location.py b/webapp/models/location.py index 0769217..dcf457d 100644 --- a/webapp/models/location.py +++ b/webapp/models/location.py @@ -242,6 +242,9 @@ def to_dict(self, *args, strict: bool = False, ignore: list[str] | None = None, # OCPI id has to be a string result['id'] = str(self.id) + # We just handle public locations + result['publish'] = True + if not strict: result['original_id'] = self.uid result['source'] = self.source diff --git a/webapp/public_api/location_api/location_rest_api.py b/webapp/public_api/location_api/location_rest_api.py index 21f8f28..77a46d9 100644 --- a/webapp/public_api/location_api/location_rest_api.py +++ b/webapp/public_api/location_api/location_rest_api.py @@ -24,7 +24,6 @@ Parameter, Response, ResponseData, - Schema, SchemaListReference, SchemaReference, document, @@ -35,38 +34,7 @@ from webapp.common.rest import BaseMethodView from webapp.dependencies import dependencies from webapp.public_api.base_blueprint import BaseBlueprint -from webapp.shared.ocpi_schema import ( - additional_geo_location_example, - additional_geo_location_schema, - business_details_example, - business_details_schema, - connector_example, - connector_schema, - display_text_example, - display_text_schema, - energy_mix_example, - energy_mix_schema, - energy_source_example, - energy_source_schema, - environmental_impact_example, - environmental_impact_schema, - evse_example, - evse_schema, - exceptional_period_example, - exceptional_period_schema, - geo_location_example, - geo_location_schema, - hours_example, - hours_schema, - image_example, - image_schema, - location_example, - location_schema, - publish_token_type_example, - publish_token_type_schema, - regular_hours_example, - regular_hours_schema, -) +from webapp.shared.ocpi_schema import all_location_components from .location_handler import LocationHandler from .location_search_queries import LocationSearchQuery @@ -171,23 +139,7 @@ class LocationListMethodView(LocationBaseMethodView): ), ], response=[Response(ResponseData(SchemaListReference('Location'), ExampleListReference('Location')))], - components=[ - Schema('AdditionalGeoLocation', additional_geo_location_schema, additional_geo_location_example), - Schema('BusinessDetails', business_details_schema, business_details_example), - Schema('Connector', connector_schema, connector_example), - Schema('DisplayText', display_text_schema, display_text_example), - Schema('EnergyMix', energy_mix_schema, energy_mix_example), - Schema('EnergySource', energy_source_schema, energy_source_example), - Schema('EnvironmentalImpact', environmental_impact_schema, environmental_impact_example), - Schema('EVSE', evse_schema, evse_example), - Schema('ExceptionalPeriod', exceptional_period_schema, exceptional_period_example), - Schema('GeoLocation', geo_location_schema, geo_location_example), - Schema('Hours', hours_schema, hours_example), - Schema('Image', image_schema, image_example), - Schema('Location', location_schema, location_example), - Schema('PublishTokenType', publish_token_type_schema, publish_token_type_example), - Schema('RegularHours', regular_hours_schema, regular_hours_example), - ], + components=all_location_components, ) @cross_origin() def get(self): @@ -211,23 +163,7 @@ class LocationItemMethodView(LocationBaseMethodView): description='If set to true, all additional fields will be omitted for full OCPI compatibility.', ), ], - components=[ - Schema('AdditionalGeoLocation', additional_geo_location_schema, additional_geo_location_example), - Schema('BusinessDetails', business_details_schema, business_details_example), - Schema('Connector', connector_schema, connector_example), - Schema('DisplayText', display_text_schema, display_text_example), - Schema('EnergyMix', energy_mix_schema, energy_mix_example), - Schema('EnergySource', energy_source_schema, energy_source_example), - Schema('EnvironmentalImpact', environmental_impact_schema, environmental_impact_example), - Schema('EVSE', evse_schema, evse_example), - Schema('ExceptionalPeriod', exceptional_period_schema, exceptional_period_example), - Schema('GeoLocation', geo_location_schema, geo_location_example), - Schema('Hours', hours_schema, hours_example), - Schema('Image', image_schema, image_example), - Schema('Location', location_schema, location_example), - Schema('PublishTokenType', publish_token_type_schema, publish_token_type_example), - Schema('RegularHours', regular_hours_schema, regular_hours_example), - ], + components=all_location_components, ) @cross_origin() def get(self, location_id: int): diff --git a/webapp/repositories/base_repository.py b/webapp/repositories/base_repository.py index 3e1db89..9fcdafe 100644 --- a/webapp/repositories/base_repository.py +++ b/webapp/repositories/base_repository.py @@ -55,12 +55,7 @@ def fetch_resource_by_id( """ load_options = load_options or [] - resource = ( - self.session.query(self.model_cls) - .options(*load_options) - .filter(self.model_cls.id == resource_id) - .one_or_none() - ) + resource = self.session.get(self.model_cls, resource_id, options=load_options) return self._or_raise( resource, diff --git a/webapp/repositories/location_repository.py b/webapp/repositories/location_repository.py index c407c75..cd9b62f 100644 --- a/webapp/repositories/location_repository.py +++ b/webapp/repositories/location_repository.py @@ -19,6 +19,7 @@ from mercantile import LngLatBbox from sqlalchemy import func, text from sqlalchemy.orm import joinedload, selectinload +from sqlalchemy.orm.interfaces import LoaderOption from validataclass_search_queries.filters import BoundSearchFilter from validataclass_search_queries.pagination import PaginatedResult from validataclass_search_queries.search_queries import BaseSearchQuery @@ -27,7 +28,6 @@ from webapp.models import Business, Evse, Location from .base_repository import BaseRepository -from .exceptions import ObjectNotFoundException class LocationRepository(BaseRepository[Location]): @@ -50,20 +50,14 @@ def fetch_location_ids_by_source(self, source: str) -> list[int]: return [item.id for item in items] def fetch_location_by_id(self, location_id: int, *, include_children: bool = False) -> Location: - location = self.session.query(Location) - + load_options: list[LoaderOption] = [] if include_children: - location = location.options( + load_options += [ + joinedload(Location.operator), selectinload(Location.evses).selectinload(Evse.connectors), - selectinload(Location.operator), - ) - - location = location.get(location_id) - - if location is None: - raise ObjectNotFoundException(message=f'location with id {location_id} not found') + ] - return location + return self.fetch_resource_by_id(location_id, load_options=load_options) def fetch_location_by_uid(self, source: str, location_uid: str, *, include_children: bool = False) -> Location: query = self.session.query(Location) diff --git a/webapp/services/import_services/generic_import_runner.py b/webapp/services/import_services/generic_import_runner.py index 52b9167..2593044 100644 --- a/webapp/services/import_services/generic_import_runner.py +++ b/webapp/services/import_services/generic_import_runner.py @@ -33,6 +33,9 @@ def __init__(self, *, import_services: ImportServices, **kwargs): self.import_services = import_services def start(self): + if self.config_helper.get('DISABLE_AUTOFETCH'): + return + celery.add_periodic_task( crontab( minute=str(self.config_helper.get('IMAGE_PULL_MINUTE', 0)), diff --git a/webapp/shared/ocpi_schema.py b/webapp/shared/ocpi_schema.py index 1f98b19..39170a7 100644 --- a/webapp/shared/ocpi_schema.py +++ b/webapp/shared/ocpi_schema.py @@ -16,6 +16,7 @@ along with this program. If not, see . """ +from flask_openapi.decorator import Schema as Component from flask_openapi.schema import ( ArrayField, BooleanField, @@ -66,6 +67,11 @@ additional_geo_location_example = {} +additional_geo_location_component = Component( + 'AdditionalGeoLocation', additional_geo_location_schema, additional_geo_location_example +) + + business_details_schema = JsonSchema( title='BusinessDetails', properties={ @@ -79,6 +85,9 @@ business_details_example = {} +business_details_component = Component('BusinessDetails', business_details_schema, business_details_example) + + connector_schema = JsonSchema( title='Connector', description='A Connector is the socket or cable and plug available for the EV to use. A single EVSE may provide multiple Connectors ' @@ -126,6 +135,9 @@ connector_example = {} +connector_component = Component('Connector', connector_schema, connector_example) + + display_text_schema = JsonSchema( title='DisplayText', properties={ @@ -141,6 +153,9 @@ display_text_example = {} +display_text_component = Component('DisplayText', display_text_schema, display_text_example) + + energy_mix_schema = JsonSchema( title='EnergyMix', description='This type is used to specify the energy mix and environmental impact of the supplied energy at a location or in a tariff.', @@ -175,6 +190,9 @@ energy_mix_example = {} +energy_mix_component = Component('EnergyMix', energy_mix_schema, energy_mix_example) + + energy_source_schema = JsonSchema( title='EnergySource', description='Key-value pairs (enum + percentage) of energy sources. All given values of all categories should add up to 100 percent.', @@ -188,6 +206,9 @@ energy_source_example = {} +energy_source_component = Component('EnergySource', energy_source_schema, energy_source_example) + + environmental_impact_schema = JsonSchema( title='EnvironmentalImpact', description='Amount of waste produced/emitted per kWh.', @@ -204,6 +225,11 @@ environmental_impact_example = {} +environmental_impact_component = Component( + 'EnvironmentalImpact', environmental_impact_schema, environmental_impact_example +) + + evse_schema = JsonSchema( title='EVSE', description='The EVSE object describes the part that controls the power supply to a single EV in a single session. It always belongs ' @@ -250,6 +276,7 @@ 'floor_level': StringField( maxLength=4, description='Level on which the Charge Point is located (in garage buildings) in the locally displayed numbering scheme.', + required=False, ), 'coordinates': Reference(obj='GeoLocation', required=False, description='Coordinates of the EVSE.'), 'physical_reference': StringField( @@ -283,6 +310,9 @@ evse_example = {} +evse_component = Component('EVSE', evse_schema, evse_example) + + exceptional_period_schema = JsonSchema( title='ExceptionalPeriod', description='Specifies one exceptional period for opening or access hours.', @@ -300,6 +330,9 @@ exceptional_period_example = {} +exceptional_period_component = Component('ExceptionalPeriod', exceptional_period_schema, exceptional_period_example) + + geo_location_schema = JsonSchema( title='GeoLocation', description='This class defines the geo location of the Charge Point. The geodetic system to be used is WGS 84.', @@ -323,6 +356,9 @@ geo_location_example = {} +geo_location_component = Component('GeoLocation', geo_location_schema, geo_location_example) + + hours_schema = JsonSchema( title='Hours', description='Opening and access hours of the location.', @@ -355,6 +391,9 @@ hours_example = {} +hours_component = Component('Hours', hours_schema, hours_example) + + image_schema = JsonSchema( title='Image', description='This class references an image related to an EVSE in terms of a file name or url. According to the roaming connection ' @@ -385,6 +424,9 @@ image_example = {} +image_component = Component('Image', image_schema, image_example) + + location_schema = JsonSchema( title='Location', description='The Location object describes the location and its properties where a group of EVSEs that belong together are installed. ' @@ -394,23 +436,19 @@ 'country_code': StringField( minLength=3, maxLength=3, - description="ISO-3166 alpha-3 country code of the CPO that 'owns' this Location.", + description="ISO-3166 alpha-3 country code of the CPO that 'owns' this Location.\n*In OCPI, this field is required. Most sources don't have it, though, so we cannot output it in OCPDB.*", + required=False, ), 'party_id': StringField( minLength=3, maxLength=3, - description="ID of the CPO that 'owns' this Location (following the ISO-15118 standard).", + description="ID of the CPO that 'owns' this Location (following the ISO-15118 standard).\n*In OCPI, this field is required. Most sources don't have it, though, so we cannot output it in OCPDB.*", + required=False, ), 'id': IntegerField( minimum=1, description='Unique internal ID which identifies the location', ), - 'uid': StringField( - minLength=1, - maxLength=36, - description='Uniquely identifies the location within the CPOs platform (and suboperator platforms). This field can never be ' - 'changed, modified or renamed. In original OCPI, this field is called id.', - ), 'publish': BooleanField( description='Defines if a Location may be published on an website or app etc. When this is set to false, only tokens ' 'identified in the field: publish_allowed_to are allowed to be shown this Location. When the same location has ' @@ -508,17 +546,31 @@ 'last_updated': DateTimeField( description='Timestamp when this Location or one of its EVSEs or Connectors were last updated (or created).', ), - 'source': StringField( - maxLength=64, - description='Source UID to identify the data source the OCPDB got this dataset from.', - ), }, ) +extended_location_schema = JsonSchema( + title='ExtendedLocation', + description=f'{location_schema.description}
*Extended with non-standard fields.*', + properties={ + **location_schema.properties, + 'source': StringField(maxLength=255, required=False, description='Source of the location data.'), + 'original_id': StringField( + minLength=1, + maxLength=36, + description='Uniquely identifies the location within the CPOs platform (and suboperator platforms). This field can never be ' + 'changed, modified or renamed. In original OCPI, this field is called id.', + ), + }, +) + location_example = {} +location_component = Component('Location', location_schema, location_example) + + publish_token_type_schema = JsonSchema( title='PublishTokenType', description='Defines the set of values that identify a token to which a Location might be published. At least one of the following ' @@ -554,6 +606,9 @@ publish_token_type_example = {} +publish_token_type_component = Component('PublishTokenType', publish_token_type_schema, publish_token_type_example) + + regular_hours_schema = JsonSchema( title='RegularHours', description='Regular recurring operation or access hours.', @@ -577,3 +632,51 @@ regular_hours_example = {} + + +regular_hours_component = Component('RegularHours', regular_hours_schema, regular_hours_example) + + +status_schedule_schema = JsonSchema( + title='StatusSchedule', + description='This type is used to schedule status periods in the future. The eMSP can provide this information to ' + 'the EV user for trip planning purposes. A period MAY have no end. Example: "This station will be ' + 'running as of tomorrow. Today it is still planned and under construction."', + properties={ + 'period_begin': DateTimeField( + description='Begin of the scheduled period.', + ), + 'period_end': DateTimeField( + description='Status value during the scheduled period.', + ), + 'status': EnumField( + enum=EvseStatus, + description='Status value during the scheduled period.', + ), + }, +) + + +status_schedule_example = {} + +status_schedule_component = Component('StatusSchedule', status_schedule_schema, status_schedule_example) + + +all_location_components = [ + additional_geo_location_component, + business_details_component, + connector_component, + display_text_component, + energy_mix_component, + energy_source_component, + environmental_impact_component, + evse_component, + exceptional_period_component, + geo_location_component, + hours_component, + image_component, + location_component, + publish_token_type_component, + regular_hours_component, + status_schedule_component, +] From d98f714d23267182d950d40ce527a6dd28dc8b37 Mon Sep 17 00:00:00 2001 From: Ernesto Ruge Date: Sat, 22 Nov 2025 10:36:48 +0100 Subject: [PATCH 2/2] fix tests --- .github/workflows/build-publish.yml | 5 +++-- .../import_services/bnetza/bnetza_excel_service_test.py | 1 + .../ocpi/sw_stuttgart/sw_stuttgart_service_test.py | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-publish.yml b/.github/workflows/build-publish.yml index de43c14..f1ea9e2 100644 --- a/.github/workflows/build-publish.yml +++ b/.github/workflows/build-publish.yml @@ -4,8 +4,9 @@ on: release: types: - published - pull_request: - branches: [ "main" ] + push: + tags: + - 'preview-**' jobs: build-publish: diff --git a/tests/integration/services/import_services/bnetza/bnetza_excel_service_test.py b/tests/integration/services/import_services/bnetza/bnetza_excel_service_test.py index b9ce6e4..eb48974 100644 --- a/tests/integration/services/import_services/bnetza/bnetza_excel_service_test.py +++ b/tests/integration/services/import_services/bnetza/bnetza_excel_service_test.py @@ -78,6 +78,7 @@ def test_bnetza_excel_import(db: SQLAlchemy, requests_mock: Mocker) -> None: }, 'directions': None, 'parking_type': None, + 'publish': True, 'time_zone': None, 'last_updated': ANY, 'terms_and_conditions': None, diff --git a/tests/integration/services/import_services/ocpi/sw_stuttgart/sw_stuttgart_service_test.py b/tests/integration/services/import_services/ocpi/sw_stuttgart/sw_stuttgart_service_test.py index 8289d56..664530b 100644 --- a/tests/integration/services/import_services/ocpi/sw_stuttgart/sw_stuttgart_service_test.py +++ b/tests/integration/services/import_services/ocpi/sw_stuttgart/sw_stuttgart_service_test.py @@ -92,6 +92,7 @@ def test_sw_stuttgart_import(db: SQLAlchemy, requests_mock: Mocker) -> None: 'parking_type': None, 'time_zone': 'Europe/Berlin', 'last_updated': None, + 'publish': True, 'terms_and_conditions': None, } assert len(sample_location.evses) == 10