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/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/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
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,
+]